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
7 changes: 4 additions & 3 deletions app/hire/welcome/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
27 changes: 27 additions & 0 deletions app/student/l/[slug]/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
79 changes: 79 additions & 0 deletions app/student/search/[job_id]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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/<uuid> 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<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 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;
}
6 changes: 3 additions & 3 deletions app/student/search/[job_id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export default function JobPage() {
</Button>
{job.data && !job.data.hibernating && (
<div className="flex flex-wrap items-center gap-3">
<ShareJobButton id={job.data.id ?? ""} />
<ShareJobButton job={job.data} />
<SaveJobButton job={job.data} />
<ApplyToJobButton
profile={profile.data}
Expand Down Expand Up @@ -215,9 +215,9 @@ export default function JobPage() {
</div>
{job.data?.id && (
<ShareJobButton
id={job.data.id}
job={job.data}
className="w-full justify-start"
onCopied={() => setIsActionsSheetOpen(false)}
onOpen={() => setIsActionsSheetOpen(false)}
/>
)}
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/student/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ export default function SearchPage() {
}}
job={selectedJob}
actions={[
<ShareJobButton id={selectedJob?.id} />,
<ShareJobButton job={selectedJob} />,
<SaveJobButton job={selectedJob} />,
<ApplyToJobButton
profile={profile.data}
Expand Down
37 changes: 21 additions & 16 deletions components/features/hire/listings/jobDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"use client"
"use client";

import { JobDetails } from "@/components/shared/jobs";
import { Card } from "@/components/ui/card";
Expand All @@ -9,40 +9,45 @@ import { ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
import { ShareJobButton } from "@/components/features/student/job/share-job-button";

interface JobDetailsPageProps {
job: Job;
};
}

const JobDetailsPage = ({
job,
}: JobDetailsPageProps) => {
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 (
<AnimatePresence>
<motion.div
<motion.div
initial={{ scale: 0.98, filter: "blur(4px)", opacity: 0 }}
animate={exitingBack ? { scale: 0.98, filter: "blur(4px)", opacity: 0 } : { scale: 1, filter: "blur(0px)", opacity: 1 }}
animate={
exitingBack
? { scale: 0.98, filter: "blur(4px)", opacity: 0 }
: { scale: 1, filter: "blur(0px)", opacity: 1 }
}
transition={{ duration: 0.3, ease: "easeOut" }}
className={cn(
"py-2",
isMobile ? "px-1" : ""
)}
className={cn("py-2", isMobile ? "px-1" : "")}
>
<Card>
<JobDetails job={job} />
{/* Employers share to candidates too — same dialog, same endpoint,
still a student-domain link (Docs/plans/JOB_SHORT_LINKS_IMPLEMENTATION_PLAN.md D14). */}
<JobDetails
job={job}
actions={[<ShareJobButton key="share" job={job} />]}
/>
</Card>
</motion.div>
</AnimatePresence>
)
}
);
};

export default JobDetailsPage;
export default JobDetailsPage;
66 changes: 39 additions & 27 deletions components/features/student/job/share-job-button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
variant="outline"
onClick={copyJobLink}
name="Copy link"
onClick={() => void handleClick()}
disabled={minting}
name="Share"
scheme="default"
size="md"
className={cn(
"!p-4",
clicked ? "text-supportive border-supportive" : "",
className,
)}
className={cn("!p-4", className)}
>
{clicked ? <CopyCheck /> : <Copy />}
{clicked ? "Copied link" : "Copy link"}
{minting ? <Loader2 className="animate-spin" /> : <Share2 />}
Share
</Button>
);
};
4 changes: 2 additions & 2 deletions components/modals/JobModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,9 @@ export const JobModal = ({
</div>
{job.id && (
<ShareJobButton
id={job.id}
job={job}
className="w-full justify-start"
onCopied={() => setIsActionsSheetOpen(false)}
onOpen={() => setIsActionsSheetOpen(false)}
/>
)}
</div>
Expand Down
Loading