Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,4 @@ next-env.d.ts
temp/

interviewresources.app-audit
.env*
138 changes: 138 additions & 0 deletions app/(company)/careers/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import Link from "next/link";
import { notFound } from "next/navigation";

import { siteConfig } from "@/site";
import { ArrowLeft, Globe, MapPin } from "lucide-react";

import { JsonLd } from "@/components/json-ld";

import { type Role, getRole, roles } from "@/lib/careers/roles";
import { buildMetadata } from "@/lib/seo/metadata";

import { ApplicationForm } from "@/features/careers/application-form";

export function generateStaticParams() {
return roles.map((role) => ({ slug: role.slug }));
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const role = getRole(slug);
if (!role) return {};

return buildMetadata({
path: `/careers/${slug}`,
title: role.title,
description: role.tagline,
});
}

/** schema.org/JobPosting, so the role can surface in Google's job results. */
function jobPostingJsonLd(role: Role) {
return {
"@context": "https://schema.org",
"@type": "JobPosting",
title: role.title,
description: [...role.about, ...role.responsibilities].join(" "),
employmentType: "FULL_TIME",
hiringOrganization: {
"@type": "Organization",
name: siteConfig.name,
sameAs: siteConfig.url,
},
jobLocationType: "TELECOMMUTE",
applicantLocationRequirements: { "@type": "Country", name: "Worldwide" },
directApply: true,
url: `${siteConfig.url}/careers/${role.slug}`,
};
}

/** A titled list block. Every JD section on the page is one of these. */
function ListSection({ title, items }: { title: string; items: string[] }) {
return (
<section className="mt-10">
<h2 className="font-display text-lg font-semibold tracking-tight">{title}</h2>
<ul className="mt-4 grid gap-3">
{items.map((item) => (
<li
key={item}
className="text-muted-foreground font-display before:bg-primary/60 relative pl-5 text-sm leading-relaxed before:absolute before:top-[0.55rem] before:left-0 before:h-1.5 before:w-1.5 before:rounded-full">
{item}
</li>
))}
</ul>
</section>
);
}

export default async function RolePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const role = getRole(slug);
if (!role) notFound();

return (
<div className="mx-auto w-full max-w-3xl px-5 py-12 md:py-16">
<JsonLd data={jobPostingJsonLd(role)} />

<Link
href="/careers"
className="text-muted-foreground hover:text-foreground font-display inline-flex items-center gap-1.5 text-sm transition-colors">
<ArrowLeft className="h-4 w-4" aria-hidden />
All open roles
</Link>

<h1 className="font-display mt-6 text-3xl font-bold tracking-tight md:text-4xl">
{role.title}
</h1>

<div className="text-muted-foreground font-display mt-4 flex flex-wrap items-center gap-4 text-sm">
<span className="flex items-center gap-1.5">
<MapPin className="h-4 w-4" aria-hidden />
{role.location}
</span>
<span className="flex items-center gap-1.5">
<Globe className="h-4 w-4" aria-hidden />
{role.type}
</span>
</div>

<ul className="mt-5 flex flex-wrap gap-2">
{role.stack.map((item) => (
<li
key={item}
className="bg-muted text-muted-foreground font-display rounded-full px-3 py-1 text-xs">
{item}
</li>
))}
</ul>

<section className="mt-10">
<h2 className="font-display text-lg font-semibold tracking-tight">About the role</h2>
{role.about.map((paragraph) => (
<p
key={paragraph}
className="text-muted-foreground font-display mt-4 text-sm leading-relaxed">
{paragraph}
</p>
))}
</section>

<ListSection title="What you will do" items={role.responsibilities} />
<ListSection title="What we are looking for" items={role.requirements} />
<ListSection title="Nice to have" items={role.niceToHave} />
<ListSection title="How the process runs" items={role.process} />

<section id="apply" className="mt-14 scroll-mt-20 border-t pt-10">
<h2 className="font-display text-xl font-semibold tracking-tight">Apply</h2>
<p className="text-muted-foreground font-display mt-3 text-sm leading-relaxed">
No resume upload and no application portal. Write us something real. A short, specific
message about what you have built beats three pages of adjectives every time.
</p>

<div className="mt-8">
<ApplicationForm roleSlug={role.slug} roleTitle={role.title} />
</div>
</section>
</div>
);
}
75 changes: 75 additions & 0 deletions app/(company)/careers/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Link from "next/link";

import { ArrowRight, Globe, MapPin } from "lucide-react";

import { BreadcrumbJsonLd } from "@/components/json-ld";

import { roles } from "@/lib/careers/roles";
import { buildMetadata } from "@/lib/seo/metadata";

export const metadata = buildMetadata({
path: "/careers",
title: "Careers",
description:
"Open roles at Interview Resources. A small, remote, deliberately unglamorous team building evidence-graded interview research. Engineering and go-to-market.",
});

