init
This commit is contained in:
commit
d295c7f139
1527 files changed
+270726
No files matched your search
@@ -0,0 +1,359 @@
|
||||
// The ONE action registry — the single source of truth for "what can be done".
|
||||
// Both UI controls and the assistant dispatch the SAME entries via dispatch(),
|
||||
// so the human UI and the agentic UI can never drift. Pure module (no React
|
||||
// hooks): the caller assembles `ctx` from its hooks and passes it in.
|
||||
//
|
||||
// The model emits intents as a vendor-neutral text envelope (<<act:ID {json}>>),
|
||||
// parsed CLIENT-SIDE; the registry validates args by hand (no zod dep) and gates
|
||||
// side-effects. Resolution of "all the Anthropic ones" happens here, off the
|
||||
// client pipeline snapshot — the model never sees or invents URLs.
|
||||
|
||||
import type { Application, InboxJob } from "@/lib/career-ops";
|
||||
import type { Job } from "@/components/jobs/job-store";
|
||||
|
||||
export const AUTO_FIRE_MAX = 3; // fire ≤3 evaluations silently; confirm above that
|
||||
export const BATCH_CAP = 12; // hard ceiling on a single fan-out
|
||||
|
||||
// Canonical states (templates/states.yml) — the web validates against the same set.
|
||||
const CANON_STATUS = ["Evaluated", "Applied", "Responded", "Interview", "Offer", "Rejected", "Discarded", "SKIP"];
|
||||
|
||||
const TAB_VALUES = [
|
||||
"INBOX", "ALL", "EVALUATED", "APPLIED", "RESPONDED", "INTERVIEW", "OFFER", "REJECTED", "DISCARDED", "SKIP",
|
||||
] as const;
|
||||
const SORT_VALUES = ["company", "role", "score", "status", "date"] as const;
|
||||
|
||||
export type StartJobInput = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
kind: string;
|
||||
input: string;
|
||||
page?: string;
|
||||
batchId?: string;
|
||||
};
|
||||
|
||||
export type ActionCtx = {
|
||||
push: (path: string) => void; // router.push — section/detail change
|
||||
replace: (path: string) => void; // router.replace — incremental filter tweak
|
||||
startJob: (opts: StartJobInput) => string | null;
|
||||
inbox: InboxJob[];
|
||||
applications: Application[]; // tracker snapshot — resolve #n → company/role for confirms
|
||||
jobForUrl: (url: string) => Job | undefined; // skip-if-done / retry logic
|
||||
rememberFact: (fact: string) => void;
|
||||
writeStatus: (n: string, status: string) => void; // UPDATE-only writeback via /api/status
|
||||
setApplyField: (idOrLabel: string, value: string) => void; // edit an apply-proxy answer
|
||||
startApply: (url: string) => void; // open the apply form-proxy for a posting URL
|
||||
applyExplore?: (patch: Record<string, unknown>, opts?: { merge?: boolean; run?: boolean }) => void; // build a FREE discovery search
|
||||
writeProfile?: (patch: Record<string, unknown>) => void; // merge-safe config/profile.yml write
|
||||
writePortals?: (roles: string[], location?: string[]) => void; // merge-safe portals.yml title_filter write
|
||||
};
|
||||
|
||||
export type ProfilePatch = {
|
||||
name?: string;
|
||||
email?: string;
|
||||
location?: string;
|
||||
roles?: string[];
|
||||
compMin?: number;
|
||||
compMax?: number;
|
||||
currency?: string;
|
||||
remote?: string;
|
||||
seniority?: string;
|
||||
};
|
||||
|
||||
// House-style hand validation (no zod). Keeps only well-formed, confident fields.
|
||||
function coerceProfile(raw: Record<string, unknown>): ProfilePatch {
|
||||
const out: ProfilePatch = {};
|
||||
const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
||||
const num = (v: unknown) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : undefined);
|
||||
out.name = str(raw.name);
|
||||
out.email = str(raw.email);
|
||||
out.location = str(raw.location);
|
||||
out.currency = str(raw.currency);
|
||||
out.remote = str(raw.remote);
|
||||
out.seniority = str(raw.seniority);
|
||||
out.compMin = num(raw.compMin);
|
||||
out.compMax = num(raw.compMax);
|
||||
if (Array.isArray(raw.roles)) out.roles = raw.roles.filter((r): r is string => typeof r === "string" && r.trim().length > 0).map((r) => r.trim()).slice(0, 6);
|
||||
return out;
|
||||
}
|
||||
|
||||
export type DoneInfo = { jobIds?: string[]; batchId?: string; note?: string };
|
||||
export type DispatchResult =
|
||||
| ({ status: "done" } & DoneInfo)
|
||||
| { status: "ignored"; note?: string }
|
||||
| { status: "confirm"; summary: string; run: () => DoneInfo };
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
const isStr = (v: unknown): v is string => typeof v === "string" && v.length > 0;
|
||||
const normCompany = (s: string) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
||||
|
||||
// Allow only in-app routes (with optional :segment and ?query). Blocks anything
|
||||
// that isn't one of the app's own sections — the model can't navigate offsite.
|
||||
function isAllowedPath(p: string): boolean {
|
||||
if (!p.startsWith("/")) return false;
|
||||
if (/^(https?:)?\/\//i.test(p)) return false;
|
||||
const path = p.split(/[?#]/)[0];
|
||||
if (path === "/") return true;
|
||||
return /^\/(explore|pipeline|portals|analytics|cv|config|apply|jobs)(\/[^/]+)?$/.test(path);
|
||||
}
|
||||
|
||||
function genBatchId(): string {
|
||||
return `batch-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
|
||||
}
|
||||
|
||||
// ── actions ──────────────────────────────────────────────────────────────
|
||||
type ActionDef = {
|
||||
sideEffect: "none" | "spend" | "write";
|
||||
// parse returns typed args or null (invalid → envelope ignored)
|
||||
run: (raw: Record<string, unknown>, ctx: ActionCtx) => DispatchResult;
|
||||
};
|
||||
|
||||
const ACTIONS: Record<string, ActionDef> = {
|
||||
navigate: {
|
||||
sideEffect: "none",
|
||||
run: (raw, ctx) => {
|
||||
const path = raw.path;
|
||||
if (!isStr(path) || !isAllowedPath(path)) return { status: "ignored", note: "blocked navigation" };
|
||||
ctx.push(path);
|
||||
return { status: "done" };
|
||||
},
|
||||
},
|
||||
|
||||
filterPipeline: {
|
||||
sideEffect: "none",
|
||||
run: (raw, ctx) => {
|
||||
const sp = new URLSearchParams();
|
||||
const tab = typeof raw.tab === "string" ? raw.tab.toUpperCase() : "";
|
||||
if ((TAB_VALUES as readonly string[]).includes(tab)) sp.set("tab", tab);
|
||||
const min = typeof raw.min === "number" ? raw.min : parseFloat(String(raw.min ?? ""));
|
||||
if (Number.isFinite(min) && min >= 0 && min <= 5) sp.set("min", String(min));
|
||||
if (isStr(raw.q)) sp.set("q", String(raw.q).slice(0, 80));
|
||||
const sort = typeof raw.sort === "string" ? raw.sort : "";
|
||||
if ((SORT_VALUES as readonly string[]).includes(sort)) sp.set("sort", sort);
|
||||
const dir = Number(raw.dir);
|
||||
if (dir === 1 || dir === -1) sp.set("dir", String(dir));
|
||||
const qs = sp.toString();
|
||||
ctx.replace(`/pipeline${qs ? `?${qs}` : ""}`);
|
||||
return { status: "done" };
|
||||
},
|
||||
},
|
||||
|
||||
evaluate: {
|
||||
sideEffect: "spend",
|
||||
run: (raw, ctx) => {
|
||||
const url = raw.url;
|
||||
if (!isStr(url) || !/^https?:\/\//i.test(url)) return { status: "ignored", note: "invalid url" };
|
||||
const ex = ctx.jobForUrl(url);
|
||||
if (ex && ex.status !== "error" && !raw.rerun) return { status: "ignored", note: "already evaluated" };
|
||||
const id = ctx.startJob({
|
||||
title: isStr(raw.title) ? String(raw.title) : "Evaluate",
|
||||
subtitle: isStr(raw.subtitle) ? String(raw.subtitle) : undefined,
|
||||
kind: "evaluate",
|
||||
input: url,
|
||||
page: "/pipeline",
|
||||
});
|
||||
return { status: "done", jobIds: id ? [id] : [] };
|
||||
},
|
||||
},
|
||||
|
||||
evaluateCompany: {
|
||||
sideEffect: "spend",
|
||||
run: (raw, ctx) => {
|
||||
const company = raw.company;
|
||||
if (!isStr(company)) return { status: "ignored", note: "missing company" };
|
||||
const target = normCompany(company);
|
||||
const rerun = raw.rerun === true;
|
||||
const cap = Number.isFinite(Number(raw.max)) ? Math.min(BATCH_CAP, Number(raw.max)) : BATCH_CAP;
|
||||
|
||||
// statusScope: only 'inbox' is supported (Application rows carry no URL).
|
||||
const matches = ctx.inbox.filter((j) => {
|
||||
if (j.done) return false;
|
||||
const c = normCompany(j.company);
|
||||
return c === target || c.includes(target) || target.includes(c);
|
||||
});
|
||||
const pending = matches
|
||||
.filter((j) => {
|
||||
if (rerun) return true;
|
||||
const ex = ctx.jobForUrl(j.url);
|
||||
return !ex || ex.status === "error";
|
||||
})
|
||||
.slice(0, cap);
|
||||
|
||||
if (pending.length === 0) {
|
||||
return {
|
||||
status: "ignored",
|
||||
note: matches.length > 0 ? `Already evaluated every ${company} posting.` : `No pending ${company} postings in your inbox.`,
|
||||
};
|
||||
}
|
||||
|
||||
const fire = (): DoneInfo => {
|
||||
const batchId = pending.length > 1 ? genBatchId() : undefined;
|
||||
const ids = pending
|
||||
.map((j) =>
|
||||
ctx.startJob({
|
||||
title: `Evaluate · ${j.company}`,
|
||||
subtitle: j.role,
|
||||
kind: "evaluate",
|
||||
input: j.url,
|
||||
page: "/pipeline",
|
||||
batchId,
|
||||
}),
|
||||
)
|
||||
.filter((x): x is string => !!x);
|
||||
return { jobIds: ids, batchId };
|
||||
};
|
||||
|
||||
if (pending.length <= AUTO_FIRE_MAX) return { status: "done", ...fire() };
|
||||
return {
|
||||
status: "confirm",
|
||||
summary: `Evaluate ${pending.length} ${company} postings? (~${pending.length} worker${pending.length > 1 ? "s" : ""})`,
|
||||
run: fire,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
explore: {
|
||||
// FREE: opens the Explorer and builds a discovery search. Zero tokens — it
|
||||
// never spends, so it bypasses the confirm gate. The provider clamps/validates.
|
||||
sideEffect: "none",
|
||||
run: (raw, ctx) => {
|
||||
if (!ctx.applyExplore) return { status: "ignored", note: "explore unavailable here" };
|
||||
const run = raw.run === true;
|
||||
const merge = raw.merge === true;
|
||||
ctx.push("/explore");
|
||||
ctx.applyExplore(raw, { merge, run });
|
||||
return { status: "done", note: run ? "Scanning the ATS network for fresh roles (free)…" : "Opened Explore with your filters." };
|
||||
},
|
||||
},
|
||||
|
||||
research: {
|
||||
sideEffect: "spend",
|
||||
run: (raw, ctx) => {
|
||||
const target = raw.target;
|
||||
if (!isStr(target)) return { status: "ignored", note: "missing target" };
|
||||
const id = ctx.startJob({
|
||||
title: isStr(raw.title) ? String(raw.title) : "Research",
|
||||
kind: "research",
|
||||
input: target,
|
||||
page: "/pipeline",
|
||||
});
|
||||
return { status: "done", jobIds: id ? [id] : [] };
|
||||
},
|
||||
},
|
||||
|
||||
generatePdf: {
|
||||
sideEffect: "spend",
|
||||
run: (raw, ctx) => {
|
||||
const n = String(raw.n ?? "").trim();
|
||||
if (!n) return { status: "ignored", note: "need an application #" };
|
||||
const app = ctx.applications.find((a) => a.n === n);
|
||||
const id = ctx.startJob({ title: `CV PDF · ${app?.company ?? `#${n}`}`, subtitle: "tailored CV", kind: "pdf", input: n, page: `/pipeline/${n}` });
|
||||
return { status: "done", jobIds: id ? [id] : [] };
|
||||
},
|
||||
},
|
||||
|
||||
setStatus: {
|
||||
sideEffect: "write",
|
||||
run: (raw, ctx) => {
|
||||
const n = String(raw.n ?? "").trim();
|
||||
const status = String(raw.status ?? "").trim();
|
||||
const canon = CANON_STATUS.find((s) => s.toLowerCase() === status.toLowerCase());
|
||||
if (!n || !canon) return { status: "ignored", note: "need an application # and a canonical status" };
|
||||
const app = ctx.applications.find((a) => a.n === n);
|
||||
const label = app ? `${app.company} · ${app.role}` : `#${n}`;
|
||||
return {
|
||||
status: "confirm",
|
||||
summary: `Mark ${label} → ${canon}?`,
|
||||
run: () => {
|
||||
ctx.writeStatus(n, canon);
|
||||
return { note: `Marked #${n} as ${canon}.` };
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
apply: {
|
||||
sideEffect: "none",
|
||||
run: (raw, ctx) => {
|
||||
const url = raw.url;
|
||||
if (!isStr(url) || !/^https?:\/\//i.test(url)) return { status: "ignored", note: "need an application form URL" };
|
||||
ctx.startApply(url);
|
||||
return { status: "done", note: "Opening the application form…" };
|
||||
},
|
||||
},
|
||||
|
||||
setApplyField: {
|
||||
sideEffect: "none",
|
||||
run: (raw, ctx) => {
|
||||
const field = (raw.field ?? raw.label) as unknown;
|
||||
const value = raw.value;
|
||||
if (!isStr(field) || typeof value !== "string") return { status: "ignored", note: "need a field and a value" };
|
||||
ctx.setApplyField(String(field), value);
|
||||
return { status: "done", note: `Updated "${field}".` };
|
||||
},
|
||||
},
|
||||
|
||||
remember: {
|
||||
sideEffect: "write",
|
||||
run: (raw, ctx) => {
|
||||
const fact = raw.fact;
|
||||
if (!isStr(fact)) return { status: "ignored" };
|
||||
ctx.rememberFact(String(fact).trim());
|
||||
return { status: "done" };
|
||||
},
|
||||
},
|
||||
|
||||
// Propose the user's profile → on confirm, merge-safe write to config/profile.yml
|
||||
// AND seed the scanner (portals.yml title_filter) so the very first scan has roles.
|
||||
// DATA_CONTRACT: deep-merge only proposed keys; never clobber archetypes/narrative.
|
||||
setProfile: {
|
||||
sideEffect: "write",
|
||||
run: (raw, ctx) => {
|
||||
if (!ctx.writeProfile) return { status: "ignored", note: "profile write unavailable here" };
|
||||
const p = coerceProfile(raw);
|
||||
const has = Object.values(p).some((v) => (Array.isArray(v) ? v.length : v !== undefined));
|
||||
if (!has) return { status: "ignored", note: "nothing to save" };
|
||||
const bits = [p.roles?.length ? `roles: ${p.roles.join(", ")}` : "", p.location ? `in ${p.location}` : "", p.compMin && p.compMax ? `comp ${p.compMin}–${p.compMax}` : ""].filter(Boolean).join(" · ");
|
||||
return {
|
||||
status: "confirm",
|
||||
summary: `Save your profile?${bits ? ` (${bits})` : ""}`,
|
||||
run: () => {
|
||||
ctx.writeProfile!(p as Record<string, unknown>);
|
||||
if (p.roles?.length) ctx.writePortals?.(p.roles, p.location ? [p.location] : undefined);
|
||||
return { note: "Profile saved — your matches will sharpen." };
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
setPortals: {
|
||||
sideEffect: "write",
|
||||
run: (raw, ctx) => {
|
||||
if (!ctx.writePortals) return { status: "ignored", note: "portals write unavailable here" };
|
||||
const roles = Array.isArray(raw.roles) ? raw.roles.filter((r): r is string => typeof r === "string" && r.trim().length > 0).map((r) => r.trim()) : [];
|
||||
if (roles.length === 0) return { status: "ignored", note: "no roles" };
|
||||
const location = Array.isArray(raw.location) ? raw.location.filter((l): l is string => typeof l === "string") : undefined;
|
||||
return {
|
||||
status: "confirm",
|
||||
summary: `Set your scan targets to: ${roles.join(", ")}?`,
|
||||
run: () => {
|
||||
ctx.writePortals!(roles, location);
|
||||
return { note: "Scan targets updated." };
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function actionExists(id: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(ACTIONS, id);
|
||||
}
|
||||
|
||||
export function dispatch(id: string, rawArgs: Record<string, unknown>, ctx: ActionCtx): DispatchResult {
|
||||
const def = ACTIONS[id];
|
||||
if (!def) return { status: "ignored", note: `unknown action: ${id}` };
|
||||
try {
|
||||
return def.run(rawArgs ?? {}, ctx);
|
||||
} catch {
|
||||
return { status: "ignored", note: `could not run ${id}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { pipelineSummary } from "@/lib/career-ops";
|
||||
import { canonStatus, scoreNum } from "@/lib/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STAGES: { key: string; label: string }[] = [
|
||||
{ key: "EVALUATED", label: "Evaluated" },
|
||||
{ key: "APPLIED", label: "Applied" },
|
||||
{ key: "RESPONDED", label: "Responded" },
|
||||
{ key: "INTERVIEW", label: "Interview" },
|
||||
{ key: "OFFER", label: "Offer" },
|
||||
{ key: "REJECTED", label: "Rejected" },
|
||||
{ key: "DISCARDED", label: "Discarded" },
|
||||
];
|
||||
|
||||
export default function Analytics() {
|
||||
const { applications } = pipelineSummary();
|
||||
const total = applications.length;
|
||||
|
||||
const stageCounts = STAGES.map((s) => ({
|
||||
...s,
|
||||
n: applications.filter((a) => canonStatus(a.status).includes(s.key)).length,
|
||||
}));
|
||||
const maxStage = Math.max(1, ...stageCounts.map((s) => s.n));
|
||||
|
||||
const scores = applications.map((a) => scoreNum(a.score)).filter((n) => !Number.isNaN(n));
|
||||
const avg = scores.length ? scores.reduce((a, b) => a + b, 0) / scores.length : 0;
|
||||
const buckets = [
|
||||
{ label: "4.5 – 5.0", test: (n: number) => n >= 4.5 },
|
||||
{ label: "4.0 – 4.4", test: (n: number) => n >= 4 && n < 4.5 },
|
||||
{ label: "3.0 – 3.9", test: (n: number) => n >= 3 && n < 4 },
|
||||
{ label: "< 3.0", test: (n: number) => n < 3 },
|
||||
].map((b) => ({ label: b.label, n: scores.filter(b.test).length }));
|
||||
const maxBucket = Math.max(1, ...buckets.map((b) => b.n));
|
||||
|
||||
const companyCounts = new Map<string, number>();
|
||||
for (const a of applications) if (a.company) companyCounts.set(a.company, (companyCounts.get(a.company) ?? 0) + 1);
|
||||
const topCompanies = [...companyCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10);
|
||||
const maxCompany = Math.max(1, ...topCompanies.map((c) => c[1]));
|
||||
|
||||
const offers = stageCounts.find((s) => s.key === "OFFER")?.n ?? 0;
|
||||
const interviews = stageCounts.find((s) => s.key === "INTERVIEW")?.n ?? 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-10">
|
||||
<h1 className="font-display text-2xl tracking-tight text-landing">Analytics</h1>
|
||||
<p className="mt-1 text-sm text-muted">Across {total} tracked evaluations.</p>
|
||||
|
||||
{/* headline stats */}
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-4">
|
||||
<Stat value={total} label="evaluated" />
|
||||
<Stat value={avg ? avg.toFixed(2) : "—"} label="avg score" />
|
||||
<Stat value={interviews} label="interviews" />
|
||||
<Stat value={offers} label="offers" />
|
||||
</div>
|
||||
|
||||
<Section title="Pipeline by stage">
|
||||
{stageCounts.map((s) => (
|
||||
<Bar key={s.key} label={s.label} value={s.n} pct={(s.n / maxStage) * 100} total={total} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="Score distribution">
|
||||
{buckets.map((b) => (
|
||||
<Bar key={b.label} label={b.label} value={b.n} pct={(b.n / maxBucket) * 100} total={scores.length} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="Top companies" id="companies">
|
||||
{topCompanies.map(([name, n]) => (
|
||||
<Bar key={name} label={name} value={n} pct={(n / maxCompany) * 100} />
|
||||
))}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ value, label }: { value: number | string; label: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-surface/50 p-4">
|
||||
<div className="text-3xl font-semibold tabular-nums">{value}</div>
|
||||
<div className="mt-1 text-xs text-faint">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children, id }: { title: string; children: React.ReactNode; id?: string }) {
|
||||
return (
|
||||
<section id={id} className="mt-10 scroll-mt-8">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">{title}</h2>
|
||||
<div className="mt-4 space-y-2.5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Bar({ label, value, pct, total }: { label: string; value: number; pct: number; total?: number }) {
|
||||
const share = total && total > 0 ? Math.round((value / total) * 100) : null;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-32 shrink-0 truncate text-sm text-muted">{label}</div>
|
||||
<div className="relative h-7 flex-1 overflow-hidden rounded-md bg-surface">
|
||||
<div
|
||||
className="h-full rounded-md bg-gradient-to-r from-brand/70 to-brand/40"
|
||||
style={{ width: `${Math.max(pct, value > 0 ? 4 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20 shrink-0 text-right text-sm tabular-nums">
|
||||
{value}
|
||||
{share !== null && <span className="ml-1 text-xs text-faint">{share}%</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { closeSession } from "@/lib/apply/session";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Explicitly close an apply session (the user hit "new" or left the page) so we
|
||||
// free the off-screen browser tab promptly instead of waiting for the prune.
|
||||
export async function POST(req: Request) {
|
||||
let body: { sessionId?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ ok: false }, { status: 400 });
|
||||
}
|
||||
if (body.sessionId) await closeSession(body.sessionId).catch(() => {});
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { getSession, finalizeDrivenSession, extractCurrent, isApplicationFormFn, handoffSession } from "@/lib/apply/session";
|
||||
import { driveSession } from "@/lib/apply/drive";
|
||||
import { classifyEmpty } from "@/lib/apply/diagnose";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 300;
|
||||
|
||||
// Streamed agentic drive over an OPEN session: the AI drives the headed page to
|
||||
// REACH a fillable application form (the user watches each step live), then we
|
||||
// extract + finalize. NEVER submits (enforced in driveSession).
|
||||
export async function POST(req: Request) {
|
||||
let body: { sessionId?: string; cliId?: string; goal?: "reach" | "full"; answers?: { label: string; value: string }[] };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "bad json" }, { status: 400 });
|
||||
}
|
||||
const { sessionId, cliId = "", goal = "reach", answers } = body;
|
||||
const s = sessionId ? getSession(sessionId) : undefined;
|
||||
if (!s) return Response.json({ error: "apply session not found (it may have expired)" }, { status: 404 });
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const emit = (o: unknown) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(JSON.stringify(o) + "\n"));
|
||||
} catch {
|
||||
/* client gone */
|
||||
}
|
||||
};
|
||||
try {
|
||||
const page = s.page;
|
||||
const isFormReady = async () => {
|
||||
try {
|
||||
return isApplicationFormFn((await extractCurrent(page, s.url)).form);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const budget = goal === "full" ? 16 : 7;
|
||||
const result = await driveSession(page, cliId, goal, isFormReady, (step) => emit({ t: "step", ...step }), budget, answers);
|
||||
|
||||
if (goal === "full") {
|
||||
// The agent filled the real form — bring it to the front for the human
|
||||
// to review + submit themselves. We never submit.
|
||||
if (result.reached) await handoffSession(s.id).catch(() => {});
|
||||
emit({ t: "done", filled: result.reached, turns: result.turns, reason: result.reason });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.reached) {
|
||||
const fin = await finalizeDrivenSession(s.id, cliId);
|
||||
if (fin) {
|
||||
emit({ t: "done", reached: true, turns: result.turns, title: fin.title, fields: fin.fields, issues: fin.issues });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Didn't reach a real form → classify why for a clear message.
|
||||
const why = await classifyEmpty(page, s.url).catch(() => ({ message: "Couldn't reach a fillable form on this page." }));
|
||||
emit({ t: "error", reason: result.reason, message: result.reason === "stuck" ? result.steps.at(-1)?.detail || why.message : why.message });
|
||||
} catch (e) {
|
||||
emit({ t: "error", message: e instanceof Error ? e.message.slice(0, 160) : "drive failed" });
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
return new Response(stream, { headers: { "Content-Type": "application/x-ndjson", "Cache-Control": "no-store" } });
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { fillSession, handoffSession, getSession } from "@/lib/apply/session";
|
||||
import { resolveTailoredCv, companyFromTitle } from "@/lib/apply/cv";
|
||||
import type { ApplyField } from "@/lib/apply/extract";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 120;
|
||||
|
||||
// Fill the real form behind the scenes (headed-but-off-screen), screenshotting
|
||||
// each step for the "behind the scenes" strip, then bring the window to the front
|
||||
// so the HUMAN reviews and submits. NEVER submits — there is no submit path here.
|
||||
export async function POST(req: Request) {
|
||||
let body: { sessionId?: string; answers?: Record<string, string>; fields?: ApplyField[]; handoff?: boolean; company?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "bad json" }, { status: 400 });
|
||||
}
|
||||
const { sessionId, answers = {}, fields = [], handoff, company } = body;
|
||||
if (!sessionId) return Response.json({ error: "sessionId required" }, { status: 400 });
|
||||
|
||||
// Resolve the tailored CV server-side (never trust a client path): by the
|
||||
// offer's company if known, else best-effort from the form title.
|
||||
const session = getSession(sessionId);
|
||||
const cvPath = resolveTailoredCv(company) ?? resolveTailoredCv(companyFromTitle(session?.title)) ?? undefined;
|
||||
|
||||
try {
|
||||
const result = await fillSession(sessionId, answers, fields, cvPath);
|
||||
if (handoff) await handoffSession(sessionId).catch(() => {});
|
||||
return Response.json({ ...result, handedOff: !!handoff, cvAttached: !!cvPath });
|
||||
} catch (e) {
|
||||
return Response.json({ error: e instanceof Error ? e.message.slice(0, 200) : "fill failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveCli } from "@/lib/clis";
|
||||
import { careerOpsRoot, readMemory } from "@/lib/career-ops";
|
||||
import { getSession } from "@/lib/apply/session";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 320;
|
||||
|
||||
/**
|
||||
* Pull a JSON object out of an LLM's text answer, tolerating code fences,
|
||||
* trailing prose, and — crucially — TRUNCATION (the planner getting killed
|
||||
* mid-output on a big form). When the object is incomplete we salvage the
|
||||
* largest valid prefix so the fields that DID finish still come through.
|
||||
*/
|
||||
function extractJsonObject(text: string): { obj: Record<string, unknown> | null; truncated: boolean } {
|
||||
const s = text.replace(/```(?:json)?/gi, "");
|
||||
const start = s.indexOf("{");
|
||||
if (start === -1) return { obj: null, truncated: false };
|
||||
|
||||
let depth = 0;
|
||||
let inStr = false;
|
||||
let esc = false;
|
||||
let end = -1;
|
||||
for (let i = start; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
if (inStr) {
|
||||
if (esc) esc = false;
|
||||
else if (c === "\\") esc = true;
|
||||
else if (c === '"') inStr = false;
|
||||
} else if (c === '"') inStr = true;
|
||||
else if (c === "{") depth++;
|
||||
else if (c === "}") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (end !== -1) {
|
||||
try {
|
||||
return { obj: JSON.parse(s.slice(start, end + 1)) as Record<string, unknown>, truncated: false };
|
||||
} catch {
|
||||
/* malformed even though balanced — fall through to salvage */
|
||||
}
|
||||
}
|
||||
|
||||
// Truncated / unbalanced: walk back from successive commas, close the JSON,
|
||||
// and parse the largest prefix that is valid.
|
||||
const frag = s.slice(start);
|
||||
const open = (frag.match(/{/g) || []).length;
|
||||
const close = (frag.match(/}/g) || []).length;
|
||||
const pad = "}".repeat(Math.max(0, open - close));
|
||||
for (let tryEnd = frag.length; tryEnd > 1; ) {
|
||||
const cand = frag.slice(0, tryEnd).replace(/,\s*$/, "") + pad;
|
||||
try {
|
||||
return { obj: JSON.parse(cand) as Record<string, unknown>, truncated: true };
|
||||
} catch {
|
||||
const prevComma = frag.lastIndexOf(",", tryEnd - 1);
|
||||
if (prevComma <= start) break;
|
||||
tryEnd = prevComma;
|
||||
}
|
||||
}
|
||||
return { obj: null, truncated: true };
|
||||
}
|
||||
|
||||
// AI pre-fill (STREAMING NDJSON). The user's BYO CLI (read-only PLANNER — no
|
||||
// browser access) drafts an answer per field from cv.md / profile / the job's
|
||||
// report. We stream a live diagnostic log of every step (spawn, heartbeats,
|
||||
// exit code/signal, parse outcome) so a stuck/empty prefill is observable on the
|
||||
// page AND written to <root>/.career-ops-web/apply-prefill.log for debugging.
|
||||
export async function POST(req: Request) {
|
||||
let body: { sessionId?: string; cliId?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "bad json" }, { status: 400 });
|
||||
}
|
||||
const { sessionId, cliId } = body;
|
||||
const t0 = Date.now();
|
||||
const encoder = new TextEncoder();
|
||||
const logPath = path.join(careerOpsRoot(), ".career-ops-web", "apply-prefill.log");
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const emit = (obj: unknown) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(JSON.stringify(obj) + "\n"));
|
||||
} catch {
|
||||
/* client gone */
|
||||
}
|
||||
};
|
||||
const log = (m: string) => {
|
||||
const el = Date.now() - t0;
|
||||
emit({ t: "log", m, el });
|
||||
try {
|
||||
fs.appendFileSync(logPath, `${new Date(t0 + el).toISOString()} [+${(el / 1000).toFixed(1)}s] ${m}\n`);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
const fail = (m: string, raw?: string) => {
|
||||
log(`ERROR: ${m}`);
|
||||
emit({ t: "error", m, raw });
|
||||
controller.close();
|
||||
};
|
||||
try {
|
||||
fs.appendFileSync(logPath, `\n===== prefill ${new Date(t0).toISOString()} session=${sessionId} cli=${cliId} =====\n`);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const s = sessionId ? getSession(sessionId) : undefined;
|
||||
if (!s) return fail("apply session not found (it may have expired)");
|
||||
const resolved = cliId ? resolveCli(cliId) : null;
|
||||
if (!resolved) return fail(`CLI '${cliId}' not found on this machine`);
|
||||
const { spec, binPath } = resolved;
|
||||
|
||||
const fieldsList = s.fields
|
||||
.map((f) => `${f.id}\t${f.type}${f.required ? "*" : ""}\t${f.label}${f.options ? `\t[options: ${f.options.join(" | ")}]` : ""}`)
|
||||
.join("\n");
|
||||
const mem = readMemory().trim();
|
||||
const prompt = `You are pre-filling a job application for the user (company/role: ${s.title}). Read cv.md and config/profile.yml; if a matching report for this company exists in reports/, read it too. Ground EVERY answer in the REAL candidate — never invent facts.${mem ? `\n\nDurable notes about the user:\n${mem}` : ""}
|
||||
|
||||
FIELDS (id ⇥ type ⇥ label ⇥ options):
|
||||
${fieldsList}
|
||||
|
||||
For each field give the best answer:
|
||||
- identity/contact (name, email, phone, github, linkedin, location) → from profile/cv.
|
||||
- free-text (Why us?, cover-letter, "most impactful thing you've built", etc.) → a concise, honest, concrete answer in the candidate's own voice (no buzzwords, active voice, real metrics only). Keep each under ~120 words.
|
||||
- select/radio → choose the best-matching option using the EXACT option text from the list.
|
||||
- NEVER fill legal / visa / work-authorization / salary / demographic / sensitive fields → set needs_confirmation:true and value:"".
|
||||
|
||||
Output ONLY a compact JSON object mapping each field id → {"value": "...", "needs_confirmation": boolean}. No prose, no markdown, no code fence.`;
|
||||
|
||||
log(`Form: "${s.title}" · ${s.fields.length} fields · prompt ${prompt.length} chars · memory ${mem.length} chars`);
|
||||
log(`Planner: ${cliId} (${binPath})`);
|
||||
|
||||
const isClaude = cliId === "claude";
|
||||
// --strict-mcp-config with no --mcp-config = load ZERO MCP servers → much
|
||||
// faster startup (skips the user's global playwright/gmail/linear/… servers
|
||||
// the planner doesn't need; it only reads local files).
|
||||
const args = isClaude
|
||||
? ["-p", prompt, "--permission-mode", "acceptEdits", "--strict-mcp-config", "--allowedTools", "Read,Glob,Grep", "--disallowedTools", "Bash,Write,Edit,NotebookEdit,Task,WebFetch,WebSearch"]
|
||||
: spec.args(prompt);
|
||||
// Scale the timeout with form size (big forms = more drafting). Cap < maxDuration.
|
||||
const killMs = Math.min(300_000, 150_000 + s.fields.length * 6_000);
|
||||
log(`Spawning planner (timeout ${Math.round(killMs / 1000)}s)…`);
|
||||
|
||||
const result = await new Promise<{ buf: string; code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
||||
// stdin = /dev/null so the CLI doesn't wait 3s for piped input.
|
||||
const child = spawn(binPath, args, { cwd: careerOpsRoot(), env: process.env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let buf = "";
|
||||
let firstByteAt = 0;
|
||||
const hb = setInterval(() => {
|
||||
log(`…running ${Math.round((Date.now() - t0) / 1000)}s · ${buf.length} chars received`);
|
||||
}, 4000);
|
||||
child.stdout.on("data", (d: Buffer) => {
|
||||
if (!firstByteAt) {
|
||||
firstByteAt = Date.now();
|
||||
log(`first output byte at ${Math.round((firstByteAt - t0) / 1000)}s`);
|
||||
}
|
||||
buf += d.toString();
|
||||
});
|
||||
child.stderr.on("data", (d: Buffer) => {
|
||||
const e = d.toString().trim();
|
||||
if (e) log(`stderr: ${e.slice(0, 160).replace(/\s+/g, " ")}`);
|
||||
});
|
||||
const killer = setTimeout(() => {
|
||||
log("TIMEOUT reached → SIGTERM");
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, killMs);
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(killer);
|
||||
clearInterval(hb);
|
||||
resolve({ buf, code, signal });
|
||||
});
|
||||
child.on("error", (e) => {
|
||||
clearTimeout(killer);
|
||||
clearInterval(hb);
|
||||
log(`spawn error: ${e.message}`);
|
||||
resolve({ buf, code: null, signal: null });
|
||||
});
|
||||
});
|
||||
|
||||
log(`Planner exited code=${result.code} signal=${result.signal} · ${result.buf.length} chars total`);
|
||||
log(`output head: ${result.buf.slice(0, 100).replace(/\s+/g, " ") || "(empty)"}`);
|
||||
log(`output tail: ${result.buf.slice(-100).replace(/\s+/g, " ") || "(empty)"}`);
|
||||
|
||||
if (!result.buf.trim()) {
|
||||
return fail(result.signal ? "planner was killed before producing any output (try again / smaller form)" : "planner produced no output (check the CLI works in this folder)");
|
||||
}
|
||||
|
||||
const { obj, truncated } = extractJsonObject(result.buf);
|
||||
if (!obj) {
|
||||
return fail(
|
||||
result.signal ? "planner was killed mid-answer (form too large/slow) — couldn't recover any fields" : "couldn't parse the planner's answer as JSON",
|
||||
result.buf.slice(-300),
|
||||
);
|
||||
}
|
||||
const count = Object.keys(obj).length;
|
||||
log(`Parsed ${count} answers${truncated ? " (RECOVERED from truncated output — some fields may be missing)" : ""}`);
|
||||
emit({ t: "done", answers: obj, truncated, count });
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, { headers: { "Content-Type": "application/x-ndjson", "Cache-Control": "no-store" } });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { openSession } from "@/lib/apply/session";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 300; // the agentic drive + interpretation fallbacks spawn a planner
|
||||
|
||||
// Open a persistent apply session: headed-but-off-screen Chrome opens the real
|
||||
// form, we extract + tag its fields. The session stays open for fill + handoff.
|
||||
// cliId enables the agentic fallback (the AI interprets the live form) when
|
||||
// deterministic extraction is low-confidence.
|
||||
export async function POST(req: Request) {
|
||||
let body: { url?: string; cliId?: string; agent?: boolean; _noApplyBtn?: boolean };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "bad json" }, { status: 400 });
|
||||
}
|
||||
const url = (body.url ?? "").trim();
|
||||
if (!/^https?:\/\//i.test(url)) return Response.json({ error: "A valid application URL (https://…) is required" }, { status: 400 });
|
||||
try {
|
||||
const session = await openSession(url, body.cliId, body.agent, body._noApplyBtn);
|
||||
return Response.json(session);
|
||||
} catch (e) {
|
||||
return Response.json({ error: e instanceof Error ? e.message.slice(0, 200) : "could not open the form" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { resolveCli } from "@/lib/clis";
|
||||
import { careerOpsRoot, readMemory, doctorState } from "@/lib/career-ops";
|
||||
|
||||
export const runtime = "nodejs"; // child_process (spawn) requires the Node runtime
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 120;
|
||||
|
||||
const SYSTEM_PREAMBLE = `You are the career-ops assistant — a proactive, friendly career co-pilot for a person who is actively job-hunting. You live inside their LOCAL career-ops web dashboard (a pipeline of evaluated jobs, A–F reports, their CV, analytics) and run on their own AI CLI.
|
||||
|
||||
YOUR MISSION: genuinely help THIS person land a great role. Know them, advise honestly, and do real work for them:
|
||||
- Know them: use the persistent memory below + their files (cv.md, config/profile.yml, reports/, data/applications.md, and past worker logs in .career-ops-web/runs/{id}.md). Read them to be concrete.
|
||||
- Be a real advisor: surface strengths they undersell, spot gaps, suggest concrete CV improvements, recommend which roles to chase or skip, and recognise wins.
|
||||
|
||||
YOU CAN ACT — you do it by emitting ACTION ENVELOPES inside your reply. An envelope is ONE line, on its own line (never inside a code fence):
|
||||
<<act:ACTION_ID {"arg":"value"}>>
|
||||
The args are a single JSON object. The dashboard parses the envelope and performs the action (you won't see its output) — so just say briefly what you're doing, then emit the envelope.
|
||||
|
||||
ACTIONS:
|
||||
- navigate {"path":"/pipeline?tab=OFFER&min=4"} — take the user to a section. Valid paths: /, /pipeline, /portals, /analytics, /cv, /config, /apply, /pipeline/{n} (a report), /jobs/{id} (a worker). The path may carry a query string.
|
||||
- filterPipeline {"tab":"OFFER","min":4,"q":"text","sort":"score","dir":-1} — filter the pipeline table in place. tab ∈ INBOX, ALL, EVALUATED, APPLIED, RESPONDED, INTERVIEW, OFFER, REJECTED, DISCARDED, SKIP; min = score floor 0–5.
|
||||
- evaluate {"url":"https://…","title":"Evaluate · Acme","subtitle":"Role"} — spin ONE read-only evaluation worker on a SPECIFIC posting URL. Only when you actually have a real URL (e.g. from the page the user is on).
|
||||
- evaluateCompany {"company":"Anthropic"} — evaluate ALL of the user's PENDING inbox postings for that company. Emit the COMPANY NAME ONLY — never URLs; the app resolves the concrete postings itself. Big batches ask the user to confirm first.
|
||||
- research {"target":"https://… or 'my portfolio'","title":"Research · X"} — spin a read-only research worker.
|
||||
- generatePdf {"n":"42"} — generate an ATS-optimized CV tailored to application #42 (runs the real pdf mode → output/ + marks the tracker PDF column). Spends tokens.
|
||||
- setStatus {"n":"42","status":"Applied"} — move a tracked application to a new state (asks the user to confirm first). Canonical states: Evaluated, Applied, Responded, Interview, Offer, Rejected, Discarded, SKIP. Use the application number (the "#42" on its report page).
|
||||
- apply {"url":"https://…"} — open the apply form-proxy for a posting URL (we re-render the real form in plain language; the user verifies and submits it themselves — never auto-submit).
|
||||
- setApplyField {"field":"Why this role?","value":"<the answer>"} — write or revise an answer in the apply form the user is filling (only when an APPLY FORM is shown in your context). Use the field's label or id. When the user asks to make an answer shorter/sharper/etc, generate the new text and emit this.
|
||||
- remember {"fact":"the concise fact"} — durably remember a preference/fact about the user (carries across sessions and across whichever CLI runs).
|
||||
- setProfile {"name":"…","email":"…","location":"…","roles":["AI Engineer","ML Engineer"],"compMin":70000,"compMax":95000,"currency":"EUR","remote":"Remote (EU)","seniority":"Senior"} — PROPOSE the user's profile; the app shows a confirm card and ONLY on their OK writes config/profile.yml (merge-safe — it never clobbers their other fields) AND seeds the free scanner from the roles. Emit only fields you're confident about (most come from their CV). NEVER write a profile they didn't approve.
|
||||
- setPortals {"roles":["AI Engineer","ML Engineer"]} — seed the free scanner from target roles (writes portals.yml title_filter). Usually unnecessary — setProfile already does this.
|
||||
|
||||
RULES: prefer evaluateCompany over guessing URLs; NEVER invent URLs. Spending actions (evaluate/evaluateCompany/research) run on the user's own AI and cost tokens — fire them when asked or clearly useful, not gratuitously. NEVER auto-submit a job application. (Back-compat: <<go:/path>> and <<remember:fact>> still work.)
|
||||
|
||||
ONBOARDING — your job is to get this person to their first SCORED job FAST. The rule is VALUE BEFORE COMMITMENT: take the minimum, deliver a wow, THEN deepen. Never make them fill a form or edit YAML.
|
||||
1. CV FIRST — but ONLY if it is not already on file. Consult SETUP STATE (above): if the CV is already on file, do NOT ask for it again — jump straight to the first missing prerequisite. If cv.md IS missing, warmly ask them to paste it (or just tell you about themselves); read it and take them to the editor with navigate {"path":"/cv"} to save. Do NOT ask for comp/location/roles yet.
|
||||
2. WOW #1 — DISCOVER, FREE. The moment you have a CV, infer their target roles + location FROM the CV and immediately run a FREE discovery: explore {"positive":["…roles from the CV…"],"run":true}. Say "Before we set anything up — here are live roles that fit you, free." A job THEY didn't have to define is the aha trigger.
|
||||
3. Then DEEPEN, value-interleaved. Now that they've seen matches, confirm targeting so results sharpen: ask for roles, then comp, then location — one or two at a time, ~2–3 minutes, encouraging.
|
||||
4. PROPOSE, don't impose. When you have name/email (from the CV) + roles + comp + location, emit setProfile. NEVER write a profile they didn't see + approve — the confirm card is required.
|
||||
5. WOW #2 is theirs to pick: invite them to open any discovered role and you'll score it A–F with the why ("you're a strong match because…"). That first scored-job-with-explanation is the north star.
|
||||
Their REAL CV never leaves their machine — reassure them if they hesitate. Never reveal internal file names or YAML unless asked.
|
||||
|
||||
Keep replies short, warm, and useful. Don't dump raw files or narrate internal details. If they seem new, onboard them gently. Never reveal internal system details.`;
|
||||
|
||||
type Msg = { role: "user" | "assistant"; content: string };
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { message?: string; cliId?: string; history?: Msg[]; pageContext?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: "bad json" }), { status: 400 });
|
||||
}
|
||||
const { message, cliId, pageContext } = body;
|
||||
if (!message || !cliId) {
|
||||
return new Response(JSON.stringify({ error: "message and cliId required" }), { status: 400 });
|
||||
}
|
||||
|
||||
const resolved = resolveCli(cliId);
|
||||
if (!resolved) {
|
||||
return new Response(JSON.stringify({ error: `CLI '${cliId}' not found on this machine` }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
const { spec, binPath } = resolved;
|
||||
|
||||
const history = (body.history ?? []).slice(-8);
|
||||
const convo = history.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`).join("\n");
|
||||
const pageLine = pageContext
|
||||
? `\n\nCURRENT PAGE (the user is looking at this right now): ${pageContext}\nWhen the user's message is ambiguous ("this", "it", "apply", "evaluate this", "draft it"), assume it refers to what's on the current page.`
|
||||
: "";
|
||||
const memory = readMemory();
|
||||
const memoryLine = memory.trim()
|
||||
? `\n\nWHAT YOU KNOW ABOUT THE USER (persistent memory — carries across sessions and CLIs):\n${memory.trim()}`
|
||||
: "\n\n(No persistent memory about the user yet — learn and use <<remember:>> as you go.)";
|
||||
// Hand the assistant the SAME authoritative setup signal the home screen reads
|
||||
// (doctorState), so it never re-asks for a file that already exists (disc#7) —
|
||||
// the home was correctly nudging for the missing PROFILE while the assistant,
|
||||
// given no state, restarted its onboarding script and asked for the CV again.
|
||||
const { hasCv, onboardingNeeded, missing } = doctorState();
|
||||
const setupLine = onboardingNeeded
|
||||
? `\n\nSETUP STATE (authoritative — the SAME signal the home screen uses; trust it over guessing, and do NOT re-ask for anything already on file):\n- CV on file (cv.md): ${hasCv ? "YES — do NOT ask for it again; read it to be concrete" : "NO — this is the first thing to collect"}\n- Still missing: ${missing.length ? missing.join(", ") : "nothing"}\nWhen onboarding, START at the first item actually missing. If the CV is already on file, SKIP step 1 entirely and go straight to the next missing prerequisite (usually the profile — target roles, comp, location).`
|
||||
: `\n\nSETUP STATE: this user is fully set up (CV + profile + scanner all on file). Do NOT run onboarding or ask for a CV — just help them with what they actually asked.`;
|
||||
const prompt = `${SYSTEM_PREAMBLE}${setupLine}${memoryLine}${pageLine}\n\n--- Conversation ---\n${convo}\nUser: ${message}\nAssistant:`;
|
||||
|
||||
// Claude Code streams token-level deltas via stream-json + partial messages.
|
||||
// Other CLIs: pass their stdout through raw.
|
||||
// The chat CLI is READ-ONLY: all writes go through gated registry actions
|
||||
// (remember → /api/memory, setStatus → /api/status), never the CLI editing
|
||||
// files directly. Scope its tools so it can advise (read) but not blind-write.
|
||||
const isClaude = cliId === "claude";
|
||||
// allowedTools must be COMMA-separated; disallowedTools is the hard guardrail
|
||||
// so the advisor can read (and WebFetch) but never blind-writes or shells out.
|
||||
const args = isClaude
|
||||
? [
|
||||
"-p",
|
||||
prompt,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--include-partial-messages",
|
||||
"--permission-mode",
|
||||
"acceptEdits",
|
||||
"--allowedTools",
|
||||
"Read,WebFetch,Glob,Grep",
|
||||
"--disallowedTools",
|
||||
"Bash,Write,Edit,NotebookEdit,Task",
|
||||
]
|
||||
: spec.args(prompt);
|
||||
|
||||
const child = spawn(binPath, args, { cwd: careerOpsRoot(), env: process.env });
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
// `closed` + kill timer in the OUTER scope so cancel() can flip `closed` before
|
||||
// the child's late handlers run — otherwise they enqueue onto an already-closed
|
||||
// controller and throw an uncaught "Controller is already closed" (see #1155).
|
||||
let closed = false;
|
||||
let killer: ReturnType<typeof setTimeout> | undefined;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
let buf = "";
|
||||
let emitted = false;
|
||||
killer = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 90_000);
|
||||
const safeClose = () => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
if (killer) clearTimeout(killer);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
};
|
||||
const safeEnqueue = (s: string): boolean => {
|
||||
if (closed || !s) return false;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(s));
|
||||
return true;
|
||||
} catch {
|
||||
closed = true; // controller already closed underneath us — stop, never crash
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const emit = (s: string) => {
|
||||
if (safeEnqueue(s)) emitted = true;
|
||||
};
|
||||
|
||||
child.stdout.on("data", (d: Buffer) => {
|
||||
if (closed) return;
|
||||
if (!isClaude) {
|
||||
emit(d.toString());
|
||||
return;
|
||||
}
|
||||
// line-buffered NDJSON → emit only assistant text deltas
|
||||
buf += d.toString();
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) !== -1) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
const obj = JSON.parse(line);
|
||||
if (obj.type === "stream_event" && obj.event?.type === "content_block_delta") {
|
||||
const text = obj.event.delta?.text;
|
||||
if (typeof text === "string") emit(text);
|
||||
}
|
||||
} catch {
|
||||
/* partial / non-json line — skip */
|
||||
}
|
||||
}
|
||||
});
|
||||
child.stderr.on("data", (d: Buffer) => {
|
||||
const s = d.toString();
|
||||
if (/error|not found|denied|fatal/i.test(s)) {
|
||||
safeEnqueue(`\n[${spec.name}] ${s.trim()}\n`);
|
||||
}
|
||||
});
|
||||
child.on("error", (e) => {
|
||||
safeEnqueue(`\n[error launching ${spec.name}: ${e.message}]`);
|
||||
safeClose();
|
||||
});
|
||||
child.on("close", () => {
|
||||
if (!emitted) {
|
||||
safeEnqueue("_(no output — is the CLI authenticated?)_");
|
||||
}
|
||||
safeClose();
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
if (killer) clearTimeout(killer);
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { detectClis } from "@/lib/clis";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Detects which agnostic CLIs are installed on THIS machine (local-first). The
|
||||
// web delegates career-ops to one of these in headless mode, on the user's own
|
||||
// auth/tokens — no API key needed.
|
||||
export async function GET() {
|
||||
return NextResponse.json({ clis: detectClis() });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { careerOpsRoot } from "@/lib/career-ops";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Serve the tailored CV PDF the pdf mode wrote to output/cv-…-{company}-…pdf for
|
||||
// a given offer (matched by company slug, newest first). Inline so it opens in
|
||||
// the browser. Local-first: reads the user's own output/ dir.
|
||||
export async function GET(req: NextRequest) {
|
||||
const company = (req.nextUrl.searchParams.get("company") ?? "").trim();
|
||||
if (!company) return new Response("company required", { status: 400 });
|
||||
// Token-extract instead of replace-then-trim: same slug, and no `-+$`-style
|
||||
// pattern that backtracks polynomially on adversarial input (CodeQL).
|
||||
const slug = (company.toLowerCase().match(/[a-z0-9]+/g) ?? []).join("-");
|
||||
const dir = path.join(careerOpsRoot(), "output");
|
||||
// Match the slug at a token boundary (delimited by non-alphanumerics) so "Meta"
|
||||
// doesn't serve "Metabase"'s tailored CV. The pdf mode names files cv-…-{slug}-….
|
||||
const re = new RegExp(`(^|[^a-z0-9])${slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z0-9]|$)`, "i");
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.toLowerCase().endsWith(".pdf"))
|
||||
.filter((f) => re.test(f.toLowerCase()));
|
||||
} catch {
|
||||
return new Response("no output directory", { status: 404 });
|
||||
}
|
||||
if (!files.length) return new Response("no tailored CV found for this offer", { status: 404 });
|
||||
|
||||
files.sort((a, b) => fs.statSync(path.join(dir, b)).mtimeMs - fs.statSync(path.join(dir, a)).mtimeMs);
|
||||
const file = path.join(dir, files[0]);
|
||||
try {
|
||||
const buf = fs.readFileSync(file);
|
||||
return new Response(new Uint8Array(buf), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/pdf", "Content-Disposition": `inline; filename="${files[0]}"`, "Cache-Control": "no-store" },
|
||||
});
|
||||
} catch {
|
||||
return new Response("could not read the PDF", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveCli } from "@/lib/clis";
|
||||
import { careerOpsRoot } from "@/lib/career-ops";
|
||||
|
||||
// Parse a CV (pasted text or an uploaded PDF) into clean cv.md markdown by running
|
||||
// the USER'S OWN CLI headless — the web never ships a heavyweight parser, and the
|
||||
// real CV NEVER leaves the machine (local-first, PII-safe). This route is a
|
||||
// PROPOSER: it produces candidate markdown only; the actual write to cv.md happens
|
||||
// via the existing POST /api/cv after the user confirms (propose-then-confirm).
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 300;
|
||||
|
||||
// Prefer the CANONICAL core mode (single source of truth — CLI + web parse CVs
|
||||
// identically); fall back to the inline prompt until modes/cv-ingest.md lands
|
||||
// (exactly how the explore route handles a missing discover.md).
|
||||
function readCanonicalMode(): string | null {
|
||||
try {
|
||||
return fs.readFileSync(path.join(careerOpsRoot(), "modes", "cv-ingest.md"), "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ingestPrompt(source: string): string {
|
||||
const mode = readCanonicalMode();
|
||||
if (mode) {
|
||||
return `${mode}\n\n--- HEADLESS OUTPUT CONTRACT (the career-ops WEB is parsing your stream) ---\nFollow the mode above exactly. You are a PROPOSER running headless: emit ONLY the markdown between <<cv:start>> and <<cv:end>> (own lines, never in a code fence), then one <<cv:seed>>{...} line; or <<cv:error>>{"reason":"unreadable"} if you can't read it. Narrate one short line before <<cv:start>>.\n\n${source}`;
|
||||
}
|
||||
// Fallback mirrors the canonical examples/cv-example.md format (the SSOT the
|
||||
// project ships) so a web-parsed CV is the same shape as a hand-written one.
|
||||
return `You convert a person's CV into clean cv.md markdown that EXACTLY mirrors career-ops's reference format.
|
||||
|
||||
FORMAT (match exactly; omit a section if the source lacks it; INVENT NOTHING):
|
||||
\`# CV -- {Full Name}\`
|
||||
then bold contact lines directly under the title (no "Contact" section):
|
||||
\`**Location:** …\` / \`**Email:** …\` / \`**LinkedIn:** …\` / \`**Portfolio:** …\` / \`**GitHub:** …\`
|
||||
\`## Professional Summary\` — a 2-4 line summary, only from facts present.
|
||||
\`## Work Experience\` — each role as: \`### {Company} -- {Location}\`, then \`**{Job Title}**\` on its own line, then \`{Start}-{End or Present}\` on its own line, then bullet points (preserve EVERY quantified achievement verbatim).
|
||||
\`## Projects\` — flat bullets: \`- **{Name}** ({type}) -- {what + hero metric}\`.
|
||||
\`## Education\` — flat bullets: \`- {Degree}, {Institution} ({year})\`.
|
||||
\`## Skills\` — grouped bullets: \`- **{Category}:** {comma list}\`.
|
||||
Use \`--\` (double hyphen), NEVER an em dash (ATS rule). Preserve every company/title/date/metric. Clean, don't rewrite — it's THEIR CV.
|
||||
|
||||
OUTPUT PROTOCOL:
|
||||
- You are a PROPOSER: do NOT write any file. Emit ONLY the markdown wrapped EXACTLY between a line \`<<cv:start>>\` and a line \`<<cv:end>>\` (each on its own line, never inside a code fence).
|
||||
- After \`<<cv:end>>\`, emit ONE more line: \`<<cv:seed>>{"title":"<their current/target role>","roles":["<3-5 role keywords>"],"location":"<their location or 'Remote'>"}\`
|
||||
- If the source is unreadable or empty, emit ONLY: \`<<cv:error>>{"reason":"unreadable"}\` and stop.
|
||||
- Narrate one short line BEFORE \`<<cv:start>>\` (e.g. "Reading your CV…").
|
||||
|
||||
${source}`;
|
||||
}
|
||||
|
||||
const TEXT_SRC = (t: string) => `SOURCE (the user's CV, pasted as text — convert it):\n"""\n${t.slice(0, 24000)}\n"""`;
|
||||
const FILE_SRC = (p: string) => `SOURCE: the user's CV is the file at this local path — READ it with your file/Read tool, then convert it:\n${p}`;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const ctype = req.headers.get("content-type") || "";
|
||||
let cliId = "";
|
||||
let promptSource = "";
|
||||
let tempFile: string | null = null;
|
||||
|
||||
try {
|
||||
if (ctype.includes("application/json")) {
|
||||
const body = (await req.json()) as { text?: string; cliId?: string };
|
||||
cliId = body.cliId || "";
|
||||
const text = (body.text || "").trim();
|
||||
if (!text) return Response.json({ error: "empty cv text" }, { status: 400 });
|
||||
promptSource = TEXT_SRC(text);
|
||||
} else if (ctype.includes("multipart/form-data")) {
|
||||
const form = await req.formData();
|
||||
cliId = String(form.get("cliId") || "");
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) return Response.json({ error: "no file" }, { status: 400 });
|
||||
// Reading a PDF/DOCX from a path needs the CLI's file tool, which only Claude
|
||||
// is granted here. Tell non-Claude users plainly instead of failing opaquely.
|
||||
if (cliId !== "claude" && /\.(pdf|docx)$/i.test(file.name)) {
|
||||
return Response.json({ error: "PDF upload needs Claude Code — paste your CV text instead." }, { status: 400 });
|
||||
}
|
||||
const ext = (file.name.match(/\.[a-z0-9]+$/i)?.[0] || ".pdf").toLowerCase();
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "career-ops-cv-"));
|
||||
tempFile = path.join(dir, `cv${ext}`); // outside the repo, basename-only
|
||||
fs.writeFileSync(tempFile, Buffer.from(await file.arrayBuffer()), { mode: 0o600 }); // PII → owner-only
|
||||
promptSource = FILE_SRC(tempFile);
|
||||
} else {
|
||||
return Response.json({ error: "unsupported content-type" }, { status: 400 });
|
||||
}
|
||||
} catch {
|
||||
return Response.json({ error: "bad request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const resolved = resolveCli(cliId);
|
||||
if (!resolved) {
|
||||
if (tempFile) cleanupTemp(tempFile);
|
||||
return Response.json({ error: `CLI '${cliId}' not found on this machine` }, { status: 404 });
|
||||
}
|
||||
const { spec, binPath } = resolved;
|
||||
const prompt = ingestPrompt(promptSource);
|
||||
const isClaude = cliId === "claude";
|
||||
const args = isClaude
|
||||
? [
|
||||
"-p",
|
||||
prompt,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--include-partial-messages",
|
||||
"--permission-mode",
|
||||
"acceptEdits",
|
||||
"--allowedTools",
|
||||
"Read,Glob,Grep", // read the temp PDF; CANNOT write/edit/shell (proposer)
|
||||
"--disallowedTools",
|
||||
"Bash,Write,Edit,NotebookEdit,Task,WebFetch,WebSearch",
|
||||
]
|
||||
: spec.args(prompt);
|
||||
|
||||
let child;
|
||||
try {
|
||||
child = spawn(binPath, args, { cwd: careerOpsRoot(), env: process.env });
|
||||
} catch (e) {
|
||||
if (tempFile) cleanupTemp(tempFile); // never leak the CV temp if spawn throws sync
|
||||
return Response.json({ error: e instanceof Error ? e.message : "failed to start the CLI" }, { status: 500 });
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
// The `closed` flag + kill timer live in the OUTER scope so the ReadableStream
|
||||
// `cancel()` callback (fired on client disconnect / response teardown) can flip
|
||||
// `closed` BEFORE the child's late close/error/stderr handlers run — otherwise
|
||||
// they enqueue onto an already-closed controller and throw an UNCAUGHT
|
||||
// "Invalid state: Controller is already closed" that crashes the server (#1155).
|
||||
let closed = false;
|
||||
let killer: ReturnType<typeof setTimeout> | undefined;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
let buf = "";
|
||||
let emitted = false;
|
||||
killer = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 240_000);
|
||||
const safeClose = () => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
if (killer) clearTimeout(killer);
|
||||
if (tempFile) cleanupTemp(tempFile);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
};
|
||||
// Every write goes through here: guarded on `closed` AND try/catch'd, so a
|
||||
// lost race with cancel()/close can never throw out of an EventEmitter cb.
|
||||
const safeEnqueue = (s: string): boolean => {
|
||||
if (closed || !s) return false;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(s));
|
||||
return true;
|
||||
} catch {
|
||||
closed = true; // controller already closed underneath us — stop, never crash
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const emit = (s: string) => {
|
||||
if (safeEnqueue(s)) emitted = true;
|
||||
};
|
||||
|
||||
child.stdout.on("data", (d: Buffer) => {
|
||||
if (closed) return;
|
||||
if (!isClaude) {
|
||||
emit(d.toString());
|
||||
return;
|
||||
}
|
||||
buf += d.toString();
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) !== -1) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
const obj = JSON.parse(line);
|
||||
if (obj.type === "stream_event" && obj.event?.type === "content_block_delta") {
|
||||
const text = obj.event.delta?.text;
|
||||
if (typeof text === "string") emit(text);
|
||||
}
|
||||
} catch {
|
||||
/* partial / non-json line */
|
||||
}
|
||||
}
|
||||
});
|
||||
child.stderr.on("data", (d: Buffer) => {
|
||||
const s = d.toString();
|
||||
if (/error|not found|denied|fatal/i.test(s)) safeEnqueue(`\n[${spec.name}] ${s.trim()}\n`);
|
||||
});
|
||||
child.on("error", (e) => {
|
||||
safeEnqueue(`\n[error launching ${spec.name}: ${e.message}]`);
|
||||
safeClose();
|
||||
});
|
||||
child.on("close", () => {
|
||||
if (!emitted) safeEnqueue("<<cv:error>>{\"reason\":\"no-output\"}");
|
||||
safeClose();
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
closed = true; // a consumer teardown must stop the child handlers from enqueuing
|
||||
if (killer) clearTimeout(killer);
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (tempFile) cleanupTemp(tempFile);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no" },
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupTemp(file: string) {
|
||||
try {
|
||||
fs.rmSync(path.dirname(file), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { careerOpsRoot } from "@/lib/career-ops";
|
||||
import { atomicWriteWithBackup } from "@/lib/core/safe-write";
|
||||
|
||||
function cvPath() {
|
||||
return path.join(careerOpsRoot(), "cv.md");
|
||||
}
|
||||
|
||||
const MAX_CV_BYTES = 200_000;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return NextResponse.json({ content: fs.readFileSync(cvPath(), "utf8"), exists: true });
|
||||
} catch {
|
||||
return NextResponse.json({ content: "", exists: false });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { content?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "bad json" }, { status: 400 });
|
||||
}
|
||||
if (typeof body.content !== "string") {
|
||||
return NextResponse.json({ error: "content required" }, { status: 400 });
|
||||
}
|
||||
if (Buffer.byteLength(body.content, "utf8") > MAX_CV_BYTES) {
|
||||
return NextResponse.json({ error: "CV is too large (over 200KB)" }, { status: 413 });
|
||||
}
|
||||
// DATA_CONTRACT: cv.md is user-layer and gitignored (no git recovery). Never
|
||||
// blind-overwrite — snapshot the prior CV to a .bak first, write atomically.
|
||||
try {
|
||||
const bak = atomicWriteWithBackup(cvPath(), body.content);
|
||||
return NextResponse.json({ ok: true, backedUp: !!bak });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "write failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { careerOpsRoot, rootScript } from "@/lib/career-ops";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Orchestrates the core's own cold-start check (doctor.mjs --json) — the SAME
|
||||
// source of truth the CLI uses to decide onboarding. We never reimplement the
|
||||
// prerequisite list; we read the core's verdict.
|
||||
export async function GET() {
|
||||
const root = careerOpsRoot();
|
||||
const doctor = rootScript("doctor");
|
||||
if (!fs.existsSync(doctor)) {
|
||||
return Response.json({ available: false, onboardingNeeded: false, missing: [], warnings: [] });
|
||||
}
|
||||
const stdout = await new Promise<string>((resolve) => {
|
||||
execFile("node", [doctor, "--json"], { cwd: root, timeout: 10_000 }, (_err, out) => resolve(out || ""));
|
||||
});
|
||||
try {
|
||||
const last = stdout.trim().split("\n").pop() || "{}";
|
||||
const j = JSON.parse(last);
|
||||
return Response.json({ available: true, onboardingNeeded: !!j.onboardingNeeded, missing: j.missing ?? [], warnings: j.warnings ?? [] });
|
||||
} catch {
|
||||
return Response.json({ available: false, onboardingNeeded: false, missing: [], warnings: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { addOffersToPipeline } from "@/lib/core/pipeline";
|
||||
import type { DiscoveredOffer } from "@/lib/explore";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Free + reversible: append chosen discovered offers to data/pipeline.md AND
|
||||
// record them in data/scan-history.tsv, via the core's CANONICAL exported writers
|
||||
// (no parallel writer). No tokens spent.
|
||||
export async function POST(req: NextRequest) {
|
||||
let offers: DiscoveredOffer[] = [];
|
||||
try {
|
||||
const body = (await req.json()) as { offers?: DiscoveredOffer[] };
|
||||
offers = Array.isArray(body.offers) ? body.offers : [];
|
||||
} catch {
|
||||
return Response.json({ added: 0, error: "bad request" }, { status: 400 });
|
||||
}
|
||||
if (offers.length === 0) return Response.json({ added: 0 });
|
||||
|
||||
const result = await addOffersToPipeline(offers);
|
||||
return Response.json(result);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { assembleDedupContext } from "@/lib/core/discover";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// The client fetches this once before opening the AI stream and uses the set as a
|
||||
// silent dedup backstop in the envelope parser (drops any AI candidate whose URL
|
||||
// is already known). Keeps the stream itself pure text/plain.
|
||||
export async function GET() {
|
||||
try {
|
||||
const { urls } = assembleDedupContext();
|
||||
return Response.json({ urls: [...urls] });
|
||||
} catch {
|
||||
return Response.json({ urls: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveCli } from "@/lib/clis";
|
||||
import { careerOpsRoot, readMemory } from "@/lib/career-ops";
|
||||
import { assembleDedupContext } from "@/lib/core/discover";
|
||||
|
||||
// AI search orchestrates modes/discover.md by running the USER'S configured CLI
|
||||
// headless (CLI-agnostic, like the assistant). Web hunting is slow → generous
|
||||
// budget. The agent is a PROPOSER: Write/Edit/Bash are disabled so it structurally
|
||||
// cannot persist; the only writes happen when the user later ADDs a candidate.
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 600;
|
||||
|
||||
const OUTPUT_CONTRACT = `
|
||||
|
||||
--- OUTPUT CONTRACT (the career-ops WEB is parsing your stream) ---
|
||||
Follow modes/discover.md exactly. You are running headless for the web:
|
||||
- You are a PROPOSER — never write a file (Write/Edit/Bash are disabled).
|
||||
- Emit each candidate as ONE line, never inside a code fence:
|
||||
<<offer:{"url":"…","title":"…","company":"…","location":"…","source":"ai-search","why":"…","postedHint":"…","ats":"…","verification":"unconfirmed"}>>
|
||||
Valid JSON, one per line, the moment you're confident — stream them as you go.
|
||||
- Between envelopes, narrate briefly (plain text) what you're searching — shown live as your reasoning.
|
||||
- Be frugal (~3–6 searches, stop at a strong set). EVERY candidate is UNVERIFIED.
|
||||
- Be a GENEROUS FINDER, not a judge: when a constraint (location, seniority, stage) can't be confirmed from the shallow signal, INCLUDE + flag the uncertainty in "why" — don't discard. NEVER score or judge fit; the A–F evaluation does that later, with the full JD.
|
||||
- DEDUP: skip anything already known below; don't re-propose the user's existing companies.
|
||||
`;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { query?: string; cliId?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "bad json" }, { status: 400 });
|
||||
}
|
||||
const query = (body.query || "").trim();
|
||||
const cliId = body.cliId;
|
||||
if (!query || !cliId) return Response.json({ error: "query and cliId required" }, { status: 400 });
|
||||
|
||||
const resolved = resolveCli(cliId);
|
||||
if (!resolved) return Response.json({ error: `CLI '${cliId}' not found on this machine` }, { status: 404 });
|
||||
const { spec, binPath } = resolved;
|
||||
|
||||
// Read the CANONICAL mode at request time — single source of truth, never a
|
||||
// homegrown prompt. Missing (older core) → graceful 400 so the Scan tab stays usable.
|
||||
let mode: string;
|
||||
try {
|
||||
mode = fs.readFileSync(path.join(careerOpsRoot(), "modes", "discover.md"), "utf8");
|
||||
} catch {
|
||||
return Response.json({ code: "MODE_MISSING", error: "AI search needs a newer career-ops — update to enable it." }, { status: 400 });
|
||||
}
|
||||
|
||||
const { lines } = assembleDedupContext();
|
||||
const memory = readMemory();
|
||||
const memoryLine = memory.trim() ? `\n\nWHAT YOU KNOW ABOUT THE USER (persistent memory):\n${memory.trim()}` : "";
|
||||
const knownBlock = lines.length ? `\n\n--- ALREADY KNOWN (dedup — do NOT propose these) ---\n${lines.join("\n")}` : "";
|
||||
const prompt = `${mode}${OUTPUT_CONTRACT}${memoryLine}${knownBlock}\n\n--- USER INTENT ---\n${query}\n`;
|
||||
|
||||
const isClaude = cliId === "claude";
|
||||
const args = isClaude
|
||||
? [
|
||||
"-p",
|
||||
prompt,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--include-partial-messages",
|
||||
"--permission-mode",
|
||||
"acceptEdits",
|
||||
"--allowedTools",
|
||||
"Read,WebFetch,WebSearch,Glob,Grep", // WebSearch ADDED vs the read-only assistant
|
||||
"--disallowedTools",
|
||||
"Bash,Write,Edit,NotebookEdit,Task", // proposer-not-writer, by construction
|
||||
]
|
||||
: spec.args(prompt);
|
||||
|
||||
const child = spawn(binPath, args, { cwd: careerOpsRoot(), env: process.env });
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
// `closed` + kill timer in the OUTER scope so cancel() can flip `closed` before
|
||||
// the child's late handlers run — otherwise they enqueue onto an already-closed
|
||||
// controller and throw an uncaught "Controller is already closed" (see #1155).
|
||||
let closed = false;
|
||||
let killer: ReturnType<typeof setTimeout> | undefined;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
let buf = "";
|
||||
let emitted = false;
|
||||
killer = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 480_000);
|
||||
const safeClose = () => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
if (killer) clearTimeout(killer);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
};
|
||||
const safeEnqueue = (s: string): boolean => {
|
||||
if (closed || !s) return false;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(s));
|
||||
return true;
|
||||
} catch {
|
||||
closed = true; // controller already closed underneath us — stop, never crash
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const emit = (s: string) => {
|
||||
if (safeEnqueue(s)) emitted = true;
|
||||
};
|
||||
|
||||
child.stdout.on("data", (d: Buffer) => {
|
||||
if (closed) return;
|
||||
if (!isClaude) {
|
||||
emit(d.toString());
|
||||
return;
|
||||
}
|
||||
buf += d.toString();
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) !== -1) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
const obj = JSON.parse(line);
|
||||
if (obj.type === "stream_event" && obj.event?.type === "content_block_delta") {
|
||||
const text = obj.event.delta?.text;
|
||||
if (typeof text === "string") emit(text);
|
||||
}
|
||||
} catch {
|
||||
/* partial / non-json line — skip */
|
||||
}
|
||||
}
|
||||
});
|
||||
child.stderr.on("data", (d: Buffer) => {
|
||||
const s = d.toString();
|
||||
if (/error|not found|denied|fatal/i.test(s)) {
|
||||
safeEnqueue(`\n[${spec.name}] ${s.trim()}\n`);
|
||||
}
|
||||
});
|
||||
child.on("error", (e) => {
|
||||
safeEnqueue(`\n[error launching ${spec.name}: ${e.message}]`);
|
||||
safeClose();
|
||||
});
|
||||
child.on("close", () => {
|
||||
if (!emitted) safeEnqueue("_(no output — is the CLI authenticated?)_");
|
||||
safeClose();
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
if (killer) clearTimeout(killer);
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import fs from "node:fs";
|
||||
import { runDiscovery } from "@/lib/core/scan";
|
||||
import { rootScript } from "@/lib/career-ops";
|
||||
import { parseExplorePatch, DEFAULT_FILTERS, type DiscoveredOffer, type ScanEvent } from "@/lib/explore";
|
||||
|
||||
// Discovery is HTTP-bound across many ATS boards; give it room. It is FREE —
|
||||
// zero LLM tokens (the scanner only does HTTP + JSON, and --dry-run writes nothing).
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
body = (await req.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
/* empty body → defaults */
|
||||
}
|
||||
|
||||
const filters = parseExplorePatch(body, DEFAULT_FILTERS);
|
||||
|
||||
// Guard: a data-only checkout (or pre-onboarding) has no scanner. Fail soft.
|
||||
if (!fs.existsSync(rootScript("scan-ats-full"))) {
|
||||
return Response.json(
|
||||
{ error: "The discovery scanner isn't available in this checkout yet." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (obj: unknown) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(JSON.stringify(obj) + "\n"));
|
||||
} catch {
|
||||
/* stream closed */
|
||||
}
|
||||
};
|
||||
send({ kind: "start", ats: filters.ats, sinceDays: filters.sinceDays, limit: filters.limitPerAts, free: true } satisfies ScanEvent);
|
||||
let offers: DiscoveredOffer[] = [];
|
||||
try {
|
||||
offers = await runDiscovery(filters, (e: ScanEvent) => send(e));
|
||||
} catch (err) {
|
||||
send({ kind: "error", message: err instanceof Error ? err.message : "discovery failed" } satisfies ScanEvent);
|
||||
}
|
||||
send({ kind: "done", count: offers.length, offers, cost: { tokens: 0, usd: 0 } } satisfies ScanEvent);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "application/x-ndjson; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { careerOpsRoot } from "@/lib/career-ops";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Append-only follow-up log → data/follow-ups.md (NEVER clobber; the cadence
|
||||
// calculator reads this to advance the schedule). Mirrors how the CLI records a
|
||||
// follow-up. One dated line per logged follow-up.
|
||||
export async function POST(req: Request) {
|
||||
let body: { num?: string | number; company?: string; note?: string };
|
||||
try {
|
||||
body = (await req.json()) as { num?: string | number; company?: string; note?: string };
|
||||
} catch {
|
||||
return Response.json({ error: "bad json" }, { status: 400 });
|
||||
}
|
||||
const company = (body.company || "").toString().trim();
|
||||
if (!company) return Response.json({ error: "company required" }, { status: 400 });
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const num = body.num != null ? `#${body.num} ` : "";
|
||||
const note = (body.note || "Followed up").toString().replace(/[\r\n]+/g, " ").trim();
|
||||
const line = `- ${today} · ${num}${company} — ${note}\n`;
|
||||
|
||||
const file = path.join(careerOpsRoot(), "data", "follow-ups.md");
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
if (!fs.existsSync(file)) fs.writeFileSync(file, "# Follow-ups\n\n", "utf8");
|
||||
fs.appendFileSync(file, line, "utf8");
|
||||
} catch (e) {
|
||||
return Response.json({ error: e instanceof Error ? e.message : "write failed" }, { status: 500 });
|
||||
}
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { careerOpsRoot, rootScript } from "@/lib/career-ops";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// The DEMAND loop: surface follow-ups due, via the core's own
|
||||
// followup-cadence.mjs --json (the SAME calculator the CLI uses) — we never
|
||||
// reimplement the cadence logic, we read its verdict (mirrors /api/doctor).
|
||||
export async function GET() {
|
||||
const script = rootScript("followup-cadence");
|
||||
if (!fs.existsSync(script)) return Response.json({ available: false, metadata: null, entries: [] });
|
||||
const stdout = await new Promise<string>((resolve) => {
|
||||
execFile("node", [script, "--json"], { cwd: careerOpsRoot(), timeout: 12_000 }, (_e, out) => resolve(out || ""));
|
||||
});
|
||||
try {
|
||||
const start = stdout.indexOf("{");
|
||||
const j = JSON.parse(stdout.slice(start));
|
||||
const entries = Array.isArray(j.entries) ? j.entries : [];
|
||||
// Overdue first; cap for the home (full list lives in the tracker).
|
||||
const overdue = entries.filter((e: { status?: string }) => /overdue|urgent/i.test(String(e.status))).slice(0, 8);
|
||||
const top = (overdue.length ? overdue : entries).slice(0, 6);
|
||||
return Response.json({ available: true, metadata: j.metadata ?? null, entries: top });
|
||||
} catch {
|
||||
return Response.json({ available: false, metadata: null, entries: [] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { careerOpsRoot } from "@/lib/career-ops";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Localhost logo proxy + on-disk cache, FOREVER per key. Honors local-first: the
|
||||
// browser never talks to Google directly. Accepts `?domain=` (exact) OR
|
||||
// `?company=` (a name → we guess a handful of likely domains and resolve once).
|
||||
// Each key is fetched at most once ever, then served from .career-ops-web/logo-cache
|
||||
// (a hit, or an empty sentinel for a known miss). On any miss → 404 so the
|
||||
// client's <img onError> falls back to the offline monogram. Because the cache is
|
||||
// keyed by company, once a company's logo resolves it's instant for that card AND
|
||||
// every other card (this search or any future one), forever.
|
||||
|
||||
const DOMAIN_RE = /^[a-z0-9.-]{1,253}\.[a-z]{2,}$/i;
|
||||
|
||||
function cacheDir(): string {
|
||||
return path.join(careerOpsRoot(), ".career-ops-web", "logo-cache");
|
||||
}
|
||||
|
||||
/** Plausible domains for a company name, cheapest/likeliest first. */
|
||||
function companyDomains(company: string): string[] {
|
||||
const paren = company.match(/\(([A-Za-z0-9]{2,12})\)/)?.[1]; // "… (5WPR)"
|
||||
// [^()] (not [^)]) keeps the match unambiguous — no polynomial backtracking on
|
||||
// adversarial inputs full of unclosed parens (CodeQL js/polynomial-redos).
|
||||
const base = company.replace(/\([^()]*\)/g, "").trim();
|
||||
const compact = base.toLowerCase().replace(/&/g, "and").replace(/[^a-z0-9]/g, "");
|
||||
const firstWord = base.toLowerCase().split(/\s+/)[0].replace(/[^a-z0-9]/g, "");
|
||||
const stems = [...new Set([compact, paren?.toLowerCase(), firstWord].filter((s): s is string => !!s && s.length >= 2 && s.length <= 30))];
|
||||
const out: string[] = [];
|
||||
for (const t of [".com", ".ai", ".io", ".co"]) for (const s of stems) out.push(s + t);
|
||||
return out.slice(0, 5);
|
||||
}
|
||||
|
||||
/** Fetch a real favicon for one domain (Google's tokenless service). Returns the
|
||||
* bytes, or null for a miss (Google serves a tiny globe placeholder for misses). */
|
||||
async function fetchFavicon(domain: string): Promise<ArrayBuffer | null> {
|
||||
try {
|
||||
const res = await fetch(`https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`, {
|
||||
headers: { Accept: "image/*" },
|
||||
signal: AbortSignal.timeout(3500),
|
||||
redirect: "follow",
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const ab = await res.arrayBuffer();
|
||||
return ab.byteLength > 220 ? ab : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const sp = req.nextUrl.searchParams;
|
||||
const domain = (sp.get("domain") ?? "").trim().toLowerCase();
|
||||
const company = (sp.get("company") ?? "").trim();
|
||||
|
||||
let key: string;
|
||||
let candidates: string[];
|
||||
if (domain) {
|
||||
if (!DOMAIN_RE.test(domain) || domain.includes("..")) return new Response("bad domain", { status: 400 });
|
||||
key = domain.replace(/[^a-z0-9.-]/g, "_");
|
||||
candidates = [domain];
|
||||
} else if (company) {
|
||||
const slug = company.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 40);
|
||||
if (!slug) return new Response("bad company", { status: 400 });
|
||||
key = `co_${slug}`;
|
||||
candidates = companyDomains(company);
|
||||
if (candidates.length === 0) return new Response("no logo", { status: 404 });
|
||||
} else {
|
||||
return new Response("need domain or company", { status: 400 });
|
||||
}
|
||||
|
||||
// `key` is already sanitized above, but enforce containment anyway: a cache
|
||||
// path must never resolve outside the cache dir (defense in depth).
|
||||
const file = path.resolve(cacheDir(), `${key}.png`);
|
||||
if (!file.startsWith(path.resolve(cacheDir()) + path.sep)) return new Response("bad key", { status: 400 });
|
||||
|
||||
// 1) serve from disk cache forever (hit, or empty sentinel = known miss)
|
||||
try {
|
||||
const buf = await fs.readFile(file);
|
||||
if (buf.byteLength > 0) {
|
||||
return new Response(new Uint8Array(buf), { status: 200, headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=604800" } });
|
||||
}
|
||||
return new Response("no logo", { status: 404 });
|
||||
} catch {
|
||||
/* not cached yet → resolve below */
|
||||
}
|
||||
|
||||
// 2) resolve once: first candidate domain that yields a real favicon wins
|
||||
let bytes: ArrayBuffer | null = null;
|
||||
for (const d of candidates) {
|
||||
bytes = await fetchFavicon(d);
|
||||
if (bytes) break;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.mkdir(cacheDir(), { recursive: true });
|
||||
await fs.writeFile(file, bytes ? Buffer.from(bytes) : new Uint8Array(0)); // sentinel on miss → never refetch
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
if (!bytes) return new Response("no logo", { status: 404 });
|
||||
return new Response(bytes, { status: 200, headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=604800" } });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readMemory, rememberFact } from "@/lib/career-ops";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ memory: readMemory() });
|
||||
}
|
||||
|
||||
// Append a durable fact the assistant learned about the user. Written to the
|
||||
// CANONICAL modes/_profile.md (single source of truth) so the CLI/TUI see it too
|
||||
// — never a web-only memory store.
|
||||
export async function POST(req: Request) {
|
||||
let b: { fact?: string };
|
||||
try {
|
||||
b = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "bad json" }, { status: 400 });
|
||||
}
|
||||
const fact = (b.fact ?? "").toString();
|
||||
if (!fact.trim()) return NextResponse.json({ error: "fact required" }, { status: 400 });
|
||||
|
||||
const result = rememberFact(fact);
|
||||
if (result === "error") return NextResponse.json({ error: "write failed" }, { status: 500 });
|
||||
return NextResponse.json({ ok: true, deduped: result === "deduped" });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { pipelineSummary } from "@/lib/career-ops";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic"; // always read fresh local files
|
||||
|
||||
// Exposes the user's pipeline (inbox + tracker) to the client so the assistant
|
||||
// can resolve "all the Anthropic ones" to concrete postings CLIENT-SIDE — the
|
||||
// model only ever emits a company name, never URLs (no hallucination, no tokens).
|
||||
export async function GET() {
|
||||
const s = pipelineSummary();
|
||||
return Response.json({
|
||||
inbox: s.inbox,
|
||||
applications: s.applications,
|
||||
root: s.root,
|
||||
rootExists: s.rootExists,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import yaml from "js-yaml";
|
||||
import { careerOpsRoot } from "@/lib/career-ops";
|
||||
import { atomicWriteWithBackup } from "@/lib/core/safe-write";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Merge-safe writer for portals.yml's title_filter (a USER-LAYER file). Replaces
|
||||
// ONLY title_filter.positive (the role keywords the free scanner matches), seeding
|
||||
// from templates/portals.example.yml on first create, and PRESERVING tracked_companies
|
||||
// + every other block. Atomic write, confirm-gated (setProfile/setPortals). This is
|
||||
// what loads the very first home scan once the user confirms their target roles.
|
||||
|
||||
function isObj(v: unknown): v is Record<string, unknown> {
|
||||
return !!v && typeof v === "object" && !Array.isArray(v);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { roles?: string[]; location?: string[] };
|
||||
try {
|
||||
body = (await req.json()) as { roles?: string[]; location?: string[] };
|
||||
} catch {
|
||||
return Response.json({ error: "bad json" }, { status: 400 });
|
||||
}
|
||||
const roles = (Array.isArray(body.roles) ? body.roles : []).map((r) => String(r).trim()).filter(Boolean).slice(0, 24);
|
||||
if (roles.length === 0) return Response.json({ error: "no roles" }, { status: 400 });
|
||||
|
||||
const root = careerOpsRoot();
|
||||
const file = path.join(root, "portals.yml");
|
||||
let doc: Record<string, unknown> = {};
|
||||
try {
|
||||
doc = (yaml.load(fs.readFileSync(file, "utf8")) as Record<string, unknown>) || {};
|
||||
} catch {
|
||||
try {
|
||||
doc = (yaml.load(fs.readFileSync(path.join(root, "templates", "portals.example.yml"), "utf8")) as Record<string, unknown>) || {};
|
||||
} catch {
|
||||
doc = {};
|
||||
}
|
||||
}
|
||||
|
||||
const tf = isObj(doc.title_filter) ? { ...doc.title_filter } : {};
|
||||
tf.positive = roles; // replace ONLY the positive keywords; keep negative/etc.
|
||||
doc.title_filter = tf;
|
||||
if (Array.isArray(body.location) && body.location.length) {
|
||||
const lf = isObj(doc.location_filter) ? { ...doc.location_filter } : {};
|
||||
lf.allow = body.location.map((l) => String(l).trim()).filter(Boolean);
|
||||
doc.location_filter = lf;
|
||||
}
|
||||
|
||||
try {
|
||||
atomicWriteWithBackup(file, yaml.dump(doc, { lineWidth: 100, noRefs: true }));
|
||||
} catch (e) {
|
||||
return Response.json({ error: e instanceof Error ? e.message : "write failed" }, { status: 500 });
|
||||
}
|
||||
return Response.json({ ok: true, roles: roles.length });
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { careerOpsRoot, rootScript } from "@/lib/career-ops";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 120;
|
||||
|
||||
// Orchestrates the core's verify-portals.mjs (#1016) — the SAME ATS-slug
|
||||
// validator the CLI uses. Catches the silent 404s that quietly drop a company
|
||||
// from every future scan (= lost offers). We parse its console output; we do NOT
|
||||
// reimplement the validation.
|
||||
const STATUS: Record<string, "live" | "empty" | "broken" | "skipped"> = {
|
||||
"✅": "live",
|
||||
"🟡": "empty",
|
||||
"❌": "broken",
|
||||
"➖": "skipped",
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const root = careerOpsRoot();
|
||||
const verifyPortals = rootScript("verify-portals");
|
||||
if (!fs.existsSync(verifyPortals)) {
|
||||
return Response.json({ available: false, configured: false, companies: [] });
|
||||
}
|
||||
if (!fs.existsSync(path.join(root, "portals.yml"))) {
|
||||
return Response.json({ available: true, configured: false, companies: [] });
|
||||
}
|
||||
|
||||
const stdout = await new Promise<string>((resolve) => {
|
||||
execFile(
|
||||
"node",
|
||||
[verifyPortals],
|
||||
{ cwd: root, timeout: 110_000, maxBuffer: 4 * 1024 * 1024 },
|
||||
(_e, out, err) => resolve((out || "") + (err || "")),
|
||||
);
|
||||
});
|
||||
|
||||
const companies: { name: string; status: string; detail: string }[] = [];
|
||||
for (const line of stdout.split("\n")) {
|
||||
const m = line.match(/^\s*(✅|🟡|❌|➖)\s+(.+?)\s+—\s+(.*)$/);
|
||||
if (m) companies.push({ name: m[2].trim(), status: STATUS[m[1]] ?? "unknown", detail: m[3].trim() });
|
||||
}
|
||||
return Response.json({ available: true, configured: true, companies });
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import yaml from "js-yaml";
|
||||
import { careerOpsRoot } from "@/lib/career-ops";
|
||||
import { atomicWriteWithBackup } from "@/lib/core/safe-write";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Merge-safe writer for config/profile.yml (a USER-LAYER file — DATA_CONTRACT:
|
||||
// never clobber the user's archetypes/narrative/proof-points). On first create we
|
||||
// seed from config/profile.example.yml; on an existing file we deep-merge ONLY the
|
||||
// proposed keys, write atomically (temp + rename), and only ever via the confirm-
|
||||
// gated setProfile action. The web orchestrates the real file — no parallel store.
|
||||
|
||||
type ProfilePatch = {
|
||||
name?: string;
|
||||
email?: string;
|
||||
location?: string;
|
||||
roles?: string[];
|
||||
compMin?: number;
|
||||
compMax?: number;
|
||||
currency?: string;
|
||||
remote?: string;
|
||||
};
|
||||
|
||||
function isObj(v: unknown): v is Record<string, unknown> {
|
||||
return !!v && typeof v === "object" && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/** Deep-merge src onto dst (objects recurse; arrays/scalars replace). Non-mutating. */
|
||||
function deepMerge(dst: unknown, src: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = isObj(dst) ? { ...dst } : {};
|
||||
for (const [k, v] of Object.entries(src)) {
|
||||
out[k] = isObj(v) ? deepMerge(out[k], v) : v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function patchToProfile(p: ProfilePatch): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
const candidate: Record<string, unknown> = {};
|
||||
if (p.name) candidate.full_name = p.name;
|
||||
if (p.email) candidate.email = p.email;
|
||||
if (p.location) candidate.location = p.location;
|
||||
if (Object.keys(candidate).length) out.candidate = candidate;
|
||||
if (p.roles?.length) out.target_roles = { primary: p.roles.slice(0, 6) };
|
||||
const comp: Record<string, unknown> = {};
|
||||
if (p.compMin && p.compMax) comp.target_range = `${p.compMin}-${p.compMax}`;
|
||||
if (p.currency) comp.currency = p.currency;
|
||||
if (p.remote) comp.location_flexibility = p.remote;
|
||||
if (Object.keys(comp).length) out.compensation = comp;
|
||||
// seniority intentionally not written (no canonical home in profile.yml);
|
||||
// archetypes/narrative live in modes/_profile.md — this writer never touches them.
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let patch: ProfilePatch;
|
||||
try {
|
||||
patch = (await req.json()) as ProfilePatch;
|
||||
} catch {
|
||||
return Response.json({ error: "bad json" }, { status: 400 });
|
||||
}
|
||||
const proposed = patchToProfile(patch);
|
||||
if (Object.keys(proposed).length === 0) return Response.json({ error: "nothing to write" }, { status: 400 });
|
||||
|
||||
const root = careerOpsRoot();
|
||||
const file = path.join(root, "config", "profile.yml");
|
||||
let base: Record<string, unknown> = {};
|
||||
let seeded = false;
|
||||
// DATA-LOSS GUARD (maintainer, bug-class #649/#704/#920/#958): distinguish
|
||||
// "no profile yet" (safe to seed from the example) from "profile EXISTS but is
|
||||
// malformed" (NEVER overwrite — that would silently destroy the user's data).
|
||||
if (!fs.existsSync(file)) {
|
||||
try {
|
||||
base = (yaml.load(fs.readFileSync(path.join(root, "config", "profile.example.yml"), "utf8")) as Record<string, unknown>) || {};
|
||||
seeded = Object.keys(base).length > 0;
|
||||
} catch {
|
||||
base = {};
|
||||
}
|
||||
} else {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = yaml.load(fs.readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
return Response.json({ error: "config/profile.yml exists but is not valid YAML — refusing to overwrite it." }, { status: 409 });
|
||||
}
|
||||
base = isObj(parsed) ? (parsed as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
const merged = deepMerge(base, proposed);
|
||||
try {
|
||||
// Back up the prior profile before the first normalized write (yaml.dump
|
||||
// reformats — comments are not preserved; the .bak is the safety net).
|
||||
atomicWriteWithBackup(file, yaml.dump(merged, { lineWidth: 100, noRefs: true }));
|
||||
} catch (e) {
|
||||
return Response.json({ error: e instanceof Error ? e.message : "write failed" }, { status: 500 });
|
||||
}
|
||||
return Response.json({ ok: true, seeded });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { careerOpsRoot, doctorState, readApplications, readInbox, trackerCanDelete } from "@/lib/career-ops";
|
||||
import { scannerSupportsJson } from "@/lib/core/scan";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// STRUCTURAL diagnostics for the in-app bug reporter: shapes, counts and
|
||||
// capability probes only — never file contents. A malformed tracker shows up
|
||||
// here as "12 candidate rows, 5 parsed", which is exactly what triage needs,
|
||||
// without a single company name leaving the machine.
|
||||
|
||||
function lineCount(rel: string, predicate: (l: string) => boolean): number {
|
||||
try {
|
||||
return fs
|
||||
.readFileSync(path.join(careerOpsRoot(), rel), "utf8")
|
||||
.split("\n")
|
||||
.filter(predicate).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function dirCount(rel: string, ext: string): number {
|
||||
try {
|
||||
return fs.readdirSync(path.join(careerOpsRoot(), rel)).filter((f) => f.endsWith(ext)).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const doctor = doctorState();
|
||||
// "candidate" = a line that LOOKS like a row; parsed = what the tolerant
|
||||
// reader accepted. A gap between the two is the data-contract fingerprint.
|
||||
const inboxCandidates = lineCount("data/pipeline.md", (l) => /^\s*-\s*\[[ xX]\]/.test(l));
|
||||
const trackerCandidates = lineCount(
|
||||
"data/applications.md",
|
||||
(l) => l.trim().startsWith("|") && !/^\|\s*#\s*\|/.test(l.trim()) && !/^\|\s*:?-{2,}/.test(l.trim()),
|
||||
);
|
||||
return Response.json({
|
||||
runtime: { node: process.version, platform: process.platform, arch: process.arch },
|
||||
setup: {
|
||||
phase: doctor.phase,
|
||||
missing: doctor.missing, // system prereq FILENAMES only (cv.md, portals.yml…)
|
||||
hasCv: doctor.hasCv,
|
||||
hasData: doctor.hasData,
|
||||
},
|
||||
data: {
|
||||
inbox: { candidates: inboxCandidates, parsed: readInbox().length },
|
||||
tracker: { candidates: trackerCandidates, parsed: readApplications().length },
|
||||
reports: dirCount("reports", ".md"),
|
||||
pdfs: dirCount("output", ".pdf"),
|
||||
followupsFile: fs.existsSync(path.join(careerOpsRoot(), "data", "follow-ups.md")),
|
||||
},
|
||||
capabilities: {
|
||||
scanJson: scannerSupportsJson(),
|
||||
trackerDelete: trackerCanDelete(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveCli } from "@/lib/clis";
|
||||
import { careerOpsRoot, readMemory } from "@/lib/career-ops";
|
||||
import { acquireTrackerWrite, releaseTrackerWrite } from "@/lib/core/run-registry";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 800; // a real oferta evaluation / pdf-mode CV tailoring + render is heavy and multi-step
|
||||
|
||||
// The web ORCHESTRATES the real career-ops engine — it does NOT reimplement it.
|
||||
// kind "evaluate" runs the REAL modes/oferta.md and persists the canonical
|
||||
// artifacts (A–F report + tracker row) via the SAME scripts the CLI uses
|
||||
// (reserve-report-num.mjs → reports/ → batch/tracker-additions/ → merge-tracker.mjs),
|
||||
// so a web evaluation is byte-identical to a CLI one (single source of truth, no
|
||||
// drift). kind "research" stays read-only. Streams progress as NDJSON events.
|
||||
function buildPrompt(kind: string, input: string, memory: string, today: string): string {
|
||||
const mem = memory.trim() ? `\n\nDurable notes about the user (from their profile):\n${memory.trim()}\n` : "";
|
||||
if (kind === "research") {
|
||||
return `You are investigating the user's OWN work / portfolio to surface job-search-relevant strengths, headless. Investigate the target (use WebFetch for URLs; read local files if referenced) and report: what it is, why it is impressive, and how to leverage it in their job search — which roles/claims it supports and how to frame it on a CV. Be specific, honest, and encouraging.${mem}
|
||||
|
||||
End with EXACTLY one final line: VERDICT: {0-5 signal strength}/5 — {why it helps their search, ≤12 words}
|
||||
|
||||
Target: ${input}`;
|
||||
}
|
||||
if (kind === "pdf") {
|
||||
return `You are generating the user's ATS-optimized, TAILORED CV PDF for application #${input}, headless, on their machine. Run the REAL career-ops "pdf" mode — follow modes/pdf.md EXACTLY (do not improvise a format).
|
||||
1. Read modes/pdf.md, cv.md, config/profile.yml, and the evaluation report at reports/${input}-*.md (for the JD keywords + analysis).
|
||||
2. Tailor the CV per modes/pdf.md: inject the JD's keywords into the summary + first bullets, reorder experience by relevance, build the competency grid, pick the top 3–4 projects. NEVER invent skills — only reword REAL experience using the JD's vocabulary.
|
||||
3. Fill templates/cv-template.html's {{...}} placeholders with the tailored content; write the HTML to /tmp/cv-{candidate}-{company}.html (candidate = the profile name in kebab-case).
|
||||
4. Render the PDF: \`node generate-pdf.mjs /tmp/cv-{candidate}-{company}.html output/cv-{candidate}-{company}-${today}.pdf --format={letter for US/Canada companies, else a4}\`.
|
||||
5. Update the tracker: in data/applications.md, change the PDF column for row #${input} from ❌ to ✅.
|
||||
Do not submit anything anywhere.
|
||||
|
||||
End with EXACTLY one final line: VERDICT: {5 if the PDF was written, else 1}/5 — {the output/ path, ≤12 words}`;
|
||||
}
|
||||
if (kind === "fix-portal") {
|
||||
return `A company's job-portal ATS slug is BROKEN — career-ops can no longer scan it, so it silently disappears from every future scan. Repair it (headless, on the user's machine):
|
||||
1. Run \`node verify-portals.mjs --add "${input}"\` — it probes Greenhouse/Ashby/Lever for the company's correct ATS slug and prints the suggested ats + slug.
|
||||
2. Open portals.yml, find the "${input}" entry under tracked_companies, and update its careers_url (and any api/slug field) to the suggested WORKING ATS URL. Change ONLY this one company; preserve all other YAML structure, comments and formatting exactly.
|
||||
3. Re-run \`node verify-portals.mjs\` and confirm "${input}" now shows ✅ live (not ❌).
|
||||
If NO slug variant resolves, say so clearly and leave portals.yml unchanged. Never touch any other company.
|
||||
|
||||
End with EXACTLY one final line: VERDICT: {5 if now live, else 1}/5 — {what you changed, ≤12 words}`;
|
||||
}
|
||||
// evaluate (default) — run the REAL oferta mode + persist canonically
|
||||
return `You are running the OFFICIAL career-ops job evaluation, HEADLESS, on the user's own machine. Today is ${today}. Run the REAL career-ops evaluation — do NOT improvise your own scoring.
|
||||
|
||||
1. Read modes/oferta.md and follow it EXACTLY (blocks A–F, G posting-legitimacy, and the Machine Summary). Ground the fit in THIS person: read cv.md, config/profile.yml and modes/_profile.md. Use WebFetch to read the posting (you are headless — Playwright is unavailable, so use WebFetch and mark the report header "Verification: unconfirmed (batch mode)").
|
||||
|
||||
2. Persist the result CANONICALLY so the web and the CLI share ONE source of truth:
|
||||
a. Reserve a report number: run \`node reserve-report-num.mjs\` — its stdout is a 3-digit number (e.g. 035).
|
||||
b. Write the full report to reports/{num}-{company-slug}-${today}.md (company-slug = company lowercased, non-alphanumerics → hyphens).
|
||||
c. Append ONE row of 9 TAB-separated columns to batch/tracker-additions/{num}-{company-slug}.tsv, in THIS exact order (real \\t tabs, status BEFORE score):
|
||||
{num}\t${today}\t{Company}\t{Role}\t{CanonicalStatus e.g. Evaluated}\t{score}/5\t❌\t[{num}](reports/{num}-{company-slug}-${today}.md)\t{one-line note}
|
||||
d. Merge into the tracker: run \`node merge-tracker.mjs\` (it dedupes by company+role+report-num, validates the status, and writes data/applications.md — NEVER edit applications.md by hand).
|
||||
|
||||
3. NEVER submit an application, fill no forms, contact no one. This is evaluation + persistence ONLY.${mem}
|
||||
|
||||
After everything above is written and merged, output EXACTLY one final line, nothing after it:
|
||||
VERDICT: {score}/5 — {reason in 12 words or fewer}
|
||||
|
||||
Posting URL: ${input}`;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { kind?: string; input?: string; cliId?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: "bad json" }), { status: 400 });
|
||||
}
|
||||
const { kind = "evaluate", input, cliId } = body;
|
||||
if (!input || !cliId) {
|
||||
return new Response(JSON.stringify({ error: "input and cliId required" }), { status: 400 });
|
||||
}
|
||||
const resolved = resolveCli(cliId);
|
||||
if (!resolved) {
|
||||
return new Response(JSON.stringify({ error: `CLI '${cliId}' not found` }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
const { spec, binPath } = resolved;
|
||||
|
||||
// These run the REAL core (modes/scripts), not just data — fail clearly if the
|
||||
// root is incomplete instead of faking it.
|
||||
const needsScript: Record<string, string> = { evaluate: "modes/oferta.md", "fix-portal": "verify-portals.mjs", pdf: "generate-pdf.mjs" };
|
||||
const required = needsScript[kind];
|
||||
if (required && !fs.existsSync(path.join(careerOpsRoot(), required))) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: `This needs a complete career-ops checkout (${required}). CAREER_OPS_ROOT has data only — point it at a full checkout.`,
|
||||
}),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// An A–F score is meaningless without a CV to score against — the CLI would
|
||||
// hallucinate a fit narrative and still emit a VERDICT. Require cv.md first.
|
||||
if ((kind === "evaluate" || kind === "pdf") && !fs.existsSync(path.join(careerOpsRoot(), "cv.md"))) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Add your CV first so I can score this against you — drop it on the home page." }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const prompt = buildPrompt(kind, input, readMemory(), today);
|
||||
|
||||
const isClaude = cliId === "claude";
|
||||
// Tool scope by kind (comma-separated lists; disallowedTools is the hard
|
||||
// guardrail). 'evaluate' runs the REAL mode + persists canonical artifacts →
|
||||
// it needs Write + Bash (reserve-report-num / merge-tracker / write the
|
||||
// report). 'research' stays read-only. Task (sub-agents) is always blocked
|
||||
// (runaway cost). NEVER auto-submits — that is a prompt-level guarantee.
|
||||
const tools =
|
||||
kind === "evaluate" || kind === "fix-portal" || kind === "pdf"
|
||||
? { allowed: "Read,WebFetch,WebSearch,Write,Edit,Bash,Glob,Grep", disallowed: "Task,NotebookEdit" }
|
||||
: { allowed: "Read,WebFetch,WebSearch,Glob,Grep", disallowed: "Bash,Write,Edit,NotebookEdit,Task" };
|
||||
const args = isClaude
|
||||
? ["-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages",
|
||||
"--permission-mode", "acceptEdits",
|
||||
"--allowedTools", tools.allowed,
|
||||
"--disallowedTools", tools.disallowed]
|
||||
: spec.args(prompt);
|
||||
|
||||
// For write-needing kinds, snapshot reports/ so we can verify the worker
|
||||
// actually persisted (non-Claude CLIs lack Write auth and silently no-op).
|
||||
const reportsDir = path.join(careerOpsRoot(), "reports");
|
||||
const countReports = () => {
|
||||
try {
|
||||
return fs.readdirSync(reportsDir).filter((f) => f.endsWith(".md")).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
const persists = kind === "evaluate";
|
||||
const reportsBefore = persists ? countReports() : 0;
|
||||
// Tracker-mutating runs hold a write token so a row delete can't race their merge
|
||||
// (tracker.mjs delete doesn't yet share a lock with merge-tracker — see run-registry).
|
||||
const writeToken = kind === "evaluate" || kind === "pdf" ? acquireTrackerWrite() : null;
|
||||
|
||||
const child = spawn(binPath, args, { cwd: careerOpsRoot(), env: process.env });
|
||||
const enc = new TextEncoder();
|
||||
|
||||
// `closed` + kill timer in the OUTER scope so cancel() (client disconnect) can
|
||||
// flip `closed` before the child's late handlers run, and send() is try/catch'd —
|
||||
// otherwise a late enqueue onto a closed controller throws uncaught (see #1155).
|
||||
let closed = false;
|
||||
let killer: ReturnType<typeof setTimeout> | undefined;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
let buf = "";
|
||||
let emittedText = false; // any assistant text delta → the CLI actually ran
|
||||
let sawError = false;
|
||||
let lastTokens = 0; // per-run token cost from the Claude result event (#6) — local only
|
||||
let lastCostUsd: number | null = null;
|
||||
// pdf-mode tailors a full CV + renders it — give it more headroom.
|
||||
const killMs = kind === "pdf" ? 720_000 : 285_000;
|
||||
killer = setTimeout(() => {
|
||||
try { child.kill("SIGTERM"); } catch { /* ignore */ }
|
||||
}, killMs);
|
||||
const send = (obj: unknown) => {
|
||||
if (closed) return;
|
||||
try { controller.enqueue(enc.encode(JSON.stringify(obj) + "\n")); } catch { closed = true; }
|
||||
};
|
||||
const close = () => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
if (killer) clearTimeout(killer);
|
||||
if (writeToken !== null) releaseTrackerWrite(writeToken);
|
||||
try { controller.close(); } catch { /* */ }
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on("data", (d: Buffer) => {
|
||||
if (closed) return;
|
||||
if (!isClaude) {
|
||||
emittedText = true;
|
||||
send({ type: "text", text: d.toString() });
|
||||
return;
|
||||
}
|
||||
buf += d.toString();
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) !== -1) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
const ev = JSON.parse(line);
|
||||
if (ev.type === "stream_event") {
|
||||
const e = ev.event;
|
||||
if (e?.type === "content_block_start" && e.content_block?.type === "tool_use") {
|
||||
send({ type: "tool", name: e.content_block.name });
|
||||
} else if (e?.type === "content_block_delta" && e.delta?.text) {
|
||||
emittedText = true;
|
||||
send({ type: "text", text: e.delta.text });
|
||||
}
|
||||
} else if (ev.type === "system" && ev.subtype === "init") {
|
||||
send({ type: "status", label: "Agent ready" });
|
||||
} else if (ev.type === "result") {
|
||||
// Capture the per-run cost; the authoritative "done" is sent on close
|
||||
// (so the honesty gate decides done-vs-error first). Tokens = the same
|
||||
// formula /api/usage uses: input + output + cache-creation.
|
||||
const u = ev.usage || {};
|
||||
lastTokens = (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
||||
if (typeof ev.total_cost_usd === "number") lastCostUsd = ev.total_cost_usd;
|
||||
}
|
||||
} catch {
|
||||
/* partial line */
|
||||
}
|
||||
}
|
||||
});
|
||||
child.stderr.on("data", (d: Buffer) => {
|
||||
const s = d.toString();
|
||||
// Widened: auth/login/quota failures are the most common real error and
|
||||
// the old narrow regex missed them (silent false "success").
|
||||
if (/error|denied|fatal|not found|unauthorized|forbidden|auth|login|credential|api[ -]?key|quota|rate limit|not authenticated/i.test(s)) {
|
||||
sawError = true;
|
||||
send({ type: "error", msg: s.trim().slice(0, 200) });
|
||||
}
|
||||
});
|
||||
child.on("error", (e) => { send({ type: "error", msg: e.message }); close(); });
|
||||
child.on("close", (code) => {
|
||||
const wroteReport = countReports() > reportsBefore;
|
||||
const cleanExit = code === 0; // non-zero OR null (killed/signal) = NOT clean
|
||||
// Honesty gate (#9): a green "done" with a parsed score requires a CLEAN exit,
|
||||
// real output, AND (for evaluations) a report actually written. Anything else
|
||||
// is surfaced — an errored run must never be banked as a confident score.
|
||||
if (!emittedText && !sawError && !cleanExit) {
|
||||
send({ type: "error", msg: "The CLI exited with an error — is it installed and authenticated?" });
|
||||
} else if (!emittedText && !sawError) {
|
||||
send({ type: "error", msg: "The CLI produced no output — is it installed and authenticated? (career-ops is best on Claude Code.)" });
|
||||
} else if (persists && !wroteReport) {
|
||||
// The worker ran but never wrote the report/tracker row (e.g. a CLI
|
||||
// without file-write authorization) — surface it instead of a fake score.
|
||||
send({ type: "error", msg: "This evaluation didn't save a report, so it's not in your tracker. Full evaluation is verified on Claude Code." });
|
||||
} else if (!cleanExit || sawError) {
|
||||
// Produced output (maybe even a report) but did NOT finish cleanly — flag it
|
||||
// instead of recording a confident score off a half-finished run.
|
||||
send({ type: "error", msg: "This run hit an error before finishing, so it isn't recorded as a confident result — re-run it to verify." });
|
||||
} else {
|
||||
send({ type: "done", tokens: lastTokens, costUsd: lastCostUsd });
|
||||
}
|
||||
close();
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
if (killer) clearTimeout(killer);
|
||||
if (writeToken !== null) releaseTrackerWrite(writeToken);
|
||||
try { child.kill("SIGTERM"); } catch { /* ignore */ }
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { careerOpsRoot } from "@/lib/career-ops";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Body = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
page?: string;
|
||||
input?: string;
|
||||
result?: { score: number | null; summary: string };
|
||||
steps?: { kind: string; label: string }[];
|
||||
output?: string;
|
||||
};
|
||||
|
||||
// Persist a finished worker's log as markdown under a web-managed dir so the CLI
|
||||
// assistant can read past runs ("what did we find on that Anthropic role?").
|
||||
export async function POST(req: Request) {
|
||||
let b: Body;
|
||||
try {
|
||||
b = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "bad json" }, { status: 400 });
|
||||
}
|
||||
if (!b.id) return NextResponse.json({ error: "id required" }, { status: 400 });
|
||||
|
||||
const dir = path.join(careerOpsRoot(), ".career-ops-web", "runs");
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "mkdir failed" }, { status: 500 });
|
||||
}
|
||||
const safeId = String(b.id).replace(/[^a-z0-9_-]/gi, "");
|
||||
const steps = (b.steps ?? []).map((s) => `- ${s.kind === "tool" ? `🔧 ${s.label}` : s.label}`).join("\n");
|
||||
const verdict = b.result?.score != null ? `${b.result.score}/5 — ${b.result.summary || ""}` : "—";
|
||||
const md = `# Web run · ${b.title || b.id}
|
||||
|
||||
- id: ${b.id}
|
||||
- page: ${b.page || "-"}
|
||||
- input: ${b.input || "-"}
|
||||
- verdict: ${verdict}
|
||||
|
||||
## Steps
|
||||
${steps}
|
||||
|
||||
## Output
|
||||
${b.output || ""}
|
||||
`;
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, `${safeId}.md`), md);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "write failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { careerOpsRoot } from "@/lib/career-ops";
|
||||
import { canonicalizeStatus } from "@/lib/core/states";
|
||||
import { atomicWrite } from "@/lib/core/safe-write";
|
||||
|
||||
// Writeback: UPDATE the status cell of an EXISTING tracker row only. Never adds
|
||||
// rows — per the core data contract, new rows go through the TSV + merge flow.
|
||||
// HARDENED: validate against the 8 canonical states (states.yml SSOT); reject any
|
||||
// value with table-breaking chars (| \r \n **) that would scramble the row; detect
|
||||
// the Status column from the header (8- and 9-col layouts); atomic write.
|
||||
export async function POST(req: Request) {
|
||||
let body: { n?: string; status?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "bad json" }, { status: 400 });
|
||||
}
|
||||
const { n, status } = body;
|
||||
if (!n || typeof status !== "string" || !status.trim()) {
|
||||
return NextResponse.json({ error: "n and status required" }, { status: 400 });
|
||||
}
|
||||
if (/[|\r\n*]/.test(status)) {
|
||||
return NextResponse.json({ error: "invalid status (table-breaking characters)" }, { status: 400 });
|
||||
}
|
||||
const canon = canonicalizeStatus(status);
|
||||
if (!canon) {
|
||||
return NextResponse.json({ error: `not a canonical status: ${status}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = path.join(careerOpsRoot(), "data", "applications.md");
|
||||
let md: string;
|
||||
try {
|
||||
md = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return NextResponse.json({ error: "tracker not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const lines = md.split("\n");
|
||||
// Find the Status column index from the header row (robust to 8- vs 9-col).
|
||||
let statusIdx = 6;
|
||||
for (const l of lines) {
|
||||
if (!l.trim().startsWith("|")) continue;
|
||||
const cells = l.split("|").map((c) => c.trim().toLowerCase());
|
||||
const idx = cells.findIndex((c) => c === "status");
|
||||
if (idx > 0) {
|
||||
statusIdx = idx;
|
||||
break;
|
||||
}
|
||||
if (/^:?-{2,}:?$/.test(cells[1] ?? "")) break; // hit the separator → no header match, keep default
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!lines[i].trim().startsWith("|")) continue;
|
||||
const parts = lines[i].split("|");
|
||||
if (parts.length < 8) continue;
|
||||
if (parts[1].trim() !== String(n)) continue;
|
||||
if (statusIdx >= parts.length - 1) continue; // guard malformed row
|
||||
parts[statusIdx] = ` ${canon} `;
|
||||
lines[i] = parts.join("|");
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
if (!changed) return NextResponse.json({ error: "row not found" }, { status: 404 });
|
||||
|
||||
try {
|
||||
atomicWrite(file, lines.join("\n"));
|
||||
} catch {
|
||||
return NextResponse.json({ error: "write failed" }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ ok: true, status: canon });
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { careerOpsRoot, rootScript, trackerCanDelete } from "@/lib/career-ops";
|
||||
import { isTrackerWriting } from "@/lib/core/run-registry";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Remove ONE application row from applications.md by orchestrating the core
|
||||
// write-gate `tracker.mjs delete --num N` (#1200) — we NEVER hand-edit the file
|
||||
// (atomic write + SQLite reindex + orphan-report report all live in the script).
|
||||
// This is the disc#9 fix: a bogus row (e.g. an evaluation that errored mid-run)
|
||||
// must be removable. `--dry-run` previews; the real delete is irreversible, so the
|
||||
// UI confirms first.
|
||||
|
||||
// One delete at a time (single local process). Real deletes only — dry-runs write
|
||||
// nothing and can overlap.
|
||||
let deleting = false;
|
||||
|
||||
function parseOrphan(stderr: string): string | null {
|
||||
// dry-run: "(report file would be orphaned: <path>)"
|
||||
// real: "Note: report file may now be orphaned — <path>"
|
||||
const m = stderr.match(/orphaned[:—-]+\s*([^\n)]+)\)?\s*$/im);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { n?: string | number; dryRun?: boolean };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "bad json" }, { status: 400 });
|
||||
}
|
||||
const num = String(body.n ?? "").trim();
|
||||
if (!/^\d+$/.test(num)) {
|
||||
return Response.json({ error: "a numeric application number is required" }, { status: 400 });
|
||||
}
|
||||
const dryRun = !!body.dryRun;
|
||||
|
||||
if (!trackerCanDelete()) {
|
||||
return Response.json(
|
||||
{ error: "Removing a tracker row needs a newer career-ops — update to delete rows from here." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
// Serialize: the delete must not run while an evaluation is writing the tracker
|
||||
// (tracker.mjs delete doesn't share a lock with merge-tracker yet).
|
||||
if (isTrackerWriting()) {
|
||||
return Response.json(
|
||||
{ error: "An evaluation is updating your tracker right now — try again in a moment." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (!dryRun && deleting) {
|
||||
return Response.json({ error: "Another delete is already in progress — try again in a moment." }, { status: 409 });
|
||||
}
|
||||
if (!dryRun) deleting = true;
|
||||
|
||||
const args = [rootScript("tracker"), "delete", "--num", num];
|
||||
if (dryRun) args.push("--dry-run");
|
||||
|
||||
try {
|
||||
const result = await new Promise<{ code: number | null; err: string }>((resolve) => {
|
||||
let err = "";
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, args, { cwd: careerOpsRoot(), env: process.env });
|
||||
} catch (e) {
|
||||
resolve({ code: 1, err: e instanceof Error ? e.message : "failed to start tracker.mjs" });
|
||||
return;
|
||||
}
|
||||
child.stderr.on("data", (d: Buffer) => {
|
||||
err += d.toString();
|
||||
});
|
||||
child.stdout.on("data", () => {
|
||||
/* delete reports to stderr; drain stdout so the pipe never stalls */
|
||||
});
|
||||
const killer = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 30_000);
|
||||
child.on("error", (e) => {
|
||||
clearTimeout(killer);
|
||||
resolve({ code: 1, err: e.message });
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(killer);
|
||||
resolve({ code, err });
|
||||
});
|
||||
});
|
||||
|
||||
if (result.code !== 0) {
|
||||
const notFound = /No application numbered/i.test(result.err);
|
||||
return Response.json(
|
||||
{ error: result.err.trim().split("\n")[0] || "delete failed" },
|
||||
{ status: notFound ? 404 : 400 },
|
||||
);
|
||||
}
|
||||
return Response.json({ ok: true, dryRun, orphanReport: parseOrphan(result.err) });
|
||||
} finally {
|
||||
if (!dryRun) deleting = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Reads Claude Code's per-message usage across ALL projects (~/.claude/projects/
|
||||
// **/*.jsonl) and sums tokens in the rolling 5h and 7d windows — the same scope
|
||||
// as the account's rate-limit windows. Cached 60s (the read is heavy).
|
||||
|
||||
type Usage = { window5h: { tokens: number; messages: number }; window7d: { tokens: number; messages: number }; computedAt: number };
|
||||
let cache: { at: number; data: Usage } | null = null;
|
||||
|
||||
function projectsDir(): string {
|
||||
return path.join(os.homedir(), ".claude", "projects");
|
||||
}
|
||||
|
||||
function* walkJsonl(dir: string): Generator<string> {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) yield* walkJsonl(p);
|
||||
else if (e.isFile() && e.name.endsWith(".jsonl")) yield p;
|
||||
}
|
||||
}
|
||||
|
||||
function compute(): Usage {
|
||||
const now = Date.now();
|
||||
const w5 = now - 5 * 3600 * 1000;
|
||||
const w7 = now - 7 * 24 * 3600 * 1000;
|
||||
const cutoffMtime = w7 - 3600 * 1000;
|
||||
let t5 = 0;
|
||||
let t7 = 0;
|
||||
let m5 = 0;
|
||||
let m7 = 0;
|
||||
|
||||
for (const file of walkJsonl(projectsDir())) {
|
||||
let st: fs.Stats;
|
||||
try {
|
||||
st = fs.statSync(file);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (st.mtimeMs < cutoffMtime) continue;
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const line of content.split("\n")) {
|
||||
if (!line.includes('"usage"')) continue;
|
||||
let d: { message?: { usage?: Record<string, number> }; timestamp?: string };
|
||||
try {
|
||||
d = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const u = d.message?.usage;
|
||||
const ts = d.timestamp ? Date.parse(d.timestamp) : NaN;
|
||||
if (!u || Number.isNaN(ts) || ts < w7) continue;
|
||||
const tok = (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
||||
t7 += tok;
|
||||
m7 += 1;
|
||||
if (ts >= w5) {
|
||||
t5 += tok;
|
||||
m5 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { window5h: { tokens: t5, messages: m5 }, window7d: { tokens: t7, messages: m7 }, computedAt: now };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (cache && Date.now() - cache.at < 60_000) return NextResponse.json(cache.data);
|
||||
const data = compute();
|
||||
cache = { at: Date.now(), data };
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
// The WEB build's own version + channel (NOT the user's data checkout) — read from
|
||||
// the repo's VERSION (parent of the web/ cwd). The channel is derived from a
|
||||
// pre-release suffix (`-rc`/`-beta`) so the UI can show a beta banner + the bug
|
||||
// reporter can tag the right release. Invisible to stable installs (the updater
|
||||
// reads VERSION from `main`, which stays stable while the bundle lives on a branch).
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function readVersion(): string {
|
||||
const candidates = [path.join(process.cwd(), "..", "VERSION"), path.join(process.cwd(), "VERSION")];
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
const v = fs.readFileSync(p, "utf8").split(/\s+/)[0].trim();
|
||||
if (v) return v;
|
||||
} catch {
|
||||
/* next candidate */
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function shortSha(): string {
|
||||
try {
|
||||
return execSync("git rev-parse --short HEAD", { cwd: process.cwd(), stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function webVersion(): string {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"));
|
||||
return typeof pkg.version === "string" ? pkg.version : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const coreVersion = readVersion();
|
||||
const web = webVersion();
|
||||
const m = coreVersion.match(/-(rc|beta|alpha|next)\b/i);
|
||||
// Channel precedence: an explicit core pre-release suffix wins (RC installs);
|
||||
// otherwise the web component's own maturity decides — pre-1.0 on main IS the
|
||||
// alpha (release-please versions web/ independently), and the banner/bug-report
|
||||
// stay visible until the web graduates to 1.0.
|
||||
const channel = m ? m[1].toLowerCase() : web && /^0\./.test(web) ? "alpha" : "stable";
|
||||
const version = web ? `web ${web}` : coreVersion;
|
||||
return Response.json({ version, coreVersion, channel, sha: shortSha() });
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { careerOpsRoot, readApplications } from "@/lib/career-ops";
|
||||
import type { DiscoveredOffer } from "@/lib/explore";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// The SUPPLY loop, ZERO tokens: "new matches this week" = roles surfaced by past
|
||||
// free scans (data/scan-history.tsv) in the last N days that the user hasn't
|
||||
// evaluated yet. No scan runs here — it reads the history a past scan already
|
||||
// wrote, so the home stays instant + free (directly answers the #1 token-cost
|
||||
// complaint). cols: url, first_seen, portal, title, company, status, location.
|
||||
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const days = Math.min(30, Math.max(1, Number(new URL(req.url).searchParams.get("days")) || 7));
|
||||
const cutoff = Date.now() - days * 86_400_000;
|
||||
let rows: string[];
|
||||
try {
|
||||
rows = fs.readFileSync(path.join(careerOpsRoot(), "data", "scan-history.tsv"), "utf8").split("\n");
|
||||
} catch {
|
||||
return Response.json({ offers: [], count: 0 });
|
||||
}
|
||||
|
||||
// Companies already evaluated → don't resurface as "new".
|
||||
const evaluated = new Set(readApplications().map((a) => norm(a.company)).filter(Boolean));
|
||||
|
||||
const toOffer = (c: string[]): DiscoveredOffer | null => {
|
||||
const [url, firstSeen, portal, title, company, status, location] = c;
|
||||
if (!url || !/^https?:\/\//i.test(url)) return null;
|
||||
if (status && /skipped|expired/i.test(status)) return null;
|
||||
if (company && evaluated.has(norm(company))) return null;
|
||||
return {
|
||||
url,
|
||||
company: (company || "").trim(),
|
||||
title: (title || "").trim(),
|
||||
location: (location || "").trim(),
|
||||
postedAt: /^\d{4}-\d{2}-\d{2}$/.test(firstSeen || "") ? firstSeen : "",
|
||||
ats: (portal || "").replace(/-full$/, "").trim() || "other",
|
||||
source: "whats-new",
|
||||
};
|
||||
};
|
||||
|
||||
const seen = new Set<string>();
|
||||
const offers: DiscoveredOffer[] = [];
|
||||
let anyDated = false;
|
||||
// Pass 1: recent-by-date (the supply loop).
|
||||
for (let i = rows.length - 1; i >= 1 && offers.length < 24; i--) {
|
||||
const c = rows[i].split("\t");
|
||||
const t = Date.parse(c[1] || "");
|
||||
if (Number.isFinite(t)) anyDated = true;
|
||||
if (!Number.isFinite(t) || t < cutoff) continue;
|
||||
const o = toOffer(c);
|
||||
if (!o || seen.has(o.url)) continue;
|
||||
seen.add(o.url);
|
||||
offers.push(o);
|
||||
}
|
||||
// Fallback for LEGACY scan.mjs histories with no parseable first_seen (every row
|
||||
// would be dropped → a false "all caught up"). Show the most-recent-by-append-order
|
||||
// un-evaluated rows instead, so the supply loop still surfaces something.
|
||||
if (offers.length === 0 && !anyDated) {
|
||||
for (let i = rows.length - 1; i >= 1 && offers.length < 12; i--) {
|
||||
const o = toOffer(rows[i].split("\t"));
|
||||
if (!o || seen.has(o.url)) continue;
|
||||
seen.add(o.url);
|
||||
offers.push(o);
|
||||
}
|
||||
}
|
||||
return Response.json({ offers, count: offers.length });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Send } from "lucide-react";
|
||||
import { ApplyView } from "@/components/apply-view";
|
||||
import { ApplyBackdropMount } from "@/components/apply/apply-backdrop-mount";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function ApplyPage() {
|
||||
return (
|
||||
<div className="relative min-h-screen">
|
||||
{/* full-viewport blurred form wallpaper (behind everything) */}
|
||||
<ApplyBackdropMount />
|
||||
<div className="relative z-10 mx-auto max-w-3xl px-6 py-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Send className="size-6 text-brand" />
|
||||
<h1 className="font-display text-2xl tracking-tight text-landing">Apply</h1>
|
||||
</div>
|
||||
<p className="mt-1.5 max-w-xl text-sm text-muted">
|
||||
career-ops reads the real application form on your machine and re-renders it here in plain language, pre-filled
|
||||
from your CV. You verify every answer — then it fills the real form behind the scenes and you submit it yourself.
|
||||
It never submits for you.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<ApplyView />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ConfigForm } from "@/components/config-form";
|
||||
|
||||
export default function ConfigPage() {
|
||||
return <ConfigForm />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { CvEditor } from "@/components/cv-editor";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function CvPage() {
|
||||
return <CvEditor />;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import fs from "node:fs";
|
||||
import { ExplorerView } from "@/components/explore/explorer-view";
|
||||
import { seedExploreFilters } from "@/lib/core/portals";
|
||||
import { readInbox, readApplications, careerOpsRoot } from "@/lib/career-ops";
|
||||
import { DEFAULT_FILTERS } from "@/lib/explore";
|
||||
|
||||
// Read live data at request time so a bare checkout (or `next build` with no
|
||||
// CAREER_OPS_ROOT) never fails — discovery seeds are best-effort.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function ExplorePage() {
|
||||
let seed: { filters: typeof DEFAULT_FILTERS; seededFrom: string[] } = { filters: DEFAULT_FILTERS, seededFrom: [] };
|
||||
try {
|
||||
seed = seedExploreFilters();
|
||||
} catch {
|
||||
/* bare checkout → defaults */
|
||||
}
|
||||
let rootExists = false;
|
||||
try {
|
||||
rootExists = fs.existsSync(careerOpsRoot());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return (
|
||||
<ExplorerView seed={seed} inboxSnapshot={readInbox()} appsSnapshot={readApplications()} rootExists={rootExists} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
@import "tailwindcss";
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Design tokens — inherited from career-ops-docs. Brand burnt-orange #dd7627
|
||||
(mode-invariant), zinc neutrals, light + dark (Fumadocs neutral values).
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
@theme {
|
||||
--color-brand: hsl(26 73% 51%);
|
||||
--color-brand-200: hsl(33 100% 60%); /* hover / lighter */
|
||||
--color-brand-foreground: #ffffff;
|
||||
--color-brand-soft: hsl(26 73% 51% / 0.12);
|
||||
--color-brand-secondary: #fc7744;
|
||||
|
||||
--font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-serif: var(--font-instrument-serif), ui-serif, Georgia, serif;
|
||||
--font-display: var(--font-instrument-serif), ui-serif, Georgia, serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
/* Semantic surfaces reference runtime vars so they swap with the theme class. */
|
||||
@theme inline {
|
||||
--color-background: var(--bg);
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-hover: var(--surface-hover);
|
||||
--color-border: var(--border);
|
||||
--color-foreground: var(--fg);
|
||||
--color-muted: var(--muted);
|
||||
--color-faint: var(--faint);
|
||||
--color-landing: var(--landing); /* editorial olive (light) / cream (dark) — docs landing-foreground */
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #f7f6f3; /* warm off-white (docs stone tone) */
|
||||
--surface: #ffffff;
|
||||
--surface-hover: #efeeea;
|
||||
--border: hsl(40 9% 86%);
|
||||
--fg: hsl(30 10% 11%); /* warm near-black, high legibility */
|
||||
--muted: hsl(35 9% 34%); /* darker than before for readable body text */
|
||||
--faint: hsl(35 7% 42%); /* AA: ~4.6:1 on #fff (was 50% ≈ 3.9:1, failed small text) */
|
||||
--landing: #59592a; /* olive editorial headline */
|
||||
--dot: 40 8% 42% / 0.09;
|
||||
--pre-bg: #f2f1ed;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #161616;
|
||||
--surface-hover: #232323;
|
||||
--border: #262626;
|
||||
--fg: #fafafa;
|
||||
--muted: #a1a1aa;
|
||||
--faint: #8b8b95; /* AA: ~4.6:1 on #161616 (was #71717a ≈ 3.7:1, failed small text) */
|
||||
--landing: #e4e2d0; /* cream editorial headline */
|
||||
--dot: 240 5% 65% / 0.10;
|
||||
--pre-bg: #111111;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.font-serif-italic {
|
||||
font-family: var(--font-instrument-serif-italic), var(--font-serif);
|
||||
font-style: italic;
|
||||
}
|
||||
/* Ambient dot grid — adapts to the theme via --dot. Pure CSS, 0 DOM. */
|
||||
.dot-bg {
|
||||
background-image: radial-gradient(circle, hsl(var(--dot)) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Animations ported from the docs home ───────────────────────────────── */
|
||||
@keyframes fade-in-delayed {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.animate-fade-in-delayed { animation: fade-in-delayed 700ms ease 200ms both; }
|
||||
|
||||
@keyframes cli-cursor-pulse {
|
||||
0%, 50% { opacity: 1; }
|
||||
50.01%, 100% { opacity: 0; }
|
||||
}
|
||||
.cli-cursor { animation: cli-cursor-pulse 1s infinite; }
|
||||
|
||||
@keyframes terminal-popup-in {
|
||||
from { opacity: 0; transform: translateY(-2.5rem); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-terminal-popup { animation: terminal-popup-in 300ms ease both; }
|
||||
|
||||
/* Worker-pill indeterminate progress bar — a brand sweep (hero aesthetic). */
|
||||
@keyframes job-progress {
|
||||
0% { transform: translateX(-110%); }
|
||||
100% { transform: translateX(320%); }
|
||||
}
|
||||
.job-indeterminate { position: relative; overflow: hidden; }
|
||||
.job-indeterminate::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 35%;
|
||||
background: linear-gradient(90deg, transparent, var(--color-brand), transparent);
|
||||
animation: job-progress 1.3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cli-cursor,
|
||||
.animate-terminal-popup,
|
||||
.animate-fade-in-delayed {
|
||||
animation: none;
|
||||
}
|
||||
.job-indeterminate::after { animation: none; width: 100%; opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* ── Report / CV markdown prose (theme-aware) ───────────────────────────── */
|
||||
.report-prose {
|
||||
color: color-mix(in srgb, var(--fg) 88%, transparent);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.report-prose > :first-child { margin-top: 0; }
|
||||
.report-prose h1, .report-prose h2, .report-prose h3, .report-prose h4 {
|
||||
color: var(--fg);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.report-prose h1 { font-family: var(--font-display); font-size: 1.7rem; margin: 2rem 0 0.75rem; }
|
||||
.report-prose h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.3rem;
|
||||
margin: 2.25rem 0 0.75rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.report-prose h3 { font-weight: 600; font-size: 1.05rem; margin: 1.6rem 0 0.5rem; }
|
||||
.report-prose h4 { font-weight: 600; font-size: 0.95rem; color: var(--muted); margin: 1.25rem 0 0.4rem; }
|
||||
.report-prose p { margin: 0.9rem 0; }
|
||||
.report-prose strong { color: var(--fg); font-weight: 600; }
|
||||
.report-prose a { color: var(--color-brand); text-decoration: none; }
|
||||
.report-prose a:hover { text-decoration: underline; }
|
||||
.report-prose code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
color: var(--color-brand);
|
||||
background: var(--surface-hover);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
.report-prose pre {
|
||||
background: var(--pre-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.report-prose pre code { background: none; color: var(--fg); padding: 0; }
|
||||
.report-prose ul, .report-prose ol { margin: 0.9rem 0; padding-left: 1.4rem; }
|
||||
.report-prose ul { list-style: disc; }
|
||||
.report-prose ol { list-style: decimal; }
|
||||
.report-prose li { margin: 0.35rem 0; }
|
||||
.report-prose li::marker { color: var(--faint); }
|
||||
.report-prose blockquote {
|
||||
border-left: 3px solid var(--color-brand);
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.report-prose hr { border: none; border-top: 1px solid var(--border); margin: 1.75rem 0; }
|
||||
.report-prose table { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin: 1rem 0; }
|
||||
.report-prose thead { border-bottom: 1px solid var(--border); }
|
||||
.report-prose th { text-align: left; font-weight: 600; color: var(--fg); padding: 0.5rem 0.75rem; vertical-align: top; }
|
||||
.report-prose td {
|
||||
padding: 0.55rem 0.75rem;
|
||||
vertical-align: top;
|
||||
border-top: 1px solid var(--border);
|
||||
word-break: break-word;
|
||||
}
|
||||
.report-prose tbody tr:hover { background: color-mix(in srgb, var(--fg) 3%, transparent); }
|
||||
.report-prose img { border-radius: 0.5rem; }
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 96 96" width="96" height="96">
|
||||
<title>career-ops</title>
|
||||
<rect width="96" height="96" rx="14" fill="#D5742E"/>
|
||||
<path d="M 32.407 64.702Q 28.975 64.702 26.206 62.401Q 23.437 60.100 21.799 55.810Q 20.161 51.520 20.161 45.592Q 20.161 38.962 22.150 34.048Q 24.139 29.134 27.571 26.443Q 31.003 23.752 35.215 23.752Q 38.803 23.752 40.831 25.156Q 41.611 25.702 41.611 26.872L 41.767 36.388Q 41.767 37.558 40.831 37.558Q 39.973 37.558 39.739 36.544Q 38.647 32.254 37.633 29.914Q 36.619 27.574 35.527 26.677Q 34.435 25.780 33.109 25.780Q 31.549 25.780 29.872 27.574Q 28.195 29.368 27.064 33.385Q 25.933 37.402 25.933 43.954Q 25.933 52.300 28.078 56.512Q 30.223 60.724 33.655 60.724Q 36.385 60.724 38.218 58.462Q 40.051 56.200 41.377 50.272Q 41.611 49.414 42.313 49.414Q 43.249 49.414 43.015 50.974Q 42.157 56.434 40.636 59.437Q 39.115 62.440 37.048 63.571Q 34.981 64.702 32.407 64.702Z" fill="#FFFFFF"/>
|
||||
<path d="M 61.643 64.702Q 57.665 64.702 54.506 62.011Q 51.347 59.320 49.475 54.640Q 47.603 49.960 47.603 44.188Q 47.603 38.416 49.475 33.814Q 51.347 29.212 54.545 26.482Q 57.743 23.752 61.643 23.752Q 65.621 23.752 68.780 26.482Q 71.939 29.212 73.811 33.814Q 75.683 38.416 75.683 44.188Q 75.683 49.960 73.850 54.640Q 72.017 59.320 68.819 62.011Q 65.621 64.702 61.643 64.702ZM 61.643 62.752Q 69.755 62.752 69.755 44.188Q 69.755 25.702 61.643 25.702Q 53.531 25.702 53.531 44.188Q 53.531 62.752 61.643 62.752Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { ArrowLeft, Loader2, Wrench, CircleDot, Check, X } from "lucide-react";
|
||||
import { useJobs } from "@/components/jobs/job-store";
|
||||
import { HeroGlow } from "@/components/hero-glow";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default function JobPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const { jobs } = useJobs();
|
||||
const job = jobs.find((j) => j.id === id);
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-10">
|
||||
<Link href="/pipeline" className="inline-flex items-center gap-1.5 text-sm text-muted transition-colors hover:text-brand">
|
||||
<ArrowLeft className="size-4" /> Pipeline
|
||||
</Link>
|
||||
<p className="mt-8 text-sm text-muted">
|
||||
This worker is no longer in memory (it finished earlier or the page was reloaded).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-8">
|
||||
<Link href="/pipeline" className="inline-flex items-center gap-1.5 text-sm text-muted transition-colors hover:text-brand">
|
||||
<ArrowLeft className="size-4" /> Pipeline
|
||||
</Link>
|
||||
|
||||
<section className="dot-bg relative mt-5 overflow-hidden rounded-2xl border border-border bg-surface/40 px-6 py-7">
|
||||
{job.status === "running" && <HeroGlow />}
|
||||
<div className="relative z-10">
|
||||
<p className="flex items-center gap-2 font-mono text-xs uppercase tracking-[0.18em] text-faint">
|
||||
{job.status === "running" ? (
|
||||
<><Loader2 className="size-3 animate-spin text-brand" /> working</>
|
||||
) : job.status === "done" ? (
|
||||
<><Check className="size-3 text-emerald-500" /> done</>
|
||||
) : (
|
||||
<><X className="size-3 text-red-400" /> error</>
|
||||
)}
|
||||
</p>
|
||||
<h1 className="mt-2 font-display text-2xl tracking-tight text-landing">{job.title}</h1>
|
||||
{job.subtitle && <p className="mt-1 text-sm text-muted">{job.subtitle}</p>}
|
||||
{job.result?.score != null && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2.5">
|
||||
<Badge tone={job.result.tone}>{job.result.score}/5</Badge>
|
||||
{job.result.summary && <span className="text-sm text-muted">{job.result.summary}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ol className="mt-6 space-y-2">
|
||||
{job.steps.map((s, i) => (
|
||||
<li key={i} className="flex items-start gap-2.5 text-sm">
|
||||
{s.kind === "tool" ? (
|
||||
<Wrench className="mt-0.5 size-3.5 shrink-0 text-brand" />
|
||||
) : (
|
||||
<CircleDot className="mt-0.5 size-3.5 shrink-0 text-faint" />
|
||||
)}
|
||||
<span className={s.kind === "tool" ? "font-medium" : "text-muted"}>
|
||||
{s.kind === "tool" ? `Using ${s.label}` : s.label}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{job.status === "running" && (
|
||||
<li className="flex items-center gap-2.5 text-sm text-muted">
|
||||
<Loader2 className="size-3.5 animate-spin text-brand" /> thinking…
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
|
||||
{job.text && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">Output</h2>
|
||||
<div className="report-prose mt-3 rounded-2xl border border-border bg-surface/40 p-5">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{job.text}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Check, AlertTriangle, Loader2, Trash2 } from "lucide-react";
|
||||
import { useJobs } from "@/components/jobs/job-store";
|
||||
import { pillTone } from "@/components/jobs/worker-pills";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const TONE_CHIP = {
|
||||
good: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
|
||||
warn: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
|
||||
bad: "bg-red-500/15 text-red-700 dark:text-red-400",
|
||||
muted: "bg-surface-hover text-muted",
|
||||
} as const;
|
||||
|
||||
export default function JobsHistory() {
|
||||
const { jobs, clearFinished } = useJobs();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-6 py-10">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl tracking-tight text-landing">Workers</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Every evaluation you ran — a persistent log. <span className="tabular-nums">{jobs.length}</span> total.
|
||||
</p>
|
||||
</div>
|
||||
{jobs.some((j) => j.status !== "running") && (
|
||||
<button
|
||||
onClick={clearFinished}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-surface px-3 py-1.5 text-xs text-muted transition-colors hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
<Trash2 className="size-3.5" /> Clear finished
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<div className="mt-8 rounded-2xl border border-dashed border-border bg-surface/30 px-6 py-12 text-center text-sm text-muted">
|
||||
No workers yet. Hit <span className="text-foreground">Evaluate</span> on an inbox posting to spin one up.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-6 divide-y divide-border overflow-hidden rounded-2xl border border-border bg-surface/40">
|
||||
{jobs.map((j) => {
|
||||
const tone = pillTone(j);
|
||||
return (
|
||||
<li key={j.id}>
|
||||
<Link href={`/jobs/${j.id}`} className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-hover">
|
||||
{j.status === "running" ? (
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-brand" />
|
||||
) : j.status === "error" ? (
|
||||
<AlertTriangle className="size-4 shrink-0 text-red-400" />
|
||||
) : (
|
||||
<Check className="size-4 shrink-0 text-emerald-500" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{j.title}</div>
|
||||
{(j.subtitle || j.result?.summary) && (
|
||||
<div className="truncate text-xs text-muted">{j.result?.summary || j.subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
{j.result?.score != null && (
|
||||
<span className={cn("shrink-0 rounded-md px-1.5 py-0.5 text-xs font-semibold tabular-nums", TONE_CHIP[tone])}>
|
||||
{j.result.score}/5
|
||||
</span>
|
||||
)}
|
||||
<span className="hidden shrink-0 text-xs capitalize text-faint sm:block">{j.status}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { inter, instrumentSerif, instrumentSerifItalic } from "@/lib/fonts";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "career-ops — official web experience",
|
||||
description: "The official, local-first web experience for career-ops.",
|
||||
// Home-screen / standalone (iOS): let our theme-color flow up to the status bar
|
||||
// + Dynamic Island; safe-area insets handle the layout.
|
||||
appleWebApp: { capable: true, statusBarStyle: "black-translucent", title: "career-ops" },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
// viewport-fit=cover → env(safe-area-inset-*) become non-zero so the header can
|
||||
// sit flush under the notch / Dynamic Island.
|
||||
viewportFit: "cover",
|
||||
// Default (corrected to the real theme before paint by THEME_SCRIPT, then kept
|
||||
// in sync by the theme toggle). Dark flows seamlessly into the black island.
|
||||
themeColor: "#0a0a0a",
|
||||
};
|
||||
|
||||
// Before paint: set the theme class AND tint the browser chrome (theme-color) to
|
||||
// match — so Safari's status bar / URL bar unify with the header instead of a
|
||||
// jarring light seam. Matches --bg (light #f7f6f3 / dark #0a0a0a).
|
||||
const THEME_SCRIPT = `(function(){try{var t=localStorage.getItem('career-ops:theme');var d=t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches);if(d)document.documentElement.classList.add('dark');var m=document.querySelector('meta[name="theme-color"]');if(!m){m=document.createElement('meta');m.setAttribute('name','theme-color');document.head.appendChild(m);}m.setAttribute('content',d?'#0a0a0a':'#f7f6f3');}catch(e){document.documentElement.classList.add('dark');}})();`;
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={`${inter.variable} ${instrumentSerif.variable} ${instrumentSerifItalic.variable}`}
|
||||
>
|
||||
<body className="font-sans antialiased">
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
|
||||
<AppShell>{children}</AppShell>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { pipelineSummary, doctorState } from "@/lib/career-ops";
|
||||
import { OnboardingBanner } from "@/components/onboarding-banner";
|
||||
import { FirstRunHome } from "@/components/home/first-run-home";
|
||||
import { TodayDashboard } from "@/components/home/today-dashboard";
|
||||
|
||||
export const dynamic = "force-dynamic"; // always read fresh local files at request time (never at build — CI has no user data)
|
||||
|
||||
export default function Home() {
|
||||
const { phase, onboardingNeeded } = doctorState();
|
||||
// First run (truly empty install): the CV-upload takeover IS the home — value
|
||||
// before commitment. The full dashboard returns once they have a CV or any data.
|
||||
if (phase === "first-run") return <FirstRunHome />;
|
||||
|
||||
const { inbox, applications } = pipelineSummary();
|
||||
// Established / in-between: the dual-loop retention dashboard. Show the setup
|
||||
// banner whenever ANY prereq is missing (mirrors the core doctor.mjs), so a
|
||||
// portals-missing user is nudged rather than told "all caught up".
|
||||
return (
|
||||
<>
|
||||
{onboardingNeeded && <OnboardingBanner />}
|
||||
<TodayDashboard applications={applications} inbox={inbox} inBetween={phase === "in-between"} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { readReport, findApplication, trackerCanDelete } from "@/lib/career-ops";
|
||||
import { ReportView } from "@/components/report-view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ReportPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const app = findApplication(id);
|
||||
const report = readReport(id);
|
||||
if (!app && !report) notFound();
|
||||
return <ReportView id={id} app={app} report={report?.content ?? null} file={report?.file ?? null} canDelete={trackerCanDelete()} />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Suspense } from "react";
|
||||
import { pipelineSummary } from "@/lib/career-ops";
|
||||
import { PipelineView } from "@/components/pipeline-view";
|
||||
|
||||
export const dynamic = "force-dynamic"; // always read fresh local files
|
||||
|
||||
export default function PipelinePage() {
|
||||
const { inbox, applications } = pipelineSummary();
|
||||
return (
|
||||
<Suspense>
|
||||
<PipelineView applications={applications} inbox={inbox} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Radar } from "lucide-react";
|
||||
import { PortalsView } from "@/components/portals-view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function PortalsPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Radar className="size-6 text-brand" />
|
||||
<h1 className="font-display text-2xl tracking-tight text-landing">Portals</h1>
|
||||
</div>
|
||||
<p className="mt-1.5 max-w-xl text-sm text-muted">
|
||||
The companies the scanner watches for new roles (<code className="text-foreground">portals.yml</code>). Run a
|
||||
health check to catch ATS slugs that have quietly broken — a 404 means that company silently disappears from
|
||||
every future scan.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<PortalsView />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CoMark } from "@/components/co-mark";
|
||||
import { AssistantConsole } from "@/components/assistant-console";
|
||||
import { MobileNav } from "@/components/mobile-nav";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { JobsProvider } from "@/components/jobs/job-store";
|
||||
import { PipelineProvider } from "@/components/pipeline/pipeline-provider";
|
||||
import { ApplyProvider } from "@/components/apply/apply-provider";
|
||||
import { ExploreProvider } from "@/components/explore/explore-provider";
|
||||
import { FirstScoreView } from "@/components/explore/first-score-view";
|
||||
import { BetaBanner } from "@/components/beta/beta-banner";
|
||||
import { WorkerPills } from "@/components/jobs/worker-pills";
|
||||
import { UsageMeter } from "@/components/usage-meter";
|
||||
import { instrumentSerif } from "@/lib/fonts";
|
||||
import { NAV_ITEMS, isActivePath } from "@/lib/nav-items";
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<JobsProvider>
|
||||
<PipelineProvider>
|
||||
<ApplyProvider>
|
||||
<ExploreProvider>
|
||||
<MobileNav />
|
||||
<div className="flex min-h-screen">
|
||||
<aside className="sticky top-0 hidden h-screen w-60 shrink-0 flex-col overflow-y-auto border-r border-border bg-surface/30 p-4 md:flex">
|
||||
<Link href="/" className="mb-8 flex items-center gap-2.5 px-1">
|
||||
<CoMark size={32} />
|
||||
<span className={`${instrumentSerif.className} relative -top-px text-2xl font-normal tracking-tight text-landing`}>
|
||||
career-ops
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="flex flex-col gap-1">
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon, chip }) => {
|
||||
const active = isActivePath(href, pathname);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
|
||||
active
|
||||
? "bg-brand-soft text-brand"
|
||||
: "text-muted hover:bg-surface-hover hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{label}
|
||||
{chip && (
|
||||
<span className="ml-auto rounded-full border border-brand/30 bg-brand-soft px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-brand">
|
||||
{chip}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<WorkerPills />
|
||||
|
||||
<div className="mt-auto space-y-3 pt-4">
|
||||
<UsageMeter />
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className={`${instrumentSerif.className} text-sm text-faint`}>local-first · v0</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-x-hidden">{children}</main>
|
||||
<AssistantConsole />
|
||||
<FirstScoreView />
|
||||
<BetaBanner />
|
||||
</div>
|
||||
</ExploreProvider>
|
||||
</ApplyProvider>
|
||||
</PipelineProvider>
|
||||
</JobsProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Send, Lock } from "lucide-react";
|
||||
import { useJobs } from "@/components/jobs/job-store";
|
||||
import { useApply } from "@/components/apply/apply-provider";
|
||||
|
||||
// The "Apply" CTA — brand orange, paper-plane. Enabled ONLY when the tailored CV
|
||||
// for THIS offer is ready (the tracker's PDF column is ✅, or a pdf worker for
|
||||
// this #n just finished). On click it opens the apply form-proxy for the offer
|
||||
// (where the user reviews and submits it themselves — never auto-submit).
|
||||
export function ApplyButton({ n, url, company, pdfReady }: { n: string; url?: string; company: string; pdfReady: boolean }) {
|
||||
const router = useRouter();
|
||||
const { jobs } = useJobs();
|
||||
const apply = useApply();
|
||||
|
||||
const pdfJobDone = jobs.some((j) => j.kind === "pdf" && j.input === n && j.status === "done");
|
||||
const hasUrl = !!url && /^https?:\/\//i.test(url);
|
||||
const ready = (pdfReady || pdfJobDone) && hasUrl;
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title={!hasUrl ? "No application URL on this report" : "Generate the tailored CV (PDF) first to apply"}
|
||||
className="inline-flex cursor-not-allowed items-center gap-1.5 rounded-full border border-border bg-surface/40 px-3.5 py-1 text-xs font-medium text-faint"
|
||||
>
|
||||
<Lock className="size-3.5" /> Apply
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
apply.open(url!, { prefill: true, company });
|
||||
router.push("/apply");
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-brand px-3.5 py-1 text-xs font-medium text-brand-foreground shadow-sm transition-colors hover:bg-brand-200"
|
||||
title="Apply — opens the form pre-filled, you review and submit yourself"
|
||||
>
|
||||
<Send className="size-3.5" /> Apply
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Wand2, Asterisk, Paperclip, Sparkles, ArrowUpRight, ShieldCheck, RotateCcw, FileCheck2, AlertTriangle, Terminal, Check, ScanLine, PenLine, CheckCircle2, Info, ExternalLink, MousePointerClick } from "lucide-react";
|
||||
import type { ApplyIssue, DriveStep } from "@/lib/apply/issue";
|
||||
import { useApply } from "@/components/apply/apply-provider";
|
||||
import type { ApplyField } from "@/lib/apply/extract";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
|
||||
// Co-located UI animations (HMR-proof vs Tailwind v4's stale globals.css):
|
||||
// field cascade-in, per-field "just drafted" flash, skeleton shimmer, hero orb.
|
||||
const STYLE = `
|
||||
@keyframes co-rise{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
||||
.co-rise{animation:co-rise .55s cubic-bezier(.22,1,.36,1) both}
|
||||
@keyframes co-flash{0%{box-shadow:0 0 0 0 hsl(26 82% 55% / 0)}22%{box-shadow:0 0 0 3px hsl(26 82% 55% / .38)}100%{box-shadow:0 0 0 0 hsl(26 82% 55% / 0)}}
|
||||
.co-flash{animation:co-flash 1.15s ease both;border-radius:.6rem}
|
||||
@keyframes co-shim{0%{background-position:-200% 0}100%{background-position:200% 0}}
|
||||
.co-skel{background:linear-gradient(90deg, color-mix(in srgb,var(--fg) 5%, transparent) 25%, color-mix(in srgb,var(--fg) 12%, transparent) 37%, color-mix(in srgb,var(--fg) 5%, transparent) 63%);background-size:200% 100%;animation:co-shim 1.6s linear infinite;border-radius:.5rem}
|
||||
@keyframes co-orb{0%,100%{transform:scale(1);opacity:.55}50%{transform:scale(1.35);opacity:.9}}
|
||||
.co-orb{animation:co-orb 2.4s ease-in-out infinite}
|
||||
@keyframes co-spin{to{transform:rotate(360deg)}}
|
||||
.co-ring{animation:co-spin 3s linear infinite}
|
||||
@media (prefers-reduced-motion: reduce){.co-rise,.co-flash,.co-skel,.co-orb,.co-ring{animation:none}}
|
||||
`;
|
||||
|
||||
// The form-proxy UI: the real employer form is opened headlessly on the user's
|
||||
// machine and re-rendered here in plain language, pre-filled from their CV. The
|
||||
// user verifies every answer, then we fill the real form behind the scenes and
|
||||
// bring it to the front for them to submit. We never submit.
|
||||
export function ApplyView() {
|
||||
const a = useApply();
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
if (a.status === "idle" || a.status === "error") {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex max-w-2xl items-center gap-2 rounded-full border border-border bg-surface/70 py-1.5 pl-4 pr-1.5 shadow-sm transition focus-within:border-brand/50 focus-within:shadow-md">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && a.open(input.trim())}
|
||||
placeholder="Paste an application form URL (Ashby, Lever, Greenhouse…)"
|
||||
className="min-w-0 flex-1 bg-transparent py-1.5 text-sm outline-none placeholder:text-faint"
|
||||
/>
|
||||
<button
|
||||
onClick={() => a.open(input.trim())}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-brand px-4 py-1.5 text-sm font-medium text-brand-foreground transition-colors hover:bg-brand-200"
|
||||
>
|
||||
<Wand2 className="size-4" /> Read form
|
||||
</button>
|
||||
</div>
|
||||
{a.error && (
|
||||
<div className="mt-4 max-w-2xl rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3.5">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-500" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300">{a.error}</p>
|
||||
{a.url && /^https?:\/\//.test(a.url) && (
|
||||
<a href={a.url} target="_blank" rel="noreferrer" className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-brand hover:underline">
|
||||
Open the form directly <ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const opening = a.status === "opening";
|
||||
const driving = a.status === "driving";
|
||||
const prefilling = a.status === "prefilling";
|
||||
const filling = a.status === "filling";
|
||||
const done = a.status === "done";
|
||||
const busy = opening || driving;
|
||||
const phase = busy ? 0 : prefilling ? 1 : 2;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<style>{STYLE}</style>
|
||||
|
||||
{/* journey: Read → Draft → Review */}
|
||||
<PhaseRail phase={phase} />
|
||||
|
||||
{!busy && (
|
||||
<div className="co-rise mb-4 flex items-baseline justify-between gap-3">
|
||||
<h2 className="font-display text-xl text-landing drop-shadow-sm">{a.title || "Application"}</h2>
|
||||
<button onClick={a.reset} className="inline-flex items-center gap-1 text-xs text-faint transition-colors hover:text-foreground">
|
||||
<RotateCcw className="size-3" /> new
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* opening: big magic hero + skeleton fields (no layout jump when real ones arrive) */}
|
||||
{opening && (
|
||||
<>
|
||||
<ProcessingHero title="Reading your form…" subtitle="Opening the real application on your machine and reading every field." />
|
||||
<FieldSkeleton />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* driving: watch the agent reach the form live (it navigates, never submits) */}
|
||||
{driving && <DrivePanel steps={a.driveSteps} />}
|
||||
|
||||
{a.error && (
|
||||
<p className="co-rise mb-3 flex items-start gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-700 backdrop-blur-sm dark:text-amber-400">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" /> {a.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!busy && (
|
||||
<div className="co-rise">
|
||||
<ApplyIssues issues={a.issues} />
|
||||
{/* drafting banner while the planner writes the answers */}
|
||||
{prefilling && (
|
||||
<div className="mb-4 flex items-center gap-3 rounded-xl border border-brand/30 bg-brand-soft/60 px-4 py-3 backdrop-blur-sm">
|
||||
<span className="relative grid size-8 shrink-0 place-items-center">
|
||||
<span className="co-orb absolute inset-0 rounded-full bg-brand/40 blur-[6px]" />
|
||||
<Sparkles className="size-4 text-brand" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">Drafting your answers…</div>
|
||||
<RotatingStatus />
|
||||
</div>
|
||||
<Loader2 className="ml-auto size-4 shrink-0 animate-spin text-brand" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={a.prefill}
|
||||
disabled={prefilling || filling}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-brand/40 bg-brand-soft px-3.5 py-1.5 text-sm font-medium text-brand transition-colors hover:bg-brand/15 disabled:opacity-50"
|
||||
>
|
||||
{prefilling ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
|
||||
{prefilling ? "Drafting from your CV…" : "Pre-fill from my CV"}
|
||||
</button>
|
||||
<span className="text-xs text-muted">…or ask the corner assistant to write/revise any answer.</span>
|
||||
</div>
|
||||
|
||||
{(prefilling || a.prefillLog.length > 0) && (
|
||||
<details className="mb-4 rounded-lg border border-border bg-surface/60 backdrop-blur-sm" open={false}>
|
||||
<summary className="flex cursor-pointer select-none items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-muted">
|
||||
<Terminal className="size-3.5" /> Pre-fill diagnostics
|
||||
{prefilling && <Loader2 className="size-3 animate-spin text-brand" />}
|
||||
<span className="ml-auto text-faint">{a.prefillLog.length} steps</span>
|
||||
</summary>
|
||||
<div className="max-h-52 overflow-y-auto border-t border-border px-3 py-2">
|
||||
<ol className="space-y-0.5 font-mono text-[11px] leading-relaxed text-muted">
|
||||
{a.prefillLog.map((l, i) => (
|
||||
<li key={i} className={l.startsWith("✗") ? "text-amber-600 dark:text-amber-400" : ""}>
|
||||
{l}
|
||||
</li>
|
||||
))}
|
||||
{prefilling && <li className="text-faint">…</li>}
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* the questions — float on the blurred form image, cascade in, each
|
||||
flashes brand-orange the instant its drafted answer lands */}
|
||||
<div className="space-y-1 rounded-2xl border border-border/70 bg-surface/80 p-2 shadow-2xl shadow-black/10 backdrop-blur-md sm:p-3">
|
||||
{a.fields.map((f, i) => (
|
||||
<div key={f.id} className="co-rise rounded-xl px-3 py-2.5" style={{ animationDelay: `${Math.min(i * 45, 700)}ms` }}>
|
||||
<FieldRow
|
||||
field={f}
|
||||
value={a.answers[f.id] ?? ""}
|
||||
needs={!!a.meta[f.id]?.needsConfirmation}
|
||||
index={i}
|
||||
drafting={prefilling}
|
||||
onChange={(v) => a.setAnswer(f.id, v)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={a.fill}
|
||||
disabled={filling || prefilling}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-brand px-5 py-2.5 text-sm font-medium text-brand-foreground shadow-lg shadow-brand/25 transition-all hover:bg-brand-200 hover:shadow-brand/40 disabled:opacity-50"
|
||||
>
|
||||
{filling ? <Loader2 className="size-4 animate-spin" /> : <ArrowUpRight className="size-4" />}
|
||||
{filling ? "Filling the real form…" : "Fill the real form & review"}
|
||||
</button>
|
||||
<button
|
||||
onClick={a.agentFill}
|
||||
disabled={filling || prefilling}
|
||||
title="Let the AI drive the real form and fill it field-by-field (for tricky / multi-step forms). It never submits."
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-border px-4 py-2.5 text-sm font-medium text-muted transition-colors hover:border-brand/40 hover:text-brand disabled:opacity-50"
|
||||
>
|
||||
<MousePointerClick className="size-4" /> Let the AI fill it
|
||||
</button>
|
||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted">
|
||||
<ShieldCheck className="size-3.5 text-emerald-500" /> Never submits — you click Submit yourself.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* agent filling the form live (full-agent escalation) */}
|
||||
{filling && a.driveSteps.length > 0 && <div className="mt-6"><DrivePanel steps={a.driveSteps} filling /></div>}
|
||||
|
||||
{(filling || done) && a.steps.length > 0 && (
|
||||
<div className="co-rise mt-6">
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-faint">Behind the scenes</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{a.steps.map((s, i) => (
|
||||
<figure key={i} className="shrink-0">
|
||||
{s.thumb ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={s.thumb} alt="" className="h-24 w-36 rounded-md border border-border object-cover" />
|
||||
) : (
|
||||
<div className="flex h-24 w-36 items-center justify-center rounded-md border border-dashed border-border text-faint">…</div>
|
||||
)}
|
||||
<figcaption className={cn("mt-1 w-36 truncate text-[10px]", s.ok ? "text-faint" : "text-amber-500")}>{s.label || "field"}</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{done && (
|
||||
<div className="co-rise mt-4 flex items-start gap-2.5 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm backdrop-blur-sm">
|
||||
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-emerald-500" />
|
||||
<div>
|
||||
<span className="font-medium text-emerald-700 dark:text-emerald-400">The real form is now in front, pre-filled.</span>{" "}
|
||||
<span className="text-muted">Review it and click Submit yourself — career-ops never submits for you.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Watch the agent reach the form live (it navigates, never submits) ───────
|
||||
const DRIVE_VERB: Record<string, string> = { click: "Clicked", type: "Typed into", select: "Selected", scroll: "Scrolled", "parse-error": "Thinking…", stuck: "Stuck", reached_form: "Reached the form" };
|
||||
function DrivePanel({ steps, filling }: { steps: DriveStep[]; filling?: boolean }) {
|
||||
const last = steps[steps.length - 1];
|
||||
return (
|
||||
<div className="co-rise">
|
||||
<div className="flex flex-col items-center gap-3 py-7 text-center">
|
||||
<span className="relative grid size-14 place-items-center">
|
||||
<span className="co-orb absolute inset-0 rounded-full bg-brand/30 blur-lg" />
|
||||
<span className="co-ring absolute inset-0 rounded-full border-2 border-brand/30 border-t-brand" />
|
||||
<MousePointerClick className="size-6 text-brand" />
|
||||
</span>
|
||||
<div className="font-display text-2xl text-landing">{filling ? "AI is filling the form…" : "Reaching your form…"}</div>
|
||||
<p className="max-w-sm text-sm text-muted">{filling ? "The AI is driving the real form field-by-field on your machine — it never submits; you review and submit." : "The AI is navigating the real application on your machine to reach the form — it never submits."}</p>
|
||||
</div>
|
||||
{last?.thumb ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={last.thumb} alt="" className="w-full rounded-xl border border-border shadow-xl shadow-black/10" />
|
||||
) : (
|
||||
<div className="co-skel h-56 w-full rounded-xl" />
|
||||
)}
|
||||
{steps.length > 0 && (
|
||||
<ol className="mt-3 space-y-1.5 rounded-xl border border-border/70 bg-surface/70 p-3 backdrop-blur-sm">
|
||||
{steps.map((s, i) => (
|
||||
<li key={i} className={cn("flex items-center gap-2 text-xs", i === steps.length - 1 ? "text-foreground" : "text-muted")}>
|
||||
<span className="grid size-5 shrink-0 place-items-center rounded-full bg-brand-soft text-[10px] font-semibold text-brand">{s.turn}</span>
|
||||
<span className="shrink-0 font-medium">{DRIVE_VERB[s.action] ?? s.action}</span>
|
||||
<span className="truncate text-faint">{s.detail}</span>
|
||||
{s.note && <span className="shrink-0 text-amber-500">· {s.note}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Issues the interpreter surfaced — never fail mute ───────────────────────
|
||||
function ApplyIssues({ issues }: { issues: ApplyIssue[] }) {
|
||||
if (!issues.length) return null;
|
||||
const warns = issues.filter((i) => i.level === "warn" || i.level === "block");
|
||||
const infos = issues.filter((i) => i.level === "info");
|
||||
return (
|
||||
<div className="mb-4 space-y-2">
|
||||
{warns.length > 0 && (
|
||||
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 backdrop-blur-sm">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle className="size-4" /> A few things to check
|
||||
</div>
|
||||
<ul className="space-y-1 text-xs text-amber-800/90 dark:text-amber-300/90">
|
||||
{warns.map((i, k) => (
|
||||
<li key={k} className="flex gap-1.5">
|
||||
<span className="mt-px text-amber-500">•</span> {i.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{infos.map((i, k) => (
|
||||
<div key={k} className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<Info className="size-3.5 shrink-0 text-faint" /> {i.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Journey rail: Reading → Drafting → Review ───────────────────────────────
|
||||
function PhaseRail({ phase }: { phase: number }) {
|
||||
const steps = [
|
||||
{ label: "Reading form", icon: ScanLine },
|
||||
{ label: "Drafting answers", icon: PenLine },
|
||||
{ label: "Review & submit", icon: CheckCircle2 },
|
||||
];
|
||||
return (
|
||||
<div className="mb-6 flex items-center gap-2.5">
|
||||
{steps.map((s, i) => {
|
||||
const Icon = s.icon;
|
||||
const state = i < phase ? "done" : i === phase ? "active" : "todo";
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"relative grid size-6 place-items-center rounded-full border transition-colors",
|
||||
state === "done" && "border-brand bg-brand text-brand-foreground",
|
||||
state === "active" && "border-brand text-brand",
|
||||
state === "todo" && "border-border text-faint",
|
||||
)}
|
||||
>
|
||||
{state === "done" ? <Check className="size-3.5" /> : <Icon className="size-3.5" />}
|
||||
{state === "active" && <span className="absolute inset-0 -z-10 animate-ping rounded-full bg-brand/30" />}
|
||||
</span>
|
||||
<span className={cn("hidden text-xs font-medium sm:inline", i <= phase ? "text-foreground" : "text-faint")}>{s.label}</span>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<span className="relative h-px flex-1 overflow-hidden rounded bg-border">
|
||||
<span className={cn("absolute inset-y-0 left-0 bg-brand transition-all duration-700", i < phase ? "w-full" : "w-0")} />
|
||||
</span>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Honest, calming rotation of what the planner is actually doing, so the (~1-2min)
|
||||
// draft doesn't feel stalled. Crossfades every ~2.8s.
|
||||
const DRAFT_MSGS = [
|
||||
"Reading your CV…",
|
||||
"Reading the role and company…",
|
||||
"Matching your experience to each question…",
|
||||
"Writing every answer in your own voice…",
|
||||
"Flagging anything that needs your call…",
|
||||
];
|
||||
function RotatingStatus() {
|
||||
const [i, setI] = useState(0);
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setI((n) => (n + 1) % DRAFT_MSGS.length), 2800);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
return (
|
||||
<div key={i} className="co-rise truncate text-xs text-muted">
|
||||
{DRAFT_MSGS[i]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProcessingHero({ title, subtitle }: { title: string; subtitle: string }) {
|
||||
return (
|
||||
<div className="co-rise flex flex-col items-center gap-3 py-14 text-center">
|
||||
<span className="relative grid size-16 place-items-center">
|
||||
<span className="co-orb absolute inset-0 rounded-full bg-brand/30 blur-lg" />
|
||||
<span className="co-ring absolute inset-0 rounded-full border-2 border-brand/30 border-t-brand" />
|
||||
<Sparkles className="size-7 text-brand" />
|
||||
</span>
|
||||
<div className="font-display text-3xl text-landing">{title}</div>
|
||||
<p className="max-w-sm text-sm text-muted">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldSkeleton() {
|
||||
return (
|
||||
<div className="co-rise space-y-3 rounded-2xl border border-border/70 bg-surface/70 p-5 backdrop-blur-md" style={{ animationDelay: "120ms" }}>
|
||||
{[64, 80, 48, 72, 56].map((w, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="co-skel h-3" style={{ width: `${w}px` }} />
|
||||
<div className="co-skel h-9 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({
|
||||
field: f,
|
||||
value,
|
||||
needs,
|
||||
index,
|
||||
drafting,
|
||||
onChange,
|
||||
}: {
|
||||
field: ApplyField;
|
||||
value: string;
|
||||
needs: boolean;
|
||||
index: number;
|
||||
drafting: boolean;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
// Flash brand-orange the moment a drafted answer first lands (empty → value).
|
||||
const prev = useRef(value);
|
||||
const [flash, setFlash] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!prev.current && value) {
|
||||
setFlash(true);
|
||||
// outlast the staggered animation-delay (≤900ms) + the 1.15s flash
|
||||
const t = setTimeout(() => setFlash(false), 2300);
|
||||
prev.current = value;
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
prev.current = value;
|
||||
}, [value]);
|
||||
|
||||
const base = cn(
|
||||
"w-full rounded-lg border bg-surface/60 px-3 py-2 text-sm outline-none transition focus:border-brand/60 focus:ring-2 focus:ring-brand/20",
|
||||
needs ? "border-amber-500/50" : "border-border",
|
||||
);
|
||||
// While the planner is drafting, an empty answer shimmers like it's being
|
||||
// written; it flashes into the real value the instant the draft lands.
|
||||
const writing = drafting && !value && f.type !== "file";
|
||||
return (
|
||||
<div className={flash ? "co-flash" : ""} style={flash ? { animationDelay: `${Math.min(index * 70, 900)}ms` } : undefined}>
|
||||
<label className="mb-1.5 flex items-center gap-1 text-sm font-medium">
|
||||
{f.label || <span className="text-faint">Untitled field</span>}
|
||||
{f.required && <Asterisk className="size-3 text-brand" />}
|
||||
{needs && <span className="ml-1 rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-amber-600 dark:text-amber-400">you confirm</span>}
|
||||
</label>
|
||||
{writing ? (
|
||||
<div className={cn("co-skel", f.type === "textarea" ? "h-[68px]" : "h-9")} />
|
||||
) : f.type === "textarea" ? (
|
||||
<textarea rows={3} maxLength={f.maxLength} value={value} onChange={(e) => onChange(e.target.value)} placeholder={needs ? "You fill this one." : "…"} className={cn(base, "resize-none")} />
|
||||
) : (f.type === "select" || f.type === "radio") && f.options && f.options.length > 0 ? (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} className={base}>
|
||||
<option value="">Choose…</option>
|
||||
{f.options.map((o, i) => (
|
||||
<option key={i} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : f.type === "checkbox" ? (
|
||||
<label className="flex items-center gap-2 text-sm text-muted">
|
||||
<input type="checkbox" checked={value === "true" || value === "yes"} onChange={(e) => onChange(e.target.checked ? "true" : "")} className="size-4 accent-brand" /> {f.label || "Yes"}
|
||||
</label>
|
||||
) : f.type === "file" ? (
|
||||
/resume|résumé|\bcv\b|curriculum|currículum|lebenslauf/i.test(f.label || "") ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-400">
|
||||
<FileCheck2 className="size-4 shrink-0" /> Your tailored CV (PDF) will be attached automatically — you can swap it on the real form.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2 text-sm text-muted">
|
||||
<Paperclip className="size-4 shrink-0" /> Attach this file yourself on the real form at the handoff.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<input type={["email", "tel", "url", "number", "date"].includes(f.type) ? f.type : "text"} maxLength={f.maxLength} value={value} onChange={(e) => onChange(e.target.value)} placeholder={needs ? "You fill this one." : "…"} className={base} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useApply } from "@/components/apply/apply-provider";
|
||||
import { ApplyBackdrop } from "@/components/apply/apply-backdrop";
|
||||
|
||||
// Mounts the full-viewport apply wallpaper at the PAGE level (a sibling before
|
||||
// the content) so it sits behind everything — header + questions scroll over the
|
||||
// blurred form. Active only once a session is opening/processing/ready.
|
||||
export function ApplyBackdropMount() {
|
||||
const a = useApply();
|
||||
if (a.status === "idle" || a.status === "error") return null;
|
||||
const intense = a.status === "opening" || a.status === "prefilling" || a.status === "filling";
|
||||
return <ApplyBackdrop image={a.shots[0]} intense={intense} />;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Full-viewport "magic processing" wallpaper for the apply page: the real form's
|
||||
// first screenshot, blurred + enlarged to fill the main area, under a dot grid
|
||||
// that drifting glow-halos light up (mix-blend screen). Fixed so the questions
|
||||
// scroll over it. CSS is co-located in a <style> tag (NOT globals.css) so it
|
||||
// can't be defeated by Tailwind v4's stale-CSS HMR. Pure CSS, GPU transforms,
|
||||
// respects prefers-reduced-motion.
|
||||
const CSS = `
|
||||
.co-bd{position:fixed;top:0;right:0;bottom:0;left:0;z-index:0;overflow:hidden;pointer-events:none;background:var(--bg)}
|
||||
@media(min-width:768px){.co-bd{left:15rem}}
|
||||
.co-bd__img{position:absolute;inset:-15%;width:130%;height:130%;object-fit:cover;object-position:center top;filter:blur(48px) saturate(1.25);transition:opacity .9s ease}
|
||||
.co-bd__tint{position:absolute;inset:0;background:
|
||||
radial-gradient(120% 90% at 50% -10%, transparent, color-mix(in srgb, var(--bg) 82%, transparent) 80%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--bg) 28%, transparent), color-mix(in srgb, var(--bg) 68%, transparent))}
|
||||
.co-bd__dots{position:absolute;inset:0;
|
||||
background-image:radial-gradient(circle, color-mix(in srgb, var(--fg) 36%, transparent) 1px, transparent 1.7px);
|
||||
background-size:24px 24px;
|
||||
-webkit-mask-image:radial-gradient(140% 110% at 50% 38%, #000 50%, transparent 100%);
|
||||
mask-image:radial-gradient(140% 110% at 50% 38%, #000 50%, transparent 100%)}
|
||||
.co-bd__halo{position:absolute;width:65%;height:80%;border-radius:50%;filter:blur(70px);mix-blend-mode:screen;will-change:transform}
|
||||
.co-bd__halo.a{left:-6%;top:-14%;background:radial-gradient(circle at center, hsl(26 82% 55% / .48), hsl(26 82% 55% / .12) 45%, transparent 70%);animation:co-halo-a 15s ease-in-out infinite}
|
||||
.co-bd__halo.b{right:-10%;bottom:-16%;background:radial-gradient(circle at center, hsl(220 90% 72% / .42), hsl(220 90% 72% / .10) 45%, transparent 70%);animation:co-halo-b 21s ease-in-out infinite}
|
||||
.co-bd__halo.c{left:26%;top:14%;width:50%;height:60%;background:radial-gradient(circle at center, hsl(0 0% 100% / .28), transparent 66%);animation:co-halo-c 18s ease-in-out infinite}
|
||||
.co-bd__vignette{position:absolute;inset:0;box-shadow:inset 0 0 150px 44px color-mix(in srgb, var(--bg) 74%, transparent)}
|
||||
.co-bd.is-intense .co-bd__img{opacity:.34}
|
||||
.co-bd.is-intense .co-bd__dots{opacity:.75}
|
||||
.co-bd.is-soft .co-bd__img{opacity:.16}
|
||||
.co-bd.is-soft .co-bd__dots{opacity:.42}
|
||||
.co-bd.is-soft .co-bd__halo{opacity:.6}
|
||||
/* LIGHT MODE: screen-blend halos vanish on a light bg → multiply colour-washes,
|
||||
softer dots, lower image opacity. (.dark uses the screen-blend rules above.) */
|
||||
html:not(.dark) .co-bd__halo{mix-blend-mode:multiply}
|
||||
html:not(.dark) .co-bd__halo.a{background:radial-gradient(circle at center, hsl(26 88% 62% / .50), hsl(26 88% 62% / .12) 45%, transparent 70%)}
|
||||
html:not(.dark) .co-bd__halo.b{background:radial-gradient(circle at center, hsl(222 84% 70% / .42), hsl(222 84% 70% / .10) 45%, transparent 70%)}
|
||||
html:not(.dark) .co-bd__halo.c{background:radial-gradient(circle at center, hsl(30 82% 72% / .32), transparent 66%)}
|
||||
html:not(.dark) .co-bd__dots{background-image:radial-gradient(circle, color-mix(in srgb, var(--fg) 22%, transparent) 1px, transparent 1.7px)}
|
||||
html:not(.dark) .co-bd.is-intense .co-bd__img{opacity:.20}
|
||||
html:not(.dark) .co-bd.is-soft .co-bd__img{opacity:.10}
|
||||
@keyframes co-halo-a{0%{transform:translate3d(-12%,-8%,0) scale(1)}50%{transform:translate3d(22%,16%,0) scale(1.32)}100%{transform:translate3d(-12%,-8%,0) scale(1)}}
|
||||
@keyframes co-halo-b{0%{transform:translate3d(26%,30%,0) scale(1.12)}50%{transform:translate3d(-10%,-12%,0) scale(.82)}100%{transform:translate3d(26%,30%,0) scale(1.12)}}
|
||||
@keyframes co-halo-c{0%{transform:translate3d(38%,-22%,0) scale(.9)}50%{transform:translate3d(8%,28%,0) scale(1.18)}100%{transform:translate3d(38%,-22%,0) scale(.9)}}
|
||||
@keyframes co-rise{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
||||
.co-rise{animation:co-rise .5s ease both}
|
||||
@media (prefers-reduced-motion: reduce){.co-bd__halo{animation:none}.co-rise{animation:none}}
|
||||
`;
|
||||
|
||||
export function ApplyBackdrop({ image, intense }: { image?: string; intense: boolean }) {
|
||||
return (
|
||||
<div aria-hidden className={`co-bd ${intense ? "is-intense" : "is-soft"}`}>
|
||||
<style>{CSS}</style>
|
||||
{image && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image} alt="" className="co-bd__img" />
|
||||
)}
|
||||
<div className="co-bd__tint" />
|
||||
<div className="co-bd__dots" />
|
||||
<div className="co-bd__halo a" />
|
||||
<div className="co-bd__halo b" />
|
||||
<div className="co-bd__halo c" />
|
||||
<div className="co-bd__vignette" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ApplyField } from "@/lib/apply/extract";
|
||||
import type { ApplyIssue, DriveStep } from "@/lib/apply/issue";
|
||||
|
||||
export type FillStep = { fieldId: string; label: string; ok: boolean; thumb?: string };
|
||||
type Meta = { needsConfirmation?: boolean };
|
||||
type Status = "idle" | "opening" | "driving" | "prefilling" | "ready" | "filling" | "done" | "error";
|
||||
|
||||
type ApplyCtx = {
|
||||
status: Status;
|
||||
url: string;
|
||||
title: string;
|
||||
company: string;
|
||||
fields: ApplyField[];
|
||||
answers: Record<string, string>;
|
||||
meta: Record<string, Meta>;
|
||||
steps: FillStep[];
|
||||
shots: string[];
|
||||
prefillLog: string[];
|
||||
issues: ApplyIssue[];
|
||||
driveSteps: DriveStep[];
|
||||
error: string;
|
||||
open: (url: string, opts?: { prefill?: boolean; company?: string }) => Promise<void>;
|
||||
prefill: () => Promise<void>;
|
||||
setAnswer: (idOrLabel: string, value: string) => void;
|
||||
fill: () => Promise<void>;
|
||||
agentFill: () => Promise<void>;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
const Ctx = createContext<ApplyCtx | null>(null);
|
||||
export function useApply(): ApplyCtx {
|
||||
const c = useContext(Ctx);
|
||||
if (!c) throw new Error("useApply must be used within <ApplyProvider>");
|
||||
return c;
|
||||
}
|
||||
|
||||
const CONFIG_KEY = "career-ops:config";
|
||||
function cliId(): string | null {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(CONFIG_KEY) || "{}").cliId || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ApplyProvider({ children }: { children: React.ReactNode }) {
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [url, setUrl] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [company, setCompany] = useState("");
|
||||
const [fields, setFields] = useState<ApplyField[]>([]);
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [meta, setMeta] = useState<Record<string, Meta>>({});
|
||||
const [steps, setSteps] = useState<FillStep[]>([]);
|
||||
const [shots, setShots] = useState<string[]>([]);
|
||||
const [prefillLog, setPrefillLog] = useState<string[]>([]);
|
||||
const [issues, setIssues] = useState<ApplyIssue[]>([]);
|
||||
const [driveSteps, setDriveSteps] = useState<DriveStep[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const sessionId = useRef<string | null>(null);
|
||||
const companyRef = useRef<string>("");
|
||||
const fieldsRef = useRef<ApplyField[]>([]);
|
||||
fieldsRef.current = fields;
|
||||
const answersRef = useRef<Record<string, string>>({});
|
||||
answersRef.current = answers;
|
||||
// When a session opens with {prefill:true}, auto-fire prefill once fields are
|
||||
// ready — driven by an effect (not a fragile setTimeout) so it can't race the
|
||||
// session response or a navigation.
|
||||
const pendingPrefill = useRef(false);
|
||||
|
||||
// Stream the agentic drive (the AI reaching the form live) and finalize the
|
||||
// session when it succeeds → fields ready → auto-prefill fires.
|
||||
const drive = useCallback(async (id: string) => {
|
||||
setDriveSteps([]);
|
||||
try {
|
||||
const r = await fetch("/api/apply/drive", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: id, cliId: cliId(), goal: "reach" }) });
|
||||
if (!r.body) {
|
||||
setError("The agent couldn't start.");
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
const reader = r.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
let finished = false;
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
let ev: { t?: string; message?: string; fields?: ApplyField[]; title?: string; issues?: ApplyIssue[] } & DriveStep;
|
||||
try {
|
||||
ev = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (ev.t === "step") setDriveSteps((p) => [...p, ev as DriveStep]);
|
||||
else if (ev.t === "done") {
|
||||
finished = true;
|
||||
setFields(ev.fields ?? []);
|
||||
if (ev.title) setTitle(ev.title);
|
||||
setIssues(ev.issues ?? []);
|
||||
setStatus("ready"); // → the ready-effect auto-prefills if pending
|
||||
} else if (ev.t === "error") {
|
||||
finished = true;
|
||||
setError(ev.message || "The agent couldn't reach a fillable form.");
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!finished) {
|
||||
setError("The agent stopped before reaching a form.");
|
||||
setStatus("error");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`The agent couldn't reach the form: ${e instanceof Error ? e.message : "stream error"}.`);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const open = useCallback(async (u: string, opts?: { prefill?: boolean; company?: string }) => {
|
||||
setStatus("opening");
|
||||
setError("");
|
||||
setFields([]);
|
||||
setAnswers({});
|
||||
setMeta({});
|
||||
setSteps([]);
|
||||
setShots([]);
|
||||
setIssues([]);
|
||||
setDriveSteps([]);
|
||||
setUrl(u);
|
||||
setCompany(opts?.company ?? "");
|
||||
companyRef.current = opts?.company ?? "";
|
||||
pendingPrefill.current = false;
|
||||
try {
|
||||
const r = await fetch("/api/apply/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: u, cliId: cliId() }) });
|
||||
const d = await r.json();
|
||||
if (d.error) {
|
||||
setError(d.error);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
sessionId.current = d.id;
|
||||
setTitle(d.title);
|
||||
setShots(d.shots ?? []);
|
||||
pendingPrefill.current = !!opts?.prefill;
|
||||
if (d.needsDrive) {
|
||||
// The form is behind navigation → the agent drives to reach it, streamed.
|
||||
setStatus("driving");
|
||||
await drive(d.id);
|
||||
return;
|
||||
}
|
||||
setFields(d.fields);
|
||||
setIssues(d.issues ?? []);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setError("Could not open the form.");
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const prefill = useCallback(async () => {
|
||||
if (!sessionId.current) return;
|
||||
if (!cliId()) {
|
||||
setError("Configure a CLI in Config first, then pre-fill from your CV.");
|
||||
return;
|
||||
}
|
||||
setStatus("prefilling");
|
||||
setError("");
|
||||
setPrefillLog([]);
|
||||
const applyAnswers = (raw: Record<string, { value?: string; needs_confirmation?: boolean }>) => {
|
||||
const a: Record<string, string> = {};
|
||||
const m: Record<string, Meta> = {};
|
||||
for (const [id, v] of Object.entries(raw)) {
|
||||
a[id] = v?.value ?? "";
|
||||
m[id] = { needsConfirmation: !!v?.needs_confirmation };
|
||||
}
|
||||
setAnswers(a);
|
||||
setMeta(m);
|
||||
};
|
||||
try {
|
||||
const r = await fetch("/api/apply/prefill", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: sessionId.current, cliId: cliId() }) });
|
||||
if (!r.body) {
|
||||
setError("Couldn't pre-fill — no response stream.");
|
||||
setStatus("ready");
|
||||
return;
|
||||
}
|
||||
const reader = r.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
let got = false;
|
||||
let sawError = false;
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
let ev: { t?: string; m?: string; el?: number; raw?: string; answers?: Record<string, { value?: string; needs_confirmation?: boolean }>; count?: number; truncated?: boolean };
|
||||
try {
|
||||
ev = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (ev.t === "log") {
|
||||
setPrefillLog((p) => [...p, `+${((ev.el ?? 0) / 1000).toFixed(1)}s ${ev.m}`]);
|
||||
} else if (ev.t === "done") {
|
||||
got = true;
|
||||
applyAnswers(ev.answers ?? {});
|
||||
if ((ev.count ?? 0) === 0) setError("The planner returned 0 answers — see the diagnostics log below.");
|
||||
else if (ev.truncated) setError("The planner was cut off — some fields were recovered, others may be blank. See diagnostics.");
|
||||
} else if (ev.t === "error") {
|
||||
sawError = true;
|
||||
setError(ev.m ? `Couldn't pre-fill: ${ev.m}` : "Couldn't pre-fill from your CV.");
|
||||
setPrefillLog((p) => [...p, `✗ ${ev.m ?? "error"}${ev.raw ? ` — raw tail: ${ev.raw.slice(0, 160)}` : ""}`]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!got && !sawError) setError("Pre-fill ended without answers — see the diagnostics log below.");
|
||||
setStatus("ready");
|
||||
} catch (e) {
|
||||
setError(`Couldn't pre-fill from your CV: ${e instanceof Error ? e.message : "stream error"}. See diagnostics.`);
|
||||
setStatus("ready");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-prefill exactly once after the fields are ready.
|
||||
useEffect(() => {
|
||||
if (status === "ready" && pendingPrefill.current) {
|
||||
pendingPrefill.current = false;
|
||||
void prefill();
|
||||
}
|
||||
}, [status, prefill]);
|
||||
|
||||
const setAnswer = useCallback((idOrLabel: string, value: string) => {
|
||||
const fs = fieldsRef.current;
|
||||
const key = idOrLabel.toLowerCase();
|
||||
const f =
|
||||
fs.find((x) => x.id === idOrLabel) ||
|
||||
fs.find((x) => x.label.toLowerCase().includes(key)) ||
|
||||
fs.find((x) => key.includes(x.label.toLowerCase()) && x.label.length > 2);
|
||||
if (f) setAnswers((prev) => ({ ...prev, [f.id]: value }));
|
||||
}, []);
|
||||
|
||||
const fill = useCallback(async () => {
|
||||
if (!sessionId.current) return;
|
||||
setStatus("filling");
|
||||
setSteps([]);
|
||||
try {
|
||||
const r = await fetch("/api/apply/fill", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: sessionId.current, answers, fields, handoff: true, company: companyRef.current }) });
|
||||
const d = await r.json();
|
||||
if (d.error) {
|
||||
setError(d.error);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
setSteps(d.steps ?? []);
|
||||
// merge the verify-after-fill warnings (required-empty / mismatch / validation)
|
||||
if (Array.isArray(d.issues) && d.issues.length) {
|
||||
setIssues((prev) => {
|
||||
const seen = new Set(prev.map((i: ApplyIssue) => i.message));
|
||||
return [...prev, ...(d.issues as ApplyIssue[]).filter((i) => !seen.has(i.message))];
|
||||
});
|
||||
}
|
||||
if (d.navigated) setError("Heads up: the form's page changed during fill — review it carefully before submitting (career-ops never submits for you).");
|
||||
setStatus("done");
|
||||
// ESCALATION ("si no va, full agente"): if deterministic fill clearly
|
||||
// didn't land (most fields failed / mismatched), let the agent fill it.
|
||||
const okCount = (d.steps ?? []).filter((s: FillStep) => s.ok).length;
|
||||
const total = (d.steps ?? []).length;
|
||||
const mismatch = (d.issues ?? []).some((i: ApplyIssue) => i.code === "fill-mismatch");
|
||||
if (cliId() && total > 0 && (okCount === 0 || (mismatch && okCount < total / 2))) {
|
||||
await agentFillRef.current();
|
||||
}
|
||||
} catch {
|
||||
setError("Fill failed.");
|
||||
setStatus("error");
|
||||
}
|
||||
}, [answers, fields]);
|
||||
|
||||
// FULL-AGENT FILL — the agent fills the real form turn-by-turn from the verified
|
||||
// answers, streamed (drive panel), never submits, then hands off. Used as the
|
||||
// escalation when deterministic fill fails, or on demand.
|
||||
const agentFill = useCallback(async () => {
|
||||
if (!sessionId.current) return;
|
||||
const fs = fieldsRef.current;
|
||||
const a = answersRef.current;
|
||||
const ans = fs.filter((f) => f.type !== "file" && (a[f.id] || "").trim()).map((f) => ({ label: f.label || f.id, value: a[f.id] }));
|
||||
setDriveSteps([]);
|
||||
setError("");
|
||||
setStatus("filling");
|
||||
try {
|
||||
const r = await fetch("/api/apply/drive", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: sessionId.current, cliId: cliId(), goal: "full", answers: ans }) });
|
||||
if (!r.body) {
|
||||
setError("The agent couldn't start filling.");
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
const reader = r.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
let ev: { t?: string; message?: string; filled?: boolean } & DriveStep;
|
||||
try {
|
||||
ev = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (ev.t === "step") setDriveSteps((p) => [...p, ev as DriveStep]);
|
||||
else if (ev.t === "done") {
|
||||
setIssues((prev) => [...prev, { level: "info", code: "ai-filled", message: ev.filled ? "AI filled the form for you — review every answer on the real form, then submit it yourself." : "AI did its best but couldn't finish — check the real form before submitting." }]);
|
||||
setStatus("done");
|
||||
} else if (ev.t === "error") {
|
||||
setError(ev.message || "The agent couldn't fill the form.");
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`The agent couldn't fill the form: ${e instanceof Error ? e.message : "stream error"}.`);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
const agentFillRef = useRef(agentFill);
|
||||
agentFillRef.current = agentFill;
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (sessionId.current) {
|
||||
const id = sessionId.current;
|
||||
void fetch("/api/apply/close", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: id }), keepalive: true }).catch(() => {});
|
||||
}
|
||||
sessionId.current = null;
|
||||
companyRef.current = "";
|
||||
pendingPrefill.current = false;
|
||||
setStatus("idle");
|
||||
setUrl("");
|
||||
setTitle("");
|
||||
setCompany("");
|
||||
setFields([]);
|
||||
setAnswers({});
|
||||
setMeta({});
|
||||
setSteps([]);
|
||||
setShots([]);
|
||||
setPrefillLog([]);
|
||||
setIssues([]);
|
||||
setDriveSteps([]);
|
||||
setError("");
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ status, url, title, company, fields, answers, meta, steps, shots, prefillLog, issues, driveSteps, error, open, prefill, setAnswer, fill, agentFill, reset }),
|
||||
[status, url, title, company, fields, answers, meta, steps, shots, prefillLog, issues, driveSteps, error, open, prefill, setAnswer, fill, agentFill, reset],
|
||||
);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Send, X, Loader2, Settings, RotateCcw, ArrowUpRight, Sparkles } from "lucide-react";
|
||||
import { CoMark } from "@/components/co-mark";
|
||||
import { useJobs } from "@/components/jobs/job-store";
|
||||
import { usePipeline } from "@/components/pipeline/pipeline-provider";
|
||||
import { useApply } from "@/components/apply/apply-provider";
|
||||
import { useExplore } from "@/components/explore/explore-provider";
|
||||
import { WorkerCard } from "@/components/jobs/worker-card";
|
||||
import { dispatch, type ActionCtx, type DoneInfo } from "@/app/actions/registry";
|
||||
import { scoreNum } from "@/lib/format";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// ── message model: messages are PART arrays so a live worker card can render
|
||||
// inline next to text, both fed by the single JobsProvider store ──────────────
|
||||
type Part =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "note"; text: string }
|
||||
| { type: "card"; jobId: string }
|
||||
| { type: "batch"; batchId: string; jobIds: string[] }
|
||||
| { type: "confirm"; cid: string; summary: string; state: "pending" | "done" | "cancelled" };
|
||||
type Msg = { role: "user" | "assistant"; parts: Part[] };
|
||||
|
||||
const CONFIG_KEY = "career-ops:config";
|
||||
const CHAT_KEY = "career-ops:chat";
|
||||
// back-compat shims — the old directives still work, mapped onto the registry
|
||||
const NAV_RE = /<<\s*go:\s*(\/[a-z0-9/_-]*)\s*>>/gi;
|
||||
const REMEMBER_RE = /<<\s*remember:\s*([^>]+?)\s*>>/gi;
|
||||
|
||||
const GREETING =
|
||||
"Hi — I'm your career-ops assistant. I can walk you through onboarding, answer questions about your pipeline, or take you where you need to go. What would you like to do?";
|
||||
|
||||
// ── envelope parsing: act ONLY on complete <<act:ID {json}>> envelopes ────────
|
||||
function codeRanges(s: string): [number, number][] {
|
||||
const ranges: [number, number][] = [];
|
||||
const re = /```[\s\S]*?```/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(s))) ranges.push([m.index, m.index + m[0].length]);
|
||||
return ranges;
|
||||
}
|
||||
function inRanges(i: number, ranges: [number, number][]): boolean {
|
||||
return ranges.some(([a, b]) => i >= a && i < b);
|
||||
}
|
||||
function normalizeJson(s: string): string {
|
||||
return s
|
||||
.replace(/[“”]/g, '"')
|
||||
.replace(/[‘’]/g, "'")
|
||||
.replace(/,\s*}$/, "}")
|
||||
.trim();
|
||||
}
|
||||
type Env = { start: number; end: number; id: string; argsJson: string };
|
||||
function parseEnvelopes(acc: string): { complete: Env[]; hidePartialFrom: number } {
|
||||
const ranges = codeRanges(acc);
|
||||
const complete: Env[] = [];
|
||||
let hidePartialFrom = -1;
|
||||
const open = /<<act:([a-zA-Z]+)[ \t]+/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = open.exec(acc))) {
|
||||
const start = m.index;
|
||||
if (inRanges(start, ranges)) continue;
|
||||
const argsStart = m.index + m[0].length;
|
||||
const close = acc.indexOf(">>", argsStart);
|
||||
if (close === -1) {
|
||||
if (hidePartialFrom === -1 || start < hidePartialFrom) hidePartialFrom = start;
|
||||
continue;
|
||||
}
|
||||
complete.push({ start, end: close + 2, id: m[1], argsJson: acc.slice(argsStart, close).trim() });
|
||||
}
|
||||
return { complete, hidePartialFrom };
|
||||
}
|
||||
function removeRanges(s: string, cuts: [number, number][]): string {
|
||||
if (!cuts.length) return s;
|
||||
const merged = [...cuts].sort((a, b) => a[0] - b[0]);
|
||||
let out = "";
|
||||
let pos = 0;
|
||||
for (const [a, b] of merged) {
|
||||
if (a > pos) out += s.slice(pos, a);
|
||||
pos = Math.max(pos, b);
|
||||
}
|
||||
out += s.slice(pos);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Page awareness: describe the route so "this offer" / "apply" resolves to what
|
||||
// the user is looking at.
|
||||
function describePage(p: string): string {
|
||||
if (p === "/") return "Today / home — overview of the user's pipeline.";
|
||||
if (p === "/pipeline") return "Pipeline — the applications table + the inbox of pending job URLs.";
|
||||
const m = p.match(/^\/pipeline\/([^/]+)$/);
|
||||
if (m)
|
||||
return `The user is viewing the EVALUATION REPORT for application #${m[1]}. If they say "this offer", "apply", "evaluate it", "draft a cover letter", they mean application #${m[1]} — read reports/${m[1]}-*.md or the matching data/applications.md row and act on THAT one.`;
|
||||
if (p === "/analytics") return "Analytics — funnel, score distribution, top companies.";
|
||||
if (p === "/cv") return "CV editor (cv.md).";
|
||||
if (p === "/config") return "Config — CLI / engine setup.";
|
||||
if (p === "/apply") return "Apply — the form-proxy: the user is reviewing a job application re-rendered in plain language, pre-filled from their CV. You can write/revise answers via setApplyField.";
|
||||
if (p.startsWith("/jobs/")) return "Watching a running worker / evaluation in progress.";
|
||||
return `Route ${p}.`;
|
||||
}
|
||||
|
||||
// ── persistence migration: old {role,content:string} → parts[] ────────────────
|
||||
function migrate(raw: unknown): Msg[] | null {
|
||||
if (!Array.isArray(raw)) return null;
|
||||
return raw
|
||||
.map((m): Msg | null => {
|
||||
if (!m || typeof m !== "object") return null;
|
||||
const role = (m as { role?: string }).role === "user" ? "user" : "assistant";
|
||||
if (Array.isArray((m as { parts?: unknown }).parts)) {
|
||||
// keep only serializable parts (drop transient pending confirms)
|
||||
const parts = ((m as { parts: Part[] }).parts).filter(
|
||||
(p) => p.type !== "confirm" || p.state !== "pending",
|
||||
);
|
||||
return { role, parts };
|
||||
}
|
||||
const content = (m as { content?: string }).content;
|
||||
return { role, parts: [{ type: "text", text: typeof content === "string" ? content : "" }] };
|
||||
})
|
||||
.filter((x): x is Msg => !!x);
|
||||
}
|
||||
function msgText(m: Msg): string {
|
||||
return m.parts.filter((p): p is Extract<Part, { type: "text" }> => p.type === "text").map((p) => p.text).join(" ").trim();
|
||||
}
|
||||
|
||||
export function AssistantConsole() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [cliId, setCliId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<Msg[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { jobs, startJob } = useJobs();
|
||||
const pipeline = usePipeline();
|
||||
const apply = useApply();
|
||||
|
||||
// refs so the streaming closure always sees the latest jobs/pipeline/apply/cli
|
||||
const jobsRef = useRef(jobs);
|
||||
jobsRef.current = jobs;
|
||||
const pipelineRef = useRef(pipeline);
|
||||
pipelineRef.current = pipeline;
|
||||
const applyRef = useRef(apply);
|
||||
applyRef.current = apply;
|
||||
const explore = useExplore();
|
||||
const exploreRef = useRef(explore);
|
||||
exploreRef.current = explore;
|
||||
const handledRef = useRef<Set<string>>(new Set());
|
||||
const confirmRuns = useRef<Map<string, () => DoneInfo>>(new Map());
|
||||
|
||||
// selected CLI from Config (reacts to changes in other tabs)
|
||||
useEffect(() => {
|
||||
function read() {
|
||||
try {
|
||||
const raw = localStorage.getItem(CONFIG_KEY);
|
||||
setCliId(raw ? JSON.parse(raw).cliId || null : null);
|
||||
} catch {
|
||||
setCliId(null);
|
||||
}
|
||||
}
|
||||
read();
|
||||
window.addEventListener("storage", read);
|
||||
return () => window.removeEventListener("storage", read);
|
||||
}, []);
|
||||
|
||||
// restore + persist conversation
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(CHAT_KEY);
|
||||
const m = raw ? migrate(JSON.parse(raw)) : null;
|
||||
if (m && m.length) setMessages(m);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!messages.length) return;
|
||||
try {
|
||||
const serializable = messages
|
||||
.slice(-30)
|
||||
.map((m) => ({ role: m.role, parts: m.parts.filter((p) => p.type !== "confirm" || p.state !== "pending") }));
|
||||
localStorage.setItem(CHAT_KEY, JSON.stringify(serializable));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && messages.length === 0) setMessages([{ role: "assistant", parts: [{ type: "text", text: GREETING }] }]);
|
||||
}, [open, messages.length]);
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// ── message mutators (operate on the last assistant message) ──
|
||||
function patchLastAssistant(ms: Msg[], fn: (m: Msg) => Msg): Msg[] {
|
||||
const copy = [...ms];
|
||||
for (let i = copy.length - 1; i >= 0; i--) {
|
||||
if (copy[i].role === "assistant") {
|
||||
copy[i] = fn(copy[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
const setStreamText = (text: string) =>
|
||||
setMessages((ms) =>
|
||||
patchLastAssistant(ms, (m) => {
|
||||
const parts = [...m.parts];
|
||||
const idx = parts.findIndex((p) => p.type === "text");
|
||||
if (idx === -1) parts.unshift({ type: "text", text });
|
||||
else parts[idx] = { type: "text", text };
|
||||
return { ...m, parts };
|
||||
}),
|
||||
);
|
||||
const appendParts = (newParts: Part[]) =>
|
||||
setMessages((ms) => patchLastAssistant(ms, (m) => ({ ...m, parts: [...m.parts, ...newParts] })));
|
||||
|
||||
function appendCards(info: DoneInfo) {
|
||||
const ids = info.jobIds ?? [];
|
||||
if (!ids.length) {
|
||||
if (info.note) appendParts([{ type: "note", text: info.note }]);
|
||||
return;
|
||||
}
|
||||
if (info.batchId && ids.length > 1) appendParts([{ type: "batch", batchId: info.batchId, jobIds: ids }]);
|
||||
else appendParts(ids.map((jobId) => ({ type: "card" as const, jobId })));
|
||||
}
|
||||
|
||||
function buildCtx(): ActionCtx {
|
||||
return {
|
||||
push: (p) => router.push(p),
|
||||
replace: (p) => router.replace(p),
|
||||
startJob,
|
||||
inbox: pipelineRef.current.inbox,
|
||||
applications: pipelineRef.current.applications,
|
||||
jobForUrl: (url) => {
|
||||
const m = jobsRef.current.filter((j) => j.input === url).sort((a, b) => b.startedAt - a.startedAt);
|
||||
return m[0];
|
||||
},
|
||||
rememberFact: (fact) => {
|
||||
fetch("/api/memory", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fact }),
|
||||
}).catch(() => {});
|
||||
},
|
||||
writeStatus: (n, status) => {
|
||||
fetch("/api/status", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ n, status }),
|
||||
})
|
||||
.then(() => {
|
||||
router.refresh();
|
||||
pipelineRef.current.refetch();
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
setApplyField: (idOrLabel, value) => applyRef.current.setAnswer(idOrLabel, value),
|
||||
startApply: (u) => {
|
||||
router.push("/apply");
|
||||
setTimeout(() => applyRef.current.open(u), 60);
|
||||
},
|
||||
applyExplore: (patch, opts) => exploreRef.current.applyPatch(patch, opts),
|
||||
writeProfile: (patch) => {
|
||||
fetch("/api/profile", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch) })
|
||||
.then(() => router.refresh())
|
||||
.catch(() => {});
|
||||
},
|
||||
writePortals: (roles, location) => {
|
||||
fetch("/api/portals", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roles, location }) }).catch(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runDispatch(id: string, args: Record<string, unknown>) {
|
||||
const res = dispatch(id, args, buildCtx());
|
||||
if (res.status === "done") appendCards(res);
|
||||
else if (res.status === "ignored") {
|
||||
if (res.note) appendParts([{ type: "note", text: res.note }]);
|
||||
} else if (res.status === "confirm") {
|
||||
const cid = `c-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
|
||||
confirmRuns.current.set(cid, res.run);
|
||||
appendParts([{ type: "confirm", cid, summary: res.summary, state: "pending" }]);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfirm(cid: string, accept: boolean) {
|
||||
const run = confirmRuns.current.get(cid);
|
||||
confirmRuns.current.delete(cid);
|
||||
const info = accept && run ? run() : null;
|
||||
setMessages((ms) =>
|
||||
ms.map((m) => {
|
||||
if (!m.parts.some((p) => p.type === "confirm" && p.cid === cid)) return m;
|
||||
const parts: Part[] = m.parts.map((p) =>
|
||||
p.type === "confirm" && p.cid === cid ? { ...p, state: accept ? "done" : "cancelled" } : p,
|
||||
);
|
||||
if (info?.jobIds?.length) {
|
||||
if (info.batchId && info.jobIds.length > 1) parts.push({ type: "batch", batchId: info.batchId, jobIds: info.jobIds });
|
||||
else parts.push(...info.jobIds.map((jobId) => ({ type: "card" as const, jobId })));
|
||||
}
|
||||
return { ...m, parts };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// compact pipeline snapshot for the model (counts + per-company pending — lets
|
||||
// it offer/act on "all the Anthropic ones" without re-reading files)
|
||||
function pipelineContext(): string {
|
||||
const pending = pipelineRef.current.inbox.filter((j) => !j.done);
|
||||
if (!pending.length) return "";
|
||||
const counts = new Map<string, number>();
|
||||
for (const j of pending) counts.set(j.company, (counts.get(j.company) ?? 0) + 1);
|
||||
const top = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 14);
|
||||
return `\n\nINBOX SNAPSHOT: ${pending.length} pending postings. By company: ${top
|
||||
.map(([c, n]) => `${c} (${n})`)
|
||||
.join(", ")}. To evaluate every pending posting for one company, emit evaluateCompany with just the company name.`;
|
||||
}
|
||||
|
||||
// When the user is on /apply, expose the proxy form's fields + current answers
|
||||
// so the assistant can write/revise any answer (setApplyField) on request.
|
||||
function applyContext(): string {
|
||||
const ap = applyRef.current;
|
||||
if (!pathname.startsWith("/apply") || !ap.fields.length) return "";
|
||||
const lines = ap.fields
|
||||
.map((f) => `- ${f.label || f.id}${ap.meta[f.id]?.needsConfirmation ? " (user confirms)" : ""}: ${ap.answers[f.id] ? `"${ap.answers[f.id].slice(0, 240)}"` : "(empty)"}`)
|
||||
.join("\n");
|
||||
return `\n\nAPPLY FORM — the user is filling "${ap.title}". Current answers:\n${lines}\nTo write or revise an answer, emit setApplyField {"field":"<label or id>","value":"<new text>"}. If a change reveals a durable preference or corrected fact, ALSO remember it.`;
|
||||
}
|
||||
|
||||
async function send(forced?: string) {
|
||||
const text = (forced ?? input).trim();
|
||||
if (!text || busy || !cliId) return;
|
||||
if (forced === undefined) setInput("");
|
||||
const history = messages.filter((m) => msgText(m) && msgText(m) !== GREETING).map((m) => ({ role: m.role, content: msgText(m) }));
|
||||
setMessages((m) => [...m, { role: "user", parts: [{ type: "text", text }] }, { role: "assistant", parts: [{ type: "text", text: "" }] }]);
|
||||
setBusy(true);
|
||||
handledRef.current = new Set();
|
||||
const shimsDone = new Set<string>();
|
||||
try {
|
||||
const res = await fetch("/api/assistant", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: text, cliId, history, pageContext: describePage(pathname) + pipelineContext() + applyContext() }),
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
setStreamText(`⚠️ ${err.error || "Assistant unavailable."}`);
|
||||
return;
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let acc = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
acc += dec.decode(value, { stream: true });
|
||||
|
||||
const { complete, hidePartialFrom } = parseEnvelopes(acc);
|
||||
const cuts: [number, number][] = complete.map((e) => [e.start, e.end]);
|
||||
if (hidePartialFrom >= 0) cuts.push([hidePartialFrom, acc.length]);
|
||||
let display = removeRanges(acc, cuts);
|
||||
|
||||
// back-compat shims (strip + queue) on the cleaned text
|
||||
const shimNavs: string[] = [];
|
||||
const shimRems: string[] = [];
|
||||
display = display.replace(NAV_RE, (_, p) => {
|
||||
shimNavs.push(p);
|
||||
return "";
|
||||
});
|
||||
display = display.replace(REMEMBER_RE, (_, f) => {
|
||||
shimRems.push(String(f).trim());
|
||||
return "";
|
||||
});
|
||||
setStreamText(display.trimStart());
|
||||
|
||||
for (const e of complete) {
|
||||
const key = `${e.start}|${e.id}|${e.argsJson}`;
|
||||
if (handledRef.current.has(key)) continue;
|
||||
handledRef.current.add(key);
|
||||
let args: Record<string, unknown>;
|
||||
try {
|
||||
args = JSON.parse(normalizeJson(e.argsJson));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
runDispatch(e.id, args);
|
||||
}
|
||||
for (const p of shimNavs) {
|
||||
const k = `go:${p}`;
|
||||
if (!shimsDone.has(k)) {
|
||||
shimsDone.add(k);
|
||||
runDispatch("navigate", { path: p });
|
||||
}
|
||||
}
|
||||
for (const f of shimRems) {
|
||||
const k = `rem:${f}`;
|
||||
if (f && !shimsDone.has(k)) {
|
||||
shimsDone.add(k);
|
||||
runDispatch("remember", { fact: f });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!acc.trim()) setStreamText("_(no output — is the CLI authenticated?)_");
|
||||
} catch {
|
||||
setStreamText("⚠️ Connection error.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
router.refresh();
|
||||
pipelineRef.current.refetch();
|
||||
}
|
||||
}
|
||||
|
||||
function resetChat() {
|
||||
setMessages([{ role: "assistant", parts: [{ type: "text", text: GREETING }] }]);
|
||||
confirmRuns.current.clear();
|
||||
try {
|
||||
localStorage.removeItem(CHAT_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// Other surfaces (e.g. the onboarding banner) can open the assistant and kick
|
||||
// off a turn via a window event.
|
||||
const sendRef = useRef<(m?: string) => void>(() => {});
|
||||
sendRef.current = send;
|
||||
useEffect(() => {
|
||||
function onOpen(e: Event) {
|
||||
setOpen(true);
|
||||
const msg = (e as CustomEvent).detail?.message as string | undefined;
|
||||
if (msg) setTimeout(() => sendRef.current(msg), 80);
|
||||
}
|
||||
window.addEventListener("co-assistant", onOpen);
|
||||
return () => window.removeEventListener("co-assistant", onOpen);
|
||||
}, []);
|
||||
|
||||
// ── proactive suggestion chips (onboarding + offer-driven next steps) ──
|
||||
const suggestions = useMemo(() => {
|
||||
const chips: { label: string; send: string }[] = [];
|
||||
const rep = pathname.match(/^\/pipeline\/(.+)$/);
|
||||
if (rep) {
|
||||
chips.push({ label: "Why this score?", send: "Walk me through why this offer scored the way it did — strengths and red flags." });
|
||||
chips.push({ label: "Should I apply?", send: "Given my profile, should I apply to this one? Be honest." });
|
||||
chips.push({ label: "Draft a cover letter", send: "Draft a short, sharp cover letter for this role." });
|
||||
return chips;
|
||||
}
|
||||
const pending = pipeline.inbox.filter((j) => !j.done);
|
||||
if (!pipeline.applications.length && !pending.length) {
|
||||
return [
|
||||
{ label: "Help me get set up", send: "Help me get started with career-ops — what do you need from me?" },
|
||||
{ label: "Improve my CV", send: "Look at my CV and suggest the highest-impact improvements." },
|
||||
];
|
||||
}
|
||||
if (pending.length) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const j of pending) counts.set(j.company, (counts.get(j.company) ?? 0) + 1);
|
||||
const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
||||
if (top && top[1] > 1) chips.push({ label: `Evaluate all ${top[0]} (${top[1]})`, send: `Evaluate all the pending ${top[0]} postings in my inbox.` });
|
||||
chips.push({ label: `Triage inbox (${pending.length})`, send: `I have ${pending.length} postings in my inbox — which should I evaluate first, and why?` });
|
||||
}
|
||||
const strong = pipeline.applications.filter((a) => scoreNum(a.score) >= 4.5).length;
|
||||
if (strong) chips.push({ label: "Strong matches to act on", send: "Show me my strongest matches (4.5+) I haven't applied to yet, and tell me which to prioritise." });
|
||||
chips.push({ label: "What should I do today?", send: "Look at my pipeline and tell me the 3 highest-leverage things I should do today." });
|
||||
return chips.slice(0, 4);
|
||||
}, [pathname, pipeline.inbox, pipeline.applications]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-5 right-5 z-50 flex items-center gap-2 rounded-full border border-border bg-surface/90 py-1.5 pl-1.5 pr-4 shadow-lg backdrop-blur transition-colors hover:bg-surface-hover"
|
||||
aria-label="Open assistant"
|
||||
>
|
||||
<CoMark size={26} />
|
||||
<span className="text-sm font-medium">Ask</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="fixed bottom-5 right-5 z-50 flex h-[600px] max-h-[80vh] w-[400px] max-w-[calc(100vw-2.5rem)] flex-col overflow-hidden rounded-2xl border border-border bg-surface shadow-2xl">
|
||||
<header className="flex items-center gap-2.5 border-b border-border px-4 py-3">
|
||||
<CoMark size={26} />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold tracking-tight">Assistant</div>
|
||||
<div className="text-xs text-faint">{cliId ? `via ${cliId}` : "no CLI configured"}</div>
|
||||
</div>
|
||||
<button onClick={resetChat} className="rounded-md p-1.5 text-muted transition-colors hover:bg-surface-hover hover:text-foreground" aria-label="New chat" title="New chat">
|
||||
<RotateCcw className="size-4" />
|
||||
</button>
|
||||
<button onClick={() => setOpen(false)} className="rounded-md p-1.5 text-muted transition-colors hover:bg-surface-hover hover:text-foreground" aria-label="Close assistant">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto px-4 py-4">
|
||||
{messages.map((m, i) => {
|
||||
const hasVisible = m.parts.some((p) => (p.type === "text" && p.text.trim()) || p.type !== "text");
|
||||
const isLast = i === messages.length - 1;
|
||||
return (
|
||||
<div key={i} className={cn("flex", m.role === "user" ? "justify-end" : "justify-start")}>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[88%] rounded-2xl px-3.5 py-2 text-sm",
|
||||
m.role === "user" ? "bg-brand text-brand-foreground" : "w-full bg-surface-hover text-foreground",
|
||||
)}
|
||||
>
|
||||
{m.role === "user" ? (
|
||||
msgText(m)
|
||||
) : !hasVisible && busy && isLast ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{m.parts.map((p, j) => (
|
||||
<PartView key={j} part={p} jobs={jobs} onConfirm={resolveConfirm} onOpen={() => {}} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* proactive suggestion chips — onboarding + offer-driven next steps */}
|
||||
{cliId && !busy && suggestions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 px-3 pb-1 pt-0.5">
|
||||
{suggestions.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => send(s.send)}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border bg-surface/60 px-2.5 py-1 text-xs text-muted transition-colors hover:border-brand/40 hover:bg-brand-soft hover:text-brand"
|
||||
>
|
||||
<Sparkles className="size-3 text-brand/70" />
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!cliId && (
|
||||
<Link
|
||||
href="/config"
|
||||
onClick={() => setOpen(false)}
|
||||
className="mx-4 mb-2 flex items-center gap-2 rounded-lg border border-border bg-surface/50 px-3 py-2 text-xs text-muted transition-colors hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
<Settings className="size-3.5" /> Pick a CLI in Config to enable the assistant →
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
placeholder={cliId ? "Ask anything…" : "Configure a CLI first"}
|
||||
rows={1}
|
||||
disabled={!cliId}
|
||||
className="max-h-32 flex-1 resize-none rounded-xl border border-border bg-surface/60 px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint focus:border-brand/50 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={() => send()}
|
||||
disabled={busy || !input.trim() || !cliId}
|
||||
className="rounded-xl bg-brand p-2 text-brand-foreground transition-colors hover:bg-brand-200 disabled:opacity-40"
|
||||
aria-label="Send"
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── part renderers ──
|
||||
function PartView({
|
||||
part,
|
||||
jobs,
|
||||
onConfirm,
|
||||
}: {
|
||||
part: Part;
|
||||
jobs: ReturnType<typeof useJobs>["jobs"];
|
||||
onConfirm: (cid: string, accept: boolean) => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
if (part.type === "text") {
|
||||
if (!part.text.trim()) return null;
|
||||
return (
|
||||
<div className="report-prose text-sm [&_*]:my-1 [&>:first-child]:mt-0 [&>:last-child]:mb-0">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{part.text}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (part.type === "note") {
|
||||
return <div className="text-xs italic text-faint">{part.text}</div>;
|
||||
}
|
||||
if (part.type === "card") {
|
||||
const job = jobs.find((j) => j.id === part.jobId);
|
||||
if (!job)
|
||||
return (
|
||||
<Link href={`/jobs/${part.jobId}`} className="block rounded-xl border border-border bg-surface/40 p-2.5 text-xs text-faint hover:text-foreground">
|
||||
Worker finished earlier — open log →
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<WorkerCard
|
||||
job={job}
|
||||
variant="inline"
|
||||
trailing={
|
||||
<Link href={`/jobs/${job.id}`} className="text-faint transition-colors hover:text-brand" aria-label="Open worker">
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (part.type === "batch") {
|
||||
const children = part.jobIds.map((id) => jobs.find((j) => j.id === id)).filter(Boolean);
|
||||
const done = children.filter((j) => j!.status === "done").length;
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface/40 p-2.5">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium">
|
||||
<Sparkles className="size-3.5 text-brand" />
|
||||
{part.jobIds.length} evaluations
|
||||
<span className="ml-auto tabular-nums text-faint">
|
||||
{done}/{part.jobIds.length} done
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{children.map((j) => (
|
||||
<WorkerCard
|
||||
key={j!.id}
|
||||
job={j!}
|
||||
variant="inline"
|
||||
trailing={
|
||||
<Link href={`/jobs/${j!.id}`} className="text-faint transition-colors hover:text-brand" aria-label="Open worker">
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (part.type === "confirm") {
|
||||
return (
|
||||
<div className="rounded-xl border border-brand/40 bg-brand-soft p-2.5">
|
||||
<div className="text-xs font-medium text-foreground">{part.summary}</div>
|
||||
{part.state === "pending" ? (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
onClick={() => onConfirm(part.cid, true)}
|
||||
className="rounded-full bg-brand px-3 py-1 text-xs font-medium text-brand-foreground transition-colors hover:bg-brand-200"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onConfirm(part.cid, false)}
|
||||
className="rounded-full border border-border px-3 py-1 text-xs text-muted transition-colors hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-xs text-faint">{part.state === "done" ? "✓ started" : "cancelled"}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bug, X, ShieldCheck, ThumbsUp, Search, Loader2 } from "lucide-react";
|
||||
import { collect, fingerprint, issueBody, issueUrl, type Diag } from "@/lib/report/report";
|
||||
import "@/lib/report/logbuf"; // install the client error ring-buffer (side-effect)
|
||||
|
||||
type SimilarIssue = { number: number; title: string; url: string };
|
||||
|
||||
// Dupe-deflection at write (the maintainer's #1 triage cost): search open
|
||||
// issues client-side via GitHub's public search API — no key, no server of
|
||||
// ours. Best-effort: rate-limited or offline → silently no suggestions.
|
||||
const searchCache = new Map<string, SimilarIssue[]>();
|
||||
async function searchIssues(q: string): Promise<SimilarIssue[]> {
|
||||
const cached = searchCache.get(q);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.github.com/search/issues?per_page=4&q=${encodeURIComponent(`repo:santifer/career-ops is:issue is:open ${q}`)}`,
|
||||
{ headers: { Accept: "application/vnd.github+json" } },
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const d = await res.json();
|
||||
const items: SimilarIssue[] = (d.items || []).map((i: { number: number; title: string; html_url: string }) => ({
|
||||
number: i.number,
|
||||
title: i.title,
|
||||
url: i.html_url,
|
||||
}));
|
||||
searchCache.set(q, items);
|
||||
return items;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Beta/RC differentiator: a small version+channel pill (only on a pre-release
|
||||
// channel) + a one-click "Report a bug" that opens a PRE-FILLED GitHub issue. No
|
||||
// telemetry to any server (local-first / firewall) — the user reviews the exact,
|
||||
// PII-scrubbed payload (preview-then-confirm) and clicks to open the issue himself.
|
||||
export function BetaBanner() {
|
||||
const [meta, setMeta] = useState<{ version: string; channel: string; sha: string } | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [desc, setDesc] = useState("");
|
||||
const [diag, setDiag] = useState<Diag | null>(null);
|
||||
const [similar, setSimilar] = useState<SimilarIssue[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
// Text search is behind an EXPLICIT click, never as-you-type: the user's
|
||||
// words (which can name a company) must not reach api.github.com at keystroke
|
||||
// time — that would break the banner's "nothing is sent until you click"
|
||||
// pledge, and scrub() is a path/secret scrubber, not a free-text one, so it
|
||||
// could not remove the company name anyway. The click IS the consent.
|
||||
const checkExisting = async () => {
|
||||
const words = desc.trim().split(/\s+/).slice(0, 6).join(" ");
|
||||
if (!words) return;
|
||||
setSearching(true);
|
||||
const found = await searchIssues(`label:web-alpha ${words}`);
|
||||
setSearching(false);
|
||||
if (found.length) setSimilar(found);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/version")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (d?.channel && d.channel !== "stable") setMeta(d);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
const openReport = async () => {
|
||||
const d = await collect();
|
||||
setDiag(d);
|
||||
setOpen(true);
|
||||
// One exact-match search by fingerprint: same bug already filed → the
|
||||
// strongest dedupe signal, shown before the user types a word.
|
||||
searchIssues(`in:body "${fingerprint(d)}"`).then((found) => {
|
||||
if (found.length) setSimilar(found);
|
||||
});
|
||||
};
|
||||
|
||||
if (!meta) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-3 left-3 z-[70] flex items-center gap-2 rounded-full border border-brand/30 bg-surface/90 px-3 py-1.5 text-xs shadow-lg backdrop-blur-md">
|
||||
<span className="flex items-center gap-1.5 font-medium text-brand">
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-brand" /> {meta.version} · {meta.channel}
|
||||
</span>
|
||||
{meta.sha && <span className="hidden font-mono text-faint sm:inline">{meta.sha}</span>}
|
||||
<button onClick={openReport} className="ml-1 inline-flex items-center gap-1 rounded-full bg-brand-soft px-2 py-0.5 font-medium text-brand transition-colors hover:bg-brand/15">
|
||||
<Bug className="size-3" /> Report a bug
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && diag && (
|
||||
<div className="fixed inset-0 z-[96] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="Report a bug" onClick={() => setOpen(false)}>
|
||||
<div className="w-full max-w-lg rounded-2xl border border-border bg-[var(--bg)] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Bug className="size-4 text-brand" />
|
||||
<h2 className="text-sm font-semibold text-foreground">Report a bug · {diag.channel}</h2>
|
||||
<button onClick={() => setOpen(false)} aria-label="Close" className="ml-auto text-faint transition-colors hover:text-foreground">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
rows={4}
|
||||
autoFocus
|
||||
placeholder="What were you doing, and what went wrong?"
|
||||
className="w-full resize-none rounded-lg border border-border bg-surface/60 px-3 py-2 text-sm outline-none transition focus:border-brand/50 focus:ring-2 focus:ring-brand/20"
|
||||
/>
|
||||
{desc.trim().split(/\s+/).length >= 3 && (
|
||||
<button
|
||||
onClick={checkExisting}
|
||||
disabled={searching}
|
||||
className="mt-2 inline-flex items-center gap-1.5 text-xs text-muted transition-colors hover:text-brand disabled:opacity-60"
|
||||
>
|
||||
{searching ? <Loader2 className="size-3 animate-spin" /> : <Search className="size-3" />} Check for existing reports first
|
||||
</button>
|
||||
)}
|
||||
<details className="mt-3 rounded-lg border border-border bg-surface/40">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-xs font-medium text-muted">Exactly what gets attached — review before sending ↓</summary>
|
||||
<pre className="max-h-52 overflow-auto whitespace-pre-wrap border-t border-border px-3 py-2 font-mono text-[11px] leading-relaxed text-muted">{issueBody(diag, desc)}</pre>
|
||||
</details>
|
||||
{similar.length > 0 && (
|
||||
<div className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2">
|
||||
<p className="text-xs font-medium text-amber-700 dark:text-amber-400">Already reported? A 👍 on an existing issue beats a duplicate:</p>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{similar.map((s) => (
|
||||
<li key={s.number}>
|
||||
<a href={s.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1.5 text-xs text-foreground underline-offset-2 transition-colors hover:text-brand hover:underline">
|
||||
<ThumbsUp className="size-3 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<span className="font-mono">#{s.number}</span> {s.title.slice(0, 60)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 flex items-start gap-1.5 text-[11px] text-faint">
|
||||
<ShieldCheck className="mt-px size-3.5 shrink-0 text-emerald-500" /> Opens a GitHub issue you confirm — nothing is sent until you click. NEVER includes your CV, profile, application answers, or job URLs.
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button onClick={() => setOpen(false)} className="rounded-full px-4 py-2 text-sm text-muted transition-colors hover:text-foreground">
|
||||
Cancel
|
||||
</button>
|
||||
<a
|
||||
href={issueUrl(diag, desc)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => setOpen(false)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-brand px-4 py-2 text-sm font-medium text-brand-foreground transition-colors hover:bg-brand-200"
|
||||
>
|
||||
<Bug className="size-4" /> Open GitHub issue
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { instrumentSerif } from "@/lib/fonts";
|
||||
|
||||
// Brand mark — lowercase "co" on brand orange in Instrument Serif. Matches the
|
||||
// favicon (src/app/icon.tsx) and the career-ops-docs home one-for-one so the
|
||||
// app reads as a sibling. Dual meaning: "co" of career-ops AND "co" of
|
||||
// companies — the word the manifesto inverts ("…AI to choose companies").
|
||||
export function CoMark({ size = 28 }: { size?: number }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${instrumentSerif.className} inline-flex shrink-0 items-center justify-center rounded-md bg-brand text-white`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
fontSize: Math.round(size * 0.78),
|
||||
letterSpacing: "0.01em",
|
||||
lineHeight: 1,
|
||||
paddingBottom: Math.round(size * 0.08),
|
||||
}}
|
||||
>
|
||||
co
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { companyDomain, companyInitials, monogramHue } from "@/lib/company";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const CONFIG_KEY = "career-ops:config";
|
||||
|
||||
// A small company mark: the real favicon on a white tile when logos are enabled
|
||||
// and resolvable, otherwise a deterministic colored monogram. The monogram is
|
||||
// the always-rendered base layer (SSR-safe + offline floor); the logo fades in
|
||||
// on top once loaded, and any failure (404/offline/disabled) just leaves the
|
||||
// monogram showing. See lib/company.ts + /api/logo.
|
||||
export function CompanyLogo({
|
||||
name,
|
||||
size = 20,
|
||||
className,
|
||||
}: {
|
||||
name: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const [enabled, setEnabled] = useState(false); // monogram-only until config known
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(CONFIG_KEY);
|
||||
const v = raw ? JSON.parse(raw) : null;
|
||||
setEnabled(v?.logos !== false); // default ON unless explicitly disabled
|
||||
} catch {
|
||||
setEnabled(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const domain = companyDomain(name);
|
||||
const hue = monogramHue(name);
|
||||
const radius = Math.max(4, Math.round(size * 0.28));
|
||||
const showImg = enabled && !!domain && !failed;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("relative inline-flex shrink-0 items-center justify-center overflow-hidden ring-1 ring-black/5 dark:ring-white/10", className)}
|
||||
style={{ width: size, height: size, borderRadius: radius }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className="absolute inset-0 flex items-center justify-center font-semibold leading-none text-white"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, hsl(${hue} 55% 48%), hsl(${(hue + 28) % 360} 52% 38%))`,
|
||||
fontSize: Math.round(size * 0.42),
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
{companyInitials(name)}
|
||||
</span>
|
||||
{showImg && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/logo?domain=${encodeURIComponent(domain!)}`}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setFailed(true)}
|
||||
className="absolute inset-0 h-full w-full bg-white object-contain transition-opacity duration-200"
|
||||
style={{ opacity: loaded ? 1 : 0, padding: Math.max(1, Math.round(size * 0.1)) }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
KeyRound,
|
||||
TerminalSquare,
|
||||
Terminal,
|
||||
Loader2,
|
||||
CircleDashed,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Cli = {
|
||||
id: string;
|
||||
name: string;
|
||||
run: string;
|
||||
url: string;
|
||||
installed: boolean;
|
||||
path: string | null;
|
||||
};
|
||||
|
||||
type Mode = "cli" | "key" | "manual";
|
||||
|
||||
const PROVIDERS = [
|
||||
{ id: "anthropic", label: "Anthropic (Claude)" },
|
||||
{ id: "openai", label: "OpenAI" },
|
||||
{ id: "google", label: "Google (Gemini)" },
|
||||
{ id: "openrouter", label: "OpenRouter" },
|
||||
] as const;
|
||||
|
||||
const STORAGE_KEY = "career-ops:config";
|
||||
|
||||
export function ConfigForm() {
|
||||
const [mode, setMode] = useState<Mode>("cli");
|
||||
const [clis, setClis] = useState<Cli[] | null>(null);
|
||||
const [cliId, setCliId] = useState<string>("");
|
||||
const [provider, setProvider] = useState("anthropic");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [logos, setLogos] = useState(true);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
// Load saved prefs
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const v = JSON.parse(raw);
|
||||
// key/manual are not wired yet (nothing reads them) → never restore into
|
||||
// those dead panels; only the Installed-CLI path is functional.
|
||||
if (v.mode === "cli") setMode("cli");
|
||||
if (v.cliId) setCliId(v.cliId);
|
||||
if (v.provider) setProvider(v.provider);
|
||||
if (typeof v.logos === "boolean") setLogos(v.logos);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Detect installed CLIs
|
||||
useEffect(() => {
|
||||
fetch("/api/clis")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
const list: Cli[] = d.clis ?? [];
|
||||
setClis(list);
|
||||
// auto-select first installed if nothing chosen yet
|
||||
setCliId((prev) => prev || list.find((c) => c.installed)?.id || "");
|
||||
})
|
||||
.catch(() => setClis([]));
|
||||
}, []);
|
||||
|
||||
function save() {
|
||||
// The API key is deliberately NOT persisted: nothing reads it yet (the
|
||||
// key/manual panel is unwired) and a secret must never sit in clear-text
|
||||
// localStorage. Keys belong in the user's own CLI/provider config.
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ mode, cliId, provider, logos }));
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
|
||||
const installed = clis?.filter((c) => c.installed) ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 py-10">
|
||||
<h1 className="font-display text-2xl tracking-tight text-landing">Config</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Run career-ops on your own AI, right on your computer. Your CV and data never leave your machine.
|
||||
</p>
|
||||
|
||||
{/* Engine mode */}
|
||||
<label className="mt-8 mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
AI Engine
|
||||
</label>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<ModeCard
|
||||
active={mode === "cli"}
|
||||
onClick={() => setMode("cli")}
|
||||
icon={Terminal}
|
||||
title="Use an AI tool you have"
|
||||
hint="Recommended"
|
||||
/>
|
||||
<ModeCard
|
||||
active={mode === "key"}
|
||||
onClick={() => setMode("key")}
|
||||
icon={KeyRound}
|
||||
title="Paste an AI key"
|
||||
hint="Coming soon"
|
||||
disabled
|
||||
/>
|
||||
<ModeCard
|
||||
active={mode === "manual"}
|
||||
onClick={() => setMode("manual")}
|
||||
icon={TerminalSquare}
|
||||
title="No setup needed"
|
||||
hint="Coming soon"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{mode === "cli" && (
|
||||
<div>
|
||||
<p className="mb-1 text-sm text-muted">
|
||||
career-ops uses an AI tool you already have — signed in, your own usage, nothing to paste.
|
||||
</p>
|
||||
<p className="mb-3 text-xs text-faint">Works with Claude Code, Codex, OpenCode and more — free ones work great.</p>
|
||||
{clis === null ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Loader2 className="size-4 animate-spin" /> Checking what's on your computer…
|
||||
</div>
|
||||
) : installed.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border bg-surface/30 p-4 text-sm text-muted">
|
||||
No AI tool yet? Free options like <span className="text-foreground">OpenCode</span> with Qwen or GLM work great.{" "}
|
||||
<a href="https://career-ops.org/docs/free-ai-engine" target="_blank" rel="noreferrer" className="inline-flex items-center gap-0.5 text-brand hover:underline">
|
||||
Get one free <ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{clis.map((c) => {
|
||||
const selected = c.id === cliId;
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl border px-4 py-3 text-sm transition-colors",
|
||||
selected
|
||||
? "border-brand/50 bg-brand-soft"
|
||||
: c.installed
|
||||
? "border-border bg-surface/50"
|
||||
: "border-border/60 bg-surface/20",
|
||||
)}
|
||||
>
|
||||
{c.installed ? (
|
||||
<Check className="size-4 shrink-0 text-emerald-400" />
|
||||
) : (
|
||||
<CircleDashed className="size-4 shrink-0 text-faint" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!c.installed}
|
||||
onClick={() => setCliId(c.id)}
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-2 text-left",
|
||||
c.installed ? "" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium",
|
||||
selected ? "text-foreground" : c.installed ? "" : "text-muted",
|
||||
)}
|
||||
>
|
||||
{c.name}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-faint">{c.run}</span>
|
||||
</button>
|
||||
{c.installed ? (
|
||||
<span className="hidden max-w-[40%] shrink-0 truncate text-xs text-faint sm:block">
|
||||
{c.path}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={c.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1 text-xs text-brand hover:underline"
|
||||
>
|
||||
Install <ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{installed.length === 0 && (
|
||||
<p className="rounded-xl border border-dashed border-border bg-surface/30 p-4 text-xs text-muted">
|
||||
No supported CLI found on your PATH. Install one (e.g. Claude Code, Gemini CLI, OpenCode) to get started.
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-faint">
|
||||
Best on <span className="text-muted">Claude Code</span> (live progress, the agentic apply + AI search,
|
||||
reliable evaluation persistence). Other CLIs work for the core flows with reduced features.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "key" && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
Provider
|
||||
</label>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{PROVIDERS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setProvider(p.id)}
|
||||
className={cn(
|
||||
"rounded-xl border px-4 py-2.5 text-left text-sm transition-colors",
|
||||
provider === p.id
|
||||
? "border-brand/50 bg-brand-soft text-foreground"
|
||||
: "border-border bg-surface/50 text-muted hover:bg-surface-hover hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
Paste an AI key
|
||||
</label>
|
||||
<p className="mb-2 text-xs text-faint">Bring a key from OpenAI, Anthropic, and others.</p>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-…"
|
||||
autoComplete="off"
|
||||
className="w-full rounded-xl border border-border bg-surface/60 px-4 py-2.5 font-mono text-sm outline-none transition-colors placeholder:text-faint focus:border-brand/50"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-faint">
|
||||
Stored only in this browser — never sent anywhere but your chosen provider.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "manual" && (
|
||||
<div className="rounded-xl border border-dashed border-border bg-surface/30 p-4 text-sm text-muted">
|
||||
The easiest way in — no keys, nothing to set up. On the roadmap.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Appearance / privacy */}
|
||||
<label className="mt-8 mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
Appearance
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogos((v) => !v)}
|
||||
className="flex w-full items-center justify-between gap-4 rounded-xl border border-border bg-surface/50 px-4 py-3 text-left transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-foreground">Company logos</span>
|
||||
<span className="mt-0.5 block text-xs text-faint">
|
||||
Show each company's real logo. Fetched once through your local server and cached on
|
||||
disk — only the employer domain is sent to a third party. Off = colored monograms only.
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"relative h-6 w-11 shrink-0 rounded-full transition-colors",
|
||||
logos ? "bg-brand" : "bg-surface-hover",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-0.5 size-5 rounded-full bg-white shadow transition-transform",
|
||||
logos ? "translate-x-[1.375rem]" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-brand px-5 py-2 text-sm font-medium text-brand-foreground transition-colors hover:bg-brand-200"
|
||||
>
|
||||
{saved ? <Check className="size-4" /> : null}
|
||||
{saved ? "Saved" : "Save config"}
|
||||
</button>
|
||||
<span className="text-xs text-faint">Local-first · on our roadmap</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
active,
|
||||
onClick,
|
||||
icon: Icon,
|
||||
title,
|
||||
hint,
|
||||
disabled,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
hint: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5 rounded-xl border px-4 py-3 text-left transition-colors",
|
||||
disabled
|
||||
? "cursor-not-allowed border-border bg-surface/30 opacity-55"
|
||||
: active
|
||||
? "border-brand/50 bg-brand-soft"
|
||||
: "border-border bg-surface/50 hover:bg-surface-hover",
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("size-4", active && !disabled ? "text-brand" : "text-muted")} />
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
<span className="text-xs text-faint">{hint}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Single-line monospace command + copy-to-clipboard — ported from the
|
||||
// career-ops-docs home. Truncates on narrow viewports (intent is "copy this").
|
||||
export function CopyableCommand({
|
||||
command,
|
||||
className,
|
||||
}: {
|
||||
command: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Non-secure context or permission denied — fail silently.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-xl border border-border bg-surface py-1.5 pl-4 pr-1.5 font-mono text-sm text-muted shadow-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<code className="min-w-0 flex-1 truncate">
|
||||
<span className="text-faint">$</span> {command}
|
||||
</code>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"hidden shrink-0 text-xs font-medium text-brand transition-opacity duration-200 sm:inline-block",
|
||||
copied ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
Copied
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? "Copied to clipboard" : "Copy command"}
|
||||
title={copied ? "Copied" : "Copy"}
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-md p-1.5 text-muted transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4 text-brand" aria-hidden="true" />
|
||||
) : (
|
||||
<CopyIcon className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Leaf, Coins, Sparkles } from "lucide-react";
|
||||
import { COST_META, type CostClass } from "@/lib/explore-cost";
|
||||
|
||||
// One primitive, four variants — the app's cost color-semantics (career-ops-ux
|
||||
// lock, for DESIGN_SYSTEM.md): GREEN = free/positive (celebrate); NEUTRAL/muted
|
||||
// (+ coin icon) = spend ("Uses tokens") — it INFORMS, it must not alarm nor
|
||||
// celebrate, and crucially it must NOT be brand-orange: orange is reserved for
|
||||
// the primary action (e.g. "Run your first FREE scan"), so an orange spend badge
|
||||
// would collide ("go/free" vs "costs") and shout louder than the action itself.
|
||||
// Muted spend AA-verified in both themes. Co-located style per the Tailwind v4
|
||||
// stale-CSS HMR gotcha.
|
||||
const CSS = `
|
||||
.co-cost{display:inline-flex;align-items:center;gap:.28rem;border-radius:999px;font-weight:600;line-height:1;white-space:nowrap;border:1px solid transparent}
|
||||
.co-cost[data-size="xs"]{font-size:9.5px;padding:.16rem .4rem;letter-spacing:.04em;text-transform:uppercase}
|
||||
.co-cost[data-size="sm"]{font-size:11px;padding:.24rem .5rem}
|
||||
.co-cost[data-tone="free"]{color:hsl(160 70% 38%);background:hsl(160 64% 46% / .12);border-color:hsl(160 64% 46% / .28)}
|
||||
html.dark .co-cost[data-tone="free"]{color:hsl(158 64% 60%);background:hsl(158 64% 52% / .13);border-color:hsl(158 64% 52% / .26)}
|
||||
.co-cost[data-tone="spend"]{color:hsl(35 9% 34%);background:hsl(35 8% 46% / .12);border-color:hsl(35 8% 46% / .26)}
|
||||
html.dark .co-cost[data-tone="spend"]{color:hsl(35 8% 70%);background:hsl(35 6% 60% / .12);border-color:hsl(35 6% 60% / .24)}
|
||||
.co-cost svg{width:.85em;height:.85em}
|
||||
`;
|
||||
|
||||
export function CostBadge({ kind, size = "sm", className = "" }: { kind: CostClass; size?: "xs" | "sm"; className?: string }) {
|
||||
const tone = kind === "spend" ? "spend" : "free";
|
||||
const Icon = kind === "spend" ? Coins : kind === "free-gemini" ? Sparkles : Leaf;
|
||||
const meta = COST_META[kind];
|
||||
return (
|
||||
<span className={`co-cost ${className}`} data-tone={tone} data-size={size} title={meta.tip}>
|
||||
<style>{CSS}</style>
|
||||
<Icon aria-hidden />
|
||||
{meta.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Check, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export function CvEditor() {
|
||||
const [content, setContent] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [exists, setExists] = useState(true);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/cv")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setContent(d.content ?? "");
|
||||
setExists(d.exists ?? false);
|
||||
})
|
||||
.finally(() => setLoaded(true));
|
||||
}, []);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/cv", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setDirty(false);
|
||||
setExists(true);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-8">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl tracking-tight text-landing">CV editor</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Edit <code className="text-foreground">cv.md</code> with live preview.
|
||||
{!exists && loaded && <span className="ml-1 text-faint">No cv.md yet — start typing to create it.</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving || !dirty}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-full px-5 py-2 text-sm font-medium transition-colors",
|
||||
dirty
|
||||
? "bg-brand text-brand-foreground hover:bg-brand-200"
|
||||
: "border border-border bg-surface text-muted",
|
||||
)}
|
||||
>
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : saved ? <Check className="size-4" /> : null}
|
||||
{saved ? "Saved" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!loaded ? (
|
||||
<div className="mt-6 text-sm text-muted">Loading…</div>
|
||||
) : (
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => {
|
||||
setContent(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
spellCheck={false}
|
||||
placeholder="# Your Name ## Summary ..."
|
||||
className="min-h-[60vh] w-full resize-none rounded-2xl border border-border bg-surface/50 p-4 font-mono text-sm leading-relaxed outline-none transition-colors placeholder:text-faint focus:border-brand/40"
|
||||
/>
|
||||
<article className="report-prose min-h-[60vh] overflow-auto rounded-2xl border border-border bg-surface/30 p-5">
|
||||
{content.trim() ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-muted">Preview appears here.</p>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Upload, FileText, Loader2, Check, AlertTriangle, Lock, ArrowRight, RotateCcw } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { instrumentSerif } from "@/lib/fonts";
|
||||
import { cvReadiness, parseCvStream, type CvSeed } from "@/lib/cv/quality";
|
||||
import { DEFAULT_FILTERS, filtersToParams } from "@/lib/explore";
|
||||
|
||||
type Phase = "input" | "parsing" | "review" | "saving" | "error";
|
||||
|
||||
function cliId(): string | null {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("career-ops:config") || "{}").cliId || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const STYLE = `
|
||||
.co-cvdrop{position:relative;border:1.5px dashed color-mix(in srgb, var(--fg) 22%, transparent);border-radius:1rem;transition:border-color .2s,background .2s}
|
||||
.co-cvdrop[data-over="true"]{border-color:hsl(26 73% 51%);background:hsl(26 73% 51% /.05)}
|
||||
.co-cvtrace{animation:co-rise .4s ease both}
|
||||
`;
|
||||
|
||||
export function CvIngest({ onSaved }: { onSaved?: () => void }) {
|
||||
const router = useRouter();
|
||||
const [phase, setPhase] = useState<Phase>("input");
|
||||
const [paste, setPaste] = useState("");
|
||||
const [over, setOver] = useState(false);
|
||||
const [trace, setTrace] = useState("");
|
||||
const [md, setMd] = useState("");
|
||||
const [seed, setSeed] = useState<CvSeed | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const readiness = md ? cvReadiness(md) : null;
|
||||
|
||||
// Stream the ingest, parsing markers live.
|
||||
const runStream = useCallback(async (init: RequestInit) => {
|
||||
setPhase("parsing");
|
||||
setTrace("Reading your CV…");
|
||||
setErr("");
|
||||
try {
|
||||
const r = await fetch("/api/cv/ingest", init);
|
||||
if (r.status === 404) {
|
||||
setErr("Connect an AI CLI in Config first — it parses your CV locally.");
|
||||
setPhase("error");
|
||||
return;
|
||||
}
|
||||
if (!r.body) {
|
||||
setErr("No response.");
|
||||
setPhase("error");
|
||||
return;
|
||||
}
|
||||
const reader = r.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
const parsed = parseCvStream(buf);
|
||||
if (parsed.error) {
|
||||
setErr(parsed.error === "unreadable" ? "I couldn't read text from that file (it may be a scanned image). Paste the text instead." : "Couldn't parse the CV — paste the text instead.");
|
||||
setPhase("error");
|
||||
return;
|
||||
}
|
||||
if (parsed.trace) setTrace(parsed.trace.split("\n").filter(Boolean).slice(-1)[0] || "Reading your CV…");
|
||||
if (parsed.markdown) setMd(parsed.markdown);
|
||||
if (parsed.seed) setSeed(parsed.seed);
|
||||
}
|
||||
const final = parseCvStream(buf);
|
||||
if (!final.markdown.trim()) {
|
||||
setErr("Couldn't read a CV there — paste the text instead.");
|
||||
setPhase("error");
|
||||
return;
|
||||
}
|
||||
setMd(final.markdown);
|
||||
setSeed(final.seed);
|
||||
setPhase("review");
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "stream error");
|
||||
setPhase("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const ingestText = (text: string) => {
|
||||
const id = cliId();
|
||||
if (!id) {
|
||||
setErr("needs-cli");
|
||||
setPhase("error");
|
||||
return;
|
||||
}
|
||||
void runStream({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, cliId: id }) });
|
||||
};
|
||||
|
||||
const ingestFile = (file: File) => {
|
||||
// .md/.txt/.markdown fast path — plain text, NO CLI needed, instant.
|
||||
if (/\.(md|markdown|txt)$/i.test(file.name)) {
|
||||
file
|
||||
.text()
|
||||
.then((t) => {
|
||||
if (!t.trim()) {
|
||||
setErr("That file looks empty — paste your CV instead.");
|
||||
setPhase("error");
|
||||
return;
|
||||
}
|
||||
setMd(t.trim());
|
||||
setPhase("review");
|
||||
})
|
||||
.catch(() => {
|
||||
setErr("Couldn't read that file — paste your CV instead.");
|
||||
setPhase("error");
|
||||
});
|
||||
return;
|
||||
}
|
||||
// PDF/other → the user's CLI parses it. Needs a configured CLI.
|
||||
const id = cliId();
|
||||
if (!id) {
|
||||
setErr("needs-cli");
|
||||
setPhase("error");
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("cliId", id);
|
||||
void runStream({ method: "POST", body: form });
|
||||
};
|
||||
|
||||
const [saveErr, setSaveErr] = useState("");
|
||||
const save = async () => {
|
||||
if (!md.trim()) {
|
||||
setSaveErr("Your CV looks empty — paste it again.");
|
||||
return;
|
||||
}
|
||||
setSaveErr("");
|
||||
setPhase("saving");
|
||||
try {
|
||||
const r = await fetch("/api/cv", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: md }) });
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
setSaveErr(d.error || "Couldn't save your CV — try again.");
|
||||
setPhase("review"); // keep the parsed CV so they don't lose it
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setSaveErr("Couldn't save your CV — check your connection and try again.");
|
||||
setPhase("review");
|
||||
return;
|
||||
}
|
||||
onSaved?.();
|
||||
// WOW #1 — land in the Explorer with the CV-derived filters in the URL + run=1,
|
||||
// so the Explorer auto-fires the FREE scan itself (robust, no push/replaceState race).
|
||||
// GENEROUS first scan so it never comes back empty (that would kill the wow): roles
|
||||
// only + a wide 30-day window; location stays a refinement for the deepen step, NOT a
|
||||
// hard exclude (allow=[] passes everything). Recall over precision for the first reveal.
|
||||
const roles = seed?.roles?.length ? seed.roles : seed?.title ? [seed.title] : [];
|
||||
const f = { ...DEFAULT_FILTERS, ats: [...DEFAULT_FILTERS.ats], positive: roles, sinceDays: 30 };
|
||||
const qs = filtersToParams(f);
|
||||
router.push(`/explore?${qs}${qs ? "&" : ""}run=1`);
|
||||
};
|
||||
|
||||
// ── INPUT ──
|
||||
if (phase === "input" || phase === "error") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<style>{STYLE}</style>
|
||||
<div
|
||||
className="co-cvdrop p-6"
|
||||
data-over={over}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setOver(true);
|
||||
}}
|
||||
onDragLeave={() => setOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) ingestFile(f);
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
value={paste}
|
||||
onChange={(e) => setPaste(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && paste.trim()) ingestText(paste.trim());
|
||||
}}
|
||||
placeholder="Paste your CV here — or drop a PDF / .md file below. Even a rough paste works; we'll clean it up."
|
||||
className="h-32 w-full resize-none bg-transparent text-[14px] leading-relaxed outline-none placeholder:text-faint"
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 border-t border-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface/50 px-3 py-1.5 text-xs font-medium text-foreground transition hover:border-brand/40 hover:text-brand max-sm:min-h-[44px] max-sm:px-4"
|
||||
>
|
||||
<Upload className="size-3.5" /> Upload PDF / file
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept=".pdf,.md,.markdown,.txt,.docx" hidden onChange={(e) => e.target.files?.[0] && ingestFile(e.target.files[0])} />
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-faint">
|
||||
<Lock className="size-3" /> Stays on your machine. Parsed by your own AI.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!paste.trim()}
|
||||
onClick={() => ingestText(paste.trim())}
|
||||
className="ml-auto inline-flex items-center gap-1.5 rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:brightness-110 disabled:opacity-50 max-sm:min-h-[44px]"
|
||||
>
|
||||
Read my CV <ArrowRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{phase === "error" &&
|
||||
(err === "needs-cli" ? (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-[13px] text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="size-3.5 shrink-0" />
|
||||
<span>To read a PDF, connect your AI CLI — or paste your CV text above (no setup needed).</span>
|
||||
<Link href="/config" className="ml-auto inline-flex items-center gap-1 rounded-md bg-amber-500/20 px-2.5 py-1 font-medium text-amber-700 transition hover:bg-amber-500/30 dark:text-amber-200">
|
||||
Connect your AI CLI <ArrowRight className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<p className="flex items-center gap-1.5 text-[13px] text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="size-3.5 shrink-0" /> {err}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PARSING (the 10s bridge) ──
|
||||
if (phase === "parsing") {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-surface/60 p-6 backdrop-blur-sm">
|
||||
<style>{STYLE}</style>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Loader2 className="size-4 animate-spin text-brand" />
|
||||
<span className={`${instrumentSerif.className} text-lg text-foreground`}>{trace || "Reading your CV…"}</span>
|
||||
</div>
|
||||
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
<span className="size-1.5 rounded-full bg-emerald-500" /> 0 tokens · $0.00 · local
|
||||
</div>
|
||||
{md && <div className="co-cvtrace mt-4 max-h-40 overflow-hidden rounded-lg border border-border bg-surface/40 p-3 text-[11px] text-faint">{md.slice(0, 400)}…</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── REVIEW (propose → confirm) ──
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-surface/60 p-4 backdrop-blur-sm md:p-5">
|
||||
<style>{STYLE}</style>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<FileText className="size-4 text-brand" />
|
||||
<h3 className={`${instrumentSerif.className} text-lg text-foreground`}>Here's your CV — review and save</h3>
|
||||
{readiness && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||
readiness.scoreable ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
{readiness.scoreable ? <Check className="size-3" /> : <AlertTriangle className="size-3" />}
|
||||
{readiness.scoreable ? "Ready to match" : "A bit thin"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{readiness?.hint && <p className="mb-2 text-[12px] text-amber-600 dark:text-amber-400">{readiness.hint}</p>}
|
||||
{saveErr && (
|
||||
<p className="mb-2 flex items-center gap-1.5 text-[12px] text-red-500">
|
||||
<AlertTriangle className="size-3.5 shrink-0" /> {saveErr}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<textarea
|
||||
value={md}
|
||||
onChange={(e) => setMd(e.target.value)}
|
||||
className="h-72 w-full resize-none rounded-lg border border-border bg-surface/40 p-3 font-mono text-[12px] leading-relaxed outline-none focus:border-brand/40"
|
||||
/>
|
||||
<div className="prose prose-sm dark:prose-invert h-72 max-w-none overflow-y-auto rounded-lg border border-border bg-surface/40 p-3 text-[13px]">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{md}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={phase === "saving"}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-brand px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:brightness-110 disabled:opacity-60"
|
||||
>
|
||||
{phase === "saving" ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||||
Save & find my matches
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMd("");
|
||||
setSeed(null);
|
||||
setPhase("input");
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 text-[13px] text-muted transition hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="size-3.5" /> Start over
|
||||
</button>
|
||||
<span className="ml-auto inline-flex items-center gap-1 text-[11px] text-faint">
|
||||
<Lock className="size-3" /> Saved locally to cv.md
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Trash2, Loader2 } from "lucide-react";
|
||||
|
||||
// disc#9: remove a bogus tracker row (e.g. a job marked Evaluated after the CLI
|
||||
// errored mid-run). Hard delete via the core write-gate (/api/tracker/delete →
|
||||
// tracker.mjs delete), behind a confirm. The soft option (status → Discarded) lives
|
||||
// in StatusSelect and stays for real-but-passed applications.
|
||||
export function DeleteFromTracker({ n }: { n: string }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [orphan, setOrphan] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
async function openConfirm() {
|
||||
setOpen(true);
|
||||
setErr("");
|
||||
setOrphan(null);
|
||||
try {
|
||||
const r = await fetch("/api/tracker/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ n, dryRun: true }),
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
setErr(d.error || "This row can’t be removed.");
|
||||
return;
|
||||
}
|
||||
setOrphan(d.orphanReport ?? null);
|
||||
} catch {
|
||||
setErr("Couldn’t reach the tracker.");
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
try {
|
||||
const r = await fetch("/api/tracker/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ n }),
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
setErr(d.error || "Delete failed.");
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
// Row is gone — leave the (now-orphaned) report page for the pipeline.
|
||||
router.push("/pipeline");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setErr("Delete failed.");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={openConfirm}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs text-muted transition-colors hover:border-red-400/50 hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="size-3.5" /> Remove from tracker
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-red-400/30 bg-red-500/[0.06] p-3 text-xs">
|
||||
<p className="font-medium text-foreground">Permanently remove application #{n} from your tracker?</p>
|
||||
<p className="mt-1 text-muted">
|
||||
This can’t be undone.{orphan ? ` Its report file (${orphan}) is left on disk.` : ""}
|
||||
</p>
|
||||
{err && <p className="mt-1.5 text-red-500">{err}</p>}
|
||||
<div className="mt-2.5 flex gap-2">
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={confirmDelete}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-red-500 px-2.5 py-1 font-medium text-white transition-colors hover:bg-red-600 disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />} Delete
|
||||
</button>
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md border border-border px-2.5 py-1 text-muted transition-colors hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { AiTraceChunk } from "@/lib/explore-ai";
|
||||
|
||||
// The live reasoning panel — a CONTAINED, polished card (apply-mode vocabulary)
|
||||
// rather than raw text on the backdrop. The narration arrives as arbitrary stream
|
||||
// deltas (split mid-word), so we COALESCE every narration chunk into one string,
|
||||
// strip markdown, and re-split into clean SENTENCES — healing the fragments. Newest
|
||||
// emphasized; auto-scrolls; co-located effect CSS (Tailwind v4 HMR gotcha).
|
||||
const STYLE = `
|
||||
.co-reason__dot{width:.5rem;height:.5rem;border-radius:50%;background:hsl(26 80% 55%);box-shadow:0 0 0 0 hsl(26 80% 55% /.5);animation:co-reason-pulse 1.5s ease-out infinite}
|
||||
.co-reason__body{-webkit-mask-image:linear-gradient(180deg,transparent,#000 16%);mask-image:linear-gradient(180deg,transparent,#000 16%)}
|
||||
.co-reason__line{animation:co-reason-in .35s ease both}
|
||||
@keyframes co-reason-pulse{70%{box-shadow:0 0 0 .4rem hsl(26 80% 55% /0)}100%{box-shadow:0 0 0 0 hsl(26 80% 55% /0)}}
|
||||
@keyframes co-reason-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
|
||||
@media(prefers-reduced-motion:reduce){.co-reason__dot,.co-reason__line{animation:none}}
|
||||
`;
|
||||
|
||||
// Render **bold** spans inline without a full markdown engine.
|
||||
function renderInline(s: string) {
|
||||
return s.split(/(\*\*[^*]+\*\*)/g).map((p, i) =>
|
||||
p.startsWith("**") && p.endsWith("**") ? (
|
||||
<strong key={i} className="font-semibold text-foreground">
|
||||
{p.slice(2, -2)}
|
||||
</strong>
|
||||
) : (
|
||||
<span key={i}>{p}</span>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function AiHuntTrace({ trace }: { trace: AiTraceChunk[] }) {
|
||||
const sentences = useMemo(() => {
|
||||
const full = trace
|
||||
.filter((c) => c.kind === "narration")
|
||||
.map((c) => (c as { text: string }).text)
|
||||
.join("");
|
||||
const clean = full.replace(/`/g, "").replace(/\s+/g, " ").trim();
|
||||
if (!clean) return [];
|
||||
// split into sentences (after . ! ? …), drop leading markdown bullets/quotes
|
||||
return clean
|
||||
.split(/(?<=[.!?…])\s+/)
|
||||
.map((s) => s.replace(/^[>\-*\s]+/, "").trim())
|
||||
.filter((s) => s.length > 2)
|
||||
.slice(-6);
|
||||
}, [trace]);
|
||||
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
||||
}, [sentences.length]);
|
||||
|
||||
if (sentences.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl rounded-2xl border border-border/70 bg-surface/80 text-left shadow-xl shadow-black/10 backdrop-blur-md">
|
||||
<style>{STYLE}</style>
|
||||
<div className="flex items-center gap-2 border-b border-border/60 px-4 py-2.5">
|
||||
<span className="co-reason__dot" />
|
||||
<span className="text-[12px] font-medium text-foreground">Reasoning live</span>
|
||||
<Sparkles className="ml-auto size-3.5 text-brand/70" />
|
||||
</div>
|
||||
<div ref={bodyRef} className="co-reason__body flex max-h-52 flex-col gap-2 overflow-y-auto px-4 py-3">
|
||||
{sentences.map((s, i) => (
|
||||
<p
|
||||
key={`${sentences.length}-${i}`}
|
||||
className={cn("co-reason__line text-[13.5px] leading-relaxed", i === sentences.length - 1 ? "text-foreground" : "text-muted")}
|
||||
>
|
||||
{renderInline(s)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { ApplyBackdrop } from "@/components/apply/apply-backdrop";
|
||||
import { instrumentSerif } from "@/lib/fonts";
|
||||
import { useCountUp } from "./discovering-state";
|
||||
import { AiHuntTrace } from "./ai-hunt-trace";
|
||||
import { DiscoveryCard } from "./discovery-card";
|
||||
import { useExplore } from "./explore-provider";
|
||||
|
||||
// The AI hunt surface — apply-mode polish: an animated orb, a serif headline that
|
||||
// folds in the live count (no lonely giant "0"), a brand-orange effort ledger
|
||||
// (NEVER a fake $0), the CONTAINED reasoning panel, and cards materializing below.
|
||||
const STYLE = `
|
||||
.co-aihunt{position:relative;z-index:1;display:flex;min-height:72vh;flex-direction:column;align-items:center;gap:1.2rem;padding:2.5rem 1rem 2rem;text-align:center}
|
||||
.co-aiorb{position:relative;display:grid;place-items:center;width:4rem;height:4rem}
|
||||
.co-aiorb__glow{position:absolute;inset:0;border-radius:50%;background:hsl(26 80% 55% /.28);filter:blur(18px);animation:co-aiorb-pulse 2.4s ease-in-out infinite}
|
||||
.co-aiorb__ring{position:absolute;inset:0;border-radius:50%;border:2px solid hsl(26 73% 51% /.25);border-top-color:hsl(26 73% 51%);animation:co-aiorb-spin 1.1s linear infinite}
|
||||
.co-ailedger{display:inline-flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:.45rem;border-radius:999px;border:1px solid hsl(26 73% 51% /.3);background:hsl(26 73% 51% /.1);color:hsl(26 78% 43%);padding:.4rem .9rem;font-size:12.5px;font-weight:600}
|
||||
html.dark .co-ailedger{color:hsl(26 86% 67%)}
|
||||
@keyframes co-aiorb-spin{to{transform:rotate(360deg)}}
|
||||
@keyframes co-aiorb-pulse{0%,100%{opacity:.6;transform:scale(.92)}50%{opacity:1;transform:scale(1.08)}}
|
||||
@media(prefers-reduced-motion:reduce){.co-aiorb__ring,.co-aiorb__glow{animation:none}}
|
||||
`;
|
||||
|
||||
export function AiHuntView({ cliName }: { cliName?: string }) {
|
||||
const { phase, matchCount, aiTrace, aiCost, offers } = useExplore();
|
||||
const shown = useCountUp(matchCount);
|
||||
const revealing = phase === "revealing";
|
||||
|
||||
return (
|
||||
<>
|
||||
<ApplyBackdrop intense={!revealing} />
|
||||
<div className="co-aihunt">
|
||||
<style>{STYLE}</style>
|
||||
|
||||
<span className="co-aiorb">
|
||||
<span className="co-aiorb__glow" />
|
||||
<span className="co-aiorb__ring" />
|
||||
<Sparkles className="size-6 text-brand" />
|
||||
</span>
|
||||
|
||||
<div>
|
||||
<h2 className={`${instrumentSerif.className} text-3xl leading-tight text-foreground`}>
|
||||
{matchCount > 0 ? `${shown} candidate${shown === 1 ? "" : "s"}` : "Hunting the open web"}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{revealing ? "found — review them below" : matchCount > 0 ? "found so far · streaming in" : "casting across the public web…"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="co-ailedger">
|
||||
<Sparkles className="size-3.5" />
|
||||
{cliName || "your CLI"} · searching the open web
|
||||
{aiCost.searches > 0 && <span className="opacity-75">· {aiCost.searches} searches</span>}
|
||||
{matchCount > 0 && <span className="opacity-75">· {matchCount} found</span>}
|
||||
</div>
|
||||
|
||||
<AiHuntTrace trace={aiTrace} />
|
||||
|
||||
{offers.length > 0 && (
|
||||
<div className="mt-2 grid w-full max-w-4xl gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{offers.map((o) => (
|
||||
<DiscoveryCard key={o.url} offer={o} inPipeline={false} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { ArrowRight, Sparkles } from "lucide-react";
|
||||
import { CostBadge } from "@/components/cost/cost-badge";
|
||||
|
||||
const EXAMPLES = [
|
||||
"AI infra roles at climate startups, remote EU",
|
||||
"Forward-deployed engineer at Series A devtools, US-remote",
|
||||
"Head of Applied AI at healthtech, posted this week",
|
||||
];
|
||||
|
||||
// The "magic" natural-language box: a soft contained halo at rest that intensifies
|
||||
// on focus (erupts into the full-viewport hunt on submit). Effect CSS co-located
|
||||
// per the Tailwind v4 stale-CSS HMR gotcha.
|
||||
const STYLE = `
|
||||
.co-aibox{position:relative;border-radius:1.1rem;border:1px solid var(--co-border,hsl(0 0% 50% /.22));background:color-mix(in srgb, var(--bg) 55%, transparent);transition:border-color .3s,box-shadow .3s}
|
||||
.co-aibox::before{content:"";position:absolute;inset:-1px;border-radius:1.1rem;padding:1px;background:radial-gradient(70% 140% at 28% -10%, hsl(26 82% 55% /.45), transparent 62%);-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask-composite:exclude;opacity:.45;transition:opacity .3s;pointer-events:none}
|
||||
.co-aibox:focus-within::before{opacity:1}
|
||||
.co-aibox:focus-within{border-color:hsl(26 73% 51% /.5);box-shadow:0 0 0 4px hsl(26 73% 51% /.09)}
|
||||
.co-aibox textarea{width:100%;resize:none;background:transparent;border:none;outline:none;font-size:16px;line-height:1.5;color:inherit}
|
||||
.co-aibox textarea::placeholder{color:var(--co-faint,hsl(0 0% 58%))}
|
||||
@media(prefers-reduced-motion:reduce){.co-aibox,.co-aibox::before{transition:none}}
|
||||
`;
|
||||
|
||||
export function AiSearchBox({
|
||||
intent,
|
||||
onIntent,
|
||||
onSubmit,
|
||||
cliConfigured,
|
||||
cliName,
|
||||
onRunScan,
|
||||
}: {
|
||||
intent: string;
|
||||
onIntent: (s: string) => void;
|
||||
onSubmit: () => void;
|
||||
cliConfigured: boolean;
|
||||
cliName?: string;
|
||||
onRunScan: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const grow = () => {
|
||||
const t = ref.current;
|
||||
if (t) {
|
||||
t.style.height = "auto";
|
||||
t.style.height = `${Math.min(t.scrollHeight, 160)}px`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<style>{STYLE}</style>
|
||||
<div className="co-aibox p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-[12px] font-medium text-brand">
|
||||
<Sparkles className="size-3.5" /> Describe the role — an AI hunts the open web for it
|
||||
</div>
|
||||
<textarea
|
||||
ref={ref}
|
||||
rows={2}
|
||||
value={intent}
|
||||
onChange={(e) => {
|
||||
onIntent(e.target.value);
|
||||
grow();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (intent.trim()) onSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="“AI infra at climate startups, remote EU, not staff-level” — plain language, your words"
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="text-[12px] text-muted">
|
||||
{cliConfigured ? (
|
||||
<>
|
||||
Reads the public web with <span className="text-foreground">{cliName || "your CLI"}</span> — it costs your tokens.
|
||||
</>
|
||||
) : (
|
||||
"Connect an AI CLI in Config to use AI search."
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!intent.trim()}
|
||||
onClick={onSubmit}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:brightness-110 disabled:opacity-50"
|
||||
>
|
||||
Search the open web
|
||||
<CostBadge kind="spend" size="xs" />
|
||||
<ArrowRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{EXAMPLES.map((ex) => (
|
||||
<button
|
||||
key={ex}
|
||||
type="button"
|
||||
onClick={() => onIntent(ex)}
|
||||
className="rounded-full border border-border bg-surface/40 px-3 py-1.5 text-[12px] text-muted transition hover:border-brand/40 hover:text-brand"
|
||||
>
|
||||
{ex}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={onRunScan} className="ml-auto inline-flex items-center gap-1 text-[12px] text-faint transition hover:text-foreground">
|
||||
or run the free Scan instead →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Loader2 } from "lucide-react";
|
||||
import { ApplyBackdrop } from "@/components/apply/apply-backdrop";
|
||||
import { instrumentSerif } from "@/lib/fonts";
|
||||
import { ATS_LABEL, ATS_SOURCES, type AtsSource } from "@/lib/explore";
|
||||
import { useExplore, type SourceState } from "./explore-provider";
|
||||
|
||||
const STYLE = `
|
||||
.co-disc{position:relative;z-index:1;display:flex;min-height:78vh;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:1.6rem;padding:2rem}
|
||||
.co-disc__counter{font-variant-numeric:tabular-nums;line-height:1;font-size:clamp(4rem,13vw,8rem)}
|
||||
.co-src{display:flex;flex-wrap:wrap;justify-content:center;gap:.6rem}
|
||||
.co-src__chip{display:flex;align-items:center;gap:.5rem;border-radius:.8rem;border:1px solid var(--border,hsl(0 0% 50% / .2));padding:.5rem .8rem;min-width:9.5rem;background:color-mix(in srgb, var(--bg) 70%, transparent);transition:opacity .3s,border-color .3s}
|
||||
.co-src__chip[data-state="queued"]{opacity:.4;border-style:dashed}
|
||||
.co-src__chip[data-state="active"]{border-color:hsl(26 73% 51% / .45)}
|
||||
.co-src__orb{width:.55rem;height:.55rem;border-radius:50%;background:hsl(26 80% 55%);box-shadow:0 0 0 0 hsl(26 80% 55% / .5);animation:co-orb 1.4s ease-out infinite}
|
||||
.co-src__bar{height:3px;border-radius:2px;background:hsl(26 73% 51%);transition:width .4s ease}
|
||||
.co-src__track{height:3px;border-radius:2px;background:color-mix(in srgb, var(--fg) 14%, transparent);overflow:hidden;width:3.5rem}
|
||||
.co-disc__skel{display:grid;grid-template-columns:repeat(auto-fill,minmax(15rem,1fr));gap:.7rem;width:100%;max-width:46rem;margin-top:.5rem}
|
||||
.co-disc__skelcard{height:4.4rem;border-radius:.8rem;border:1px solid var(--border,hsl(0 0% 50% / .15));background:color-mix(in srgb, var(--bg) 60%, transparent);overflow:hidden;position:relative}
|
||||
.co-disc__skelcard::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,color-mix(in srgb, var(--fg) 8%, transparent),transparent);transform:translateX(-100%);animation:co-shimmer 1.5s infinite}
|
||||
.co-ledger{display:inline-flex;align-items:center;gap:.5rem;border-radius:999px;border:1px solid hsl(160 64% 46% / .3);background:hsl(160 64% 46% / .1);color:hsl(160 60% 40%);padding:.35rem .85rem;font-size:12.5px;font-weight:600}
|
||||
html.dark .co-ledger{color:hsl(158 64% 62%)}
|
||||
@keyframes co-orb{0%{box-shadow:0 0 0 0 hsl(26 80% 55% / .5)}70%{box-shadow:0 0 0 .5rem hsl(26 80% 55% / 0)}100%{box-shadow:0 0 0 0 hsl(26 80% 55% / 0)}}
|
||||
@keyframes co-shimmer{100%{transform:translateX(100%)}}
|
||||
@media (prefers-reduced-motion: reduce){.co-src__orb,.co-disc__skelcard::after{animation:none}}
|
||||
`;
|
||||
|
||||
export function useCountUp(target: number): number {
|
||||
const [val, setVal] = useState(target);
|
||||
const raf = useRef(0);
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
setVal((v) => {
|
||||
const diff = target - v;
|
||||
if (Math.abs(diff) < 0.5) return target;
|
||||
raf.current = requestAnimationFrame(tick);
|
||||
return v + diff * 0.18;
|
||||
});
|
||||
};
|
||||
raf.current = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf.current);
|
||||
}, [target]);
|
||||
return Math.round(val);
|
||||
}
|
||||
|
||||
function SourceChip({ ats, s }: { ats: AtsSource; s?: SourceState }) {
|
||||
const state = s?.state ?? "queued";
|
||||
const pct = s?.total ? Math.min(100, Math.round(((s.done ?? 0) / s.total) * 100)) : state === "swept" || state === "noisy" ? 100 : 0;
|
||||
return (
|
||||
<div className="co-src__chip" data-state={state === "noisy" ? "active" : state}>
|
||||
{state === "active" ? (
|
||||
<span className="co-src__orb" />
|
||||
) : state === "swept" || state === "noisy" ? (
|
||||
<Check className="size-3.5 text-emerald-500" />
|
||||
) : (
|
||||
<span className="size-2.5 rounded-full border border-current opacity-40" />
|
||||
)}
|
||||
<span className="text-[13px] font-medium text-foreground">{ATS_LABEL[ats]}</span>
|
||||
<div className="ml-auto flex flex-col items-end gap-1">
|
||||
{state === "noisy" && <span className="text-[10px] text-faint">~{s?.unreachable} skipped</span>}
|
||||
<div className="co-src__track">
|
||||
<div className="co-src__bar" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiscoveringState() {
|
||||
const { sources, matchCount, companiesScanned, status, phase } = useExplore();
|
||||
const shown = useCountUp(matchCount);
|
||||
const companies = useCountUp(companiesScanned);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ApplyBackdrop intense={phase !== "revealing"} />
|
||||
<div className="co-disc">
|
||||
<style>{STYLE}</style>
|
||||
|
||||
<div className="co-ledger">
|
||||
<span className="size-1.5 rounded-full bg-emerald-500" />
|
||||
0 tokens · $0.00 {companies > 0 && <span className="opacity-70">· {companies.toLocaleString()} companies</span>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={`${instrumentSerif.className} co-disc__counter text-foreground`}>{shown}</div>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{phase === "revealing" ? "fresh roles found — free" : matchCount > 0 ? "fresh roles and counting…" : "scanning the network…"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="co-src">
|
||||
{ATS_SOURCES.map((a) => (
|
||||
<SourceChip key={a} ats={a} s={sources[a]} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="flex items-center gap-2 text-[13px] text-faint">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{status || "Casting the net across the ATS network…"}
|
||||
</p>
|
||||
|
||||
{phase !== "revealing" && (
|
||||
<div className="co-disc__skel" aria-hidden>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="co-disc__skelcard" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { ExternalLink, Plus, Check, Loader2, ShieldQuestion, Sparkles, Coins } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { instrumentSerif } from "@/lib/fonts";
|
||||
import { ATS_LABEL, type AtsSource, type DiscoveredOffer } from "@/lib/explore";
|
||||
import { useJobs } from "@/components/jobs/job-store";
|
||||
import { useExplore } from "./explore-provider";
|
||||
|
||||
function freshness(postedAt: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(postedAt)) return "";
|
||||
const days = Math.max(0, Math.round((Date.now() - new Date(postedAt + "T00:00:00Z").getTime()) / 86_400_000));
|
||||
return days === 0 ? "today" : days === 1 ? "1d ago" : `${days}d ago`;
|
||||
}
|
||||
|
||||
// Real company logo (favicon) via the localhost proxy, cached on disk FOREVER per
|
||||
// company — so once it resolves it's instant for this card AND every other card,
|
||||
// this search or any future one. Falls back to a monogram on miss.
|
||||
function Logo({ company }: { company: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const letter = (company || "?").trim().charAt(0).toUpperCase();
|
||||
if (failed || !company.trim()) {
|
||||
return <div className="grid size-9 shrink-0 place-items-center rounded-lg bg-brand-soft text-sm font-semibold text-brand">{letter}</div>;
|
||||
}
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/logo?company=${encodeURIComponent(company)}`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
className="size-9 shrink-0 rounded-lg border border-border bg-surface object-contain p-1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// What a running worker is doing on this exact posting → the live CTA label.
|
||||
const WORKER_LABEL: Record<string, string> = { evaluate: "Evaluating…", pdf: "Preparing CV…", research: "Researching…", apply: "Filling…" };
|
||||
|
||||
export function DiscoveryCard({ offer, inPipeline, evaluatedN }: { offer: DiscoveredOffer; inPipeline: boolean; evaluatedN?: string }) {
|
||||
const { added, adding, addToPipeline } = useExplore();
|
||||
const { jobs, startJob } = useJobs();
|
||||
|
||||
// GLOBAL worker awareness: any worker acting on this URL drives the CTA, here
|
||||
// and on every other surface that renders this offer (the jobs store is global).
|
||||
const job = useMemo(
|
||||
() => jobs.filter((j) => j.input === offer.url).sort((a, b) => b.startedAt - a.startedAt)[0],
|
||||
[jobs, offer.url],
|
||||
);
|
||||
const working = job?.status === "running";
|
||||
const doneEval = job?.status === "done" && job.kind === "evaluate";
|
||||
const statusLabel = WORKER_LABEL[job?.kind ?? ""] ?? "Working…";
|
||||
|
||||
const isAdded = added.has(offer.url) || inPipeline || working || doneEval;
|
||||
const isAdding = adding.has(offer.url);
|
||||
const unverified = offer.verification === "unconfirmed";
|
||||
const fresh = freshness(offer.postedAt) || offer.postedHint || "";
|
||||
|
||||
const evaluate = () => {
|
||||
addToPipeline([offer]); // evaluating implies it's in the pipeline — record it
|
||||
startJob({ title: `Evaluate · ${offer.company}`, subtitle: offer.title, kind: "evaluate", input: offer.url, page: "/explore" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="co-rise group flex min-w-0 flex-col gap-2.5 rounded-xl border border-border bg-surface/40 p-3.5 text-left transition-all hover:-translate-y-0.5 hover:border-brand/30 hover:shadow-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<Logo company={offer.company} />
|
||||
<a href={offer.url} target="_blank" rel="noopener noreferrer" className="block min-w-0 flex-1">
|
||||
<h3 className={`${instrumentSerif.className} truncate text-[17px] leading-tight text-foreground transition-colors group-hover:text-brand`}>{offer.title}</h3>
|
||||
<p className="mt-0.5 truncate text-[13px] text-muted">
|
||||
{offer.company}
|
||||
{offer.location && <span className="text-faint"> · {offer.location}</span>}
|
||||
</p>
|
||||
</a>
|
||||
<a
|
||||
href={offer.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Open the posting"
|
||||
aria-label="Open the posting"
|
||||
className="-m-1 shrink-0 rounded p-1 text-faint transition-colors hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<span className="rounded border border-border px-1.5 py-0.5 font-medium text-muted">{ATS_LABEL[offer.ats as AtsSource] ?? offer.ats}</span>
|
||||
{fresh && <span className="text-faint">{fresh}</span>}
|
||||
{unverified && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 font-medium text-amber-600 dark:text-amber-300"
|
||||
title="Found by AI on the public web — we can't confirm it's still live without opening it. Evaluating runs a real browser check and sets the verdict."
|
||||
>
|
||||
<ShieldQuestion className="size-3" /> unverified
|
||||
</span>
|
||||
)}
|
||||
{offer.matchedKeyword && (
|
||||
<span className="text-faint" title="Keyword match — not yet scored. Evaluate to get an A–F fit score.">
|
||||
· matched <span className="text-brand/80">{offer.matchedKeyword}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{offer.why && (
|
||||
<p className="flex items-start gap-1.5 text-[12px] leading-snug text-brand/80">
|
||||
<Sparkles className="mt-0.5 size-3 shrink-0" />
|
||||
{offer.why}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-0.5">
|
||||
{evaluatedN || doneEval ? (
|
||||
<a
|
||||
href={evaluatedN ? `/pipeline/${evaluatedN}` : job ? `/jobs/${job.id}` : "/pipeline"}
|
||||
className="inline-flex w-full items-center justify-center gap-1.5 rounded-md bg-brand-soft px-2.5 py-2 text-xs font-medium text-brand"
|
||||
>
|
||||
<Check className="size-3.5" /> Evaluated · view report
|
||||
</a>
|
||||
) : working ? (
|
||||
<div className="inline-flex w-full items-center justify-center gap-1.5 rounded-md border border-brand/30 bg-brand-soft/60 px-2.5 py-2 text-xs font-medium text-brand">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{statusLabel}
|
||||
<span className="text-brand/60">· in pipeline</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isAdded || isAdding}
|
||||
onClick={() => addToPipeline([offer])}
|
||||
className={cn(
|
||||
"inline-flex flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-2.5 py-2 text-xs font-medium transition-colors",
|
||||
isAdded ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-surface-hover text-foreground hover:bg-brand-soft hover:text-brand",
|
||||
)}
|
||||
>
|
||||
{isAdding ? <Loader2 className="size-3.5 animate-spin" /> : isAdded ? <Check className="size-3.5" /> : <Plus className="size-3.5" />}
|
||||
{isAdded ? "In pipeline" : "Add to pipeline"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={evaluate}
|
||||
title={unverified ? "Runs a real evaluation — and verifies the posting is live. Uses tokens." : "Runs a real A–F evaluation. Uses tokens."}
|
||||
className="inline-flex flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-brand/30 px-2.5 py-2 text-xs font-medium text-brand transition-colors hover:bg-brand-soft"
|
||||
>
|
||||
Evaluate <Coins className="size-3.5 opacity-80" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { Compass, Sparkles } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CostBadge } from "@/components/cost/cost-badge";
|
||||
import type { ExploreMode } from "@/lib/explore";
|
||||
|
||||
// Cost honesty rendered at the POINT OF CHOICE: free deterministic Scan (default)
|
||||
// vs token-spending AI search. The AI segment stays selectable even with no CLI —
|
||||
// selecting it reveals the blocked state (more discoverable than a dead tab).
|
||||
export function ExploreModeToggle({
|
||||
mode,
|
||||
onChange,
|
||||
cliConfigured,
|
||||
}: {
|
||||
mode: ExploreMode;
|
||||
onChange: (m: ExploreMode) => void;
|
||||
cliConfigured: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full rounded-xl border border-border bg-surface/40 p-1 sm:inline-flex sm:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("scan")}
|
||||
aria-pressed={mode === "scan"}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-2.5 py-2 text-sm transition-colors sm:flex-none sm:gap-2 sm:px-3",
|
||||
mode === "scan" ? "bg-brand-soft text-brand" : "text-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Compass className="size-4" />
|
||||
<span className="font-medium">Scan</span>
|
||||
<span className="hidden sm:inline-flex">
|
||||
<CostBadge kind="free-network" size="xs" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("ai")}
|
||||
aria-pressed={mode === "ai"}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-2.5 py-2 text-sm transition-colors sm:flex-none sm:gap-2 sm:px-3",
|
||||
mode === "ai" ? "bg-brand-soft text-brand" : "text-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
<span className="font-medium">AI search</span>
|
||||
<span className="hidden sm:inline-flex">
|
||||
<CostBadge kind="spend" size="xs" />
|
||||
</span>
|
||||
{!cliConfigured && <span className="text-[10px] text-faint">needs a CLI</span>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
DEFAULT_FILTERS,
|
||||
ATS_LABEL,
|
||||
filtersToParams,
|
||||
aiToParams,
|
||||
isBroadSearch,
|
||||
parseExplorePatch,
|
||||
type AtsSource,
|
||||
type DiscoveredOffer,
|
||||
type ExploreFilters,
|
||||
type ExploreMode,
|
||||
type ScanEvent,
|
||||
} from "@/lib/explore";
|
||||
import { makeAiStreamParser, type AiTraceChunk } from "@/lib/explore-ai";
|
||||
|
||||
export type Phase =
|
||||
| "idle"
|
||||
| "casting"
|
||||
| "scanning"
|
||||
| "revealing"
|
||||
| "results"
|
||||
| "empty-current"
|
||||
| "empty-loose"
|
||||
| "failed"
|
||||
| "degraded" // scan completed but searched nothing (transient fetch/rate-limit) — not "all caught up"
|
||||
| "hunting" // AI search streaming
|
||||
| "blocked"; // AI search needs a CLI
|
||||
export type AiCost = { searches: number; candidates: number; fetches: number };
|
||||
export type SourceState = {
|
||||
state: "queued" | "active" | "swept" | "noisy";
|
||||
companies?: number;
|
||||
done?: number;
|
||||
total?: number;
|
||||
matches?: number;
|
||||
unreachable?: number;
|
||||
};
|
||||
|
||||
type ExploreCtx = {
|
||||
filters: ExploreFilters;
|
||||
setFilters: (f: ExploreFilters) => void;
|
||||
/** Set filters from a seed/URL only if the user/assistant hasn't touched them
|
||||
* yet — so a fresh page mount can't clobber assistant-set filters. */
|
||||
initFilters: (f: ExploreFilters) => void;
|
||||
phase: Phase;
|
||||
running: boolean;
|
||||
offers: DiscoveredOffer[];
|
||||
sources: Partial<Record<AtsSource, SourceState>>;
|
||||
matchCount: number;
|
||||
companiesScanned: number;
|
||||
companiesAvailable: number;
|
||||
capHit: boolean;
|
||||
droppedNoDate: number;
|
||||
status: string;
|
||||
partial: boolean;
|
||||
error: string;
|
||||
added: Set<string>;
|
||||
adding: Set<string>;
|
||||
discover: () => Promise<void>;
|
||||
addToPipeline: (offers: DiscoveredOffer[]) => Promise<number>;
|
||||
applyPatch: (raw: Record<string, unknown>, opts?: { merge?: boolean; run?: boolean }) => void;
|
||||
reset: () => void;
|
||||
// ── AI search (modes/discover.md) ──
|
||||
mode: ExploreMode;
|
||||
setMode: (m: ExploreMode) => void;
|
||||
aiIntent: string;
|
||||
setAiIntent: (s: string) => void;
|
||||
discoverAI: () => Promise<void>;
|
||||
aiTrace: AiTraceChunk[];
|
||||
aiCost: AiCost;
|
||||
};
|
||||
|
||||
const Ctx = createContext<ExploreCtx | null>(null);
|
||||
export function useExplore(): ExploreCtx {
|
||||
const c = useContext(Ctx);
|
||||
if (!c) throw new Error("useExplore must be used within <ExploreProvider>");
|
||||
return c;
|
||||
}
|
||||
|
||||
// Explore results are expensive (a scan walks the ATS network; an AI search spends
|
||||
// tokens). Persist the SETTLED result set per-tab so a reload or a mode toggle never
|
||||
// throws the work away (disc#5 — "came back to explore, work is lost").
|
||||
const RESULTS_KEY = "career-ops:explore-results";
|
||||
type ResultSnapshot = {
|
||||
v: number;
|
||||
mode: ExploreMode;
|
||||
phase: Phase;
|
||||
offers: DiscoveredOffer[];
|
||||
matchCount: number;
|
||||
companiesScanned: number;
|
||||
companiesAvailable: number;
|
||||
capHit: boolean;
|
||||
droppedNoDate: number;
|
||||
sources: Partial<Record<AtsSource, SourceState>>;
|
||||
partial: boolean;
|
||||
status: string;
|
||||
error: string;
|
||||
added: string[];
|
||||
aiTrace: AiTraceChunk[];
|
||||
aiCost: AiCost;
|
||||
aiIntent: string;
|
||||
};
|
||||
|
||||
export function ExploreProvider({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const [filters, setFiltersState] = useState<ExploreFilters>({ ...DEFAULT_FILTERS, ats: [...DEFAULT_FILTERS.ats] });
|
||||
const touched = useRef(false);
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [offers, setOffers] = useState<DiscoveredOffer[]>([]);
|
||||
const [sources, setSources] = useState<Partial<Record<AtsSource, SourceState>>>({});
|
||||
const [matchCount, setMatchCount] = useState(0);
|
||||
const [companiesScanned, setCompaniesScanned] = useState(0);
|
||||
// Authoritative scan-health signals (scanner --json mode, #1199): tell a capped /
|
||||
// degraded scan from a genuinely empty one, and power a "scanned X of Y" banner.
|
||||
const [companiesAvailable, setCompaniesAvailable] = useState(0);
|
||||
const [capHit, setCapHit] = useState(false);
|
||||
const [droppedNoDate, setDroppedNoDate] = useState(0);
|
||||
const [status, setStatus] = useState("");
|
||||
const [partial, setPartial] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [added, setAdded] = useState<Set<string>>(new Set());
|
||||
const [adding, setAdding] = useState<Set<string>>(new Set());
|
||||
const [mode, setModeState] = useState<ExploreMode>("scan");
|
||||
const [aiIntent, setAiIntent] = useState("");
|
||||
const [aiTrace, setAiTrace] = useState<AiTraceChunk[]>([]);
|
||||
const [aiCost, setAiCost] = useState<AiCost>({ searches: 0, candidates: 0, fetches: 0 });
|
||||
const runningRef = useRef(false);
|
||||
const aiIntentRef = useRef(aiIntent);
|
||||
aiIntentRef.current = aiIntent;
|
||||
const filtersRef = useRef(filters);
|
||||
filtersRef.current = filters;
|
||||
|
||||
const setFilters = useCallback((f: ExploreFilters) => {
|
||||
touched.current = true;
|
||||
filtersRef.current = f;
|
||||
setFiltersState(f);
|
||||
}, []);
|
||||
const initFilters = useCallback((f: ExploreFilters) => {
|
||||
if (touched.current) return;
|
||||
filtersRef.current = f;
|
||||
setFiltersState(f);
|
||||
}, []);
|
||||
|
||||
const discover = useCallback(async () => {
|
||||
if (runningRef.current) return;
|
||||
const f = filtersRef.current;
|
||||
runningRef.current = true;
|
||||
setPhase("casting");
|
||||
setOffers([]);
|
||||
setMatchCount(0);
|
||||
setCompaniesScanned(0);
|
||||
setCompaniesAvailable(0);
|
||||
setCapHit(false);
|
||||
setDroppedNoDate(0);
|
||||
setPartial(false);
|
||||
setError("");
|
||||
setStatus("Casting the net across the ATS network…");
|
||||
const init: Partial<Record<AtsSource, SourceState>> = {};
|
||||
for (const a of f.ats) init[a] = { state: "queued" };
|
||||
setSources(init);
|
||||
if (typeof window !== "undefined") {
|
||||
const qs = filtersToParams(f);
|
||||
window.history.replaceState(null, "", `/explore${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
const acc: DiscoveredOffer[] = [];
|
||||
let sawError = "";
|
||||
let companiesScannedAcc = 0; // 0 at the end = the directories never downloaded → degraded, not empty
|
||||
let capHitAcc = false; // scan was capped (only a slice of the universe searched)
|
||||
let datasetIssueAcc = false; // some ATS dataset was stale/empty/unreachable
|
||||
let droppedNoDateAcc = 0; // postings dropped for lacking a publish date
|
||||
try {
|
||||
const r = await fetch("/api/explore", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(f),
|
||||
});
|
||||
if (r.status === 400) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
sawError = d.error || "The scanner isn't available.";
|
||||
} else if (!r.body) {
|
||||
sawError = "No response stream.";
|
||||
} else {
|
||||
const reader = r.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
let ev: ScanEvent;
|
||||
try {
|
||||
ev = JSON.parse(line) as ScanEvent;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
switch (ev.kind) {
|
||||
case "atsStart":
|
||||
setPhase("scanning");
|
||||
setStatus(`Walking ${ATS_LABEL[ev.ats as AtsSource] ?? ev.ats} — ${ev.companies.toLocaleString()} companies`);
|
||||
setSources((s) => ({ ...s, [ev.ats]: { ...s[ev.ats as AtsSource], state: "active", companies: ev.companies } }));
|
||||
break;
|
||||
case "progress":
|
||||
// `matches` is the GLOBAL running total (the engine batches the
|
||||
// offer list to the very end), so it drives the live hero counter.
|
||||
setMatchCount((m) => Math.max(m, ev.matches));
|
||||
setSources((s) => ({ ...s, [ev.ats]: { ...s[ev.ats as AtsSource], state: "active", done: ev.scanned, total: ev.total } }));
|
||||
break;
|
||||
case "atsDone":
|
||||
setSources((s) => ({ ...s, [ev.ats]: { ...s[ev.ats as AtsSource], state: ev.unreachable > 0 ? "noisy" : "swept", unreachable: ev.unreachable } }));
|
||||
break;
|
||||
case "offer":
|
||||
acc.push(ev.offer);
|
||||
setOffers((o) => [...o, ev.offer]);
|
||||
break;
|
||||
case "summary": {
|
||||
companiesScannedAcc = ev.companiesScanned;
|
||||
setCompaniesScanned(ev.companiesScanned);
|
||||
if (typeof ev.companiesAvailable === "number") setCompaniesAvailable(ev.companiesAvailable);
|
||||
if (ev.capHit) {
|
||||
capHitAcc = true;
|
||||
setCapHit(true);
|
||||
}
|
||||
const datasetIssue = ev.datasetStatus ? Object.values(ev.datasetStatus).some((s) => s !== "ok") : false;
|
||||
if (datasetIssue) datasetIssueAcc = true;
|
||||
if (typeof ev.postingsDroppedNoDate === "number" && ev.postingsDroppedNoDate > 0) {
|
||||
droppedNoDateAcc = ev.postingsDroppedNoDate;
|
||||
setDroppedNoDate(ev.postingsDroppedNoDate);
|
||||
}
|
||||
if (ev.unreachable > 0 || datasetIssue) setPartial(true);
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
sawError = ev.message;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
sawError = e instanceof Error ? e.message : "stream error";
|
||||
}
|
||||
|
||||
// Mark any still-active sources as swept (stream ended).
|
||||
setSources((s) => {
|
||||
const next = { ...s };
|
||||
for (const k of Object.keys(next) as AtsSource[]) if (next[k]?.state === "active" || next[k]?.state === "queued") next[k] = { ...next[k]!, state: "swept" };
|
||||
return next;
|
||||
});
|
||||
|
||||
runningRef.current = false;
|
||||
if (acc.length > 0) {
|
||||
setMatchCount(acc.length);
|
||||
setPhase("revealing");
|
||||
setStatus(`${acc.length} fresh role${acc.length === 1 ? "" : "s"} found — free.`);
|
||||
window.setTimeout(() => setPhase("results"), 850);
|
||||
} else if (sawError) {
|
||||
setError(sawError);
|
||||
setPhase("failed");
|
||||
} else if (capHitAcc || datasetIssueAcc || droppedNoDateAcc > 0 || companiesScannedAcc === 0) {
|
||||
// Maintainer's RULE (#1199): it is NOT "all caught up" if the scan was capped,
|
||||
// a dataset was stale/unreachable, postings were dropped for missing a date, OR
|
||||
// nothing was searched at all (legacy 0-companies fallback when --json is absent).
|
||||
// Truly-empty is only when live datasets were fully searched and found nothing.
|
||||
setPhase("degraded");
|
||||
} else {
|
||||
setPhase(isBroadSearch(f) ? "empty-current" : "empty-loose");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addToPipeline = useCallback(async (list: DiscoveredOffer[]) => {
|
||||
const fresh = list.filter((o) => !added.has(o.url));
|
||||
if (fresh.length === 0) return 0;
|
||||
setAdding((s) => new Set([...s, ...fresh.map((o) => o.url)]));
|
||||
try {
|
||||
const r = await fetch("/api/explore/add", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ offers: fresh }),
|
||||
});
|
||||
const d = (await r.json()) as { added?: number };
|
||||
if (d.added && d.added > 0) {
|
||||
setAdded((s) => new Set([...s, ...fresh.map((o) => o.url)]));
|
||||
// The new inbox rows were written server-side. Invalidate the Next router
|
||||
// cache so the (server-rendered) Pipeline view shows them instead of a stale
|
||||
// snapshot, and ping live listeners (today's dashboard, pipeline provider) —
|
||||
// otherwise the user adds a job, opens Pipeline, and sees it empty (disc#5).
|
||||
router.refresh();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("co-job-done", { detail: { kind: "explore-add" } }));
|
||||
}
|
||||
}
|
||||
return d.added ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
} finally {
|
||||
setAdding((s) => {
|
||||
const next = new Set(s);
|
||||
for (const o of fresh) next.delete(o.url);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [added, router]);
|
||||
|
||||
const applyPatch = useCallback((raw: Record<string, unknown>, opts?: { merge?: boolean; run?: boolean }) => {
|
||||
const next = parseExplorePatch(raw, filtersRef.current, opts?.merge ?? false);
|
||||
setFilters(next);
|
||||
filtersRef.current = next;
|
||||
if (opts?.run) void discover();
|
||||
}, [discover]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
runningRef.current = false;
|
||||
setPhase("idle");
|
||||
setOffers([]);
|
||||
setSources({});
|
||||
setMatchCount(0);
|
||||
setCompaniesScanned(0);
|
||||
setStatus("");
|
||||
setPartial(false);
|
||||
setError("");
|
||||
setAiTrace([]);
|
||||
setAiCost({ searches: 0, candidates: 0, fetches: 0 });
|
||||
try {
|
||||
sessionStorage.removeItem(RESULTS_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
// AI search — orchestrate modes/discover.md via the user's CLI, streamed.
|
||||
const discoverAI = useCallback(async () => {
|
||||
if (runningRef.current) return;
|
||||
const intent = aiIntentRef.current.trim();
|
||||
if (!intent) return;
|
||||
let cliId: string | null = null;
|
||||
try {
|
||||
cliId = JSON.parse(localStorage.getItem("career-ops:config") || "{}").cliId || null;
|
||||
} catch {
|
||||
cliId = null;
|
||||
}
|
||||
if (!cliId) {
|
||||
setPhase("blocked");
|
||||
return;
|
||||
}
|
||||
runningRef.current = true;
|
||||
setPhase("casting");
|
||||
setOffers([]);
|
||||
setMatchCount(0);
|
||||
setAiTrace([]);
|
||||
setAiCost({ searches: 0, candidates: 0, fetches: 0 });
|
||||
setError("");
|
||||
setStatus("Casting across the open web…");
|
||||
if (typeof window !== "undefined") window.history.replaceState(null, "", `/explore?${aiToParams(intent)}`);
|
||||
|
||||
let knownUrls = new Set<string>();
|
||||
try {
|
||||
const k = await fetch("/api/explore/ai/known").then((r) => r.json());
|
||||
knownUrls = new Set<string>(Array.isArray(k.urls) ? k.urls : []);
|
||||
} catch {
|
||||
/* best-effort dedup */
|
||||
}
|
||||
const parser = makeAiStreamParser({ knownUrls });
|
||||
|
||||
const acc: DiscoveredOffer[] = [];
|
||||
let sawError = "";
|
||||
const handle = (chunks: AiTraceChunk[]) => {
|
||||
for (const ch of chunks) {
|
||||
if (ch.kind === "offer") {
|
||||
acc.push(ch.offer);
|
||||
setOffers((o) => [...o, ch.offer]);
|
||||
setMatchCount(acc.length);
|
||||
setAiCost((c) => ({ ...c, candidates: acc.length }));
|
||||
setPhase("hunting");
|
||||
} else {
|
||||
setAiTrace((t) => [...t, ch]);
|
||||
if (ch.kind === "narration") {
|
||||
const s = (ch.text.match(/\bsearch(ing|ed)?\b/gi) || []).length;
|
||||
const f = (ch.text.match(/\bfetch(ing|ed)?\b/gi) || []).length;
|
||||
if (s || f) setAiCost((c) => ({ ...c, searches: c.searches + s, fetches: c.fetches + f }));
|
||||
setPhase((p) => (p === "casting" ? "hunting" : p));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const r = await fetch("/api/explore/ai", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: intent, cliId }),
|
||||
});
|
||||
if (r.status === 404) {
|
||||
runningRef.current = false;
|
||||
setPhase("blocked");
|
||||
return;
|
||||
}
|
||||
if (r.status === 400) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
sawError = d.error || "AI search isn't available.";
|
||||
} else if (!r.body) {
|
||||
sawError = "No response stream.";
|
||||
} else {
|
||||
const reader = r.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
handle(parser.feed(dec.decode(value, { stream: true })));
|
||||
}
|
||||
handle(parser.flush());
|
||||
}
|
||||
} catch (e) {
|
||||
sawError = e instanceof Error ? e.message : "stream error";
|
||||
}
|
||||
|
||||
runningRef.current = false;
|
||||
if (acc.length > 0) {
|
||||
setMatchCount(acc.length);
|
||||
setPhase("revealing");
|
||||
setStatus(`${acc.length} candidate${acc.length === 1 ? "" : "s"} found.`);
|
||||
window.setTimeout(() => setPhase("results"), 850);
|
||||
} else if (sawError) {
|
||||
setError(sawError);
|
||||
setPhase("failed");
|
||||
} else {
|
||||
setPhase("empty-loose");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Switch surface but PRESERVE the current results + filters — toggling scan↔AI must
|
||||
// not throw away a completed search (disc#5). A new search (discover/discoverAI)
|
||||
// clears + repopulates; an explicit reset() clears. Just stop any half-run.
|
||||
const setMode = useCallback((m: ExploreMode) => {
|
||||
runningRef.current = false;
|
||||
setModeState(m);
|
||||
}, []);
|
||||
|
||||
// Rehydrate the last settled result set on mount (per-tab sessionStorage), unless a
|
||||
// search is already running. Done in an effect (not a useState initializer) to avoid
|
||||
// an SSR hydration mismatch.
|
||||
useEffect(() => {
|
||||
if (runningRef.current) return;
|
||||
let snap: ResultSnapshot | null = null;
|
||||
try {
|
||||
snap = JSON.parse(sessionStorage.getItem(RESULTS_KEY) || "null") as ResultSnapshot | null;
|
||||
} catch {
|
||||
snap = null;
|
||||
}
|
||||
if (!snap || snap.v !== 1 || !Array.isArray(snap.offers)) return;
|
||||
setModeState(snap.mode === "ai" ? "ai" : "scan");
|
||||
setOffers(snap.offers);
|
||||
setMatchCount(typeof snap.matchCount === "number" ? snap.matchCount : snap.offers.length);
|
||||
setCompaniesScanned(snap.companiesScanned ?? 0);
|
||||
setCompaniesAvailable(snap.companiesAvailable ?? 0);
|
||||
setCapHit(!!snap.capHit);
|
||||
setDroppedNoDate(snap.droppedNoDate ?? 0);
|
||||
setSources(snap.sources ?? {});
|
||||
setPartial(!!snap.partial);
|
||||
setStatus(typeof snap.status === "string" ? snap.status : "");
|
||||
setError(typeof snap.error === "string" ? snap.error : "");
|
||||
setAdded(new Set(Array.isArray(snap.added) ? snap.added : []));
|
||||
setAiTrace(Array.isArray(snap.aiTrace) ? snap.aiTrace : []);
|
||||
setAiCost(snap.aiCost ?? { searches: 0, candidates: 0, fetches: 0 });
|
||||
if (typeof snap.aiIntent === "string") setAiIntent(snap.aiIntent);
|
||||
// Never rehydrate INTO a running phase — no live stream backs it.
|
||||
const RUNNING = new Set<Phase>(["casting", "scanning", "revealing", "hunting"]);
|
||||
setPhase(RUNNING.has(snap.phase) ? (snap.offers.length ? "results" : "idle") : snap.phase);
|
||||
}, []);
|
||||
|
||||
// Persist only SETTLED states (never mid-stream) so a reload restores a complete set.
|
||||
useEffect(() => {
|
||||
const SETTLED = new Set<Phase>(["results", "empty-current", "empty-loose", "failed", "degraded", "blocked"]);
|
||||
if (!SETTLED.has(phase)) return;
|
||||
try {
|
||||
const snap: ResultSnapshot = {
|
||||
v: 1, mode, phase, offers, matchCount, companiesScanned, companiesAvailable, capHit, droppedNoDate, sources,
|
||||
partial, status, error, added: [...added], aiTrace, aiCost, aiIntent,
|
||||
};
|
||||
sessionStorage.setItem(RESULTS_KEY, JSON.stringify(snap));
|
||||
} catch {
|
||||
/* sessionStorage full/unavailable — non-fatal */
|
||||
}
|
||||
}, [phase, mode, offers, matchCount, companiesScanned, companiesAvailable, capHit, droppedNoDate, sources, partial, status, error, added, aiTrace, aiCost, aiIntent]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
filters, setFilters, initFilters, phase,
|
||||
running: phase === "casting" || phase === "scanning" || phase === "revealing" || phase === "hunting",
|
||||
offers, sources, matchCount, companiesScanned, companiesAvailable, capHit, droppedNoDate, status, partial, error, added, adding,
|
||||
discover, addToPipeline, applyPatch, reset,
|
||||
mode, setMode, aiIntent, setAiIntent, discoverAI, aiTrace, aiCost,
|
||||
}),
|
||||
[filters, setFilters, initFilters, phase, offers, sources, matchCount, companiesScanned, companiesAvailable, capHit, droppedNoDate, status, partial, error, added, adding, discover, addToPipeline, applyPatch, reset, mode, setMode, aiIntent, discoverAI, aiTrace, aiCost],
|
||||
);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Compass, ChevronDown, RotateCcw, AlertTriangle, Sparkles, Settings } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { instrumentSerif } from "@/lib/fonts";
|
||||
import type { Application, InboxJob } from "@/lib/career-ops";
|
||||
import { paramsToFilters, paramsToAi, type ExploreFilters } from "@/lib/explore";
|
||||
import { FilterBuilder } from "./filter-builder";
|
||||
import { DiscoveringState } from "./discovering-state";
|
||||
import { AiHuntView } from "./ai-hunt-view";
|
||||
import { ExploreModeToggle } from "./explore-mode-toggle";
|
||||
import { AiSearchBox } from "./ai-search-box";
|
||||
import { ResultsList, type EnrichedOffer } from "./results-list";
|
||||
import { useExplore } from "./explore-provider";
|
||||
|
||||
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
const CLI_NAMES: Record<string, string> = {
|
||||
claude: "Claude Code",
|
||||
codex: "Codex",
|
||||
gemini: "Gemini CLI",
|
||||
opencode: "OpenCode",
|
||||
copilot: "Copilot CLI",
|
||||
qwen: "Qwen CLI",
|
||||
antigravity: "Antigravity CLI",
|
||||
};
|
||||
|
||||
export function ExplorerView({
|
||||
seed,
|
||||
inboxSnapshot,
|
||||
appsSnapshot,
|
||||
rootExists,
|
||||
}: {
|
||||
seed: { filters: ExploreFilters; seededFrom: string[] };
|
||||
inboxSnapshot: InboxJob[];
|
||||
appsSnapshot: Application[];
|
||||
rootExists: boolean;
|
||||
}) {
|
||||
const { filters, setFilters, initFilters, phase, running, offers, discover, status, error, mode, setMode, aiIntent, setAiIntent, discoverAI, companiesScanned, companiesAvailable, capHit, droppedNoDate, partial } = useExplore();
|
||||
const scanNote =
|
||||
companiesScanned > 0
|
||||
? `Scanned ${companiesScanned.toLocaleString()}${companiesAvailable > companiesScanned ? ` of ${companiesAvailable.toLocaleString()}` : ""} compan${companiesScanned === 1 ? "y" : "ies"}${partial ? " · some sources were unreachable" : ""}.`
|
||||
: undefined;
|
||||
const inited = useRef(false);
|
||||
const [refineOpen, setRefineOpen] = useState(false);
|
||||
const [cli, setCli] = useState<{ id: string | null; name?: string }>({ id: null });
|
||||
const [firstRun, setFirstRun] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const id = JSON.parse(localStorage.getItem("career-ops:config") || "{}").cliId || null;
|
||||
setCli({ id, name: id ? CLI_NAMES[id] || id : undefined });
|
||||
} catch {
|
||||
setCli({ id: null });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize once from the URL (shareable search) or the server seed — without
|
||||
// clobbering anything the assistant set before this mount.
|
||||
useEffect(() => {
|
||||
if (inited.current) return;
|
||||
inited.current = true;
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
const ai = paramsToAi(sp);
|
||||
if (ai !== null) {
|
||||
setMode("ai");
|
||||
setAiIntent(ai);
|
||||
} else {
|
||||
initFilters(sp.toString() ? paramsToFilters(sp) : seed.filters);
|
||||
// Onboarding hand-off: ?run=1 auto-fires the free scan + flags the first-run
|
||||
// banner (the "matches found from your CV, free" reveal).
|
||||
if (sp.get("run") === "1") {
|
||||
setFirstRun(true);
|
||||
void discover();
|
||||
}
|
||||
}
|
||||
}, [seed.filters, initFilters, setMode, setAiIntent, discover]);
|
||||
|
||||
const inboxUrls = useMemo(() => new Set(inboxSnapshot.map((j) => j.url)), [inboxSnapshot]);
|
||||
const enriched: EnrichedOffer[] = useMemo(
|
||||
() =>
|
||||
offers.map((o) => {
|
||||
const inPipeline = inboxUrls.has(o.url);
|
||||
const c = norm(o.company);
|
||||
const t = norm(o.title);
|
||||
const ev = appsSnapshot.find((a) => {
|
||||
if (norm(a.company) !== c) return false;
|
||||
const ar = norm(a.role);
|
||||
return ar.length > 3 && (t.includes(ar) || ar.includes(t.split(" ").slice(0, 3).join(" ")));
|
||||
});
|
||||
return { ...o, inPipeline, evaluatedN: ev?.n };
|
||||
}),
|
||||
[offers, inboxUrls, appsSnapshot],
|
||||
);
|
||||
|
||||
const isAi = mode === "ai";
|
||||
if (running) return isAi ? <AiHuntView cliName={cli.name} /> : <DiscoveringState />;
|
||||
|
||||
const canDiscover = filters.ats.length > 0;
|
||||
const isResults = phase === "results";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-5 py-8 md:px-8">
|
||||
<header className="mb-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Compass className="size-6 text-brand" />
|
||||
<h1 className={`${instrumentSerif.className} text-3xl text-foreground`}>Explore</h1>
|
||||
<span className="rounded-full border border-brand/30 bg-brand-soft px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-brand">New</span>
|
||||
</div>
|
||||
<div className="w-full sm:ml-auto sm:w-auto">
|
||||
<ExploreModeToggle mode={mode} onChange={setMode} cliConfigured={!!cli.id} />
|
||||
</div>
|
||||
</div>
|
||||
{!isResults && (
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-muted">
|
||||
{isAi
|
||||
? "Describe the role in plain language — an AI hunts the open web for it, on your own AI. Candidates are unverified until you evaluate."
|
||||
: "Scan the public ATS network — Greenhouse, Lever, Ashby, Workday. Fresh postings matched to you, zero tokens. You only spend when you choose to evaluate one."}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{!rootExists && (
|
||||
<div className="mb-5 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
|
||||
Your career-ops home isn’t set up yet — discovery needs a checkout with a profile to seed from.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAi ? (
|
||||
phase === "blocked" ? (
|
||||
<BlockedCard />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<AiSearchBox
|
||||
intent={aiIntent}
|
||||
onIntent={setAiIntent}
|
||||
onSubmit={() => void discoverAI()}
|
||||
cliConfigured={!!cli.id}
|
||||
cliName={cli.name}
|
||||
onRunScan={() => setMode("scan")}
|
||||
/>
|
||||
{phase === "results" && <ResultsList offers={enriched} />}
|
||||
{phase === "empty-loose" && (
|
||||
<EmptyState
|
||||
tone="loose"
|
||||
title="No public matches — yet."
|
||||
body="AI search reads what's public. Try broader intent, or run the free Scan over the ATS network."
|
||||
onRerun={() => setMode("scan")}
|
||||
rerunLabel="Run the free Scan"
|
||||
/>
|
||||
)}
|
||||
{phase === "failed" && <FailedCard msg={error || status} onRetry={() => void discoverAI()} />}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{isResults ? (
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface/30">
|
||||
<button type="button" onClick={() => setRefineOpen((v) => !v)} className="flex w-full items-center gap-2 px-4 py-3 text-sm font-medium text-foreground">
|
||||
<Compass className="size-4 text-brand" /> Refine search
|
||||
<ChevronDown className={cn("ml-auto size-4 text-muted transition-transform", refineOpen && "rotate-180")} />
|
||||
</button>
|
||||
{refineOpen && (
|
||||
<div className="space-y-4 border-t border-border p-4">
|
||||
<FilterBuilder filters={filters} onChange={setFilters} seededFrom={seed.seededFrom} />
|
||||
<DiscoverBar canDiscover={canDiscover} onDiscover={discover} label="Re-cast (free)" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6 rounded-2xl border border-border bg-surface/30 p-5">
|
||||
<FilterBuilder filters={filters} onChange={setFilters} seededFrom={seed.seededFrom} />
|
||||
<div className="mt-5">
|
||||
<DiscoverBar canDiscover={canDiscover} onDiscover={discover} label="Discover (free)" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isResults && firstRun && (
|
||||
<div className="mb-4 flex items-start gap-2.5 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3">
|
||||
<Sparkles className="mt-0.5 size-4 shrink-0 text-emerald-500" />
|
||||
<p className="text-[13px] leading-relaxed text-foreground">
|
||||
These are live roles that match your CV. <span className="text-emerald-600 dark:text-emerald-400">Nothing here cost you a token.</span> Pick the one you're most curious about — Evaluate it and I'll tell you exactly how you score, and why.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isResults && capHit && (
|
||||
<CappedBanner companiesScanned={companiesScanned} companiesAvailable={companiesAvailable} onRefine={() => setRefineOpen(true)} />
|
||||
)}
|
||||
{isResults && <ResultsList offers={enriched} />}
|
||||
|
||||
{phase === "empty-current" && (
|
||||
<EmptyState
|
||||
tone="good"
|
||||
title="You're all caught up."
|
||||
body="Nothing new since your last scan. Your pipeline is current — that's the goal."
|
||||
note={scanNote}
|
||||
onRerun={() => {
|
||||
setFilters({ ...filters, sinceDays: Math.max(filters.sinceDays, 30) });
|
||||
void discover();
|
||||
}}
|
||||
rerunLabel="Look back 30 days"
|
||||
/>
|
||||
)}
|
||||
{phase === "empty-loose" && (
|
||||
<EmptyState
|
||||
tone="loose"
|
||||
title="No fresh matches — yet."
|
||||
body="Discovery is free — loosen and re-cast as often as you want."
|
||||
note={scanNote}
|
||||
onRerun={() => {
|
||||
setFilters({ ...filters, sinceDays: 30, block: [], allow: [] });
|
||||
void discover();
|
||||
}}
|
||||
rerunLabel="Widen to 30 days · clear location"
|
||||
/>
|
||||
)}
|
||||
{phase === "degraded" && (
|
||||
<DegradedCard
|
||||
onRetry={() => void discover()}
|
||||
companiesScanned={companiesScanned}
|
||||
companiesAvailable={companiesAvailable}
|
||||
capHit={capHit}
|
||||
droppedNoDate={droppedNoDate}
|
||||
partial={partial}
|
||||
/>
|
||||
)}
|
||||
{phase === "failed" && <FailedCard msg={error || status} onRetry={() => void discover()} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiscoverBar({ canDiscover, onDiscover, label }: { canDiscover: boolean; onDiscover: () => void; label: string }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canDiscover}
|
||||
onClick={onDiscover}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-brand px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:brightness-110 disabled:opacity-50"
|
||||
>
|
||||
<Compass className="size-4" /> {label}
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5 text-[12px] text-muted">
|
||||
<span className="size-1.5 rounded-full bg-emerald-500" />
|
||||
Evaluating a role later costs tokens. Discovering never does.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ tone, title, body, note, onRerun, rerunLabel }: { tone: "good" | "loose"; title: string; body: string; note?: string; onRerun: () => void; rerunLabel: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-surface/30 px-6 py-12 text-center">
|
||||
<div className={cn("mx-auto grid size-12 place-items-center rounded-full", tone === "good" ? "bg-emerald-500/12 text-emerald-500" : "bg-brand-soft text-brand")}>
|
||||
<Sparkles className="size-6" />
|
||||
</div>
|
||||
<h2 className={`${instrumentSerif.className} mt-4 text-2xl text-foreground`}>{title}</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-md text-sm text-muted">{body}</p>
|
||||
{note && <p className="mx-auto mt-1 max-w-md text-[12px] text-faint">{note}</p>}
|
||||
<button onClick={onRerun} className="mt-4 inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface/50 px-3.5 py-2 text-sm font-medium text-foreground transition-colors hover:border-brand/40 hover:text-brand">
|
||||
<RotateCcw className="size-4" /> {rerunLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DegradedCard({
|
||||
onRetry,
|
||||
companiesScanned,
|
||||
companiesAvailable,
|
||||
capHit,
|
||||
droppedNoDate,
|
||||
partial,
|
||||
}: {
|
||||
onRetry: () => void;
|
||||
companiesScanned: number;
|
||||
companiesAvailable: number;
|
||||
capHit: boolean;
|
||||
droppedNoDate: number;
|
||||
partial: boolean;
|
||||
}) {
|
||||
// 0 results, but the scan was NOT a clean full search → never "all caught up".
|
||||
// Pick the most informative reason (authoritative when the scanner's --json mode
|
||||
// is available; otherwise the 0-companies fallback).
|
||||
let title = "The scan ran, but couldn’t reach any sources.";
|
||||
let body =
|
||||
"The public ATS directories didn’t respond — usually a transient network hiccup or rate-limit, so nothing could be searched. This isn’t “all caught up”; a retry in a moment usually clears it.";
|
||||
if (companiesScanned > 0 && capHit) {
|
||||
title = "No matches in the slice we searched.";
|
||||
body = `The scan is capped, so it only searched ${companiesScanned.toLocaleString()}${companiesAvailable > companiesScanned ? ` of ${companiesAvailable.toLocaleString()}` : ""} companies — not the whole network. Raise scan depth (Refine search) or narrow your roles, then re-cast to look deeper.`;
|
||||
} else if (companiesScanned > 0 && droppedNoDate > 0) {
|
||||
title = "Fresh-looking roles were skipped for missing dates.";
|
||||
body = `${droppedNoDate.toLocaleString()} posting${droppedNoDate === 1 ? "" : "s"} matched but had no clear publish date, so the freshness filter dropped them. Widening the time window often brings dated equivalents back.`;
|
||||
} else if (companiesScanned > 0 && partial) {
|
||||
title = "Some job boards were unreachable.";
|
||||
body = `The scan searched ${companiesScanned.toLocaleString()} companies, but one or more sources didn’t respond — so this is a partial result, not “all caught up”. A retry usually clears it.`;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-5 text-center">
|
||||
<AlertTriangle className="mx-auto size-6 text-amber-500" />
|
||||
<p className="mt-2 text-sm font-medium text-foreground">{title}</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-[13px] text-muted">{body}</p>
|
||||
<button onClick={onRetry} className="mt-3 inline-flex items-center gap-1.5 rounded-md bg-brand-soft px-3 py-1.5 text-sm font-medium text-brand">
|
||||
<RotateCcw className="size-4" /> Retry the scan
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CappedBanner({ companiesScanned, companiesAvailable, onRefine }: { companiesScanned: number; companiesAvailable: number; onRefine: () => void }) {
|
||||
// Results ARE present, but the scan was capped — tell the user there's more, so a
|
||||
// partial list never reads as "everything there is".
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-1.5 rounded-xl border border-amber-500/25 bg-amber-500/[0.07] px-4 py-2.5 text-[13px]">
|
||||
<span className="text-foreground">
|
||||
Showing a capped slice — searched {companiesScanned.toLocaleString()}
|
||||
{companiesAvailable > companiesScanned ? ` of ${companiesAvailable.toLocaleString()}` : ""} companies.
|
||||
</span>
|
||||
<button onClick={onRefine} className="font-medium text-brand hover:underline">
|
||||
Raise scan depth to search deeper
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FailedCard({ msg, onRetry }: { msg: string; onRetry: () => void }) {
|
||||
// The scanner-missing 400 (data-only / pre-scan-ats-full checkout) must NOT
|
||||
// offer a "Try again" that re-fails forever — give a real next step instead.
|
||||
const scannerMissing = /isn'?t available|data only|complete career-ops checkout|scanner/i.test(msg);
|
||||
if (scannerMissing) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-surface/30 px-6 py-10 text-center">
|
||||
<div className="mx-auto grid size-12 place-items-center rounded-full bg-brand-soft text-brand">
|
||||
<Compass className="size-6" />
|
||||
</div>
|
||||
<h2 className={`${instrumentSerif.className} mt-4 text-2xl text-foreground`}>Discovery needs the full toolkit</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-md text-sm text-muted">
|
||||
Your career-ops home looks data-only or is on an older version. The free scanner ships with a complete checkout —
|
||||
update career-ops, or paste a job URL on the pipeline to evaluate it directly.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap justify-center gap-2">
|
||||
<Link href="/pipeline" className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3.5 py-2 text-sm font-semibold text-white transition hover:brightness-110">
|
||||
Open pipeline
|
||||
</Link>
|
||||
<Link href="/config" className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-foreground transition hover:border-brand/40 hover:text-brand">
|
||||
Open Config
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-5 text-center">
|
||||
<AlertTriangle className="mx-auto size-6 text-amber-500" />
|
||||
<p className="mt-2 text-sm font-medium text-foreground">Couldn’t finish the search.</p>
|
||||
<p className="mt-1 text-[13px] text-muted">{msg}</p>
|
||||
<button onClick={onRetry} className="mt-3 inline-flex items-center gap-1.5 rounded-md bg-brand-soft px-3 py-1.5 text-sm font-medium text-brand">
|
||||
<RotateCcw className="size-4" /> Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockedCard() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-surface/30 px-6 py-12 text-center">
|
||||
<div className="mx-auto grid size-12 place-items-center rounded-full bg-brand-soft text-brand">
|
||||
<Sparkles className="size-6" />
|
||||
</div>
|
||||
<h2 className={`${instrumentSerif.className} mt-4 text-2xl text-foreground`}>AI search needs a CLI</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-md text-sm text-muted">
|
||||
Connect Claude Code, Gemini, or any agent CLI — your key, your tokens, your machine. The free Scan stays available without one.
|
||||
</p>
|
||||
<Link href="/config" className="mt-4 inline-flex items-center gap-1.5 rounded-lg bg-brand px-3.5 py-2 text-sm font-semibold text-white transition hover:brightness-110">
|
||||
<Settings className="size-4" /> Open Config
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X, Ban, Clock, MapPin, ChevronDown, SlidersHorizontal } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { ATS_LABEL, ATS_SOURCES, cleanChips, type AtsSource, type ExploreFilters } from "@/lib/explore";
|
||||
|
||||
const RECENCY = [
|
||||
{ label: "24h", days: 1 },
|
||||
{ label: "3d", days: 3 },
|
||||
{ label: "7d", days: 7 },
|
||||
{ label: "14d", days: 14 },
|
||||
{ label: "30d", days: 30 },
|
||||
];
|
||||
|
||||
const STYLE = `
|
||||
.co-fb__chip{display:inline-flex;align-items:center;gap:.3rem;border-radius:999px;padding:.2rem .5rem .2rem .6rem;font-size:12.5px;line-height:1.2;border:1px solid transparent}
|
||||
.co-fb__chip button{display:inline-flex;opacity:.6;transition:opacity .15s}
|
||||
.co-fb__chip button:hover{opacity:1}
|
||||
.co-fb__chip.inc{color:hsl(26 78% 42%);background:hsl(26 73% 51% / .11);border-color:hsl(26 73% 51% / .26)}
|
||||
html.dark .co-fb__chip.inc{color:hsl(26 86% 70%);background:hsl(26 80% 55% / .14);border-color:hsl(26 80% 55% / .28)}
|
||||
.co-fb__field{display:flex;flex-wrap:wrap;gap:.4rem;align-items:center;min-height:2.6rem;padding:.45rem .55rem;border-radius:.7rem}
|
||||
.co-fb__field input{flex:1;min-width:7rem;background:transparent;border:none;outline:none;font-size:13.5px;color:inherit}
|
||||
.co-fb__field input::placeholder{color:var(--co-faint,hsl(0 0% 60%))}
|
||||
`;
|
||||
|
||||
function KeywordField({
|
||||
values,
|
||||
tone,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
values: string[];
|
||||
tone: "inc" | "exc";
|
||||
placeholder: string;
|
||||
onChange: (v: string[]) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
// Split only on UNAMBIGUOUS item separators (comma / newline / semicolon) — never
|
||||
// bare spaces, which are legitimate inside multi-word entries ("AI platform",
|
||||
// "New York", "Costa Rica"). A space-only paste stays one chip on purpose (#1147).
|
||||
const commit = (text: string) => {
|
||||
const parts = text.split(/[,\n;\t\r]+/);
|
||||
const next = cleanChips([...values, ...parts]);
|
||||
onChange(next);
|
||||
setDraft("");
|
||||
};
|
||||
return (
|
||||
<div className={cn("co-fb__field border border-border bg-surface/40 focus-within:border-brand/40 transition-colors")}>
|
||||
{values.map((v) => (
|
||||
<span key={v} className={cn("co-fb__chip", tone === "inc" ? "inc" : "border-border bg-surface-hover text-muted")}>
|
||||
{tone === "exc" && <Ban className="size-3 opacity-70" />}
|
||||
{v}
|
||||
<button type="button" aria-label={`Remove ${v}`} onClick={() => onChange(values.filter((x) => x !== v))}>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (/[,\n;\t\r]$/.test(val)) commit(val);
|
||||
else setDraft(val);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && draft.trim()) {
|
||||
e.preventDefault();
|
||||
commit(draft);
|
||||
} else if (e.key === "Backspace" && !draft && values.length) {
|
||||
onChange(values.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData.getData("text");
|
||||
const merged = draft + text;
|
||||
// Only commit to chips when the paste contains item separators.
|
||||
// A plain-text paste (e.g. pasting "-EMEA" after typing "Remote")
|
||||
// stays in the input field so the user can keep editing.
|
||||
if (/[,;\n\t\r]/.test(text)) commit(merged);
|
||||
else setDraft(merged);
|
||||
}}
|
||||
onBlur={() => draft.trim() && commit(draft)}
|
||||
placeholder={values.length ? "" : placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({ children, hint }: { children: React.ReactNode; hint?: string }) {
|
||||
return (
|
||||
<div className="mb-1.5 flex items-baseline justify-between">
|
||||
<span className="text-[13px] font-medium text-foreground">{children}</span>
|
||||
{hint && <span className="text-[11px] text-faint">{hint}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterBuilder({
|
||||
filters,
|
||||
onChange,
|
||||
seededFrom = [],
|
||||
}: {
|
||||
filters: ExploreFilters;
|
||||
onChange: (f: ExploreFilters) => void;
|
||||
seededFrom?: string[];
|
||||
}) {
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
const set = (patch: Partial<ExploreFilters>) => onChange({ ...filters, ...patch });
|
||||
const toggleAts = (a: AtsSource) => {
|
||||
const has = filters.ats.includes(a);
|
||||
const next = has ? filters.ats.filter((x) => x !== a) : [...filters.ats, a];
|
||||
set({ ats: next.length ? next : filters.ats });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<style>{STYLE}</style>
|
||||
|
||||
<div>
|
||||
<Label hint={filters.positive.length === 0 ? "empty = every fresh posting" : undefined}>Roles to find</Label>
|
||||
<KeywordField values={filters.positive} tone="inc" placeholder="AI platform, ML infrastructure, staff engineer…" onChange={(v) => set({ positive: v })} />
|
||||
{seededFrom.length > 0 && filters.positive.length > 0 && (
|
||||
<p className="mt-1 text-[11px] text-faint">Seeded from your {seededFrom.join(" + ")} — edit freely.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Exclude</Label>
|
||||
<KeywordField values={filters.negative} tone="exc" placeholder="manager, sales, contract…" onChange={(v) => set({ negative: v })} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-x-8 gap-y-4">
|
||||
<div>
|
||||
<Label hint="postings published in this window">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Clock className="size-3.5 text-muted" /> Posted within
|
||||
</span>
|
||||
</Label>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface/40 p-0.5">
|
||||
{RECENCY.map((r) => (
|
||||
<button
|
||||
key={r.days}
|
||||
type="button"
|
||||
onClick={() => set({ sinceDays: r.days })}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
filters.sinceDays === r.days ? "bg-brand-soft text-brand" : "text-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label hint={filters.ats.length === 0 ? "pick at least one" : undefined}>Sources</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ATS_SOURCES.map((a) => {
|
||||
const on = filters.ats.includes(a);
|
||||
return (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
onClick={() => toggleAts(a)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
on ? "border-brand/40 bg-brand-soft text-brand" : "border-border text-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{ATS_LABEL[a]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvanced((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-[12px] text-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
Location & scope
|
||||
<ChevronDown className={cn("size-3.5 transition-transform", advanced && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
{advanced && (
|
||||
<div className="space-y-3 rounded-xl border border-border bg-surface/30 p-3">
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-muted">
|
||||
<MapPin className="size-3.5" /> Location
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<Label hint="rescues multi-loc posts">Always include</Label>
|
||||
<KeywordField values={filters.alwaysAllow} tone="inc" placeholder="London…" onChange={(v) => set({ alwaysAllow: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Only in</Label>
|
||||
<KeywordField values={filters.allow} tone="inc" placeholder="Remote, EMEA…" onChange={(v) => set({ allow: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Never in</Label>
|
||||
<KeywordField values={filters.block} tone="exc" placeholder="India…" onChange={(v) => set({ block: v })} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label hint={`${filters.limitPerAts} companies / source`}>Scan depth</Label>
|
||||
<input
|
||||
type="range"
|
||||
min={50}
|
||||
max={500}
|
||||
step={50}
|
||||
value={filters.limitPerAts}
|
||||
onChange={(e) => set({ limitPerAts: Number(e.target.value) })}
|
||||
className="w-full accent-brand"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Sparkles, X, FileText, Compass, ShieldCheck, Coins } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { instrumentSerif } from "@/lib/fonts";
|
||||
import { parseReport, scoreTone, legitimacyTone } from "@/lib/format";
|
||||
import { useJobs, type Job } from "@/components/jobs/job-store";
|
||||
|
||||
const SEEN_KEY = "career-ops:first-score-seen";
|
||||
|
||||
// THE AHA — fires once, the first time an evaluation completes. The maintainer's
|
||||
// north star: the WHY is the hero (a sentence that clearly read THIS CV and reasoned
|
||||
// about THIS job), the grade is large-but-secondary. A celebration, not a report.
|
||||
const STYLE = `
|
||||
.co-aha{position:fixed;inset:0;z-index:80;display:flex;align-items:center;justify-content:center;padding:1.2rem;background:color-mix(in srgb, var(--bg) 70%, rgba(0,0,0,.5));-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);animation:co-aha-in .35s ease both}
|
||||
.co-aha__card{position:relative;width:min(34rem,100%);border-radius:1.3rem;border:1px solid var(--border,hsl(0 0% 50% /.2));background:var(--bg);box-shadow:0 24px 70px -20px rgba(0,0,0,.5);overflow:hidden}
|
||||
.co-aha__glow{position:absolute;inset:0;background:radial-gradient(80% 60% at 50% -10%, hsl(26 82% 55% /.22), transparent 70%);pointer-events:none}
|
||||
.co-aha__grade{font-variant-numeric:tabular-nums;line-height:1}
|
||||
@keyframes co-aha-in{from{opacity:0;transform:translateY(10px) scale(.985)}to{opacity:1;transform:none}}
|
||||
@media(prefers-reduced-motion:reduce){.co-aha{animation:none}}
|
||||
`;
|
||||
|
||||
/** Pull the strongest "why this person" line out of the worker output. Prefer the
|
||||
* VERDICT summary; else the first substantive sentence of the report body. */
|
||||
function extractWhy(job: Job): string {
|
||||
const s = (job.result?.summary || "").trim();
|
||||
if (s.length > 30) return s.replace(/\.$/, "") + ".";
|
||||
const body = parseReport(job.text || "").body;
|
||||
const para = body
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => p.replace(/[#*>`-]/g, "").replace(/\s+/g, " ").trim())
|
||||
.find((p) => p.length > 60 && /\b(you|your|fit|match|strong|experience|background)\b/i.test(p));
|
||||
return para ? para.slice(0, 240) : "You're a strong match for this role — open the full report for the breakdown.";
|
||||
}
|
||||
|
||||
export function FirstScoreView() {
|
||||
const router = useRouter();
|
||||
const { jobs } = useJobs();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [seen, setSeen] = useState(true); // assume seen until we read localStorage (avoid flash)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setSeen(localStorage.getItem(SEEN_KEY) === "1");
|
||||
} catch {
|
||||
setSeen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const firstDone = useMemo(
|
||||
() => jobs.filter((j) => j.kind === "evaluate" && j.status === "done").sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0))[0],
|
||||
[jobs],
|
||||
);
|
||||
|
||||
// A11y for the emotional-peak modal: focus into it on open, trap Tab, Escape to
|
||||
// close, restore focus on close (mirrors the MobileNav pattern).
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const open = !seen && !dismissed && !!firstDone;
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.activeElement as HTMLElement | null;
|
||||
const t = window.setTimeout(() => panelRef.current?.querySelector<HTMLElement>("a, button")?.focus(), 40);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
try {
|
||||
localStorage.setItem(SEEN_KEY, "1"); // dismiss = seen (don't re-pop on reload)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDismissed(true);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" && panelRef.current) {
|
||||
const f = panelRef.current.querySelectorAll<HTMLElement>("a[href], button:not([disabled])");
|
||||
if (!f.length) return;
|
||||
const first = f[0];
|
||||
const last = f[f.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.clearTimeout(t);
|
||||
prev?.focus?.();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const why = extractWhy(firstDone);
|
||||
const score = firstDone.result?.score ?? null;
|
||||
const meta = parseReport(firstDone.text || "");
|
||||
const legit = meta.legitimacy;
|
||||
const company = firstDone.title.replace(/^Evaluate\s*·\s*/, "");
|
||||
const role = firstDone.subtitle || "";
|
||||
const tone = score != null ? scoreTone(`${score}`) : "muted";
|
||||
|
||||
const close = () => {
|
||||
try {
|
||||
localStorage.setItem(SEEN_KEY, "1");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="co-aha" role="dialog" aria-modal="true" aria-label="Your first score" onClick={close}>
|
||||
<style>{STYLE}</style>
|
||||
<div ref={panelRef} className="co-aha__card" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="co-aha__glow" />
|
||||
<button onClick={close} aria-label="Close" className="absolute right-3 top-3 z-10 rounded-md p-1.5 text-faint transition hover:text-foreground">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
|
||||
<div className="relative px-7 pb-7 pt-8">
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-brand">
|
||||
<span className="text-faint">//</span> the job we found you — scored
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex items-start gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className={`${instrumentSerif.className} truncate text-2xl leading-tight text-foreground`}>{role || company}</h2>
|
||||
{role && <p className="truncate text-sm text-muted">{company}</p>}
|
||||
</div>
|
||||
{score != null && (
|
||||
<div className="shrink-0 text-right">
|
||||
<div
|
||||
className={cn(
|
||||
"co-aha__grade text-5xl font-semibold",
|
||||
tone === "good" ? "text-emerald-500" : tone === "warn" ? "text-amber-500" : tone === "bad" ? "text-red-400" : "text-muted",
|
||||
)}
|
||||
>
|
||||
{score}
|
||||
</div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-faint">/ 5 fit</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* THE WHY — the hero. A sentence that read THIS CV against THIS job. */}
|
||||
<blockquote className={`${instrumentSerif.className} mt-5 border-l-2 border-brand/40 pl-4 text-[19px] leading-snug text-foreground`}>
|
||||
<Sparkles className="mb-1 inline size-4 text-brand" /> {why}
|
||||
</blockquote>
|
||||
|
||||
{legit && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium",
|
||||
legitimacyTone(legit) === "good" ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
<ShieldCheck className="size-3" /> Legitimacy: {legit}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-5 flex items-center gap-1.5 text-[12px] text-faint">
|
||||
<Coins className="size-3.5" /> That ran on your own AI. Everything before it — finding this job — was free.
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
close();
|
||||
router.push("/pipeline?tab=EVALUATED");
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl bg-brand px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:brightness-110"
|
||||
>
|
||||
<FileText className="size-4" /> See the full report
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
close();
|
||||
router.push("/explore");
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-border bg-surface/50 px-4 py-2.5 text-sm font-medium text-foreground transition hover:border-brand/40 hover:text-brand"
|
||||
>
|
||||
<Compass className="size-4" /> Find more like this
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Search, Plus } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { DiscoveredOffer } from "@/lib/explore";
|
||||
import { CostBadge } from "@/components/cost/cost-badge";
|
||||
import { DiscoveryCard } from "./discovery-card";
|
||||
import { useExplore } from "./explore-provider";
|
||||
|
||||
export type EnrichedOffer = DiscoveredOffer & { inPipeline: boolean; evaluatedN?: string };
|
||||
|
||||
export function ResultsList({ offers }: { offers: EnrichedOffer[] }) {
|
||||
const { companiesScanned, partial, addToPipeline, added, mode } = useExplore();
|
||||
const isAi = mode === "ai";
|
||||
const [sort, setSort] = useState<"fresh" | "company">("fresh");
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
const view = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
let list = offers;
|
||||
if (needle) list = list.filter((o) => o.title.toLowerCase().includes(needle) || o.company.toLowerCase().includes(needle));
|
||||
const sorted = [...list].sort((a, b) =>
|
||||
sort === "fresh" ? (b.postedAt || "").localeCompare(a.postedAt || "") : a.company.localeCompare(b.company),
|
||||
);
|
||||
return sorted;
|
||||
}, [offers, q, sort]);
|
||||
|
||||
const addable = offers.filter((o) => !o.inPipeline && !o.evaluatedN && !added.has(o.url));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-semibold">{offers.length}</span> {isAi ? `candidate${offers.length === 1 ? "" : "s"}` : `fresh role${offers.length === 1 ? "" : "s"}`}
|
||||
<CostBadge kind={isAi ? "spend" : "free-network"} size="xs" className="ml-2 align-middle" />
|
||||
</p>
|
||||
<p className="text-[12px] text-faint">
|
||||
{isAi
|
||||
? "found by AI on the open web · unverified until you evaluate"
|
||||
: `${companiesScanned > 0 ? `${companiesScanned.toLocaleString()} companies scanned · ` : ""}0 tokens spent${partial ? " · some boards were unreachable (normal for public directories)" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-border bg-surface/40 px-2.5 py-1.5">
|
||||
<Search className="size-3.5 text-faint" />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Filter results…"
|
||||
className="w-32 bg-transparent text-[13px] outline-none placeholder:text-faint"
|
||||
/>
|
||||
</div>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface/40 p-0.5 text-xs">
|
||||
{(["fresh", "company"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSort(s)}
|
||||
className={cn("rounded-md px-2.5 py-1 font-medium capitalize transition-colors", sort === s ? "bg-brand-soft text-brand" : "text-muted hover:text-foreground")}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{addable.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addToPipeline(addable)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface/40 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-brand-soft hover:text-brand"
|
||||
>
|
||||
<Plus className="size-3.5" /> Add all {addable.length}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{view.map((o) => (
|
||||
<DiscoveryCard key={o.url} offer={o} inPipeline={o.inPipeline} evaluatedN={o.evaluatedN} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{view.length === 0 && <p className="py-10 text-center text-sm text-faint">No results match “{q}”.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { FileDown, Loader2, FileText, RotateCcw } from "lucide-react";
|
||||
import { useJobs } from "@/components/jobs/job-store";
|
||||
import { CostBadge } from "@/components/cost/cost-badge";
|
||||
|
||||
// Fires the real career-ops `pdf` mode (worker kind "pdf") to generate an
|
||||
// ATS-optimized CV tailored to THIS offer → output/cv-… + marks the tracker.
|
||||
// Once a tailored CV exists (tracker PDF ✅, or a pdf worker just finished), it
|
||||
// becomes a "View tailored CV" link (served by /api/cv-pdf) + a regenerate icon.
|
||||
export function GeneratePdfButton({ n, company, pdfReady }: { n: string; company: string; pdfReady: boolean }) {
|
||||
const { jobs, startJob } = useJobs();
|
||||
const job = useMemo(
|
||||
() => jobs.filter((j) => j.kind === "pdf" && j.input === n).sort((a, b) => b.startedAt - a.startedAt)[0],
|
||||
[jobs, n],
|
||||
);
|
||||
const generate = () =>
|
||||
startJob({ title: `CV PDF · ${company}`, subtitle: "tailored for this role", kind: "pdf", input: n, page: `/pipeline/${n}` });
|
||||
|
||||
if (job?.status === "running")
|
||||
return (
|
||||
<Link href={`/jobs/${job.id}`} className="inline-flex items-center gap-1.5 rounded-full border border-brand/40 bg-brand-soft px-3 py-1 text-xs font-medium text-brand">
|
||||
<Loader2 className="size-3.5 animate-spin" /> Generating CV…
|
||||
</Link>
|
||||
);
|
||||
|
||||
const ready = pdfReady || job?.status === "done";
|
||||
if (ready)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<a
|
||||
href={`/api/cv-pdf?company=${encodeURIComponent(company)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/40 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 transition-colors hover:bg-emerald-500/15 dark:text-emerald-400"
|
||||
>
|
||||
<FileText className="size-3.5" /> View tailored CV
|
||||
</a>
|
||||
<button
|
||||
onClick={generate}
|
||||
title="Regenerate the tailored CV"
|
||||
className="rounded-full p-1 text-faint transition-colors hover:text-brand"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
|
||||
// Point-of-action cost affordance: generating a tailored CV runs the user's
|
||||
// AI (spends tokens). Surface it right on the trigger so cost is never a
|
||||
// surprise — the community's #1 pain (mirrors Explore's token-honesty).
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={generate}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1 text-xs font-medium text-muted transition-colors hover:border-brand/40 hover:text-brand"
|
||||
title="Generate an ATS-optimized CV tailored to this role"
|
||||
>
|
||||
<FileDown className="size-3.5" /> Generate tailored CV (PDF)
|
||||
</button>
|
||||
<CostBadge kind="spend" size="xs" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// The career-ops-docs home signature: an animated grain-gradient glow. Deferred
|
||||
// to browser idle, skipped on reduced-motion, ssr:false → zero LCP cost. Renders
|
||||
// grain in the corners (transparent center) so the dot-grid shows through.
|
||||
const GrainGradient = dynamic(
|
||||
() => import("@paper-design/shaders-react").then((m) => m.GrainGradient),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export function HeroGlow() {
|
||||
const [show, setShow] = useState(false);
|
||||
const [dark, setDark] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
const readTheme = () => setDark(document.documentElement.classList.contains("dark"));
|
||||
readTheme();
|
||||
window.addEventListener("themechange", readTheme);
|
||||
|
||||
const w = window as Window & {
|
||||
requestIdleCallback?: (cb: () => void, o?: { timeout?: number }) => number;
|
||||
};
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (typeof w.requestIdleCallback === "function") {
|
||||
w.requestIdleCallback(() => setShow(true), { timeout: 2000 });
|
||||
} else {
|
||||
timer = setTimeout(() => setShow(true), 400);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("themechange", readTheme);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<GrainGradient
|
||||
className="absolute inset-0 z-0 animate-fade-in-delayed"
|
||||
colors={dark ? ["#D5742E", "#9c2f05", "#7A2A0000"] : ["#f6c89a", "#e8a35f", "#D5742E00"]}
|
||||
colorBack="#00000000"
|
||||
softness={1}
|
||||
intensity={dark ? 0.42 : 0.26}
|
||||
noise={0.32}
|
||||
speed={0.45}
|
||||
shape="corners"
|
||||
minPixelRatio={1}
|
||||
maxPixelCount={1920 * 1080}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, X, FileText, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CompanyLogo } from "@/components/company-logo";
|
||||
import { scoreNum, scoreTone } from "@/lib/format";
|
||||
import type { Application } from "@/lib/career-ops";
|
||||
|
||||
// Awaiting-decision row: a scored role with no terminal status. One-tap Apply /
|
||||
// Skip writes back through the EXISTING /api/status (UPDATE-only, canonical states).
|
||||
export function DecisionCard({ app }: { app: Application }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState<"" | "Applied" | "Discarded">("");
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
const score = scoreNum(app.score);
|
||||
const tone = scoreTone(app.score);
|
||||
|
||||
const setStatus = async (status: "Applied" | "Discarded") => {
|
||||
setBusy(status);
|
||||
try {
|
||||
await fetch("/api/status", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ n: app.n, status }) });
|
||||
setDone(status);
|
||||
router.refresh();
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
if (done) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2.5 rounded-xl border border-border bg-surface/40 p-3.5 transition hover:border-brand/30">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<CompanyLogo name={app.company} size={24} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">{app.company}</p>
|
||||
<p className="truncate text-[13px] text-muted">{app.role}</p>
|
||||
</div>
|
||||
{Number.isFinite(score) && score > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums",
|
||||
tone === "good" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : tone === "warn" ? "bg-amber-500/10 text-amber-600 dark:text-amber-400" : "bg-surface-hover text-muted",
|
||||
)}
|
||||
>
|
||||
{app.score}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* brand-soft AT REST (not a solid fill, not hover-only): a calm-but-
|
||||
affirmative primary — a queue of these reads as gentle brand, not 6
|
||||
solid shouts (P5), while staying visibly the positive action next to
|
||||
the neutral Skip even on touch (no hover). */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => setStatus("Applied")}
|
||||
className="inline-flex flex-1 items-center justify-center gap-1.5 rounded-md bg-brand-soft px-2.5 py-1.5 text-xs font-medium text-brand transition hover:bg-brand/15 disabled:opacity-60 max-sm:min-h-[44px]"
|
||||
>
|
||||
{busy === "Applied" ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />} Mark applied
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => setStatus("Discarded")}
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-muted transition hover:text-foreground disabled:opacity-60 max-sm:min-h-[44px] max-sm:px-4"
|
||||
>
|
||||
{busy === "Discarded" ? <Loader2 className="size-3.5 animate-spin" /> : <X className="size-3.5" />} Skip
|
||||
</button>
|
||||
<a href={`/pipeline/${app.n}`} title="Open report" aria-label="Open report" className="inline-flex shrink-0 items-center justify-center rounded p-1.5 text-faint transition hover:text-brand max-sm:min-h-[44px] max-sm:min-w-[44px]">
|
||||
<FileText className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { instrumentSerif } from "@/lib/fonts";
|
||||
import { HeroGlow } from "@/components/hero-glow";
|
||||
import { CvIngest } from "@/components/cv/cv-ingest";
|
||||
|
||||
// The first-run takeover: when cv.md is missing, the CV-upload hero IS the home.
|
||||
// One input, value-coming framing (not a form), the same product chrome (HeroGlow
|
||||
// + dot-bg) so it feels like the app, not a gate. The whole aha (CV → free matches
|
||||
// → first score) flows from here.
|
||||
export function FirstRunHome() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-10 md:py-16">
|
||||
<section className="dot-bg relative overflow-hidden rounded-2xl border border-border bg-surface/40 px-7 py-10 md:px-10 md:py-12">
|
||||
<HeroGlow />
|
||||
{/* Readability scrim between the animated glow (z-0) and the copy (z-10):
|
||||
the glow still reads at the edges, but text always sits on a surface that
|
||||
clears WCAG AA contrast instead of washing out over a bright corner. */}
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 z-[1] bg-surface/55 backdrop-blur-[2px] dark:bg-background/45" />
|
||||
<div className="relative z-10">
|
||||
<p className="font-mono text-xs uppercase tracking-[0.2em] text-muted">
|
||||
<span className="text-faint">//</span> local-first · your machine
|
||||
</p>
|
||||
<h1 className={`${instrumentSerif.className} mt-3 text-4xl leading-[1.05] text-landing md:text-5xl`}>
|
||||
Drop your CV. See who's hiring you in 60 seconds.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-xl text-[15px] leading-relaxed text-muted">
|
||||
No account. No setup. Your CV is parsed once on your own AI, then we scan the live job market for roles
|
||||
that fit you — <span className="text-foreground">that part's free</span>. You only spend tokens again
|
||||
when you choose to score a role.
|
||||
</p>
|
||||
<div className="mt-7">
|
||||
<CvIngest />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Clock, FileText, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CompanyLogo } from "@/components/company-logo";
|
||||
|
||||
export type FollowUp = { num?: number; company: string; role?: string; status?: string; appliedDate?: string; notes?: string };
|
||||
|
||||
// One-tap overdue follow-up row (demand loop). "Mark followed up" appends to
|
||||
// data/follow-ups.md (append-only) and optimistically clears the row; "Snooze" is
|
||||
// a client dismiss. The cadence is the core's — we just surface + record.
|
||||
export function FollowUpCard({ followup, onLogged }: { followup: FollowUp; onLogged?: () => void }) {
|
||||
const [state, setState] = useState<"idle" | "logging" | "done" | "snoozed">("idle");
|
||||
if (state === "snoozed" || state === "done") return null;
|
||||
|
||||
const log = async () => {
|
||||
setState("logging");
|
||||
try {
|
||||
await fetch("/api/followups/log", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ num: followup.num, company: followup.company, note: "Followed up" }),
|
||||
});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
onLogged?.();
|
||||
setState("done");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 rounded-xl border border-border bg-surface/40 px-3.5 py-3 transition hover:border-brand/30">
|
||||
<div className="flex min-w-0 flex-[1_1_55%] items-center gap-3">
|
||||
<CompanyLogo name={followup.company} size={22} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">
|
||||
<span className="font-medium text-foreground">{followup.company}</span>
|
||||
{followup.role && <span className="text-muted"> · {followup.role}</span>}
|
||||
</p>
|
||||
<p className="flex items-center gap-1 text-[11px] text-faint">
|
||||
<Clock className="size-3" /> {followup.appliedDate ? `applied ${followup.appliedDate}` : "follow-up due"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={state === "logging"}
|
||||
onClick={log}
|
||||
className={cn("inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md bg-surface-hover px-2.5 py-1.5 text-xs font-medium text-foreground transition hover:bg-brand-soft hover:text-brand max-sm:min-h-[44px]")}
|
||||
>
|
||||
{state === "logging" ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />} <span className="hidden sm:inline">Mark followed up</span><span className="sm:hidden">Followed up</span>
|
||||
</button>
|
||||
{followup.num != null && (
|
||||
<a href={`/pipeline/${followup.num}`} title="Open report" className="inline-flex shrink-0 items-center justify-center rounded p-1 text-faint transition hover:text-brand max-sm:min-h-[44px] max-sm:min-w-[44px]">
|
||||
<FileText className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
<button type="button" onClick={() => setState("snoozed")} className="inline-flex shrink-0 items-center justify-center text-[11px] text-faint transition hover:text-foreground max-sm:min-h-[44px] max-sm:min-w-[44px]">
|
||||
Snooze
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Bell, CircleHelp, Sparkles, ArrowRight } from "lucide-react";
|
||||
import { instrumentSerif } from "@/lib/fonts";
|
||||
import { HeroGlow } from "@/components/hero-glow";
|
||||
import type { Application, InboxJob } from "@/lib/career-ops";
|
||||
import type { DiscoveredOffer } from "@/lib/explore";
|
||||
import { DiscoveryCard } from "@/components/explore/discovery-card";
|
||||
import { FollowUpCard, type FollowUp } from "@/components/home/follow-up-card";
|
||||
import { DecisionCard } from "@/components/home/decision-card";
|
||||
import { QuickEvaluate } from "@/components/quick-evaluate";
|
||||
|
||||
// The retention "Today": a dual-loop action queue (the maintainer's
|
||||
// "N new matches this week · M follow-ups due"). SUPPLY loop = fresh free-scan
|
||||
// matches (zero tokens, /api/whats-new); DEMAND loop = follow-ups due
|
||||
// (/api/followups). Each item one-tap actionable. Home stays a VIEW over the
|
||||
// canonical files — every action dispatches a real registry action / route.
|
||||
export function TodayDashboard({
|
||||
applications,
|
||||
inbox,
|
||||
inBetween,
|
||||
}: {
|
||||
applications: Application[];
|
||||
inbox: InboxJob[];
|
||||
inBetween: boolean;
|
||||
}) {
|
||||
const [followups, setFollowups] = useState<FollowUp[]>([]);
|
||||
const [overdue, setOverdue] = useState(0);
|
||||
const [fresh, setFresh] = useState<DiscoveredOffer[]>([]);
|
||||
const router = useRouter();
|
||||
const dateLabel = useMemo(() => new Date().toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" }), []);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
fetch("/api/followups")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setFollowups(Array.isArray(d.entries) ? d.entries : []);
|
||||
setOverdue(d.metadata?.overdue ?? d.entries?.length ?? 0);
|
||||
})
|
||||
.catch(() => {});
|
||||
fetch("/api/whats-new")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setFresh(Array.isArray(d.offers) ? d.offers : []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
// A worker (evaluate/pdf) just wrote a real tracker row — refresh the server
|
||||
// snapshot (applications/inbox props) + the client loops so the freshly-scored
|
||||
// role appears in "Awaiting your decision" without a manual reload.
|
||||
const onDone = () => {
|
||||
router.refresh();
|
||||
refetch();
|
||||
};
|
||||
window.addEventListener("co-job-done", onDone);
|
||||
return () => window.removeEventListener("co-job-done", onDone);
|
||||
}, [refetch, router]);
|
||||
|
||||
// Awaiting decision: scored (Evaluated) but no terminal status yet.
|
||||
const awaiting = useMemo(
|
||||
() => applications.filter((a) => /^evaluat/i.test(a.status)).slice(0, 6),
|
||||
[applications],
|
||||
);
|
||||
|
||||
const newThisWeek = fresh.length;
|
||||
const allClear = newThisWeek === 0 && overdue === 0 && awaiting.length === 0;
|
||||
const inboxUrls = useMemo(() => new Set(inbox.map((j) => j.url)), [inbox]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-6 py-10 max-sm:pb-24">
|
||||
<section className="dot-bg relative overflow-hidden rounded-2xl border border-border bg-surface/40 px-7 py-10 md:px-10 md:py-12">
|
||||
<HeroGlow />
|
||||
{/* Readability scrim between the animated glow (z-0) and the copy (z-10). */}
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 z-[1] bg-surface/55 backdrop-blur-[2px] dark:bg-background/45" />
|
||||
<div className="relative z-10">
|
||||
<p className="font-mono text-xs uppercase tracking-[0.2em] text-muted">
|
||||
<span className="text-faint">//</span> today · <span className="tabular-nums">{dateLabel}</span>
|
||||
</p>
|
||||
<h1 className={`${instrumentSerif.className} mt-3 text-4xl leading-[1.05] text-landing md:text-5xl`}>
|
||||
{allClear ? (
|
||||
<>You're all caught up.</>
|
||||
) : (
|
||||
<>
|
||||
{newThisWeek > 0 && (
|
||||
<>
|
||||
<span className="text-brand tabular-nums">{newThisWeek}</span> new match{newThisWeek === 1 ? "" : "es"} this week
|
||||
</>
|
||||
)}
|
||||
{newThisWeek > 0 && overdue > 0 && <span className="text-faint"> · </span>}
|
||||
{overdue > 0 && (
|
||||
<>
|
||||
<span className="text-brand tabular-nums">{overdue}</span> follow-up{overdue === 1 ? "" : "s"} due
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-xl text-sm text-muted">
|
||||
{allClear ? "I'll keep scanning the market in the background and surface anything that fits." : "Your action queue for today — discovery and follow-ups, in one place."}
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-2.5">
|
||||
<Link href="/explore" className="inline-flex items-center gap-2 rounded-full bg-brand px-5 py-2.5 text-sm font-medium text-brand-foreground transition hover:bg-brand-200 max-sm:min-h-[44px]">
|
||||
Find new roles <ArrowRight className="size-4" />
|
||||
</Link>
|
||||
<Link href="/pipeline" className="inline-flex items-center gap-2 rounded-full border border-border px-5 py-2.5 text-sm font-medium text-foreground transition hover:border-brand/40 hover:text-brand max-sm:min-h-[44px]">
|
||||
Open pipeline
|
||||
</Link>
|
||||
</div>
|
||||
{inBetween && <QuickEvaluate />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* A. Follow-ups due (demand loop) */}
|
||||
{followups.length > 0 && (
|
||||
<Section icon={Bell} title="Follow-ups due" hint="Keep your applications alive — a nudge beats silence">
|
||||
<div className="grid gap-2.5">
|
||||
{followups.map((f) => (
|
||||
<FollowUpCard key={`${f.num}-${f.company}`} followup={f} onLogged={() => setOverdue((n) => Math.max(0, n - 1))} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* B. Awaiting your decision */}
|
||||
{awaiting.length > 0 && (
|
||||
<Section icon={CircleHelp} title="Awaiting your decision" hint="Scored — apply or skip">
|
||||
<div className="grid gap-2.5 sm:grid-cols-2">
|
||||
{awaiting.map((a) => (
|
||||
<DecisionCard key={a.n} app={a} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* C. Fresh matches this week (supply loop) */}
|
||||
{fresh.length > 0 && (
|
||||
<Section icon={Sparkles} title="Fresh matches this week" hint="Found by your free scans · 0 tokens">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{fresh.slice(0, 6).map((o) => (
|
||||
<DiscoveryCard key={o.url} offer={o} inPipeline={inboxUrls.has(o.url)} />
|
||||
))}
|
||||
</div>
|
||||
{fresh.length > 6 && (
|
||||
<Link href="/explore" className="mt-3 inline-block text-sm text-muted transition hover:text-brand">
|
||||
See all {fresh.length} →
|
||||
</Link>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{allClear && (
|
||||
<div className="mt-8 rounded-2xl border border-border bg-surface/30 px-6 py-10 text-center">
|
||||
<Sparkles className="mx-auto size-6 text-brand" />
|
||||
<p className="mx-auto mt-3 max-w-md text-sm text-muted">
|
||||
Nothing needs you right now. Run a <Link href="/explore" className="text-brand hover:underline">free scan</Link> to surface this week's roles, or check your <Link href="/pipeline" className="text-brand hover:underline">pipeline</Link>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ icon: Icon, title, hint, children }: { icon: React.ComponentType<{ className?: string }>; title: string; hint: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="mt-10">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Icon className="size-4 text-brand" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-muted">{title}</h2>
|
||||
<span className="text-xs text-faint">· {hint}</span>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { Search, X } from "lucide-react";
|
||||
import type { AtsSource } from "@/lib/explore";
|
||||
import { ATS_LABEL } from "@/lib/explore";
|
||||
import { FRESHNESS_WINDOWS, SENIORITY_LABEL, type Seniority } from "@/lib/inbox";
|
||||
import { CostBadge } from "@/components/cost/cost-badge";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Free, client-side facets over the raw firehose — 0 tokens, instant. Mirrors the
|
||||
// Explore chip language so the two surfaces read as one system. On mobile the chip
|
||||
// row scrolls INSIDE its own container (never the page).
|
||||
export function FacetChips({
|
||||
within,
|
||||
setWithin,
|
||||
sources,
|
||||
toggleSource,
|
||||
seniorities,
|
||||
toggleSeniority,
|
||||
locQ,
|
||||
setLocQ,
|
||||
kw,
|
||||
setKw,
|
||||
availSources,
|
||||
availSeniorities,
|
||||
resultCount,
|
||||
totalCount,
|
||||
anyActive,
|
||||
onClear,
|
||||
}: {
|
||||
within: number | null;
|
||||
setWithin: (d: number | null) => void;
|
||||
sources: Set<AtsSource>;
|
||||
toggleSource: (s: AtsSource) => void;
|
||||
seniorities: Set<Seniority>;
|
||||
toggleSeniority: (s: Seniority) => void;
|
||||
locQ: string;
|
||||
setLocQ: (v: string) => void;
|
||||
kw: string;
|
||||
setKw: (v: string) => void;
|
||||
availSources: AtsSource[];
|
||||
availSeniorities: Seniority[];
|
||||
resultCount: number;
|
||||
totalCount: number;
|
||||
anyActive: boolean;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{/* keyword search + live count */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-faint" />
|
||||
<input
|
||||
value={kw}
|
||||
onChange={(e) => setKw(e.target.value)}
|
||||
placeholder="Filter by company or role…"
|
||||
className="w-full rounded-lg border border-border bg-surface/60 py-2 pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-faint focus:border-brand/50 focus-visible:ring-2 focus-visible:ring-brand/40 max-sm:min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted">
|
||||
<span className="tabular-nums text-foreground">{resultCount}</span>
|
||||
<span className="text-faint">/{totalCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* chip row — desktop wraps, mobile scrolls inside the container */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:overflow-visible sm:pb-0">
|
||||
{/* freshness (single-select segmented; click active to clear) */}
|
||||
<div className="inline-flex shrink-0 rounded-lg border border-border bg-surface/40 p-0.5">
|
||||
{FRESHNESS_WINDOWS.map((w) => (
|
||||
<button
|
||||
key={w.days}
|
||||
type="button"
|
||||
onClick={() => setWithin(within === w.days ? null : w.days)}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 text-xs font-medium transition-colors max-sm:min-h-[44px]",
|
||||
within === w.days ? "bg-brand-soft text-brand" : "text-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{availSources.map((s) => (
|
||||
<Pill key={s} on={sources.has(s)} onClick={() => toggleSource(s)}>
|
||||
{ATS_LABEL[s]}
|
||||
</Pill>
|
||||
))}
|
||||
|
||||
{availSeniorities.map((s) => (
|
||||
<Pill key={s} on={seniorities.has(s)} onClick={() => toggleSeniority(s)}>
|
||||
{SENIORITY_LABEL[s]}
|
||||
</Pill>
|
||||
))}
|
||||
|
||||
{/* location contains */}
|
||||
<input
|
||||
value={locQ}
|
||||
onChange={(e) => setLocQ(e.target.value)}
|
||||
placeholder="location…"
|
||||
className="w-28 shrink-0 rounded-full border border-border bg-surface/40 px-3 text-xs outline-none transition-colors placeholder:text-faint focus:border-brand/40 max-sm:min-h-[44px] py-1"
|
||||
/>
|
||||
|
||||
{anyActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 text-xs text-faint transition-colors hover:text-foreground max-sm:min-h-[44px]"
|
||||
>
|
||||
<X className="size-3" /> Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Token-honesty is bidirectional: the "free" reassurance is as always-visible
|
||||
as the tray's "spend" cue (mobile + desktop) — never desktop-only. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CostBadge kind="free" size="xs" />
|
||||
<span className="text-[11px] text-faint">Filtering is free — only scoring uses tokens.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pill({ on, onClick, children }: { on: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-2.5 text-xs font-medium transition-colors max-sm:min-h-[44px]",
|
||||
on ? "border-brand/40 bg-brand-soft text-brand" : "border-border text-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Undo2 } from "lucide-react";
|
||||
import { useJobs } from "@/components/jobs/job-store";
|
||||
import type { InboxJob } from "@/lib/career-ops";
|
||||
import type { AtsSource } from "@/lib/explore";
|
||||
import { ATS_SOURCES } from "@/lib/explore";
|
||||
import { daysSince, seniorityFromTitle, sourceFromUrl, SENIORITY_ORDER, type Seniority } from "@/lib/inbox";
|
||||
import { FacetChips } from "./facet-chips";
|
||||
import { TriageRow, type RowScore } from "./triage-row";
|
||||
import { ShortlistTray, type ShortItem } from "./shortlist-tray";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const SHORTLIST_KEY = "career-ops:shortlist";
|
||||
const HIDDEN_KEY = "career-ops:hidden";
|
||||
const CONFIG_KEY = "career-ops:config";
|
||||
const BATCH = 20;
|
||||
|
||||
// The inbox as a TRIAGE surface: Abundance → Triage → Shortlist → Opt-in Score.
|
||||
// Default is a small fresh batch (never the full wall); free facets + Save/Skip narrow
|
||||
// it; only "Score shortlist" spends tokens. 🔴 The shell is agnostic to what makes a
|
||||
// role relevant — order is freshness with a single documented plug point.
|
||||
export function InboxTriage({ inbox }: { inbox: InboxJob[] }) {
|
||||
const { jobs, startJob } = useJobs();
|
||||
|
||||
// facets
|
||||
const [within, setWithin] = useState<number | null>(null);
|
||||
const [sources, setSources] = useState<Set<AtsSource>>(() => new Set());
|
||||
const [seniorities, setSeniorities] = useState<Set<Seniority>>(() => new Set());
|
||||
const [locQ, setLocQ] = useState("");
|
||||
const [kw, setKw] = useState("");
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
// persisted triage state + ephemeral selection/undo
|
||||
const [shortlist, setShortlist] = useState<ShortItem[]>([]);
|
||||
const [hidden, setHidden] = useState<string[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||
const [undo, setUndo] = useState<{ label: string; fn: () => void } | null>(null);
|
||||
const [hasCli, setHasCli] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const s = localStorage.getItem(SHORTLIST_KEY);
|
||||
if (s) setShortlist(JSON.parse(s));
|
||||
const h = localStorage.getItem(HIDDEN_KEY);
|
||||
if (h) setHidden(JSON.parse(h));
|
||||
const c = localStorage.getItem(CONFIG_KEY);
|
||||
setHasCli(!!(c && JSON.parse(c).cliId));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (loaded) try { localStorage.setItem(SHORTLIST_KEY, JSON.stringify(shortlist)); } catch { /* quota */ }
|
||||
}, [shortlist, loaded]);
|
||||
useEffect(() => {
|
||||
if (loaded) try { localStorage.setItem(HIDDEN_KEY, JSON.stringify(hidden)); } catch { /* quota */ }
|
||||
}, [hidden, loaded]);
|
||||
// auto-dismiss the undo toast
|
||||
useEffect(() => {
|
||||
if (!undo) return;
|
||||
const t = setTimeout(() => setUndo(null), 5000);
|
||||
return () => clearTimeout(t);
|
||||
}, [undo]);
|
||||
|
||||
// stable "now" for freshness (per mount)
|
||||
const now = useMemo(() => Date.now(), []);
|
||||
|
||||
// Dedupe by URL — pipeline.md can list the same posting twice; it's one job, so it
|
||||
// triages once (and Save/Skip/score, all keyed by URL, act on it coherently).
|
||||
const enriched = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: { job: InboxJob; source: AtsSource | null; seniority: Seniority | null; age: number | null }[] = [];
|
||||
for (const job of inbox) {
|
||||
if (seen.has(job.url)) continue;
|
||||
seen.add(job.url);
|
||||
out.push({ job, source: sourceFromUrl(job.url), seniority: seniorityFromTitle(job.role), age: daysSince(job.postedAt, now) });
|
||||
}
|
||||
return out;
|
||||
}, [inbox, now]);
|
||||
|
||||
// EVALUADA lookup: the latest evaluate worker per posting URL (running → badge).
|
||||
const scoreByUrl = useMemo(() => {
|
||||
const best = new Map<string, (typeof jobs)[number]>();
|
||||
for (const j of jobs) {
|
||||
if (!j.input || j.kind !== "evaluate") continue;
|
||||
const ex = best.get(j.input);
|
||||
if (!ex || j.startedAt > ex.startedAt) best.set(j.input, j);
|
||||
}
|
||||
const m = new Map<string, RowScore>();
|
||||
for (const [url, j] of best) {
|
||||
m.set(url, { score: j.result?.score ?? null, tone: j.result?.tone ?? "muted", jobId: j.id, running: j.status === "running" });
|
||||
}
|
||||
return m;
|
||||
}, [jobs]);
|
||||
|
||||
// facet options — only surface what's actually present in the (non-hidden) data
|
||||
const availSources = useMemo(() => {
|
||||
const set = new Set<AtsSource>();
|
||||
for (const e of enriched) if (e.source && !hidden.includes(e.job.url)) set.add(e.source);
|
||||
return ATS_SOURCES.filter((s) => set.has(s));
|
||||
}, [enriched, hidden]);
|
||||
const availSeniorities = useMemo(() => {
|
||||
const set = new Set<Seniority>();
|
||||
for (const e of enriched) if (e.seniority && !hidden.includes(e.job.url)) set.add(e.seniority);
|
||||
return SENIORITY_ORDER.filter((s) => set.has(s));
|
||||
}, [enriched, hidden]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
enriched.filter((e) => {
|
||||
if (hidden.includes(e.job.url)) return false;
|
||||
if (within != null && (e.age == null || e.age > within)) return false;
|
||||
if (sources.size && (!e.source || !sources.has(e.source))) return false;
|
||||
if (seniorities.size && (!e.seniority || !seniorities.has(e.seniority))) return false;
|
||||
if (locQ.trim() && !(e.job.location || "").toLowerCase().includes(locQ.trim().toLowerCase())) return false;
|
||||
if (kw.trim() && !`${e.job.company} ${e.job.role}`.toLowerCase().includes(kw.trim().toLowerCase())) return false;
|
||||
return true;
|
||||
}),
|
||||
[enriched, hidden, within, sources, seniorities, locQ, kw],
|
||||
);
|
||||
|
||||
// 🔴 SINGLE ORDER PLUG POINT — freshness only (newest first_seen first; unknown last).
|
||||
// A smarter ranker replaces ONLY this comparator; facets/triage/shortlist/score never
|
||||
// touch relevance. This is the whole firewall in one line.
|
||||
const ordered = useMemo(() => [...filtered].sort((a, b) => (a.age ?? Infinity) - (b.age ?? Infinity)), [filtered]);
|
||||
|
||||
const anyFacet = within != null || sources.size > 0 || seniorities.size > 0 || locQ.trim() !== "" || kw.trim() !== "";
|
||||
const capped = !showAll && !anyFacet;
|
||||
const visible = capped ? ordered.slice(0, BATCH) : ordered;
|
||||
const hiddenCount = hidden.length;
|
||||
|
||||
const isShortlisted = (url: string) => shortlist.some((s) => s.url === url);
|
||||
|
||||
const save = (job: InboxJob) => {
|
||||
if (isShortlisted(job.url)) return;
|
||||
setShortlist((s) => [...s, { url: job.url, company: job.company, role: job.role }]);
|
||||
};
|
||||
const skip = (job: InboxJob) => {
|
||||
setHidden((h) => (h.includes(job.url) ? h : [...h, job.url]));
|
||||
setUndo({ label: `Skipped ${job.company}`, fn: () => setHidden((h) => h.filter((u) => u !== job.url)) });
|
||||
};
|
||||
const toggleSelect = (url: string) =>
|
||||
setSelected((s) => {
|
||||
const n = new Set(s);
|
||||
if (n.has(url)) n.delete(url);
|
||||
else n.add(url);
|
||||
return n;
|
||||
});
|
||||
const saveSelected = () => {
|
||||
const add = enriched
|
||||
.filter((e) => selected.has(e.job.url) && !isShortlisted(e.job.url))
|
||||
.map((e) => ({ url: e.job.url, company: e.job.company, role: e.job.role }));
|
||||
if (add.length) setShortlist((s) => [...s, ...add]);
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
const estimate = useMemo(() => {
|
||||
const samples = jobs.filter((j) => j.kind === "evaluate" && j.status === "done" && j.cost?.tokens).map((j) => j.cost!);
|
||||
if (!samples.length || shortlist.length === 0) return {};
|
||||
const avgT = samples.reduce((a, c) => a + c.tokens, 0) / samples.length;
|
||||
const usds = samples.filter((s) => s.usd != null).map((s) => s.usd!);
|
||||
const avgUsd = usds.length ? usds.reduce((a, c) => a + c, 0) / usds.length : undefined;
|
||||
return { tokens: Math.round(avgT * shortlist.length), usd: avgUsd != null ? +(avgUsd * shortlist.length).toFixed(2) : undefined };
|
||||
}, [jobs, shortlist.length]);
|
||||
|
||||
const scoreShortlist = () => {
|
||||
const batchId = `shortlist-${Date.now()}`;
|
||||
for (const it of shortlist) {
|
||||
startJob({ title: `Score · ${it.company}`, subtitle: it.role, kind: "evaluate", input: it.url, page: "/pipeline", batchId });
|
||||
}
|
||||
setShortlist([]); // sent — the rows flip to Scoring… → badge via scoreByUrl
|
||||
};
|
||||
|
||||
// The parent (PipelineView) renders the rich empty-inbox card; here we always
|
||||
// have ≥1 raw posting.
|
||||
if (inbox.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("mx-auto mt-4 max-w-3xl", shortlist.length > 0 && "pb-28 sm:pb-24")}>
|
||||
<FacetChips
|
||||
within={within}
|
||||
setWithin={setWithin}
|
||||
sources={sources}
|
||||
toggleSource={(s) => setSources((set) => { const n = new Set(set); n.has(s) ? n.delete(s) : n.add(s); return n; })}
|
||||
seniorities={seniorities}
|
||||
toggleSeniority={(s) => setSeniorities((set) => { const n = new Set(set); n.has(s) ? n.delete(s) : n.add(s); return n; })}
|
||||
locQ={locQ}
|
||||
setLocQ={setLocQ}
|
||||
kw={kw}
|
||||
setKw={setKw}
|
||||
availSources={availSources}
|
||||
availSeniorities={availSeniorities}
|
||||
resultCount={filtered.length}
|
||||
totalCount={enriched.length - hiddenCount}
|
||||
anyActive={anyFacet}
|
||||
onClear={() => { setWithin(null); setSources(new Set()); setSeniorities(new Set()); setLocQ(""); setKw(""); }}
|
||||
/>
|
||||
|
||||
{/* batch header: fresh slice by default, or the full filtered set */}
|
||||
<div className="mt-4 flex items-baseline justify-between gap-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{capped ? "Fresh — worth a look" : anyFacet ? `${filtered.length} match${filtered.length === 1 ? "" : "es"}` : "All roles"}
|
||||
</p>
|
||||
{hiddenCount > 0 && (
|
||||
<button type="button" onClick={() => setHidden([])} className="text-xs text-faint transition-colors hover:text-foreground">
|
||||
{hiddenCount} hidden · restore
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* multi-select action bar */}
|
||||
{selected.size > 0 && (
|
||||
<div className="mt-2 flex items-center gap-3 rounded-lg border border-brand/30 bg-brand-soft px-3 py-2 text-sm">
|
||||
<span className="font-medium text-brand tabular-nums">{selected.size} selected</span>
|
||||
<button type="button" onClick={saveSelected} className="rounded-md bg-brand px-2.5 py-1 text-xs font-medium text-brand-foreground max-sm:min-h-[44px]">
|
||||
Save to shortlist
|
||||
</button>
|
||||
<button type="button" onClick={() => setSelected(new Set())} className="text-xs text-muted hover:text-foreground max-sm:min-h-[44px]">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visible.length > 0 ? (
|
||||
<ul className="mt-3 divide-y divide-border overflow-hidden rounded-2xl border border-border bg-surface/40">
|
||||
{visible.map((e) => (
|
||||
<TriageRow
|
||||
key={e.job.url}
|
||||
job={e.job}
|
||||
source={e.source}
|
||||
age={e.age}
|
||||
scored={scoreByUrl.get(e.job.url)}
|
||||
selected={selected.has(e.job.url)}
|
||||
shortlisted={isShortlisted(e.job.url)}
|
||||
onToggleSelect={() => toggleSelect(e.job.url)}
|
||||
onSave={() => save(e.job)}
|
||||
onSkip={() => skip(e.job)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="mt-3 rounded-2xl border border-dashed border-border bg-surface/30 px-6 py-10 text-center">
|
||||
<p className="font-display text-lg">No matches</p>
|
||||
<p className="mx-auto mt-1 max-w-sm text-sm text-muted">Loosen the filters to see more of your inbox.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* "See all N" — only when the fresh batch is capping a larger list */}
|
||||
{capped && ordered.length > BATCH && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll(true)}
|
||||
className="mt-3 inline-flex w-full items-center justify-center gap-1 rounded-xl border border-border bg-surface/40 py-2.5 text-sm font-medium text-muted transition-colors hover:border-brand/40 hover:text-brand max-sm:min-h-[44px]"
|
||||
>
|
||||
See all {ordered.length} in inbox →
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* empty-shortlist guidance (only once there's nothing saved) */}
|
||||
{shortlist.length === 0 && (
|
||||
<p className="mt-4 text-center text-xs text-faint">Save roles worth a look, then score them together — one token spend.</p>
|
||||
)}
|
||||
|
||||
{/* undo toast (sits above the tray) */}
|
||||
{undo && (
|
||||
<div className={cn("fixed inset-x-0 z-40 flex justify-center px-4", shortlist.length > 0 ? "bottom-24 sm:bottom-24" : "bottom-6")}>
|
||||
<div className="inline-flex items-center gap-3 rounded-full border border-border bg-surface px-4 py-2 text-sm shadow-lg">
|
||||
<span className="text-muted">{undo.label}</span>
|
||||
<button type="button" onClick={() => { undo.fn(); setUndo(null); }} className="inline-flex items-center gap-1 font-medium text-brand max-sm:min-h-[44px]">
|
||||
<Undo2 className="size-3.5" /> Undo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ShortlistTray
|
||||
items={shortlist}
|
||||
estimate={estimate}
|
||||
hasCli={hasCli}
|
||||
onRemove={(url) => setShortlist((s) => s.filter((x) => x.url !== url))}
|
||||
onClear={() => setShortlist([])}
|
||||
onScore={scoreShortlist}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown, Coins, Settings, Sparkles, X } from "lucide-react";
|
||||
import { CompanyLogo } from "@/components/company-logo";
|
||||
import { CostBadge } from "@/components/cost/cost-badge";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export type ShortItem = { url: string; company: string; role: string };
|
||||
|
||||
function fmtTokens(t: number): string {
|
||||
if (t >= 1_000_000) return `${(t / 1_000_000).toFixed(1)}M`;
|
||||
if (t >= 1_000) return `${Math.round(t / 1_000)}k`;
|
||||
return `${t}`;
|
||||
}
|
||||
|
||||
// The persistent shortlist tray — bottom-sheet on mobile (thumb-zone), floating card
|
||||
// on desktop. "Score shortlist" is the ONLY token spend in the whole inbox: cost is
|
||||
// shown BEFORE the click and gated behind an explicit confirm (never spend by surprise).
|
||||
export function ShortlistTray({
|
||||
items,
|
||||
estimate,
|
||||
hasCli,
|
||||
onRemove,
|
||||
onClear,
|
||||
onScore,
|
||||
}: {
|
||||
items: ShortItem[];
|
||||
estimate: { tokens?: number; usd?: number };
|
||||
hasCli: boolean;
|
||||
onRemove: (url: string) => void;
|
||||
onClear: () => void;
|
||||
onScore: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const n = items.length;
|
||||
const costText = estimate.tokens
|
||||
? `≈ ${fmtTokens(estimate.tokens)} tokens${estimate.usd != null ? ` · ≈ $${estimate.usd.toFixed(2)}` : ""}`
|
||||
: "uses your tokens";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 sm:bottom-4">
|
||||
<div className="mx-auto max-w-3xl sm:px-6">
|
||||
<div className="border-t border-border bg-surface shadow-lg shadow-black/10 sm:rounded-2xl sm:border">
|
||||
{/* expandable saved-items list */}
|
||||
{open && (
|
||||
<ul className="max-h-64 divide-y divide-border overflow-y-auto px-3 py-1">
|
||||
{items.map((it) => (
|
||||
<li key={it.url} className="flex items-center gap-2.5 py-2">
|
||||
<CompanyLogo name={it.company} size={18} />
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
<span className="font-medium">{it.company}</span> <span className="text-muted">· {it.role}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(it.url)}
|
||||
aria-label={`Remove ${it.company}`}
|
||||
className="inline-flex items-center justify-center rounded-md p-1 text-faint transition-colors hover:text-foreground max-sm:min-h-[44px] max-sm:min-w-[44px]"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* the persistent bar */}
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium max-sm:min-h-[44px]"
|
||||
>
|
||||
<ChevronDown className={cn("size-4 text-muted transition-transform", open && "rotate-180")} />
|
||||
Shortlist <span className="tabular-nums text-brand">({n})</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<button type="button" onClick={onClear} className="text-xs text-faint transition-colors hover:text-foreground max-sm:min-h-[44px]">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{!confirming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-brand px-4 py-2 text-sm font-medium text-brand-foreground transition-colors hover:bg-brand-200 max-sm:min-h-[44px]"
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
<span>Score {n}</span>
|
||||
<span className="hidden text-xs font-normal text-brand-foreground/80 sm:inline">· {costText}</span>
|
||||
</button>
|
||||
) : (
|
||||
<ConfirmScore n={n} costText={costText} hasCli={hasCli} onCancel={() => setConfirming(false)} onConfirm={() => { setConfirming(false); onScore(); }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* cost line — always visible on mobile (where it doesn't fit in the button) */}
|
||||
<div className="flex items-center gap-2 border-t border-border/60 px-3 py-1.5 text-[11px] text-muted sm:hidden">
|
||||
<CostBadge kind="spend" size="xs" />
|
||||
<span>{costText} — the only step that spends</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmScore({
|
||||
n,
|
||||
costText,
|
||||
hasCli,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
n: number;
|
||||
costText: string;
|
||||
hasCli: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
if (!hasCli) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted">No AI configured.</span>
|
||||
<Link href="/config" className="inline-flex items-center gap-1 rounded-full border border-brand/40 bg-brand-soft px-3 py-1.5 font-medium text-brand max-sm:min-h-[44px]">
|
||||
<Settings className="size-3.5" /> Set up
|
||||
</Link>
|
||||
<button type="button" onClick={onCancel} className="text-faint hover:text-foreground max-sm:min-h-[44px]">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="hidden items-center gap-1 text-[11px] text-muted sm:inline-flex">
|
||||
<Coins className="size-3.5 text-brand" /> {costText}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-brand px-4 py-2 text-sm font-medium text-brand-foreground transition-colors hover:bg-brand-200 max-sm:min-h-[44px]"
|
||||
>
|
||||
Score {n} now
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="rounded-full px-2 py-2 text-xs text-faint transition-colors hover:text-foreground max-sm:min-h-[44px]">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Bookmark, BookmarkCheck, Loader2, X } from "lucide-react";
|
||||
import type { InboxJob } from "@/lib/career-ops";
|
||||
import type { AtsSource } from "@/lib/explore";
|
||||
import { ATS_LABEL } from "@/lib/explore";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CompanyLogo } from "@/components/company-logo";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export type RowScore = { score: number | null; tone: "good" | "warn" | "bad" | "muted"; jobId: string; running: boolean };
|
||||
|
||||
function agoLabel(age: number | null): string | null {
|
||||
if (age == null) return null;
|
||||
if (age <= 0) return "today";
|
||||
if (age === 1) return "yesterday";
|
||||
if (age < 7) return `${age}d ago`;
|
||||
if (age < 30) return `${Math.floor(age / 7)}w ago`;
|
||||
return `${Math.floor(age / 30)}mo ago`;
|
||||
}
|
||||
|
||||
// One raw posting in the triage list. Shows ONLY cheap, free signals + an honest
|
||||
// "not scored" (CRUDA) — never a fake match%. Once its shortlist eval finishes it
|
||||
// flips to EVALUADA (a real A–F badge). Save→shortlist / Skip→hidden are free + undoable.
|
||||
export function TriageRow({
|
||||
job,
|
||||
source,
|
||||
age,
|
||||
scored,
|
||||
selected,
|
||||
shortlisted,
|
||||
onToggleSelect,
|
||||
onSave,
|
||||
onSkip,
|
||||
}: {
|
||||
job: InboxJob;
|
||||
source: AtsSource | null;
|
||||
age: number | null;
|
||||
scored?: RowScore;
|
||||
selected: boolean;
|
||||
shortlisted: boolean;
|
||||
onToggleSelect: () => void;
|
||||
onSave: () => void;
|
||||
onSkip: () => void;
|
||||
}) {
|
||||
const ago = agoLabel(age);
|
||||
const evaluated = !!scored && (scored.running || scored.score != null);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2.5 transition-colors sm:gap-3 sm:px-4",
|
||||
selected ? "bg-brand-soft/50" : "hover:bg-surface-hover",
|
||||
evaluated && "opacity-95",
|
||||
)}
|
||||
>
|
||||
{/* multi-select — power-user batch to shortlist */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={onToggleSelect}
|
||||
aria-label={`Select ${job.company} ${job.role}`}
|
||||
className="size-4 shrink-0 accent-brand max-sm:min-h-[44px] max-sm:min-w-[24px]"
|
||||
/>
|
||||
|
||||
<CompanyLogo name={job.company} size={20} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">
|
||||
<span className="font-medium text-foreground">{job.company}</span>
|
||||
<span className="text-muted"> · {job.role}</span>
|
||||
</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-faint">
|
||||
{job.location && <span className="truncate">{job.location}</span>}
|
||||
{source && <span className="rounded bg-surface-hover px-1 py-px font-medium text-muted">{ATS_LABEL[source]}</span>}
|
||||
{ago && <span>{ago}</span>}
|
||||
{/* 🔴 CRUDA: honest "not scored" — no fabricated match%. */}
|
||||
{!evaluated && <span className="italic text-faint/80">not scored</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* EVALUADA state (right-aligned, visually distinct from raw rows) */}
|
||||
{evaluated ? (
|
||||
<Link href={`/jobs/${scored!.jobId}`} className="flex shrink-0 items-center gap-1.5 text-xs">
|
||||
{scored!.running ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin text-brand" />
|
||||
<span className="text-brand max-sm:hidden">Scoring…</span>
|
||||
</>
|
||||
) : (
|
||||
<Badge tone={scored!.tone}>{scored!.score}/5</Badge>
|
||||
)}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
title={shortlisted ? "In your shortlist" : "Save to shortlist"}
|
||||
aria-pressed={shortlisted}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors max-sm:min-h-[44px] max-sm:min-w-[44px]",
|
||||
shortlisted ? "text-brand" : "text-muted hover:bg-surface-hover hover:text-brand",
|
||||
)}
|
||||
>
|
||||
{shortlisted ? <BookmarkCheck className="size-4" /> : <Bookmark className="size-4" />}
|
||||
<span className="max-sm:hidden">{shortlisted ? "Saved" : "Save"}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
title="Skip — hide from the inbox"
|
||||
className="inline-flex items-center justify-center rounded-md p-1 text-faint transition-colors hover:bg-surface-hover hover:text-foreground max-sm:min-h-[44px] max-sm:min-w-[44px]"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { scoreTone } from "@/lib/format";
|
||||
|
||||
export type JobStep = { kind: "tool" | "status"; label: string; ts: number };
|
||||
export type JobResult = { score: number | null; summary: string; tone: "good" | "warn" | "bad" | "muted" };
|
||||
|
||||
export type Job = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
page?: string; // route the job was launched from / refers to
|
||||
input?: string; // the URL/posting it processed (links inbox rows to their worker)
|
||||
kind?: string;
|
||||
batchId?: string; // groups jobs fired together (e.g. "evaluate all Anthropic")
|
||||
status: "running" | "done" | "error";
|
||||
steps: JobStep[];
|
||||
text: string;
|
||||
result?: JobResult;
|
||||
cost?: { tokens: number; usd?: number }; // per-run token cost (Claude result event) — local only
|
||||
startedAt: number;
|
||||
endedAt?: number;
|
||||
};
|
||||
|
||||
type StartOpts = { title: string; subtitle?: string; kind: string; input: string; page?: string; batchId?: string };
|
||||
|
||||
type Ctx = {
|
||||
jobs: Job[];
|
||||
startJob: (opts: StartOpts) => string | null;
|
||||
removeJob: (id: string) => void;
|
||||
clearFinished: () => void;
|
||||
};
|
||||
|
||||
const JobsContext = createContext<Ctx | null>(null);
|
||||
export function useJobs() {
|
||||
const c = useContext(JobsContext);
|
||||
if (!c) throw new Error("useJobs must be used within <JobsProvider>");
|
||||
return c;
|
||||
}
|
||||
|
||||
const CONFIG_KEY = "career-ops:config";
|
||||
const JOBS_KEY = "career-ops:jobs";
|
||||
|
||||
function parseVerdict(text: string): JobResult {
|
||||
const m = text.match(/VERDICT:\s*([\d.]+)\s*\/\s*5\s*[—:|-]+\s*(.+)/i);
|
||||
if (m) {
|
||||
const score = parseFloat(m[1]);
|
||||
return { score, summary: m[2].trim().replace(/\s+/g, " ").slice(0, 90), tone: scoreTone(`${score}`) };
|
||||
}
|
||||
const s = text.match(/\b([0-5](?:\.\d)?)\s*\/\s*5\b/);
|
||||
if (s) {
|
||||
const score = parseFloat(s[1]);
|
||||
return { score, summary: "", tone: scoreTone(`${score}`) };
|
||||
}
|
||||
return { score: null, summary: "", tone: "muted" };
|
||||
}
|
||||
|
||||
export function JobsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const seq = useRef(0);
|
||||
const loaded = useRef(false);
|
||||
|
||||
// restore history
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(JOBS_KEY);
|
||||
const arr = raw ? JSON.parse(raw) : null;
|
||||
if (Array.isArray(arr)) {
|
||||
// anything left "running" from a previous session is stale → mark interrupted
|
||||
setJobs(arr.map((j: Job) => (j.status === "running" ? { ...j, status: "error", steps: [...(j.steps || []), { kind: "status", label: "Interrupted (page reloaded)", ts: Date.now() }] } : j)));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
loaded.current = true;
|
||||
}, []);
|
||||
|
||||
// persist
|
||||
useEffect(() => {
|
||||
if (!loaded.current) return;
|
||||
try {
|
||||
localStorage.setItem(JOBS_KEY, JSON.stringify(jobs.slice(0, 40)));
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
}, [jobs]);
|
||||
|
||||
const patch = useCallback((id: string, fn: (j: Job) => Job) => {
|
||||
setJobs((js) => js.map((j) => (j.id === id ? fn(j) : j)));
|
||||
}, []);
|
||||
|
||||
const startJob = useCallback(
|
||||
(opts: StartOpts): string | null => {
|
||||
let cliId: string | null = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(CONFIG_KEY);
|
||||
cliId = raw ? JSON.parse(raw).cliId || null : null;
|
||||
} catch {
|
||||
cliId = null;
|
||||
}
|
||||
const id = `job-${Date.now()}-${seq.current++}`;
|
||||
const job: Job = {
|
||||
id,
|
||||
title: opts.title,
|
||||
subtitle: opts.subtitle,
|
||||
page: opts.page,
|
||||
input: opts.input,
|
||||
kind: opts.kind,
|
||||
batchId: opts.batchId,
|
||||
status: "running",
|
||||
steps: [{ kind: "status", label: "Starting…", ts: Date.now() }],
|
||||
text: "",
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
setJobs((js) => [job, ...js]);
|
||||
|
||||
if (!cliId) {
|
||||
patch(id, (j) => ({ ...j, status: "error", endedAt: Date.now(), steps: [...j.steps, { kind: "status", label: "No CLI configured — open Config", ts: Date.now() }] }));
|
||||
return id;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
let text = "";
|
||||
let verdictLine = ""; // latched separately so the 8000-char tail can't drop it
|
||||
let doneTokens = 0; // per-run token cost, forwarded on the done event (#6)
|
||||
let doneCostUsd: number | null = null;
|
||||
const steps: JobStep[] = [];
|
||||
const finish = (status: "done" | "error", lastLabel?: string) => {
|
||||
const result = status === "done" ? parseVerdict(verdictLine || text) : undefined;
|
||||
const cost = status === "done" && doneTokens > 0 ? { tokens: doneTokens, usd: doneCostUsd ?? undefined } : undefined;
|
||||
patch(id, (j) => ({
|
||||
...j,
|
||||
status,
|
||||
result,
|
||||
cost,
|
||||
endedAt: Date.now(),
|
||||
steps: lastLabel ? [...j.steps, { kind: "status", label: lastLabel, ts: Date.now() }] : j.steps,
|
||||
}));
|
||||
// persist a readable log file so the CLI/assistant can read past runs
|
||||
if (status === "done") {
|
||||
fetch("/api/runs/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, title: opts.title, subtitle: opts.subtitle, page: opts.page, input: opts.input, result, cost, steps, output: text }),
|
||||
}).catch(() => {});
|
||||
// Tell server-snapshot surfaces (Today, pipeline) to refetch — the
|
||||
// worker just wrote a real tracker row / report they don't yet see.
|
||||
if (typeof window !== "undefined" && (opts.kind === "evaluate" || opts.kind === "pdf")) {
|
||||
window.dispatchEvent(new CustomEvent("co-job-done", { detail: { kind: opts.kind, input: opts.input } }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind: opts.kind, input: opts.input, cliId }),
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
const e = await res.json().catch(() => ({}));
|
||||
finish("error", e.error || "Failed to start");
|
||||
return;
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) !== -1) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
const ev = JSON.parse(line);
|
||||
if (ev.type === "tool") {
|
||||
steps.push({ kind: "tool", label: ev.name, ts: Date.now() });
|
||||
patch(id, (j) => ({ ...j, steps: [...j.steps, { kind: "tool", label: ev.name, ts: Date.now() }] }));
|
||||
} else if (ev.type === "status") {
|
||||
steps.push({ kind: "status", label: ev.label, ts: Date.now() });
|
||||
patch(id, (j) => ({ ...j, steps: [...j.steps, { kind: "status", label: ev.label, ts: Date.now() }] }));
|
||||
} else if (ev.type === "text") {
|
||||
const full = text + ev.text;
|
||||
const vm = full.match(/VERDICT:[^\n]*/i);
|
||||
if (vm) verdictLine = vm[0];
|
||||
text = full.slice(-8000);
|
||||
patch(id, (j) => ({ ...j, text }));
|
||||
} else if (ev.type === "done") {
|
||||
// finish happens on stream-close; capture the per-run cost it carries
|
||||
if (typeof ev.tokens === "number") doneTokens = ev.tokens;
|
||||
if (typeof ev.costUsd === "number") doneCostUsd = ev.costUsd;
|
||||
} else if (ev.type === "error") {
|
||||
finish("error", ev.msg || "Error");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
finish("done", "Done");
|
||||
} catch {
|
||||
finish("error", "Connection error");
|
||||
}
|
||||
})();
|
||||
|
||||
return id;
|
||||
},
|
||||
[patch],
|
||||
);
|
||||
|
||||
const removeJob = useCallback((id: string) => setJobs((js) => js.filter((j) => j.id !== id)), []);
|
||||
const clearFinished = useCallback(() => setJobs((js) => js.filter((j) => j.status === "running")), []);
|
||||
|
||||
return <JobsContext.Provider value={{ jobs, startJob, removeJob, clearFinished }}>{children}</JobsContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, X, Loader2, AlertTriangle } from "lucide-react";
|
||||
import type { Job } from "@/components/jobs/job-store";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Humanize raw agent tool names into what the user actually cares about, so a
|
||||
// multi-minute evaluation reads as progress instead of a cryptic tool dump (#8).
|
||||
const STEP_LABELS: Record<string, string> = {
|
||||
WebFetch: "Reading the posting",
|
||||
WebSearch: "Searching the web",
|
||||
Read: "Reading your CV & profile",
|
||||
Glob: "Looking through your files",
|
||||
Grep: "Looking through your files",
|
||||
Write: "Writing the report",
|
||||
Edit: "Updating the report",
|
||||
NotebookEdit: "Updating the report",
|
||||
Bash: "Saving to your tracker",
|
||||
TodoWrite: "Planning the steps",
|
||||
Task: "Working",
|
||||
};
|
||||
const humanizeStep = (label: string): string => STEP_LABELS[label] ?? label;
|
||||
|
||||
// Auth/sign-in failures are the most common real error — detect them so we can give
|
||||
// a concrete next step instead of a dead end (#8).
|
||||
function isAuthError(job: Job): boolean {
|
||||
if (job.status !== "error") return false;
|
||||
const hay = `${job.steps[job.steps.length - 1]?.label ?? ""} ${job.text}`.toLowerCase();
|
||||
return /auth|login|sign[ -]?in|credential|api[ -]?key|unauthorized|not authenticated|installed and authenticated/.test(hay);
|
||||
}
|
||||
|
||||
const fmtElapsed = (ms: number): string => {
|
||||
const s = Math.max(0, Math.floor(ms / 1000));
|
||||
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
};
|
||||
const fmtTokens = (n: number): string => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
|
||||
|
||||
// Tick once a second WHILE running so a long evaluation visibly counts up (never
|
||||
// looks frozen). Stops re-rendering as soon as the job settles.
|
||||
function useElapsed(running: boolean, startedAt: number): number {
|
||||
const [now, setNow] = useState(startedAt);
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
setNow(Date.now());
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [running, startedAt]);
|
||||
return Math.max(0, now - startedAt);
|
||||
}
|
||||
|
||||
// The ONE worker card — a pure function of a Job. Rendered in three surfaces:
|
||||
// the sidebar tray (variant="tray", inside WorkerPills' Link), inline in the
|
||||
// assistant chat (variant="inline"), and conceptually the /jobs/[id] timeline.
|
||||
// Keeping it single is what guarantees the human UI and the agentic UI stay
|
||||
// visually identical. TONE + pillTone live here (the canonical source).
|
||||
|
||||
export const TONE = {
|
||||
good: { bar: "bg-emerald-500/70", chip: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400", icon: "text-emerald-500" },
|
||||
warn: { bar: "bg-amber-500/70", chip: "bg-amber-500/15 text-amber-700 dark:text-amber-400", icon: "text-amber-500" },
|
||||
bad: { bar: "bg-red-400/70", chip: "bg-red-500/15 text-red-700 dark:text-red-400", icon: "text-red-400" },
|
||||
muted: { bar: "bg-zinc-400/50", chip: "bg-surface-hover text-muted", icon: "text-zinc-400" },
|
||||
} as const;
|
||||
|
||||
export function pillTone(j: Job): keyof typeof TONE {
|
||||
if (j.status === "error") return "bad";
|
||||
if (j.status === "done") return j.result?.tone ?? "muted";
|
||||
return "muted";
|
||||
}
|
||||
|
||||
export function WorkerCard({
|
||||
job,
|
||||
variant = "tray",
|
||||
trailing,
|
||||
}: {
|
||||
job: Job;
|
||||
variant?: "tray" | "inline";
|
||||
trailing?: React.ReactNode;
|
||||
}) {
|
||||
const tone = TONE[pillTone(job)];
|
||||
const running = job.status === "running";
|
||||
const elapsed = useElapsed(running, job.startedAt);
|
||||
const rawLast = job.steps[job.steps.length - 1]?.label;
|
||||
const last = rawLast ? humanizeStep(rawLast) : undefined;
|
||||
const bottom = job.status === "done" && job.result?.summary ? job.result.summary : last;
|
||||
const inline = variant === "inline";
|
||||
const hasScore = job.result?.score != null;
|
||||
const authError = isAuthError(job);
|
||||
const tokens = job.status === "done" ? job.cost?.tokens ?? 0 : 0;
|
||||
|
||||
return (
|
||||
<div className={cn(inline && "rounded-xl border border-border bg-surface/60 p-2.5")}>
|
||||
<div className="flex items-center gap-2">
|
||||
{job.status === "running" ? (
|
||||
<Loader2 className="size-3 shrink-0 animate-spin text-brand" />
|
||||
) : job.status === "error" ? (
|
||||
<AlertTriangle className={cn("size-3 shrink-0", tone.icon)} />
|
||||
) : (
|
||||
<Check className={cn("size-3 shrink-0", tone.icon)} />
|
||||
)}
|
||||
<span className={cn("truncate font-medium", inline ? "text-sm" : "text-xs")}>{job.title}</span>
|
||||
{hasScore && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto shrink-0 rounded px-1 py-0.5 font-semibold tabular-nums",
|
||||
inline ? "text-xs" : "text-[10px]",
|
||||
tone.chip,
|
||||
)}
|
||||
>
|
||||
{job.result!.score}
|
||||
</span>
|
||||
)}
|
||||
{trailing != null && (
|
||||
<span className={cn("shrink-0", hasScore ? "ml-1" : "ml-auto")}>{trailing}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("mt-1.5 w-full overflow-hidden rounded-full bg-surface-hover", inline ? "h-1.5" : "h-1")}>
|
||||
{job.status === "running" ? (
|
||||
<div className="job-indeterminate h-full w-full" />
|
||||
) : (
|
||||
<div className={cn("h-full w-full rounded-full", tone.bar)} />
|
||||
)}
|
||||
</div>
|
||||
{(bottom || running) && (
|
||||
<div className={cn("mt-1 truncate text-faint", inline ? "text-xs" : "text-[10px]")}>
|
||||
{running ? `${last ?? "Working"} · ${fmtElapsed(elapsed)}` : bottom}
|
||||
</div>
|
||||
)}
|
||||
{authError && (
|
||||
<div className={cn("mt-1 text-amber-700 dark:text-amber-400", inline ? "text-xs" : "text-[10px]")}>
|
||||
Sign your CLI in from Config, then re-run.
|
||||
</div>
|
||||
)}
|
||||
{tokens > 0 && (
|
||||
<div className={cn("mt-1 text-faint tabular-nums", inline ? "text-xs" : "text-[10px]")}>
|
||||
{fmtTokens(tokens)} tokens{job.cost?.usd != null ? ` · $${job.cost.usd.toFixed(2)}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-exported icon used by callers that compose their own trailing affordances.
|
||||
export { X as DismissIcon };
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { X, History } from "lucide-react";
|
||||
import { useJobs } from "@/components/jobs/job-store";
|
||||
import { WorkerCard, pillTone, TONE } from "@/components/jobs/worker-card";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Back-compat re-exports (app/jobs/page.tsx imports pillTone from here).
|
||||
export { pillTone, TONE };
|
||||
|
||||
// Collapsed "worker" pills in the sidebar — each the shared <WorkerCard> wrapped
|
||||
// in a Link to its detail. Same component the assistant chat renders inline.
|
||||
export function WorkerPills() {
|
||||
const { jobs, removeJob, clearFinished } = useJobs();
|
||||
const pathname = usePathname();
|
||||
if (jobs.length === 0) return null;
|
||||
const running = jobs.filter((j) => j.status === "running").length;
|
||||
const finished = jobs.length - running;
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
<div className="mb-2 flex items-center gap-2 px-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-faint">Workers</span>
|
||||
{running > 0 && <span className="text-[10px] tabular-nums text-brand">{running} running</span>}
|
||||
<Link href="/jobs" className="ml-auto text-faint transition-colors hover:text-foreground" title="History" aria-label="Worker history">
|
||||
<History className="size-3.5" />
|
||||
</Link>
|
||||
{finished > 0 && (
|
||||
<button onClick={clearFinished} className="text-[10px] text-faint transition-colors hover:text-foreground" title="Clear finished">
|
||||
clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{jobs.slice(0, 6).map((j) => {
|
||||
const active = pathname === `/jobs/${j.id}`;
|
||||
return (
|
||||
<li key={j.id}>
|
||||
<Link
|
||||
href={`/jobs/${j.id}`}
|
||||
className={cn(
|
||||
"group block rounded-lg border px-2.5 py-2 transition-colors",
|
||||
active ? "border-brand/50 bg-brand-soft" : "border-border bg-surface/60 hover:bg-surface-hover",
|
||||
)}
|
||||
>
|
||||
<WorkerCard
|
||||
job={j}
|
||||
variant="tray"
|
||||
trailing={
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
removeJob(j.id);
|
||||
}}
|
||||
className="text-faint opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
|
||||
aria-label="Dismiss job"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CoMark } from "@/components/co-mark";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { WorkerPills } from "@/components/jobs/worker-pills";
|
||||
import { UsageMeter } from "@/components/usage-meter";
|
||||
import { instrumentSerif } from "@/lib/fonts";
|
||||
import { NAV_ITEMS, isActivePath } from "@/lib/nav-items";
|
||||
import { useJobs } from "@/components/jobs/job-store";
|
||||
|
||||
// Mobile navigation (< md): a glass top bar + a right-side slide-over drawer that
|
||||
// mirrors the desktop sidebar (nav + workers + usage + theme). Premium details:
|
||||
// spring slide, scrim blur, swipe-to-close, body scroll-lock, Escape, focus move,
|
||||
// safe-area insets (notch / home bar), worker pulse, reduced-motion aware.
|
||||
// Motion/inset CSS is co-located (env() insets + the Tailwind v4 stale-CSS HMR gotcha).
|
||||
const STYLE = `
|
||||
/* Solid bg == the page bg == the theme-color meta, so Safari's top bar / status
|
||||
strip / Dynamic Island and the header read as ONE continuous surface (seamless
|
||||
on iOS, esp. dark). Top padding = the notch inset PLUS a comfortable base, so
|
||||
the title never sits flush/cramped at the top (env() is 0 in browser mode). */
|
||||
.co-mnav{position:sticky;top:0;z-index:30;background:var(--bg);padding-top:calc(env(safe-area-inset-top) + .8rem)}
|
||||
.co-mscrim{position:fixed;inset:0;z-index:60;background:rgba(8,8,12,.45);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);opacity:0;pointer-events:none;transition:opacity .3s ease}
|
||||
.co-mscrim.open{opacity:1;pointer-events:auto}
|
||||
.co-mdrawer{position:fixed;top:0;right:0;bottom:0;z-index:61;width:min(20rem,86vw);display:flex;flex-direction:column;overflow-y:auto;overscroll-behavior:contain;transform:translateX(102%);transition:transform .34s cubic-bezier(.32,.72,0,1);will-change:transform;box-shadow:-16px 0 48px -16px rgba(0,0,0,.4);padding-top:calc(env(safe-area-inset-top) + .25rem)}
|
||||
.co-mdrawer.open{transform:translateX(0)}
|
||||
.co-msafe{padding-bottom:calc(1rem + env(safe-area-inset-bottom))}
|
||||
.co-pulse{animation:co-pulse 1.6s ease-in-out infinite}
|
||||
@keyframes co-pulse{0%,100%{opacity:1}50%{opacity:.35}}
|
||||
@media(prefers-reduced-motion:reduce){.co-mdrawer,.co-mscrim{transition:none}.co-pulse{animation:none}}
|
||||
`;
|
||||
|
||||
export function MobileNav() {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const { jobs } = useJobs();
|
||||
const running = jobs.filter((j) => j.status === "running").length;
|
||||
|
||||
// Close on route change.
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Scroll-lock + Escape + move focus into the drawer while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
// Trap Tab focus within the drawer while open.
|
||||
if (e.key === "Tab" && panelRef.current) {
|
||||
const f = panelRef.current.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])');
|
||||
if (f.length === 0) return;
|
||||
const first = f[0];
|
||||
const last = f[f.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const t = window.setTimeout(() => panelRef.current?.querySelector<HTMLElement>("a, button")?.focus(), 60);
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.clearTimeout(t);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Swipe-right-to-close.
|
||||
const drag = useRef({ x0: 0, dx: 0, active: false });
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
drag.current = { x0: e.touches[0].clientX, dx: 0, active: true };
|
||||
};
|
||||
const onTouchMove = (e: React.TouchEvent) => {
|
||||
if (!drag.current.active || !panelRef.current) return;
|
||||
const dx = Math.max(0, e.touches[0].clientX - drag.current.x0);
|
||||
drag.current.dx = dx;
|
||||
panelRef.current.style.transition = "none";
|
||||
panelRef.current.style.transform = `translateX(${dx}px)`;
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
if (!panelRef.current) return;
|
||||
panelRef.current.style.transition = "";
|
||||
panelRef.current.style.transform = "";
|
||||
if (drag.current.dx > 90) setOpen(false);
|
||||
drag.current.active = false;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{STYLE}</style>
|
||||
|
||||
<header className="co-mnav flex items-center gap-2 border-b border-border px-4 pb-3 md:hidden">
|
||||
<Link href="/" className="flex items-center gap-2" aria-label="career-ops home">
|
||||
<CoMark size={26} />
|
||||
<span className={`${instrumentSerif.className} relative -top-px text-xl text-landing`}>career-ops</span>
|
||||
</Link>
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open menu"
|
||||
aria-expanded={open}
|
||||
className="relative inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-md p-2 text-muted transition-colors hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
{running > 0 && <span aria-hidden className="co-pulse absolute right-1.5 top-1.5 size-2 rounded-full bg-brand ring-2 ring-surface" />}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={cn("co-mscrim md:hidden", open && "open")} onClick={() => setOpen(false)} aria-hidden />
|
||||
|
||||
<aside
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Navigation menu"
|
||||
inert={!open}
|
||||
className={cn("co-mdrawer border-l border-border bg-surface md:hidden", open && "open")}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className={`${instrumentSerif.className} text-lg text-landing`}>Menu</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close menu"
|
||||
className="inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-md p-1.5 text-muted transition-colors hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-1 px-3">
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon, chip }) => {
|
||||
const active = isActivePath(href, pathname);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
onClick={() => setOpen(false)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-3 text-[15px] transition-colors",
|
||||
active ? "bg-brand-soft text-brand" : "text-muted hover:bg-surface-hover hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
{label}
|
||||
{chip && (
|
||||
<span className="ml-auto rounded-full border border-brand/30 bg-brand-soft px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-brand">
|
||||
{chip}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="px-3">
|
||||
<WorkerPills />
|
||||
</div>
|
||||
|
||||
<div className="co-msafe mt-auto space-y-3 border-t border-border px-4 pt-4">
|
||||
<UsageMeter />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`${instrumentSerif.className} text-sm text-faint`}>local-first · v0</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Sparkles, X, Settings } from "lucide-react";
|
||||
|
||||
type Doctor = { available: boolean; onboardingNeeded: boolean; missing: string[]; warnings: string[] };
|
||||
|
||||
function hasCli(): boolean {
|
||||
try {
|
||||
return !!JSON.parse(localStorage.getItem("career-ops:config") || "{}").cliId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
"cv.md": "your CV",
|
||||
"config/profile.yml": "your profile — target roles, comp, location",
|
||||
"modes/_profile.md": "your personalization",
|
||||
"portals.yml": "the companies to scan",
|
||||
};
|
||||
|
||||
// Detect (via the core's doctor.mjs) whether setup is incomplete, and offer to
|
||||
// finish it CONVERSATIONALLY — the assistant asks in plain language and writes
|
||||
// the canonical files (no YAML to edit). This is the #1 adoption barrier.
|
||||
export function OnboardingBanner() {
|
||||
const [d, setD] = useState<Doctor | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [cli, setCli] = useState(true); // assume until read (avoid CTA flash)
|
||||
|
||||
useEffect(() => {
|
||||
setCli(hasCli());
|
||||
fetch("/api/doctor")
|
||||
.then((r) => r.json())
|
||||
.then(setD)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (dismissed || !d || !d.onboardingNeeded) return null;
|
||||
const items = d.missing.map((m) => LABELS[m] ?? m);
|
||||
const kickoff =
|
||||
`Help me finish setting up career-ops. I still need to add ${items.join(", ")} — walk me through just those, conversationally, and write the files for me. Don't ask me for anything that's already set up (for example, don't ask for my CV if it's already saved).`;
|
||||
|
||||
return (
|
||||
<div className="dot-bg relative mb-6 overflow-hidden rounded-2xl border border-brand/30 bg-gradient-to-br from-brand/10 via-surface/40 to-transparent p-5">
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="absolute right-3 top-3 text-faint transition-colors hover:text-foreground"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
<h2 className="font-display text-xl text-landing">Let's finish setting you up</h2>
|
||||
<p className="mt-1.5 max-w-xl text-sm text-muted">
|
||||
career-ops works best when it knows you. We still need {items.join(", ")}.{" "}
|
||||
<span className="text-foreground">No YAML to edit</span> — answer in plain language and the assistant writes it
|
||||
for you.
|
||||
</p>
|
||||
{cli ? (
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("co-assistant", { detail: { message: kickoff } }))}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-full bg-brand px-4 py-2 text-sm font-medium text-brand-foreground transition-colors hover:bg-brand-200"
|
||||
>
|
||||
<Sparkles className="size-4" /> Set me up with the assistant
|
||||
</button>
|
||||
) : (
|
||||
// The assistant needs a CLI to run — without one the kickoff would silently
|
||||
// drop. Send them to connect one first.
|
||||
<Link
|
||||
href="/config"
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-full bg-brand px-4 py-2 text-sm font-medium text-brand-foreground transition-colors hover:bg-brand-200"
|
||||
>
|
||||
<Settings className="size-4" /> Connect your AI CLI to get started
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Search, ChevronsUpDown, X, Compass, ArrowRight } from "lucide-react";
|
||||
import type { Application, InboxJob } from "@/lib/career-ops";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CompanyLogo } from "@/components/company-logo";
|
||||
import { canonStatus, scoreNum, scoreTone, statusDot } from "@/lib/format";
|
||||
import { InboxTriage } from "@/components/inbox/inbox-triage";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// INBOX (the triage queue) is the default tab; the rest filter the tracker.
|
||||
const TABS = [
|
||||
"INBOX",
|
||||
"ALL",
|
||||
"EVALUATED",
|
||||
"APPLIED",
|
||||
"RESPONDED",
|
||||
"INTERVIEW",
|
||||
"OFFER",
|
||||
"REJECTED",
|
||||
"DISCARDED",
|
||||
"SKIP",
|
||||
] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
const SORT_KEYS = ["company", "role", "score", "status", "date"] as const;
|
||||
type SortKey = (typeof SORT_KEYS)[number];
|
||||
|
||||
export function PipelineView({
|
||||
applications,
|
||||
inbox,
|
||||
}: {
|
||||
applications: Application[];
|
||||
inbox: InboxJob[];
|
||||
}) {
|
||||
const params = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// The URL is the SINGLE source of truth for tab/min/sort/dir, so the home stat
|
||||
// tiles' deep links AND the assistant's filterPipeline/navigate actions drive
|
||||
// the table identically (no useState mirror → no desync).
|
||||
const pTab = (params.get("tab") ?? "").toUpperCase();
|
||||
const tab: Tab = (TABS as readonly string[]).includes(pTab) ? (pTab as Tab) : "INBOX";
|
||||
const pMin = parseFloat(params.get("min") ?? "");
|
||||
const minFilter: number | null = Number.isFinite(pMin) ? pMin : null;
|
||||
const pSort = params.get("sort") ?? "";
|
||||
const sortKey: SortKey = (SORT_KEYS as readonly string[]).includes(pSort) ? (pSort as SortKey) : "score";
|
||||
const sort = { key: sortKey, dir: (params.get("dir") === "1" ? 1 : -1) as 1 | -1 };
|
||||
|
||||
// Search stays LOCAL for snappy typing; seeded from the URL and re-synced only
|
||||
// when the URL's q changes (i.e. the assistant set it) — never per keystroke.
|
||||
const [q, setQ] = useState(params.get("q") ?? "");
|
||||
const lastUrlQ = useRef(params.get("q") ?? "");
|
||||
useEffect(() => {
|
||||
const urlQ = params.get("q") ?? "";
|
||||
if (urlQ !== lastUrlQ.current) {
|
||||
lastUrlQ.current = urlQ;
|
||||
setQ(urlQ);
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
const setParams = useCallback(
|
||||
(updates: Record<string, string | number | null>) => {
|
||||
const sp = new URLSearchParams(params.toString());
|
||||
for (const [k, v] of Object.entries(updates)) {
|
||||
if (v == null || v === "") sp.delete(k);
|
||||
else sp.set(k, String(v));
|
||||
}
|
||||
const qs = sp.toString();
|
||||
router.replace(`${pathname}${qs ? `?${qs}` : ""}`, { scroll: false });
|
||||
},
|
||||
[params, router, pathname],
|
||||
);
|
||||
|
||||
// Pending + deduped by URL (pipeline.md can list the same posting twice) so the
|
||||
// header count, the tab count and the triage list all agree on one number.
|
||||
const pendingInbox = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: InboxJob[] = [];
|
||||
for (const j of inbox) {
|
||||
if (j.done || seen.has(j.url)) continue;
|
||||
seen.add(j.url);
|
||||
out.push(j);
|
||||
}
|
||||
return out;
|
||||
}, [inbox]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (tab === "INBOX") return [];
|
||||
let rows = applications;
|
||||
if (tab !== "ALL") rows = rows.filter((r) => canonStatus(r.status).includes(tab));
|
||||
if (minFilter != null) {
|
||||
rows = rows.filter((r) => {
|
||||
const n = scoreNum(r.score);
|
||||
return !Number.isNaN(n) && n >= minFilter;
|
||||
});
|
||||
}
|
||||
if (q.trim()) {
|
||||
const needle = q.toLowerCase();
|
||||
rows = rows.filter((r) => `${r.company} ${r.role}`.toLowerCase().includes(needle));
|
||||
}
|
||||
return [...rows].sort((a, b) => {
|
||||
if (sort.key === "score") {
|
||||
const an = scoreNum(a.score);
|
||||
const bn = scoreNum(b.score);
|
||||
const av = Number.isNaN(an) ? -Infinity : an;
|
||||
const bv = Number.isNaN(bn) ? -Infinity : bn;
|
||||
return (av - bv) * sort.dir;
|
||||
}
|
||||
return (a[sort.key] || "").localeCompare(b[sort.key] || "") * sort.dir;
|
||||
});
|
||||
}, [applications, tab, q, sort, minFilter]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-8 max-sm:pb-24">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl tracking-tight text-landing">Pipeline</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
<span className="tabular-nums">{pendingInbox.length}</span> in inbox ·{" "}
|
||||
<span className="tabular-nums">{applications.length}</span> tracked
|
||||
</p>
|
||||
</div>
|
||||
{/* the tracker has its own search; the inbox brings its own facet filters */}
|
||||
{tab !== "INBOX" && (
|
||||
<div className="relative w-64 max-w-[40vw]">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-faint" />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search company or role…"
|
||||
className="w-full rounded-md border border-border bg-surface/60 py-2 pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-faint focus:border-brand/50 focus-visible:ring-2 focus-visible:ring-brand/40"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* tabs */}
|
||||
<div className="mt-6 flex flex-wrap gap-1 border-b border-border">
|
||||
{TABS.map((t) => {
|
||||
const count =
|
||||
t === "INBOX"
|
||||
? pendingInbox.length
|
||||
: t === "ALL"
|
||||
? applications.length
|
||||
: applications.filter((r) => canonStatus(r.status).includes(t)).length;
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setParams({ tab: t === "INBOX" ? null : t })}
|
||||
className={cn(
|
||||
"-mb-px border-b-2 px-3 py-2 text-xs font-medium transition-colors",
|
||||
tab === t
|
||||
? "border-brand text-foreground"
|
||||
: "border-transparent text-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t} <span className="text-faint tabular-nums">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tab !== "INBOX" && minFilter != null && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-xs text-faint">Filtered:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParams({ min: null })}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-brand/40 bg-brand-soft px-2.5 py-1 text-xs font-medium text-brand transition-colors hover:bg-brand/15"
|
||||
title="Clear score filter"
|
||||
>
|
||||
score ≥ {minFilter.toFixed(1)}
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "INBOX" ? (
|
||||
/* ── Inbox: the triage surface (Abundance → Triage → Shortlist → Score) ── */
|
||||
pendingInbox.length > 0 ? (
|
||||
<InboxTriage inbox={pendingInbox} />
|
||||
) : (
|
||||
<InboxEmpty count={0} filtered={false} />
|
||||
)
|
||||
) : filtered.length > 0 ? (
|
||||
/* ── Tracker table ── */
|
||||
<div className="mt-4 overflow-hidden rounded-2xl border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-surface/60 text-left text-xs uppercase tracking-wide text-faint">
|
||||
<tr>
|
||||
{SORT_KEYS.map((k) => (
|
||||
<th
|
||||
key={k}
|
||||
className="cursor-pointer select-none px-4 py-2.5 font-medium hover:text-foreground"
|
||||
onClick={() => setParams({ sort: k, dir: sort.key === k ? sort.dir * -1 : -1 })}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{k}
|
||||
<ChevronsUpDown className="size-3" />
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map((r, i) => (
|
||||
<tr key={`${r.n}-${i}`} className="group transition-colors hover:bg-surface/40">
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<Link href={`/pipeline/${r.n}`} className="flex items-center gap-2.5 transition-colors group-hover:text-brand">
|
||||
<CompanyLogo name={r.company} size={20} />
|
||||
{r.company}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted">
|
||||
<Link href={`/pipeline/${r.n}`}>{r.role}</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge tone={scoreTone(r.score)}>{r.score || "—"}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={cn("size-1.5 shrink-0 rounded-full", statusDot(r.status))} />
|
||||
{r.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-faint tabular-nums">{r.date}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-2xl border border-dashed border-border bg-surface/30 px-6 py-12 text-center">
|
||||
<p className="font-display text-lg">No matches</p>
|
||||
<p className="mx-auto mt-1 max-w-sm text-sm text-muted">Try a different tab or clear the search.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty inbox. Self-sufficient for the mainstream user (a primary in-web action),
|
||||
// honest for devs (the CLI/file path stays, demoted to progressive transparency).
|
||||
function InboxEmpty({ count, filtered }: { count: number; filtered: boolean }) {
|
||||
if (filtered) {
|
||||
return (
|
||||
<div className="mt-4 rounded-2xl border border-dashed border-border bg-surface/30 px-6 py-12 text-center">
|
||||
<p className="font-display text-lg">No matches</p>
|
||||
<p className="mx-auto mt-1 max-w-sm text-sm text-muted">Clear the search to see the full inbox.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="dot-bg mt-4 overflow-hidden rounded-2xl border border-border bg-surface/50 bg-origin-border bg-gradient-to-tr from-brand/10 via-transparent to-transparent shadow-lg">
|
||||
<div className="flex items-center gap-2 border-b border-foreground/10 px-5 py-3">
|
||||
<span className="size-2.5 rounded-full bg-foreground/15" aria-hidden="true" />
|
||||
<span className="size-2.5 rounded-full bg-foreground/15" aria-hidden="true" />
|
||||
<span className="size-2.5 rounded-full bg-foreground/15" aria-hidden="true" />
|
||||
<span className="ml-3 font-mono text-xs tracking-wide text-muted">career-ops · inbox</span>
|
||||
</div>
|
||||
<div className="px-6 py-10 text-center">
|
||||
<p className="font-display text-lg">
|
||||
Your <span className="text-brand">inbox</span> is empty.
|
||||
</p>
|
||||
{count > 0 ? (
|
||||
<p className="mx-auto mt-2 max-w-sm text-sm text-muted">Nothing pending right now.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mx-auto mt-2 max-w-sm text-sm text-muted">Find roles that match your CV — free, no tokens spent.</p>
|
||||
<Link
|
||||
href="/explore?run=1"
|
||||
className="mt-5 inline-flex items-center gap-2 rounded-full bg-brand px-5 py-2.5 text-sm font-medium text-brand-foreground shadow-sm transition-all duration-200 hover:bg-brand-200 hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<Compass className="size-4" /> Run your first free scan <ArrowRight className="size-4" />
|
||||
</Link>
|
||||
<p className="mx-auto mt-4 max-w-sm text-xs text-muted">
|
||||
Prefer the terminal? Run <code className="rounded bg-surface-hover px-1 py-0.5 font-mono">career-ops scan</code>, or add job URLs to{" "}
|
||||
<code className="rounded bg-surface-hover px-1 py-0.5 font-mono">data/pipeline.md</code>.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import type { Application, InboxJob } from "@/lib/career-ops";
|
||||
|
||||
// A thin client mirror of the pipeline so BOTH the pages and the assistant
|
||||
// console (a different subtree) can read inbox/applications. Hydrated once from
|
||||
// /api/pipeline; refetches on window focus and on demand (refetch()) so the
|
||||
// snapshot doesn't go stale after a status writeback or a new scan.
|
||||
type Snapshot = {
|
||||
inbox: InboxJob[];
|
||||
applications: Application[];
|
||||
loading: boolean;
|
||||
refetch: () => void;
|
||||
};
|
||||
|
||||
const PipelineContext = createContext<Snapshot | null>(null);
|
||||
|
||||
export function usePipeline(): Snapshot {
|
||||
const c = useContext(PipelineContext);
|
||||
if (!c) throw new Error("usePipeline must be used within <PipelineProvider>");
|
||||
return c;
|
||||
}
|
||||
|
||||
export function PipelineProvider({ children }: { children: React.ReactNode }) {
|
||||
const [inbox, setInbox] = useState<InboxJob[]>([]);
|
||||
const [applications, setApplications] = useState<Application[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetch("/api/pipeline")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setInbox(Array.isArray(d.inbox) ? d.inbox : []);
|
||||
setApplications(Array.isArray(d.applications) ? d.applications : []);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
const onFocus = () => refetch();
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
}, [refetch]);
|
||||
|
||||
return (
|
||||
<PipelineContext.Provider value={{ inbox, applications, loading, refetch }}>
|
||||
{children}
|
||||
</PipelineContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Loader2, Radar, Wrench } from "lucide-react";
|
||||
import { CompanyLogo } from "@/components/company-logo";
|
||||
import { useJobs, type Job } from "@/components/jobs/job-store";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Company = { name: string; status: string; detail: string };
|
||||
type Result = { available: boolean; configured: boolean; companies: Company[] };
|
||||
|
||||
const TONE: Record<string, { dot: string; label: string; chip: string }> = {
|
||||
live: { dot: "bg-emerald-500", label: "live", chip: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400" },
|
||||
empty: { dot: "bg-amber-500", label: "live · empty", chip: "bg-amber-500/15 text-amber-700 dark:text-amber-400" },
|
||||
broken: { dot: "bg-red-500", label: "broken", chip: "bg-red-500/15 text-red-700 dark:text-red-400" },
|
||||
skipped: { dot: "bg-zinc-400", label: "no ATS", chip: "bg-surface-hover text-muted" },
|
||||
};
|
||||
const ORDER: Record<string, number> = { broken: 0, empty: 1, live: 2, skipped: 3 };
|
||||
|
||||
export function PortalsView() {
|
||||
const [res, setRes] = useState<Result | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { jobs, startJob } = useJobs();
|
||||
|
||||
// map the agentic "fix-portal" workers to the company they're repairing
|
||||
const fixByCompany = useMemo(() => {
|
||||
const m = new Map<string, (typeof jobs)[number]>();
|
||||
for (const j of jobs) {
|
||||
if (j.kind !== "fix-portal" || !j.input) continue;
|
||||
const ex = m.get(j.input);
|
||||
if (!ex || j.startedAt > ex.startedAt) m.set(j.input, j);
|
||||
}
|
||||
return m;
|
||||
}, [jobs]);
|
||||
|
||||
function check() {
|
||||
setLoading(true);
|
||||
fetch("/api/portals/verify")
|
||||
.then((r) => r.json())
|
||||
.then(setRes)
|
||||
.catch(() => setRes({ available: false, configured: false, companies: [] }))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
const companies = res?.companies ?? [];
|
||||
const broken = companies.filter((c) => c.status === "broken");
|
||||
const liveN = companies.filter((c) => c.status === "live" || c.status === "empty").length;
|
||||
const sorted = [...companies].sort((a, b) => (ORDER[a.status] ?? 9) - (ORDER[b.status] ?? 9));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={check}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-brand px-4 py-2 text-sm font-medium text-brand-foreground transition-colors hover:bg-brand-200 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Loader2 className="size-4 animate-spin" /> : <Radar className="size-4" />}
|
||||
Check portal health
|
||||
</button>
|
||||
{loading && <span className="text-xs text-faint">Probing each company's ATS… (~30–60s)</span>}
|
||||
</div>
|
||||
|
||||
{res && !res.available && (
|
||||
<p className="mt-4 rounded-xl border border-dashed border-border bg-surface/30 p-4 text-sm text-muted">
|
||||
<code className="text-foreground">verify-portals.mjs</code> not found — this needs a complete career-ops
|
||||
checkout (the web orchestrates the core's validator).
|
||||
</p>
|
||||
)}
|
||||
{res && res.available && !res.configured && (
|
||||
<p className="mt-4 rounded-xl border border-dashed border-border bg-surface/30 p-4 text-sm text-muted">
|
||||
No <code className="text-foreground">portals.yml</code> yet — ask the assistant to set up the companies to scan.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{res && res.configured && (
|
||||
<div className="mt-5">
|
||||
<p className="text-sm text-muted">
|
||||
<span className="tabular-nums text-emerald-600 dark:text-emerald-400">{liveN}</span> live ·{" "}
|
||||
<span className="tabular-nums text-red-600 dark:text-red-400">{broken.length}</span> broken ·{" "}
|
||||
<span className="tabular-nums">{companies.length}</span> tracked
|
||||
</p>
|
||||
{broken.length > 0 && (
|
||||
<div className="mt-3 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm">
|
||||
<span className="font-medium text-red-700 dark:text-red-400">
|
||||
{broken.length} {broken.length === 1 ? "company silently drops" : "companies silently drop"} from every
|
||||
scan
|
||||
</span>{" "}
|
||||
<span className="text-muted">
|
||||
— their ATS slug 404s. Fix the <code>careers_url</code> in <code>portals.yml</code> (or ask the assistant
|
||||
to repair them).
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ul className="mt-4 divide-y divide-border overflow-hidden rounded-2xl border border-border bg-surface/40">
|
||||
{sorted.map((c) => {
|
||||
const t = TONE[c.status] ?? TONE.skipped;
|
||||
return (
|
||||
<li key={c.name} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<CompanyLogo name={c.name} size={20} />
|
||||
<span className={cn("size-1.5 shrink-0 rounded-full", t.dot)} />
|
||||
<span className="shrink-0 text-sm font-medium">{c.name}</span>
|
||||
<span className="truncate font-mono text-xs text-faint">{c.detail}</span>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{c.status === "broken" && <FixAffordance company={c.name} job={fixByCompany.get(c.name)} onFix={() => startJob({ title: `Fix · ${c.name}`, subtitle: "repair portal slug", kind: "fix-portal", input: c.name, page: "/portals" })} />}
|
||||
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-semibold", t.chip)}>{t.label}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FixAffordance({ company, job, onFix }: { company: string; job?: Job; onFix: () => void }) {
|
||||
if (job?.status === "running")
|
||||
return (
|
||||
<Link href={`/jobs/${job.id}`} className="inline-flex items-center gap-1 text-xs font-medium text-brand">
|
||||
<Loader2 className="size-3 animate-spin" /> Fixing…
|
||||
</Link>
|
||||
);
|
||||
if (job?.status === "done")
|
||||
return (
|
||||
<Link href={`/jobs/${job.id}`} className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
||||
repaired · re-check
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
onClick={onFix}
|
||||
title={`Have the agent repair ${company}'s portal slug`}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-muted transition-colors hover:border-brand/40 hover:text-brand"
|
||||
>
|
||||
<Wrench className="size-3" /> Fix
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useJobs } from "@/components/jobs/job-store";
|
||||
import { CostBadge } from "@/components/cost/cost-badge";
|
||||
|
||||
// Auto-pipeline, one click: paste a job URL → fire a real evaluation worker
|
||||
// (the same kind:"evaluate" that runs modes/oferta.md + writes the A–F report +
|
||||
// tracker row). The worker pills + assistant cards show progress.
|
||||
export function QuickEvaluate() {
|
||||
const { startJob } = useJobs();
|
||||
const [url, setUrl] = useState("");
|
||||
const [hint, setHint] = useState("");
|
||||
|
||||
function run() {
|
||||
const u = url.trim();
|
||||
if (!/^https?:\/\//i.test(u)) {
|
||||
setHint("Paste a full job-posting URL (https://…).");
|
||||
return;
|
||||
}
|
||||
startJob({ title: "Evaluate · pasted URL", subtitle: u, kind: "evaluate", input: u, page: "/" });
|
||||
setUrl("");
|
||||
setHint("Evaluating — watch it in the Workers tray.");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-7">
|
||||
<div className="flex max-w-xl items-center gap-2 rounded-full border border-border bg-surface/70 py-1.5 pl-4 pr-1.5 shadow-sm focus-within:border-brand/50">
|
||||
<Sparkles className="size-4 shrink-0 text-brand/70" />
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
if (hint) setHint("");
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") run();
|
||||
}}
|
||||
placeholder="Paste a job URL to evaluate…"
|
||||
className="min-w-0 flex-1 bg-transparent py-1.5 text-sm outline-none placeholder:text-faint"
|
||||
/>
|
||||
<button
|
||||
onClick={run}
|
||||
className="shrink-0 rounded-full bg-brand px-4 py-1.5 text-sm font-medium text-brand-foreground transition-colors hover:bg-brand-200"
|
||||
>
|
||||
Evaluate
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<CostBadge kind="spend" size="xs" />
|
||||
<span className="text-xs text-faint">Evaluation runs on your own AI — your key, your machine.</span>
|
||||
</div>
|
||||
{hint && <p className="mt-1 text-xs text-faint">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, FileText, ExternalLink, ChevronDown } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Application } from "@/lib/career-ops";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { scoreTone, scoreNum, legitimacyTone, parseReport } from "@/lib/format";
|
||||
import { StatusSelect } from "@/components/status-select";
|
||||
import { CompanyLogo } from "@/components/company-logo";
|
||||
import { ScoreMethodology } from "@/components/score-methodology";
|
||||
import { GeneratePdfButton } from "@/components/generate-pdf-button";
|
||||
import { ApplyButton } from "@/components/apply-button";
|
||||
import { DeleteFromTracker } from "@/components/delete-from-tracker";
|
||||
|
||||
// Progressive disclosure of the report. The core writes prose blocks
|
||||
// "## F) Verdict (lead)", "## A) Role Summary", "## B) Match with CV", then
|
||||
// C–G + machine artifacts (Machine Summary YAML, Application Answers, submit
|
||||
// log). A mainstream user deciding "should I apply?" needs the verdict + fit;
|
||||
// the rest is depth-on-demand. We lead with the verdict as a callout, keep A/B
|
||||
// expanded, collapse C–G as content, and drop machine artifacts to a dimmer
|
||||
// "Technical" tier — and strip the bare "F)" author-letters from headings
|
||||
// (native <details>, no client JS — this stays a server component).
|
||||
|
||||
type Section = { heading: string; letter: string | null; content: string };
|
||||
|
||||
function cleanHeading(h: string): string {
|
||||
const stripped = h
|
||||
.replace(/^\s*(?:Block\s+)?[A-G][).:]\s*/i, "")
|
||||
.replace(/\s*\((?:lead|verdict)\)\s*$/i, "")
|
||||
.trim();
|
||||
return stripped || h.trim();
|
||||
}
|
||||
|
||||
// Machine artifacts (collapsed because they're for devs, not the mainstream) vs
|
||||
// human content C–G (collapsed only for length) — ux's "honest for devs" tier.
|
||||
function isMachine(heading: string): boolean {
|
||||
return /machine summary|submitted|submit[-\s]?log/i.test(heading);
|
||||
}
|
||||
|
||||
// A one-line teaser for a collapsed content section — drops the interaction cost
|
||||
// of "what's in here?" without defeating the collapse.
|
||||
function preview(md: string): string {
|
||||
const text = md
|
||||
.replace(/^#+\s.*$/gm, "")
|
||||
.replace(/```[\s\S]*?```/g, "")
|
||||
.replace(/[*_`>#|]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const sentence = text.split(/(?<=[.!?])\s/)[0] ?? text;
|
||||
return sentence.length > 96 ? sentence.slice(0, 96).trimEnd() + "…" : sentence;
|
||||
}
|
||||
|
||||
function splitSections(body: string): { intro: string; sections: Section[] } {
|
||||
const intro: string[] = [];
|
||||
const sections: Section[] = [];
|
||||
let cur: { heading: string; letter: string | null; lines: string[] } | null = null;
|
||||
for (const line of body.split("\n")) {
|
||||
const h = line.match(/^##\s+(.*)$/);
|
||||
if (h) {
|
||||
if (cur) sections.push({ heading: cur.heading, letter: cur.letter, content: cur.lines.join("\n").trim() });
|
||||
const heading = h[1].trim();
|
||||
const letter = heading.match(/^(?:Block\s+)?([A-G])[).:\s]/i)?.[1]?.toUpperCase() ?? null;
|
||||
cur = { heading, letter, lines: [] };
|
||||
} else if (cur) {
|
||||
cur.lines.push(line);
|
||||
} else {
|
||||
intro.push(line);
|
||||
}
|
||||
}
|
||||
if (cur) sections.push({ heading: cur.heading, letter: cur.letter, content: cur.lines.join("\n").trim() });
|
||||
return { intro: intro.join("\n").trim(), sections };
|
||||
}
|
||||
|
||||
export function ReportView({
|
||||
id,
|
||||
app,
|
||||
report,
|
||||
canDelete = false,
|
||||
}: {
|
||||
id: string;
|
||||
app: Application | null;
|
||||
report: string | null;
|
||||
/** kept in the props contract (the page passes it) but no longer surfaced —
|
||||
* the raw .md filename is a dev artifact, not header content. */
|
||||
file?: string | null;
|
||||
canDelete?: boolean;
|
||||
}) {
|
||||
const meta = report ? parseReport(report) : null;
|
||||
const field = (label: string) => meta?.fields.find((f) => f.label === label)?.value;
|
||||
const score = app?.score || field("Score");
|
||||
const date = app?.date || field("Date");
|
||||
const archetype = field("Archetype");
|
||||
const url = field("URL");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-8">
|
||||
<Link
|
||||
href="/pipeline"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted transition-colors hover:text-brand"
|
||||
>
|
||||
<ArrowLeft className="size-4" /> Pipeline
|
||||
</Link>
|
||||
|
||||
<header className="mt-5">
|
||||
<p className="font-mono text-xs uppercase tracking-[0.18em] text-faint">#{id}</p>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<CompanyLogo name={app?.company ?? meta?.title ?? `Report #${id}`} size={40} />
|
||||
<h1 className="font-display text-3xl tracking-tight text-landing">
|
||||
{app?.company ?? meta?.title ?? `Report #${id}`}
|
||||
</h1>
|
||||
</div>
|
||||
{app?.role && <p className="mt-1 text-muted">{app.role}</p>}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2.5">
|
||||
{score && <Badge tone={scoreTone(score)}>{score}</Badge>}
|
||||
{/* Verdict-first: the score's apply/don't-apply call (4.0 is the line,
|
||||
per the public methodology) as a <2s-scannable chip. */}
|
||||
{(() => {
|
||||
const n = scoreNum(score ?? "");
|
||||
if (Number.isNaN(n)) return null;
|
||||
return n >= 4.0 ? <Badge tone="good">Recommended</Badge> : <Badge tone="muted">Below the apply line</Badge>;
|
||||
})()}
|
||||
{meta?.legitimacy && <Badge tone={legitimacyTone(meta.legitimacy)}>{meta.legitimacy}</Badge>}
|
||||
{app && <StatusSelect n={id} current={app.status} />}
|
||||
<GeneratePdfButton n={id} company={app?.company ?? meta?.title ?? id} pdfReady={(app?.pdf ?? "").includes("✅")} />
|
||||
<ApplyButton n={id} url={url && url.startsWith("http") ? url : undefined} company={app?.company ?? meta?.title ?? id} pdfReady={(app?.pdf ?? "").includes("✅")} />
|
||||
</div>
|
||||
|
||||
{app && canDelete && (
|
||||
<div className="mt-3">
|
||||
<DeleteFromTracker n={id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(archetype || date || (url && url.startsWith("http"))) && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
|
||||
{archetype && <span className="max-w-full truncate">{archetype}</span>}
|
||||
{date && <span className="tabular-nums text-faint">{date}</span>}
|
||||
{url && url.startsWith("http") && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-brand hover:underline"
|
||||
>
|
||||
posting <ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{report ? (
|
||||
<>
|
||||
{(() => {
|
||||
const { intro, sections } = splitSections(meta?.body ?? report);
|
||||
// Tolerant fallback: unrecognized layout → render the whole body as
|
||||
// before, so an old/odd report never loses content.
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<article className="report-prose mt-8">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{meta?.body ?? report}</ReactMarkdown>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
// Verdict (F) leads as a highlighted callout with no competing heading —
|
||||
// it's THE answer. A/B stay expanded (fit detail); C–G collapse as
|
||||
// content (with a 1-line preview); machine artifacts drop to a dimmer
|
||||
// "Technical" tier so the CLI-DNA is present-but-clearly-secondary.
|
||||
const verdict = sections.find((s) => s.letter === "F");
|
||||
const rest = sections.filter((s) => s !== verdict);
|
||||
const machine = rest.filter((s) => isMachine(s.heading));
|
||||
const mainSections = rest.filter((s) => !isMachine(s.heading));
|
||||
const anyAB = mainSections.some((s) => s.letter === "A" || s.letter === "B");
|
||||
return (
|
||||
<div className="mt-8">
|
||||
{intro && (
|
||||
<article className="report-prose">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{intro}</ReactMarkdown>
|
||||
</article>
|
||||
)}
|
||||
|
||||
{verdict && (
|
||||
<div className="rounded-2xl border border-brand/25 bg-brand-soft/50 px-5 py-4">
|
||||
<p className="mb-1 font-mono text-[11px] uppercase tracking-[0.16em] text-brand/80">Verdict</p>
|
||||
<article className="report-prose [&_p]:font-medium [&_p]:text-foreground">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{verdict.content}</ReactMarkdown>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mainSections.map((s, i) => {
|
||||
const expanded = s.letter === "A" || s.letter === "B" || (!anyAB && i === 0);
|
||||
if (expanded) {
|
||||
return (
|
||||
<article key={i} className="report-prose mt-6">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{`## ${cleanHeading(s.heading)}\n\n${s.content}`}</ReactMarkdown>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<details key={i} className="group mt-3 overflow-hidden rounded-xl border border-border bg-surface/30">
|
||||
<summary className="flex min-h-[44px] cursor-pointer list-none items-center gap-2 px-4 py-3 transition-colors hover:bg-surface-hover">
|
||||
<span className="text-sm font-medium">{cleanHeading(s.heading)}</span>
|
||||
<span className="hidden truncate text-xs text-faint sm:inline">{preview(s.content)}</span>
|
||||
<ChevronDown className="ml-auto size-4 shrink-0 text-faint transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<div className="report-prose border-t border-border px-4 py-3">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{s.content}</ReactMarkdown>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
|
||||
{machine.length > 0 && (
|
||||
<>
|
||||
<div className="mt-6 flex items-center gap-3 text-[11px] uppercase tracking-[0.14em] text-faint">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
Technical details · for developers
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
{machine.map((s, i) => (
|
||||
<details key={i} className="group mt-2 overflow-hidden rounded-xl border border-border/60 bg-surface/20">
|
||||
<summary className="flex min-h-[44px] cursor-pointer list-none items-center gap-2 px-4 py-3 font-mono text-xs text-muted transition-colors hover:bg-surface-hover">
|
||||
{cleanHeading(s.heading)}
|
||||
<ChevronDown className="ml-auto size-4 shrink-0 text-faint transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<div className="report-prose border-t border-border/60 px-4 py-3 opacity-80">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{s.content}</ReactMarkdown>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<ScoreMethodology />
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-8 flex items-center gap-3 rounded-2xl border border-dashed border-border bg-surface/30 p-5 text-sm text-muted">
|
||||
<FileText className="size-5 shrink-0 text-faint" />
|
||||
No report file found for #{id} in <code className="text-foreground">reports/</code>.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ChevronDown, ExternalLink } from "lucide-react";
|
||||
|
||||
// Transparency = our differentiator ("why it's a 4.0 for YOU"). The wording is
|
||||
// the CANONICAL public text from career-ops.org/methodology + /docs — rendered
|
||||
// verbatim, NOT a web reinterpretation of the rubric (whose weights live in the
|
||||
// core, modes/_shared.md). Native <details> → no client JS.
|
||||
|
||||
const DIMENSIONS: [string, string][] = [
|
||||
["Match", "how well your CV maps to the role's requirements"],
|
||||
["North-star alignment", "how far the role moves you toward your stated career goal"],
|
||||
["Compensation", "the offer vs market rates (says “insufficient data” when comp is missing — never invents numbers)"],
|
||||
["Cultural signals", "team, values and ways-of-working signals from the posting"],
|
||||
["Red flags", "ghost-job, scam or mismatch warnings"],
|
||||
["Overall", "the single judgment that rolls the above into the score"],
|
||||
];
|
||||
|
||||
const BLOCKS: [string, string][] = [
|
||||
["A", "Plain-English summary of the role"],
|
||||
["B", "A table of how your CV matches each requirement, plus the gaps"],
|
||||
["C", "Strategy — how to position yourself for this role"],
|
||||
["D", "Compensation research, comparing the offer to market rates"],
|
||||
["E", "Personalization notes for your application"],
|
||||
["F", "Interview prep — STAR stories tailored to this job"],
|
||||
["G", "Posting legitimacy — a check that the listing is real, not a scam or ghost job"],
|
||||
];
|
||||
|
||||
export function ScoreMethodology() {
|
||||
return (
|
||||
<details className="group mt-10 overflow-hidden rounded-2xl border border-border bg-surface/30">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 px-5 py-3.5 text-sm font-medium transition-colors hover:bg-surface-hover">
|
||||
How career-ops scored this — and why it's for <span className="text-landing">you</span>
|
||||
<ChevronDown className="ml-auto size-4 text-faint transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<div className="space-y-5 border-t border-border px-5 py-4 text-sm">
|
||||
<p className="text-muted">
|
||||
Every role is scored <strong className="text-foreground">1.0–5.0</strong> across six dimensions.{" "}
|
||||
<strong className="text-brand">4.0</strong> is the apply / don't-apply line — below it, career-ops
|
||||
recommends against applying.
|
||||
</p>
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-faint">The six dimensions</div>
|
||||
<ul className="space-y-1.5">
|
||||
{DIMENSIONS.map(([k, v]) => (
|
||||
<li key={k}>
|
||||
<span className="font-medium text-foreground">{k}</span> <span className="text-muted">— {v}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-faint">What each report block means</div>
|
||||
<ul className="space-y-2">
|
||||
{BLOCKS.map(([k, v]) => (
|
||||
<li key={k} className="flex items-start gap-2.5">
|
||||
<span className="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded bg-brand-soft text-xs font-semibold text-brand">
|
||||
{k}
|
||||
</span>
|
||||
<span className="text-muted">{v}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<a
|
||||
href="https://career-ops.org/methodology"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-brand transition-colors hover:underline"
|
||||
>
|
||||
Full methodology <ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check } from "lucide-react";
|
||||
import { CANONICAL_STATES } from "@/lib/format";
|
||||
|
||||
// Status writeback control. Updates the existing tracker row (status cell) via
|
||||
// /api/status — never adds rows. Reverts on failure; confirms with the
|
||||
// terminal-popup animation.
|
||||
export function StatusSelect({ n, current }: { n: string; current: string }) {
|
||||
const [status, setStatus] = useState(current);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function onChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const next = e.target.value;
|
||||
const prev = status;
|
||||
setStatus(next);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/status", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ n, status: next }),
|
||||
});
|
||||
if (!res.ok) throw new Error("write failed");
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setStatus(prev); // revert on failure
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const known = (CANONICAL_STATES as readonly string[]).includes(status);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<label className="text-xs text-faint">status</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={onChange}
|
||||
disabled={busy}
|
||||
className="rounded-md border border-border bg-surface px-2.5 py-1 text-sm text-foreground outline-none transition-colors focus:border-brand/50 disabled:opacity-50"
|
||||
>
|
||||
{!known && <option value={status}>{status}</option>}
|
||||
{CANONICAL_STATES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{saved && (
|
||||
<span className="animate-terminal-popup inline-flex items-center gap-1 text-xs font-medium text-brand">
|
||||
<Check className="size-3" /> saved
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Sun, Moon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const KEY = "career-ops:theme";
|
||||
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
const [dark, setDark] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setDark(document.documentElement.classList.contains("dark"));
|
||||
}, []);
|
||||
|
||||
function toggle() {
|
||||
const next = !dark;
|
||||
setDark(next);
|
||||
document.documentElement.classList.toggle("dark", next);
|
||||
// keep the browser chrome (Safari status bar / Dynamic Island) tinted to match
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", next ? "#0a0a0a" : "#f7f6f3");
|
||||
try {
|
||||
localStorage.setItem(KEY, next ? "dark" : "light");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// let theme-reactive components (shaders) re-read
|
||||
window.dispatchEvent(new Event("themechange"));
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
title={dark ? "Light mode" : "Dark mode"}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md p-1.5 text-muted transition-colors hover:bg-surface-hover hover:text-foreground max-sm:min-h-[44px] max-sm:min-w-[44px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{dark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Score / status pill. No brand tone — orange is reserved for "active/selected"
|
||||
// (active tab, nav, focus ring), never for a score. Grades route through the
|
||||
// good/warn/bad scale so the table stays legible.
|
||||
export function Badge({
|
||||
className,
|
||||
tone = "muted",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement> & {
|
||||
tone?: "good" | "warn" | "bad" | "muted";
|
||||
}) {
|
||||
const tones = {
|
||||
good: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
|
||||
warn: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
|
||||
bad: "bg-red-500/15 text-red-700 dark:text-red-400",
|
||||
muted: "bg-surface-hover text-muted",
|
||||
} as const;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block rounded-md px-1.5 py-0.5 text-xs font-semibold tabular-nums",
|
||||
tones[tone],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Mirrors the career-ops-docs ui/button (cva) on app tokens. Docs CTAs are
|
||||
// rounded-full pills; we down-tune controls to rounded-md for dashboard
|
||||
// density and reserve the one rounded-full pill for the Today hero CTA
|
||||
// (expressed inline, not a variant — avoids pill overuse).
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-100 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: "bg-brand text-brand-foreground hover:bg-brand-200",
|
||||
outline: "border border-border bg-surface hover:bg-surface-hover hover:text-foreground",
|
||||
ghost: "hover:bg-surface-hover hover:text-foreground",
|
||||
secondary: "border border-border bg-surface text-foreground hover:bg-surface-hover",
|
||||
},
|
||||
size: { sm: "px-2 py-1.5 text-xs", icon: "p-1.5", default: "" },
|
||||
},
|
||||
defaultVariants: { variant: "primary", size: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
export function Button({
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {
|
||||
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Panel with the docs home signature: a resting rotating brand-gradient corner
|
||||
// that lights the 1px edge (bg-origin-border). Both opt-in — dense work
|
||||
// surfaces (tables, lists) pass neither corner nor elevated.
|
||||
const CORNERS = {
|
||||
br: "bg-gradient-to-br",
|
||||
bl: "bg-gradient-to-bl",
|
||||
tr: "bg-gradient-to-tr",
|
||||
tl: "bg-gradient-to-tl",
|
||||
} as const;
|
||||
|
||||
export function Card({
|
||||
className,
|
||||
corner,
|
||||
elevated,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
corner?: keyof typeof CORNERS;
|
||||
elevated?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-2xl border border-border bg-surface/50 p-5",
|
||||
corner && `${CORNERS[corner]} from-brand/10 via-transparent to-transparent bg-origin-border`,
|
||||
elevated && "shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Link from "next/link";
|
||||
import { instrumentSerif } from "@/lib/fonts";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Stat tile with the home's resting gradient corner + lit edge + shadow. The
|
||||
// number is serif ONLY when `featured` (≤1 serif number per route); the rest
|
||||
// use Inter tabular-nums so numerals never form a serif wall.
|
||||
const CORNERS = {
|
||||
br: "bg-gradient-to-br",
|
||||
bl: "bg-gradient-to-bl",
|
||||
tr: "bg-gradient-to-tr",
|
||||
} as const;
|
||||
|
||||
export function StatCard({
|
||||
href,
|
||||
icon: Icon,
|
||||
value,
|
||||
label,
|
||||
hint,
|
||||
featured = false,
|
||||
corner = "br",
|
||||
}: {
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
value: number | string;
|
||||
label: string;
|
||||
hint: string;
|
||||
featured?: boolean;
|
||||
corner?: keyof typeof CORNERS;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-2xl border border-border bg-surface/50 bg-origin-border p-5 shadow-lg transition-colors",
|
||||
CORNERS[corner],
|
||||
"from-brand/10 via-transparent to-transparent",
|
||||
"hover:border-brand/40 hover:bg-surface-hover group-hover:from-brand/20",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5 text-brand" />
|
||||
<div
|
||||
className={cn(
|
||||
"mt-3 text-4xl leading-none tabular-nums",
|
||||
featured ? instrumentSerif.className : "font-semibold",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-foreground">{label}</div>
|
||||
<div className="text-xs text-faint">{hint}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 1527 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user