diff --git a/app/student/search/[job_id]/layout.tsx b/app/student/search/[job_id]/layout.tsx index 07c8004c..515e3119 100644 --- a/app/student/search/[job_id]/layout.tsx +++ b/app/student/search/[job_id]/layout.tsx @@ -1,10 +1,5 @@ import type { Metadata } from "next"; - -interface JobPreview { - title?: string | null; - description?: string | null; - employer?: { name?: string | null } | null; -} +import { fetchJobPreview } from "@/lib/api/job-preview.server"; /** * job.description is Markdown (rendered via react-markdown — see @@ -29,11 +24,12 @@ const stripMarkdown = (text: string): string => * follow the short link's 307 and read these tags at the destination, so * short and long links preview identically. * - * Uses the same public, active-only GET /jobs/:id the client already calls - * (lib/api/services.ts JobService.getJobById) — the only anonymously - * fetchable single-job endpoint. A deactivated or unverified-employer job - * falls back to the parent layout's generic metadata rather than a broken - * or misleading preview; the short link itself still resolves regardless. + * A deactivated or unverified-employer job falls back to the parent + * layout's generic metadata rather than a broken or misleading preview; the + * short link itself still resolves regardless. The image is a per-job card + * (Docs/plans/JOB_OG_IMAGE_IMPLEMENTATION_PLAN.md) rendered by the sibling + * og/[job_id] route, which independently re-fetches the same preview and + * falls back to the static /og.png on its own failures. */ export async function generateMetadata({ params, @@ -42,36 +38,26 @@ export async function generateMetadata({ }): Promise { const { job_id } = await params; - try { - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/jobs/${job_id}`, - { - next: { revalidate: 300 }, - }, - ); - const data = (await res.json()) as { job?: JobPreview | null }; - const job = data?.job; - if (!job) return {}; + const job = await fetchJobPreview(job_id); + if (!job) return {}; - const title = `${job.title} at ${job.employer?.name ?? "BetterInternship"}`; - const description = job.description - ? stripMarkdown(job.description).slice(0, 160) - : undefined; + const title = `${job.title} at ${job.employer?.name ?? "BetterInternship"}`; + const description = job.description + ? stripMarkdown(job.description).slice(0, 160) + : undefined; + const image = `/search/og/${job_id}`; - return { + return { + title, + description, + openGraph: { title, description, images: [image], type: "website" }, + twitter: { + card: "summary_large_image", title, description, - openGraph: { title, description, images: ["/og.png"], type: "website" }, - twitter: { - card: "summary_large_image", - title, - description, - images: ["/og.png"], - }, - }; - } catch { - return {}; - } + images: [image], + }, + }; } export default function JobLayout({ children }: { children: React.ReactNode }) { diff --git a/app/student/search/og/[job_id]/route.tsx b/app/student/search/og/[job_id]/route.tsx new file mode 100644 index 00000000..2586d18f --- /dev/null +++ b/app/student/search/og/[job_id]/route.tsx @@ -0,0 +1,299 @@ +import fs from "node:fs"; +import path from "node:path"; +import { ImageResponse } from "next/og"; +import { fetchJobPreview, JobPreviewData } from "@/lib/api/job-preview.server"; +import { getRefsData } from "@/lib/db/use-refs-backend"; +import { createRefHelpers } from "@/lib/db/ref-lookup"; +import { JobAllowance, JobMode, JobPayFreq, JobType } from "@/lib/db/db.types"; + +export const contentType = "image/png"; + +export const size = { + width: 1200, + height: 630, +}; + +const betterInternshipLogo = `data:image/png;base64,${fs + .readFileSync(path.join(process.cwd(), "public/BetterInternshipLogo.png")) + .toString("base64")}`; +const backgroundImage = `data:image/png;base64,${fs + .readFileSync(path.join(process.cwd(), "public/bg.png")) + .toString("base64")}`; +const spaceGroteskMediumUrl = + "https://fonts.gstatic.com/s/spacegrotesk/v22/V8mQoQDjQSkFtoMM3T6r8E7mF71Q-gOoraIAEj7aUUsj.ttf"; +const spaceGroteskBoldUrl = + "https://fonts.gstatic.com/s/spacegrotesk/v22/V8mQoQDjQSkFtoMM3T6r8E7mF71Q-gOoraIAEj4PVksj.ttf"; + +/** + * Picks a font size from `steps` (ascending `maxLength`, first match wins), + * falling back below the smallest size once `text` outgrows every step. + * Satori can't measure rendered text width for us, so this approximates + * "does it fit" from character count instead — a short title/employer name + * gets a much bigger, denser treatment; a long one shrinks toward the size + * the fixed layout used before, where the 2-line clamp (title) or ellipsis + * (employer) still guarantees it never overflows the card. + */ +function fontSizeForLength( + length: number, + steps: { maxLength: number; size: number }[], + fallback: number, +): number { + return steps.find((step) => length <= step.maxLength)?.size ?? fallback; +} + +const TITLE_FONT_STEPS = [ + { maxLength: 16, size: 88 }, + { maxLength: 28, size: 74 }, + { maxLength: 42, size: 62 }, + { maxLength: 60, size: 52 }, +]; +const TITLE_FONT_FALLBACK = 44; + +const EMPLOYER_FONT_STEPS = [ + { maxLength: 14, size: 42 }, + { maxLength: 24, size: 36 }, + { maxLength: 36, size: 32 }, +]; +const EMPLOYER_FONT_FALLBACK = 28; + +/** + * Compensation tag text (D2): the exact figure when the job is salaried, + * the allowance-category label otherwise. Mirrors the allowance === 0 + * sentinel + pay-freq branching already established in JobDetailsSummary + * (components/shared/jobs.tsx). Returns null when nothing meaningful + * resolves, so the caller can drop the tag instead of showing a blank pill. + */ +function formatCompensation( + job: JobPreviewData, + jobAllowanceHelpers: ReturnType< + typeof createRefHelpers + >, + jobPayFreqHelpers: ReturnType>, +): string | null { + if (job.allowance === 0) { + const salaryNum = job.salary ? Number(job.salary) : null; + if (!salaryNum) return "With pay"; + + const freqName = jobPayFreqHelpers.toName(job.salary_freq); + const amount = `₱${salaryNum.toLocaleString("en-PH")}`; + return freqName !== "Not specified" ? `${amount}/${freqName}` : amount; + } + + return jobAllowanceHelpers.toName(job.allowance, null) || null; +} + +export async function GET( + request: Request, + context: { params: Promise<{ job_id: string }> }, +) { + const { job_id } = await context.params; + const fallback = () => + Response.redirect(new URL("/og.png", request.url).toString(), 307); + + try { + const [job, refs, spaceGroteskMedium, spaceGroteskBold] = await Promise.all( + [ + fetchJobPreview(job_id), + getRefsData(), + fetch(spaceGroteskMediumUrl).then((response) => response.arrayBuffer()), + fetch(spaceGroteskBoldUrl).then((response) => response.arrayBuffer()), + ], + ); + + // Not found, deleted, deactivated, or an unverified employer — same + // "hidden job" contract fetchJobPreview shares with generateMetadata + // (D5/D8). + if (!job) return fallback(); + + const jobModeHelpers = createRefHelpers(refs.job_modes); + const jobTypeHelpers = createRefHelpers(refs.job_types); + const jobAllowanceHelpers = createRefHelpers( + refs.job_allowances, + ); + const jobPayFreqHelpers = createRefHelpers( + refs.job_pay_freq, + ); + + const modeTags = (job.internship_preferences?.job_setup_ids ?? []) + .map((id) => jobModeHelpers.toName(id, null)) + .filter((name): name is string => !!name); + const typeTags = (job.internship_preferences?.job_commitment_ids ?? []) + .map((id) => jobTypeHelpers.toName(id, null)) + .filter((name): name is string => !!name); + const compensationTag = formatCompensation( + job, + jobAllowanceHelpers, + jobPayFreqHelpers, + ); + + const tags = [ + ...modeTags, + ...typeTags, + ...(compensationTag ? [compensationTag] : []), + ]; + const employerName = job.employer?.name ?? "BetterInternship"; + const titleFontSize = fontSizeForLength( + job.title?.length ?? 0, + TITLE_FONT_STEPS, + TITLE_FONT_FALLBACK, + ); + const employerFontSize = fontSizeForLength( + employerName.length, + EMPLOYER_FONT_STEPS, + EMPLOYER_FONT_FALLBACK, + ); + + return new ImageResponse( +
+ + +
+
+ BetterInternship logo + + BetterInternship + +
+ +
+ {job.title} +
+ +
+ {employerName} +
+ + {tags.length > 0 && ( +
+ {tags.map((tag, i) => ( +
+ {tag} +
+ ))} +
+ )} +
+
, + { + ...size, + headers: { + "Cache-Control": "public, max-age=86400", + }, + fonts: [ + { + name: "Space Grotesk", + data: spaceGroteskMedium, + weight: 500, + style: "normal", + }, + { + name: "Space Grotesk", + data: spaceGroteskBold, + weight: 700, + style: "normal", + }, + ], + }, + ); + } catch { + return fallback(); + } +} diff --git a/lib/api/job-preview.server.ts b/lib/api/job-preview.server.ts new file mode 100644 index 00000000..c51f0dee --- /dev/null +++ b/lib/api/job-preview.server.ts @@ -0,0 +1,43 @@ +import "server-only"; + +export interface JobPreviewData { + title?: string | null; + description?: string | null; + employer?: { name?: string | null } | null; + allowance?: number | null; + salary?: number | string | null; + salary_freq?: number | null; + internship_preferences?: { + job_setup_ids?: number[]; + job_commitment_ids?: number[]; + } | null; +} + +/** + * Fetches the public, active-only job preview shared by the job page's HTML + * metadata (generateMetadata) and its OG image route — both need the same + * "hidden jobs (deactivated / deleted / unverified employer) get no real + * preview" fallback, so it lives here once instead of twice + * (Docs/plans/JOB_OG_IMAGE_IMPLEMENTATION_PLAN.md D8). + * + * Uses the same public, anonymously-fetchable GET /jobs/:id the client + * already calls (lib/api/services.ts JobService.getJobById). Returns null on + * any failure — not-found, deactivated, unverified employer, or a network + * error — never throws. + */ +export async function fetchJobPreview( + jobId: string, +): Promise { + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/jobs/${jobId}`, + { + next: { revalidate: 300 }, + }, + ); + const data = (await res.json()) as { job?: JobPreviewData | null }; + return data?.job ?? null; + } catch { + return null; + } +} diff --git a/lib/db/ref-lookup.ts b/lib/db/ref-lookup.ts new file mode 100644 index 00000000..7cd8018c --- /dev/null +++ b/lib/db/ref-lookup.ts @@ -0,0 +1,41 @@ +/** + * Plain, non-hook ref-name lookups. Extracted out of use-refs.tsx (a + * "use client" module) so server-only callers — Route Handlers, Server + * Components — can resolve ref ids to names too, fed by getRefsData() + * (use-refs-backend.ts) instead of the RefsContext provider tree, which + * only exists inside the client boundary. + */ + +export const createRefHelpers = < + ID extends string | number, + T extends { id: ID; name: string }, +>( + data: T[], +) => { + const get = (id: ID | null | undefined): T | null => { + if (!id && id !== 0) return null; + const found = data.find((d) => d.id === id); + return found ?? null; + }; + + const toName = ( + id: ID | null | undefined, + def: string | null | undefined = "Not specified", + ): string => { + if (!id && id !== 0) return def ?? ""; + const found = data.find((d) => d.id === id); + return found?.name ?? def ?? ""; + }; + + const getByName = (name: string | null | undefined): T | null => { + if (!name) return null; + const found = data.find((d) => d.name === name); + return found ?? null; + }; + + return { + get, + toName, + getByName, + }; +}; diff --git a/lib/db/use-refs.tsx b/lib/db/use-refs.tsx index 6c777124..e3073d26 100644 --- a/lib/db/use-refs.tsx +++ b/lib/db/use-refs.tsx @@ -25,6 +25,7 @@ import { RefsData, IRefsContext, } from "./db.types"; +import { createRefHelpers } from "./ref-lookup"; // The context template const RefsContext = createContext({} as IRefsContext); @@ -55,40 +56,6 @@ export const useDbRefs = (): IRefsContext => { return useContext(RefsContext); }; -const createRefHelpers = < - ID extends string | number, - T extends { id: ID; name: string }, ->( - data: T[], -) => { - const get = (id: ID | null | undefined): T | null => { - if (!id && id !== 0) return null; - const found = data.find((d) => d.id === id); - return found ?? null; - }; - - const toName = ( - id: ID | null | undefined, - def: string | null | undefined = "Not specified", - ): string => { - if (!id && id !== 0) return def ?? ""; - const found = data.find((d) => d.id === id); - return found?.name ?? def ?? ""; - }; - - const getByName = (name: string | null | undefined): T | null => { - if (!name) return null; - const found = data.find((d) => d.name === name); - return found ?? null; - }; - - return { - get, - toName, - getByName, - }; -}; - const createRefsContext = (data: RefsData): IRefsContext => { const collegeHelpers = createRefHelpers(data.colleges); const universityHelpers = createRefHelpers(