@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.
- package/README.md +19 -0
- package/dist/cli.d.mts +5 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +2 -0
- package/dist/cli.mjs +2 -0
- package/dist/components.d.mts +337 -0
- package/dist/components.d.ts +337 -0
- package/dist/components.js +1996 -0
- package/dist/components.js.map +1 -0
- package/dist/components.mjs +1962 -0
- package/dist/components.mjs.map +1 -0
- package/dist/index.d.mts +305 -0
- package/dist/index.d.ts +305 -0
- package/dist/index.js +2 -0
- package/dist/index.mjs +2 -0
- package/dist/seo.d.mts +30 -0
- package/dist/seo.d.ts +30 -0
- package/dist/seo.js +82 -0
- package/dist/seo.js.map +1 -0
- package/dist/seo.mjs +56 -0
- package/dist/seo.mjs.map +1 -0
- package/dist/slugs.d.mts +1 -0
- package/dist/slugs.d.ts +1 -0
- package/dist/slugs.js +47 -0
- package/dist/slugs.js.map +1 -0
- package/dist/slugs.mjs +19 -0
- package/dist/slugs.mjs.map +1 -0
- package/dist/tree/.env.gen.ts +27 -0
- package/dist/tree/README.md +0 -0
- package/dist/tree/app/api/zorgo-token/route.ts +11 -0
- package/dist/tree/app/apple-icon.tsx.gen.ts +54 -0
- package/dist/tree/app/checkout/page.tsx +10 -0
- package/dist/tree/app/components/Footer.tsx +50 -0
- package/dist/tree/app/components/Header.tsx +128 -0
- package/dist/tree/app/components/ModalHost.tsx +56 -0
- package/dist/tree/app/components/ModalRegistry.tsx +113 -0
- package/dist/tree/app/components/TestCard.tsx +35 -0
- package/dist/tree/app/components/index.ts +3 -0
- package/dist/tree/app/error.tsx +10 -0
- package/dist/tree/app/global-error.tsx +12 -0
- package/dist/tree/app/globals.css.gen.ts +123 -0
- package/dist/tree/app/icon.tsx.gen.ts +87 -0
- package/dist/tree/app/layout.tsx.gen.ts +55 -0
- package/dist/tree/app/locations/page.tsx +30 -0
- package/dist/tree/app/manifest.ts.gen.ts +39 -0
- package/dist/tree/app/menu/[slug]/ItemCustomizationClient.tsx +18 -0
- package/dist/tree/app/menu/[slug]/page.tsx +78 -0
- package/dist/tree/app/menu/page.tsx +36 -0
- package/dist/tree/app/not-found.tsx +8 -0
- package/dist/tree/app/opengraph-image.tsx.gen.ts +82 -0
- package/dist/tree/app/page.tsx +19 -0
- package/dist/tree/app/privacy/page.tsx.gen.ts +15 -0
- package/dist/tree/app/robots.ts +9 -0
- package/dist/tree/app/sitemap.ts +26 -0
- package/dist/tree/app/terms/page.tsx.gen.ts +16 -0
- package/dist/tree/gitignore +44 -0
- package/dist/tree/lib/zorgoSiteToken.ts +20 -0
- package/dist/tree/next.config.ts +14 -0
- package/dist/tree/open-next.config.ts +4 -0
- package/dist/tree/package.json.gen.ts +24 -0
- package/dist/tree/postcss.config.mjs +7 -0
- package/dist/tree/scripts/prebuild.ts +448 -0
- package/dist/tree/tsconfig.json +35 -0
- 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,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
|
+
}
|