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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions app/(app)/projects/[id]/stats/careers/careers-install.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
"use client";

// "Add a real /careers page to my repo" — the crawlable counterpart to the
// drop-in widget.
//
// Two steps on purpose. Detection is read-only and tells the user what we
// found before anything is written; only the second button opens a PR. People
// are reasonably wary of a button that commits to their repo sight unseen.

import { useState } from "react";
import type { RepoOption } from "@/lib/github/repo-options";

interface Detected {
framework: "next-app" | "astro";
dir: string;
typescript: boolean;
evidence: string;
}

interface InstallResult {
status: "opened" | "noop";
prUrl?: string;
paths?: string[];
detail: string;
}

const FRAMEWORK_LABEL: Record<Detected["framework"], string> = {
"next-app": "Next.js (App Router)",
astro: "Astro",
};

export function CareersInstall({
projectId,
repos,
configured,
enabled,
}: {
projectId: string;
repos: RepoOption[];
configured: boolean;
enabled: boolean;
}) {
const [open, setOpen] = useState(false);
const [repo, setRepo] = useState(repos[0]?.full_name ?? "");
const [rootPath, setRootPath] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [detected, setDetected] = useState<Detected | null | undefined>(undefined);
const [result, setResult] = useState<InstallResult | null>(null);

const selected = repos.find((r) => r.full_name === repo);

async function call(mode: "detect" | "submit") {
if (!selected) return;
const [owner, name] = selected.full_name.split("/");
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/projects/${projectId}/github/install-careers`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
owner,
repo: name,
installation_id: selected.installation_id,
default_branch: selected.default_branch ?? undefined,
root_path: rootPath.trim() || undefined,
mode,
}),
});
const payload = await res.json();
if (!res.ok) {
setError(payload?.error ?? "Something went wrong.");
return;
}
if (mode === "detect") setDetected(payload.data.detected);
else setResult(payload.data);
} catch {
setError("Could not reach GitHub. Try again.");
} finally {
setBusy(false);
}
}

if (!configured || repos.length === 0) return null;

return (
<div className="mt-4 border-t border-[var(--color-border)] pt-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h3 className="text-sm font-semibold">Server-rendered careers page</h3>
<p className="text-sm text-[var(--color-muted)]">
The widget paints your board with JavaScript, which crawlers don&apos;t run.
Open a PR that adds a real <code className="font-mono">/careers</code> route
to your repo and the roles ship as HTML on your own domain.
</p>
</div>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="btn btn-secondary text-sm"
disabled={!enabled}
>
{open ? "Close" : "Add to my repo"}
</button>
</div>

{!enabled && (
<p className="mt-2 text-xs text-[var(--color-muted)]">
Turn the careers widget on first — the generated page reads from this project&apos;s feed.
</p>
)}

{open && enabled && (
<div className="mt-3 space-y-3">
<div className="space-y-1">
<label className="text-xs text-[var(--color-muted)]">Repository</label>
<select
value={repo}
onChange={(e) => {
setRepo(e.target.value);
setDetected(undefined);
setResult(null);
}}
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm"
>
{repos.map((r) => (
<option key={`${r.installation_id}:${r.full_name}`} value={r.full_name}>
{r.full_name}
{r.bound ? " (connected)" : ""}
</option>
))}
</select>
</div>

<div className="space-y-1">
<label className="text-xs text-[var(--color-muted)]">
Site subdirectory (optional, for monorepos)
</label>
<input
type="text"
value={rootPath}
onChange={(e) => {
setRootPath(e.target.value);
setDetected(undefined);
}}
placeholder="apps/web — leave blank for the repo root"
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm"
/>
</div>

{error && <p className="text-sm text-[var(--color-danger,#dc2626)]">{error}</p>}

{detected === null && (
<p className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-muted)]">
No Next.js App Router or Astro site found there. Nothing was changed — your
board still works through the tracker snippet, it just renders client-side.
</p>
)}

{detected && (
<p className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-muted)]">
Found <strong>{FRAMEWORK_LABEL[detected.framework]}</strong> from{" "}
<code className="font-mono">{detected.evidence}</code>. The PR will add the
page under <code className="font-mono">{detected.dir}</code>.
</p>
)}

{result && (
<p className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm">
{result.detail}{" "}
{result.prUrl && (
<a
href={result.prUrl}
target="_blank"
rel="noreferrer"
className="underline hover:text-[var(--color-foreground)]"
>
Review the pull request
</a>
)}
</p>
)}

<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => call("detect")}
disabled={busy || !selected}
className="btn btn-secondary text-sm"
>
{busy ? "Checking…" : "Check repo"}
</button>
<button
type="button"
onClick={() => call("submit")}
disabled={busy || !selected || detected === null || !!result}
className="btn btn-primary text-sm"
>
{busy ? "Working…" : "Open pull request"}
</button>
</div>
</div>
)}
</div>
);
}
10 changes: 10 additions & 0 deletions app/(app)/projects/[id]/stats/careers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { notFound } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { env } from "@/lib/env";
import type { ApplicationStatus, JobStatus, Workplace } from "@/lib/careers/jobs";
import { loadRepoOptions } from "@/lib/github/repo-options";
import { StatsSubnav } from "../stats-subnav";
import { CareersToggle } from "../careers-toggle";
import { CareersInstall } from "./careers-install";
import { JobManager, type JobRow } from "./job-manager";
import { Applications, type ApplicationRow } from "./applications";

Expand Down Expand Up @@ -91,6 +93,7 @@ export default async function CareersPage({
const trackerEnabled = Boolean(project.tracker_enabled);
const careersEnabled = Boolean(project.careers_enabled);
const hostedBoardUrl = `${env.siteUrl.replace(/\/+$/, "")}/c/${id}`;
const repoOptions = await loadRepoOptions(id);

return (
<div className="space-y-6">
Expand Down Expand Up @@ -151,6 +154,13 @@ export default async function CareersPage({
</p>
</div>
)}

<CareersInstall
projectId={id}
repos={repoOptions.repos}
configured={repoOptions.configured}
enabled={careersEnabled}
/>
</section>

<JobManager projectId={id} jobs={jobs} hostedBoardUrl={hostedBoardUrl} />
Expand Down
17 changes: 16 additions & 1 deletion app/api/careers/jobs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@
import { NextRequest, NextResponse } from "next/server";
import { serviceClient } from "@/lib/supabase/service";
import { env } from "@/lib/env";
import { hostedJobUrl, schemaEmploymentType, type PublicJob } from "@/lib/careers/jobs";
import {
hostedJobUrl,
jobPostingJsonLd,
schemaEmploymentType,
type PublicJob,
} from "@/lib/careers/jobs";

export const runtime = "nodejs";

Expand Down Expand Up @@ -62,6 +67,16 @@ export async function GET(request: NextRequest) {
// then have to keep in sync with ours.
employment_type_schema: schemaEmploymentType(job.employment_type),
canonical_url: hostedJobUrl(env.siteUrl, site, job.slug),
// Same reasoning, one level up: the SSR /careers page we generate for a
// customer's repo can't import our schema.org helpers, so it gets the
// finished graph rather than a second implementation to drift from.
json_ld: jobPostingJsonLd({
job,
siteUrl: env.siteUrl,
projectId: site,
projectName: project.name ?? "",
projectUrl: project.url ?? "",
}),
}));

return NextResponse.json(
Expand Down
Loading
Loading