@zorgo/next 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +19 -0
  2. package/dist/cli.d.mts +5 -0
  3. package/dist/cli.d.ts +5 -0
  4. package/dist/cli.js +2 -0
  5. package/dist/cli.mjs +2 -0
  6. package/dist/components.d.mts +337 -0
  7. package/dist/components.d.ts +337 -0
  8. package/dist/components.js +1996 -0
  9. package/dist/components.js.map +1 -0
  10. package/dist/components.mjs +1962 -0
  11. package/dist/components.mjs.map +1 -0
  12. package/dist/index.d.mts +305 -0
  13. package/dist/index.d.ts +305 -0
  14. package/dist/index.js +2 -0
  15. package/dist/index.mjs +2 -0
  16. package/dist/seo.d.mts +30 -0
  17. package/dist/seo.d.ts +30 -0
  18. package/dist/seo.js +82 -0
  19. package/dist/seo.js.map +1 -0
  20. package/dist/seo.mjs +56 -0
  21. package/dist/seo.mjs.map +1 -0
  22. package/dist/slugs.d.mts +1 -0
  23. package/dist/slugs.d.ts +1 -0
  24. package/dist/slugs.js +47 -0
  25. package/dist/slugs.js.map +1 -0
  26. package/dist/slugs.mjs +19 -0
  27. package/dist/slugs.mjs.map +1 -0
  28. package/dist/tree/.env.gen.ts +27 -0
  29. package/dist/tree/README.md +0 -0
  30. package/dist/tree/app/api/zorgo-token/route.ts +11 -0
  31. package/dist/tree/app/apple-icon.tsx.gen.ts +54 -0
  32. package/dist/tree/app/checkout/page.tsx +10 -0
  33. package/dist/tree/app/components/Footer.tsx +50 -0
  34. package/dist/tree/app/components/Header.tsx +128 -0
  35. package/dist/tree/app/components/ModalHost.tsx +56 -0
  36. package/dist/tree/app/components/ModalRegistry.tsx +113 -0
  37. package/dist/tree/app/components/TestCard.tsx +35 -0
  38. package/dist/tree/app/components/index.ts +3 -0
  39. package/dist/tree/app/error.tsx +10 -0
  40. package/dist/tree/app/global-error.tsx +12 -0
  41. package/dist/tree/app/globals.css.gen.ts +123 -0
  42. package/dist/tree/app/icon.tsx.gen.ts +87 -0
  43. package/dist/tree/app/layout.tsx.gen.ts +55 -0
  44. package/dist/tree/app/locations/page.tsx +30 -0
  45. package/dist/tree/app/manifest.ts.gen.ts +39 -0
  46. package/dist/tree/app/menu/[slug]/ItemCustomizationClient.tsx +18 -0
  47. package/dist/tree/app/menu/[slug]/page.tsx +78 -0
  48. package/dist/tree/app/menu/page.tsx +36 -0
  49. package/dist/tree/app/not-found.tsx +8 -0
  50. package/dist/tree/app/opengraph-image.tsx.gen.ts +82 -0
  51. package/dist/tree/app/page.tsx +19 -0
  52. package/dist/tree/app/privacy/page.tsx.gen.ts +15 -0
  53. package/dist/tree/app/robots.ts +9 -0
  54. package/dist/tree/app/sitemap.ts +26 -0
  55. package/dist/tree/app/terms/page.tsx.gen.ts +16 -0
  56. package/dist/tree/gitignore +44 -0
  57. package/dist/tree/lib/zorgoSiteToken.ts +20 -0
  58. package/dist/tree/next.config.ts +14 -0
  59. package/dist/tree/open-next.config.ts +4 -0
  60. package/dist/tree/package.json.gen.ts +24 -0
  61. package/dist/tree/postcss.config.mjs +7 -0
  62. package/dist/tree/scripts/prebuild.ts +448 -0
  63. package/dist/tree/tsconfig.json +35 -0
  64. package/package.json +72 -0
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useModal } from "@zorgo/next";
5
+ import { MODAL_COMPONENTS } from "./ModalRegistry";
6
+
7
+ // This is your modal shell — style it however you like (it uses the generated
8
+ // brand tokens by default). It renders whatever modal is on top of the stack,
9
+ // looking the component up in ./ModalRegistry. Open modals from anywhere with
10
+ // `useModal().open("YOUR_MODAL", props)`.
11
+ export default function ModalHost() {
12
+ const { current, isOpen, close } = useModal();
13
+
14
+ // enter transition: mount at 0, flip to 1 on the next frame
15
+ const [shown, setShown] = useState(false);
16
+ useEffect(() => {
17
+ if (!isOpen) return setShown(false);
18
+ const id = requestAnimationFrame(() => setShown(true));
19
+ return () => cancelAnimationFrame(id);
20
+ }, [isOpen]);
21
+
22
+ // Escape closes
23
+ useEffect(() => {
24
+ if (!isOpen) return;
25
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
26
+ window.addEventListener("keydown", onKey);
27
+ return () => window.removeEventListener("keydown", onKey);
28
+ }, [isOpen, close]);
29
+
30
+ if (!isOpen || !current) return null;
31
+
32
+ const Modal = MODAL_COMPONENTS[current.type];
33
+ if (!Modal) {
34
+ console.warn(`[ModalHost] no component registered for "${current.type}"`);
35
+ return null;
36
+ }
37
+
38
+ return (
39
+ <div
40
+ className={`fixed inset-0 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ${
41
+ shown ? "opacity-100" : "opacity-0"
42
+ }`}
43
+ >
44
+ <div className="absolute inset-0 bg-black/50" onClick={close} />
45
+ <div
46
+ className={`relative max-h-[90vh] w-full max-w-2xl overflow-auto rounded-2xl border-2 border-foreground/10 bg-background-elevated p-6 text-foreground shadow-lg transition-transform duration-200 ${
47
+ shown ? "scale-100" : "scale-95"
48
+ }`}
49
+ >
50
+ <Modal {...(current.props as object)} />
51
+ </div>
52
+ </div>
53
+ );
54
+ }
55
+ // ponytail: no leave animation — unmount is instant. Add an exit state if the
56
+ // snap-close bothers you.
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ import { useLocation, useModal } from "@zorgo/next";
4
+ import { FindLocation, ItemCustomizationForm } from "@zorgo/next/components";
5
+ import type { Location, OrderItem } from "@zorgo/universal/interfaces";
6
+
7
+ // Every modal your app can open lives in this map, keyed by the string you pass
8
+ // to `useModal().open(type, props)`. Add a component, give it a key, done.
9
+ export const MODAL_COMPONENTS: Record<string, React.ComponentType<any>> = {
10
+ EXAMPLE_MODAL: ExampleModal,
11
+ CONFIRM_MODAL: ConfirmModal,
12
+ FIND_LOCATION_MODAL: FindLocationModal,
13
+ EDIT_ITEM_MODAL: EditItemModal,
14
+ };
15
+
16
+ // --- example modals (delete these once you have your own) ---------------------
17
+
18
+ // Open with: useModal().open("EXAMPLE_MODAL", { title: "Hi" })
19
+ function ExampleModal({ title }: { title?: string }) {
20
+ const { close } = useModal();
21
+ return (
22
+ <div className="flex flex-col gap-4">
23
+ <h2 className="text-xl font-semibold">
24
+ {title ?? "Example modal"}
25
+ </h2>
26
+ <p className="opacity-75">
27
+ This modal came from ModalRegistry and is rendered by ModalHost.
28
+ </p>
29
+ <button
30
+ onClick={close}
31
+ className="self-end rounded-lg bg-primary px-4 p-2 text-primary-foreground"
32
+ >
33
+ Got it
34
+ </button>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ // Open with: useModal().open("CONFIRM_MODAL", { message, onConfirm })
40
+ function ConfirmModal({
41
+ message,
42
+ onConfirm,
43
+ }: {
44
+ message: string;
45
+ onConfirm: () => void;
46
+ }) {
47
+ const { close } = useModal();
48
+ return (
49
+ <div className="flex flex-col gap-4">
50
+ <p>{message}</p>
51
+ <div className="flex justify-end gap-2">
52
+ <button
53
+ onClick={close}
54
+ className="rounded-lg bg-foreground/10 px-4 p-2"
55
+ >
56
+ Cancel
57
+ </button>
58
+ <button
59
+ onClick={() => {
60
+ onConfirm();
61
+ close();
62
+ }}
63
+ className="rounded-lg bg-primary px-4 p-2 text-primary-foreground"
64
+ >
65
+ Confirm
66
+ </button>
67
+ </div>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ // Opened from the cart's pencil button to re-customize an item in place.
73
+ function EditItemModal({
74
+ baseItemId,
75
+ seed,
76
+ editIndex,
77
+ }: {
78
+ baseItemId: number;
79
+ seed: OrderItem;
80
+ editIndex: number;
81
+ }) {
82
+ const { close } = useModal();
83
+ return (
84
+ <ItemCustomizationForm
85
+ baseItemId={baseItemId}
86
+ seed={seed}
87
+ editIndex={editIndex}
88
+ onSubmitSuccess={close}
89
+ />
90
+ );
91
+ }
92
+
93
+ // Open with: useModal().open("FIND_LOCATION_MODAL")
94
+ function FindLocationModal() {
95
+ const { close } = useModal();
96
+ const { availableLocations, currentLocation, setLocation } = useLocation();
97
+
98
+ function handleSelect(location: Location) {
99
+ setLocation(location);
100
+ close();
101
+ }
102
+
103
+ return (
104
+ <div className="flex flex-col gap-4">
105
+ <h2 className="text-xl font-semibold">Select a location</h2>
106
+ <FindLocation
107
+ locations={availableLocations}
108
+ initialSelected={currentLocation}
109
+ onSelect={handleSelect}
110
+ />
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,35 @@
1
+ export default function TestCard() {
2
+ return (
3
+ <div className="w-[400px] p-4 m-4 flex flex-col gap-8 rounded-lg border-foreground/[10%] border-[2px] bg-background-elevated shadow-sm">
4
+ <div className="flex flex-col gap-2">
5
+ <h1 className="text-xl font-semibold">
6
+ This is a test component
7
+ </h1>
8
+ <p className="opacity-75">
9
+ This component uses generated tailwind colors! Lorem ipsum
10
+ dolor sit amet consectetur adipisicing elit. Quisquam, quos.
11
+ </p>
12
+ </div>
13
+
14
+ <div className="bg-background rounded-lg p-4">
15
+ <p className="opacity-75">
16
+ Lorem ipsum dolor sit amet consectetur adipisicing elit.
17
+ Quisquam, quos.
18
+ </p>
19
+ </div>
20
+
21
+ <div className="flex flex-row gap-2 justify-end">
22
+ <button className="bg-foreground text-background rounded-lg p-2 px-4">
23
+ I'm a button
24
+ </button>
25
+ <button className="bg-primary text-primary-foreground rounded-lg p-2 px-4">
26
+ So am I
27
+ </button>
28
+
29
+ <button className="bg-accent text-accent-foreground rounded-lg p-2 px-4">
30
+ Me too
31
+ </button>
32
+ </div>
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,3 @@
1
+ export { default as Header } from "./Header";
2
+ export { default as Footer } from "./Footer";
3
+ export { default as ModalHost } from "./ModalHost";
@@ -0,0 +1,10 @@
1
+ "use client";
2
+
3
+ export default function Error({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
4
+ return (
5
+ <div>
6
+ <h2>Something went wrong</h2>
7
+ <button onClick={() => reset()}>Try again</button>
8
+ </div>
9
+ );
10
+ }
@@ -0,0 +1,12 @@
1
+ "use client";
2
+
3
+ export default function GlobalError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
4
+ return (
5
+ <html>
6
+ <body>
7
+ <h2>Something went wrong</h2>
8
+ <button onClick={() => reset()}>Try again</button>
9
+ </body>
10
+ </html>
11
+ );
12
+ }
@@ -0,0 +1,123 @@
1
+ import type { ScaffoldOptions } from "../../../types";
2
+
3
+ /** Keep in sync with scripts/prebuild.ts HARDCODED_BRANDING until DB branding lands. */
4
+ const HARDCODED_BRANDING = {
5
+ backgroundBase: "#F5F5F5",
6
+ primary: "#3264FF",
7
+ accent: "#9664FF",
8
+ fontFamily: "Inter",
9
+ };
10
+
11
+ const FONT_ON_LIGHT = "#0a0a0a";
12
+ const FONT_ON_DARK = "#f5f5f5";
13
+ /** Channel delta toward white (elevated) / black (recessed). */
14
+ const BACKGROUND_SURFACE_DELTA = 15;
15
+
16
+ function parseHexRgb(
17
+ background: string,
18
+ ): { r: number; g: number; b: number } | null {
19
+ const hex = background.trim().replace(/^#/, "");
20
+ const full =
21
+ hex.length === 3
22
+ ? hex
23
+ .split("")
24
+ .map((c) => c + c)
25
+ .join("")
26
+ : hex;
27
+ if (full.length !== 6 || Number.isNaN(Number.parseInt(full, 16))) {
28
+ return null;
29
+ }
30
+ return {
31
+ r: Number.parseInt(full.slice(0, 2), 16),
32
+ g: Number.parseInt(full.slice(2, 4), 16),
33
+ b: Number.parseInt(full.slice(4, 6), 16),
34
+ };
35
+ }
36
+
37
+ function toHex({ r, g, b }: { r: number; g: number; b: number }): string {
38
+ return `#${[r, g, b].map((c) => c.toString(16).padStart(2, "0")).join("")}`;
39
+ }
40
+
41
+ function deriveFontColor(background: string): string {
42
+ const rgb = parseHexRgb(background);
43
+ if (!rgb) return FONT_ON_LIGHT;
44
+ const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
45
+ return luminance > 0.5 ? FONT_ON_LIGHT : FONT_ON_DARK;
46
+ }
47
+
48
+ function deriveElevatedBackground(background: string): string {
49
+ const rgb = parseHexRgb(background);
50
+ if (!rgb) return background;
51
+ return toHex({
52
+ r: Math.min(255, rgb.r + BACKGROUND_SURFACE_DELTA),
53
+ g: Math.min(255, rgb.g + BACKGROUND_SURFACE_DELTA),
54
+ b: Math.min(255, rgb.b + BACKGROUND_SURFACE_DELTA),
55
+ });
56
+ }
57
+
58
+ function deriveRecessedBackground(background: string): string {
59
+ const rgb = parseHexRgb(background);
60
+ if (!rgb) return background;
61
+ return toHex({
62
+ r: Math.max(0, rgb.r - BACKGROUND_SURFACE_DELTA),
63
+ g: Math.max(0, rgb.g - BACKGROUND_SURFACE_DELTA),
64
+ b: Math.max(0, rgb.b - BACKGROUND_SURFACE_DELTA),
65
+ });
66
+ }
67
+
68
+ export default function generateGlobalsCss(_opts: ScaffoldOptions): string {
69
+ const branding = HARDCODED_BRANDING;
70
+ const fontFamilyParam = branding.fontFamily.replace(/ /g, "+");
71
+ const foreground = deriveFontColor(branding.backgroundBase);
72
+ const primaryForeground = deriveFontColor(branding.primary);
73
+ const accentForeground = deriveFontColor(branding.accent);
74
+ const backgroundElevated = deriveElevatedBackground(branding.backgroundBase);
75
+ const backgroundRecessed = deriveRecessedBackground(branding.backgroundBase);
76
+
77
+ return `@import url("https://fonts.googleapis.com/css2?family=${fontFamilyParam}:wght@400;700&display=swap");
78
+ @import "tailwindcss";
79
+ /* Tailwind v4 skips node_modules; @zorgo/next ships un-obfuscated components so
80
+ their class names are scannable here. */
81
+ @source "../node_modules/@zorgo/next/dist/components.mjs";
82
+
83
+ :root {
84
+ --background: ${branding.backgroundBase};
85
+ --background-elevated: ${backgroundElevated};
86
+ --background-recessed: ${backgroundRecessed};
87
+ --foreground: ${foreground};
88
+ --primary: ${branding.primary};
89
+ --primary-foreground: ${primaryForeground};
90
+ --accent: ${branding.accent};
91
+ --accent-foreground: ${accentForeground};
92
+ --font-sans: "${branding.fontFamily}", sans-serif;
93
+ }
94
+
95
+ @theme inline {
96
+ --color-background: var(--background);
97
+ --color-background-elevated: var(--background-elevated);
98
+ --color-background-recessed: var(--background-recessed);
99
+ --color-foreground: var(--foreground);
100
+ --color-primary: var(--primary);
101
+ --color-primary-foreground: var(--primary-foreground);
102
+ --color-accent: var(--accent);
103
+ --color-accent-foreground: var(--accent-foreground);
104
+ --font-sans: var(--font-sans);
105
+ }
106
+
107
+ @layer base {
108
+ button,
109
+ [type="button"],
110
+ [type="submit"],
111
+ [type="reset"] {
112
+ cursor: pointer;
113
+ }
114
+
115
+ button:disabled,
116
+ [type="button"]:disabled,
117
+ [type="submit"]:disabled,
118
+ [type="reset"]:disabled {
119
+ cursor: not-allowed;
120
+ }
121
+ }
122
+ `;
123
+ }
@@ -0,0 +1,87 @@
1
+ import type { ScaffoldOptions } from "../../../types";
2
+
3
+ export default function generateIcon(opts: ScaffoldOptions): string {
4
+ const letter = opts.projectName.charAt(0).toUpperCase();
5
+
6
+ return `import { ImageResponse } from "next/og";
7
+ import { readFileSync } from "fs";
8
+ import { join } from "path";
9
+
10
+ // One PNG per size: 32 for the browser tab/favicon, 192 & 512 for the web
11
+ // manifest (PWA install). generateImageMetadata gives each a stable /icon/<id> URL
12
+ // the manifest references, and all three are prerendered at build.
13
+ export function generateImageMetadata() {
14
+ return [
15
+ { id: "32", size: { width: 32, height: 32 }, contentType: "image/png" },
16
+ { id: "192", size: { width: 192, height: 192 }, contentType: "image/png" },
17
+ { id: "512", size: { width: 512, height: 512 }, contentType: "image/png" },
18
+ ];
19
+ }
20
+
21
+ // Brand palette prebuild drops at public/styles/styles.json. Neutral fallback if
22
+ // it's missing so the icon still renders. primary is the badge fill.
23
+ function loadTheme() {
24
+ try {
25
+ return JSON.parse(
26
+ readFileSync(join(process.cwd(), "public/styles/styles.json"), "utf8"),
27
+ );
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ // Prebuild drops the franchise logo at public/images/logo.png. Use it as the
34
+ // icon when it's a real image; the magic-byte sniff both picks the data-URI mime
35
+ // and rejects a 404 JSON body (no logo) so we cleanly fall back to the letter.
36
+ function loadLogo(): string | null {
37
+ try {
38
+ const buf = readFileSync(join(process.cwd(), "public/images/logo.png"));
39
+ const mime =
40
+ buf[0] === 0x89 && buf[1] === 0x50
41
+ ? "image/png"
42
+ : buf[0] === 0xff && buf[1] === 0xd8
43
+ ? "image/jpeg"
44
+ : null;
45
+ if (!mime) return null;
46
+ return \`data:\${mime};base64,\${buf.toString("base64")}\`;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ export default async function Icon({ id }: { id: Promise<string> }) {
53
+ const dimension = Number(await id);
54
+ const logo = loadLogo();
55
+ const theme = loadTheme();
56
+ const background = theme?.primary ?? "#171717";
57
+ const foreground = theme?.primaryForeground ?? "#ffffff";
58
+ return new ImageResponse(
59
+ logo ? (
60
+ <img
61
+ src={logo}
62
+ width={dimension}
63
+ height={dimension}
64
+ style={{ objectFit: "contain" }}
65
+ />
66
+ ) : (
67
+ <div
68
+ style={{
69
+ width: "100%",
70
+ height: "100%",
71
+ display: "flex",
72
+ alignItems: "center",
73
+ justifyContent: "center",
74
+ background,
75
+ color: foreground,
76
+ fontSize: dimension * 0.625,
77
+ fontWeight: 700,
78
+ }}
79
+ >
80
+ ${letter}
81
+ </div>
82
+ ),
83
+ { width: dimension, height: dimension },
84
+ );
85
+ }
86
+ `;
87
+ }
@@ -0,0 +1,55 @@
1
+ import type { ScaffoldOptions } from "../../../types";
2
+
3
+ export default function generateLayout(opts: ScaffoldOptions): string {
4
+ return `
5
+ import type { Metadata, Viewport } from "next";
6
+ import "./globals.css";
7
+ import { Header, Footer, ModalHost } from "./components";
8
+ import { CookieConsent } from "@zorgo/next/components";
9
+ import { restaurantJsonLd } from "../lib/generated/jsonld";
10
+ export const metadata: Metadata = {
11
+ // resolves relative URLs (canonicals, the auto-injected opengraph-image) to absolute
12
+ metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"),
13
+ title: {
14
+ default: ${JSON.stringify(opts.projectName)},
15
+ template: ${JSON.stringify(`%s | ${opts.projectName}`)},
16
+ },
17
+ description: ${JSON.stringify(`Order online from ${opts.projectName}.`)},
18
+ openGraph: {
19
+ type: "website",
20
+ siteName: ${JSON.stringify(opts.projectName)},
21
+ locale: "en_US",
22
+ },
23
+ twitter: {
24
+ card: "summary_large_image",
25
+ },
26
+ };
27
+
28
+ export const viewport: Viewport = {
29
+ width: "device-width",
30
+ initialScale: 1,
31
+ };
32
+
33
+ export default async function RootLayout({ children }: { children: React.ReactNode }) {
34
+ return (
35
+ <html lang="en">
36
+ <head>
37
+ <script
38
+ type="application/ld+json"
39
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(restaurantJsonLd) }}
40
+ />
41
+ </head>
42
+ <body className="min-h-screen flex flex-col bg-background text-foreground">
43
+ <Header />
44
+ <main className="flex-1">
45
+ {children}
46
+ </main>
47
+ <Footer />
48
+ <ModalHost />
49
+ <CookieConsent />
50
+ </body>
51
+ </html>
52
+ )}
53
+
54
+ `;
55
+ }
@@ -0,0 +1,30 @@
1
+ "use client";
2
+ import { useRouter } from "next/navigation";
3
+ import { useLocation } from "@zorgo/next";
4
+ import type { Location } from "@zorgo/universal/interfaces";
5
+ import { FindLocation } from "@zorgo/next/components";
6
+
7
+ export default function LocationsPage() {
8
+ const router = useRouter();
9
+ const { availableLocations, currentLocation, setLocation } = useLocation();
10
+
11
+ function handleSelect(location: Location) {
12
+ setLocation(location);
13
+ router.push("/menu");
14
+ }
15
+
16
+ return (
17
+ <div className="p-8 md:p-16">
18
+ <div className="max-w-2xl mx-auto w-full">
19
+ <h1 className="text-4xl font-extrabold text-primary text-center mb-8">
20
+ Locations
21
+ </h1>
22
+ <FindLocation
23
+ locations={availableLocations}
24
+ initialSelected={currentLocation}
25
+ onSelect={handleSelect}
26
+ />
27
+ </div>
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,39 @@
1
+ import type { ScaffoldOptions } from "../../../types";
2
+
3
+ export default function generateManifest(opts: ScaffoldOptions): string {
4
+ const name = opts.projectName;
5
+
6
+ return `import type { MetadataRoute } from "next";
7
+ import { readFileSync } from "fs";
8
+ import { join } from "path";
9
+
10
+ // Brand palette prebuild drops at public/styles/styles.json. Neutral fallback if
11
+ // it's missing.
12
+ function loadTheme() {
13
+ try {
14
+ return JSON.parse(
15
+ readFileSync(join(process.cwd(), "public/styles/styles.json"), "utf8"),
16
+ );
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export default function manifest(): MetadataRoute.Manifest {
23
+ const theme = loadTheme();
24
+ return {
25
+ name: ${JSON.stringify(name)},
26
+ short_name: ${JSON.stringify(name)},
27
+ description: ${JSON.stringify(`Order online from ${name}.`)},
28
+ start_url: "/",
29
+ display: "standalone",
30
+ background_color: theme?.backgroundBase ?? "#ffffff",
31
+ theme_color: theme?.primary ?? "#171717",
32
+ icons: [
33
+ { src: "/icon/192", sizes: "192x192", type: "image/png" },
34
+ { src: "/icon/512", sizes: "512x512", type: "image/png" },
35
+ ],
36
+ };
37
+ }
38
+ `;
39
+ }
@@ -0,0 +1,18 @@
1
+ "use client";
2
+ import { useRouter } from "next/navigation";
3
+ import { ItemCustomizationForm } from "@zorgo/next/components";
4
+
5
+ export default function ItemCustomizationClient({
6
+ baseItemId,
7
+ }: {
8
+ baseItemId: number;
9
+ }) {
10
+ const router = useRouter();
11
+
12
+ return (
13
+ <ItemCustomizationForm
14
+ baseItemId={baseItemId}
15
+ onSubmitSuccess={() => router.push("/menu")}
16
+ />
17
+ );
18
+ }
@@ -0,0 +1,78 @@
1
+ import type { Metadata } from "next";
2
+ import { baseItemIdFromSlug, menuItemSlug } from "@zorgo/next/slugs";
3
+ import { buildMenuItemJsonLd } from "@zorgo/next/seo";
4
+ import { menu } from "../../../lib/generated/menu";
5
+ import ItemCustomizationClient from "./ItemCustomizationClient";
6
+
7
+ // Single source of truth for resolving the item a slug points at — shared by
8
+ // generateMetadata and the page so the lookup never drifts. Returns undefined
9
+ // for an unknown slug; callers degrade gracefully (the page 404s downstream).
10
+ function itemFromSlug(slug: string) {
11
+ const baseItemId = baseItemIdFromSlug(slug);
12
+ return menu.items.find((item) => item.base_item_id === baseItemId);
13
+ }
14
+
15
+ // menu items are known at build time from the prebuilt menu — pre-render
16
+ // every item page instead of rendering on-demand.
17
+ export function generateStaticParams() {
18
+ return menu.items.map((item) => ({
19
+ slug: menuItemSlug(item.name, item.base_item_id),
20
+ }));
21
+ }
22
+
23
+ // only slugs present in the prebuilt menu are valid routes; anything else 404s
24
+ // instead of falling through to an on-demand render.
25
+ export const dynamicParams = false;
26
+
27
+ // per-item title/description/OG so each item ranks on its own long-tail terms
28
+ // and shares with real context, instead of inheriting the generic root title.
29
+ // title is just the item name; the root layout's title template wraps it as
30
+ // `<name> | Brand`. metadataBase (set in the root layout) resolves OG URLs, so
31
+ // nothing absolute is hardcoded here.
32
+ export async function generateMetadata({
33
+ params,
34
+ }: {
35
+ params: Promise<{ slug: string }>;
36
+ }): Promise<Metadata> {
37
+ const { slug } = await params;
38
+ const item = itemFromSlug(slug);
39
+ if (!item) return {};
40
+
41
+ const description =
42
+ item.description ?? `Order ${item.name} online for pickup or delivery.`;
43
+
44
+ return {
45
+ title: item.name,
46
+ description,
47
+ openGraph: {
48
+ title: item.name,
49
+ description,
50
+ // Next's typed OpenGraph union has no "product"; "website" is the
51
+ // closest valid value for a standalone item page.
52
+ type: "website",
53
+ },
54
+ };
55
+ }
56
+
57
+ export default async function ItemPage({
58
+ params,
59
+ }: {
60
+ params: Promise<{ slug: string }>;
61
+ }) {
62
+ const { slug } = await params;
63
+ const item = itemFromSlug(slug);
64
+
65
+ return (
66
+ <div className="p-8 md:p-16 max-w-4xl mx-auto">
67
+ {item && (
68
+ <script
69
+ type="application/ld+json"
70
+ dangerouslySetInnerHTML={{
71
+ __html: JSON.stringify(buildMenuItemJsonLd(item)),
72
+ }}
73
+ />
74
+ )}
75
+ <ItemCustomizationClient baseItemId={baseItemIdFromSlug(slug)} />
76
+ </div>
77
+ );
78
+ }