export default function CareersPage() {
return (
<div className="mx-auto w-full max-w-3xl px-5 py-12 md:py-16">
<BreadcrumbJsonLd path="/careers" />

<h1 className="font-display text-3xl font-bold tracking-tight md:text-4xl">Careers</h1>
<p className="text-muted-foreground font-display mt-4 text-base leading-relaxed">
We build interview research that shows its sources. Every question in a report is graded by
how well it is grounded, and the parts we cannot back up say so. That standard is easy to
write down and hard to keep, which is most of what the work here is.
</p>
<p className="text-muted-foreground font-display mt-4 text-base leading-relaxed">
The team is small and remote. You will own whole surfaces rather than tickets, you will talk
to customers, and there is nobody between you and the thing you ship.
</p>

<h2 className="font-display mt-12 text-lg font-semibold tracking-tight">Open roles</h2>

<ul className="mt-4 grid gap-4">
{roles.map((role) => (
<li key={role.slug}>
<Link
href={`/careers/${role.slug}`}
className="group hover:border-primary/40 hover:bg-muted/40 block rounded-xl border p-6 transition-colors">
<div className="flex items-start justify-between gap-4">
<h3 className="font-display text-lg font-semibold tracking-tight">{role.title}</h3>
<ArrowRight
className="text-muted-foreground group-hover:text-primary mt-1 h-4 w-4 shrink-0 transition-transform group-hover:translate-x-0.5"
aria-hidden
/>
</div>
<p className="text-muted-foreground font-display mt-2 text-sm leading-relaxed">
{role.tagline}
</p>
<div className="text-muted-foreground font-display mt-4 flex flex-wrap items-center gap-4 text-xs">
<span className="flex items-center gap-1.5">
<MapPin className="h-3.5 w-3.5" aria-hidden />
{role.location}
</span>
<span className="flex items-center gap-1.5">
<Globe className="h-3.5 w-3.5" aria-hidden />
{role.type}
</span>
</div>
</Link>
</li>
))}
</ul>

<div className="bg-muted/40 mt-10 rounded-xl border p-6">
<h2 className="font-display text-base font-semibold">Nothing here fits?</h2>
<p className="text-muted-foreground font-display mt-2 text-sm leading-relaxed">
Apply to the role closest to what you do and say so in your message. We would rather read
a good application to the wrong opening than miss you entirely.
</p>
</div>
</div>
);
}
89 changes: 89 additions & 0 deletions app/api/careers/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { and, eq, gt } from "drizzle-orm";

import { jobApplicationSchema } from "@/lib/careers/application";
import { getRole } from "@/lib/careers/roles";
import { db } from "@/lib/db/index";
import { jobApplications } from "@/lib/db/schema";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
* How long one email address has to wait before applying to the same role
* again. Public, unauthenticated, and writing to the database, so it needs
* some floor — but the floor is per role, because applying to two different
* openings in one sitting is a reasonable thing to do.
*/
const REAPPLY_WINDOW_MS = 24 * 60 * 60 * 1000;

/** Receives a careers application and files it. Deliberately unauthenticated. */
export async function POST(request: Request) {
let payload: unknown;
try {
payload = await request.json();
} catch {
return Response.json({ error: "Send JSON." }, { status: 400 });
}

const parsed = jobApplicationSchema.safeParse(payload);
if (!parsed.success) {
// Field-keyed so the form can put each message next to its input.
const fieldErrors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
const field = String(issue.path[0] ?? "form");
fieldErrors[field] ??= issue.message;
}
return Response.json(
{ error: "Please fix the highlighted fields.", fieldErrors },
{ status: 400 }
);
}

const { roleSlug, name, email, message, consent } = parsed.data;

// The schema already rejected unknown slugs; this is what gives us the title
// to snapshot alongside the row.
const role = getRole(roleSlug);
if (!role) {
return Response.json({ error: "That role is not open." }, { status: 400 });
}

const normalisedEmail = email.trim().toLowerCase();

try {
const [recent] = await db
.select({ id: jobApplications.id })
.from(jobApplications)
.where(
and(
eq(jobApplications.email, normalisedEmail),
eq(jobApplications.roleSlug, roleSlug),
gt(jobApplications.createdAt, new Date(Date.now() - REAPPLY_WINDOW_MS))
)
)
.limit(1);

// Not an error as far as the applicant is concerned: they already told us,
// and a second copy of the same message helps nobody. Report success.
if (recent) {
return Response.json({ ok: true, duplicate: true });
}

await db.insert(jobApplications).values({
roleSlug,
roleTitle: role.title,
name: name.trim(),
email: normalisedEmail,
message: message.trim(),
consent,
});
} catch (error) {
console.error("careers: could not file application", error);
return Response.json(
{ error: "We could not file that. Please try again in a moment." },
{ status: 500 }
);
}

return Response.json({ ok: true });
}
5 changes: 5 additions & 0 deletions components/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ export function Footer() {
Blog
</Link>
</li>
<li>
<Link href="/careers" className={linkClass}>
Careers
</Link>
</li>
<li>
<Link href="/contact" className={linkClass}>
Contact
Expand Down
13 changes: 13 additions & 0 deletions drizzle/0005_cold_red_shift.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
CREATE TABLE "job_applications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"role_slug" text NOT NULL,
"role_title" text NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"message" text NOT NULL,
"consent" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "job_applications_role_slug_created_at_idx" ON "job_applications" USING btree ("role_slug","created_at");--> statement-breakpoint
CREATE INDEX "job_applications_email_idx" ON "job_applications" USING btree ("email");
Loading
Loading