Add React editor project
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import PageEditor from '@/components/page-editor';
|
||||
|
||||
// Editor for /about. Sitting under the route it edits means the preview
|
||||
// resolves the same params the public page gets.
|
||||
export default function EditorPage() {
|
||||
return <PageEditor routeKey="/about" />;
|
||||
}
|
||||
+21
-169
@@ -1,189 +1,41 @@
|
||||
{
|
||||
"root": {
|
||||
"props": {
|
||||
"title": "About — Pulse",
|
||||
"headerFont": "Archivo Black",
|
||||
"headerFontWeight": "400",
|
||||
"bodyFont": "Inter",
|
||||
"primaryColor": "#111111",
|
||||
"secondaryColor": "#707072",
|
||||
"accentColor": "#f5f5f5",
|
||||
"bgColor": "#ffffff",
|
||||
"fgColor": "#111111",
|
||||
"mutedColor": "#f5f5f5",
|
||||
"radius": "xl",
|
||||
"buttonRadius": "full",
|
||||
"shadow": "none",
|
||||
"maxWidth": "2xl"
|
||||
"title": "About — Shop",
|
||||
"description": "About Shop.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "nav-about",
|
||||
"brand": "PULSE",
|
||||
"links": [
|
||||
{
|
||||
"label": "Run",
|
||||
"href": "/collections/run"
|
||||
},
|
||||
{
|
||||
"label": "Train",
|
||||
"href": "/collections/train"
|
||||
},
|
||||
{
|
||||
"label": "Recover",
|
||||
"href": "/collections/recover"
|
||||
},
|
||||
{
|
||||
"label": "Sale",
|
||||
"href": "/collections/sale"
|
||||
},
|
||||
{
|
||||
"label": "About",
|
||||
"href": "/about"
|
||||
}
|
||||
],
|
||||
"showSearch": "yes",
|
||||
"showAccount": "yes",
|
||||
"showCart": "yes",
|
||||
"sticky": "yes",
|
||||
"tone": "default"
|
||||
}
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "cover",
|
||||
"type": "content-section",
|
||||
"props": {
|
||||
"id": "hero-about",
|
||||
"tagline": "The lab",
|
||||
"heading": "We don't make activewear.",
|
||||
"subheading": "Pulse started in a converted warehouse with three coaches, two athletes, and a whiteboard full of things they wished worked better. Eight years later, the whiteboard is still there. So is the standard.",
|
||||
"buttons": [
|
||||
{
|
||||
"label": "Shop the kit",
|
||||
"href": "/search",
|
||||
"variant": "primary"
|
||||
}
|
||||
],
|
||||
"imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/hero-about.png",
|
||||
"align": "left",
|
||||
"height": "lg",
|
||||
"tone": "light"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "cover",
|
||||
"props": {
|
||||
"id": "cover-about-performance",
|
||||
"tagline": "Used up is the goal",
|
||||
"heading": "Designed to be worn out, not preserved.",
|
||||
"subheading": "If you're babying your kit, we did something wrong. Bring it back when it's done — we'll send the next one.",
|
||||
"buttons": [],
|
||||
"imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/cover-performance.jpeg",
|
||||
"align": "left",
|
||||
"height": "lg",
|
||||
"tone": "light"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "newsletter-cta",
|
||||
"props": {
|
||||
"id": "newsletter-about",
|
||||
"tagline": "Field notes",
|
||||
"heading": "Weekly notes from the lab.",
|
||||
"subheading": "Test results, training blocks, gear we cut and gear we kept. Every Monday at 5:30am.",
|
||||
"buttonLabel": "Subscribe",
|
||||
"endpoint": "",
|
||||
"imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/newsletter.png",
|
||||
"layout": "split"
|
||||
"id": "about-intro",
|
||||
"eyebrow": "Our story",
|
||||
"heading": "About",
|
||||
"body": "Tell your store’s story here.\n\nBlank lines start a new paragraph. Everything on this page is editable at /editor?route=/about.",
|
||||
"imageUrl": "",
|
||||
"imageAlt": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer-about",
|
||||
"brand": "PULSE",
|
||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
||||
"columns": [
|
||||
{
|
||||
"title": "Shop",
|
||||
"links": [
|
||||
{
|
||||
"label": "All gear",
|
||||
"href": "/search"
|
||||
},
|
||||
{
|
||||
"label": "New",
|
||||
"href": "/collections/new"
|
||||
},
|
||||
{
|
||||
"label": "Run",
|
||||
"href": "/collections/run"
|
||||
},
|
||||
{
|
||||
"label": "Train",
|
||||
"href": "/collections/train"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "About",
|
||||
"links": [
|
||||
{
|
||||
"label": "Our story",
|
||||
"href": "/about"
|
||||
},
|
||||
{
|
||||
"label": "The lab",
|
||||
"href": "/lab"
|
||||
},
|
||||
{
|
||||
"label": "Athletes",
|
||||
"href": "/athletes"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Help",
|
||||
"links": [
|
||||
{
|
||||
"label": "Shipping",
|
||||
"href": "/help/shipping"
|
||||
},
|
||||
{
|
||||
"label": "Returns",
|
||||
"href": "/help/returns"
|
||||
},
|
||||
{
|
||||
"label": "Lifetime guarantee",
|
||||
"href": "/help/guarantee"
|
||||
},
|
||||
{
|
||||
"label": "Contact",
|
||||
"href": "/contact"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"social": [
|
||||
{
|
||||
"label": "Instagram",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Strava",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "YouTube",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"showNewsletter": "no",
|
||||
"newsletterHeading": "",
|
||||
"newsletterEndpoint": "",
|
||||
"copyright": "© 2026 Pulse. Built to be replaced when used up."
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
import PageRender from "@/components/page-render";
|
||||
import page from "./page.json";
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
import page from './page.json';
|
||||
|
||||
export const metadata = pageMetadata(page, { path: '/about' });
|
||||
|
||||
export default function Page() {
|
||||
return <PageRender page={page} />;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import PageEditor from '@/components/page-editor';
|
||||
|
||||
// Editor for /account/activate/[id]/[token]. Sitting under the route it edits means the preview
|
||||
// resolves the same params the public page gets.
|
||||
export default function EditorPage() {
|
||||
return <PageEditor routeKey="/account/activate/[id]/[token]" />;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"root": {
|
||||
"props": {
|
||||
"title": "Activate your account",
|
||||
"description": "Finish setting up your account.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "account-activate",
|
||||
"props": {
|
||||
"id": "account-activate",
|
||||
"flow": "activate",
|
||||
"title": "Activate your account",
|
||||
"description": "Choose a password to finish setting up your account.",
|
||||
"submitLabel": "Activate account",
|
||||
"successMessage": "",
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
import page from './page.json';
|
||||
|
||||
export const metadata = pageMetadata(page);
|
||||
|
||||
export default function Page() {
|
||||
return <PageRender page={page} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import PageEditor from '@/components/page-editor';
|
||||
|
||||
// Editor for /account. Sitting under the route it edits means the preview
|
||||
// resolves the same params the public page gets.
|
||||
export default function EditorPage() {
|
||||
return <PageEditor routeKey="/account" />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import PageEditor from '@/components/page-editor';
|
||||
|
||||
// Editor for /account/login. Sitting under the route it edits means the preview
|
||||
// resolves the same params the public page gets.
|
||||
export default function EditorPage() {
|
||||
return <PageEditor routeKey="/account/login" />;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"root": {
|
||||
"props": {
|
||||
"title": "Sign in",
|
||||
"description": "Sign in to your Shop account.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "account-login",
|
||||
"props": {
|
||||
"id": "account-login",
|
||||
"flow": "login",
|
||||
"title": "Sign in",
|
||||
"description": "",
|
||||
"submitLabel": "Sign in",
|
||||
"successMessage": "",
|
||||
"links": [
|
||||
{
|
||||
"label": "Create an account",
|
||||
"url": "/account/register"
|
||||
},
|
||||
{
|
||||
"label": "Forgot your password?",
|
||||
"url": "/account/recover"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
import page from './page.json';
|
||||
|
||||
export const metadata = pageMetadata(page, { path: '/account/login' });
|
||||
|
||||
export default function Page() {
|
||||
return <PageRender page={page} />;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"root": {
|
||||
"props": {
|
||||
"title": "Order history",
|
||||
"description": "Your recent orders.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "account-orders",
|
||||
"props": {
|
||||
"id": "account-orders",
|
||||
"title": "Order history",
|
||||
"signedOutMessage": "Sign in to see your orders.",
|
||||
"emptyMessage": "You haven't placed any orders yet.",
|
||||
"limit": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
import { getSessionToken } from '@/services/shopify/session';
|
||||
import page from './page.json';
|
||||
|
||||
export const metadata = pageMetadata(page, { path: '/account' });
|
||||
|
||||
export default async function Page() {
|
||||
// The order-history block re-reads the session through /api/account/orders,
|
||||
// but bouncing signed-out visitors here avoids rendering the page at all.
|
||||
const token = await getSessionToken();
|
||||
if (!token) redirect('/account/login');
|
||||
|
||||
return <PageRender page={page} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import PageEditor from '@/components/page-editor';
|
||||
|
||||
// Editor for /account/recover. Sitting under the route it edits means the preview
|
||||
// resolves the same params the public page gets.
|
||||
export default function EditorPage() {
|
||||
return <PageEditor routeKey="/account/recover" />;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"root": {
|
||||
"props": {
|
||||
"title": "Reset password",
|
||||
"description": "Request a password reset link.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "account-recover",
|
||||
"props": {
|
||||
"id": "account-recover",
|
||||
"flow": "recover",
|
||||
"title": "Reset password",
|
||||
"description": "Enter your email and we'll send you a link to set a new password.",
|
||||
"submitLabel": "Send reset link",
|
||||
"successMessage": "If that email has an account, a reset link is on its way.",
|
||||
"links": [
|
||||
{
|
||||
"label": "Back to sign in",
|
||||
"url": "/account/login"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
import page from './page.json';
|
||||
|
||||
export const metadata = pageMetadata(page, { path: '/account/recover' });
|
||||
|
||||
export default function Page() {
|
||||
return <PageRender page={page} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import PageEditor from '@/components/page-editor';
|
||||
|
||||
// Editor for /account/register. Sitting under the route it edits means the preview
|
||||
// resolves the same params the public page gets.
|
||||
export default function EditorPage() {
|
||||
return <PageEditor routeKey="/account/register" />;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"root": {
|
||||
"props": {
|
||||
"title": "Create account",
|
||||
"description": "Create a Shop account.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "account-register",
|
||||
"props": {
|
||||
"id": "account-register",
|
||||
"flow": "register",
|
||||
"title": "Create account",
|
||||
"description": "",
|
||||
"submitLabel": "Create account",
|
||||
"successMessage": "",
|
||||
"links": [
|
||||
{
|
||||
"label": "Already have an account? Sign in",
|
||||
"url": "/account/login"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
import page from './page.json';
|
||||
|
||||
export const metadata = pageMetadata(page, { path: '/account/register' });
|
||||
|
||||
export default function Page() {
|
||||
return <PageRender page={page} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import PageEditor from '@/components/page-editor';
|
||||
|
||||
// Editor for /account/reset/[id]/[token]. Sitting under the route it edits means the preview
|
||||
// resolves the same params the public page gets.
|
||||
export default function EditorPage() {
|
||||
return <PageEditor routeKey="/account/reset/[id]/[token]" />;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"root": {
|
||||
"props": {
|
||||
"title": "Set a new password",
|
||||
"description": "Choose a new password.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "account-reset",
|
||||
"props": {
|
||||
"id": "account-reset",
|
||||
"flow": "reset",
|
||||
"title": "Set a new password",
|
||||
"description": "",
|
||||
"submitLabel": "Save password",
|
||||
"successMessage": "",
|
||||
"links": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
import page from './page.json';
|
||||
|
||||
export const metadata = pageMetadata(page);
|
||||
|
||||
export default function Page() {
|
||||
return <PageRender page={page} />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
activateAccount,
|
||||
toCustomerGid,
|
||||
customerErrorMessage,
|
||||
} from '@/services/shopify/customer';
|
||||
import { setSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { id, activationToken, password } = await req.json();
|
||||
|
||||
if (!id || !activationToken || !password) {
|
||||
return Response.json(
|
||||
{ error: 'This activation link is incomplete.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { token, errors } = await activateAccount(
|
||||
toCustomerGid(id),
|
||||
activationToken,
|
||||
password
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
||||
}
|
||||
|
||||
await setSessionToken(token.accessToken, token.expiresAt);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { login, customerErrorMessage } from '@/services/shopify/customer';
|
||||
import { setSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { email, password } = await req.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return Response.json(
|
||||
{ error: 'Enter your email and password.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { token, errors } = await login(email, password);
|
||||
|
||||
if (!token) {
|
||||
// Shopify distinguishes wrong-password from unknown-email; collapse both so
|
||||
// the form can't be used to enumerate accounts.
|
||||
return Response.json(
|
||||
{
|
||||
error: errors.length
|
||||
? 'Incorrect email or password.'
|
||||
: customerErrorMessage(errors),
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
await setSessionToken(token.accessToken, token.expiresAt);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { logout } from '@/services/shopify/customer';
|
||||
import { getSessionToken, clearSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST() {
|
||||
const token = await getSessionToken();
|
||||
if (token) await logout(token);
|
||||
|
||||
await clearSessionToken();
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getSessionToken } from '@/services/shopify/session';
|
||||
import { getCustomer } from '@/services/shopify/customer';
|
||||
|
||||
// Minimal session probe for the header menu — never returns the access token.
|
||||
export async function GET() {
|
||||
const token = await getSessionToken();
|
||||
if (!token) return Response.json({ customer: null });
|
||||
|
||||
const customer = await getCustomer(token, 0);
|
||||
if (!customer) return Response.json({ customer: null });
|
||||
|
||||
return Response.json({
|
||||
customer: {
|
||||
displayName: customer.displayName,
|
||||
email: customer.email,
|
||||
firstName: customer.firstName,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getSessionToken } from '@/services/shopify/session';
|
||||
import { getCustomer } from '@/services/shopify/customer';
|
||||
|
||||
/**
|
||||
* Full customer record including orders, for the client-rendered order-history
|
||||
* block. `/api/account/me` stays the lightweight session probe the header uses
|
||||
* — it asks for zero orders — so the two don't fight over payload size.
|
||||
*
|
||||
* The access token never leaves the server: it is read from the session cookie
|
||||
* here and only the resolved customer is returned.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const token = await getSessionToken();
|
||||
if (!token) return Response.json({ customer: null }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const parsed = Number(searchParams.get('orders'));
|
||||
const orderCount = Number.isFinite(parsed)
|
||||
? Math.min(Math.max(Math.trunc(parsed), 1), 50)
|
||||
: 20;
|
||||
|
||||
const customer = await getCustomer(token, orderCount);
|
||||
if (!customer) return Response.json({ customer: null }, { status: 401 });
|
||||
|
||||
return Response.json({ customer });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { recoverPassword } from '@/services/shopify/customer';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { email } = await req.json();
|
||||
|
||||
if (email) await recoverPassword(email);
|
||||
|
||||
// Always the same response, so the form can't reveal who has an account.
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
createCustomer,
|
||||
login,
|
||||
customerErrorMessage,
|
||||
} from '@/services/shopify/customer';
|
||||
import { setSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { email, password, firstName, lastName } = await req.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return Response.json(
|
||||
{ error: 'Enter your email and password.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { errors } = await createCustomer({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
||||
}
|
||||
|
||||
// Sign the new customer straight in. Accounts needing email confirmation
|
||||
// won't return a token yet, which is not an error.
|
||||
const { token } = await login(email, password);
|
||||
|
||||
if (token) {
|
||||
await setSessionToken(token.accessToken, token.expiresAt);
|
||||
return Response.json({ ok: true, signedIn: true });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, signedIn: false });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
resetPassword,
|
||||
toCustomerGid,
|
||||
customerErrorMessage,
|
||||
} from '@/services/shopify/customer';
|
||||
import { setSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { id, resetToken, password } = await req.json();
|
||||
|
||||
if (!id || !resetToken || !password) {
|
||||
return Response.json({ error: 'This reset link is incomplete.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { token, errors } = await resetPassword(
|
||||
toCustomerGid(id),
|
||||
resetToken,
|
||||
password
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
||||
}
|
||||
|
||||
await setSessionToken(token.accessToken, token.expiresAt);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { streamText, tool, convertToModelMessages, stepCountIs } from 'ai';
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
searchProducts,
|
||||
getProduct,
|
||||
getProductsPage,
|
||||
getCollections,
|
||||
getCollectionProductsPage,
|
||||
isDefaultTitleOption,
|
||||
isDefaultTitleSelection,
|
||||
} from '@/services/shopify/catalog';
|
||||
|
||||
// Streaming needs the Node runtime here because the Storefront helpers run
|
||||
// server-side on each tool call.
|
||||
export const maxDuration = 30;
|
||||
|
||||
const MODEL = process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.6-luna-pro';
|
||||
|
||||
const SYSTEM_PROMPT = `You are the shopping assistant for an online store built on Shopify.
|
||||
|
||||
You help shoppers find products, compare options, and understand what the store
|
||||
carries. You have tools that read live catalogue data — always use them rather
|
||||
than guessing, and never invent products, prices, availability, or policies.
|
||||
|
||||
Guidelines:
|
||||
- Call a tool whenever the answer depends on catalogue data. If a shopper asks
|
||||
something vague like "what do you have?", call listCollections or
|
||||
searchCatalogue to ground your answer.
|
||||
- Prices returned by the tools are in the store's currency; show them as given.
|
||||
- Link products as /products/{handle} and collections as /collections/{handle}
|
||||
so the shopper can click through.
|
||||
- Keep replies short and conversational — a sentence or two plus a compact list.
|
||||
Do not repeat the raw tool output; the interface already shows it.
|
||||
- If a tool returns nothing, say so plainly and suggest a different search.
|
||||
- You cannot place orders, change carts, process payments, or look up customer
|
||||
or order data. Say so and point the shopper to the relevant page instead.`;
|
||||
|
||||
// Trim the Storefront payloads to what the model actually needs to answer.
|
||||
const summariseProduct = (product: {
|
||||
id: string;
|
||||
title: string;
|
||||
handle: string;
|
||||
description?: string;
|
||||
productType?: string;
|
||||
tags?: string[];
|
||||
priceRange: { minVariantPrice: { amount: string; currencyCode: string } };
|
||||
variants?: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
title: string;
|
||||
availableForSale: boolean;
|
||||
selectedOptions?: Array<{ name: string; value: string }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
images?: { edges: Array<{ node: { url: string } }> };
|
||||
options?: Array<{ name: string; values: string[] }>;
|
||||
}) => ({
|
||||
title: product.title,
|
||||
handle: product.handle,
|
||||
url: `/products/${product.handle}`,
|
||||
price: `${product.priceRange.minVariantPrice.amount} ${product.priceRange.minVariantPrice.currencyCode}`,
|
||||
image: product.images?.edges[0]?.node.url ?? null,
|
||||
description: product.description?.slice(0, 300) ?? null,
|
||||
productType: product.productType || null,
|
||||
tags: product.tags ?? [],
|
||||
options: product.options
|
||||
?.filter((option) => !isDefaultTitleOption(option))
|
||||
.map((option) => ({
|
||||
name: option.name,
|
||||
values: option.values,
|
||||
})),
|
||||
inStock: product.variants?.edges.some((edge) => edge.node.availableForSale),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'OPENROUTER_API_KEY is not configured.' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const { messages } = await req.json();
|
||||
const openrouter = createOpenRouter({ apiKey });
|
||||
|
||||
const result = streamText({
|
||||
// `reasoning` asks OpenRouter to stream the model's thinking; the UI
|
||||
// renders it via the Reasoning component.
|
||||
model: openrouter(MODEL, { reasoning: { enabled: true, effort: 'medium' } }),
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: await convertToModelMessages(messages),
|
||||
// Let the model call a tool, read the result, then answer.
|
||||
stopWhen: stepCountIs(5),
|
||||
tools: {
|
||||
searchCatalogue: tool({
|
||||
description:
|
||||
'Search the store for products matching a term. Use for any question about what the store sells.',
|
||||
inputSchema: z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe('Search terms, e.g. "green hoodie" or "jacket".'),
|
||||
limit: z.number().int().min(1).max(10).default(5),
|
||||
}),
|
||||
execute: async ({ query, limit }) => {
|
||||
const { products, totalCount } = await searchProducts({
|
||||
query,
|
||||
first: limit,
|
||||
});
|
||||
return {
|
||||
totalCount,
|
||||
products: products.map(summariseProduct),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
getProductDetails: tool({
|
||||
description:
|
||||
'Get full details for one product by its handle, including options, variants, and stock.',
|
||||
inputSchema: z.object({
|
||||
handle: z
|
||||
.string()
|
||||
.describe('The product handle, e.g. "flowguard-jacket".'),
|
||||
}),
|
||||
execute: async ({ handle }) => {
|
||||
const product = await getProduct(handle);
|
||||
if (!product) return { found: false, handle };
|
||||
|
||||
return {
|
||||
found: true,
|
||||
...summariseProduct(product),
|
||||
variants: product.variants.edges.slice(0, 25).map(({ node }) => ({
|
||||
title: node.title,
|
||||
available: node.availableForSale,
|
||||
price: `${node.price.amount} ${node.price.currencyCode}`,
|
||||
options: node.selectedOptions?.filter(
|
||||
(option) => !isDefaultTitleSelection(option)
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
listCollections: tool({
|
||||
description:
|
||||
'List the store\'s collections. Use when the shopper asks what categories or ranges exist.',
|
||||
inputSchema: z.object({
|
||||
limit: z.number().int().min(1).max(25).default(10),
|
||||
}),
|
||||
execute: async ({ limit }) => {
|
||||
const collections = await getCollections(limit);
|
||||
return {
|
||||
collections: collections.map((collection) => ({
|
||||
title: collection.title,
|
||||
handle: collection.handle,
|
||||
url: `/collections/${collection.handle}`,
|
||||
description: collection.description?.slice(0, 200) ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
getCollectionProducts: tool({
|
||||
description:
|
||||
'List the products inside one collection, by collection handle.',
|
||||
inputSchema: z.object({
|
||||
handle: z.string().describe('The collection handle, e.g. "men".'),
|
||||
limit: z.number().int().min(1).max(20).default(8),
|
||||
}),
|
||||
execute: async ({ handle, limit }) => {
|
||||
const page = await getCollectionProductsPage(handle, { first: limit });
|
||||
if (!page.collection) return { found: false, handle };
|
||||
|
||||
return {
|
||||
found: true,
|
||||
collection: page.collection.title,
|
||||
url: `/collections/${handle}`,
|
||||
products: page.products.map(summariseProduct),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
browseProducts: tool({
|
||||
description:
|
||||
'Browse the newest products when the shopper has no specific search term.',
|
||||
inputSchema: z.object({
|
||||
limit: z.number().int().min(1).max(20).default(8),
|
||||
}),
|
||||
execute: async ({ limit }) => {
|
||||
const page = await getProductsPage({
|
||||
first: limit,
|
||||
sortKey: 'CREATED_AT',
|
||||
reverse: true,
|
||||
});
|
||||
return { products: page.products.map(summariseProduct) };
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// sendReasoning forwards reasoning parts to the client; without it the
|
||||
// stream carries text and tool calls only.
|
||||
return result.toUIMessageStreamResponse({ sendReasoning: true });
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { CLOUD_BASE, FRONTEND_API_KEY } from "@/lib/cloud";
|
||||
|
||||
// DELETE /api/media/:id — remove a media asset.
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const upstream = await fetch(
|
||||
`${CLOUD_BASE}/api/media/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "x-api-key": FRONTEND_API_KEY },
|
||||
},
|
||||
);
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
upstream.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { CLOUD_BASE, FRONTEND_API_KEY } from "@/lib/cloud";
|
||||
|
||||
// GET /api/media — list/search media. Forwards query + cursor params.
|
||||
export async function GET(req: NextRequest) {
|
||||
const incoming = new URL(req.url);
|
||||
const url = new URL("/api/media", CLOUD_BASE);
|
||||
const query = incoming.searchParams.get("query");
|
||||
const cursor = incoming.searchParams.get("cursor");
|
||||
if (query) url.searchParams.set("query", query);
|
||||
if (cursor) url.searchParams.set("cursor", cursor);
|
||||
|
||||
const upstream = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { "x-api-key": FRONTEND_API_KEY },
|
||||
});
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
upstream.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/media — upload. Forwards the multipart body as-is.
|
||||
export async function POST(req: NextRequest) {
|
||||
const upstream = await fetch(`${CLOUD_BASE}/api/media`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": req.headers.get("content-type") ?? "",
|
||||
"x-api-key": FRONTEND_API_KEY,
|
||||
},
|
||||
body: req.body,
|
||||
// @ts-expect-error - duplex is valid but missing from the lib types.
|
||||
duplex: "half",
|
||||
});
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
upstream.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { findPageRoute } from '@/lib/pages';
|
||||
|
||||
// Touches the filesystem, so it must never be statically optimised.
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Resolves a route key to its `page.json` on disk.
|
||||
*
|
||||
* The path is built from the registry in `lib/pages.ts`, never from the request
|
||||
* body, so an unknown or crafted route key is rejected outright rather than
|
||||
* escaping the `app/` directory. The realpath check is belt-and-braces for the
|
||||
* same thing.
|
||||
*/
|
||||
function resolvePageFile(routeKey: string): string | null {
|
||||
const route = findPageRoute(routeKey);
|
||||
if (!route) return null;
|
||||
|
||||
const appDir = path.join(process.cwd(), 'app');
|
||||
const file = path.join(appDir, route.dir, 'page.json');
|
||||
|
||||
return file.startsWith(appDir + path.sep) ? file : null;
|
||||
}
|
||||
|
||||
// Props of blocks marked `global: true` (header, footer) live in one file that
|
||||
// every page.json references, so editing them once updates every route.
|
||||
const GLOBALS_FILE = path.join(process.cwd(), 'app.globals.json');
|
||||
|
||||
async function readJson(file: string): Promise<Record<string, any> | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(file, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const routeKey = new URL(request.url).searchParams.get('route') ?? '/';
|
||||
const file = resolvePageFile(routeKey);
|
||||
|
||||
if (!file) {
|
||||
return Response.json({ error: `Unknown route: ${routeKey}` }, { status: 404 });
|
||||
}
|
||||
|
||||
const page = await readJson(file);
|
||||
// A route with no page.json yet is a new page, not an error.
|
||||
if (!page) return Response.json({ page: null });
|
||||
|
||||
const globals = await readJson(GLOBALS_FILE);
|
||||
return Response.json({ page: { ...page, globals: globals ?? {} } });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
let body: { route?: string; page?: unknown };
|
||||
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: 'Expected a JSON body.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const routeKey = body.route ?? '';
|
||||
const file = resolvePageFile(routeKey);
|
||||
|
||||
if (!file) {
|
||||
return Response.json({ error: `Unknown route: ${routeKey}` }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!body.page || typeof body.page !== 'object') {
|
||||
return Response.json({ error: 'Expected a page object.' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Globals belong to the whole site, not this route, so they go to their own
|
||||
// file and are stripped from the page before it is written.
|
||||
const { globals, ...page } = body.page as Record<string, unknown>;
|
||||
|
||||
try {
|
||||
await writeFile(file, `${JSON.stringify(page, null, 2)}\n`, 'utf8');
|
||||
|
||||
if (globals && typeof globals === 'object') {
|
||||
await writeFile(
|
||||
GLOBALS_FILE,
|
||||
`${JSON.stringify(globals, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ file: path.relative(process.cwd(), file) });
|
||||
} catch (err) {
|
||||
// Read-only filesystems (most serverless hosts) land here. Say so plainly
|
||||
// rather than reporting a save that did not happen.
|
||||
console.error(`Failed to write ${file}:`, err);
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
'Could not write page.json. The filesystem is read-only — run the editor locally to save.',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import PageEditor from '@/components/page-editor';
|
||||
|
||||
// Editor for /collections/[handle]. Sitting under the route it edits means the preview
|
||||
// resolves the same params the public page gets.
|
||||
export default function EditorPage() {
|
||||
return <PageEditor routeKey="/collections/[handle]" />;
|
||||
}
|
||||
@@ -1,194 +1,38 @@
|
||||
{
|
||||
"root": {
|
||||
"props": {
|
||||
"title": "Collection — Pulse",
|
||||
"headerFont": "Archivo Black",
|
||||
"headerFontWeight": "400",
|
||||
"bodyFont": "Inter",
|
||||
"primaryColor": "#111111",
|
||||
"secondaryColor": "#707072",
|
||||
"accentColor": "#f5f5f5",
|
||||
"bgColor": "#ffffff",
|
||||
"fgColor": "#111111",
|
||||
"mutedColor": "#f5f5f5",
|
||||
"radius": "xl",
|
||||
"buttonRadius": "full",
|
||||
"shadow": "none",
|
||||
"maxWidth": "2xl"
|
||||
"title": "Collection",
|
||||
"description": "Shop the collection.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "nav-collection",
|
||||
"brand": "PULSE",
|
||||
"links": [
|
||||
{
|
||||
"label": "Run",
|
||||
"href": "/collections/run"
|
||||
},
|
||||
{
|
||||
"label": "Train",
|
||||
"href": "/collections/train"
|
||||
},
|
||||
{
|
||||
"label": "Recover",
|
||||
"href": "/collections/recover"
|
||||
},
|
||||
{
|
||||
"label": "Sale",
|
||||
"href": "/collections/sale"
|
||||
},
|
||||
{
|
||||
"label": "About",
|
||||
"href": "/about"
|
||||
}
|
||||
],
|
||||
"showSearch": "yes",
|
||||
"showAccount": "yes",
|
||||
"showCart": "yes",
|
||||
"sticky": "yes",
|
||||
"tone": "default"
|
||||
}
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "collection",
|
||||
"type": "collection-detail",
|
||||
"props": {
|
||||
"id": "collection-detail",
|
||||
"collection": null,
|
||||
"showDescription": "yes",
|
||||
"showCoverImage": "yes",
|
||||
"customCoverImage": "",
|
||||
"columns": "4",
|
||||
"limit": 24,
|
||||
"defaultSort": "BEST_SELLING",
|
||||
"showAvailability": "yes",
|
||||
"showPriceRange": "yes",
|
||||
"showProductType": "yes",
|
||||
"productTypeOptions": [
|
||||
{
|
||||
"label": "Tops"
|
||||
},
|
||||
{
|
||||
"label": "Shorts"
|
||||
},
|
||||
{
|
||||
"label": "Tights"
|
||||
},
|
||||
{
|
||||
"label": "Outerwear"
|
||||
},
|
||||
{
|
||||
"label": "Shoes"
|
||||
},
|
||||
{
|
||||
"label": "Accessories"
|
||||
}
|
||||
],
|
||||
"showVendor": "no",
|
||||
"vendorOptions": [],
|
||||
"showTags": "no",
|
||||
"tagOptions": [],
|
||||
"showColor": "yes",
|
||||
"colorOptions": [
|
||||
{
|
||||
"label": "Black",
|
||||
"color": "#111111"
|
||||
},
|
||||
{
|
||||
"label": "White",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
{
|
||||
"label": "Grey",
|
||||
"color": "#707072"
|
||||
},
|
||||
{
|
||||
"label": "Volt",
|
||||
"color": "#d6ff3f"
|
||||
},
|
||||
{
|
||||
"label": "Sodium",
|
||||
"color": "#fa5400"
|
||||
}
|
||||
],
|
||||
"showStyle": "no",
|
||||
"styleOptions": [],
|
||||
"showSize": "yes",
|
||||
"sizeOptions": [
|
||||
{
|
||||
"label": "XS"
|
||||
},
|
||||
{
|
||||
"label": "S"
|
||||
},
|
||||
{
|
||||
"label": "M"
|
||||
},
|
||||
{
|
||||
"label": "L"
|
||||
},
|
||||
{
|
||||
"label": "XL"
|
||||
},
|
||||
{
|
||||
"label": "XXL"
|
||||
}
|
||||
],
|
||||
"showMaterial": "no",
|
||||
"materialOptions": [],
|
||||
"metafieldFilters": []
|
||||
"title": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer-collection",
|
||||
"brand": "PULSE",
|
||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
||||
"columns": [
|
||||
{
|
||||
"title": "Shop",
|
||||
"links": [
|
||||
{
|
||||
"label": "All gear",
|
||||
"href": "/search"
|
||||
},
|
||||
{
|
||||
"label": "New",
|
||||
"href": "/collections/new"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Help",
|
||||
"links": [
|
||||
{
|
||||
"label": "Shipping",
|
||||
"href": "/help/shipping"
|
||||
},
|
||||
{
|
||||
"label": "Returns",
|
||||
"href": "/help/returns"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"social": [
|
||||
{
|
||||
"label": "Instagram",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Strava",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"showNewsletter": "no",
|
||||
"newsletterHeading": "",
|
||||
"newsletterEndpoint": "",
|
||||
"copyright": "© 2026 Pulse. Built to be replaced when used up."
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import PageRender from "@/components/page-render";
|
||||
import page from "./page.json";
|
||||
import type { Metadata } from 'next';
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
import { getCollectionProductsPage } from '@/services/shopify/catalog';
|
||||
import { truncate } from '@/lib/utils';
|
||||
import page from './page.json';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ handle: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { handle } = await params;
|
||||
|
||||
// No collection-only query exists, so ask for the smallest page of products
|
||||
// and use just the collection node off it.
|
||||
const { collection } = await getCollectionProductsPage(handle, {
|
||||
first: 1,
|
||||
}).catch(() => ({ collection: null }));
|
||||
|
||||
// The blocks fetch client-side and render their own empty state, so a failed
|
||||
// lookup falls back to the page.json tags rather than a 500.
|
||||
if (!collection) return pageMetadata(page);
|
||||
|
||||
return pageMetadata(page, {
|
||||
title: collection.title,
|
||||
description: collection.description
|
||||
? truncate(collection.description, 160)
|
||||
: `Shop the ${collection.title} collection.`,
|
||||
image: collection.image?.url,
|
||||
path: `/collections/${collection.handle}`,
|
||||
});
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <PageRender page={page} />;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import PageEditor from '@/components/page-editor';
|
||||
|
||||
// Editor for /collections. Sitting under the route it edits means the preview
|
||||
// resolves the same params the public page gets.
|
||||
export default function EditorPage() {
|
||||
return <PageEditor routeKey="/collections" />;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"root": {
|
||||
"props": {
|
||||
"title": "Collections",
|
||||
"description": "Browse every collection in the store.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "collections",
|
||||
"props": {
|
||||
"id": "collections-grid",
|
||||
"title": "Our Collections",
|
||||
"subtitle": "Discover our carefully crafted worlds"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
import page from './page.json';
|
||||
|
||||
export const metadata = pageMetadata(page, { path: '/collections' });
|
||||
|
||||
export default function Page() {
|
||||
return <PageRender page={page} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import PageEditor from '@/components/page-editor';
|
||||
|
||||
// Editor for /. Sitting under the route it edits means the preview
|
||||
// resolves the same params the public page gets.
|
||||
export default function EditorPage() {
|
||||
return <PageEditor routeKey="/" />;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-lg font-medium text-black font-sans whitespace-nowrap">
|
||||
Something went wrong
|
||||
</div>
|
||||
<div className="border-l border-gray-300 h-6"></div>
|
||||
<div className="text-sm text-gray-700 font-sans max-w-lg">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+218
-137
@@ -1,149 +1,230 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
/* Refined neutral modern palette */
|
||||
--color-background: hsl(0 0% 100%);
|
||||
--color-foreground: hsl(240 10% 3.9%);
|
||||
|
||||
/* Card */
|
||||
--color-card: hsl(0 0% 100%);
|
||||
--color-card-foreground: hsl(240 10% 3.9%);
|
||||
|
||||
/* Popover */
|
||||
--color-popover: hsl(0 0% 100%);
|
||||
--color-popover-foreground: hsl(240 10% 3.9%);
|
||||
|
||||
/* Primary - Deep neutral slate for professional look */
|
||||
--color-primary: hsl(240 5.9% 10%);
|
||||
--color-primary-foreground: hsl(0 0% 98%);
|
||||
|
||||
/* Secondary */
|
||||
--color-secondary: hsl(240 4.8% 95.9%);
|
||||
--color-secondary-foreground: hsl(240 5.9% 10%);
|
||||
|
||||
/* Muted */
|
||||
--color-muted: hsl(240 4.8% 95.9%);
|
||||
--color-muted-foreground: hsl(240 3.8% 46.1%);
|
||||
|
||||
/* Accent - Subtle warm gray */
|
||||
--color-accent: hsl(30 6.7% 95%);
|
||||
--color-accent-foreground: hsl(240 5.9% 10%);
|
||||
|
||||
/* Destructive */
|
||||
--color-destructive: hsl(0 84.2% 60.2%);
|
||||
--color-destructive-foreground: hsl(0 0% 98%);
|
||||
|
||||
/* Shop (accelerated checkout) purple */
|
||||
--color-shop: #5a31f4;
|
||||
|
||||
/* Border */
|
||||
--color-border: hsl(240 5.9% 90%);
|
||||
--color-input: hsl(240 5.9% 90%);
|
||||
--color-ring: hsl(240 5.9% 10%);
|
||||
|
||||
/* Radius - Modern larger corners */
|
||||
--radius-sm: 0.5rem;
|
||||
--radius-md: 0.75rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-xl: 1.25rem;
|
||||
|
||||
/* Typography — Geist Sans / Geist Mono */
|
||||
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
|
||||
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
|
||||
--font-heading: var(--font-geist-sans), system-ui, sans-serif;
|
||||
--font-body: var(--font-geist-sans), system-ui, sans-serif;
|
||||
--font-poppins: var(--font-geist-sans), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Base styles for light, modern Shopify storefront */
|
||||
/* Tailwind v4 defaults an unqualified `border` to currentColor. shadcn
|
||||
components (e.g. Button's outline variant) rely on this base layer to pick up
|
||||
the theme's border colour instead of the text colour. */
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-feature-settings:
|
||||
'kern' 1,
|
||||
'tnum' 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Modern card design for products and collections */
|
||||
.card-modern {
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.card-modern:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.1);
|
||||
border-color: hsl(240 5.9% 85%);
|
||||
}
|
||||
|
||||
/* Heading styles — regular weight, slightly tightened like the Geist reference */
|
||||
h1,
|
||||
h2,
|
||||
.font-heading {
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Swipeable rows (mobile galleries) without a visible scrollbar */
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Diagonal strike marking an unavailable product option (size pill, swatch) */
|
||||
.option-unavailable {
|
||||
background-image: linear-gradient(
|
||||
to top right,
|
||||
transparent calc(50% - 0.5px),
|
||||
currentColor calc(50% - 0.5px),
|
||||
currentColor calc(50% + 0.5px),
|
||||
transparent calc(50% + 0.5px)
|
||||
);
|
||||
}
|
||||
|
||||
/* Product description (HTML returned by the Storefront API) */
|
||||
.product-description p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.product-description > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Shop policy body (HTML returned by the Storefront API) */
|
||||
.policy-body p,
|
||||
.policy-body ul,
|
||||
.policy-body ol {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.policy-body ul,
|
||||
.policy-body ol {
|
||||
padding-left: 1.25rem;
|
||||
list-style: revert;
|
||||
}
|
||||
|
||||
.policy-body li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.policy-body h2,
|
||||
.policy-body h3,
|
||||
.policy-body h4 {
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.policy-body a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.policy-body strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.policy-body > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Enhanced button styles */
|
||||
button,
|
||||
.btn-modern {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.btn-modern:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Hero section styles */
|
||||
.hero-bg {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f5 100%);
|
||||
}
|
||||
|
||||
/* Product gallery improvements */
|
||||
.product-gallery-main {
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.product-gallery-main:hover {
|
||||
box-shadow: 0 0 0 1px hsl(240 5.9% 80%) inset;
|
||||
}
|
||||
|
||||
/* Clean price display */
|
||||
.price-display {
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-family-heading: var(--font-header);
|
||||
--font-family-header: var(--font-header);
|
||||
--font-family-body: var(--font-body);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--radius-sm: calc(var(--radius) * 0.5);
|
||||
--radius-md: calc(var(--radius) * 0.75);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.5);
|
||||
|
||||
--animate-marquee: marquee var(--duration) infinite linear;
|
||||
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
|
||||
|
||||
@keyframes marquee {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(calc(-100% - var(--gap))); }
|
||||
--animate-rainbow: rainbow var(--speed, 2s) infinite linear;
|
||||
--color-color-5: var(--color-5);
|
||||
--color-color-4: var(--color-4);
|
||||
--color-color-3: var(--color-3);
|
||||
--color-color-2: var(--color-2);
|
||||
--color-color-1: var(--color-1);
|
||||
@keyframes rainbow {
|
||||
0% {
|
||||
background-position: 0%;
|
||||
}
|
||||
100% {
|
||||
background-position: 200%;
|
||||
}
|
||||
}
|
||||
@keyframes marquee-vertical {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(calc(-100% - var(--gap))); }
|
||||
}
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--radius-2xl: calc(var(--radius) * 2);
|
||||
--radius-3xl: calc(var(--radius) * 3);
|
||||
--radius-4xl: calc(var(--radius) * 4)
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--font-header: system-ui, -apple-system, sans-serif;
|
||||
--font-body: system-ui, -apple-system, sans-serif;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: #fafafa;
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* { @apply outline-ring/50; }
|
||||
html, body { background-color: var(--background); color: var(--foreground); }
|
||||
body {
|
||||
font-family: var(--font-body), "Apple Color Emoji", "Segoe UI Emoji";
|
||||
margin: 0;
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;}
|
||||
--color-1: oklch(66.2% 0.225 25.9);
|
||||
--color-2: oklch(60.4% 0.26 302);
|
||||
--color-3: oklch(69.6% 0.165 251);
|
||||
--color-4: oklch(80.2% 0.134 225);
|
||||
--color-5: oklch(90.7% 0.231 133);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--color-1: oklch(66.2% 0.225 25.9);
|
||||
--color-2: oklch(60.4% 0.26 302);
|
||||
--color-3: oklch(69.6% 0.165 251);
|
||||
--color-4: oklch(80.2% 0.134 225);
|
||||
--color-5: oklch(90.7% 0.231 133);
|
||||
}
|
||||
+47
-9
@@ -1,16 +1,54 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { site } from '@/config/site';
|
||||
|
||||
export const metadata = {
|
||||
title: "Shopify Storefront",
|
||||
const geist = Geist({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-geist-sans',
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-geist-mono',
|
||||
});
|
||||
|
||||
// Per-page tags come from each page.json's root props via pageMetadata(); this
|
||||
// is the fallback for routes that don't set their own.
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(site.url),
|
||||
title: {
|
||||
default: site.storeName,
|
||||
template: `%s — ${site.storeName}`,
|
||||
},
|
||||
description: site.description,
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
siteName: site.storeName,
|
||||
title: site.storeName,
|
||||
description: site.description,
|
||||
url: '/',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: site.storeName,
|
||||
description: site.description,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
|
||||
{/* Header, footer and the store assistant are editable blocks now, so
|
||||
each page.json carries its own chrome rather than the layout doing
|
||||
it. Keeps what the editor shows identical to what the route ships. */}
|
||||
<body className="font-body antialiased bg-background text-foreground m-0 p-0 flex min-h-screen flex-col">
|
||||
<div className="flex-1">{children}</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-lg font-medium text-black font-sans whitespace-nowrap">
|
||||
404
|
||||
</div>
|
||||
<div className="border-l border-gray-300 h-6"></div>
|
||||
<div className="text-sm text-gray-700 font-sans max-w-lg">
|
||||
This page could not be found.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+20
-229
@@ -1,249 +1,40 @@
|
||||
{
|
||||
"root": {
|
||||
"props": {
|
||||
"title": "Pulse — Made to Move",
|
||||
"headerFont": "Archivo Black",
|
||||
"headerFontWeight": "400",
|
||||
"bodyFont": "Inter",
|
||||
"primaryColor": "#111111",
|
||||
"secondaryColor": "#707072",
|
||||
"accentColor": "#f5f5f5",
|
||||
"bgColor": "#ffffff",
|
||||
"fgColor": "#111111",
|
||||
"mutedColor": "#f5f5f5",
|
||||
"radius": "xl",
|
||||
"buttonRadius": "full",
|
||||
"shadow": "none",
|
||||
"maxWidth": "2xl"
|
||||
"title": "Shop",
|
||||
"description": "An agent-friendly Shopify storefront built with Next.js and Hydrogen.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "nav-home",
|
||||
"brand": "PULSE",
|
||||
"links": [
|
||||
{
|
||||
"label": "Run",
|
||||
"href": "/collections/run"
|
||||
},
|
||||
{
|
||||
"label": "Train",
|
||||
"href": "/collections/train"
|
||||
},
|
||||
{
|
||||
"label": "Recover",
|
||||
"href": "/collections/recover"
|
||||
},
|
||||
{
|
||||
"label": "Sale",
|
||||
"href": "/collections/sale"
|
||||
},
|
||||
{
|
||||
"label": "About",
|
||||
"href": "/about"
|
||||
}
|
||||
],
|
||||
"showSearch": "yes",
|
||||
"showAccount": "yes",
|
||||
"showCart": "yes",
|
||||
"sticky": "yes",
|
||||
"tone": "default"
|
||||
}
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "cover",
|
||||
"type": "products",
|
||||
"props": {
|
||||
"id": "hero-home",
|
||||
"tagline": "Spring 2026",
|
||||
"heading": "Made to move.",
|
||||
"subheading": "Performance gear engineered for athletes who train like they mean it. Built light. Built honest. Built for the next mile.",
|
||||
"buttons": [
|
||||
{
|
||||
"label": "Shop the kit",
|
||||
"href": "/search",
|
||||
"variant": "primary"
|
||||
},
|
||||
{
|
||||
"label": "Our story",
|
||||
"href": "/about",
|
||||
"variant": "secondary"
|
||||
}
|
||||
],
|
||||
"imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/athletic-assortment.jpeg",
|
||||
"align": "left",
|
||||
"height": "lg",
|
||||
"tone": "light"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "products-carousel",
|
||||
"props": {
|
||||
"id": "carousel-home",
|
||||
"tagline": "Just dropped",
|
||||
"heading": "New arrivals",
|
||||
"subheading": "Fresh kit for the season ahead.",
|
||||
"limit": 12,
|
||||
"slidesPerView": "4",
|
||||
"ctaLabel": "Shop new",
|
||||
"ctaHref": "/collections/new"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "cover",
|
||||
"props": {
|
||||
"id": "cover-home-performance",
|
||||
"tagline": "Performance Series",
|
||||
"heading": "Engineered to outlast you.",
|
||||
"subheading": "Tested on track, trail, and treadmill. Every piece earns its place.",
|
||||
"buttons": [],
|
||||
"imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/cover-performance.jpeg",
|
||||
"align": "left",
|
||||
"height": "lg",
|
||||
"tone": "light"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "cover",
|
||||
"props": {
|
||||
"id": "cover-home-discipline",
|
||||
"tagline": "Discipline",
|
||||
"heading": "The work happens before sunrise.",
|
||||
"subheading": "We make the kit. You do the work. Five-thirty isn't early — it's the only honest hour.",
|
||||
"buttons": [],
|
||||
"imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/cover-discipline.jpeg",
|
||||
"align": "left",
|
||||
"height": "lg",
|
||||
"tone": "light"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "collection-grid",
|
||||
"props": {
|
||||
"id": "collections-home",
|
||||
"tagline": "Shop by sport",
|
||||
"heading": "Built for what you actually do.",
|
||||
"subheading": "",
|
||||
"layout": "tiles",
|
||||
"limit": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "newsletter-cta",
|
||||
"props": {
|
||||
"id": "newsletter-home",
|
||||
"tagline": "Field notes",
|
||||
"heading": "Weekly notes from the lab.",
|
||||
"subheading": "Test results, training blocks, gear we cut and gear we kept. Every Monday at 5:30am.",
|
||||
"buttonLabel": "Subscribe",
|
||||
"endpoint": "",
|
||||
"imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/newsletter.png",
|
||||
"layout": "split"
|
||||
"id": "home-products",
|
||||
"title": "Shopify Hydrogen Storefront",
|
||||
"subtitle": "An agent-friendly Shopify storefront built with Next.js and Hydrogen.",
|
||||
"collection": null,
|
||||
"limit": 12
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer-home",
|
||||
"brand": "PULSE",
|
||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
||||
"columns": [
|
||||
{
|
||||
"title": "Shop",
|
||||
"links": [
|
||||
{
|
||||
"label": "All gear",
|
||||
"href": "/search"
|
||||
},
|
||||
{
|
||||
"label": "New",
|
||||
"href": "/collections/new"
|
||||
},
|
||||
{
|
||||
"label": "Run",
|
||||
"href": "/collections/run"
|
||||
},
|
||||
{
|
||||
"label": "Train",
|
||||
"href": "/collections/train"
|
||||
},
|
||||
{
|
||||
"label": "Sale",
|
||||
"href": "/collections/sale"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "About",
|
||||
"links": [
|
||||
{
|
||||
"label": "Our story",
|
||||
"href": "/about"
|
||||
},
|
||||
{
|
||||
"label": "The lab",
|
||||
"href": "/lab"
|
||||
},
|
||||
{
|
||||
"label": "Athletes",
|
||||
"href": "/athletes"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Help",
|
||||
"links": [
|
||||
{
|
||||
"label": "Shipping",
|
||||
"href": "/help/shipping"
|
||||
},
|
||||
{
|
||||
"label": "Returns",
|
||||
"href": "/help/returns"
|
||||
},
|
||||
{
|
||||
"label": "Lifetime guarantee",
|
||||
"href": "/help/guarantee"
|
||||
},
|
||||
{
|
||||
"label": "Contact",
|
||||
"href": "/contact"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Legal",
|
||||
"links": [
|
||||
{
|
||||
"label": "Terms",
|
||||
"href": "/terms"
|
||||
},
|
||||
{
|
||||
"label": "Privacy",
|
||||
"href": "/privacy"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"social": [
|
||||
{
|
||||
"label": "Instagram",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Strava",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "YouTube",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"showNewsletter": "no",
|
||||
"newsletterHeading": "",
|
||||
"newsletterEndpoint": "",
|
||||
"copyright": "© 2026 Pulse. Built to be replaced when used up."
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
import PageRender from "@/components/page-render";
|
||||
import page from "./page.json";
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
import page from './page.json';
|
||||
|
||||
export const metadata = pageMetadata(page, { path: '/' });
|
||||
|
||||
export default function Page() {
|
||||
return <PageRender page={page} />;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import PageEditor from '@/components/page-editor';
|
||||
|
||||
// Editor for /policies/[handle]. Sitting under the route it edits means the preview
|
||||
// resolves the same params the public page gets.
|
||||
export default function EditorPage() {
|
||||
return <PageEditor routeKey="/policies/[handle]" />;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"root": {
|
||||
"props": {
|
||||
"title": "Policy",
|
||||
"description": "Store policies.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "policy",
|
||||
"props": {
|
||||
"id": "policy-body",
|
||||
"handle": "",
|
||||
"title": "",
|
||||
"notFoundMessage": "This policy has not been published yet."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
import { getShopPolicy, POLICY_HANDLES } from '@/hooks/use-shopify-policies';
|
||||
import { site } from '@/config/site';
|
||||
import page from './page.json';
|
||||
|
||||
export function generateStaticParams() {
|
||||
return POLICY_HANDLES.map((handle) => ({ handle }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ handle: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { handle } = await params;
|
||||
const policy = await getShopPolicy(handle).catch(() => null);
|
||||
|
||||
if (!policy) return pageMetadata(page);
|
||||
|
||||
return pageMetadata(page, {
|
||||
title: policy.title,
|
||||
description: `Read the ${policy.title.toLowerCase()} for ${site.storeName}.`,
|
||||
path: `/policies/${policy.handle}`,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ handle: string }>;
|
||||
}) {
|
||||
const { handle } = await params;
|
||||
|
||||
// Keep the 404 for handles Shopify doesn't know, rather than rendering the
|
||||
// policy block's "not published yet" state for a genuinely bad URL.
|
||||
if (!POLICY_HANDLES.includes(handle as (typeof POLICY_HANDLES)[number])) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <PageRender page={page} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import PageEditor from '@/components/page-editor';
|
||||
|
||||
// Editor for /products/[handle]. Sitting under the route it edits means the preview
|
||||
// resolves the same params the public page gets.
|
||||
export default function EditorPage() {
|
||||
return <PageEditor routeKey="/products/[handle]" />;
|
||||
}
|
||||
+26
-107
@@ -1,128 +1,47 @@
|
||||
{
|
||||
"root": {
|
||||
"props": {
|
||||
"title": "Pulse",
|
||||
"headerFont": "Archivo Black",
|
||||
"headerFontWeight": "400",
|
||||
"bodyFont": "Inter",
|
||||
"primaryColor": "#111111",
|
||||
"secondaryColor": "#707072",
|
||||
"accentColor": "#f5f5f5",
|
||||
"bgColor": "#ffffff",
|
||||
"fgColor": "#111111",
|
||||
"mutedColor": "#f5f5f5",
|
||||
"radius": "xl",
|
||||
"buttonRadius": "full",
|
||||
"shadow": "none",
|
||||
"maxWidth": "2xl"
|
||||
"title": "Product",
|
||||
"description": "Shop this product.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "nav-product",
|
||||
"brand": "PULSE",
|
||||
"links": [
|
||||
{
|
||||
"label": "Run",
|
||||
"href": "/collections/run"
|
||||
},
|
||||
{
|
||||
"label": "Train",
|
||||
"href": "/collections/train"
|
||||
},
|
||||
{
|
||||
"label": "Recover",
|
||||
"href": "/collections/recover"
|
||||
},
|
||||
{
|
||||
"label": "Sale",
|
||||
"href": "/collections/sale"
|
||||
},
|
||||
{
|
||||
"label": "About",
|
||||
"href": "/about"
|
||||
}
|
||||
],
|
||||
"showSearch": "yes",
|
||||
"showAccount": "yes",
|
||||
"showCart": "yes",
|
||||
"sticky": "yes",
|
||||
"tone": "default"
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "product-detail",
|
||||
"props": {
|
||||
"id": "product-detail",
|
||||
"product": null,
|
||||
"addToCartLabel": "Add to Cart"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "product-details",
|
||||
"type": "product-recommendations",
|
||||
"props": {
|
||||
"id": "product-details",
|
||||
"product": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "products-carousel",
|
||||
"props": {
|
||||
"id": "carousel-related",
|
||||
"tagline": "Pairs well with",
|
||||
"heading": "Complete the kit",
|
||||
"limit": 8,
|
||||
"slidesPerView": "4",
|
||||
"ctaLabel": "Shop all",
|
||||
"ctaHref": "/collections"
|
||||
"id": "product-recommendations",
|
||||
"title": "You May Also Like",
|
||||
"product": null,
|
||||
"limit": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer-product",
|
||||
"brand": "PULSE",
|
||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
||||
"columns": [
|
||||
{
|
||||
"title": "Shop",
|
||||
"links": [
|
||||
{
|
||||
"label": "All gear",
|
||||
"href": "/search"
|
||||
},
|
||||
{
|
||||
"label": "New",
|
||||
"href": "/collections/new"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Help",
|
||||
"links": [
|
||||
{
|
||||
"label": "Shipping",
|
||||
"href": "/help/shipping"
|
||||
},
|
||||
{
|
||||
"label": "Returns",
|
||||
"href": "/help/returns"
|
||||
},
|
||||
{
|
||||
"label": "Lifetime guarantee",
|
||||
"href": "/help/guarantee"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"social": [
|
||||
{
|
||||
"label": "Instagram",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Strava",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"showNewsletter": "no",
|
||||
"newsletterHeading": "",
|
||||
"newsletterEndpoint": "",
|
||||
"copyright": "© 2026 Pulse. Built to be replaced when used up."
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
import PageRender from "@/components/page-render";
|
||||
import page from "./page.json";
|
||||
import type { Metadata } from 'next';
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
import { getProduct } from '@/services/shopify/catalog';
|
||||
import { truncate } from '@/lib/utils';
|
||||
import page from './page.json';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ handle: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { handle } = await params;
|
||||
|
||||
// The blocks fetch client-side and render their own empty state, so a failed
|
||||
// lookup falls back to the page.json tags rather than a 500.
|
||||
const product = await getProduct(handle).catch(() => null);
|
||||
if (!product) return pageMetadata(page);
|
||||
|
||||
return pageMetadata(page, {
|
||||
title: product.title,
|
||||
description: product.description
|
||||
? truncate(product.description, 160)
|
||||
: undefined,
|
||||
image: product.images.edges[0]?.node.url,
|
||||
path: `/products/${product.handle}`,
|
||||
});
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <PageRender page={page} />;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { ShopifyProvider } from "@/contexts/shopify-context";
|
||||
|
||||
const SHOPIFY_DOMAIN =
|
||||
process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? "mock.shop";
|
||||
const STOREFRONT_TOKEN =
|
||||
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? "";
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<ShopifyProvider domain={SHOPIFY_DOMAIN} token={STOREFRONT_TOKEN}>
|
||||
{children}
|
||||
</ShopifyProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import PageEditor from '@/components/page-editor';
|
||||
|
||||
// Editor for /search. Sitting under the route it edits means the preview
|
||||
// resolves the same params the public page gets.
|
||||
export default function EditorPage() {
|
||||
return <PageEditor routeKey="/search" />;
|
||||
}
|
||||
+17
-141
@@ -1,161 +1,37 @@
|
||||
{
|
||||
"root": {
|
||||
"props": {
|
||||
"title": "Shop — Pulse",
|
||||
"headerFont": "Archivo Black",
|
||||
"headerFontWeight": "400",
|
||||
"bodyFont": "Inter",
|
||||
"primaryColor": "#111111",
|
||||
"secondaryColor": "#707072",
|
||||
"accentColor": "#f5f5f5",
|
||||
"bgColor": "#ffffff",
|
||||
"fgColor": "#111111",
|
||||
"mutedColor": "#f5f5f5",
|
||||
"radius": "xl",
|
||||
"buttonRadius": "full",
|
||||
"shadow": "none",
|
||||
"maxWidth": "2xl"
|
||||
"title": "Search",
|
||||
"description": "Search the catalogue by product name, description or type.",
|
||||
"ogImage": ""
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "header",
|
||||
"props": {
|
||||
"id": "nav-search",
|
||||
"brand": "PULSE",
|
||||
"links": [
|
||||
{
|
||||
"label": "Run",
|
||||
"href": "/collections/run"
|
||||
},
|
||||
{
|
||||
"label": "Train",
|
||||
"href": "/collections/train"
|
||||
},
|
||||
{
|
||||
"label": "Recover",
|
||||
"href": "/collections/recover"
|
||||
},
|
||||
{
|
||||
"label": "Sale",
|
||||
"href": "/collections/sale"
|
||||
},
|
||||
{
|
||||
"label": "About",
|
||||
"href": "/about"
|
||||
}
|
||||
],
|
||||
"showSearch": "yes",
|
||||
"showAccount": "yes",
|
||||
"showCart": "yes",
|
||||
"sticky": "yes",
|
||||
"tone": "default"
|
||||
}
|
||||
"id": "header"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "search-products",
|
||||
"type": "search-results",
|
||||
"props": {
|
||||
"id": "search-products",
|
||||
"heading": "All gear.",
|
||||
"subheading": "Filter by sport, fabric, fit, color. Or sort by what's been beaten the hardest.",
|
||||
"columns": "4",
|
||||
"limit": 24,
|
||||
"showAvailability": "yes",
|
||||
"showPriceRange": "yes",
|
||||
"showProductType": "yes",
|
||||
"showVendor": "no",
|
||||
"showTags": "yes",
|
||||
"metafieldFilters": [],
|
||||
"defaultSort": "BEST_SELLING",
|
||||
"productTypeOptions": [
|
||||
{
|
||||
"label": "Tops"
|
||||
},
|
||||
{
|
||||
"label": "Shorts"
|
||||
},
|
||||
{
|
||||
"label": "Tights"
|
||||
},
|
||||
{
|
||||
"label": "Outerwear"
|
||||
},
|
||||
{
|
||||
"label": "Shoes"
|
||||
},
|
||||
{
|
||||
"label": "Accessories"
|
||||
}
|
||||
],
|
||||
"vendorOptions": [],
|
||||
"tagOptions": [
|
||||
{
|
||||
"label": "New"
|
||||
},
|
||||
{
|
||||
"label": "Race day"
|
||||
},
|
||||
{
|
||||
"label": "Field tested"
|
||||
},
|
||||
{
|
||||
"label": "Sale"
|
||||
}
|
||||
]
|
||||
"id": "search-results",
|
||||
"title": "Search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"props": {
|
||||
"id": "footer-search",
|
||||
"brand": "PULSE",
|
||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
||||
"columns": [
|
||||
{
|
||||
"title": "Shop",
|
||||
"links": [
|
||||
{
|
||||
"label": "All gear",
|
||||
"href": "/search"
|
||||
},
|
||||
{
|
||||
"label": "Run",
|
||||
"href": "/collections/run"
|
||||
},
|
||||
{
|
||||
"label": "Train",
|
||||
"href": "/collections/train"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Help",
|
||||
"links": [
|
||||
{
|
||||
"label": "Shipping",
|
||||
"href": "/help/shipping"
|
||||
},
|
||||
{
|
||||
"label": "Returns",
|
||||
"href": "/help/returns"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"social": [
|
||||
{
|
||||
"label": "Instagram",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"label": "Strava",
|
||||
"href": "#"
|
||||
}
|
||||
],
|
||||
"showNewsletter": "no",
|
||||
"newsletterHeading": "",
|
||||
"newsletterEndpoint": "",
|
||||
"copyright": "© 2026 Pulse. Built to be replaced when used up."
|
||||
"id": "footer"
|
||||
},
|
||||
"synced": true
|
||||
},
|
||||
{
|
||||
"type": "store-assistant",
|
||||
"props": {
|
||||
"id": "store-assistant"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+13
-3
@@ -1,6 +1,16 @@
|
||||
import PageRender from "@/components/page-render";
|
||||
import page from "./page.json";
|
||||
import { Suspense } from 'react';
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
import page from './page.json';
|
||||
|
||||
export const metadata = pageMetadata(page, { path: '/search' });
|
||||
|
||||
export default function Page() {
|
||||
return <PageRender page={page} />;
|
||||
return (
|
||||
// The search-results block reads useSearchParams, which needs a Suspense
|
||||
// boundary to prerender this route.
|
||||
<Suspense fallback={<div className="py-10" />}>
|
||||
<PageRender page={page} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from 'next';
|
||||
import PageRender from '@/components/page-render';
|
||||
import { pageMetadata } from '@/lib/page-metadata';
|
||||
// Alias of /collections — same page.json, so editing that route updates both.
|
||||
import page from '../../collections/page.json';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
...pageMetadata(page),
|
||||
// Point crawlers at the canonical listing.
|
||||
alternates: { canonical: '/collections' },
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <PageRender page={page} />;
|
||||
}
|
||||
Reference in New Issue
Block a user