diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..afffa50 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Shopify Storefront +NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop +# NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN= +# NEXT_PUBLIC_SHOPIFY_API_VERSION=2026-07 + +# Store assistant — set to 1 to show the Ask launcher; anything else hides it +NEXT_PUBLIC_ENABLE_AI=0 + +# Store assistant (/api/chat) — https://openrouter.ai/keys +OPENROUTER_API_KEY= +# Optional; defaults to openai/gpt-5.6-luna-pro +# OPENROUTER_MODEL= diff --git a/.gitignore b/.gitignore index f1010f6..9fbf2db 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,14 @@ dist .env.local .env.*.local *.log + +# Build caches +tsconfig.tsbuildinfo +next-env.d.ts + +# Yarn (Berry) — keep the lockfile and .yarnrc.yml, ignore local state +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +.pnp.* diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..85f9ebe --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,9 @@ +nodeLinker: node-modules + +# Yarn quarantines npm releases younger than `npmMinimalAgeGate` (24h by +# default). The @reacteditor alpha channel and the Hydrogen preview build are +# both pinned to deliberately-recent versions, so preapprove just those rather +# than dropping the age gate for the whole dependency tree. +npmPreapprovedPackages: + - "@reacteditor/*" + - "@shopify/hydrogen" diff --git a/app.globals.json b/app.globals.json index 0967ef4..fb8688f 100644 --- a/app.globals.json +++ b/app.globals.json @@ -1 +1,53 @@ -{} +{ + "header": { + "props": { + "storeName": "Shop", + "logoUrl": "", + "showAnnouncement": "yes", + "announcement": "Free shipping on orders over $200", + "announcementUrl": "", + "links": [ + { + "label": "Products", + "url": "/" + }, + { + "label": "Collections", + "url": "/collections" + } + ] + } + }, + "footer": { + "props": { + "storeName": "Shop", + "logoUrl": "", + "copyright": "\u00a9 2026 Shop. All rights reserved.", + "links": [ + { + "label": "Terms of Service", + "url": "/policies/terms-of-service" + }, + { + "label": "Privacy Policy", + "url": "/policies/privacy-policy" + }, + { + "label": "Refund Policy", + "url": "/policies/refund-policy" + }, + { + "label": "Shipping Policy", + "url": "/policies/shipping-policy" + }, + { + "label": "Subscription Policy", + "url": "/policies/subscription-policy" + } + ], + "instagramUrl": "#", + "tiktokUrl": "#", + "facebookUrl": "#" + } + } +} diff --git a/app/about/editor/page.tsx b/app/about/editor/page.tsx new file mode 100644 index 0000000..ae096ec --- /dev/null +++ b/app/about/editor/page.tsx @@ -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 ; +} diff --git a/app/about/page.json b/app/about/page.json index db34682..a18df4b 100644 --- a/app/about/page.json +++ b/app/about/page.json @@ -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" } } ] diff --git a/app/about/page.tsx b/app/about/page.tsx index a8ca74a..a4fde3e 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -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 ; diff --git a/app/account/activate/[id]/[token]/editor/page.tsx b/app/account/activate/[id]/[token]/editor/page.tsx new file mode 100644 index 0000000..d47147d --- /dev/null +++ b/app/account/activate/[id]/[token]/editor/page.tsx @@ -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 ; +} diff --git a/app/account/activate/[id]/[token]/page.json b/app/account/activate/[id]/[token]/page.json new file mode 100644 index 0000000..1e4b575 --- /dev/null +++ b/app/account/activate/[id]/[token]/page.json @@ -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" + } + } + ] +} diff --git a/app/account/activate/[id]/[token]/page.tsx b/app/account/activate/[id]/[token]/page.tsx new file mode 100644 index 0000000..c1b1279 --- /dev/null +++ b/app/account/activate/[id]/[token]/page.tsx @@ -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 ; +} diff --git a/app/account/editor/page.tsx b/app/account/editor/page.tsx new file mode 100644 index 0000000..de5b899 --- /dev/null +++ b/app/account/editor/page.tsx @@ -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 ; +} diff --git a/app/account/login/editor/page.tsx b/app/account/login/editor/page.tsx new file mode 100644 index 0000000..97bc32c --- /dev/null +++ b/app/account/login/editor/page.tsx @@ -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 ; +} diff --git a/app/account/login/page.json b/app/account/login/page.json new file mode 100644 index 0000000..3e60439 --- /dev/null +++ b/app/account/login/page.json @@ -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" + } + } + ] +} diff --git a/app/account/login/page.tsx b/app/account/login/page.tsx new file mode 100644 index 0000000..14a813b --- /dev/null +++ b/app/account/login/page.tsx @@ -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 ; +} diff --git a/app/account/page.json b/app/account/page.json new file mode 100644 index 0000000..f316f05 --- /dev/null +++ b/app/account/page.json @@ -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" + } + } + ] +} diff --git a/app/account/page.tsx b/app/account/page.tsx new file mode 100644 index 0000000..91536bf --- /dev/null +++ b/app/account/page.tsx @@ -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 ; +} diff --git a/app/account/recover/editor/page.tsx b/app/account/recover/editor/page.tsx new file mode 100644 index 0000000..9795655 --- /dev/null +++ b/app/account/recover/editor/page.tsx @@ -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 ; +} diff --git a/app/account/recover/page.json b/app/account/recover/page.json new file mode 100644 index 0000000..9efe589 --- /dev/null +++ b/app/account/recover/page.json @@ -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" + } + } + ] +} diff --git a/app/account/recover/page.tsx b/app/account/recover/page.tsx new file mode 100644 index 0000000..5b99121 --- /dev/null +++ b/app/account/recover/page.tsx @@ -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 ; +} diff --git a/app/account/register/editor/page.tsx b/app/account/register/editor/page.tsx new file mode 100644 index 0000000..868bbb7 --- /dev/null +++ b/app/account/register/editor/page.tsx @@ -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 ; +} diff --git a/app/account/register/page.json b/app/account/register/page.json new file mode 100644 index 0000000..88d1ba3 --- /dev/null +++ b/app/account/register/page.json @@ -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" + } + } + ] +} diff --git a/app/account/register/page.tsx b/app/account/register/page.tsx new file mode 100644 index 0000000..c814158 --- /dev/null +++ b/app/account/register/page.tsx @@ -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 ; +} diff --git a/app/account/reset/[id]/[token]/editor/page.tsx b/app/account/reset/[id]/[token]/editor/page.tsx new file mode 100644 index 0000000..f3e2620 --- /dev/null +++ b/app/account/reset/[id]/[token]/editor/page.tsx @@ -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 ; +} diff --git a/app/account/reset/[id]/[token]/page.json b/app/account/reset/[id]/[token]/page.json new file mode 100644 index 0000000..3999413 --- /dev/null +++ b/app/account/reset/[id]/[token]/page.json @@ -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" + } + } + ] +} diff --git a/app/account/reset/[id]/[token]/page.tsx b/app/account/reset/[id]/[token]/page.tsx new file mode 100644 index 0000000..c1b1279 --- /dev/null +++ b/app/account/reset/[id]/[token]/page.tsx @@ -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 ; +} diff --git a/app/api/account/activate/route.ts b/app/api/account/activate/route.ts new file mode 100644 index 0000000..98e77c0 --- /dev/null +++ b/app/api/account/activate/route.ts @@ -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 }); +} diff --git a/app/api/account/login/route.ts b/app/api/account/login/route.ts new file mode 100644 index 0000000..27eea71 --- /dev/null +++ b/app/api/account/login/route.ts @@ -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 }); +} diff --git a/app/api/account/logout/route.ts b/app/api/account/logout/route.ts new file mode 100644 index 0000000..175c55e --- /dev/null +++ b/app/api/account/logout/route.ts @@ -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 }); +} diff --git a/app/api/account/me/route.ts b/app/api/account/me/route.ts new file mode 100644 index 0000000..46e1950 --- /dev/null +++ b/app/api/account/me/route.ts @@ -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, + }, + }); +} diff --git a/app/api/account/orders/route.ts b/app/api/account/orders/route.ts new file mode 100644 index 0000000..24e84ea --- /dev/null +++ b/app/api/account/orders/route.ts @@ -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 }); +} diff --git a/app/api/account/recover/route.ts b/app/api/account/recover/route.ts new file mode 100644 index 0000000..15fc1c9 --- /dev/null +++ b/app/api/account/recover/route.ts @@ -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 }); +} diff --git a/app/api/account/register/route.ts b/app/api/account/register/route.ts new file mode 100644 index 0000000..9ed72a2 --- /dev/null +++ b/app/api/account/register/route.ts @@ -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 }); +} diff --git a/app/api/account/reset/route.ts b/app/api/account/reset/route.ts new file mode 100644 index 0000000..96ada16 --- /dev/null +++ b/app/api/account/reset/route.ts @@ -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 }); +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000..a9ea3e2 --- /dev/null +++ b/app/api/chat/route.ts @@ -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 }); +} diff --git a/app/api/media/[id]/route.ts b/app/api/media/[id]/route.ts deleted file mode 100644 index 120bb21..0000000 --- a/app/api/media/[id]/route.ts +++ /dev/null @@ -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", - }, - }); -} diff --git a/app/api/media/route.ts b/app/api/media/route.ts deleted file mode 100644 index 9b58e95..0000000 --- a/app/api/media/route.ts +++ /dev/null @@ -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", - }, - }); -} diff --git a/app/api/pages/route.ts b/app/api/pages/route.ts new file mode 100644 index 0000000..68e3437 --- /dev/null +++ b/app/api/pages/route.ts @@ -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 | 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; + + 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 } + ); + } +} diff --git a/app/collections/[handle]/editor/page.tsx b/app/collections/[handle]/editor/page.tsx new file mode 100644 index 0000000..f36a08b --- /dev/null +++ b/app/collections/[handle]/editor/page.tsx @@ -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 ; +} diff --git a/app/collections/[handle]/page.json b/app/collections/[handle]/page.json index f3935b4..b58fbff 100644 --- a/app/collections/[handle]/page.json +++ b/app/collections/[handle]/page.json @@ -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" } } ] diff --git a/app/collections/[handle]/page.tsx b/app/collections/[handle]/page.tsx index a8ca74a..8ca6453 100644 --- a/app/collections/[handle]/page.tsx +++ b/app/collections/[handle]/page.tsx @@ -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 { + 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 ; diff --git a/app/collections/editor/page.tsx b/app/collections/editor/page.tsx new file mode 100644 index 0000000..ec8c0b8 --- /dev/null +++ b/app/collections/editor/page.tsx @@ -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 ; +} diff --git a/app/collections/page.json b/app/collections/page.json new file mode 100644 index 0000000..4d83b85 --- /dev/null +++ b/app/collections/page.json @@ -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" + } + } + ] +} diff --git a/app/collections/page.tsx b/app/collections/page.tsx new file mode 100644 index 0000000..ba062a1 --- /dev/null +++ b/app/collections/page.tsx @@ -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 ; +} diff --git a/app/editor/page.tsx b/app/editor/page.tsx new file mode 100644 index 0000000..98c3119 --- /dev/null +++ b/app/editor/page.tsx @@ -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 ; +} diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..6739a11 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,25 @@ +"use client"; + +import React from 'react'; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
+
+ Something went wrong +
+
+
+ {error.message} +
+
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index a116567..9615c91 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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); } \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index dd3ec4d..9967943 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - - - {children} + + {/* 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. */} + +
{children}
); diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..5be10c6 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,20 @@ +"use client"; + +import React from 'react'; +import Link from 'next/link'; + +export default function NotFound() { + return ( +
+
+
+ 404 +
+
+
+ This page could not be found. +
+
+
+ ); +} diff --git a/app/page.json b/app/page.json index a6bb3e3..2c35f27 100644 --- a/app/page.json +++ b/app/page.json @@ -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" } } ] diff --git a/app/page.tsx b/app/page.tsx index a8ca74a..98b9832 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 ; diff --git a/app/policies/[handle]/editor/page.tsx b/app/policies/[handle]/editor/page.tsx new file mode 100644 index 0000000..1660611 --- /dev/null +++ b/app/policies/[handle]/editor/page.tsx @@ -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 ; +} diff --git a/app/policies/[handle]/page.json b/app/policies/[handle]/page.json new file mode 100644 index 0000000..d8e28f8 --- /dev/null +++ b/app/policies/[handle]/page.json @@ -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" + } + } + ] +} diff --git a/app/policies/[handle]/page.tsx b/app/policies/[handle]/page.tsx new file mode 100644 index 0000000..e154db9 --- /dev/null +++ b/app/policies/[handle]/page.tsx @@ -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 { + 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 ; +} diff --git a/app/products/[handle]/editor/page.tsx b/app/products/[handle]/editor/page.tsx new file mode 100644 index 0000000..9c6e8f2 --- /dev/null +++ b/app/products/[handle]/editor/page.tsx @@ -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 ; +} diff --git a/app/products/[handle]/page.json b/app/products/[handle]/page.json index 04505a9..6a8dcc0 100644 --- a/app/products/[handle]/page.json +++ b/app/products/[handle]/page.json @@ -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" } } ] diff --git a/app/products/[handle]/page.tsx b/app/products/[handle]/page.tsx index a8ca74a..337de48 100644 --- a/app/products/[handle]/page.tsx +++ b/app/products/[handle]/page.tsx @@ -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 { + 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 ; diff --git a/app/providers.tsx b/app/providers.tsx deleted file mode 100644 index 32090a3..0000000 --- a/app/providers.tsx +++ /dev/null @@ -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 ( - - {children} - - ); -} diff --git a/app/search/editor/page.tsx b/app/search/editor/page.tsx new file mode 100644 index 0000000..f101259 --- /dev/null +++ b/app/search/editor/page.tsx @@ -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 ; +} diff --git a/app/search/page.json b/app/search/page.json index 626a347..c883d01 100644 --- a/app/search/page.json +++ b/app/search/page.json @@ -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" } } ] diff --git a/app/search/page.tsx b/app/search/page.tsx index a8ca74a..da0a407 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -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 ; + return ( + // The search-results block reads useSearchParams, which needs a Suspense + // boundary to prerender this route. + }> + + + ); } diff --git a/app/shop/collections/page.tsx b/app/shop/collections/page.tsx new file mode 100644 index 0000000..82c6364 --- /dev/null +++ b/app/shop/collections/page.tsx @@ -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 ; +} diff --git a/components.json b/components.json index f382eb7..c5d8163 100644 --- a/components.json +++ b/components.json @@ -1,6 +1,6 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "base-nova", + "style": "new-york", "rsc": true, "tsx": true, "tailwind": { @@ -11,7 +11,6 @@ "prefix": "" }, "iconLibrary": "lucide", - "rtl": false, "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -19,7 +18,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "menuColor": "default", - "menuAccent": "subtle", - "registries": {} + "registries": { + "@magicui": "https://magicui.design/r/{name}" + } } diff --git a/components/Heading.tsx b/components/Heading.tsx deleted file mode 100644 index bbcfc7f..0000000 --- a/components/Heading.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import * as React from "react"; -import { cn } from "@/lib/utils"; -import { Typography, type TypographyVariant } from "@/components/Typography"; - -export type HeadingSize = "sm" | "md" | "lg" | "xl"; -export type HeadingAlign = "left" | "center"; -export type HeadingTone = "default" | "light"; - -type SizeMap = { - title: TypographyVariant; - subtitle: TypographyVariant; - taglineGap: string; - subtitleGap: string; -}; - -const sizeMap: Record = { - sm: { - title: "h4", - subtitle: "subtitle2", - taglineGap: "mb-2", - subtitleGap: "mt-2", - }, - md: { - title: "h3", - subtitle: "subtitle1", - taglineGap: "mb-3", - subtitleGap: "mt-3", - }, - lg: { - title: "h2", - subtitle: "subtitle1", - taglineGap: "mb-3", - subtitleGap: "mt-3", - }, - xl: { - title: "h1", - subtitle: "subtitle1", - taglineGap: "mb-4", - subtitleGap: "mt-4", - }, -}; - -const alignClasses: Record = { - left: "items-start text-left", - center: "items-center text-center", -}; - -export type HeadingProps = { - tagline?: React.ReactNode; - title?: React.ReactNode; - subtitle?: React.ReactNode; - size?: HeadingSize; - align?: HeadingAlign; - tone?: HeadingTone; - className?: string; - titleClassName?: string; - subtitleClassName?: string; - taglineClassName?: string; - maxWidth?: string; -}; - -export function Heading({ - tagline, - title, - subtitle, - size = "lg", - align = "left", - tone = "default", - className, - titleClassName, - subtitleClassName, - taglineClassName, - maxWidth, -}: HeadingProps) { - if (!tagline && !title && !subtitle) return null; - const map = sizeMap[size]; - const isLight = tone === "light"; - - return ( -
- {tagline ? ( - - {tagline} - - ) : null} - {title ? ( - - {title} - - ) : null} - {subtitle ? ( - - {subtitle} - - ) : null} -
- ); -} - -export default Heading; diff --git a/components/Typography.tsx b/components/Typography.tsx deleted file mode 100644 index 6758975..0000000 --- a/components/Typography.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import * as React from "react"; -import { cn } from "@/lib/utils"; - -export type TypographyVariant = - | "h1" - | "h2" - | "h3" - | "h4" - | "h5" - | "h6" - | "subtitle1" - | "subtitle2" - | "body1" - | "body2" - | "caption"; - -const sizeClasses: Record = { - h1: "text-5xl md:text-6xl lg:text-7xl tracking-tight leading-[1.05]", - h2: "text-4xl md:text-5xl tracking-tight leading-[1.1]", - h3: "text-3xl md:text-4xl tracking-tight leading-tight", - h4: "text-2xl md:text-3xl tracking-tight leading-snug", - h5: "text-xl md:text-2xl leading-snug", - h6: "text-lg md:text-xl leading-snug", - subtitle1: "text-lg md:text-xl leading-relaxed text-muted-foreground", - subtitle2: "text-base md:text-lg leading-relaxed text-muted-foreground", - body1: "text-lg leading-relaxed", - body2: "text-base leading-relaxed", - caption: "text-xs font-bold uppercase tracking-[0.2em] text-muted-foreground", -}; - -// Inline-style fallbacks so headings size correctly even when Tailwind -// preflight resets

