Skip to content

Commit c8a9d4a

Browse files
authored
feat(careers): job board + application widget as an opt-in stats.js module (#183)
* feat(careers): job board + application widget as an opt-in stats.js module 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/<project_id>/<slug>. 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. * test(ads): pin house rotation off in the short-code serving tests serveAd diverts HOUSE_AD_ROTATION_RATE (10%) of otherwise-fillable requests to the house ad, which is unmetered and carries no clickUrl. Four of the tests in this file assume a paid fill, so each failed ~10% of the time — about a 1-in-4 chance of a red run on a branch that changed nothing near ads. Measured on an untouched tree: 4 failures in 15 runs; 0 in 15 with the rotation pinned off. The failure surfaced as ".toMatch() expects to receive a string, but got undefined" on whichever of the four lost the coin flip, which is why it moved around between runs. Rotation itself is covered by tests/contract/ads-house-rotation.test.ts, so nothing is lost by making this file deterministic.
1 parent 7683bdc commit c8a9d4a

20 files changed

Lines changed: 2817 additions & 3 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"use client";
2+
3+
import { useState, useTransition } from "react";
4+
import { setCareersEnabled } from "@/app/actions/careers";
5+
6+
interface CareersToggleProps {
7+
projectId: string;
8+
initialEnabled: boolean;
9+
}
10+
11+
export function CareersToggle({ projectId, initialEnabled }: CareersToggleProps) {
12+
const [enabled, setEnabled] = useState(initialEnabled);
13+
const [pending, startTransition] = useTransition();
14+
const [error, setError] = useState<string | null>(null);
15+
16+
function flip() {
17+
const next = !enabled;
18+
setError(null);
19+
startTransition(async () => {
20+
const res = await setCareersEnabled({ projectId, enabled: next });
21+
if (!res.ok) {
22+
setError(res.error);
23+
return;
24+
}
25+
setEnabled(res.enabled);
26+
});
27+
}
28+
29+
return (
30+
<div className="space-y-2">
31+
<button
32+
type="button"
33+
onClick={flip}
34+
disabled={pending}
35+
className={`btn ${enabled ? "btn-secondary" : "btn-primary"}`}
36+
>
37+
{pending ? "…" : enabled ? "Unload careers widget" : "Load careers widget"}
38+
</button>
39+
{error && <p className="text-sm text-red-600">{error}</p>}
40+
</div>
41+
);
42+
}
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
"use client";
2+
3+
import { useState, useTransition } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { setApplicationStatus } from "@/app/actions/careers";
6+
import {
7+
APPLICATION_STATUSES,
8+
APPLICATION_STATUS_LABEL,
9+
type ApplicationStatus,
10+
} from "@/lib/careers/jobs";
11+
12+
export interface ApplicationRow {
13+
id: string;
14+
job_id: string;
15+
job_title: string;
16+
full_name: string;
17+
email: string;
18+
link: string | null;
19+
status: ApplicationStatus;
20+
created_at: string;
21+
}
22+
23+
const STATUS_STYLE: Record<ApplicationStatus, string> = {
24+
new: "bg-blue-500/15 text-blue-600",
25+
shortlisted: "bg-yellow-500/15 text-yellow-700",
26+
accepted: "bg-green-500/15 text-green-600",
27+
rejected: "bg-[var(--color-bg)] text-[var(--color-muted)]",
28+
};
29+
30+
export function Applications({
31+
projectId,
32+
applications,
33+
}: {
34+
projectId: string;
35+
applications: ApplicationRow[];
36+
}) {
37+
const [filter, setFilter] = useState<ApplicationStatus | "all">("all");
38+
const [job, setJob] = useState<string>("all");
39+
40+
const jobs = Array.from(
41+
new Map(applications.map((a) => [a.job_id, a.job_title])).entries(),
42+
);
43+
44+
const shown = applications.filter(
45+
(a) =>
46+
(filter === "all" || a.status === filter) && (job === "all" || a.job_id === job),
47+
);
48+
49+
function countFor(status: ApplicationStatus) {
50+
return applications.filter((a) => a.status === status).length;
51+
}
52+
53+
return (
54+
<section className="space-y-3">
55+
<div className="flex flex-wrap items-baseline justify-between gap-2">
56+
<div>
57+
<h2 className="text-lg font-semibold">Applicants</h2>
58+
<p className="text-sm text-[var(--color-muted)]">
59+
Name, email, and one link — everything the widget collects. Shortlist,
60+
accept, or reject each one.
61+
</p>
62+
</div>
63+
{jobs.length > 1 && (
64+
<select
65+
value={job}
66+
onChange={(e) => setJob(e.target.value)}
67+
className="input w-auto"
68+
aria-label="Filter by role"
69+
>
70+
<option value="all">All roles</option>
71+
{jobs.map(([id, title]) => (
72+
<option key={id} value={id}>
73+
{title}
74+
</option>
75+
))}
76+
</select>
77+
)}
78+
</div>
79+
80+
<div className="flex flex-wrap gap-1 text-xs">
81+
<FilterChip
82+
label={`All (${applications.length})`}
83+
active={filter === "all"}
84+
onClick={() => setFilter("all")}
85+
/>
86+
{APPLICATION_STATUSES.map((s) => (
87+
<FilterChip
88+
key={s}
89+
label={`${APPLICATION_STATUS_LABEL[s]} (${countFor(s)})`}
90+
active={filter === s}
91+
onClick={() => setFilter(s)}
92+
/>
93+
))}
94+
</div>
95+
96+
{shown.length === 0 ? (
97+
<div className="card p-4">
98+
<p className="text-sm text-[var(--color-muted)]">
99+
{applications.length === 0
100+
? "No applicants yet."
101+
: "No applicants match those filters."}
102+
</p>
103+
</div>
104+
) : (
105+
<div className="card overflow-x-auto">
106+
<table className="w-full text-sm">
107+
<thead>
108+
<tr className="text-left text-[var(--color-muted)]">
109+
<th className="p-3 font-medium">Applicant</th>
110+
<th className="p-3 font-medium">Role</th>
111+
<th className="p-3 font-medium">Link</th>
112+
<th className="p-3 font-medium">Received</th>
113+
<th className="p-3 font-medium">Status</th>
114+
<th className="p-3 font-medium">Decision</th>
115+
</tr>
116+
</thead>
117+
<tbody>
118+
{shown.map((app) => (
119+
<Row key={app.id} projectId={projectId} app={app} />
120+
))}
121+
</tbody>
122+
</table>
123+
</div>
124+
)}
125+
</section>
126+
);
127+
}
128+
129+
function FilterChip({
130+
label,
131+
active,
132+
onClick,
133+
}: {
134+
label: string;
135+
active: boolean;
136+
onClick: () => void;
137+
}) {
138+
return (
139+
<button
140+
type="button"
141+
onClick={onClick}
142+
aria-pressed={active}
143+
className={`rounded-full border border-[var(--color-border)] px-3 py-1 ${
144+
active ? "bg-[var(--color-bg)] font-semibold" : "text-[var(--color-muted)]"
145+
}`}
146+
>
147+
{label}
148+
</button>
149+
);
150+
}
151+
152+
function Row({ projectId, app }: { projectId: string; app: ApplicationRow }) {
153+
const router = useRouter();
154+
const [pending, startTransition] = useTransition();
155+
const [error, setError] = useState<string | null>(null);
156+
157+
function change(status: ApplicationStatus) {
158+
setError(null);
159+
startTransition(async () => {
160+
const res = await setApplicationStatus({
161+
projectId,
162+
applicationId: app.id,
163+
status,
164+
});
165+
if (!res.ok) setError(res.error);
166+
else router.refresh();
167+
});
168+
}
169+
170+
return (
171+
<tr className="border-t border-[var(--color-border)] align-top">
172+
<td className="p-3">
173+
<div className="font-medium">{app.full_name}</div>
174+
<a href={`mailto:${app.email}`} className="text-xs underline text-[var(--color-muted)]">
175+
{app.email}
176+
</a>
177+
{error && <div className="text-xs text-red-600">{error}</div>}
178+
</td>
179+
<td className="p-3">{app.job_title}</td>
180+
<td className="p-3">
181+
{app.link ? (
182+
<a
183+
href={app.link}
184+
target="_blank"
185+
rel="noreferrer noopener"
186+
className="underline break-all"
187+
>
188+
{app.link.replace(/^https?:\/\//, "").slice(0, 48)}
189+
</a>
190+
) : (
191+
<span className="text-[var(--color-muted)]">—</span>
192+
)}
193+
</td>
194+
<td className="p-3 whitespace-nowrap text-[var(--color-muted)]">
195+
{new Date(app.created_at).toLocaleDateString()}
196+
</td>
197+
<td className="p-3">
198+
<span className={`rounded-full px-2 py-0.5 text-xs ${STATUS_STYLE[app.status]}`}>
199+
{APPLICATION_STATUS_LABEL[app.status]}
200+
</span>
201+
</td>
202+
<td className="p-3">
203+
<div className="flex flex-wrap gap-1">
204+
{app.status !== "shortlisted" && (
205+
<button
206+
type="button"
207+
className="btn btn-secondary text-xs"
208+
onClick={() => change("shortlisted")}
209+
disabled={pending}
210+
>
211+
Shortlist
212+
</button>
213+
)}
214+
{app.status !== "accepted" && (
215+
<button
216+
type="button"
217+
className="btn btn-secondary text-xs"
218+
onClick={() => change("accepted")}
219+
disabled={pending}
220+
>
221+
Accept
222+
</button>
223+
)}
224+
{app.status !== "rejected" && (
225+
<button
226+
type="button"
227+
className="btn btn-secondary text-xs"
228+
onClick={() => change("rejected")}
229+
disabled={pending}
230+
>
231+
Reject
232+
</button>
233+
)}
234+
{app.status !== "new" && (
235+
<button
236+
type="button"
237+
className="btn btn-secondary text-xs text-[var(--color-muted)]"
238+
onClick={() => change("new")}
239+
disabled={pending}
240+
title="Move back to New"
241+
>
242+
Undo
243+
</button>
244+
)}
245+
</div>
246+
</td>
247+
</tr>
248+
);
249+
}

0 commit comments

Comments
 (0)