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
60 changes: 23 additions & 37 deletions app/student/search/[job_id]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -42,36 +38,26 @@ export async function generateMetadata({
}): Promise<Metadata> {
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 }) {
Expand Down
299 changes: 299 additions & 0 deletions app/student/search/og/[job_id]/route.tsx
Original file line number Diff line number Diff line change
@@ -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<number, JobAllowance>
>,
jobPayFreqHelpers: ReturnType<typeof createRefHelpers<number, JobPayFreq>>,
): 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<number, JobMode>(refs.job_modes);
const jobTypeHelpers = createRefHelpers<number, JobType>(refs.job_types);
const jobAllowanceHelpers = createRefHelpers<number, JobAllowance>(
refs.job_allowances,
);
const jobPayFreqHelpers = createRefHelpers<number, JobPayFreq>(
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(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
position: "relative",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
color: "#061633",
fontFamily: "Space Grotesk",
}}
>
<img
src={backgroundImage}
alt=""
width={1200}
height={630}
style={{
position: "absolute",
left: 0,
top: 0,
width: 1200,
height: 630,
objectFit: "cover",
}}
/>

<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
color: "#061633",
gap: 24,
zIndex: 1,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
}}
>
<img
src={betterInternshipLogo}
alt="BetterInternship logo"
width={92}
height={92}
style={{
width: 44,
height: 44,
objectFit: "contain",
}}
/>
<span style={{ fontSize: 26, fontWeight: 700, letterSpacing: 0 }}>
BetterInternship
</span>
</div>

<div
style={{
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2,
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 980,
color: "#061633",
fontSize: titleFontSize,
fontWeight: 700,
lineHeight: 1.2,
letterSpacing: 0,
textAlign: "center",
}}
>
{job.title}
</div>

<div
style={{
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
maxWidth: 980,
color: "#345064",
fontSize: employerFontSize,
fontWeight: 500,
textAlign: "center",
}}
>
{employerName}
</div>

{tags.length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "center",
gap: 12,
maxWidth: 980,
}}
>
{tags.map((tag, i) => (
<div
key={`${tag}-${i}`}
style={{
display: "flex",
alignItems: "center",
borderRadius: 999,
backgroundColor: "rgba(6, 22, 51, 0.08)",
border: "2px solid rgba(6, 22, 51, 0.16)",
color: "#061633",
fontSize: 24,
fontWeight: 600,
padding: "10px 22px",
}}
>
{tag}
</div>
))}
</div>
)}
</div>
</div>,
{
...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();
}
}
Loading