..

to font-size: inherit and the iframe CDN -// hasn't compiled utility classes yet. The Tailwind classes above still -// apply on top once available (responsive breakpoints, leading, etc.). -const sizeStyles: Record = { - h1: { fontSize: "clamp(2.5rem, 6vw, 4.5rem)", lineHeight: 1.05, letterSpacing: "-0.02em" }, - h2: { fontSize: "clamp(2rem, 4vw, 3rem)", lineHeight: 1.1, letterSpacing: "-0.02em" }, - h3: { fontSize: "clamp(1.75rem, 3.5vw, 2.25rem)", lineHeight: 1.15, letterSpacing: "-0.015em" }, - h4: { fontSize: "clamp(1.5rem, 3vw, 1.875rem)", lineHeight: 1.2, letterSpacing: "-0.01em" }, - h5: { fontSize: "1.5rem", lineHeight: 1.25 }, - h6: { fontSize: "1.25rem", lineHeight: 1.3 }, - subtitle1: { fontSize: "1.125rem", lineHeight: 1.6 }, - subtitle2: { fontSize: "1rem", lineHeight: 1.6 }, - body1: { fontSize: "1.125rem", lineHeight: 1.6 }, - body2: { fontSize: "1rem", lineHeight: 1.6 }, - caption: { fontSize: "0.75rem", lineHeight: 1.5, fontWeight: 700, letterSpacing: "0.2em", textTransform: "uppercase" }, -}; - -const defaultTag: Record = { - h1: "h1", - h2: "h2", - h3: "h3", - h4: "h4", - h5: "h5", - h6: "h6", - subtitle1: "p", - subtitle2: "p", - body1: "p", - body2: "p", - caption: "p", -}; - -type Props = { - variant: TypographyVariant; - as?: C; - className?: string; - style?: React.CSSProperties; - children?: React.ReactNode; -} & Omit, "className" | "children" | "style">; - -export function Typography({ - variant, - as, - className, - style, - children, - ...rest -}: Props) { - const Tag = (as ?? defaultTag[variant]) as keyof JSX.IntrinsicElements; - const isHeading = variant.startsWith("h"); - const fontClass = isHeading ? "font-heading" : "font-body"; - - // Apply the heading font + weight inline so the styling holds even when - // the rendered element isn't h1–h6 (e.g. via the `as` prop). The - // ThemeProvider's element-scoped CSS rule only matches real h-tags, and - // the `font-heading` Tailwind utility can't be relied on in CDN mode. - const fontStyles: React.CSSProperties = isHeading - ? { - fontFamily: "var(--font-header), system-ui, sans-serif", - fontWeight: "var(--font-weight-header, 600)", - } - : {}; - - return React.createElement( - Tag, - { - className: cn(fontClass, sizeClasses[variant], className), - style: { ...fontStyles, ...sizeStyles[variant], ...style }, - ...rest, - }, - children, - ); -} - -export default Typography; diff --git a/components/ai-elements/attachments.tsx b/components/ai-elements/attachments.tsx new file mode 100644 index 0000000..d4e4e34 --- /dev/null +++ b/components/ai-elements/attachments.tsx @@ -0,0 +1,426 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { cn } from "@/lib/utils"; +import type { FileUIPart, SourceDocumentUIPart } from "ai"; +import { + FileTextIcon, + GlobeIcon, + ImageIcon, + Music2Icon, + PaperclipIcon, + VideoIcon, + XIcon, +} from "lucide-react"; +import type { ComponentProps, HTMLAttributes, ReactNode } from "react"; +import { createContext, useCallback, useContext, useMemo } from "react"; + +// ============================================================================ +// Types +// ============================================================================ + +export type AttachmentData = + | (FileUIPart & { id: string }) + | (SourceDocumentUIPart & { id: string }); + +export type AttachmentMediaCategory = + | "image" + | "video" + | "audio" + | "document" + | "source" + | "unknown"; + +export type AttachmentVariant = "grid" | "inline" | "list"; + +const mediaCategoryIcons: Record = { + audio: Music2Icon, + document: FileTextIcon, + image: ImageIcon, + source: GlobeIcon, + unknown: PaperclipIcon, + video: VideoIcon, +}; + +// ============================================================================ +// Utility Functions +// ============================================================================ + +export const getMediaCategory = ( + data: AttachmentData +): AttachmentMediaCategory => { + if (data.type === "source-document") { + return "source"; + } + + const mediaType = data.mediaType ?? ""; + + if (mediaType.startsWith("image/")) { + return "image"; + } + if (mediaType.startsWith("video/")) { + return "video"; + } + if (mediaType.startsWith("audio/")) { + return "audio"; + } + if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) { + return "document"; + } + + return "unknown"; +}; + +export const getAttachmentLabel = (data: AttachmentData): string => { + if (data.type === "source-document") { + return data.title || data.filename || "Source"; + } + + const category = getMediaCategory(data); + return data.filename || (category === "image" ? "Image" : "Attachment"); +}; + +const renderAttachmentImage = ( + url: string, + filename: string | undefined, + isGrid: boolean +) => + isGrid ? ( + {filename + ) : ( + {filename + ); + +// ============================================================================ +// Contexts +// ============================================================================ + +interface AttachmentsContextValue { + variant: AttachmentVariant; +} + +const AttachmentsContext = createContext(null); + +interface AttachmentContextValue { + data: AttachmentData; + mediaCategory: AttachmentMediaCategory; + onRemove?: () => void; + variant: AttachmentVariant; +} + +const AttachmentContext = createContext(null); + +// ============================================================================ +// Hooks +// ============================================================================ + +export const useAttachmentsContext = () => + useContext(AttachmentsContext) ?? { variant: "grid" as const }; + +export const useAttachmentContext = () => { + const ctx = useContext(AttachmentContext); + if (!ctx) { + throw new Error("Attachment components must be used within "); + } + return ctx; +}; + +// ============================================================================ +// Attachments - Container +// ============================================================================ + +export type AttachmentsProps = HTMLAttributes & { + variant?: AttachmentVariant; +}; + +export const Attachments = ({ + variant = "grid", + className, + children, + ...props +}: AttachmentsProps) => { + const contextValue = useMemo(() => ({ variant }), [variant]); + + return ( + +
+ {children} +
+
+ ); +}; + +// ============================================================================ +// Attachment - Item +// ============================================================================ + +export type AttachmentProps = HTMLAttributes & { + data: AttachmentData; + onRemove?: () => void; +}; + +export const Attachment = ({ + data, + onRemove, + className, + children, + ...props +}: AttachmentProps) => { + const { variant } = useAttachmentsContext(); + const mediaCategory = getMediaCategory(data); + + const contextValue = useMemo( + () => ({ data, mediaCategory, onRemove, variant }), + [data, mediaCategory, onRemove, variant] + ); + + return ( + +
+ {children} +
+
+ ); +}; + +// ============================================================================ +// AttachmentPreview - Media preview +// ============================================================================ + +export type AttachmentPreviewProps = HTMLAttributes & { + fallbackIcon?: ReactNode; +}; + +export const AttachmentPreview = ({ + fallbackIcon, + className, + ...props +}: AttachmentPreviewProps) => { + const { data, mediaCategory, variant } = useAttachmentContext(); + + const iconSize = variant === "inline" ? "size-3" : "size-4"; + + const renderIcon = (Icon: typeof ImageIcon) => ( + + ); + + const renderContent = () => { + if (mediaCategory === "image" && data.type === "file" && data.url) { + return renderAttachmentImage(data.url, data.filename, variant === "grid"); + } + + if (mediaCategory === "video" && data.type === "file" && data.url) { + return