From b64003e078d21a3fa9153b0a0ec7674947ce085e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 14:19:18 +0000 Subject: [PATCH 1/2] feat(careers): job board + application widget as an opt-in stats.js module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customers already paste the stats snippet. Flipping "Load careers widget" in the dashboard makes that same snippet lazy-load /careers.js, which paints a job board and an inline application form on their /careers page. No second script tag. The cost discipline that makes this acceptable on every page of every customer site: stats.js only fetches the module when the page actually looks like a careers page — a [data-cp-careers] container, or a pathname match. Everything else pays one querySelector. data-careers="off" and data-careers-path="/jobs" are the escape hatches. A client-rendered job board is invisible to crawlers, which is the exact failure CrawlProof audits for. So the widget also writes JobPosting JSON-LD into the host page, and every open role gets a server-rendered canonical page at /c//. Google for Jobs and answer engines get real HTML either way. Applications are three fields and a link — no resume upload, so we never take custody of a file. They land in an applicant inbox where a reviewer can shortlist, accept, or reject, filtered by status and role. Workplace is remote/hybrid/onsite rather than a remote boolean: hybrid is the case a boolean forces you to misreport, and each value maps to different schema.org output. Tests cover the pure helpers plus the two hand-written browser scripts, which the compiler never parses — a typo there would ship straight to every customer's site. --- .../projects/[id]/stats/careers-toggle.tsx | 42 ++ .../[id]/stats/careers/applications.tsx | 249 ++++++++++ .../[id]/stats/careers/job-manager.tsx | 437 ++++++++++++++++++ .../projects/[id]/stats/careers/page.tsx | 159 +++++++ app/(app)/projects/[id]/stats/page.tsx | 29 ++ .../projects/[id]/stats/stats-subnav.tsx | 1 + app/(marketing)/docs/statistics/page.tsx | 29 ++ app/actions/careers.ts | 235 ++++++++++ app/api/careers/apply/route.ts | 121 +++++ app/api/careers/jobs/route.ts | 76 +++ app/c/[project]/[slug]/page.tsx | 144 ++++++ app/c/[project]/page.tsx | 113 +++++ app/careers.js/route.ts | 326 +++++++++++++ app/stats.js/route.ts | 32 ++ lib/careers/board.ts | 60 +++ lib/careers/jobs.ts | 241 ++++++++++ .../20260803120000_careers_widget.sql | 204 ++++++++ tests/careers-jobs.test.ts | 219 +++++++++ tests/careers-widget-script.test.ts | 86 ++++ 19 files changed, 2803 insertions(+) create mode 100644 app/(app)/projects/[id]/stats/careers-toggle.tsx create mode 100644 app/(app)/projects/[id]/stats/careers/applications.tsx create mode 100644 app/(app)/projects/[id]/stats/careers/job-manager.tsx create mode 100644 app/(app)/projects/[id]/stats/careers/page.tsx create mode 100644 app/actions/careers.ts create mode 100644 app/api/careers/apply/route.ts create mode 100644 app/api/careers/jobs/route.ts create mode 100644 app/c/[project]/[slug]/page.tsx create mode 100644 app/c/[project]/page.tsx create mode 100644 app/careers.js/route.ts create mode 100644 lib/careers/board.ts create mode 100644 lib/careers/jobs.ts create mode 100644 supabase/migrations/20260803120000_careers_widget.sql create mode 100644 tests/careers-jobs.test.ts create mode 100644 tests/careers-widget-script.test.ts diff --git a/app/(app)/projects/[id]/stats/careers-toggle.tsx b/app/(app)/projects/[id]/stats/careers-toggle.tsx new file mode 100644 index 00000000..7dcb1b86 --- /dev/null +++ b/app/(app)/projects/[id]/stats/careers-toggle.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { setCareersEnabled } from "@/app/actions/careers"; + +interface CareersToggleProps { + projectId: string; + initialEnabled: boolean; +} + +export function CareersToggle({ projectId, initialEnabled }: CareersToggleProps) { + const [enabled, setEnabled] = useState(initialEnabled); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function flip() { + const next = !enabled; + setError(null); + startTransition(async () => { + const res = await setCareersEnabled({ projectId, enabled: next }); + if (!res.ok) { + setError(res.error); + return; + } + setEnabled(res.enabled); + }); + } + + return ( +
+ + {error &&

{error}

} +
+ ); +} diff --git a/app/(app)/projects/[id]/stats/careers/applications.tsx b/app/(app)/projects/[id]/stats/careers/applications.tsx new file mode 100644 index 00000000..dcad6a94 --- /dev/null +++ b/app/(app)/projects/[id]/stats/careers/applications.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { setApplicationStatus } from "@/app/actions/careers"; +import { + APPLICATION_STATUSES, + APPLICATION_STATUS_LABEL, + type ApplicationStatus, +} from "@/lib/careers/jobs"; + +export interface ApplicationRow { + id: string; + job_id: string; + job_title: string; + full_name: string; + email: string; + link: string | null; + status: ApplicationStatus; + created_at: string; +} + +const STATUS_STYLE: Record = { + new: "bg-blue-500/15 text-blue-600", + shortlisted: "bg-yellow-500/15 text-yellow-700", + accepted: "bg-green-500/15 text-green-600", + rejected: "bg-[var(--color-bg)] text-[var(--color-muted)]", +}; + +export function Applications({ + projectId, + applications, +}: { + projectId: string; + applications: ApplicationRow[]; +}) { + const [filter, setFilter] = useState("all"); + const [job, setJob] = useState("all"); + + const jobs = Array.from( + new Map(applications.map((a) => [a.job_id, a.job_title])).entries(), + ); + + const shown = applications.filter( + (a) => + (filter === "all" || a.status === filter) && (job === "all" || a.job_id === job), + ); + + function countFor(status: ApplicationStatus) { + return applications.filter((a) => a.status === status).length; + } + + return ( +
+
+
+

Applicants

+

+ Name, email, and one link — everything the widget collects. Shortlist, + accept, or reject each one. +

+
+ {jobs.length > 1 && ( + + )} +
+ +
+ setFilter("all")} + /> + {APPLICATION_STATUSES.map((s) => ( + setFilter(s)} + /> + ))} +
+ + {shown.length === 0 ? ( +
+

+ {applications.length === 0 + ? "No applicants yet." + : "No applicants match those filters."} +

+
+ ) : ( +
+ + + + + + + + + + + + + {shown.map((app) => ( + + ))} + +
ApplicantRoleLinkReceivedStatusDecision
+
+ )} +
+ ); +} + +function FilterChip({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function Row({ projectId, app }: { projectId: string; app: ApplicationRow }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function change(status: ApplicationStatus) { + setError(null); + startTransition(async () => { + const res = await setApplicationStatus({ + projectId, + applicationId: app.id, + status, + }); + if (!res.ok) setError(res.error); + else router.refresh(); + }); + } + + return ( + + +
{app.full_name}
+ + {app.email} + + {error &&
{error}
} + + {app.job_title} + + {app.link ? ( + + {app.link.replace(/^https?:\/\//, "").slice(0, 48)} + + ) : ( + — + )} + + + {new Date(app.created_at).toLocaleDateString()} + + + + {APPLICATION_STATUS_LABEL[app.status]} + + + +
+ {app.status !== "shortlisted" && ( + + )} + {app.status !== "accepted" && ( + + )} + {app.status !== "rejected" && ( + + )} + {app.status !== "new" && ( + + )} +
+ + + ); +} diff --git a/app/(app)/projects/[id]/stats/careers/job-manager.tsx b/app/(app)/projects/[id]/stats/careers/job-manager.tsx new file mode 100644 index 00000000..de6ef840 --- /dev/null +++ b/app/(app)/projects/[id]/stats/careers/job-manager.tsx @@ -0,0 +1,437 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { + deleteJobPosting, + saveJobPosting, + setJobStatus, +} from "@/app/actions/careers"; +import { + EMPLOYMENT_TYPES, + WORKPLACES, + WORKPLACE_LABEL, + type JobStatus, + type Workplace, + workplaceSummary, +} from "@/lib/careers/jobs"; + +export interface JobRow { + id: string; + slug: string; + title: string; + department: string | null; + location: string | null; + employment_type: string; + workplace: Workplace; + compensation: string | null; + apply_url: string | null; + overview: string | null; + responsibilities: string[]; + qualifications: string[]; + status: JobStatus; + sort_order: number; + application_count: number; +} + +const STATUS_LABEL: Record = { + draft: "Draft", + open: "Open", + closed: "Closed", +}; + +export function JobManager({ + projectId, + jobs, + hostedBoardUrl, +}: { + projectId: string; + jobs: JobRow[]; + hostedBoardUrl: string; +}) { + const [editing, setEditing] = useState(null); + + return ( +
+
+
+

Job postings

+

+ Open roles appear in the widget on your site and on your{" "} + + hosted board + + . Drafts stay private. +

+
+ {editing === null && ( + + )} +
+ + {editing !== null && ( + setEditing(null)} + /> + )} + + {jobs.length === 0 && editing === null ? ( +
+

+ No roles yet. Add one and set it to Open to publish it. +

+
+ ) : ( + jobs.map((job) => ( + setEditing(job)} + busy={editing !== null} + /> + )) + )} +
+ ); +} + +function JobCard({ + projectId, + job, + onEdit, + busy, +}: { + projectId: string; + job: JobRow; + onEdit: () => void; + busy: boolean; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [confirming, setConfirming] = useState(false); + + function changeStatus(status: JobStatus) { + setError(null); + startTransition(async () => { + const res = await setJobStatus({ projectId, jobId: job.id, status }); + if (!res.ok) setError(res.error); + else router.refresh(); + }); + } + + function remove() { + setError(null); + startTransition(async () => { + const res = await deleteJobPosting({ projectId, jobId: job.id }); + if (!res.ok) setError(res.error); + else router.refresh(); + }); + } + + const meta = [ + job.department, + workplaceSummary(job.workplace, job.location), + job.employment_type, + job.compensation, + ] + .filter(Boolean) + .join(" · "); + + return ( +
+
+
+

{job.title}

+

{meta || "No details set"}

+
+
+ + {STATUS_LABEL[job.status]} + + + {job.application_count} application{job.application_count === 1 ? "" : "s"} + +
+
+ +
+ + {job.status !== "open" && ( + + )} + {job.status === "open" && ( + + )} + {confirming ? ( + <> + + + + ) : ( + + )} +
+ {confirming && ( +

+ Deleting removes its {job.application_count} application + {job.application_count === 1 ? "" : "s"} too. +

+ )} + {error &&

{error}

} +
+ ); +} + +function JobForm({ + projectId, + job, + onDone, +}: { + projectId: string; + job: JobRow | null; + onDone: () => void; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function submit(formData: FormData) { + setError(null); + startTransition(async () => { + const res = await saveJobPosting({ + projectId, + jobId: job?.id, + title: String(formData.get("title") ?? ""), + department: String(formData.get("department") ?? ""), + location: String(formData.get("location") ?? ""), + employmentType: String(formData.get("employmentType") ?? ""), + workplace: String(formData.get("workplace") ?? "onsite") as Workplace, + compensation: String(formData.get("compensation") ?? ""), + applyUrl: String(formData.get("applyUrl") ?? ""), + overview: String(formData.get("overview") ?? ""), + responsibilities: String(formData.get("responsibilities") ?? ""), + qualifications: String(formData.get("qualifications") ?? ""), + status: String(formData.get("status") ?? "draft") as JobStatus, + sortOrder: Number(formData.get("sortOrder") ?? 0), + }); + if (!res.ok) { + setError(res.error); + return; + } + onDone(); + router.refresh(); + }); + } + + return ( +
+
+ + + + + + + + + + + + + + + + + + + + + +
+ + +