fix(career): public CV deep links work on direct load, refresh, and shared links
Phase 4.5 (priority 4). The app is a React Router SPA behind Next static
export, which only generated `/` — so a hard load of any deep path (/cv/{slug},
/login, /career/builder/…) hit Next's client not-found before React Router
could route it. Replace the single app/page.tsx with an optional catch-all
app/[[...slug]] (server page + client shell so generateStaticParams stays
server-only) that matches every path; nginx already serves index.html for
unknown paths (try_files), so React Router now owns routing on direct load.
Also: PublicCvPage shows a friendly 404 empty state and sets the document
title. Verified live — /login and /cv/{slug} both resolve on direct navigation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
// The whole app is a client-side React Router SPA whose providers read window/localStorage during
|
||||||
|
// their initial render -- ssr:false keeps Next's static prerender from ever executing any of it on
|
||||||
|
// the server. Kept in its own client module so the route's page.tsx can stay a server component
|
||||||
|
// (generateStaticParams is a server-only export and cannot live in a "use client" file).
|
||||||
|
const ClientApp = dynamic(() => import("../../src/ClientApp"), { ssr: false });
|
||||||
|
|
||||||
|
export default function ClientShell() {
|
||||||
|
return <ClientApp />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import ClientShell from "./ClientShell";
|
||||||
|
|
||||||
|
// Optional catch-all so EVERY path (/, /cv/{slug}, /career/builder/…) resolves to this same shell on
|
||||||
|
// a hard load. Without it, `output: export` only generates `/`, and Next's client renders its
|
||||||
|
// not-found for any deep URL before React Router can route it — breaking refresh and shared links.
|
||||||
|
// generateStaticParams emits just index.html (the empty slug); nginx (try_files $uri /index.html)
|
||||||
|
// serves that shell for every unknown path, and this catch-all matches it client-side so React
|
||||||
|
// Router takes over. See docs/architecture/frontend.md (SPA routing).
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return [{ slug: [] }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <ClientShell />;
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
|
|
||||||
// The whole app is a client-side React Router SPA whose providers read window/localStorage
|
|
||||||
// during their initial render -- ssr:false keeps Next's static prerender from ever executing
|
|
||||||
// any of it on the server.
|
|
||||||
const ClientApp = dynamic(() => import("../src/ClientApp"), { ssr: false });
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <ClientApp />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { api, getApiErrorMessage } from "../api";
|
import { api } from "../api";
|
||||||
|
|
||||||
// Anonymous read-only public CV at /cv/:slug. Renders the server-produced HTML in a sandboxed
|
// Anonymous read-only public CV at /cv/:slug. Renders the server-produced HTML in a sandboxed
|
||||||
// iframe. noindex is enforced server-side (X-Robots-Tag) and reinforced with a meta tag here.
|
// iframe. noindex is enforced server-side (X-Robots-Tag) and reinforced with a meta tag here.
|
||||||
@@ -18,8 +18,14 @@ export default function PublicCvPage() {
|
|||||||
|
|
||||||
let alive = true;
|
let alive = true;
|
||||||
api.get<{ html: string; name: string }>(`/public-cv/${slug}`)
|
api.get<{ html: string; name: string }>(`/public-cv/${slug}`)
|
||||||
.then((r) => { if (alive) setHtml(r.data.html); })
|
.then((r) => { if (alive) { setHtml(r.data.html); if (r.data.name) document.title = `${r.data.name} — CV`; } })
|
||||||
.catch((err) => { if (alive) setError(getApiErrorMessage(err, "This CV is not available.")); });
|
.catch((err) => {
|
||||||
|
if (!alive) return;
|
||||||
|
// 404 = unknown slug or the owner made it private again. Anything else is a transient error.
|
||||||
|
setError(err?.response?.status === 404
|
||||||
|
? "This CV isn’t available. The link may be wrong, or the owner has made it private."
|
||||||
|
: "This CV can’t be loaded right now. Please try again in a moment.");
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user