From b4083e64566119a9cef8f2236e88297db9ae3efa Mon Sep 17 00:00:00 2001 From: Mo David Date: Sat, 8 Aug 2026 12:44:48 +0800 Subject: [PATCH] feat: add job short links (share dialog + /l/ resolver) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShareJobButton pre-mints the link before opening ShareJobModal, so the modal never shows an empty/loading flash — the button itself shows a loader while minting - ShareJobModal: connected link input + copy button, native-share ("Send…") merged into the modal's footer - wired into all student share entry points (search list, job modal, job detail page) plus a new one on the hire listing-details page - app/student/l/[slug]/route.ts resolves a slug and 307s to its destination - search/[job_id]/layout.tsx adds generateMetadata (OG/Twitter tags) so shared links unfurl as a real preview card in chat apps --- app/hire/welcome/page.tsx | 7 +- app/student/l/[slug]/route.ts | 27 +++++ app/student/search/[job_id]/layout.tsx | 79 +++++++++++++++ app/student/search/[job_id]/page.tsx | 6 +- app/student/search/page.tsx | 2 +- .../features/hire/listings/jobDetails.tsx | 37 ++++--- .../features/student/job/share-job-button.tsx | 66 ++++++++----- components/modals/JobModal.tsx | 4 +- .../modals/components/ShareJobModal.tsx | 99 +++++++++++++++++++ components/modals/modal-registry.tsx | 28 +++++- lib/api/services.ts | 11 ++- lib/api/student.data.api.ts | 46 ++++++++- 12 files changed, 354 insertions(+), 58 deletions(-) create mode 100644 app/student/l/[slug]/route.ts create mode 100644 app/student/search/[job_id]/layout.tsx create mode 100644 components/modals/components/ShareJobModal.tsx diff --git a/app/hire/welcome/page.tsx b/app/hire/welcome/page.tsx index 9f115fd7..5db6371e 100644 --- a/app/hire/welcome/page.tsx +++ b/app/hire/welcome/page.tsx @@ -60,9 +60,10 @@ function WelcomeContent() { const next = NEXT_WHITELIST.has(rawNext) ? rawNext : "dashboard"; // Set by the IOM "Post a listing" CTA when this account was provisioned // for an IOM company (Docs/plans/CAREER_IOM_LINK_IMPLEMENTATION_PLAN.md - // §4.2 follow-up) — the tin is only bound once onboarding actually - // finishes, never at account-creation time (see API-Server's - // internal.service.ts's ensureEmployer for why). + // §4.2 follow-up) — for a passwordless account the tin is only bound once + // onboarding actually finishes, never at account-creation time (see + // API-Server's internal.service.ts's ensureEmployer for why; accounts + // provisioned *with* a password are bound there and never land here). const autoLinkToken = searchParams.get("auto_link"); const router = useRouter(); const { refreshAuthentication } = useAuthContext(); diff --git a/app/student/l/[slug]/route.ts b/app/student/l/[slug]/route.ts new file mode 100644 index 00000000..2200267c --- /dev/null +++ b/app/student/l/[slug]/route.ts @@ -0,0 +1,27 @@ +/** + * Resolves a short link. Generic on purpose — it never mentions jobs, so + * porting it to another repo means changing the API base URL and nothing + * else (Docs/plans/JOB_SHORT_LINKS_IMPLEMENTATION_PLAN.md §5.1). + * + * Always a 307: an unknown slug redirects to the site root rather than + * erroring, and a temporary redirect means a mis-pointed slug can still be + * fixed later instead of staying cached in every browser that clicked it (D10). + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + const origin = new URL(request.url).origin; + + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/links/${slug}`, + { cache: "no-store" }, + ); + const data = (await res.json()) as { url?: string | null }; + return Response.redirect(data?.url ?? origin, 307); + } catch { + return Response.redirect(origin, 307); + } +} diff --git a/app/student/search/[job_id]/layout.tsx b/app/student/search/[job_id]/layout.tsx new file mode 100644 index 00000000..07c8004c --- /dev/null +++ b/app/student/search/[job_id]/layout.tsx @@ -0,0 +1,79 @@ +import type { Metadata } from "next"; + +interface JobPreview { + title?: string | null; + description?: string | null; + employer?: { name?: string | null } | null; +} + +/** + * job.description is Markdown (rendered via react-markdown — see + * MarkdownBlock in components/shared/jobs.tsx), not HTML, so a chat preview + * needs the syntax stripped or it shows raw `#`/`*`/`[]` markup. + */ +const stripMarkdown = (text: string): string => + text + .replace(/```[\s\S]*?```/g, " ") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/[#>*_~-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + +/** + * Server-side metadata for a job page — the crawler-visible reason a `/l/` + * short link (or a long /search/ link) unfurls as a real card instead + * of a generic BetterInternship tile + * (Docs/plans/JOB_SHORT_LINKS_IMPLEMENTATION_PLAN.md D11 + §5.5). Crawlers + * 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. + */ +export async function generateMetadata({ + params, +}: { + params: Promise<{ job_id: string }>; +}): 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 title = `${job.title} at ${job.employer?.name ?? "BetterInternship"}`; + const description = job.description + ? stripMarkdown(job.description).slice(0, 160) + : undefined; + + return { + title, + description, + openGraph: { title, description, images: ["/og.png"], type: "website" }, + twitter: { + card: "summary_large_image", + title, + description, + images: ["/og.png"], + }, + }; + } catch { + return {}; + } +} + +export default function JobLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/app/student/search/[job_id]/page.tsx b/app/student/search/[job_id]/page.tsx index 7e6b21a8..2e45f200 100644 --- a/app/student/search/[job_id]/page.tsx +++ b/app/student/search/[job_id]/page.tsx @@ -133,7 +133,7 @@ export default function JobPage() { {job.data && !job.data.hibernating && (
- + {job.data?.id && ( setIsActionsSheetOpen(false)} + onOpen={() => setIsActionsSheetOpen(false)} /> )}
diff --git a/app/student/search/page.tsx b/app/student/search/page.tsx index a5955e54..05a22194 100644 --- a/app/student/search/page.tsx +++ b/app/student/search/page.tsx @@ -567,7 +567,7 @@ export default function SearchPage() { }} job={selectedJob} actions={[ - , + , , { +const JobDetailsPage = ({ job }: JobDetailsPageProps) => { const router = useRouter(); const { isMobile } = useAppContext(); const [exitingBack, setExitingBack] = useState(false); const handleBack = () => { setExitingBack(true); - router.push(`/dashboard/manage?jobId=${job.id}`) + router.push(`/dashboard/manage?jobId=${job.id}`); }; return ( - - + {/* Employers share to candidates too — same dialog, same endpoint, + still a student-domain link (Docs/plans/JOB_SHORT_LINKS_IMPLEMENTATION_PLAN.md D14). */} + ]} + /> - ) -} + ); +}; -export default JobDetailsPage; \ No newline at end of file +export default JobDetailsPage; diff --git a/components/features/student/job/share-job-button.tsx b/components/features/student/job/share-job-button.tsx index 8746c573..e74c74df 100644 --- a/components/features/student/job/share-job-button.tsx +++ b/components/features/student/job/share-job-button.tsx @@ -1,48 +1,60 @@ "use client"; +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { Copy, CopyCheck } from "lucide-react"; -import { useEffect, useState } from "react"; +import { Loader2, Share2 } from "lucide-react"; +import { Job } from "@/lib/db/db.types"; +import { shareLinkQueryOptions } from "@/lib/api/student.data.api"; +import useModalRegistry from "@/components/modals/modal-registry"; export const ShareJobButton = ({ - id, + job, className, - onCopied, + onOpen, }: { - id: string; + job: Job; className?: string; - onCopied?: () => void; + // Called right before the dialog opens — e.g. to dismiss a mobile actions + // sheet the button sits in, the way onCopied used to. Fires *after* the + // mint settles (not on click) so the sheet — and this button's loader — + // stays visible for the whole wait instead of closing immediately. + onOpen?: () => void; }) => { - const [clicked, setClicked] = useState(false); + const modals = useModalRegistry(); + const queryClient = useQueryClient(); + const [minting, setMinting] = useState(false); - const copyJobLink = () => { - void navigator.clipboard.writeText( - `${process.env.NEXT_PUBLIC_CLIENT_URL}/search/${id}`, - ); - setClicked(true); - onCopied?.(); - setTimeout(() => setClicked(false), 1500); + const handleClick = async () => { + if (minting || !job.id) return; + setMinting(true); + try { + // Pre-mint so the modal opens with the link already in hand — no + // in-modal loading flash. A `success: false` result (no thrown error) + // still gets cached; the modal's own useShareLink() reads it and shows + // the error + retry state (D13), so failures aren't handled here. + await queryClient.fetchQuery(shareLinkQueryOptions(job.id)); + } catch { + // Network-level failure — same deal, let the modal surface it. + } finally { + setMinting(false); + } + onOpen?.(); + modals.shareJob.open({ job }); }; - useEffect(() => { - setClicked(false); - }, [id]); - return ( ); }; diff --git a/components/modals/JobModal.tsx b/components/modals/JobModal.tsx index e4e6aa2c..ebd200eb 100644 --- a/components/modals/JobModal.tsx +++ b/components/modals/JobModal.tsx @@ -183,9 +183,9 @@ export const JobModal = ({ {job.id && ( setIsActionsSheetOpen(false)} + onOpen={() => setIsActionsSheetOpen(false)} /> )} diff --git a/components/modals/components/ShareJobModal.tsx b/components/modals/components/ShareJobModal.tsx new file mode 100644 index 00000000..76ff5744 --- /dev/null +++ b/components/modals/components/ShareJobModal.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useState } from "react"; +import { Check, Copy, Loader2, Send } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useShareLink } from "@/lib/api/student.data.api"; +import { Job } from "@/lib/db/db.types"; + +interface ShareJobModalProps { + job: Job; +} + +/** + * Mint fires on mount (via useShareLink) and the URL only ever appears once + * it has one — a job never falls back to the long URL on failure + * (Docs/plans/JOB_SHORT_LINKS_IMPLEMENTATION_PLAN.md D13). The copy handler + * stays synchronous so it works on Safari too (D12): by the time it's + * clicked, the string is already sitting in `url`. + */ +export function ShareJobModal({ job }: ShareJobModalProps) { + const { url, isPending, isError, refetch } = useShareLink(job.id); + const [copied, setCopied] = useState(false); + + const canShare = typeof navigator !== "undefined" && "share" in navigator; + + const handleCopy = () => { + if (!url) return; + void navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + const handleShare = () => { + if (!url) return; + void navigator.share({ title: job.title ?? undefined, url }); + }; + + if (isError) { + return ( +
+

+ Couldn't generate a link. +

+ +
+ ); + } + + return ( +
+
+ e.target.select()} + className="rounded-r-none border-r-0 text-gray-700" + /> + +
+ + {/* Breaks out of the modal's own px-4/pb-4 to read as a footer bar + flush with the panel's bottom (and, on desktop, its bottom + corners) rather than a button floating in the content area. */} + {canShare && ( + + )} +
+ ); +} diff --git a/components/modals/modal-registry.tsx b/components/modals/modal-registry.tsx index eb3e9e13..aa9e3dc2 100644 --- a/components/modals/modal-registry.tsx +++ b/components/modals/modal-registry.tsx @@ -1,5 +1,5 @@ import { useGlobalModal } from "../providers/modal-provider/ModalProvider"; -import { BellOff, FileUp, LucideIcon, Trash2 } from "lucide-react"; +import { BellOff, FileUp, LucideIcon, Share2, Trash2 } from "lucide-react"; import { FormSubmissionSuccessModal } from "./components/FormSubmissionSuccessModal"; import { FollowUpFormModal } from "./components/ResendFormModal"; import { CancelFormModal } from "./components/CancelFormModal"; @@ -31,6 +31,7 @@ import { AddResumeModal } from "../features/student/profile/AddResumeModal"; import { HeaderIcon } from "../ui/text"; import { DigestOptoutModalContent } from "../features/hire/account/digest-optout-dialog"; import type { EligibleListing } from "@/lib/api/services"; +import { ShareJobModal } from "./components/ShareJobModal"; const modalTitleWithIcon = (Icon: LucideIcon, title: string) => (
@@ -135,6 +136,31 @@ export const useModalRegistry = () => { ), close: () => close("delete-listing"), }, + // modal for sharing a job listing's short link + // (Docs/plans/JOB_SHORT_LINKS_IMPLEMENTATION_PLAN.md D12). + shareJob: { + open: ({ job }: { job: Job }) => + open("share-job", DefaultModalLayout, , { + title: ( +
+ +

