Add React editor project

This commit is contained in:
Rami Bitar
2026-08-09 16:00:10 -04:00
parent 909d99251a
commit 63ecc5e284
212 changed files with 20655 additions and 11571 deletions
+12
View File
@@ -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=
+11
View File
@@ -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.*
+9
View File
@@ -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"
+53 -1
View File
@@ -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": "#"
}
}
}
+7
View File
@@ -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" />;
}
+19 -167
View File
@@ -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"
"id": "header"
},
"synced": true
},
{
"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"
}
},
{
"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 stores 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"
"id": "footer"
},
"synced": true
},
{
"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."
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
+5 -2
View File
@@ -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} />;
}
+7
View File
@@ -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" />;
}
+7
View File
@@ -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" />;
}
+52
View File
@@ -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"
}
}
]
}
+9
View File
@@ -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} />;
}
+41
View File
@@ -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"
}
}
]
}
+16
View File
@@ -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} />;
}
+7
View File
@@ -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" />;
}
+48
View File
@@ -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"
}
}
]
}
+9
View File
@@ -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} />;
}
+7
View File
@@ -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" />;
}
+48
View File
@@ -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"
}
}
]
}
+9
View File
@@ -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]" />;
}
+43
View File
@@ -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"
}
}
]
}
+9
View File
@@ -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} />;
}
+30
View File
@@ -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 });
}
+31
View File
@@ -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 });
}
+10
View File
@@ -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 });
}
+19
View File
@@ -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,
},
});
}
+26
View File
@@ -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 });
}
+10
View File
@@ -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 });
}
+39
View File
@@ -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 });
}
+27
View File
@@ -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 });
}
+207
View File
@@ -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 });
}
-25
View File
@@ -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",
},
});
}
-47
View File
@@ -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",
},
});
}
+103
View File
@@ -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 }
);
}
}
+7
View File
@@ -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]" />;
}
+14 -170
View File
@@ -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"
"id": "header"
},
"synced": true
},
{
"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"
}
},
{
"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"
"id": "footer"
},
"synced": true
},
{
"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."
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
+33 -2
View File
@@ -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} />;
+7
View File
@@ -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" />;
}
+39
View File
@@ -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"
}
}
]
}
+9
View File
@@ -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} />;
}
+7
View File
@@ -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="/" />;
}
+25
View File
@@ -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>
);
}
+217 -136
View File
@@ -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
View File
@@ -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>
);
+20
View File
@@ -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>
);
}
+18 -227
View File
@@ -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"
"id": "header"
},
"synced": true
},
{
"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"
}
},
{
"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"
"id": "footer"
},
"synced": true
},
{
"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."
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
+5 -2
View File
@@ -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} />;
+7
View File
@@ -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]" />;
}
+40
View File
@@ -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"
}
}
]
}
+44
View File
@@ -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} />;
}
+7
View File
@@ -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]" />;
}
+21 -102
View File
@@ -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"
"id": "header"
},
"synced": true
},
{
"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"
}
},
{
"type": "product-details",
"type": "product-detail",
"props": {
"id": "product-details",
"product": null
"id": "product-detail",
"product": null,
"addToCartLabel": "Add to Cart"
}
},
{
"type": "products-carousel",
"type": "product-recommendations",
"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"
"id": "footer"
},
"synced": true
},
{
"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."
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
+28 -2
View File
@@ -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} />;
-21
View File
@@ -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>
);
}
+7
View File
@@ -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" />;
}
+15 -139
View File
@@ -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"
"id": "header"
},
"synced": true
},
{
"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"
}
},
{
"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"
"id": "footer"
},
"synced": true
},
{
"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."
"type": "store-assistant",
"props": {
"id": "store-assistant"
}
}
]
+13 -3
View File
@@ -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>
);
}
+15
View File
@@ -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} />;
}
+4 -5
View File
@@ -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}"
}
}
-117
View File
@@ -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<HeadingSize, SizeMap> = {
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<HeadingAlign, string> = {
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 (
<div className={cn("flex flex-col", alignClasses[align], maxWidth, className)}>
{tagline ? (
<Typography
variant="caption"
className={cn(
map.taglineGap,
isLight && "text-background/70",
taglineClassName,
)}
>
{tagline}
</Typography>
) : null}
{title ? (
<Typography
variant={map.title}
className={cn(isLight && "text-background", titleClassName)}
>
{title}
</Typography>
) : null}
{subtitle ? (
<Typography
variant={map.subtitle}
className={cn(
map.subtitleGap,
isLight && "text-background/70",
subtitleClassName,
)}
>
{subtitle}
</Typography>
) : null}
</div>
);
}
export default Heading;
-105
View File
@@ -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<TypographyVariant, string> = {
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 <h1>..<h6> 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<TypographyVariant, React.CSSProperties> = {
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<TypographyVariant, keyof JSX.IntrinsicElements> = {
h1: "h1",
h2: "h2",
h3: "h3",
h4: "h4",
h5: "h5",
h6: "h6",
subtitle1: "p",
subtitle2: "p",
body1: "p",
body2: "p",
caption: "p",
};
type Props<C extends keyof JSX.IntrinsicElements = "p"> = {
variant: TypographyVariant;
as?: C;
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
} & Omit<React.ComponentPropsWithoutRef<C>, "className" | "children" | "style">;
export function Typography<C extends keyof JSX.IntrinsicElements = "p">({
variant,
as,
className,
style,
children,
...rest
}: Props<C>) {
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 h1h6 (e.g. <span> 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;
+426
View File
@@ -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<AttachmentMediaCategory, typeof ImageIcon> = {
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 ? (
<img
alt={filename || "Image"}
className="size-full object-cover"
height={96}
src={url}
width={96}
/>
) : (
<img
alt={filename || "Image"}
className="size-full rounded object-cover"
height={20}
src={url}
width={20}
/>
);
// ============================================================================
// Contexts
// ============================================================================
interface AttachmentsContextValue {
variant: AttachmentVariant;
}
const AttachmentsContext = createContext<AttachmentsContextValue | null>(null);
interface AttachmentContextValue {
data: AttachmentData;
mediaCategory: AttachmentMediaCategory;
onRemove?: () => void;
variant: AttachmentVariant;
}
const AttachmentContext = createContext<AttachmentContextValue | null>(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 <Attachment>");
}
return ctx;
};
// ============================================================================
// Attachments - Container
// ============================================================================
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
variant?: AttachmentVariant;
};
export const Attachments = ({
variant = "grid",
className,
children,
...props
}: AttachmentsProps) => {
const contextValue = useMemo(() => ({ variant }), [variant]);
return (
<AttachmentsContext.Provider value={contextValue}>
<div
className={cn(
"flex items-start",
variant === "list" ? "flex-col gap-2" : "flex-wrap gap-2",
variant === "grid" && "ml-auto w-fit",
className
)}
{...props}
>
{children}
</div>
</AttachmentsContext.Provider>
);
};
// ============================================================================
// Attachment - Item
// ============================================================================
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: AttachmentData;
onRemove?: () => void;
};
export const Attachment = ({
data,
onRemove,
className,
children,
...props
}: AttachmentProps) => {
const { variant } = useAttachmentsContext();
const mediaCategory = getMediaCategory(data);
const contextValue = useMemo<AttachmentContextValue>(
() => ({ data, mediaCategory, onRemove, variant }),
[data, mediaCategory, onRemove, variant]
);
return (
<AttachmentContext.Provider value={contextValue}>
<div
className={cn(
"group relative",
variant === "grid" && "size-24 overflow-hidden rounded-lg",
variant === "inline" && [
"flex h-8 cursor-pointer select-none items-center gap-1.5",
"rounded-md border border-border px-1.5",
"font-medium text-sm transition-all",
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
],
variant === "list" && [
"flex w-full items-center gap-3 rounded-lg border p-3",
"hover:bg-accent/50",
],
className
)}
{...props}
>
{children}
</div>
</AttachmentContext.Provider>
);
};
// ============================================================================
// AttachmentPreview - Media preview
// ============================================================================
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
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) => (
<Icon className={cn(iconSize, "text-muted-foreground")} />
);
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 <video className="size-full object-cover" muted src={data.url} />;
}
const Icon = mediaCategoryIcons[mediaCategory];
return fallbackIcon ?? renderIcon(Icon);
};
return (
<div
className={cn(
"flex shrink-0 items-center justify-center overflow-hidden",
variant === "grid" && "size-full bg-muted",
variant === "inline" && "size-5 rounded bg-background",
variant === "list" && "size-12 rounded bg-muted",
className
)}
{...props}
>
{renderContent()}
</div>
);
};
// ============================================================================
// AttachmentInfo - Name and type display
// ============================================================================
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
showMediaType?: boolean;
};
export const AttachmentInfo = ({
showMediaType = false,
className,
...props
}: AttachmentInfoProps) => {
const { data, variant } = useAttachmentContext();
const label = getAttachmentLabel(data);
if (variant === "grid") {
return null;
}
return (
<div className={cn("min-w-0 flex-1", className)} {...props}>
<span className="block truncate">{label}</span>
{showMediaType && data.mediaType && (
<span className="block truncate text-muted-foreground text-xs">
{data.mediaType}
</span>
)}
</div>
);
};
// ============================================================================
// AttachmentRemove - Remove button
// ============================================================================
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
label?: string;
};
export const AttachmentRemove = ({
label = "Remove",
className,
children,
...props
}: AttachmentRemoveProps) => {
const { onRemove, variant } = useAttachmentContext();
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onRemove?.();
},
[onRemove]
);
if (!onRemove) {
return null;
}
return (
<Button
aria-label={label}
className={cn(
variant === "grid" && [
"absolute top-2 right-2 size-6 rounded-full p-0",
"bg-background/80 backdrop-blur-sm",
"opacity-0 transition-opacity group-hover:opacity-100",
"hover:bg-background",
"[&>svg]:size-3",
],
variant === "inline" && [
"size-5 rounded p-0",
"opacity-0 transition-opacity group-hover:opacity-100",
"[&>svg]:size-2.5",
],
variant === "list" && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"],
className
)}
onClick={handleClick}
type="button"
variant="ghost"
{...props}
>
{children ?? <XIcon />}
<span className="sr-only">{label}</span>
</Button>
);
};
// ============================================================================
// AttachmentHoverCard - Hover preview
// ============================================================================
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
export const AttachmentHoverCard = ({
openDelay = 0,
closeDelay = 0,
...props
}: AttachmentHoverCardProps) => (
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
);
export type AttachmentHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger
>;
export const AttachmentHoverCardTrigger = (
props: AttachmentHoverCardTriggerProps
) => <HoverCardTrigger {...props} />;
export type AttachmentHoverCardContentProps = ComponentProps<
typeof HoverCardContent
>;
export const AttachmentHoverCardContent = ({
align = "start",
className,
...props
}: AttachmentHoverCardContentProps) => (
<HoverCardContent
align={align}
className={cn("w-auto p-2", className)}
{...props}
/>
);
// ============================================================================
// AttachmentEmpty - Empty state
// ============================================================================
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
export const AttachmentEmpty = ({
className,
children,
...props
}: AttachmentEmptyProps) => (
<div
className={cn(
"flex items-center justify-center p-4 text-muted-foreground text-sm",
className
)}
{...props}
>
{children ?? "No attachments"}
</div>
);
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "ai";
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
const getMessageText = (message: UIMessage): string =>
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
export type ConversationDownloadProps = Omit<
ComponentProps<typeof Button>,
"onClick"
> & {
messages: UIMessage[];
filename?: string;
formatMessage?: (message: UIMessage, index: number) => string;
};
const defaultFormatMessage = (message: UIMessage): string => {
const roleLabel =
message.role.charAt(0).toUpperCase() + message.role.slice(1);
return `**${roleLabel}:** ${getMessageText(message)}`;
};
export const messagesToMarkdown = (
messages: UIMessage[],
formatMessage: (
message: UIMessage,
index: number
) => string = defaultFormatMessage
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
export const ConversationDownload = ({
messages,
filename = "conversation.md",
formatMessage = defaultFormatMessage,
className,
children,
...props
}: ConversationDownloadProps) => {
const handleDownload = useCallback(() => {
const markdown = messagesToMarkdown(messages, formatMessage);
const blob = new Blob([markdown], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}, [messages, filename, formatMessage]);
return (
<Button
className={cn(
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleDownload}
size="icon"
type="button"
variant="outline"
{...props}
>
{children ?? <DownloadIcon className="size-4" />}
</Button>
);
};
+295
View File
@@ -0,0 +1,295 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import Link from "next/link";
import type { Nodes } from "hast";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import type { Components } from "hast-util-to-jsx-runtime";
import type { ComponentProps, HTMLAttributes, MouseEvent } from "react";
import { Fragment, memo, useCallback, useMemo, useState } from "react";
import { jsx, jsxs } from "react/jsx-runtime";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import remend from "remend";
import { unified } from "unified";
// Raw HTML never reaches the tree: remark-rehype drops it (allowDangerousHtml
// is off by default) and rehype-sanitize is the second line of defence, mainly
// for its href protocol allow-list — that is what stops `javascript:` URLs.
// Relative hrefs carry no protocol, so storefront links pass through untouched.
const schema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
// GFM task lists render as disabled checkboxes. The default schema allows
// `type` and `disabled` but not `checked`, so every box would read unticked.
input: [...(defaultSchema.attributes?.input ?? []), "checked"],
},
};
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeSanitize, schema);
// `text-only` leaves a half-streamed `[label](htt` as plain text. remend's
// default instead emits a `streamdown:incomplete-link` placeholder href, which
// the sanitizer would strip anyway.
const REMEND_OPTIONS = { linkMode: "text-only" } as const;
// remend only ever runs on text that is still arriving. On finished text it can
// do damage: it reads `*$89*` as an unclosed italic (the `$` throws off its
// closing-delimiter scan) and appends a stray `*`, which then parses as an empty
// bullet. Prices in italics are ordinary storefront copy, so settled messages
// render verbatim and only the in-flight one gets repaired.
const repair = (markdown: string, isStreaming: boolean) =>
isStreaming ? remend(markdown, REMEND_OPTIONS) : markdown;
export interface LinkSafetyConfig {
enabled: boolean;
/** Return true for links that may open without a confirmation step. */
onLinkCheck?: (url: string) => boolean;
}
type AnchorProps = ComponentProps<"a"> & {
linkSafety?: LinkSafetyConfig;
onUntrusted: (url: string) => void;
};
const LINK_CLASS =
"font-medium underline underline-offset-4 hover:text-foreground";
// Route-relative hrefs are the storefront links the assistant emits. Deciding
// this from the string alone keeps the server and client passes identical;
// `linkSafety.onLinkCheck` does the origin-aware check later, at click time.
const isRouteHref = (href: string) => /^[/#?]/.test(href);
const MarkdownLink = ({
href,
linkSafety,
onUntrusted,
...props
}: AnchorProps) => {
// Runs on click rather than on render so it can read `window.location`,
// which is unavailable during the server pass.
const handleClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
if (!href || !linkSafety?.enabled || !linkSafety.onLinkCheck) return;
if (linkSafety.onLinkCheck(href)) return;
event.preventDefault();
onUntrusted(href);
},
[href, linkSafety, onUntrusted]
);
// In-app destinations navigate client-side in the same tab; sending a shopper
// to a product page in a new tab would strand the conversation behind it.
if (href && isRouteHref(href)) {
return <Link className={LINK_CLASS} href={href} {...props} />;
}
return (
<a
className={LINK_CLASS}
href={href}
onClick={handleClick}
rel="noreferrer"
target="_blank"
{...props}
/>
);
};
// Fenced code renders as plain preformatted text — no tokenizer, no themes.
const buildComponents = (
linkSafety: LinkSafetyConfig | undefined,
onUntrusted: (url: string) => void
): Partial<Components> => ({
a: (props: ComponentProps<"a">) => (
<MarkdownLink {...props} linkSafety={linkSafety} onUntrusted={onUntrusted} />
),
blockquote: (props: ComponentProps<"blockquote">) => (
<blockquote
className="my-3 border-border border-l-2 pl-3 text-muted-foreground"
{...props}
/>
),
code: ({ className, ...props }: ComponentProps<"code">) => {
// Only fenced blocks carry a language class, and those already sit inside a
// <pre>, so they must not get the inline pill treatment.
const isBlock =
typeof className === "string" && className.includes("language-");
return (
<code
className={cn(
isBlock
? "font-mono text-xs"
: "rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
className
)}
{...props}
/>
);
},
em: (props: ComponentProps<"em">) => <em className="italic" {...props} />,
h1: (props: ComponentProps<"h1">) => (
<h1 className="mt-4 mb-2 font-semibold text-base" {...props} />
),
h2: (props: ComponentProps<"h2">) => (
<h2 className="mt-4 mb-2 font-semibold text-base" {...props} />
),
h3: (props: ComponentProps<"h3">) => (
<h3 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h4: (props: ComponentProps<"h4">) => (
<h4 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h5: (props: ComponentProps<"h5">) => (
<h5 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h6: (props: ComponentProps<"h6">) => (
<h6 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
hr: (props: ComponentProps<"hr">) => (
<hr className="my-4 border-border" {...props} />
),
// GFM task-list boxes are display only; readOnly silences React's warning
// about a `checked` input with no change handler.
input: (props: ComponentProps<"input">) => (
<input className="mr-1.5 align-middle" readOnly {...props} />
),
img: ({ alt, ...props }: ComponentProps<"img">) => (
// biome-ignore lint/nursery/noImgElement: model output, not a known asset
<img alt={alt ?? ""} className="my-2 max-w-full rounded-md" {...props} />
),
li: (props: ComponentProps<"li">) => <li className="my-0.5" {...props} />,
ol: (props: ComponentProps<"ol">) => (
<ol className="my-2 list-decimal space-y-0.5 pl-5" {...props} />
),
p: (props: ComponentProps<"p">) => (
<p className="my-2 leading-relaxed" {...props} />
),
pre: (props: ComponentProps<"pre">) => (
<pre
className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs"
{...props}
/>
),
strong: (props: ComponentProps<"strong">) => (
<strong className="font-semibold" {...props} />
),
table: (props: ComponentProps<"table">) => (
<div className="my-3 overflow-x-auto">
<table className="w-full border-collapse text-left text-xs" {...props} />
</div>
),
td: (props: ComponentProps<"td">) => (
<td className="border border-border px-2 py-1" {...props} />
),
th: (props: ComponentProps<"th">) => (
<th
className="border border-border bg-muted px-2 py-1 font-medium"
{...props}
/>
),
ul: (props: ComponentProps<"ul">) => (
<ul className="my-2 list-disc space-y-0.5 pl-5" {...props} />
),
});
export type MarkdownProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
children: string;
/** True while tokens are still arriving; enables incomplete-syntax repair. */
isAnimating?: boolean;
linkSafety?: LinkSafetyConfig;
};
export const Markdown = memo(
({
children,
className,
isAnimating,
linkSafety,
...props
}: MarkdownProps) => {
const [pending, setPending] = useState<string | null>(null);
const handleUntrusted = useCallback((url: string) => setPending(url), []);
const content = useMemo(() => {
// While streaming, close syntax the model has not finished emitting so a
// partial `**bold` renders as bold rather than as literal asterisks.
const source = repair(children ?? "", isAnimating === true);
const tree = processor.runSync(processor.parse(source)) as Nodes;
return toJsxRuntime(tree, {
components: buildComponents(linkSafety, handleUntrusted),
Fragment,
jsx,
jsxs,
});
}, [children, isAnimating, linkSafety, handleUntrusted]);
return (
<div
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
{...props}
>
{content}
<Dialog
onOpenChange={(open) => !open && setPending(null)}
open={pending !== null}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Leave this site?</DialogTitle>
<DialogDescription>
This link points somewhere outside the store. Continue only if
you trust it.
</DialogDescription>
</DialogHeader>
<p className="break-all rounded-md bg-muted px-3 py-2 font-mono text-xs">
{pending}
</p>
<DialogFooter>
<Button onClick={() => setPending(null)} variant="outline">
Cancel
</Button>
<Button
onClick={() => {
if (pending) {
window.open(pending, "_blank", "noopener,noreferrer");
}
setPending(null);
}}
>
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
},
(prev, next) =>
prev.children === next.children && prev.isAnimating === next.isAnimating
);
Markdown.displayName = "Markdown";
+339
View File
@@ -0,0 +1,339 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ButtonGroup,
ButtonGroupText,
} from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { Markdown } from "./markdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
interface MessageBranchContextType {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
}
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch"
);
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = useCallback(
(newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
},
[onBranchChange]
);
const goToPrevious = useCallback(() => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const goToNext = useCallback(() => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const contextValue = useMemo<MessageBranchContextType>(
() => ({
branches,
currentBranch,
goToNext,
goToPrevious,
setBranches,
totalBranches: branches.length,
}),
[branches, currentBranch, goToNext, goToPrevious]
);
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({
children,
...props
}: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = useMemo(
() => (Array.isArray(children) ? children : [children]),
[children]
);
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
export const MessageBranchSelector = ({
className,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className={cn(
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
className
)}
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Markdown>;
// Markdown already memoises on children/isAnimating.
export const MessageResponse = Markdown;
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
)}
{...props}
>
{children}
</div>
);
File diff suppressed because it is too large Load Diff
+224
View File
@@ -0,0 +1,224 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Markdown } from "./markdown";
import { Shimmer } from "./shimmer";
interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
export const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
const isExplicitlyClosed = defaultOpen === false;
const [isOpen, setIsOpen] = useControllableState<boolean>({
defaultProp: resolvedDefaultOpen,
onChange: onOpenChange,
prop: open,
});
const [duration, setDuration] = useControllableState<number | undefined>({
defaultProp: undefined,
prop: durationProp,
});
const hasEverStreamedRef = useRef(isStreaming);
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const startTimeRef = useRef<number | null>(null);
// Track when streaming starts and compute duration
useEffect(() => {
if (isStreaming) {
hasEverStreamedRef.current = true;
if (startTimeRef.current === null) {
startTimeRef.current = Date.now();
}
} else if (startTimeRef.current !== null) {
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
startTimeRef.current = null;
}
}, [isStreaming, setDuration]);
// Auto-open when streaming starts (unless explicitly closed)
useEffect(() => {
if (isStreaming && !isOpen && !isExplicitlyClosed) {
setIsOpen(true);
}
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
// Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => {
if (
hasEverStreamedRef.current &&
!isStreaming &&
isOpen &&
!hasAutoClosed
) {
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosed(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = useCallback(
(newOpen: boolean) => {
setIsOpen(newOpen);
},
[setIsOpen]
);
const contextValue = useMemo(
() => ({ duration, isOpen, isStreaming, setIsOpen }),
[duration, isOpen, isStreaming, setIsOpen]
);
return (
<ReasoningContext.Provider value={contextValue}>
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
};
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>;
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>;
}
return <p>Thought for {duration} seconds</p>;
};
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => {
const { isStreaming } = useReasoning();
return (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<Markdown isAnimating={isStreaming}>{children}</Markdown>
</CollapsibleContent>
);
}
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { cn } from "@/lib/utils";
import type { MotionProps } from "motion/react";
import { motion } from "motion/react";
import type { CSSProperties, ElementType, JSX } from "react";
import { memo, useMemo } from "react";
type MotionHTMLProps = MotionProps & Record<string, unknown>;
// Cache motion components at module level to avoid creating during render
const motionComponentCache = new Map<
keyof JSX.IntrinsicElements,
React.ComponentType<MotionHTMLProps>
>();
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
let component = motionComponentCache.get(element);
if (!component) {
component = motion.create(element);
motionComponentCache.set(element, component);
}
return component;
};
export interface TextShimmerProps {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
}
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = getMotionComponent(
Component as keyof JSX.IntrinsicElements
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread]
);
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
duration,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ScrollArea,
ScrollBar,
} from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import type { ComponentProps } from "react";
import { useCallback } from "react";
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
export const Suggestions = ({
className,
children,
...props
}: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
{children}
</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
);
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
suggestion: string;
onClick?: (suggestion: string) => void;
};
export const Suggestion = ({
suggestion,
onClick,
className,
variant = "outline",
size = "sm",
children,
...props
}: SuggestionProps) => {
const handleClick = useCallback(() => {
onClick?.(suggestion);
}, [onClick, suggestion]);
return (
<Button
className={cn("cursor-pointer rounded-full px-4", className)}
onClick={handleClick}
size={size}
type="button"
variant={variant}
{...props}
>
{children || suggestion}
</Button>
);
};
-82
View File
@@ -1,82 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { LayoutTemplate } from "lucide-react";
import { Cover, type CoverProps } from "@/components/cover/cover";
const coverEditor: ComponentConfig<CoverProps> = {
label: "Cover",
icon: <LayoutTemplate size={16} />,
category: "hero",
defaultProps: {
tagline: "Spring 2026",
heading: "Made for the way you move",
subheading:
"A considered wardrobe of essentials, cut from natural fibers and designed to last.",
buttons: [
{ label: "Shop the collection", href: "/collections", variant: "primary" },
{ label: "Our story", href: "/about", variant: "secondary" },
],
imageUrl:
"https://images.unsplash.com/photo-1490481651871-ab68de25d43d?auto=format&fit=crop&w=2400&q=80",
align: "left",
height: "lg",
tone: "dark",
},
fields: {
tagline: { label: "Tagline", type: "text", contentEditable: true },
heading: { label: "Heading", type: "textarea", contentEditable: true },
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
buttons: {
label: "Buttons",
type: "array",
arrayFields: {
label: { label: "Label", type: "text", contentEditable: true },
href: { label: "Link", type: "text" },
variant: {
label: "Variant",
type: "select",
options: [
{ label: "Primary (filled)", value: "primary" },
{ label: "Secondary (outline)", value: "secondary" },
{ label: "Outline", value: "outline" },
{ label: "Ghost", value: "ghost" },
],
},
},
defaultItemProps: {
label: "Button",
href: "/",
variant: "primary",
},
getItemSummary: (item) => item?.label || "Button",
},
imageUrl: { label: "Background image", type: "image" },
align: {
label: "Alignment",
type: "radio",
options: [
{ label: "Left", value: "left" },
{ label: "Center", value: "center" },
],
},
height: {
label: "Height",
type: "select",
options: [
{ label: "Medium", value: "md" },
{ label: "Large", value: "lg" },
{ label: "Full", value: "full" },
],
},
tone: {
label: "Tone",
type: "radio",
options: [
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
],
},
},
render: (props) => <Cover {...props} />,
};
export default coverEditor;
-141
View File
@@ -1,141 +0,0 @@
import Link from "next/link";
import { cn } from "@/lib/utils";
import { Typography } from "@/components/Typography";
export type CoverButtonVariant = "primary" | "secondary" | "outline" | "ghost";
export type CoverButton = {
label: string;
href: string;
variant: CoverButtonVariant;
};
export type CoverProps = {
tagline: string;
heading: string;
subheading: string;
buttons: CoverButton[];
imageUrl: string;
align: "left" | "center";
height: "md" | "lg" | "full";
tone: "light" | "dark";
};
const heightClass: Record<CoverProps["height"], string> = {
md: "min-h-[60vh]",
lg: "min-h-[80vh]",
full: "min-h-screen",
};
function buttonClass(variant: CoverButtonVariant, isDark: boolean): string {
switch (variant) {
case "primary":
return cn(
"inline-flex items-center justify-center rounded-md px-6 py-3 text-sm font-medium tracking-wide transition-opacity hover:opacity-90",
isDark ? "bg-white text-black" : "bg-foreground text-background",
);
case "secondary":
case "outline":
return cn(
"inline-flex items-center justify-center rounded-md border px-6 py-3 text-sm font-medium tracking-wide transition-opacity hover:opacity-80",
isDark ? "border-white text-white" : "border-foreground text-foreground",
);
case "ghost":
return cn(
"inline-flex items-center justify-center rounded-md px-6 py-3 text-sm font-medium tracking-wide transition-opacity hover:opacity-80",
isDark ? "text-white" : "text-foreground",
);
}
}
export function Cover({
tagline,
heading,
subheading,
buttons,
imageUrl,
align,
height,
tone,
}: CoverProps) {
const isDark = tone === "dark";
const visibleButtons = (buttons ?? []).filter((b) => b?.label);
return (
<section
className={cn(
"relative flex w-full items-end overflow-hidden isolate",
heightClass[height],
)}
>
{imageUrl ? (
<img
src={imageUrl}
alt=""
className="absolute inset-0 z-0 h-full w-full object-cover"
/>
) : null}
<div
className="absolute inset-0 z-[1]"
style={{
background: isDark
? "linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.55) 100%)"
: "linear-gradient(180deg, rgba(255,255,255,0.0) 30%, rgba(255,255,255,0.85) 100%)",
}}
/>
<div
className={cn(
"container relative z-[2] mx-auto flex max-w-7xl flex-col px-6 py-20 md:py-28",
align === "center" ? "items-center text-center" : "items-start",
isDark ? "text-white" : "text-foreground",
)}
>
{tagline ? (
<Typography
variant="caption"
className={cn(
"mb-5",
isDark ? "text-white/80" : "text-foreground/70",
)}
>
{tagline}
</Typography>
) : null}
<Typography variant="h1" className="max-w-3xl">
{heading}
</Typography>
{subheading ? (
<Typography
variant="subtitle1"
className={cn(
"mt-6 max-w-xl",
isDark ? "text-white/80" : "text-foreground/70",
)}
>
{subheading}
</Typography>
) : null}
{visibleButtons.length > 0 ? (
<div
className={cn(
"mt-10 flex flex-wrap gap-3",
align === "center" && "justify-center",
)}
>
{visibleButtons.map((b, i) => (
<Link
key={`${b.href}-${b.label}-${i}`}
href={b.href || "#"}
className={buttonClass(b.variant, isDark)}
>
{b.label}
</Link>
))}
</div>
) : null}
</div>
</section>
);
}
-104
View File
@@ -1,104 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { LayoutGrid } from "lucide-react";
import { Footer, type FooterProps } from "@/components/footer/footer";
const footerEditor: ComponentConfig<FooterProps> = {
label: "Footer",
icon: <LayoutGrid size={16} />,
category: "footer",
global: true,
defaultProps: {
brand: "Maison",
tagline:
"Considered essentials, made in small batches and built to last beyond the season.",
columns: [
{
title: "Shop",
links: [
{ label: "All", href: "/collections" },
{ label: "New", href: "/collections/new" },
{ label: "Best sellers", href: "/collections/best" },
],
},
{
title: "About",
links: [
{ label: "Our story", href: "/about" },
{ label: "Materials", href: "/materials" },
{ label: "Journal", href: "/journal" },
],
},
{
title: "Help",
links: [
{ label: "Shipping", href: "/help/shipping" },
{ label: "Returns", href: "/help/returns" },
{ label: "Contact", href: "/contact" },
],
},
{
title: "Legal",
links: [
{ label: "Terms", href: "/terms" },
{ label: "Privacy", href: "/privacy" },
],
},
],
social: [
{ label: "Instagram", href: "#" },
{ label: "Pinterest", href: "#" },
{ label: "TikTok", href: "#" },
],
showNewsletter: "yes",
newsletterHeading: "Stay in touch",
newsletterEndpoint: "",
copyright: "© 2026 Maison. All rights reserved.",
},
fields: {
brand: { label: "Brand", type: "text", contentEditable: true },
tagline: { label: "Tagline", type: "textarea", contentEditable: true },
columns: {
label: "Columns",
type: "array",
defaultItemProps: { title: "Column", links: [] },
getItemSummary: (it) => it?.title || "Column",
arrayFields: {
title: { label: "Title", type: "text", contentEditable: true },
links: {
label: "Links",
type: "array",
defaultItemProps: { label: "Link", href: "/" },
getItemSummary: (it) => it?.label || "Link",
arrayFields: {
label: { label: "Label", type: "text", contentEditable: true },
href: { label: "Link", type: "text" },
},
},
},
},
social: {
label: "Social links",
type: "array",
defaultItemProps: { label: "Instagram", href: "#" },
getItemSummary: (it) => it?.label || "Social",
arrayFields: {
label: { label: "Label", type: "text", contentEditable: true },
href: { label: "Link", type: "text" },
},
},
showNewsletter: {
label: "Newsletter form",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
newsletterHeading: { label: "Newsletter heading", type: "text", contentEditable: true },
newsletterEndpoint: { label: "Newsletter endpoint", type: "text" },
copyright: { label: "Copyright", type: "text", contentEditable: true },
},
render: (props) => <Footer {...props} />,
};
export default footerEditor;
-130
View File
@@ -1,130 +0,0 @@
import { useState } from "react";
import Link from "next/link";
import { Typography } from "@/components/Typography";
import { Container } from "@/components/layout/Container";
export type FooterProps = {
brand: string;
tagline: string;
columns: Array<{
title: string;
links: Array<{ label: string; href: string }>;
}>;
social: Array<{ label: string; href: string }>;
showNewsletter: "yes" | "no";
newsletterHeading: string;
newsletterEndpoint: string;
copyright: string;
};
export function Footer({
brand,
tagline,
columns,
social,
showNewsletter,
newsletterHeading,
newsletterEndpoint,
copyright,
}: FooterProps) {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
if (newsletterEndpoint) {
try {
await fetch(newsletterEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
} catch {}
}
setSubmitted(true);
};
return (
<footer className="border-t border-border bg-background">
<Container className="py-20 md:py-24">
<div className="grid grid-cols-1 gap-12 md:grid-cols-12">
<div className="md:col-span-4">
<Typography variant="h5" as="p">
{brand}
</Typography>
{tagline ? (
<Typography variant="body2" className="mt-3 max-w-sm text-muted-foreground">
{tagline}
</Typography>
) : null}
{showNewsletter === "yes" ? (
<form onSubmit={submit} className="mt-8 max-w-sm">
<p className="text-sm font-medium">{newsletterHeading}</p>
{submitted ? (
<p className="mt-3 text-sm text-muted-foreground">
Thanks we'll be in touch.
</p>
) : (
<div className="mt-3 flex border-b border-border focus-within:border-foreground">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="flex-1 bg-transparent py-2 text-sm placeholder:text-muted-foreground focus:outline-none"
/>
<button
type="submit"
className="ml-3 text-sm font-medium tracking-wide hover:opacity-70"
>
Subscribe
</button>
</div>
)}
</form>
) : null}
</div>
<div className="grid grid-cols-2 gap-8 md:col-span-8 md:grid-cols-4">
{columns.map((col, i) => (
<div key={i}>
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
{col.title}
</p>
<ul className="mt-4 space-y-2.5">
{col.links.map((l, j) => (
<li key={j}>
<Link
href={l.href}
className="text-sm text-foreground/80 hover:text-foreground"
>
{l.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
<div className="mt-16 flex flex-col items-start justify-between gap-4 border-t border-border pt-8 md:flex-row md:items-center">
<p className="text-xs text-muted-foreground">{copyright}</p>
<div className="flex flex-wrap gap-x-5 gap-y-2">
{social.map((s, i) => (
<a
key={i}
href={s.href}
className="text-xs uppercase tracking-[0.18em] text-foreground/70 hover:text-foreground"
>
{s.label}
</a>
))}
</div>
</div>
</Container>
</footer>
);
}
-74
View File
@@ -1,74 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { Menu as MenuIcon } from "lucide-react";
import { Header, type HeaderProps } from "@/components/header/header";
const headerEditor: ComponentConfig<HeaderProps> = {
label: "Header",
icon: <MenuIcon size={16} />,
category: "navigation",
global: true,
defaultProps: {
brand: "Maison",
logo: "",
links: [
{ label: "Mens", href: "/collections/mens" },
{ label: "Womens", href: "/collections/womens" },
{ label: "Shop", href: "/search" },
{ label: "About", href: "/about" },
],
showSearch: "yes",
showCart: "yes",
sticky: "yes",
tone: "default",
},
fields: {
logo: { label: "Logo", type: "image" },
brand: { label: "Logo Alt", type: "text", contentEditable: true },
links: {
label: "Links",
type: "array",
defaultItemProps: { label: "Link", href: "/" },
getItemSummary: (it) => it?.label || "Link",
arrayFields: {
label: { label: "Label", type: "text", contentEditable: true },
href: { label: "Link", type: "text" },
},
},
showSearch: {
label: "Search icon",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
showCart: {
label: "Cart icon",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
sticky: {
label: "Position",
type: "radio",
options: [
{ label: "Sticky", value: "yes" },
{ label: "Static", value: "no" },
],
},
tone: {
label: "Tone",
type: "select",
options: [
{ label: "Default", value: "default" },
{ label: "Muted", value: "muted" },
{ label: "Inverse (dark)", value: "inverse" },
],
},
},
render: (props) => <Header {...props} />,
};
export default headerEditor;
-139
View File
@@ -1,139 +0,0 @@
import { Menu as MenuIcon, ShoppingBag, Search } from "lucide-react";
import { useState } from "react";
import Link from "next/link";
import { useShopifyCart } from "@/hooks/use-shopify-cart";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Container } from "@/components/layout/Container";
import { Typography } from "@/components/Typography";
import { cn } from "@/lib/utils";
export type HeaderProps = {
brand: string;
logo?: string;
links: Array<{ label: string; href: string }>;
showSearch: "yes" | "no";
showCart: "yes" | "no";
sticky: "yes" | "no";
tone: "default" | "muted" | "inverse";
};
export function Header({
brand,
logo,
links,
showSearch,
showCart,
sticky,
tone,
}: HeaderProps) {
const [mobileOpen, setMobileOpen] = useState(false);
const cart = useShopifyCart();
const itemCount = cart?.itemCount ?? 0;
const toneClass: Record<HeaderProps["tone"], string> = {
default: "bg-background text-foreground border-b border-border",
muted: "bg-muted/40 text-foreground border-b border-border",
inverse: "bg-foreground text-background",
};
return (
<>
<div
className={cn(
"w-full",
sticky === "yes" && "sticky top-0 z-40",
)}
>
<header
className={cn(
"w-full",
sticky === "yes" && "backdrop-blur",
toneClass[tone],
)}
>
<Container className="flex h-16 items-center justify-between md:h-20">
<Link href="/" className="inline-flex items-center">
{logo ? (
<img
src={logo}
alt={brand || "Brand Logo"}
className="h-8 w-auto object-contain"
/>
) : (
<Typography variant="h3" as="span">
{brand || "Brand Logo"}
</Typography>
)}
</Link>
<nav className="hidden items-center gap-8 md:flex">
{links.map((l) => (
<Link
key={l.href + l.label}
href={l.href}
className="text-sm tracking-wide opacity-80 transition-opacity hover:opacity-100"
>
{l.label}
</Link>
))}
</nav>
<div className="flex items-center gap-1">
{showSearch === "yes" && (
<Link
href="/search"
aria-label="Search"
className="hidden h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5 md:inline-flex"
>
<Search size={18} strokeWidth={1.5} />
</Link>
)}
{showCart === "yes" && (
<button
onClick={() => cart.openCart()}
aria-label="Cart"
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5"
>
<ShoppingBag size={18} strokeWidth={1.5} />
{itemCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-foreground px-1 text-[10px] font-medium text-background">
{itemCount}
</span>
)}
</button>
)}
<button
onClick={() => setMobileOpen(true)}
aria-label="Menu"
className="inline-flex h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5 md:hidden"
>
<MenuIcon size={20} strokeWidth={1.5} />
</button>
</div>
</Container>
</header>
</div>
{/* Mobile menu */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="right" className="w-[88vw] max-w-sm">
<SheetHeader>
<SheetTitle className="text-left">{brand}</SheetTitle>
</SheetHeader>
<nav className="mt-2 flex flex-col gap-1 px-4">
{links.map((l) => (
<Link
key={l.href + l.label}
href={l.href}
className="rounded-md px-3 py-3 text-base hover:bg-muted"
>
{l.label}
</Link>
))}
</nav>
</SheetContent>
</Sheet>
</>
);
}
-21
View File
@@ -1,21 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type ContainerProps = React.HTMLAttributes<HTMLElement> & {
as?: React.ElementType;
};
export function Container({
as: Comp = "div",
className,
style,
...props
}: ContainerProps) {
return (
<Comp
className={cn("mx-auto w-full px-6", className)}
style={{ maxWidth: "var(--container-max-width, 80rem)", ...style }}
{...props}
/>
);
}
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
import Link from 'next/link';
interface LogoProps {
/** Logo image URL. Falls back to the store name as a wordmark when absent. */
src?: string | null;
/** Wordmark text, and the image's alt text. */
storeName?: string;
/** Where the logo links to. Pass null to render it unwrapped. */
href?: string | null;
/** Sizing for the image — height only, so the aspect ratio is preserved. */
imageClassName?: string;
/** Sizing and weight for the wordmark fallback. */
textClassName?: string;
}
const Logo: React.FC<LogoProps> = ({
src,
storeName = 'Shop',
href = '/',
imageClassName = 'h-6',
textClassName = 'text-xl',
}) => {
const mark = src ? (
<img
src={src}
alt={storeName}
className={`w-auto object-contain ${imageClassName}`}
/>
) : (
<span
className={`font-medium tracking-tight text-foreground ${textClassName}`}
>
{storeName}
</span>
);
if (href === null) {
return <span className="flex items-center">{mark}</span>;
}
return (
<Link href={href} className="flex items-center">
{mark}
</Link>
);
};
export default Logo;
+40
View File
@@ -0,0 +1,40 @@
import React, { memo } from 'react';
import { cn } from '@/lib/utils';
interface AuroraTextProps {
children: React.ReactNode;
className?: string;
colors?: string[];
speed?: number;
}
export const AuroraText = memo(
({
children,
className,
colors = ['#FF0080', '#7928CA', '#0070F3', '#38bdf8'],
speed = 1,
}: AuroraTextProps) => {
const animationDuration = `${10 / speed}s`;
const gradientStyle = {
backgroundImage: `linear-gradient(90deg, ${colors.join(', ')}, ${colors[0]})`,
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
animation: `aurora-flow ${animationDuration} ease-in-out infinite`,
} as React.CSSProperties;
return (
<span className={cn('relative inline-block', className)}>
<span className="sr-only">{children}</span>
<span className="relative" style={gradientStyle} aria-hidden="true">
{children}
</span>
</span>
);
}
);
AuroraText.displayName = 'AuroraText';
+63
View File
@@ -0,0 +1,63 @@
import React from 'react';
import { useRef, useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
interface BlurFadeProps {
children: React.ReactNode;
className?: string;
duration?: number;
delay?: number;
inView?: boolean;
}
export function BlurFade({
children,
className,
duration = 0.4,
delay = 0,
inView = false,
}: BlurFadeProps) {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(!inView);
useEffect(() => {
if (!inView) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: '-50px' }
);
if (ref.current) {
observer.observe(ref.current);
}
return () => {
if (ref.current) {
observer.unobserve(ref.current);
}
};
}, [inView]);
return (
<div
ref={ref}
className={cn(
isVisible ? 'opacity-100 blur-none' : 'opacity-0 blur-sm',
className
)}
style={{
animation: isVisible
? `blur-fade ${duration}s ease-out ${delay}s forwards`
: 'none',
}}
>
{children}
</div>
);
}
@@ -1,80 +0,0 @@
import { ComponentConfig, Fields } from "@reacteditor/core";
import { Mail } from "lucide-react";
import { NewsletterCta, type NewsletterCtaProps } from "@/components/newsletter-cta/newsletter-cta";
const baseFields: Fields<NewsletterCtaProps> = {
tagline: { label: "Tagline", type: "text", contentEditable: true },
heading: { label: "Heading", type: "text", contentEditable: true },
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
buttonLabel: { label: "Button label", type: "text", contentEditable: true },
emailProvider: {
label: "Email provider",
type: "select",
options: [
{ label: "None (custom endpoint)", value: "none" },
{ label: "Mailchimp", value: "mailchimp" },
{ label: "Klaviyo", value: "klaviyo" },
],
},
imageUrl: { label: "Image", type: "image" },
layout: {
label: "Layout",
type: "radio",
options: [
{ label: "Split (image + form)", value: "split" },
{ label: "Stacked (centered)", value: "stacked" },
],
},
};
const newsletterCtaEditor: ComponentConfig<NewsletterCtaProps> = {
label: "Newsletter",
icon: <Mail size={16} />,
category: "content",
defaultProps: {
tagline: "Stay in the loop",
heading: "Letters from the studio",
subheading:
"New collections, mill stories, and the occasional invitation to in-person events. Twice a month.",
buttonLabel: "Subscribe",
emailProvider: "none",
endpoint: "",
mailchimpApiKey: "",
mailchimpServerPrefix: "",
mailchimpAudienceId: "",
klaviyoCompanyId: "",
klaviyoListId: "",
imageUrl:
"https://images.unsplash.com/photo-1469334031218-e382a71b716b?auto=format&fit=crop&w=1800&q=80",
layout: "split",
},
fields: baseFields,
resolveFields: (data) => {
const provider = data.props.emailProvider;
if (provider === "mailchimp") {
return {
...baseFields,
mailchimpApiKey: { label: "Mailchimp API key", type: "text" },
mailchimpServerPrefix: {
label: "Server prefix (e.g. us21)",
type: "text",
},
mailchimpAudienceId: { label: "Audience ID", type: "text" },
};
}
if (provider === "klaviyo") {
return {
...baseFields,
klaviyoCompanyId: { label: "Company ID (public API key)", type: "text" },
klaviyoListId: { label: "List ID", type: "text" },
};
}
return {
...baseFields,
endpoint: { label: "Submit endpoint", type: "text" },
};
},
render: (props) => <NewsletterCta {...props} />,
};
export default newsletterCtaEditor;
@@ -1,190 +0,0 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Container } from "@/components/layout/Container";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Heading } from "@/components/Heading";
export type EmailProvider = "none" | "mailchimp" | "klaviyo";
export type NewsletterCtaProps = {
tagline: string;
heading: string;
subheading: string;
buttonLabel: string;
emailProvider: EmailProvider;
endpoint?: string;
mailchimpApiKey?: string;
mailchimpServerPrefix?: string;
mailchimpAudienceId?: string;
klaviyoCompanyId?: string;
klaviyoListId?: string;
imageUrl: string;
layout: "split" | "stacked";
};
export function NewsletterCta({
tagline,
heading,
subheading,
buttonLabel,
emailProvider,
endpoint,
mailchimpApiKey,
mailchimpServerPrefix,
mailchimpAudienceId,
klaviyoCompanyId,
klaviyoListId,
imageUrl,
layout,
}: NewsletterCtaProps) {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
setSubmitting(true);
try {
if (emailProvider === "mailchimp") {
if (mailchimpServerPrefix && mailchimpAudienceId && mailchimpApiKey) {
await fetch(
`https://${mailchimpServerPrefix}.api.mailchimp.com/3.0/lists/${mailchimpAudienceId}/members`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${mailchimpApiKey}`,
},
body: JSON.stringify({
email_address: email,
status: "subscribed",
}),
},
);
}
} else if (emailProvider === "klaviyo") {
if (klaviyoCompanyId && klaviyoListId) {
await fetch(
`https://a.klaviyo.com/client/subscriptions/?company_id=${klaviyoCompanyId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
revision: "2024-10-15",
},
body: JSON.stringify({
data: {
type: "subscription",
attributes: {
profile: {
data: {
type: "profile",
attributes: { email },
},
},
},
relationships: {
list: { data: { type: "list", id: klaviyoListId } },
},
},
}),
},
);
}
} else if (endpoint) {
await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
}
setSubmitted(true);
} catch {
setSubmitted(true);
} finally {
setSubmitting(false);
}
};
const Form = (
<form
onSubmit={submit}
className="flex w-full max-w-md flex-col gap-3 sm:flex-row sm:items-center"
>
<Input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
className="h-11 flex-1"
/>
<Button type="submit" size="lg" disabled={submitting} className="h-11">
{submitting ? "Joining…" : buttonLabel}
</Button>
</form>
);
const isStacked = layout === "stacked";
return (
<section className="bg-background">
<Container className="py-20 md:py-28">
<div
className={cn(
"grid grid-cols-1 gap-10",
isStacked
? "mx-auto max-w-3xl items-center text-center"
: "items-center md:grid-cols-12 md:gap-16",
)}
>
{imageUrl ? (
<div className={cn(!isStacked && "md:col-span-7")}>
<div className="relative overflow-hidden rounded-xl bg-muted">
<img
src={imageUrl}
alt=""
className={cn(
"w-full object-cover transition-transform duration-700 hover:scale-105",
isStacked ? "aspect-[16/9]" : "aspect-[4/3]",
)}
/>
</div>
</div>
) : null}
<div
className={cn(
"flex w-full flex-col",
isStacked ? "items-center" : "items-start md:col-span-5",
)}
>
<Heading
tagline={tagline}
title={heading}
subtitle={subheading}
align={isStacked ? "center" : "left"}
size="lg"
subtitleClassName={isStacked ? "mx-auto max-w-xl" : "max-w-md"}
/>
<div
className={cn(
"mt-8 w-full",
isStacked && "flex justify-center",
)}
>
{submitted ? (
<p className="text-sm font-medium uppercase tracking-wide">
You're in. See you Monday at 5:30am.
</p>
) : (
Form
)}
</div>
</div>
</div>
</Container>
</section>
);
}
+164
View File
@@ -0,0 +1,164 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, usePathname, useRouter } from 'next/navigation';
import { Editor, outlinePlugin, type Data } from '@reacteditor/core';
import { Loader } from '@/components/ui/loader';
import createTailwindCdnPlugin from '@reacteditor/plugin-tailwind-cdn';
import { createShopifyPlugin } from '@reacteditor/plugin-shopify';
import { appConfig } from '@/editor.config';
import { ROUTE_KEYS, editorHref, findPageRoute } from '@/lib/pages';
// Plugin instances must keep a stable identity across renders, same as
// `appConfig`, so they are built once at module scope.
//
// The Tailwind CDN plugin only styles the editor's preview iframe; the public
// routes still get their utilities from the compiled `app/globals.css`.
const tailwindCdn = createTailwindCdnPlugin();
// Registers the `shopifyProduct` and `shopifyCollection` field types used by
// the commerce blocks. Credentials are the same public storefront pair the
// rendered components read — safe in the browser by definition.
const shopify = createShopifyPlugin({
storeDomain: process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? 'mock.shop',
publicAccessToken:
process.env.NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN ?? undefined,
apiVersion: process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION ?? '2026-07',
});
// Adds the outline panel — the tree of blocks on the page, for selecting and
// reordering without hunting through the preview.
const outline = outlinePlugin();
const plugins = [outline, tailwindCdn, shopify];
const EMPTY_PAGE: Data = { root: { props: { title: 'Untitled' } }, content: [] };
export interface PageEditorProps {
/** Route key from `lib/pages.ts`, e.g. `/products/[handle]`. */
routeKey: string;
}
/**
* Shared editor shell, mounted by each route's `editor/page.tsx` child. The
* public routes mount `PageRender` against the same `appConfig`, so the two
* never drift.
*
* Being a child of the route it edits is what makes the preview real: the
* editor sits on the same dynamic segments as the public page, so a block
* calling `useParams()` inside the preview iframe sees the actual handle from
* `/products/warrior-club-hoodie/editor` no stand-in data required.
*
* Data is read from and published back to the route's own `page.json` through
* `/api/pages`, which writes the file on disk.
*/
export default function PageEditor({ routeKey }: PageEditorProps) {
const router = useRouter();
const pathname = usePathname();
const params = useParams();
const [data, setData] = useState<Data | null>(null);
const [status, setStatus] = useState<string | null>(null);
// The editor lives at `<public path>/editor`; drop that segment to recover
// the page's own URL for the route descriptor and the URL bar.
const publicPath = useMemo(
() => (pathname ?? '/editor').replace(/\/editor\/?$/, '') || '/',
[pathname]
);
const routeParams = useMemo(() => {
const entries: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(params ?? {})) {
if (typeof value === 'string') entries[key] = value;
}
return entries;
}, [params]);
useEffect(() => {
let cancelled = false;
setData(null);
fetch(`/api/pages?route=${encodeURIComponent(routeKey)}`)
.then((response) => response.json())
.then((body) => {
if (!cancelled) setData(body.page ?? EMPTY_PAGE);
})
.catch(() => {
if (!cancelled) setData(EMPTY_PAGE);
});
return () => {
cancelled = true;
};
}, [routeKey]);
const handlePublish = useCallback(
async (published: Data) => {
setStatus('Saving…');
try {
const response = await fetch('/api/pages', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ route: routeKey, page: published }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
setStatus(body.error ?? 'Could not save this page.');
return;
}
setStatus(`Saved to ${body.file}`);
} catch {
setStatus('Could not reach the server.');
}
},
[routeKey]
);
// Wait for the fetch: handing <Editor> a placeholder and then swapping it
// would seed the undo history with a page the author never wrote.
//
// Uses the local `components/ui/loader`, not core's export of the same name:
// core's is built on Chakra's Spinner and reads a context that only exists
// inside `<Editor>`, so it throws when used for a pre-Editor wait state.
// This one is provider-free SVG and renders anywhere.
if (!data) {
return (
<div
className="flex h-screen items-center justify-center text-muted-foreground"
role="status"
aria-label={`Loading ${routeKey}`}
>
<Loader size={24} />
</div>
);
}
return (
<Editor
key={routeKey}
config={appConfig as any}
data={data}
plugins={plugins}
routes={ROUTE_KEYS}
route={{ key: routeKey, path: publicPath, params: routeParams }}
onRouteChange={(nextKey) => {
// The picker hands back a route key; ignore anything not registered.
if (findPageRoute(nextKey)) router.push(editorHref(nextKey));
}}
onPublish={handlePublish}
headerTitle={findPageRoute(routeKey)?.label ?? routeKey}
// The concrete URL being previewed. The editor's own URL bar shows the
// route key (the template being edited); this is the resolved path.
headerPath={publicPath}
renderHeaderActions={({ state }) => (
<span className="text-xs text-muted-foreground">
{status ?? `${state.data.content.length} blocks`}
</span>
)}
/>
);
}
+14 -6
View File
@@ -1,20 +1,28 @@
"use client";
'use client';
import { Render } from "@reacteditor/core/render";
import { appConfig } from "@/editor.config";
import globals from "@/app.globals.json";
import { Render } from '@reacteditor/core/render';
import { appConfig } from '@/editor.config';
import globals from '@/app.globals.json';
export type PageData = {
root?: unknown;
content?: unknown;
globals?: unknown;
};
/**
* Shared renderer for a route. Drop a `page.json` next to a route's
* `page.tsx`, import it, and hand it here:
* Shared renderer for a route. Drop a `page.json` next to a route's `page.tsx`,
* import it, and hand it here:
*
* import page from "./page.json";
* export default () => <PageRender page={page} />;
*
* The same `appConfig` backs `PageEditor`, so what an editor sees is what the
* route ships.
*
* `app.globals.json` carries the props of blocks marked `global: true` the
* header and footer. Every page.json references them via `"synced": true`, so
* editing the header once updates all thirteen routes.
*/
export default function PageRender({ page }: { page: PageData }) {
const data = { root: page.root, content: page.content, globals };
+142
View File
@@ -0,0 +1,142 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
export interface AccountFormField {
name: string;
label: string;
type?: 'text' | 'email' | 'password';
autoComplete?: string;
required?: boolean;
}
interface AccountFormProps {
title: string;
description?: string;
fields: AccountFormField[];
submitLabel: string;
endpoint: string;
/** Merged into the request body alongside the field values. */
extraPayload?: Record<string, string>;
/** Where to go on success; omit to show `successMessage` instead. */
redirectTo?: string;
successMessage?: string;
footer?: React.ReactNode;
}
const AccountForm: React.FC<AccountFormProps> = ({
title,
description,
fields,
submitLabel,
endpoint,
extraPayload,
redirectTo,
successMessage,
footer,
}) => {
const router = useRouter();
const [values, setValues] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (submitting) return;
try {
setSubmitting(true);
setError(null);
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...values, ...extraPayload }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error ?? 'Something went wrong. Please try again.');
return;
}
if (redirectTo) {
// refresh() so server components re-read the new session cookie.
router.push(redirectTo);
router.refresh();
} else {
setDone(true);
}
} catch {
setError('Could not reach the server. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<div className="mx-auto w-full max-w-sm">
<h1 className="text-2xl font-normal text-foreground">{title}</h1>
{description && (
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
)}
{done && successMessage ? (
<p className="mt-6 text-sm text-foreground">{successMessage}</p>
) : (
<form onSubmit={handleSubmit} className="mt-6 flex flex-col gap-4">
{fields.map((field) => (
<label key={field.name} className="flex flex-col gap-1.5">
<span className="text-sm text-muted-foreground">
{field.label}
</span>
<input
type={field.type ?? 'text'}
name={field.name}
required={field.required ?? true}
autoComplete={field.autoComplete}
value={values[field.name] ?? ''}
onChange={(event) =>
setValues((prev) => ({
...prev,
[field.name]: event.target.value,
}))
}
className="h-11 rounded-md border border-border px-3 text-sm outline-none transition-colors focus:border-foreground"
/>
</label>
))}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={submitting} className="h-11">
{submitting && <Loader size={16} />}
{submitLabel}
</Button>
</form>
)}
{footer && (
<div className="mt-6 flex flex-col gap-2 text-sm text-muted-foreground">
{footer}
</div>
)}
</div>
);
};
export const AccountFormLink: React.FC<{ href: string; children: React.ReactNode }> = ({
href,
children,
}) => (
<Link href={href} className="underline underline-offset-2 hover:text-foreground">
{children}
</Link>
);
export default AccountForm;
+112
View File
@@ -0,0 +1,112 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { RiUserLine } from '@remixicon/react';
interface SessionCustomer {
displayName: string;
email: string;
firstName?: string | null;
}
const AccountMenu: React.FC = () => {
const router = useRouter();
const [customer, setCustomer] = useState<SessionCustomer | null>(null);
const [open, setOpen] = useState(false);
// The token lives in an httpOnly cookie, so the signed-in state has to come
// from the server rather than being read directly.
useEffect(() => {
let cancelled = false;
fetch('/api/account/me')
.then((response) => response.json())
.then((data) => {
if (!cancelled) setCustomer(data.customer ?? null);
})
.catch(() => {
if (!cancelled) setCustomer(null);
});
return () => {
cancelled = true;
};
}, []);
const handleSignOut = async () => {
setOpen(false);
await fetch('/api/account/logout', { method: 'POST' });
setCustomer(null);
router.push('/');
router.refresh();
};
// Signed out: straight to the sign-in page, no menu.
if (!customer) {
return (
<Button
asChild
variant="ghost"
size="icon"
aria-label="Sign in"
className="rounded-full"
>
<Link href="/account/login">
<RiUserLine className="size-5" />
</Link>
</Button>
);
}
return (
<div className="relative">
<Button
onClick={() => setOpen((prev) => !prev)}
variant="ghost"
size="icon"
aria-label="Account menu"
aria-expanded={open}
className="rounded-full"
>
<RiUserLine className="size-5" />
</Button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-full z-50 mt-1 w-60 rounded-md border border-border bg-background py-1 shadow-md">
<div className="px-4 py-2">
<p className="truncate text-sm font-medium text-foreground">
{customer.firstName || customer.displayName}
</p>
<p className="truncate text-xs text-muted-foreground">
{customer.email}
</p>
</div>
<div className="my-1 h-px bg-border" />
<Link
href="/account"
onClick={() => setOpen(false)}
className="block px-4 py-2 text-sm text-foreground hover:bg-accent"
>
Order history
</Link>
<button
onClick={handleSignOut}
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
>
Sign out
</button>
</div>
</>
)}
</div>
);
};
export default AccountMenu;
@@ -0,0 +1,34 @@
import { ComponentConfig } from '@reacteditor/core';
import { Receipt } from 'lucide-react';
import AccountOrders, {
type AccountOrdersProps,
} from '@/components/shopify/account-orders';
const accountOrdersEditor: ComponentConfig<AccountOrdersProps> = {
label: 'Order history',
icon: <Receipt size={16} />,
category: 'account',
defaultProps: {
title: 'Order history',
signedOutMessage: 'Sign in to see your orders.',
emptyMessage: "You haven't placed any orders yet.",
limit: 20,
},
fields: {
title: { label: 'Title', type: 'text', contentEditable: true },
signedOutMessage: {
label: 'Signed-out message',
type: 'text',
contentEditable: true,
},
emptyMessage: {
label: 'Empty message',
type: 'text',
contentEditable: true,
},
limit: { label: 'Orders shown', type: 'number', min: 1, max: 50 },
},
render: (props) => <AccountOrders {...props} />,
};
export default accountOrdersEditor;
+83
View File
@@ -0,0 +1,83 @@
'use client';
import React, { useEffect, useState } from 'react';
import OrderHistory from '@/components/shopify/order-history';
import type { Customer } from '@/services/shopify/customer';
export interface AccountOrdersProps {
title?: string;
/** Shown while signed out — the route guard normally redirects first. */
signedOutMessage?: string;
emptyMessage?: string;
limit?: number;
}
/**
* Client-side wrapper so order history can live in a page.json alongside the
* other blocks. `/account/page.tsx` still guards the route server-side; this
* re-reads the session through `/api/account/orders` so the block works the
* same whether it is rendered by the page or previewed in the editor.
*/
const AccountOrders: React.FC<AccountOrdersProps> = ({
title = 'Order history',
signedOutMessage = 'Sign in to see your orders.',
emptyMessage = "You haven't placed any orders yet.",
limit = 20,
}) => {
const [customer, setCustomer] = useState<Customer | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetch(`/api/account/orders?orders=${limit}`)
.then((response) => response.json())
.then((data) => {
if (!cancelled) setCustomer(data.customer ?? null);
})
.catch(() => {
if (!cancelled) setCustomer(null);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [limit]);
const orderCount = customer?.orders?.edges.length ?? 0;
return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-12">
<h1 className="text-2xl md:text-3xl font-normal text-foreground">
{title}
</h1>
{customer && (
<p className="mt-1 text-sm text-muted-foreground">
{customer.displayName} · {customer.email}
</p>
)}
<div className="mt-10">
{loading ? (
<div className="animate-pulse space-y-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="h-20 bg-zinc-100" />
))}
</div>
) : !customer ? (
<p className="text-sm text-muted-foreground">{signedOutMessage}</p>
) : orderCount === 0 ? (
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
) : (
<OrderHistory customer={customer} />
)}
</div>
</main>
);
};
export default AccountOrders;
+118
View File
@@ -0,0 +1,118 @@
import { ComponentConfig } from '@reacteditor/core';
import { KeyRound, LogIn, UserPlus } from 'lucide-react';
import AccountPanel, {
type AccountFlow,
type AccountPanelProps,
} from '@/components/shopify/account-panel';
/**
* One component, five registered blocks. `flow` decides the field list and the
* API endpoint, so it lives in `defaultProps` and is deliberately absent from
* `fields` it is behaviour, not content. Everything editable here is copy.
*/
const accountBlock = (
flow: AccountFlow,
label: string,
icon: React.ReactNode,
defaults: Omit<AccountPanelProps, 'flow'>
): ComponentConfig<AccountPanelProps> => ({
label,
icon,
category: 'account',
defaultProps: { flow, ...defaults },
fields: {
title: { label: 'Title', type: 'text', contentEditable: true },
description: {
label: 'Description',
type: 'textarea',
contentEditable: true,
},
submitLabel: { label: 'Button label', type: 'text', contentEditable: true },
successMessage: {
label: 'Success message',
type: 'textarea',
contentEditable: true,
},
links: {
label: 'Footer links',
type: 'array',
defaultItemProps: { label: 'Link', url: '/account/login' },
getItemSummary: (item) => item?.label || 'Link',
arrayFields: {
label: { label: 'Label', type: 'text', contentEditable: true },
url: { label: 'Link', type: 'text' },
},
},
},
render: (props) => <AccountPanel {...props} />,
});
export const accountLoginEditor = accountBlock(
'login',
'Sign in form',
<LogIn size={16} />,
{
title: 'Sign in',
description: '',
submitLabel: 'Sign in',
successMessage: '',
links: [
{ label: 'Create an account', url: '/account/register' },
{ label: 'Forgot your password?', url: '/account/recover' },
],
}
);
export const accountRegisterEditor = accountBlock(
'register',
'Register form',
<UserPlus size={16} />,
{
title: 'Create account',
description: '',
submitLabel: 'Create account',
successMessage: '',
links: [{ label: 'Already have an account? Sign in', url: '/account/login' }],
}
);
export const accountRecoverEditor = accountBlock(
'recover',
'Password recovery form',
<KeyRound size={16} />,
{
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' }],
}
);
export const accountResetEditor = accountBlock(
'reset',
'Set password form',
<KeyRound size={16} />,
{
title: 'Set a new password',
description: '',
submitLabel: 'Save password',
successMessage: '',
links: [],
}
);
export const accountActivateEditor = accountBlock(
'activate',
'Activate account form',
<KeyRound size={16} />,
{
title: 'Activate your account',
description: 'Choose a password to finish setting up your account.',
submitLabel: 'Activate account',
successMessage: '',
links: [],
}
);
+170
View File
@@ -0,0 +1,170 @@
'use client';
import React from 'react';
import { useParams } from 'next/navigation';
import AccountForm, {
AccountFormLink,
type AccountFormField,
} from '@/components/shopify/account-form';
/**
* The account routes are all the same form with a different field list and a
* different endpoint. Those two things are behaviour, not content, so they are
* fixed here per flow and are deliberately *not* exposed as editor fields
* the editor only gets the wording (see `account-panel.editor.tsx`).
*/
export type AccountFlow =
| 'login'
| 'register'
| 'recover'
| 'reset'
| 'activate';
const EMAIL: AccountFormField = {
name: 'email',
label: 'Email',
type: 'email',
autoComplete: 'email',
};
const FLOWS: Record<
AccountFlow,
{
fields: AccountFormField[];
endpoint: string;
redirectTo?: string;
/** Reads Shopify's emailed `/{id}/{token}` link segments into the body. */
usesRouteToken?: 'resetToken' | 'activationToken';
}
> = {
login: {
fields: [
EMAIL,
{
name: 'password',
label: 'Password',
type: 'password',
autoComplete: 'current-password',
},
],
endpoint: '/api/account/login',
redirectTo: '/account',
},
register: {
fields: [
{
name: 'firstName',
label: 'First name',
autoComplete: 'given-name',
required: false,
},
{
name: 'lastName',
label: 'Last name',
autoComplete: 'family-name',
required: false,
},
EMAIL,
{
name: 'password',
label: 'Password',
type: 'password',
autoComplete: 'new-password',
},
],
endpoint: '/api/account/register',
redirectTo: '/account',
},
recover: {
fields: [EMAIL],
endpoint: '/api/account/recover',
},
reset: {
fields: [
{
name: 'password',
label: 'New password',
type: 'password',
autoComplete: 'new-password',
},
],
endpoint: '/api/account/reset',
redirectTo: '/account',
usesRouteToken: 'resetToken',
},
activate: {
fields: [
{
name: 'password',
label: 'Password',
type: 'password',
autoComplete: 'new-password',
},
],
endpoint: '/api/account/activate',
redirectTo: '/account',
usesRouteToken: 'activationToken',
},
};
export interface AccountPanelLink {
label: string;
url: string;
}
export interface AccountPanelProps {
flow?: AccountFlow;
title?: string;
description?: string;
submitLabel?: string;
successMessage?: string;
/** Rendered under the form — "New here? Create an account", etc. */
links?: AccountPanelLink[];
}
const AccountPanel: React.FC<AccountPanelProps> = ({
flow = 'login',
title = 'Sign in',
description,
submitLabel = 'Sign in',
successMessage,
links = [],
}) => {
const params = useParams();
const config = FLOWS[flow] ?? FLOWS.login;
const extraPayload = config.usesRouteToken
? {
id: (params?.id as string) ?? '',
[config.usesRouteToken]: (params?.token as string) ?? '',
}
: undefined;
return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<AccountForm
title={title}
description={description}
fields={config.fields}
submitLabel={submitLabel}
endpoint={config.endpoint}
extraPayload={extraPayload}
redirectTo={config.redirectTo}
successMessage={successMessage}
footer={
links.length > 0 ? (
<>
{links.map((link) => (
<AccountFormLink key={link.url} href={link.url}>
{link.label}
</AccountFormLink>
))}
</>
) : undefined
}
/>
</main>
);
};
export default AccountPanel;
+226 -115
View File
@@ -1,11 +1,24 @@
'use client';
import React from 'react';
import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart';
import React, { useState } from 'react';
import Image from 'next/image';
import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { Loader } from '@/components/ui/loader';
import { X, ImageIcon, Minus, Plus } from 'lucide-react';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetBody,
AnimatePresence,
} from '@/components/ui/sheet';
import {
RiCloseLine,
RiImageLine,
RiSubtractLine,
RiAddLine,
} from '@remixicon/react';
import {
Empty,
EmptyHeader,
@@ -13,25 +26,43 @@ import {
EmptyDescription,
EmptyContent,
} from '@/components/ui/empty';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { isDefaultTitleSelection } from '@/services/shopify/catalog';
const CartDrawer: React.FC = () => {
const {
isOpen,
closeCart,
items,
itemCount,
totalAmount,
checkoutUrl,
loading,
removeItem,
updateItemQuantity,
} = useShopifyCart();
const isOpen = useCartStore((s) => s.isOpen);
const closeCart = useCartStore((s) => s.closeCart);
const loading = useCartStore((s) => s.loading);
const cart = useCartStore((s) => s.cart);
const removeItem = useCartStore((s) => s.removeItem);
const updateItemQuantity = useCartStore((s) => s.updateItemQuantity);
const applyDiscountCode = useCartStore((s) => s.applyDiscountCode);
const [discountCode, setDiscountCode] = useState('');
const [discountError, setDiscountError] = useState<string | null>(null);
const [applyingDiscount, setApplyingDiscount] = useState(false);
// The store's `loading` flag is global, so track the specific line being
// changed to keep the other rows interactive.
const [pendingLineId, setPendingLineId] = useState<string | null>(null);
const runLineAction = async (lineId: string, action: () => Promise<unknown>) => {
if (pendingLineId) return;
try {
setPendingLineId(lineId);
await action();
} catch (err) {
console.error('Cart line update failed:', err);
} finally {
setPendingLineId(null);
}
};
const items = cart?.lines?.edges?.map((edge) => edge.node) ?? [];
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = parseFloat(cart?.cost?.totalAmount?.amount ?? '0');
const checkoutUrl = cart?.checkoutUrl ?? null;
const appliedDiscounts =
cart?.discountCodes?.filter((discount) => discount.applicable) ?? [];
const handleCheckout = () => {
if (checkoutUrl) {
@@ -39,200 +70,280 @@ const CartDrawer: React.FC = () => {
}
};
const handleApplyDiscount = async (event: React.FormEvent) => {
event.preventDefault();
const code = discountCode.trim();
if (!code || applyingDiscount) return;
try {
setApplyingDiscount(true);
setDiscountError(null);
const updatedCart = await applyDiscountCode(code);
// Shopify accepts unknown codes silently, flagging them as inapplicable.
const accepted = updatedCart.discountCodes?.some(
(discount) =>
discount.applicable &&
discount.code.toLowerCase() === code.toLowerCase()
);
if (accepted) {
setDiscountCode('');
} else {
setDiscountError('That code is not valid for this cart.');
}
} catch {
setDiscountError('Could not apply that code. Please try again.');
} finally {
setApplyingDiscount(false);
}
};
const getItemImage = (item: (typeof items)[0]) => {
return item.merchandise.image?.url;
};
const getSelectedOptions = (item: (typeof items)[0]) => {
return item.merchandise.selectedOptions ?? [];
// Single-SKU items carry a synthetic `Title: Default Title` — not worth a line.
return (item.merchandise.selectedOptions ?? []).filter(
(option) => !isDefaultTitleSelection(option)
);
};
return (
<Sheet open={isOpen} onOpenChange={(open) => !open && closeCart()}>
<SheetContent
<Sheet
open={isOpen}
onOpenChange={(open) => !open && closeCart()}
side="right"
className="w-full max-w-md"
showCloseButton={false}
>
<AnimatePresence>
{isOpen && (
<SheetContent className="w-full max-w-md" showCloseButton={false}>
{/* Header */}
<SheetHeader className="h-14 min-h-0 px-4 py-3 flex items-center border-b border-border">
<SheetHeader className="min-h-0 px-5 py-4 border-b-0">
<div className="flex items-center justify-between w-full">
<SheetTitle className="text-base">
Shopping Cart ({itemCount})
<SheetTitle className="text-base font-medium flex items-center gap-x-2">
Cart
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-medium text-background">
{itemCount}
</span>
</SheetTitle>
<Button onClick={closeCart} variant="ghost" size="icon-sm">
<X size={20} />
<Button
onClick={closeCart}
variant="ghost"
size="icon"
aria-label="Close cart"
className="rounded-full"
>
<RiCloseLine className="size-5" />
</Button>
</div>
</SheetHeader>
{/* Cart Items */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<SheetBody className="px-5">
{loading && items.length === 0 ? (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" />
<Loader size={20} />
</div>
) : items.length === 0 ? (
<Empty className="border-0">
<EmptyContent>
<Empty>
<EmptyHeader>
<EmptyTitle>Your cart is empty</EmptyTitle>
<EmptyDescription>
Add some products to get started!
</EmptyDescription>
</EmptyHeader>
<Button
onClick={closeCart}
variant="outline"
>
<EmptyContent>
<Button onClick={closeCart} className="w-full">
Continue Shopping
</Button>
</EmptyContent>
</Empty>
) : (
<div className="space-y-6">
<div className="space-y-5">
{items.map((item) => {
const image = getItemImage(item);
const selectedOptions = getSelectedOptions(item);
const isPending = pendingLineId === item.id;
return (
<div
key={item.id}
className="flex items-start space-x-4 pb-6 border-b border-border last:border-b-0"
>
<div key={item.id} className="flex items-start gap-x-3">
{/* Product Image */}
<div className="w-20 h-20 bg-muted rounded-lg overflow-hidden flex-shrink-0">
<div className="w-16 h-16 bg-zinc-100 overflow-hidden shrink-0">
{image ? (
<img
<Image
src={image}
alt={item.merchandise.product.title}
width={64}
height={64}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<ImageIcon size={24} />
<div className="w-full h-full flex items-center justify-center text-zinc-400">
<RiImageLine size={20} />
</div>
)}
</div>
{/* Product Details */}
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-foreground mb-1 line-clamp-2">
<div className="flex items-start justify-between gap-x-3">
<h4 className="text-sm font-medium text-foreground line-clamp-2">
{item.merchandise.product.title}
</h4>
{/* Variant Info */}
{selectedOptions.length > 0 && (
<div className="text-sm text-muted-foreground mb-2">
{selectedOptions.map((option, index) => (
<span key={option.name}>
{option.value}
{index < selectedOptions.length - 1
? ' / '
: ''}
<span className="shrink-0 font-mono tabular-nums tracking-tight text-sm text-foreground">
$
{parseFloat(
item.cost?.totalAmount?.amount ??
item.merchandise.price.amount
).toFixed(2)}
</span>
))}
</div>
{selectedOptions.length > 0 && (
<div className="text-xs text-muted-foreground mt-0.5">
{selectedOptions
.map((option) => option.value)
.join(' / ')}
</div>
)}
{/* Quantity Controls */}
<div className="flex items-center mt-3">
<div className="flex items-center border border-border rounded-lg">
<div className="flex items-center gap-x-2 mt-2">
<div className="inline-flex items-center rounded-full bg-secondary">
<Button
onClick={() =>
updateItemQuantity(item.id, item.quantity - 1)
runLineAction(item.id, () =>
updateItemQuantity(
item.id,
item.quantity - 1
)
)
}
disabled={item.quantity <= 1 || isPending}
variant="ghost"
size="icon-sm"
disabled={item.quantity <= 1 || loading}
className="h-7 w-7"
aria-label="Decrease quantity"
className="size-7 rounded-full"
>
<Minus size={14} />
<RiSubtractLine size={14} />
</Button>
<span className="px-2 py-1 font-semibold min-w-[30px] text-center text-sm">
{item.quantity}
<span className="min-w-8 px-1 text-sm tabular-nums text-center">
{isPending ? (
<Loader size={12} className="mx-auto" />
) : (
item.quantity
)}
</span>
<Button
onClick={() =>
updateItemQuantity(item.id, item.quantity + 1)
runLineAction(item.id, () =>
updateItemQuantity(
item.id,
item.quantity + 1
)
)
}
disabled={isPending}
variant="ghost"
size="icon-sm"
disabled={loading}
className="h-7 w-7"
aria-label="Increase quantity"
className="size-7 rounded-full"
>
<Plus size={14} />
<RiAddLine size={14} />
</Button>
</div>
</div>
</div>
{/* Price */}
<div className="flex-shrink-0">
<span className="text-sm font-semibold text-foreground">
${parseFloat(item.merchandise.price.amount).toFixed(2)}
</span>
</div>
{/* Remove Button */}
<div className="flex-shrink-0">
<Button
onClick={() => removeItem(item.id)}
variant="ghost"
size="icon-sm"
disabled={loading}
className="h-auto w-auto p-2 text-muted-foreground hover:text-foreground"
onClick={() =>
runLineAction(item.id, () => removeItem(item.id))
}
disabled={isPending}
variant="link"
aria-label={`Remove ${item.merchandise.product.title}`}
className="ml-auto h-7 self-center px-0 text-xs font-normal leading-none text-muted-foreground underline decoration-dashed underline-offset-2 hover:text-foreground hover:no-underline"
>
<X size={18} />
remove
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</SheetBody>
{/* Footer - Checkout Section */}
{/* Footer — discount, total, checkout */}
{items.length > 0 && (
<div className="border-t border-border p-6">
{/* Subtotal */}
<div className="flex items-center justify-between mb-4">
<span className="text-base font-semibold">Subtotal</span>
<span className="text-lg font-bold">
<div className="px-5 py-5 space-y-4">
{/* Discount Code */}
<form onSubmit={handleApplyDiscount} className="flex gap-x-2">
<input
type="text"
value={discountCode}
onChange={(event) => {
setDiscountCode(event.target.value);
setDiscountError(null);
}}
placeholder="Discount code"
aria-label="Discount code"
className="flex-1 h-10 rounded-md border border-border px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:border-foreground transition-colors"
/>
<Button
type="submit"
disabled={!discountCode.trim() || applyingDiscount}
className="h-10 bg-muted-foreground px-5 hover:bg-foreground"
>
{applyingDiscount ? <Loader size={16} /> : 'Apply'}
</Button>
</form>
{discountError && (
<p className="text-xs text-destructive">{discountError}</p>
)}
{appliedDiscounts.length > 0 && (
<p className="text-xs text-muted-foreground">
Applied: {appliedDiscounts.map((d) => d.code).join(', ')}
</p>
)}
{/* Estimated total */}
<div>
<div className="flex items-baseline justify-between">
<span className="text-base text-foreground">
Estimated total
</span>
<span className="font-mono tabular-nums tracking-tight text-lg text-foreground">
${totalAmount.toFixed(2)}
</span>
</div>
<div className="text-sm text-muted-foreground mb-4">
Shipping and taxes calculated at checkout
<p className="text-xs text-muted-foreground mt-1">
Taxes and shipping calculated at checkout.
</p>
</div>
{/* Action Buttons */}
<div className="space-y-3">
<Button
onClick={handleCheckout}
disabled={loading || !checkoutUrl}
className="w-full"
size="lg"
disabled={!checkoutUrl || pendingLineId !== null}
className="h-12 w-full"
>
{loading ? (
<span className="flex items-center justify-center space-x-2">
<Loader size={16} />
<span>Processing...</span>
</span>
) : (
'Checkout'
)}
Go to Checkout
</Button>
<Button onClick={closeCart} variant="link" className="w-full">
<Button
onClick={closeCart}
variant="ghost"
className="w-full font-normal text-muted-foreground hover:text-foreground"
>
Continue Shopping
</Button>
</div>
</div>
)}
</SheetContent>
)}
</AnimatePresence>
</Sheet>
);
};
+17 -30
View File
@@ -1,11 +1,10 @@
import React from 'react';
import Link from 'next/link';
import { Card, CardContent } from '@/components/ui/card';
import { Typography } from '@/components/Typography';
import Image from 'next/image';
interface CollectionImage {
url: string;
altText?: string;
altText?: string | null;
}
interface Collection {
@@ -13,7 +12,7 @@ interface Collection {
title: string;
handle: string;
description?: string;
image?: CollectionImage;
image?: CollectionImage | null;
}
interface CollectionCardProps {
@@ -22,45 +21,33 @@ interface CollectionCardProps {
const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
return (
<Link href={`/collections/${collection.handle}`} className="block group">
<Card className="hover:shadow-xl transition-shadow duration-300 overflow-hidden py-0 gap-0">
<Link
href={`/collections/${collection.handle}`}
className="group block h-full"
>
{/* Collection Image */}
<div className="aspect-video overflow-hidden bg-muted">
<div className="relative aspect-square overflow-hidden">
{collection.image ? (
<img
<Image
src={collection.image.url}
alt={collection.image.altText || collection.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
fill
sizes="(min-width: 1024px) 25vw, 50vw"
className="object-cover transition-transform duration-500 group-hover:scale-[1.04]"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<i className="ri-folder-line text-6xl"></i>
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
<i className="ri-folder-line text-8xl"></i>
</div>
)}
</div>
{/* Collection Info */}
<CardContent className="p-6">
<Typography
variant="subtitle1"
className="mb-3 font-semibold tracking-tight text-foreground transition-colors group-hover:text-muted-foreground"
>
<div className="flex flex-col flex-1 py-2.5">
<h3 className="text-sm font-medium text-foreground line-clamp-1">
{collection.title}
</Typography>
{collection.description && (
<p className="text-muted-foreground">
{collection.description.substring(0, 100)}
{collection.description.length > 100 ? '...' : ''}
</p>
)}
<div className="mt-4 text-foreground font-semibold group-hover:text-muted-foreground transition-colors flex items-center">
<span>View Collection</span>
<i className="ri-arrow-right-s-line ml-2"></i>
</h3>
</div>
</CardContent>
</Card>
</Link>
);
};
@@ -0,0 +1,41 @@
import { ComponentConfig } from '@reacteditor/core';
import type { ShopifyCollection } from '@reacteditor/plugin-shopify';
import { Boxes } from 'lucide-react';
import CollectionDetail from '@/components/shopify/collection-detail';
export type CollectionDetailBlockProps = {
collection?: ShopifyCollection | null;
title?: string;
};
/**
* Used two ways: dropped on `/collections/[handle]` it follows the route, so
* `collection` stays empty and the picker only drives the editor preview;
* dropped on any other page it pins to whichever collection is picked.
*/
const collectionDetailEditor: ComponentConfig<CollectionDetailBlockProps> = {
label: 'Collection page',
icon: <Boxes size={16} />,
category: 'commerce',
defaultProps: {
collection: null,
title: '',
},
fields: {
collection: {
label: 'Collection',
type: 'shopifyCollection',
} as any,
title: {
label: 'Title override',
type: 'text',
placeholder: "Leave empty to use the collection's own title",
contentEditable: true,
},
},
render: ({ collection, title }) => (
<CollectionDetail handle={collection?.handle} title={title} />
),
};
export default collectionDetailEditor;
+202 -67
View File
@@ -1,94 +1,229 @@
'use client';
import React from 'react';
import { useCollectionProducts } from '@/hooks/use-shopify-collections';
import React, { useEffect, useMemo, useState } from 'react';
import { useParams } from 'next/navigation';
import ProductCard from './product-card';
import { Skeleton } from '@/components/ui/skeleton';
import ProductFilters, { type ProductFilterFacet } from './product-filters';
import ProductToolbar from './product-toolbar';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
import {
getCollectionProductsPage,
type CollectionSortKey,
} from '@/hooks/use-shopify-collections';
import type { Product } from '@/hooks/use-shopify-products';
const CollectionDetail: React.FC<{ handle?: string }> = ({ handle: handleProp }) => {
const handle = handleProp ?? '';
const PAGE_SIZE = 24;
const { collection, loading, error, refetch } = useCollectionProducts(handle);
const GRID_CLASSES =
'grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12';
// Format title from handle
interface SortOption {
label: string;
sortKey: CollectionSortKey;
reverse: boolean;
}
const SORT_OPTIONS: SortOption[] = [
{ label: 'Featured', sortKey: 'COLLECTION_DEFAULT', reverse: false },
{ label: 'Best Selling', sortKey: 'BEST_SELLING', reverse: false },
{ label: 'Price: Low to High', sortKey: 'PRICE', reverse: false },
{ label: 'Price: High to Low', sortKey: 'PRICE', reverse: true },
{ label: 'Newest', sortKey: 'CREATED', reverse: true },
];
interface CollectionDetailProps {
/**
* Pins the block to one collection. Left empty, it reads the `[handle]`
* segment instead, which is what the `/collections/[handle]` template does.
*/
handle?: string;
/** Overrides the collection's own title. Empty falls back to Shopify's. */
title?: string;
}
const CollectionDetail: React.FC<CollectionDetailProps> = ({
handle: handleProp,
title: titleProp,
}) => {
const params = useParams();
const handle = handleProp || (params?.handle as string);
const [products, setProducts] = useState<Product[]>([]);
const [filters, setFilters] = useState<ProductFilterFacet[]>([]);
const [title, setTitle] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(false);
const [sortIndex, setSortIndex] = useState(0);
const [filtersOpen, setFiltersOpen] = useState(false);
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const sort = SORT_OPTIONS[sortIndex];
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
// Fall back to the handle until the collection's real title arrives.
const formattedTitle = handle
? handle.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
: 'Collection';
if (loading || !handle) {
useEffect(() => {
// Reached when the editor was opened on the literal `[handle]` pattern
// rather than a real collection URL. Drop the skeleton and say so rather
// than spinning forever.
if (!handle || handle === '[handle]') {
setLoading(false);
setError(
'Open a collection page and add /editor to preview it, or pick a collection above.'
);
return;
}
let cancelled = false;
const run = async () => {
try {
setLoading(true);
setError(null);
const page = await getCollectionProductsPage(handle, {
first: PAGE_SIZE,
sortKey: sort.sortKey,
reverse: sort.reverse,
filterInputs: activeFilters,
});
if (cancelled) return;
if (!page.collection) {
setError('Collection not found');
return;
}
setTitle(page.collection.title);
setProducts(page.products);
setCursor(page.endCursor);
setHasNextPage(page.hasNextPage);
// Keep the facet list stable while a selection is active, so options
// don't disappear out from under the panel.
if (activeFilters.length === 0) setFilters(page.filters);
} catch (err) {
if (cancelled) return;
console.error('Error fetching collection products:', err);
setError(err instanceof Error ? err.message : 'Failed to load collection');
} finally {
if (!cancelled) setLoading(false);
}
};
run();
return () => {
cancelled = true;
};
}, [handle, sort.sortKey, sort.reverse, activeKey]);
const handleLoadMore = async () => {
if (loadingMore || !hasNextPage) return;
try {
setLoadingMore(true);
const page = await getCollectionProductsPage(handle, {
first: PAGE_SIZE,
after: cursor,
sortKey: sort.sortKey,
reverse: sort.reverse,
filterInputs: activeFilters,
});
setProducts((prev) => {
const seen = new Set(prev.map((p) => p.id));
return [...prev, ...page.products.filter((p) => !seen.has(p.id))];
});
setCursor(page.endCursor);
setHasNextPage(page.hasNextPage);
} catch (err) {
console.error('Failed to load more products:', err);
} finally {
setLoadingMore(false);
}
};
return (
<div className="pt-4 pb-16">
<div className="container mx-auto px-4">
<div className="mb-16 flex justify-center">
<Skeleton className="h-12 w-64" />
<section className="bg-background py-10">
<div className="max-w-screen-2xl mx-auto px-8">
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
{titleProp || title || formattedTitle}
</h1>
<div className="mt-6">
<ProductToolbar
totalCount={products.length > 0 ? products.length : null}
onOpenFilters={() => setFiltersOpen(true)}
sortOptions={SORT_OPTIONS}
sortIndex={sortIndex}
onSortChange={setSortIndex}
activeFilterCount={activeFilters.length}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="flex flex-col gap-3">
<Skeleton className="aspect-square w-full" />
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/3" />
<div className="mt-8">
{loading ? (
<div className={GRID_CLASSES}>
{Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
<div className="pt-4 space-y-2">
<div className="h-4 w-4/5 bg-zinc-200"></div>
<div className="h-4 w-1/4 bg-zinc-200"></div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="pt-4 pb-16">
<div className="container mx-auto px-4">
<div className="mx-auto flex max-w-md flex-col items-start gap-3 rounded-lg border border-border bg-foreground/[0.02] p-6">
<p className="text-sm font-medium">Could not load collection</p>
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
{error}
) : error ? (
<p className="text-sm text-muted-foreground">{error}</p>
) : products.length === 0 ? (
<p className="text-sm text-muted-foreground">
{activeFilters.length > 0
? 'No products matched your filters.'
: "This collection doesn't have any products yet."}
</p>
<button
onClick={() => refetch()}
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-muted"
>
Retry
</button>
</div>
</div>
</div>
);
}
const products = collection?.products || [];
const title = collection?.title || formattedTitle;
return (
<div className="pt-4 pb-16">
<div className="container mx-auto px-4">
<h2 className="text-5xl font-bold text-center mb-16 text-foreground font-heading">
{title}
</h2>
{products.length === 0 ? (
<div className="text-center py-12">
<div className="bg-muted border border-border rounded-lg p-8 max-w-md mx-auto">
<i className="ri-shopping-bag-line text-4xl text-muted-foreground mb-4"></i>
<h3 className="text-lg font-semibold text-foreground mb-2">
No Products in Collection
</h3>
<p className="text-muted-foreground">
This collection doesn&apos;t have any products yet.
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
<>
<div className={GRID_CLASSES}>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{hasNextPage && (
<div className="mt-16 flex justify-center">
<Button
onClick={handleLoadMore}
disabled={loadingMore}
variant="outline"
size="lg"
className="font-normal text-muted-foreground hover:text-foreground"
>
{loadingMore && <Loader size={16} />}
{loadingMore ? 'Loading' : 'Load more'}
</Button>
</div>
)}
</>
)}
</div>
</div>
<ProductFilters
open={filtersOpen}
onOpenChange={setFiltersOpen}
filters={filters}
activeFilters={activeFilters}
onActiveFiltersChange={setActiveFilters}
/>
</section>
);
};
@@ -1,33 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { FolderOpen } from "lucide-react";
import { CollectionGrid, type CollectionGridProps } from "@/components/shopify/collection-grid";
const collectionGridEditor: ComponentConfig<CollectionGridProps> = {
label: "Collections",
icon: <FolderOpen size={16} />,
category: "commerce",
defaultProps: {
tagline: "Shop by collection",
heading: "Curated edits",
subheading: "Bundles built around the way you actually live.",
layout: "tiles",
limit: 6,
},
fields: {
tagline: { label: "Tagline", type: "text", contentEditable: true },
heading: { label: "Heading", type: "text", contentEditable: true },
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
layout: {
label: "Layout",
type: "radio",
options: [
{ label: "Tiles", value: "tiles" },
{ label: "Editorial", value: "editorial" },
],
},
limit: { label: "Limit", type: "number", min: 2, max: 12 },
},
render: (props) => <CollectionGrid {...props} />,
};
export default collectionGridEditor;
-119
View File
@@ -1,119 +0,0 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { shopifyFetch } from "@/services/shopify/client";
import { GET_COLLECTIONS_QUERY } from "@/graphql/collections";
import { Container } from "@/components/layout/Container";
import { Typography } from "@/components/Typography";
import { Heading } from "@/components/Heading";
export type CollectionGridProps = {
tagline: string;
heading: string;
subheading: string;
layout: "tiles" | "editorial";
limit: number;
};
type CollectionRow = {
id: string;
handle: string;
title: string;
description?: string;
image?: { url: string; altText?: string };
};
export function CollectionGrid({
tagline,
heading,
subheading,
layout,
limit,
}: CollectionGridProps) {
const [collections, setCollections] = useState<CollectionRow[]>([]);
useEffect(() => {
shopifyFetch<any>({
query: GET_COLLECTIONS_QUERY,
variables: { first: limit },
})
.then((res) => {
const list = (res.data?.collections?.edges ?? []).map((e: any) => e.node);
setCollections(list);
})
.catch(() => setCollections([]));
}, [limit]);
const isEditorial = layout === "editorial";
return (
<section className="bg-background py-20 md:py-28">
<Container>
<Heading
tagline={tagline}
title={heading}
subtitle={subheading}
align="center"
size="lg"
className="mx-auto mb-12"
maxWidth="max-w-2xl"
/>
<div
className={
isEditorial
? "grid grid-cols-1 gap-8 md:grid-cols-2"
: "grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-3 lg:grid-cols-4"
}
>
{(collections.length === 0
? Array.from({ length: limit }).map((_, i) => ({ id: `sk-${i}` }) as any)
: collections
).map((c: CollectionRow) => (
<Link
key={c.id}
href={c.handle ? `/collections/${c.handle}` : "#"}
className="group block"
>
<div
className={`relative overflow-hidden rounded-md bg-muted ${isEditorial ? "aspect-[3/4] md:aspect-[5/6]" : "aspect-[4/5]"}`}
>
{c.image?.url ? (
<img
src={c.image.url}
alt={c.image.altText || c.title}
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
/>
) : null}
{isEditorial ? (
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-8">
<div>
<Typography
variant="subtitle1"
className="font-semibold tracking-tight text-white"
>
{c.title}
</Typography>
<span className="mt-2 inline-flex text-xs uppercase tracking-[0.2em] text-white/80">
Shop now
</span>
</div>
</div>
) : null}
</div>
{!isEditorial ? (
<div className="mt-4">
<Typography
variant="subtitle2"
className="font-medium tracking-tight text-foreground"
>
{c.title}
</Typography>
</div>
) : null}
</Link>
))}
</div>
</Container>
</section>
);
}
-247
View File
@@ -1,247 +0,0 @@
import { ComponentConfig } from "@reacteditor/core";
import { FolderOpen } from "lucide-react";
import { CollectionView, type CollectionProps } from "@/components/shopify/collection";
const collectionEditor: ComponentConfig<CollectionProps> = {
label: "Collection page",
icon: <FolderOpen size={16} />,
category: "commerce",
defaultProps: {
collection: null,
showDescription: "yes",
showCoverImage: "yes",
customCoverImage: "",
columns: "4",
limit: 24,
defaultSort: "BEST_SELLING",
showAvailability: "yes",
showPriceRange: "yes",
showProductType: "no",
productTypeOptions: [],
showVendor: "no",
vendorOptions: [],
showTags: "no",
tagOptions: [],
showColor: "yes",
colorOptions: [
{ label: "Black", color: "#000000" },
{ label: "White", color: "#FFFFFF" },
{ label: "Navy", color: "#1e3a5f" },
],
showStyle: "no",
styleOptions: [],
showSize: "yes",
sizeOptions: [
{ label: "XS" },
{ label: "S" },
{ label: "M" },
{ label: "L" },
{ label: "XL" },
],
showMaterial: "no",
materialOptions: [],
metafieldFilters: [],
},
fields: {
collection: { label: "Collection", type: "shopifyCollection" } as any,
showDescription: {
label: "Description",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
showCoverImage: {
label: "Cover image",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
customCoverImage: {
label: "Custom cover image",
type: "image",
},
columns: {
label: "Columns",
type: "radio",
options: [
{ label: "2", value: "2" },
{ label: "3", value: "3" },
{ label: "4", value: "4" },
],
},
limit: { label: "Products per page", type: "number", min: 4, max: 48 },
defaultSort: {
label: "Default sort",
type: "select",
options: [
{ label: "Best Selling", value: "BEST_SELLING" },
{ label: "Newest", value: "CREATED" },
{ label: "Price: Low to High", value: "PRICE" },
{ label: "Alphabetical", value: "TITLE" },
],
},
showAvailability: {
label: "Availability filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
showPriceRange: {
label: "Price range filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
showColor: {
label: "Color filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
colorOptions: {
label: "Colors",
type: "array",
defaultItemProps: { label: "", color: "#000000" },
getItemSummary: (it: any) => it?.label || "Color",
arrayFields: {
label: { label: "Color name", type: "text" },
color: { label: "Color", type: "color" },
},
},
showStyle: {
label: "Style filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
styleOptions: {
label: "Styles",
type: "array",
defaultItemProps: { label: "" },
getItemSummary: (it: any) => it?.label || "Style",
arrayFields: {
label: { label: "Style name", type: "text" },
},
},
showSize: {
label: "Size filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
sizeOptions: {
label: "Sizes",
type: "array",
defaultItemProps: { label: "" },
getItemSummary: (it: any) => it?.label || "Size",
arrayFields: {
label: { label: "Size name", type: "text" },
},
},
showMaterial: {
label: "Material filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
materialOptions: {
label: "Materials",
type: "array",
defaultItemProps: { label: "" },
getItemSummary: (it: any) => it?.label || "Material",
arrayFields: {
label: { label: "Material name", type: "text" },
},
},
showVendor: {
label: "Brand / vendor filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
vendorOptions: {
label: "Brands",
type: "array",
defaultItemProps: { label: "" },
getItemSummary: (it: any) => it?.label || "Brand",
arrayFields: {
label: { label: "Brand name", type: "text" },
},
},
showProductType: {
label: "Product type filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
productTypeOptions: {
label: "Product types",
type: "array",
defaultItemProps: { label: "" },
getItemSummary: (it: any) => it?.label || "Type",
arrayFields: {
label: { label: "Type name", type: "text" },
},
},
showTags: {
label: "Tags filter",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
tagOptions: {
label: "Tags",
type: "array",
defaultItemProps: { label: "" },
getItemSummary: (it: any) => it?.label || "Tag",
arrayFields: {
label: { label: "Tag name", type: "text" },
},
},
metafieldFilters: {
label: "Metafield filters",
type: "array",
defaultItemProps: { namespace: "", key: "", label: "", values: [{ label: "" }] },
getItemSummary: (it: any) => it?.label || it?.key || "Metafield",
arrayFields: {
namespace: { label: "Namespace", type: "text" },
key: { label: "Key", type: "text" },
label: { label: "Label", type: "text" },
values: {
label: "Values",
type: "array",
defaultItemProps: { label: "" },
getItemSummary: (v: any) => v?.label || "Value",
arrayFields: {
label: { label: "Value", type: "text" },
},
},
},
},
},
render: (props) => <CollectionView {...props} />,
};
export default collectionEditor;
-552
View File
@@ -1,552 +0,0 @@
import { useState, useCallback } from 'react';
import { useRouteSegment } from '@/hooks/use-route-segment';
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
import type { ShopifyCollection } from '@reacteditor/field-shopify';
import {
useCollectionProducts,
type CollectionSortKey,
type ProductFilter,
} from '@/hooks/use-shopify-collections';
import { ProductCard } from './product-card';
import { Typography } from '@/components/Typography';
import { Skeleton } from '@/components/ui/skeleton';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetFooter } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Container } from '@/components/layout/Container';
import { cn } from '@/lib/utils';
type FilterOption = { label: string };
type ColorOption = { label: string; color: string };
export type CollectionProps = {
collection: ShopifyCollection | null;
showDescription: 'yes' | 'no';
showCoverImage: 'yes' | 'no';
customCoverImage: string;
columns: '2' | '3' | '4';
limit: number;
defaultSort: CollectionSortKey;
showAvailability: 'yes' | 'no';
showPriceRange: 'yes' | 'no';
showProductType: 'yes' | 'no';
productTypeOptions: FilterOption[];
showVendor: 'yes' | 'no';
vendorOptions: FilterOption[];
showTags: 'yes' | 'no';
tagOptions: FilterOption[];
showColor: 'yes' | 'no';
colorOptions: ColorOption[];
showStyle: 'yes' | 'no';
styleOptions: FilterOption[];
showSize: 'yes' | 'no';
sizeOptions: FilterOption[];
showMaterial: 'yes' | 'no';
materialOptions: FilterOption[];
metafieldFilters: { namespace: string; key: string; label: string; values: { label: string }[] }[];
};
const SORT_OPTIONS: { label: string; value: CollectionSortKey }[] = [
{ label: 'Best Selling', value: 'BEST_SELLING' },
{ label: 'Newest', value: 'CREATED' },
{ label: 'Price: Low to High', value: 'PRICE' },
{ label: 'Alphabetical', value: 'TITLE' },
];
const colClass: Record<CollectionProps['columns'], string> = {
'2': 'grid-cols-2',
'3': 'grid-cols-2 md:grid-cols-3',
'4': 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
};
// ─── Filter group (collapsible) ────────────────────────────────────────────────
function FilterGroup({ label, children, defaultOpen = true }: {
label: string;
children: React.ReactNode;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border-b border-border py-4">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between text-xs font-semibold uppercase tracking-[0.15em] text-foreground"
>
{label}
<ChevronDown
size={14}
className={cn('transition-transform', open ? 'rotate-180' : '')}
/>
</button>
{open && <div className="mt-3 space-y-2">{children}</div>}
</div>
);
}
function Checkbox({ checked, onChange, label }: {
checked: boolean;
onChange: (v: boolean) => void;
label: string;
}) {
return (
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-foreground/80 hover:text-foreground">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="sr-only"
/>
<span
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded border',
checked ? 'border-foreground bg-foreground' : 'border-border',
)}
>
{checked && (
<svg viewBox="0 0 10 8" className="h-2.5 w-2.5 fill-background" aria-hidden>
<path d="M1 4l3 3 5-6" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
{label}
</label>
);
}
// ─── Sidebar ────────────────────────────────────────────────────────────────────
type ActiveFilters = {
availability: boolean;
productTypes: string[];
vendors: string[];
tags: string[];
colors: string[];
styles: string[];
sizes: string[];
materials: string[];
minPrice: string;
maxPrice: string;
metafieldValues: Record<string, string[]>;
};
function Sidebar({
props,
active,
onChange,
}: {
props: CollectionProps;
active: ActiveFilters;
onChange: (patch: Partial<ActiveFilters>) => void;
}) {
const productTypes = (props.productTypeOptions ?? []).map((o) => o.label).filter(Boolean);
const vendors = (props.vendorOptions ?? []).map((o) => o.label).filter(Boolean);
const tags = (props.tagOptions ?? []).map((o) => o.label).filter(Boolean);
const colors = (props.colorOptions ?? []) as ColorOption[];
const styles = (props.styleOptions ?? []).map((o) => o.label).filter(Boolean);
const sizes = (props.sizeOptions ?? []).map((o) => o.label).filter(Boolean);
const materials = (props.materialOptions ?? []).map((o) => o.label).filter(Boolean);
const metafieldFilters = (props.metafieldFilters ?? []).filter((mf) => mf.namespace && mf.key);
function toggle(key: 'productTypes' | 'vendors' | 'tags' | 'colors' | 'styles' | 'sizes' | 'materials', value: string) {
const arr = active[key];
onChange({ [key]: arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] });
}
return (
<div className="w-full">
{props.showAvailability === 'yes' && (
<FilterGroup label="Availability">
<Checkbox
checked={active.availability}
onChange={(v) => onChange({ availability: v })}
label="In stock"
/>
</FilterGroup>
)}
{props.showPriceRange === 'yes' && (
<FilterGroup label="Price">
<div className="flex items-center gap-2">
<input
type="number"
min={0}
placeholder="Min"
value={active.minPrice}
onChange={(e) => onChange({ minPrice: e.target.value })}
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
/>
<span className="text-muted-foreground"></span>
<input
type="number"
min={0}
placeholder="Max"
value={active.maxPrice}
onChange={(e) => onChange({ maxPrice: e.target.value })}
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
/>
</div>
</FilterGroup>
)}
{props.showVendor === 'yes' && vendors.length > 0 && (
<FilterGroup label="Brand">
{vendors.map((v) => (
<Checkbox
key={v}
checked={active.vendors.includes(v)}
onChange={() => toggle('vendors', v)}
label={v}
/>
))}
</FilterGroup>
)}
{props.showColor === 'yes' && colors.length > 0 && (
<FilterGroup label="Color">
{colors.filter((c) => c.label).map((c) => (
<label
key={c.label}
className="flex cursor-pointer items-center gap-2.5 text-sm text-foreground/80 hover:text-foreground"
>
<input
type="checkbox"
checked={active.colors.includes(c.label)}
onChange={() => toggle('colors', c.label)}
className="sr-only"
/>
<span
className={cn(
'flex h-5 w-5 shrink-0 rounded-full border-2',
active.colors.includes(c.label) ? 'border-foreground' : 'border-transparent',
)}
style={{ backgroundColor: c.color || undefined }}
/>
{c.label}
</label>
))}
</FilterGroup>
)}
{props.showStyle === 'yes' && styles.length > 0 && (
<FilterGroup label="Style">
{styles.map((s) => (
<Checkbox
key={s}
checked={active.styles.includes(s)}
onChange={() => toggle('styles', s)}
label={s}
/>
))}
</FilterGroup>
)}
{props.showSize === 'yes' && sizes.length > 0 && (
<FilterGroup label="Size">
{sizes.map((s) => (
<Checkbox
key={s}
checked={active.sizes.includes(s)}
onChange={() => toggle('sizes', s)}
label={s}
/>
))}
</FilterGroup>
)}
{props.showMaterial === 'yes' && materials.length > 0 && (
<FilterGroup label="Material">
{materials.map((m) => (
<Checkbox
key={m}
checked={active.materials.includes(m)}
onChange={() => toggle('materials', m)}
label={m}
/>
))}
</FilterGroup>
)}
{props.showProductType === 'yes' && productTypes.length > 0 && (
<FilterGroup label="Product type">
{productTypes.map((pt) => (
<Checkbox
key={pt}
checked={active.productTypes.includes(pt)}
onChange={() => toggle('productTypes', pt)}
label={pt}
/>
))}
</FilterGroup>
)}
{props.showTags === 'yes' && tags.length > 0 && (
<FilterGroup label="Tags">
{tags.map((t) => (
<Checkbox
key={t}
checked={active.tags.includes(t)}
onChange={() => toggle('tags', t)}
label={t}
/>
))}
</FilterGroup>
)}
{metafieldFilters.map((mf, i) => {
const mfKey = `${mf.namespace}.${mf.key}`;
const selected = active.metafieldValues[mfKey] ?? [];
return (
<FilterGroup key={mfKey + i} label={mf.label || mfKey}>
{mf.values.map((v) => v.label).filter(Boolean).map((val) => (
<Checkbox
key={val}
checked={selected.includes(val)}
onChange={(checked) => {
const next = checked
? [...selected, val]
: selected.filter((v) => v !== val);
onChange({ metafieldValues: { ...active.metafieldValues, [mfKey]: next } });
}}
label={val}
/>
))}
</FilterGroup>
);
})}
</div>
);
}
// ─── Build Shopify ProductFilter array ──────────────────────────────────────────
function buildProductFilters(active: ActiveFilters): ProductFilter[] {
const filters: ProductFilter[] = [];
if (active.availability) filters.push({ available: true });
if (active.minPrice !== '' || active.maxPrice !== '') {
filters.push({
price: {
min: active.minPrice !== '' ? parseFloat(active.minPrice) : undefined,
max: active.maxPrice !== '' ? parseFloat(active.maxPrice) : undefined,
},
});
}
for (const pt of active.productTypes) filters.push({ productType: pt });
for (const v of active.vendors) filters.push({ productVendor: v });
for (const t of active.tags) filters.push({ tag: t });
for (const c of active.colors) filters.push({ variantOption: { name: 'Color', value: c } });
for (const s of active.styles) filters.push({ variantOption: { name: 'Style', value: s } });
for (const s of active.sizes) filters.push({ variantOption: { name: 'Size', value: s } });
for (const m of active.materials) filters.push({ variantOption: { name: 'Material', value: m } });
for (const [mfKey, vals] of Object.entries(active.metafieldValues)) {
const [namespace, key] = mfKey.split('.');
for (const value of vals) {
filters.push({ productMetafield: { namespace, key, value } });
}
}
return filters;
}
// ─── Main component ──────────────────────────────────────────────────────────
export function CollectionView(props: CollectionProps) {
const { collection: selected, showDescription, showCoverImage, customCoverImage, columns, limit, defaultSort } = props;
const routeHandle = useRouteSegment();
const handle = selected?.handle ?? routeHandle ?? '';
const [sort, setSort] = useState<CollectionSortKey>(defaultSort);
const [reverse, setReverse] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
const [active, setActive] = useState<ActiveFilters>({
availability: false,
productTypes: [],
vendors: [],
tags: [],
colors: [],
styles: [],
sizes: [],
materials: [],
minPrice: '',
maxPrice: '',
metafieldValues: {},
});
const patchActive = useCallback((patch: Partial<ActiveFilters>) => {
setActive((prev) => ({ ...prev, ...patch }));
}, []);
const clearAll = useCallback(() => {
setActive({
availability: false,
productTypes: [],
vendors: [],
tags: [],
colors: [],
styles: [],
sizes: [],
materials: [],
minPrice: '',
maxPrice: '',
metafieldValues: {},
});
}, []);
const productFilters = buildProductFilters(active);
const handleSortChange = (value: string) => {
if (value === 'PRICE_DESC') {
setSort('PRICE');
setReverse(true);
} else {
setSort(value as CollectionSortKey);
setReverse(false);
}
};
const sortValue = sort === 'PRICE' && reverse ? 'PRICE_DESC' : sort;
const { collection, loading, hasNextPage, fetchMore } = useCollectionProducts(handle, {
first: limit,
sortKey: sort,
reverse,
filters: productFilters.length ? productFilters : undefined,
});
const products = collection?.products ?? [];
const description = collection?.description ?? (selected as any)?.description;
const collectionImage = customCoverImage || collection?.image?.url;
if (!selected && !routeHandle) {
return (
<section className="bg-background pb-24 pt-12 md:pt-20">
<Container>
<header className="mx-auto mb-14 flex max-w-2xl flex-col items-center gap-3 text-center">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-10 w-3/4" />
</header>
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="aspect-[4/5] w-full" />
))}
</div>
</Container>
</section>
);
}
return (
<section className="bg-background pb-24 pt-12 md:pt-20">
<Container>
{/* Cover image */}
{showCoverImage === 'yes' && collectionImage && (
<div className="mb-10 overflow-hidden rounded-lg">
<img
src={collectionImage}
alt={collection?.title ?? ''}
className="h-48 w-full object-cover md:h-72 lg:h-80"
/>
</div>
)}
{/* Header */}
<header className="mb-10">
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
Collection
</p>
<Typography variant="h1">
{collection?.title ?? (selected as any)?.title ?? routeHandle}
</Typography>
{showDescription === 'yes' && description ? (
<Typography variant="subtitle1" className="mt-4 max-w-2xl">
{description}
</Typography>
) : null}
</header>
{/* Filter + sort bar */}
<div className="mb-6 flex items-center justify-between">
<Sheet open={filtersOpen} onOpenChange={setFiltersOpen}>
<SheetTrigger asChild>
<button
type="button"
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-muted"
>
<SlidersHorizontal size={14} />
Filters
</button>
</SheetTrigger>
<SheetContent side="left" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>Filters</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4">
<Sidebar props={props} active={active} onChange={patchActive} />
</div>
<SheetFooter className="flex-row gap-2 border-t border-border">
<Button variant="outline" className="flex-1" onClick={clearAll}>
Clear
</Button>
<Button className="flex-1" onClick={() => setFiltersOpen(false)}>
Search
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<div className="flex items-center gap-4">
<p className="hidden text-sm text-muted-foreground sm:block">
{loading ? 'Loading…' : `${products.length} product${products.length === 1 ? '' : 's'}`}
</p>
<Select value={sortValue} onValueChange={handleSortChange}>
<SelectTrigger className="h-auto px-3 py-2 text-sm">
<SelectValue>
{[...SORT_OPTIONS, { label: 'Price: High to Low', value: 'PRICE_DESC' }].find((o) => o.value === sortValue)?.label}
</SelectValue>
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
<SelectItem value="PRICE_DESC">Price: High to Low</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Grid */}
<div className={cn('grid gap-x-6 gap-y-10', colClass[columns])}>
{loading
? Array.from({ length: limit }).map((_, i) => (
<Skeleton key={i} className="aspect-[4/5] w-full" />
))
: products.map((p: any) => <ProductCard key={p.id} product={p} />)}
</div>
{!loading && products.length === 0 && (
<div className="mt-16 text-center text-sm text-muted-foreground">
No products found in this collection.
</div>
)}
{hasNextPage && !loading && (
<div className="mt-12 flex justify-center">
<button
type="button"
onClick={fetchMore}
className="rounded-md border border-border px-8 py-3 text-sm font-medium hover:bg-muted"
>
Load more
</button>
</div>
)}
</Container>
</section>
);
}

Some files were not shown because too many files have changed in this diff Show More