+ Share listing + {job.title && ( + + {" "} + — {job.title} + + )} +

+
+ ), + closeOnBackdropClick: true, + closeOnEscapeKey: true, + showHeaderDivider: true, + }), + close: () => close("share-job"), + }, // modal for any action performed on a job application. applicationAction: { open: ({ diff --git a/lib/api/services.ts b/lib/api/services.ts index 4787e426..26f13d1a 100644 --- a/lib/api/services.ts +++ b/lib/api/services.ts @@ -237,7 +237,6 @@ export const EmployerUserService = { { receives_applicant_digest }, ); }, - }; // Auth Services @@ -668,6 +667,10 @@ interface DeactivateBulkResponse extends FetchResponse { job_ids: string[]; } +export interface ShareLinkResponse extends FetchResponse { + url?: string; +} + export const JobService = { async getAllJobs() { return APIClient.get(APIRouteBuilder("jobs").build()); @@ -784,6 +787,12 @@ export const JobService = { APIRouteBuilder("jobs").r("waitlisted").build(), ); }, + + async mintShareLink(jobId: string) { + return APIClient.post( + APIRouteBuilder("jobs").r(jobId, "share-link").build(), + ); + }, }; interface ConversationResponse extends FetchResponse { diff --git a/lib/api/student.data.api.ts b/lib/api/student.data.api.ts index 67121bd9..5f6e5452 100644 --- a/lib/api/student.data.api.ts +++ b/lib/api/student.data.api.ts @@ -119,10 +119,7 @@ export function useJobStatus() { * link that resolves via the current listing page first, and only falls * back to this fetch-by-id when the job isn't on that page). */ -export function useJobData( - jobId: string, - options: { enabled?: boolean } = {}, -) { +export function useJobData(jobId: string, options: { enabled?: boolean } = {}) { const applications = useApplicationsData(); const applied = !!useMemo( () => applications.data.find((application) => application.job_id === jobId), @@ -137,6 +134,47 @@ export function useJobData( return { isPending, data: data?.job ?? null, applied, error }; } +/** + * Shared queryKey/queryFn for a job's share link, so ShareJobButton can + * pre-mint via queryClient.fetchQuery *before* opening the modal — the + * button owns the wait (loader on the button itself, not a flash of empty + * state inside the modal) — and useShareLink() below then reads the same, + * already-populated cache entry instantly once the modal mounts. + */ +export function shareLinkQueryOptions(jobId: string) { + return { + queryKey: ["share-link", jobId], + queryFn: () => JobService.mintShareLink(jobId), + staleTime: Infinity, + }; +} + +/** + * Mints (or reuses) a job's short share link. Cached per job id at + * staleTime: Infinity so reopening the share dialog for the same job never + * re-POSTs (Docs/plans/JOB_SHORT_LINKS_IMPLEMENTATION_PLAN.md defaults). + * + * A `success: false` mint (no thrown error, just no `url`) is folded into + * `isError` here rather than left to React Query's own retry-on-throw path, + * so a real failure surfaces immediately instead of after a few silent retries. + * + * @hook + * @param jobId + */ +export function useShareLink(jobId: string | undefined) { + const { data, isPending, isFetching, isError, refetch } = useQuery({ + ...shareLinkQueryOptions(jobId as string), + enabled: !!jobId, + }); + + return { + url: data?.url, + isPending: isPending || isFetching, + isError: isError || (!!data && !data.url), + refetch, + }; +} + /** * Requests profile information and allows profile updates. *