Compare commits

...
12 Commits
Author SHA1 Message Date
Rami BitarandClaude Opus 5.5 6104838684 Upgrade Next to 16.3.8 and default the store domain to mock.shop
An unset or blank NEXT_PUBLIC_SHOPIFY_DOMAIN reached the Hydrogen client
as an empty storeDomain, which throws 'storeDomain is required and must
be non-empty'. Fall back to the tokenless mock.shop store, only send the
public token alongside a configured domain, and have the editor's Shopify
plugin read the same resolved config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:56:31 -04:00
Rami Bitar f71d73d7c0 Bump React Editor core to 0.0.43 2026-09-05 12:17:28 -04:00
Rami Bitar b2d2f65071 Fix Hydrogen webpack build and editor link keys 2026-08-29 21:31:32 -04:00
Rami BitarandClaude Opus 5 69ca137c7d Rename block .editor files to .config
Also drop the color and leftSideBarVisible overrides from PageEditor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xjLo7mFxZXfPDCHuKX4Dz
2026-08-29 11:10:24 -04:00
Rami BitarandClaude Opus 5 3bc61066f0 Add collections pagination and a search field on the search page
Paginate the collections grid with a "See more" button backed by a new
getCollectionsPage cursor API, add an editable search input to the
search-results block, and bump @shopify/hydrogen to 2026.10.0-preview.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqFkSaLYRPZ18dsMbzjiP4
2026-08-23 11:01:48 -04:00
Rami Bitar 9ed1cc60de Bump react editor 2026-08-15 21:20:35 -04:00
Rami BitarandClaude Opus 5 ebb07c40fc Show loading skeletons instead of empty state when product or collection is missing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DoqTD6MbXbwpCofyqwvELd
2026-08-15 16:58:14 -04:00
Rami BitarandClaude Opus 5 bd7e6810b7 Remove quantityAvailable from product variant query
The Storefront API only returns ProductVariant.quantityAvailable when the
app has the unauthenticated_read_product_inventory scope, and nothing in
the template reads the field — availableForSale already drives stock state.

Also hides the editor left sidebar by default in PageEditor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaxyZTjAyd7UXK8xpQpF7o
2026-08-15 16:49:25 -04:00
Rami Bitar bd68e1eafb Set React Editor core alpha.5 2026-08-15 13:48:14 -04:00
Rami Bitar 0fb6cef226 Update React Editor integration and clean up template 2026-08-15 13:47:33 -04:00
Rami BitarandClaude Fable 5 0a7ea0bf6b Bump @reacteditor/plugin-tailwind-cdn to 0.0.3-alpha.2
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KagUob1DrrRgQbYKG5GbyV
2026-08-14 17:39:08 -04:00
Rami BitarandClaude Opus 5 f11a764426 Load page.json by import, publish via fs, drop cookie auth
The editor no longer round-trips through /api/pages. Each route's
editor/page.tsx imports its own `../page.json` and hands it to
PageEditor as a prop, so the editor opens with the page already in
hand — no fetch, no loading state, no undo history seeded from a
placeholder. Globals still come from app.globals.json.

Publishing moves from `PUT /api/pages` to a `publishPage` server
action that writes the route's page.json with node:fs directly.
The target path is still built from the lib/pages.ts registry rather
than from the caller, so an unknown route key is rejected instead of
escaping app/.

Removes the customer account auth entirely: the httpOnly cookie
session, the /api/account/* handlers, the customer service and
GraphQL documents, the account-* blocks, and the /account/* routes.
The template has no auth, so nothing reads a cookie now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STGDvL4X7FhHHnxdRE2ayo
2026-08-09 16:36:18 -04:00
126 changed files with 772 additions and 3351 deletions
-12
View File
@@ -1,12 +0,0 @@
# 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=
+7 -4
View File
@@ -1,9 +1,12 @@
approvedGitRepositories:
- "**"
enableScripts: true
nodeLinker: node-modules nodeLinker: node-modules
# Yarn quarantines npm releases younger than `npmMinimalAgeGate` (24h by npmMinimalAgeGate: 0
# 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: npmPreapprovedPackages:
- "@reacteditor/*" - "@reacteditor/*"
- "@shopify/hydrogen" - "@shopify/hydrogen"
+1 -3
View File
@@ -1,6 +1,4 @@
# React Editor Demo (Shopify) # Shopify Hydrogen
Standalone Vite SPA wiring up the Shopify-aware React Editor via `<App />` from `@reacteditor/core`.
## What's here ## What's here
+2 -3
View File
@@ -1,7 +1,6 @@
import PageEditor from '@/components/page-editor'; import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /about. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
export default function EditorPage() { export default function EditorPage() {
return <PageEditor routeKey="/about" />; return <PageEditor pagePath="app/about/page.json" page={pageData} />;
} }
@@ -1,7 +0,0 @@
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]" />;
}
@@ -1,43 +0,0 @@
{
"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"
}
}
]
}
@@ -1,9 +0,0 @@
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
@@ -1,7 +0,0 @@
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
@@ -1,7 +0,0 @@
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
@@ -1,52 +0,0 @@
{
"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
@@ -1,9 +0,0 @@
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
@@ -1,41 +0,0 @@
{
"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
@@ -1,16 +0,0 @@
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
@@ -1,7 +0,0 @@
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
@@ -1,48 +0,0 @@
{
"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
@@ -1,9 +0,0 @@
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
@@ -1,7 +0,0 @@
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
@@ -1,48 +0,0 @@
{
"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
@@ -1,9 +0,0 @@
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} />;
}
@@ -1,7 +0,0 @@
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
@@ -1,43 +0,0 @@
{
"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
@@ -1,9 +0,0 @@
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
@@ -1,30 +0,0 @@
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
@@ -1,31 +0,0 @@
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
@@ -1,10 +0,0 @@
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
@@ -1,19 +0,0 @@
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
@@ -1,26 +0,0 @@
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
@@ -1,10 +0,0 @@
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
@@ -1,39 +0,0 @@
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
@@ -1,27 +0,0 @@
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 });
}
+1 -9
View File
@@ -9,10 +9,8 @@ import {
getCollectionProductsPage, getCollectionProductsPage,
isDefaultTitleOption, isDefaultTitleOption,
isDefaultTitleSelection, isDefaultTitleSelection,
} from '@/services/shopify/catalog'; } from '@/services/shopify/shop';
// Streaming needs the Node runtime here because the Storefront helpers run
// server-side on each tool call.
export const maxDuration = 30; export const maxDuration = 30;
const MODEL = process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.6-luna-pro'; const MODEL = process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.6-luna-pro';
@@ -36,7 +34,6 @@ Guidelines:
- You cannot place orders, change carts, process payments, or look up customer - 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.`; 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: { const summariseProduct = (product: {
id: string; id: string;
title: string; title: string;
@@ -88,12 +85,9 @@ export async function POST(req: Request) {
const openrouter = createOpenRouter({ apiKey }); const openrouter = createOpenRouter({ apiKey });
const result = streamText({ 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' } }), model: openrouter(MODEL, { reasoning: { enabled: true, effort: 'medium' } }),
system: SYSTEM_PROMPT, system: SYSTEM_PROMPT,
messages: await convertToModelMessages(messages), messages: await convertToModelMessages(messages),
// Let the model call a tool, read the result, then answer.
stopWhen: stepCountIs(5), stopWhen: stepCountIs(5),
tools: { tools: {
searchCatalogue: tool({ searchCatalogue: tool({
@@ -201,7 +195,5 @@ export async function POST(req: Request) {
}, },
}); });
// sendReasoning forwards reasoning parts to the client; without it the
// stream carries text and tool calls only.
return result.toUIMessageStreamResponse({ sendReasoning: true }); return result.toUIMessageStreamResponse({ sendReasoning: true });
} }
-103
View File
@@ -1,103 +0,0 @@
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 -3
View File
@@ -1,7 +1,11 @@
import PageEditor from '@/components/page-editor'; import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// 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() { export default function EditorPage() {
return <PageEditor routeKey="/collections/[handle]" />; return (
<PageEditor
pagePath="app/collections/[handle]/page.json"
page={pageData}
/>
);
} }
+2 -5
View File
@@ -1,7 +1,7 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import PageRender from '@/components/page-render'; import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata'; import { pageMetadata } from '@/lib/page-metadata';
import { getCollectionProductsPage } from '@/services/shopify/catalog'; import { getCollectionProductsPage } from '@/services/shopify/shop';
import { truncate } from '@/lib/utils'; import { truncate } from '@/lib/utils';
import page from './page.json'; import page from './page.json';
@@ -12,14 +12,11 @@ export async function generateMetadata({
}): Promise<Metadata> { }): Promise<Metadata> {
const { handle } = await params; const { handle } = await params;
// No collection-only query exists, so ask for the smallest page of products // Shopify exposes collection metadata through the products connection.
// and use just the collection node off it.
const { collection } = await getCollectionProductsPage(handle, { const { collection } = await getCollectionProductsPage(handle, {
first: 1, first: 1,
}).catch(() => ({ collection: null })); }).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); if (!collection) return pageMetadata(page);
return pageMetadata(page, { return pageMetadata(page, {
+2 -3
View File
@@ -1,7 +1,6 @@
import PageEditor from '@/components/page-editor'; import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /collections. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
export default function EditorPage() { export default function EditorPage() {
return <PageEditor routeKey="/collections" />; return <PageEditor pagePath="app/collections/page.json" page={pageData} />;
} }
+2 -3
View File
@@ -1,7 +1,6 @@
import PageEditor from '@/components/page-editor'; import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
export default function EditorPage() { export default function EditorPage() {
return <PageEditor routeKey="/" />; return <PageEditor pagePath="app/page.json" page={pageData} />;
} }
+1 -26
View File
@@ -3,53 +3,41 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme { @theme {
/* Refined neutral modern palette */
--color-background: hsl(0 0% 100%); --color-background: hsl(0 0% 100%);
--color-foreground: hsl(240 10% 3.9%); --color-foreground: hsl(240 10% 3.9%);
/* Card */
--color-card: hsl(0 0% 100%); --color-card: hsl(0 0% 100%);
--color-card-foreground: hsl(240 10% 3.9%); --color-card-foreground: hsl(240 10% 3.9%);
/* Popover */
--color-popover: hsl(0 0% 100%); --color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(240 10% 3.9%); --color-popover-foreground: hsl(240 10% 3.9%);
/* Primary - Deep neutral slate for professional look */
--color-primary: hsl(240 5.9% 10%); --color-primary: hsl(240 5.9% 10%);
--color-primary-foreground: hsl(0 0% 98%); --color-primary-foreground: hsl(0 0% 98%);
/* Secondary */
--color-secondary: hsl(240 4.8% 95.9%); --color-secondary: hsl(240 4.8% 95.9%);
--color-secondary-foreground: hsl(240 5.9% 10%); --color-secondary-foreground: hsl(240 5.9% 10%);
/* Muted */
--color-muted: hsl(240 4.8% 95.9%); --color-muted: hsl(240 4.8% 95.9%);
--color-muted-foreground: hsl(240 3.8% 46.1%); --color-muted-foreground: hsl(240 3.8% 46.1%);
/* Accent - Subtle warm gray */
--color-accent: hsl(30 6.7% 95%); --color-accent: hsl(30 6.7% 95%);
--color-accent-foreground: hsl(240 5.9% 10%); --color-accent-foreground: hsl(240 5.9% 10%);
/* Destructive */
--color-destructive: hsl(0 84.2% 60.2%); --color-destructive: hsl(0 84.2% 60.2%);
--color-destructive-foreground: hsl(0 0% 98%); --color-destructive-foreground: hsl(0 0% 98%);
/* Shop (accelerated checkout) purple */
--color-shop: #5a31f4; --color-shop: #5a31f4;
/* Border */
--color-border: hsl(240 5.9% 90%); --color-border: hsl(240 5.9% 90%);
--color-input: hsl(240 5.9% 90%); --color-input: hsl(240 5.9% 90%);
--color-ring: hsl(240 5.9% 10%); --color-ring: hsl(240 5.9% 10%);
/* Radius - Modern larger corners */
--radius-sm: 0.5rem; --radius-sm: 0.5rem;
--radius-md: 0.75rem; --radius-md: 0.75rem;
--radius-lg: 1rem; --radius-lg: 1rem;
--radius-xl: 1.25rem; --radius-xl: 1.25rem;
/* Typography — Geist Sans / Geist Mono */
--font-sans: var(--font-geist-sans), system-ui, sans-serif; --font-sans: var(--font-geist-sans), system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace; --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
--font-heading: var(--font-geist-sans), system-ui, sans-serif; --font-heading: var(--font-geist-sans), system-ui, sans-serif;
@@ -57,10 +45,7 @@
--font-poppins: 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 otherwise defaults unqualified borders to currentColor. */
/* 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 { @layer base {
* { * {
border-color: var(--color-border); border-color: var(--color-border);
@@ -75,7 +60,6 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/* Modern card design for products and collections */
.card-modern { .card-modern {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -87,7 +71,6 @@ body {
border-color: hsl(240 5.9% 85%); border-color: hsl(240 5.9% 85%);
} }
/* Heading styles — regular weight, slightly tightened like the Geist reference */
h1, h1,
h2, h2,
.font-heading { .font-heading {
@@ -95,7 +78,6 @@ h2,
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
/* Swipeable rows (mobile galleries) without a visible scrollbar */
.no-scrollbar { .no-scrollbar {
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;
@@ -105,7 +87,6 @@ h2,
display: none; display: none;
} }
/* Diagonal strike marking an unavailable product option (size pill, swatch) */
.option-unavailable { .option-unavailable {
background-image: linear-gradient( background-image: linear-gradient(
to top right, to top right,
@@ -116,7 +97,6 @@ h2,
); );
} }
/* Product description (HTML returned by the Storefront API) */
.product-description p { .product-description p {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -125,7 +105,6 @@ h2,
margin-bottom: 0; margin-bottom: 0;
} }
/* Shop policy body (HTML returned by the Storefront API) */
.policy-body p, .policy-body p,
.policy-body ul, .policy-body ul,
.policy-body ol { .policy-body ol {
@@ -164,7 +143,6 @@ h2,
margin-bottom: 0; margin-bottom: 0;
} }
/* Enhanced button styles */
button, button,
.btn-modern { .btn-modern {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
@@ -177,12 +155,10 @@ button,
0 4px 6px -4px rgb(0 0 0 / 0.1); 0 4px 6px -4px rgb(0 0 0 / 0.1);
} }
/* Hero section styles */
.hero-bg { .hero-bg {
background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f5 100%); background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f5 100%);
} }
/* Product gallery improvements */
.product-gallery-main { .product-gallery-main {
transition: box-shadow 0.3s ease; transition: box-shadow 0.3s ease;
} }
@@ -191,7 +167,6 @@ button,
box-shadow: 0 0 0 1px hsl(240 5.9% 80%) inset; box-shadow: 0 0 0 1px hsl(240 5.9% 80%) inset;
} }
/* Clean price display */
.price-display { .price-display {
font-feature-settings: 'tnum'; font-feature-settings: 'tnum';
} }
-5
View File
@@ -14,8 +14,6 @@ const geistMono = Geist_Mono({
variable: '--font-geist-mono', 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 = { export const metadata: Metadata = {
metadataBase: new URL(site.url), metadataBase: new URL(site.url),
title: { title: {
@@ -44,9 +42,6 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}> <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"> <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> <div className="flex-1">{children}</div>
</body> </body>
+4 -3
View File
@@ -1,7 +1,8 @@
import PageEditor from '@/components/page-editor'; import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// 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() { export default function EditorPage() {
return <PageEditor routeKey="/policies/[handle]" />; return (
<PageEditor pagePath="app/policies/[handle]/page.json" page={pageData} />
);
} }
+4 -3
View File
@@ -1,7 +1,8 @@
import PageEditor from '@/components/page-editor'; import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// 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() { export default function EditorPage() {
return <PageEditor routeKey="/products/[handle]" />; return (
<PageEditor pagePath="app/products/[handle]/page.json" page={pageData} />
);
} }
+1 -3
View File
@@ -1,7 +1,7 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import PageRender from '@/components/page-render'; import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata'; import { pageMetadata } from '@/lib/page-metadata';
import { getProduct } from '@/services/shopify/catalog'; import { getProduct } from '@/services/shopify/shop';
import { truncate } from '@/lib/utils'; import { truncate } from '@/lib/utils';
import page from './page.json'; import page from './page.json';
@@ -12,8 +12,6 @@ export async function generateMetadata({
}): Promise<Metadata> { }): Promise<Metadata> {
const { handle } = await params; 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); const product = await getProduct(handle).catch(() => null);
if (!product) return pageMetadata(page); if (!product) return pageMetadata(page);
+2 -3
View File
@@ -1,7 +1,6 @@
import PageEditor from '@/components/page-editor'; import PageEditor from '@/components/page-editor';
import pageData from '../page.json';
// Editor for /search. Sitting under the route it edits means the preview
// resolves the same params the public page gets.
export default function EditorPage() { export default function EditorPage() {
return <PageEditor routeKey="/search" />; return <PageEditor pagePath="app/search/page.json" page={pageData} />;
} }
+2 -1
View File
@@ -18,7 +18,8 @@
"type": "search-results", "type": "search-results",
"props": { "props": {
"id": "search-results", "id": "search-results",
"title": "Search" "title": "Search",
"placeholder": "Search products"
} }
}, },
{ {
-2
View File
@@ -1,12 +1,10 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import PageRender from '@/components/page-render'; import PageRender from '@/components/page-render';
import { pageMetadata } from '@/lib/page-metadata'; import { pageMetadata } from '@/lib/page-metadata';
// Alias of /collections — same page.json, so editing that route updates both.
import page from '../../collections/page.json'; import page from '../../collections/page.json';
export const metadata: Metadata = { export const metadata: Metadata = {
...pageMetadata(page), ...pageMetadata(page),
// Point crawlers at the canonical listing.
alternates: { canonical: '/collections' }, alternates: { canonical: '/collections' },
}; };
-44
View File
@@ -20,10 +20,6 @@ import {
import type { ComponentProps, HTMLAttributes, ReactNode } from "react"; import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo } from "react"; import { createContext, useCallback, useContext, useMemo } from "react";
// ============================================================================
// Types
// ============================================================================
export type AttachmentData = export type AttachmentData =
| (FileUIPart & { id: string }) | (FileUIPart & { id: string })
| (SourceDocumentUIPart & { id: string }); | (SourceDocumentUIPart & { id: string });
@@ -47,10 +43,6 @@ const mediaCategoryIcons: Record<AttachmentMediaCategory, typeof ImageIcon> = {
video: VideoIcon, video: VideoIcon,
}; };
// ============================================================================
// Utility Functions
// ============================================================================
export const getMediaCategory = ( export const getMediaCategory = (
data: AttachmentData data: AttachmentData
): AttachmentMediaCategory => { ): AttachmentMediaCategory => {
@@ -108,10 +100,6 @@ const renderAttachmentImage = (
/> />
); );
// ============================================================================
// Contexts
// ============================================================================
interface AttachmentsContextValue { interface AttachmentsContextValue {
variant: AttachmentVariant; variant: AttachmentVariant;
} }
@@ -127,10 +115,6 @@ interface AttachmentContextValue {
const AttachmentContext = createContext<AttachmentContextValue | null>(null); const AttachmentContext = createContext<AttachmentContextValue | null>(null);
// ============================================================================
// Hooks
// ============================================================================
export const useAttachmentsContext = () => export const useAttachmentsContext = () =>
useContext(AttachmentsContext) ?? { variant: "grid" as const }; useContext(AttachmentsContext) ?? { variant: "grid" as const };
@@ -142,10 +126,6 @@ export const useAttachmentContext = () => {
return ctx; return ctx;
}; };
// ============================================================================
// Attachments - Container
// ============================================================================
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & { export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
variant?: AttachmentVariant; variant?: AttachmentVariant;
}; };
@@ -175,10 +155,6 @@ export const Attachments = ({
); );
}; };
// ============================================================================
// Attachment - Item
// ============================================================================
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & { export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: AttachmentData; data: AttachmentData;
onRemove?: () => void; onRemove?: () => void;
@@ -225,10 +201,6 @@ export const Attachment = ({
); );
}; };
// ============================================================================
// AttachmentPreview - Media preview
// ============================================================================
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & { export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
fallbackIcon?: ReactNode; fallbackIcon?: ReactNode;
}; };
@@ -275,10 +247,6 @@ export const AttachmentPreview = ({
); );
}; };
// ============================================================================
// AttachmentInfo - Name and type display
// ============================================================================
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & { export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
showMediaType?: boolean; showMediaType?: boolean;
}; };
@@ -307,10 +275,6 @@ export const AttachmentInfo = ({
); );
}; };
// ============================================================================
// AttachmentRemove - Remove button
// ============================================================================
export type AttachmentRemoveProps = ComponentProps<typeof Button> & { export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
label?: string; label?: string;
}; };
@@ -365,10 +329,6 @@ export const AttachmentRemove = ({
); );
}; };
// ============================================================================
// AttachmentHoverCard - Hover preview
// ============================================================================
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>; export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
export const AttachmentHoverCard = ({ export const AttachmentHoverCard = ({
@@ -403,10 +363,6 @@ export const AttachmentHoverCardContent = ({
/> />
); );
// ============================================================================
// AttachmentEmpty - Empty state
// ============================================================================
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>; export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
export const AttachmentEmpty = ({ export const AttachmentEmpty = ({
+4 -30
View File
@@ -24,16 +24,12 @@ import remarkRehype from "remark-rehype";
import remend from "remend"; import remend from "remend";
import { unified } from "unified"; import { unified } from "unified";
// Raw HTML never reaches the tree: remark-rehype drops it (allowDangerousHtml // Sanitize model output and reject unsafe URL protocols.
// 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 = { const schema = {
...defaultSchema, ...defaultSchema,
attributes: { attributes: {
...defaultSchema.attributes, ...defaultSchema.attributes,
// GFM task lists render as disabled checkboxes. The default schema allows // Preserve checked state for sanitized GFM task lists.
// `type` and `disabled` but not `checked`, so every box would read unticked.
input: [...(defaultSchema.attributes?.input ?? []), "checked"], input: [...(defaultSchema.attributes?.input ?? []), "checked"],
}, },
}; };
@@ -44,22 +40,15 @@ const processor = unified()
.use(remarkRehype) .use(remarkRehype)
.use(rehypeSanitize, schema); .use(rehypeSanitize, schema);
// `text-only` leaves a half-streamed `[label](htt` as plain text. remend's // Keep incomplete links as text while streaming.
// default instead emits a `streamdown:incomplete-link` placeholder href, which
// the sanitizer would strip anyway.
const REMEND_OPTIONS = { linkMode: "text-only" } as const; const REMEND_OPTIONS = { linkMode: "text-only" } as const;
// remend only ever runs on text that is still arriving. On finished text it can // Repair only streaming text; remend can corrupt settled italicized prices.
// 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) => const repair = (markdown: string, isStreaming: boolean) =>
isStreaming ? remend(markdown, REMEND_OPTIONS) : markdown; isStreaming ? remend(markdown, REMEND_OPTIONS) : markdown;
export interface LinkSafetyConfig { export interface LinkSafetyConfig {
enabled: boolean; enabled: boolean;
/** Return true for links that may open without a confirmation step. */
onLinkCheck?: (url: string) => boolean; onLinkCheck?: (url: string) => boolean;
} }
@@ -71,9 +60,6 @@ type AnchorProps = ComponentProps<"a"> & {
const LINK_CLASS = const LINK_CLASS =
"font-medium underline underline-offset-4 hover:text-foreground"; "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 isRouteHref = (href: string) => /^[/#?]/.test(href);
const MarkdownLink = ({ const MarkdownLink = ({
@@ -82,8 +68,6 @@ const MarkdownLink = ({
onUntrusted, onUntrusted,
...props ...props
}: AnchorProps) => { }: AnchorProps) => {
// Runs on click rather than on render so it can read `window.location`,
// which is unavailable during the server pass.
const handleClick = useCallback( const handleClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => { (event: MouseEvent<HTMLAnchorElement>) => {
if (!href || !linkSafety?.enabled || !linkSafety.onLinkCheck) return; if (!href || !linkSafety?.enabled || !linkSafety.onLinkCheck) return;
@@ -95,8 +79,6 @@ const MarkdownLink = ({
[href, linkSafety, onUntrusted] [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)) { if (href && isRouteHref(href)) {
return <Link className={LINK_CLASS} href={href} {...props} />; return <Link className={LINK_CLASS} href={href} {...props} />;
} }
@@ -113,7 +95,6 @@ const MarkdownLink = ({
); );
}; };
// Fenced code renders as plain preformatted text — no tokenizer, no themes.
const buildComponents = ( const buildComponents = (
linkSafety: LinkSafetyConfig | undefined, linkSafety: LinkSafetyConfig | undefined,
onUntrusted: (url: string) => void onUntrusted: (url: string) => void
@@ -128,8 +109,6 @@ const buildComponents = (
/> />
), ),
code: ({ className, ...props }: ComponentProps<"code">) => { 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 = const isBlock =
typeof className === "string" && className.includes("language-"); typeof className === "string" && className.includes("language-");
@@ -167,8 +146,6 @@ const buildComponents = (
hr: (props: ComponentProps<"hr">) => ( hr: (props: ComponentProps<"hr">) => (
<hr className="my-4 border-border" {...props} /> <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: (props: ComponentProps<"input">) => (
<input className="mr-1.5 align-middle" readOnly {...props} /> <input className="mr-1.5 align-middle" readOnly {...props} />
), ),
@@ -213,7 +190,6 @@ const buildComponents = (
export type MarkdownProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & { export type MarkdownProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
children: string; children: string;
/** True while tokens are still arriving; enables incomplete-syntax repair. */
isAnimating?: boolean; isAnimating?: boolean;
linkSafety?: LinkSafetyConfig; linkSafety?: LinkSafetyConfig;
}; };
@@ -231,8 +207,6 @@ export const Markdown = memo(
const handleUntrusted = useCallback((url: string) => setPending(url), []); const handleUntrusted = useCallback((url: string) => setPending(url), []);
const content = useMemo(() => { 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 source = repair(children ?? "", isAnimating === true);
const tree = processor.runSync(processor.parse(source)) as Nodes; const tree = processor.runSync(processor.parse(source)) as Nodes;
-3
View File
@@ -202,7 +202,6 @@ export const MessageBranchContent = ({
[children] [children]
); );
// Use useEffect to update branches when they change
useEffect(() => { useEffect(() => {
if (branches.length !== childrenArray.length) { if (branches.length !== childrenArray.length) {
setBranches(childrenArray); setBranches(childrenArray);
@@ -231,7 +230,6 @@ export const MessageBranchSelector = ({
}: MessageBranchSelectorProps) => { }: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch(); const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) { if (totalBranches <= 1) {
return null; return null;
} }
@@ -317,7 +315,6 @@ export const MessageBranchPage = ({
export type MessageResponseProps = ComponentProps<typeof Markdown>; export type MessageResponseProps = ComponentProps<typeof Markdown>;
// Markdown already memoises on children/isAnimating.
export const MessageResponse = Markdown; export const MessageResponse = Markdown;
export type MessageToolbarProps = ComponentProps<"div">; export type MessageToolbarProps = ComponentProps<"div">;
+6 -66
View File
@@ -74,15 +74,10 @@ import {
useState, useState,
} from "react"; } from "react";
// ============================================================================
// Helpers
// ============================================================================
const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => { const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
try { try {
const response = await fetch(url); const response = await fetch(url);
const blob = await response.blob(); const blob = await response.blob();
// FileReader uses callback-based API, wrapping in Promise is necessary
// oxlint-disable-next-line eslint-plugin-promise(avoid-new) // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
return new Promise((resolve) => { return new Promise((resolve) => {
const reader = new FileReader(); const reader = new FileReader();
@@ -118,7 +113,6 @@ const captureScreenshot = async (): Promise<File | null> => {
video.srcObject = stream; video.srcObject = stream;
// Video element uses callback-based API, wrapping in Promise is necessary
// oxlint-disable-next-line eslint-plugin-promise(avoid-new) // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener) // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
@@ -144,7 +138,6 @@ const captureScreenshot = async (): Promise<File | null> => {
} }
context.drawImage(video, 0, 0, width, height); context.drawImage(video, 0, 0, width, height);
// canvas.toBlob uses callback-based API, wrapping in Promise is necessary
// oxlint-disable-next-line eslint-plugin-promise(avoid-new) // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
const blob = await new Promise<Blob | null>((resolve) => { const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, "image/png"); canvas.toBlob(resolve, "image/png");
@@ -174,10 +167,6 @@ const captureScreenshot = async (): Promise<File | null> => {
} }
}; };
// ============================================================================
// Provider Context & Types
// ============================================================================
export interface AttachmentsContext { export interface AttachmentsContext {
files: (FileUIPart & { id: string })[]; files: (FileUIPart & { id: string })[];
add: (files: File[] | FileList) => void; add: (files: File[] | FileList) => void;
@@ -196,7 +185,6 @@ export interface TextInputContext {
export interface PromptInputControllerProps { export interface PromptInputControllerProps {
textInput: TextInputContext; textInput: TextInputContext;
attachments: AttachmentsContext; attachments: AttachmentsContext;
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
__registerFileInput: ( __registerFileInput: (
ref: RefObject<HTMLInputElement | null>, ref: RefObject<HTMLInputElement | null>,
open: () => void open: () => void
@@ -220,7 +208,6 @@ export const usePromptInputController = () => {
return ctx; return ctx;
}; };
// Optional variants (do NOT throw). Useful for dual-mode components.
const useOptionalPromptInputController = () => const useOptionalPromptInputController = () =>
useContext(PromptInputController); useContext(PromptInputController);
@@ -241,19 +228,14 @@ export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string; initialInput?: string;
}>; }>;
/** /** Lifts prompt state when shared control is needed. */
* Optional global provider that lifts PromptInput state outside of PromptInput.
* If you don't use it, PromptInput stays fully self-managed.
*/
export const PromptInputProvider = ({ export const PromptInputProvider = ({
initialInput: initialTextInput = "", initialInput: initialTextInput = "",
children, children,
}: PromptInputProviderProps) => { }: PromptInputProviderProps) => {
// ----- textInput state
const [textInput, setTextInput] = useState(initialTextInput); const [textInput, setTextInput] = useState(initialTextInput);
const clearInput = useCallback(() => setTextInput(""), []); const clearInput = useCallback(() => setTextInput(""), []);
// ----- attachments state (global when wrapped)
const [attachmentFiles, setAttachmentFiles] = useState< const [attachmentFiles, setAttachmentFiles] = useState<
(FileUIPart & { id: string })[] (FileUIPart & { id: string })[]
>([]); >([]);
@@ -300,14 +282,13 @@ export const PromptInputProvider = ({
}); });
}, []); }, []);
// Keep a ref to attachments for cleanup on unmount (avoids stale closure) // Keep the latest files available to the unmount cleanup.
const attachmentsRef = useRef(attachmentFiles); const attachmentsRef = useRef(attachmentFiles);
useEffect(() => { useEffect(() => {
attachmentsRef.current = attachmentFiles; attachmentsRef.current = attachmentFiles;
}, [attachmentFiles]); }, [attachmentFiles]);
// Cleanup blob URLs on unmount to prevent memory leaks
useEffect( useEffect(
() => () => { () => () => {
for (const f of attachmentsRef.current) { for (const f of attachmentsRef.current) {
@@ -365,14 +346,9 @@ export const PromptInputProvider = ({
); );
}; };
// ============================================================================
// Component Context & Hooks
// ============================================================================
const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null); const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
export const usePromptInputAttachments = () => { export const usePromptInputAttachments = () => {
// Prefer local context (inside PromptInput) as it has validation, fall back to provider
const provider = useOptionalProviderAttachments(); const provider = useOptionalProviderAttachments();
const local = useContext(LocalAttachmentsContext); const local = useContext(LocalAttachmentsContext);
const context = local ?? provider; const context = local ?? provider;
@@ -384,10 +360,6 @@ export const usePromptInputAttachments = () => {
return context; return context;
}; };
// ============================================================================
// Referenced Sources (Local to PromptInput)
// ============================================================================
export interface ReferencedSourcesContext { export interface ReferencedSourcesContext {
sources: (SourceDocumentUIPart & { id: string })[]; sources: (SourceDocumentUIPart & { id: string })[];
add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void; add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void;
@@ -490,16 +462,11 @@ export type PromptInputProps = Omit<
HTMLAttributes<HTMLFormElement>, HTMLAttributes<HTMLFormElement>,
"onSubmit" | "onError" "onSubmit" | "onError"
> & { > & {
// e.g., "image/*" or leave undefined for any
accept?: string; accept?: string;
multiple?: boolean; multiple?: boolean;
// When true, accepts drops anywhere on document. Default false (opt-in).
globalDrop?: boolean; globalDrop?: boolean;
// Render a hidden input with given name and keep it in sync for native form posts. Default false.
syncHiddenInput?: boolean; syncHiddenInput?: boolean;
// Minimal constraints
maxFiles?: number; maxFiles?: number;
// bytes
maxFileSize?: number; maxFileSize?: number;
onError?: (err: { onError?: (err: {
code: "max_files" | "max_file_size" | "accept"; code: "max_files" | "max_file_size" | "accept";
@@ -524,24 +491,19 @@ export const PromptInput = ({
children, children,
...props ...props
}: PromptInputProps) => { }: PromptInputProps) => {
// Try to use a provider controller if present
const controller = useOptionalPromptInputController(); const controller = useOptionalPromptInputController();
const usingProvider = !!controller; const usingProvider = !!controller;
// Refs
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const formRef = useRef<HTMLFormElement | null>(null); const formRef = useRef<HTMLFormElement | null>(null);
// ----- Local attachments (only used when no provider)
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
const files = usingProvider ? controller.attachments.files : items; const files = usingProvider ? controller.attachments.files : items;
// ----- Local referenced sources (always local to PromptInput)
const [referencedSources, setReferencedSources] = useState< const [referencedSources, setReferencedSources] = useState<
(SourceDocumentUIPart & { id: string })[] (SourceDocumentUIPart & { id: string })[]
>([]); >([]);
// Keep a ref to files for cleanup on unmount (avoids stale closure)
const filesRef = useRef(files); const filesRef = useRef(files);
useEffect(() => { useEffect(() => {
@@ -565,7 +527,6 @@ export const PromptInput = ({
return patterns.some((pattern) => { return patterns.some((pattern) => {
if (pattern.endsWith("/*")) { if (pattern.endsWith("/*")) {
// e.g: image/* -> image/
const prefix = pattern.slice(0, -1); const prefix = pattern.slice(0, -1);
return f.type.startsWith(prefix); return f.type.startsWith(prefix);
} }
@@ -638,7 +599,6 @@ export const PromptInput = ({
[] []
); );
// Wrapper that validates files before calling provider's add
const addWithProviderValidation = useCallback( const addWithProviderValidation = useCallback(
(fileList: File[] | FileList) => { (fileList: File[] | FileList) => {
const incoming = [...fileList]; const incoming = [...fileList];
@@ -713,7 +673,6 @@ export const PromptInput = ({
clearReferencedSources(); clearReferencedSources();
}, [clearAttachments, clearReferencedSources]); }, [clearAttachments, clearReferencedSources]);
// Let provider know about our hidden file input so external menus can call openFileDialog()
useEffect(() => { useEffect(() => {
if (!usingProvider) { if (!usingProvider) {
return; return;
@@ -721,22 +680,19 @@ export const PromptInput = ({
controller.__registerFileInput(inputRef, () => inputRef.current?.click()); controller.__registerFileInput(inputRef, () => inputRef.current?.click());
}, [usingProvider, controller]); }, [usingProvider, controller]);
// Note: File input cannot be programmatically set for security reasons // Browsers forbid programmatically populating file inputs.
// The syncHiddenInput prop is no longer functional
useEffect(() => { useEffect(() => {
if (syncHiddenInput && inputRef.current && files.length === 0) { if (syncHiddenInput && inputRef.current && files.length === 0) {
inputRef.current.value = ""; inputRef.current.value = "";
} }
}, [files, syncHiddenInput]); }, [files, syncHiddenInput]);
// Attach drop handlers on nearest form and document (opt-in)
useEffect(() => { useEffect(() => {
const form = formRef.current; const form = formRef.current;
if (!form) { if (!form) {
return; return;
} }
if (globalDrop) { if (globalDrop) {
// when global drop is on, let the document-level handler own drops
return; return;
} }
@@ -805,7 +761,6 @@ export const PromptInput = ({
if (event.currentTarget.files) { if (event.currentTarget.files) {
add(event.currentTarget.files); add(event.currentTarget.files);
} }
// Reset input value to allow selecting files that were previously removed
event.currentTarget.value = ""; event.currentTarget.value = "";
}, },
[add] [add]
@@ -853,19 +808,16 @@ export const PromptInput = ({
return (formData.get("message") as string) || ""; return (formData.get("message") as string) || "";
})(); })();
// Reset form immediately after capturing text to avoid race condition // Reset before async conversion so new input is not cleared afterward.
// where user input during async blob conversion would be lost
if (!usingProvider) { if (!usingProvider) {
form.reset(); form.reset();
} }
try { try {
// Convert blob URLs to data URLs asynchronously
const convertedFiles: FileUIPart[] = await Promise.all( const convertedFiles: FileUIPart[] = await Promise.all(
files.map(async ({ id: _id, ...item }) => { files.map(async ({ id: _id, ...item }) => {
if (item.url?.startsWith("blob:")) { if (item.url?.startsWith("blob:")) {
const dataUrl = await convertBlobUrlToDataUrl(item.url); const dataUrl = await convertBlobUrlToDataUrl(item.url);
// If conversion failed, keep the original blob URL
return { return {
...item, ...item,
url: dataUrl ?? item.url, url: dataUrl ?? item.url,
@@ -877,7 +829,6 @@ export const PromptInput = ({
const result = onSubmit({ files: convertedFiles, text }, event); const result = onSubmit({ files: convertedFiles, text }, event);
// Handle both sync and async onSubmit
if (result instanceof Promise) { if (result instanceof Promise) {
try { try {
await result; await result;
@@ -886,23 +837,21 @@ export const PromptInput = ({
controller.textInput.clear(); controller.textInput.clear();
} }
} catch { } catch {
// Don't clear on error - user may want to retry // Preserve failed submissions for retry.
} }
} else { } else {
// Sync function completed without throwing, clear inputs
clear(); clear();
if (usingProvider) { if (usingProvider) {
controller.textInput.clear(); controller.textInput.clear();
} }
} }
} catch { } catch {
// Don't clear on error - user may want to retry // Preserve failed submissions for retry.
} }
}, },
[usingProvider, controller, files, onSubmit, clear] [usingProvider, controller, files, onSubmit, clear]
); );
// Render with or without local provider
const inner = ( const inner = (
<> <>
<input <input
@@ -932,7 +881,6 @@ export const PromptInput = ({
</LocalReferencedSourcesContext.Provider> </LocalReferencedSourcesContext.Provider>
); );
// Always provide LocalAttachmentsContext so children get validated add function
return ( return (
<LocalAttachmentsContext.Provider value={attachmentsCtx}> <LocalAttachmentsContext.Provider value={attachmentsCtx}>
{withReferencedSources} {withReferencedSources}
@@ -966,10 +914,8 @@ export const PromptInputTextarea = ({
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback( const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
(e) => { (e) => {
// Call the external onKeyDown handler first
onKeyDown?.(e); onKeyDown?.(e);
// If the external handler prevented default, don't run internal logic
if (e.defaultPrevented) { if (e.defaultPrevented) {
return; return;
} }
@@ -983,7 +929,6 @@ export const PromptInputTextarea = ({
} }
e.preventDefault(); e.preventDefault();
// Check if the submit button is disabled before submitting
const { form } = e.currentTarget; const { form } = e.currentTarget;
const submitButton = form?.querySelector( const submitButton = form?.querySelector(
'button[type="submit"]' 'button[type="submit"]'
@@ -995,7 +940,6 @@ export const PromptInputTextarea = ({
form?.requestSubmit(); form?.requestSubmit();
} }
// Remove last attachment when Backspace is pressed and textarea is empty
if ( if (
e.key === "Backspace" && e.key === "Backspace" &&
e.currentTarget.value === "" && e.currentTarget.value === "" &&
@@ -1205,9 +1149,6 @@ export const PromptInputActionMenuItem = ({
<DropdownMenuItem className={cn(className)} {...props} /> <DropdownMenuItem className={cn(className)} {...props} />
); );
// Note: Actions that perform side-effects (like opening a file dialog)
// are provided in opt-in modules (e.g., prompt-input-attachments).
export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & { export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
status?: ChatStatus; status?: ChatStatus;
onStop?: () => void; onStop?: () => void;
@@ -1364,7 +1305,6 @@ export const PromptInputTabLabel = ({
className, className,
...props ...props
}: PromptInputTabLabelProps) => ( }: PromptInputTabLabelProps) => (
// Content provided via children in props
// oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content) // oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
<h3 <h3
className={cn( className={cn(
-4
View File
@@ -63,7 +63,6 @@ export const Reasoning = memo(
...props ...props
}: ReasoningProps) => { }: ReasoningProps) => {
const resolvedDefaultOpen = defaultOpen ?? isStreaming; const resolvedDefaultOpen = defaultOpen ?? isStreaming;
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
const isExplicitlyClosed = defaultOpen === false; const isExplicitlyClosed = defaultOpen === false;
const [isOpen, setIsOpen] = useControllableState<boolean>({ const [isOpen, setIsOpen] = useControllableState<boolean>({
@@ -80,7 +79,6 @@ export const Reasoning = memo(
const [hasAutoClosed, setHasAutoClosed] = useState(false); const [hasAutoClosed, setHasAutoClosed] = useState(false);
const startTimeRef = useRef<number | null>(null); const startTimeRef = useRef<number | null>(null);
// Track when streaming starts and compute duration
useEffect(() => { useEffect(() => {
if (isStreaming) { if (isStreaming) {
hasEverStreamedRef.current = true; hasEverStreamedRef.current = true;
@@ -93,14 +91,12 @@ export const Reasoning = memo(
} }
}, [isStreaming, setDuration]); }, [isStreaming, setDuration]);
// Auto-open when streaming starts (unless explicitly closed)
useEffect(() => { useEffect(() => {
if (isStreaming && !isOpen && !isExplicitlyClosed) { if (isStreaming && !isOpen && !isExplicitlyClosed) {
setIsOpen(true); setIsOpen(true);
} }
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]); }, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
// Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => { useEffect(() => {
if ( if (
hasEverStreamedRef.current && hasEverStreamedRef.current &&
-1
View File
@@ -8,7 +8,6 @@ import { memo, useMemo } from "react";
type MotionHTMLProps = MotionProps & Record<string, unknown>; type MotionHTMLProps = MotionProps & Record<string, unknown>;
// Cache motion components at module level to avoid creating during render
const motionComponentCache = new Map< const motionComponentCache = new Map<
keyof JSX.IntrinsicElements, keyof JSX.IntrinsicElements,
React.ComponentType<MotionHTMLProps> React.ComponentType<MotionHTMLProps>
-5
View File
@@ -2,15 +2,10 @@ import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
interface LogoProps { interface LogoProps {
/** Logo image URL. Falls back to the store name as a wordmark when absent. */
src?: string | null; src?: string | null;
/** Wordmark text, and the image's alt text. */
storeName?: string; storeName?: string;
/** Where the logo links to. Pass null to render it unwrapped. */
href?: string | null; href?: string | null;
/** Sizing for the image — height only, so the aspect ratio is preserved. */
imageClassName?: string; imageClassName?: string;
/** Sizing and weight for the wordmark fallback. */
textClassName?: string; textClassName?: string;
} }
+28 -137
View File
@@ -1,164 +1,55 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useMemo } from 'react';
import { useParams, usePathname, useRouter } from 'next/navigation';
import { Editor, outlinePlugin, type Data } from '@reacteditor/core'; import { Editor, outlinePlugin, type Data } from '@reacteditor/core';
import { Loader } from '@/components/ui/loader';
import createTailwindCdnPlugin from '@reacteditor/plugin-tailwind-cdn'; import createTailwindCdnPlugin from '@reacteditor/plugin-tailwind-cdn';
import { createShopifyPlugin } from '@reacteditor/plugin-shopify'; import { createShopifyPlugin } from '@reacteditor/plugin-shopify';
import { appConfig } from '@/editor.config'; import { appConfig } from '@/editor.config';
import { ROUTE_KEYS, editorHref, findPageRoute } from '@/lib/pages'; import { publishPage } from '@/lib/publish-page';
import globals from '@/app.globals.json';
import {
SHOPIFY_API_VERSION,
SHOPIFY_PUBLIC_ACCESS_TOKEN,
SHOPIFY_STORE_DOMAIN,
} from '@/services/shopify/config';
// Plugin instances must keep a stable identity across renders, same as const plugins = [
// `appConfig`, so they are built once at module scope. outlinePlugin(),
// createTailwindCdnPlugin(),
// The Tailwind CDN plugin only styles the editor's preview iframe; the public createShopifyPlugin({
// routes still get their utilities from the compiled `app/globals.css`. // Same resolved config as the storefront, including the mock.shop fallback.
const tailwindCdn = createTailwindCdnPlugin(); storeDomain: SHOPIFY_STORE_DOMAIN,
publicAccessToken: SHOPIFY_PUBLIC_ACCESS_TOKEN,
// Registers the `shopifyProduct` and `shopifyCollection` field types used by apiVersion: SHOPIFY_API_VERSION,
// 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 { export interface PageEditorProps {
/** Route key from `lib/pages.ts`, e.g. `/products/[handle]`. */ pagePath: string;
routeKey: string; page: Record<string, unknown>;
} }
/** export default function PageEditor({ pagePath, page }: PageEditorProps) {
* Shared editor shell, mounted by each route's `editor/page.tsx` child. The const data = useMemo(
* public routes mount `PageRender` against the same `appConfig`, so the two () => ({ ...page, globals }) as unknown as Data,
* never drift. [page]
*
* 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( const handlePublish = useCallback(
async (published: Data) => { async (published: Data) => {
setStatus('Saving…'); const result = await publishPage(pagePath, published);
if (result.error) throw new Error(result.error);
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] [pagePath]
); );
// 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 ( return (
<Editor <Editor
key={routeKey} theme="light"
config={appConfig as any} config={appConfig as any}
data={data} data={data}
plugins={plugins} 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} 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
View File
@@ -10,20 +10,6 @@ export type PageData = {
globals?: 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:
*
* 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 }) { export default function PageRender({ page }: { page: PageData }) {
const data = { root: page.root, content: page.content, globals }; const data = { root: page.root, content: page.content, globals };
return <Render config={appConfig as any} data={data as any} />; return <Render config={appConfig as any} data={data as any} />;
-142
View File
@@ -1,142 +0,0 @@
'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
@@ -1,112 +0,0 @@
'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;
@@ -1,34 +0,0 @@
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
@@ -1,83 +0,0 @@
'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
@@ -1,118 +0,0 @@
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
@@ -1,170 +0,0 @@
'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;
+2 -13
View File
@@ -26,7 +26,7 @@ import {
EmptyDescription, EmptyDescription,
EmptyContent, EmptyContent,
} from '@/components/ui/empty'; } from '@/components/ui/empty';
import { isDefaultTitleSelection } from '@/services/shopify/catalog'; import { isDefaultTitleSelection } from '@/services/shopify/shop';
const CartDrawer: React.FC = () => { const CartDrawer: React.FC = () => {
const isOpen = useCartStore((s) => s.isOpen); const isOpen = useCartStore((s) => s.isOpen);
@@ -40,8 +40,7 @@ const CartDrawer: React.FC = () => {
const [discountCode, setDiscountCode] = useState(''); const [discountCode, setDiscountCode] = useState('');
const [discountError, setDiscountError] = useState<string | null>(null); const [discountError, setDiscountError] = useState<string | null>(null);
const [applyingDiscount, setApplyingDiscount] = useState(false); const [applyingDiscount, setApplyingDiscount] = useState(false);
// The store's `loading` flag is global, so track the specific line being // Track row-level work separately from the cart's global loading state.
// changed to keep the other rows interactive.
const [pendingLineId, setPendingLineId] = useState<string | null>(null); const [pendingLineId, setPendingLineId] = useState<string | null>(null);
const runLineAction = async (lineId: string, action: () => Promise<unknown>) => { const runLineAction = async (lineId: string, action: () => Promise<unknown>) => {
@@ -80,7 +79,6 @@ const CartDrawer: React.FC = () => {
setDiscountError(null); setDiscountError(null);
const updatedCart = await applyDiscountCode(code); const updatedCart = await applyDiscountCode(code);
// Shopify accepts unknown codes silently, flagging them as inapplicable.
const accepted = updatedCart.discountCodes?.some( const accepted = updatedCart.discountCodes?.some(
(discount) => (discount) =>
discount.applicable && discount.applicable &&
@@ -104,7 +102,6 @@ const CartDrawer: React.FC = () => {
}; };
const getSelectedOptions = (item: (typeof items)[0]) => { const getSelectedOptions = (item: (typeof items)[0]) => {
// Single-SKU items carry a synthetic `Title: Default Title` — not worth a line.
return (item.merchandise.selectedOptions ?? []).filter( return (item.merchandise.selectedOptions ?? []).filter(
(option) => !isDefaultTitleSelection(option) (option) => !isDefaultTitleSelection(option)
); );
@@ -119,7 +116,6 @@ const CartDrawer: React.FC = () => {
<AnimatePresence> <AnimatePresence>
{isOpen && ( {isOpen && (
<SheetContent className="w-full max-w-md" showCloseButton={false}> <SheetContent className="w-full max-w-md" showCloseButton={false}>
{/* Header */}
<SheetHeader className="min-h-0 px-5 py-4 border-b-0"> <SheetHeader className="min-h-0 px-5 py-4 border-b-0">
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<SheetTitle className="text-base font-medium flex items-center gap-x-2"> <SheetTitle className="text-base font-medium flex items-center gap-x-2">
@@ -140,7 +136,6 @@ const CartDrawer: React.FC = () => {
</div> </div>
</SheetHeader> </SheetHeader>
{/* Cart Items */}
<SheetBody className="px-5"> <SheetBody className="px-5">
{loading && items.length === 0 ? ( {loading && items.length === 0 ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@@ -169,7 +164,6 @@ const CartDrawer: React.FC = () => {
return ( return (
<div key={item.id} className="flex items-start gap-x-3"> <div key={item.id} className="flex items-start gap-x-3">
{/* Product Image */}
<div className="w-16 h-16 bg-zinc-100 overflow-hidden shrink-0"> <div className="w-16 h-16 bg-zinc-100 overflow-hidden shrink-0">
{image ? ( {image ? (
<Image <Image
@@ -186,7 +180,6 @@ const CartDrawer: React.FC = () => {
)} )}
</div> </div>
{/* Product Details */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-x-3"> <div className="flex items-start justify-between gap-x-3">
<h4 className="text-sm font-medium text-foreground line-clamp-2"> <h4 className="text-sm font-medium text-foreground line-clamp-2">
@@ -209,7 +202,6 @@ const CartDrawer: React.FC = () => {
</div> </div>
)} )}
{/* Quantity Controls */}
<div className="flex items-center gap-x-2 mt-2"> <div className="flex items-center gap-x-2 mt-2">
<div className="inline-flex items-center rounded-full bg-secondary"> <div className="inline-flex items-center rounded-full bg-secondary">
<Button <Button
@@ -274,10 +266,8 @@ const CartDrawer: React.FC = () => {
)} )}
</SheetBody> </SheetBody>
{/* Footer — discount, total, checkout */}
{items.length > 0 && ( {items.length > 0 && (
<div className="px-5 py-5 space-y-4"> <div className="px-5 py-5 space-y-4">
{/* Discount Code */}
<form onSubmit={handleApplyDiscount} className="flex gap-x-2"> <form onSubmit={handleApplyDiscount} className="flex gap-x-2">
<input <input
type="text" type="text"
@@ -309,7 +299,6 @@ const CartDrawer: React.FC = () => {
</p> </p>
)} )}
{/* Estimated total */}
<div> <div>
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<span className="text-base text-foreground"> <span className="text-base text-foreground">
-2
View File
@@ -25,7 +25,6 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
href={`/collections/${collection.handle}`} href={`/collections/${collection.handle}`}
className="group block h-full" className="group block h-full"
> >
{/* Collection Image */}
<div className="relative aspect-square overflow-hidden"> <div className="relative aspect-square overflow-hidden">
{collection.image ? ( {collection.image ? (
<Image <Image
@@ -42,7 +41,6 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
)} )}
</div> </div>
{/* Collection Info */}
<div className="flex flex-col flex-1 py-2.5"> <div className="flex flex-col flex-1 py-2.5">
<h3 className="text-sm font-medium text-foreground line-clamp-1"> <h3 className="text-sm font-medium text-foreground line-clamp-1">
{collection.title} {collection.title}
@@ -8,11 +8,6 @@ export type CollectionDetailBlockProps = {
title?: string; 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> = { const collectionDetailEditor: ComponentConfig<CollectionDetailBlockProps> = {
label: 'Collection page', label: 'Collection page',
icon: <Boxes size={16} />, icon: <Boxes size={16} />,
+6 -14
View File
@@ -33,12 +33,7 @@ const SORT_OPTIONS: SortOption[] = [
]; ];
interface CollectionDetailProps { 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; handle?: string;
/** Overrides the collection's own title. Empty falls back to Shopify's. */
title?: string; title?: string;
} }
@@ -65,15 +60,12 @@ const CollectionDetail: React.FC<CollectionDetailProps> = ({
const sort = SORT_OPTIONS[sortIndex]; const sort = SORT_OPTIONS[sortIndex];
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]); const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
// Fall back to the handle until the collection's real title arrives.
const formattedTitle = handle const formattedTitle = handle
? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()) ? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
: 'Collection'; : 'Collection';
useEffect(() => { useEffect(() => {
// Reached when the editor was opened on the literal `[handle]` pattern // The literal editor route pattern has no collection to load.
// rather than a real collection URL. Drop the skeleton and say so rather
// than spinning forever.
if (!handle || handle === '[handle]') { if (!handle || handle === '[handle]') {
setLoading(false); setLoading(false);
setError( setError(
@@ -106,8 +98,7 @@ const CollectionDetail: React.FC<CollectionDetailProps> = ({
setProducts(page.products); setProducts(page.products);
setCursor(page.endCursor); setCursor(page.endCursor);
setHasNextPage(page.hasNextPage); setHasNextPage(page.hasNextPage);
// Keep the facet list stable while a selection is active, so options // Do not remove active facet choices as result counts change.
// don't disappear out from under the panel.
if (activeFilters.length === 0) setFilters(page.filters); if (activeFilters.length === 0) setFilters(page.filters);
} catch (err) { } catch (err) {
if (cancelled) return; if (cancelled) return;
@@ -169,7 +160,10 @@ const CollectionDetail: React.FC<CollectionDetailProps> = ({
</div> </div>
<div className="mt-8"> <div className="mt-8">
{loading ? ( {/* A missing or failed collection keeps the skeletons up rather than
showing an empty state, so the page stays presentable while a
handle is being chosen. */}
{loading || error ? (
<div className={GRID_CLASSES}> <div className={GRID_CLASSES}>
{Array.from({ length: 10 }).map((_, index) => ( {Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="animate-pulse"> <div key={index} className="animate-pulse">
@@ -181,8 +175,6 @@ const CollectionDetail: React.FC<CollectionDetailProps> = ({
</div> </div>
))} ))}
</div> </div>
) : error ? (
<p className="text-sm text-muted-foreground">{error}</p>
) : products.length === 0 ? ( ) : products.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{activeFilters.length > 0 {activeFilters.length > 0
+25 -1
View File
@@ -4,6 +4,7 @@ import React from 'react';
import { useCollections } from '@/hooks/use-shopify-collections'; import { useCollections } from '@/hooks/use-shopify-collections';
import CollectionCard from './collection-card'; import CollectionCard from './collection-card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
interface CollectionsProps { interface CollectionsProps {
title?: string; title?: string;
@@ -32,7 +33,15 @@ const Collections: React.FC<CollectionsProps> = ({
title = 'Our Collections', title = 'Our Collections',
subtitle = 'Discover our carefully crafted worlds', subtitle = 'Discover our carefully crafted worlds',
}) => { }) => {
const { collections, loading, error, refetch } = useCollections(12); const {
collections,
loading,
loadingMore,
error,
hasNextPage,
loadMore,
refetch,
} = useCollections(12);
if (loading) { if (loading) {
return ( return (
@@ -92,6 +101,21 @@ const Collections: React.FC<CollectionsProps> = ({
<CollectionCard key={collection.id} collection={collection} /> <CollectionCard key={collection.id} collection={collection} />
))} ))}
</div> </div>
{hasNextPage && (
<div className="mt-16 flex justify-center">
<Button
onClick={loadMore}
disabled={loadingMore}
variant="outline"
size="lg"
className="font-normal text-muted-foreground hover:text-foreground"
>
{loadingMore && <Loader size={16} />}
{loadingMore ? 'Loading' : 'See more'}
</Button>
</div>
)}
</div> </div>
</div> </div>
); );
+2 -17
View File
@@ -6,21 +6,11 @@ import Image from 'next/image';
export interface ContentSectionProps { export interface ContentSectionProps {
eyebrow?: React.ReactNode; eyebrow?: React.ReactNode;
heading?: React.ReactNode; heading?: React.ReactNode;
/**
* Plain text when it comes from a `page.json`; while the field is being
* edited inline the editor hands over a ReactNode instead, so this must not
* assume a string.
*/
body?: React.ReactNode; body?: React.ReactNode;
imageUrl?: string; imageUrl?: string;
imageAlt?: string; imageAlt?: string;
} }
/**
* Generic prose section for the non-commerce routes (about, landing copy).
* Typography matches the storefront's other sections — the editor supplies the
* words and the image, never the layout.
*/
const ContentSection: React.FC<ContentSectionProps> = ({ const ContentSection: React.FC<ContentSectionProps> = ({
eyebrow, eyebrow,
heading, heading,
@@ -48,8 +38,6 @@ const ContentSection: React.FC<ContentSectionProps> = ({
<div className="relative mt-10 aspect-[3/2] overflow-hidden"> <div className="relative mt-10 aspect-[3/2] overflow-hidden">
<Image <Image
src={imageUrl} src={imageUrl}
// `heading` is a node while it's edited inline, and alt text
// has to be a string — fall back to empty rather than stringify.
alt={imageAlt || (typeof heading === 'string' ? heading : '')} alt={imageAlt || (typeof heading === 'string' ? heading : '')}
fill fill
sizes="(min-width: 768px) 42rem, 100vw" sizes="(min-width: 768px) 42rem, 100vw"
@@ -61,14 +49,11 @@ const ContentSection: React.FC<ContentSectionProps> = ({
{body && ( {body && (
<div className="mt-8 flex flex-col gap-4 text-[15px] leading-7 text-foreground"> <div className="mt-8 flex flex-col gap-4 text-[15px] leading-7 text-foreground">
{typeof body === 'string' {typeof body === 'string'
? // Authored as plain paragraphs; blank lines separate them. ? body
body
.split(/\n{2,}/) .split(/\n{2,}/)
.filter((paragraph) => paragraph.trim()) .filter((paragraph) => paragraph.trim())
.map((paragraph, index) => <p key={index}>{paragraph}</p>) .map((paragraph, index) => <p key={index}>{paragraph}</p>)
: // Mid-edit: render the editor's node as-is so inline editing : body}
// keeps working. Paragraph splitting resumes once saved.
body}
</div> </div>
)} )}
</div> </div>
+2 -5
View File
@@ -45,9 +45,6 @@ const Footer: React.FC<FooterProps> = ({
return ( return (
<footer className="bg-background"> <footer className="bg-background">
<div className="max-w-screen-2xl mx-auto px-8 py-10"> <div className="max-w-screen-2xl mx-auto px-8 py-10">
{/* Left-aligned: the bottom-right corner belongs to the floating
assistant launcher. Links and socials sit on separate rows so the
icons aren't crammed onto the end of the link list. */}
<div className="flex flex-col items-center gap-y-5 sm:items-start"> <div className="flex flex-col items-center gap-y-5 sm:items-start">
<Logo <Logo
src={logoUrl} src={logoUrl}
@@ -57,9 +54,9 @@ const Footer: React.FC<FooterProps> = ({
/> />
<nav className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 sm:justify-start"> <nav className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 sm:justify-start">
{links.map((link) => ( {links.map((link, index) => (
<a <a
key={link.label} key={`${link.url}-${index}`}
href={link.url} href={link.url}
className="text-sm text-muted-foreground hover:text-foreground transition-colors" className="text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
@@ -11,16 +11,10 @@ export type HeaderBlockProps = {
links?: Array<{ label: string; url: string }>; links?: Array<{ label: string; url: string }>;
}; };
/**
* Content only: the store name, the logo, the announcement copy and the nav
* labels. Stickiness, backdrop blur, spacing and the cart/search/account
* affordances are the component's business, not the editor's.
*/
const headerEditor: ComponentConfig<HeaderBlockProps> = { const headerEditor: ComponentConfig<HeaderBlockProps> = {
label: 'Header', label: 'Header',
icon: <Menu size={16} />, icon: <Menu size={16} />,
category: 'navigation', category: 'navigation',
// Shared across pages: edit it once and every page.json picks it up.
global: true, global: true,
defaultProps: { defaultProps: {
storeName: 'Shop', storeName: 'Shop',
@@ -47,9 +41,7 @@ const headerEditor: ComponentConfig<HeaderBlockProps> = {
announcement: { announcement: {
label: 'Announcement', label: 'Announcement',
type: 'text', type: 'text',
// Deliberately not `contentEditable`: an inline-edited field arrives as a // Keep this a string so blank copy can hide the announcement.
// ReactNode, which stays truthy once emptied, so the bar would linger as
// an empty band. Keeping it a plain string makes "blank hides it" work.
placeholder: 'Leave empty to hide the bar', placeholder: 'Leave empty to hide the bar',
}, },
announcementUrl: { label: 'Announcement link', type: 'text' }, announcementUrl: { label: 'Announcement link', type: 'text' },
@@ -76,9 +68,6 @@ const headerEditor: ComponentConfig<HeaderBlockProps> = {
storeName={storeName} storeName={storeName}
logoUrl={logoUrl} logoUrl={logoUrl}
links={links} links={links}
// Header decides visibility: blank copy hides the bar, and the toggle
// hides it regardless — needed because an inline-edited field is a node,
// which can't be inspected for emptiness.
showAnnouncement={showAnnouncement} showAnnouncement={showAnnouncement}
announcement={announcement} announcement={announcement}
announcementUrl={announcementUrl} announcementUrl={announcementUrl}
+1 -13
View File
@@ -5,7 +5,6 @@ import Link from 'next/link';
import { useCartStore } from '@/hooks/use-shopify-cart'; import { useCartStore } from '@/hooks/use-shopify-cart';
import CartDrawer from '@/components/shopify/cart-drawer'; import CartDrawer from '@/components/shopify/cart-drawer';
import SearchDialog from '@/components/shopify/search-dialog'; import SearchDialog from '@/components/shopify/search-dialog';
import AccountMenu from '@/components/shopify/account-menu';
import ShopMenu from '@/components/shopify/shop-menu'; import ShopMenu from '@/components/shopify/shop-menu';
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react'; import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -44,10 +43,8 @@ interface HeaderProps {
storeName?: string; storeName?: string;
logoUrl?: string; logoUrl?: string;
links?: NavLink[]; links?: NavLink[];
/** Thin bar above the nav. Blank copy hides it. */
announcement?: React.ReactNode; announcement?: React.ReactNode;
announcementUrl?: string; announcementUrl?: string;
/** Hides the bar outright, whatever the copy says. */
showAnnouncement?: 'yes' | 'no'; showAnnouncement?: 'yes' | 'no';
} }
@@ -64,9 +61,7 @@ const Header: React.FC<HeaderProps> = ({
}) => { }) => {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
// Whitespace-only copy counts as empty. While the field is edited inline the // Inline editing supplies a node, so only string values can be blank-checked.
// editor supplies a node rather than a string — it can't be inspected for
// emptiness, which is what the explicit toggle is for.
const hasAnnouncementText = const hasAnnouncementText =
typeof announcement === 'string' typeof announcement === 'string'
? announcement.trim() !== '' ? announcement.trim() !== ''
@@ -76,7 +71,6 @@ const Header: React.FC<HeaderProps> = ({
return ( return (
<> <>
{/* Sits above the sticky nav, so it scrolls away on its own. */}
{showAnnouncementBar && ( {showAnnouncementBar && (
<div className="bg-muted text-foreground"> <div className="bg-muted text-foreground">
<div className="max-w-screen-2xl mx-auto flex h-9 items-center justify-center px-8 text-center text-xs"> <div className="max-w-screen-2xl mx-auto flex h-9 items-center justify-center px-8 text-center text-xs">
@@ -97,10 +91,8 @@ const Header: React.FC<HeaderProps> = ({
<nav className="bg-background/95 backdrop-blur-md sticky top-0 z-50"> <nav className="bg-background/95 backdrop-blur-md sticky top-0 z-50">
<div className="max-w-screen-2xl mx-auto px-8"> <div className="max-w-screen-2xl mx-auto px-8">
<div className="flex justify-between items-center h-14"> <div className="flex justify-between items-center h-14">
{/* Logo */}
<Logo src={logoUrl} storeName={storeName} /> <Logo src={logoUrl} storeName={storeName} />
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-x-8 text-sm"> <div className="hidden md:flex items-center gap-x-8 text-sm">
<ShopMenu /> <ShopMenu />
{links.map((link, index) => ( {links.map((link, index) => (
@@ -114,13 +106,10 @@ const Header: React.FC<HeaderProps> = ({
))} ))}
</div> </div>
{/* Actions */}
<div className="flex items-center"> <div className="flex items-center">
<SearchDialog /> <SearchDialog />
<AccountMenu />
<CartIcon /> <CartIcon />
{/* Mobile hamburger */}
<Button <Button
onClick={() => setMenuOpen(!menuOpen)} onClick={() => setMenuOpen(!menuOpen)}
variant="ghost" variant="ghost"
@@ -138,7 +127,6 @@ const Header: React.FC<HeaderProps> = ({
</div> </div>
</div> </div>
{/* Mobile Menu */}
{menuOpen && ( {menuOpen && (
<div className="md:hidden bg-background"> <div className="md:hidden bg-background">
<div className="max-w-screen-2xl mx-auto px-8 pb-6 flex flex-col gap-y-4 text-sm"> <div className="max-w-screen-2xl mx-auto px-8 pb-6 flex flex-col gap-y-4 text-sm">
-169
View File
@@ -1,169 +0,0 @@
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import { RiImageLine } from '@remixicon/react';
import type { Customer, CustomerOrder } from '@/services/shopify/customer';
interface OrderHistoryProps {
customer: Customer;
}
const formatMoney = (amount: string, currencyCode: string) => {
const value = parseFloat(amount);
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(value);
} catch {
return `$${value.toFixed(2)}`;
}
};
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
const StatusPill: React.FC<{ label?: string | null }> = ({ label }) => {
if (!label) return null;
return (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[11px] uppercase tracking-wide text-muted-foreground">
{label.replace(/_/g, ' ').toLowerCase()}
</span>
);
};
const OrderHistory: React.FC<OrderHistoryProps> = ({ customer }) => {
const orders: CustomerOrder[] =
customer.orders?.edges.map((edge) => edge.node) ?? [];
// The Storefront API has no standalone order-by-id query for customers, so
// the detail is rendered from the order already loaded in this list.
const [selectedId, setSelectedId] = useState<string | null>(
orders[0]?.id ?? null
);
const selected = orders.find((order) => order.id === selectedId) ?? null;
if (orders.length === 0) {
return (
<p className="text-sm text-muted-foreground">
You haven&apos;t placed any orders yet.
</p>
);
}
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-5">
{/* List */}
<div className="lg:col-span-2">
<h2 className="mb-3 text-sm text-muted-foreground">Orders</h2>
<ul className="flex flex-col">
{orders.map((order) => {
const isSelected = order.id === selectedId;
return (
<li key={order.id}>
<button
onClick={() => setSelectedId(order.id)}
aria-current={isSelected}
className={`flex w-full items-baseline justify-between gap-x-4 rounded-md px-3 py-3 text-left transition-colors ${
isSelected ? 'bg-secondary' : 'hover:bg-secondary/60'
}`}
>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">
Order #{order.orderNumber}
</span>
<span className="block text-xs text-muted-foreground">
{formatDate(order.processedAt)}
</span>
</span>
<span className="shrink-0 font-mono text-sm tabular-nums tracking-tight text-foreground">
{formatMoney(
order.currentTotalPrice.amount,
order.currentTotalPrice.currencyCode
)}
</span>
</button>
</li>
);
})}
</ul>
</div>
{/* Detail — same screen, no navigation */}
<div className="lg:col-span-3">
{selected && (
<>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
<h2 className="text-lg text-foreground">
Order #{selected.orderNumber}
</h2>
<span className="font-mono text-base tabular-nums tracking-tight text-foreground">
{formatMoney(
selected.currentTotalPrice.amount,
selected.currentTotalPrice.currencyCode
)}
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">
Placed {formatDate(selected.processedAt)}
</span>
<StatusPill label={selected.financialStatus} />
<StatusPill label={selected.fulfillmentStatus} />
</div>
<ul className="mt-6 flex flex-col gap-4">
{selected.lineItems.edges.map(({ node }, index) => (
<li key={index} className="flex items-start gap-x-3">
<div className="h-16 w-16 shrink-0 overflow-hidden bg-zinc-100">
{node.variant?.image ? (
<Image
src={node.variant.image.url}
alt={node.variant.image.altText || node.title}
width={64}
height={64}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-zinc-400">
<RiImageLine className="size-5" />
</div>
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">
{node.title}
</p>
<p className="text-xs text-muted-foreground">
Qty {node.quantity}
</p>
</div>
</li>
))}
</ul>
{selected.statusUrl && (
<a
href={selected.statusUrl}
target="_blank"
rel="noreferrer"
className="mt-6 inline-block text-sm text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
View order status
</a>
)}
</>
)}
</div>
</div>
);
};
export default OrderHistory;
@@ -18,7 +18,6 @@ const policyBodyEditor: ComponentConfig<PolicyBodyProps> = {
handle: { handle: {
label: 'Policy', label: 'Policy',
type: 'select', type: 'select',
// Empty follows the `[handle]` route segment on /policies/[handle].
options: [ options: [
{ label: 'Follow the page URL', value: '' }, { label: 'Follow the page URL', value: '' },
...POLICY_HANDLES.map((handle) => ({ ...POLICY_HANDLES.map((handle) => ({
-9
View File
@@ -5,20 +5,11 @@ import { useParams } from 'next/navigation';
import { getShopPolicy, type ShopPolicy } from '@/hooks/use-shopify-policies'; import { getShopPolicy, type ShopPolicy } from '@/hooks/use-shopify-policies';
export interface PolicyBodyProps { export interface PolicyBodyProps {
/**
* Pins the block to one policy. Left empty, it reads the `[handle]` segment,
* which is what the `/policies/[handle]` template does.
*/
handle?: string; handle?: string;
/** Overrides the policy's own title. Empty falls back to Shopify's. */
title?: string; title?: string;
notFoundMessage?: string; notFoundMessage?: string;
} }
/**
* Policy copy is authored in the Shopify admin, not here — the editor only
* chooses which policy to show and can override the heading.
*/
const PolicyBody: React.FC<PolicyBodyProps> = ({ const PolicyBody: React.FC<PolicyBodyProps> = ({
handle: handleProp, handle: handleProp,
title: titleProp, title: titleProp,
-2
View File
@@ -66,7 +66,6 @@ const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
href={`/products/${product.handle}`} href={`/products/${product.handle}`}
className="group block h-full" className="group block h-full"
> >
{/* Product Image */}
<div className="relative aspect-square overflow-hidden"> <div className="relative aspect-square overflow-hidden">
{firstImage ? ( {firstImage ? (
<Image <Image
@@ -95,7 +94,6 @@ const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
)} )}
</div> </div>
{/* Product Info */}
<div className="flex flex-col flex-1 py-2.5"> <div className="flex flex-col flex-1 py-2.5">
<h3 className="text-sm font-medium text-foreground line-clamp-1"> <h3 className="text-sm font-medium text-foreground line-clamp-1">
{truncate(product.title, 65)} {truncate(product.title, 65)}
@@ -8,11 +8,6 @@ export type ProductDetailBlockProps = {
addToCartLabel?: string; addToCartLabel?: string;
}; };
/**
* On `/products/[handle]` leave `product` empty so the block follows the route;
* the picker then only chooses what the editor previews. Pinning a product
* turns it into a featured-product block usable on any page.
*/
const productDetailEditor: ComponentConfig<ProductDetailBlockProps> = { const productDetailEditor: ComponentConfig<ProductDetailBlockProps> = {
label: 'Product page', label: 'Product page',
icon: <Tag size={16} />, icon: <Tag size={16} />,
+4 -38
View File
@@ -6,14 +6,6 @@ import { useProduct, type Product } from '@/hooks/use-shopify-products';
import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart'; import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart';
import ProductDetailGallery from './product-detail-gallery'; import ProductDetailGallery from './product-detail-gallery';
import ProductDetailInfo from './product-detail-info'; import ProductDetailInfo from './product-detail-info';
import { Button } from '@/components/ui/button';
import {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
} from '@/components/ui/empty';
interface ProductVariant { interface ProductVariant {
id: string; id: string;
@@ -60,7 +52,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
const [addingToCart, setAddingToCart] = useState(false); const [addingToCart, setAddingToCart] = useState(false);
const [buyingNow, setBuyingNow] = useState(false); const [buyingNow, setBuyingNow] = useState(false);
// Initialize variant when product loads
useEffect(() => { useEffect(() => {
if (product) { if (product) {
const firstVariant = product.variants.edges[0]?.node; const firstVariant = product.variants.edges[0]?.node;
@@ -78,9 +69,7 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
} }
}, [product]); }, [product]);
// A value is available if some in-stock variant carries it alongside the // Unselected options act as wildcards when checking variant availability.
// other currently-selected options. Options the shopper hasn't chosen yet
// act as wildcards, so nothing is struck through before a full selection.
const isOptionValueAvailable = (optionName: string, value: string) => { const isOptionValueAvailable = (optionName: string, value: string) => {
const variants = product?.variants.edges ?? []; const variants = product?.variants.edges ?? [];
if (variants.length === 0) return true; if (variants.length === 0) return true;
@@ -100,7 +89,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
const newOptions = { ...selectedOptions, [optionName]: value }; const newOptions = { ...selectedOptions, [optionName]: value };
setSelectedOptions(newOptions); setSelectedOptions(newOptions);
// Find matching variant
const matchingVariant = product?.variants.edges.find(({ node }) => { const matchingVariant = product?.variants.edges.find(({ node }) => {
return node.selectedOptions.every( return node.selectedOptions.every(
(option) => newOptions[option.name] === option.value (option) => newOptions[option.name] === option.value
@@ -126,8 +114,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
} }
}; };
// Adds the item, then sends the shopper straight to the Shopify checkout
// (where Shop Pay is offered) rather than opening the cart drawer.
const handleBuyNow = async () => { const handleBuyNow = async () => {
if (!selectedVariant || !product) return; if (!selectedVariant || !product) return;
@@ -148,11 +134,12 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
} }
}; };
if (loading) { // A missing or failed product keeps the skeletons up rather than showing an
// empty state, so the page stays presentable while a handle is being chosen.
if (loading || error || !product) {
return ( return (
<div className="max-w-screen-2xl mx-auto px-8 py-8"> <div className="max-w-screen-2xl mx-auto px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10"> <div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10">
{/* Image Gallery Skeleton */}
<div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<div <div
@@ -162,7 +149,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
))} ))}
</div> </div>
{/* Product Info Skeleton */}
<div className="lg:col-span-2 animate-pulse"> <div className="lg:col-span-2 animate-pulse">
<div className="h-8 bg-zinc-100 w-2/3"></div> <div className="h-8 bg-zinc-100 w-2/3"></div>
<div className="h-5 bg-zinc-100 w-24 mt-2"></div> <div className="h-5 bg-zinc-100 w-24 mt-2"></div>
@@ -176,26 +162,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
); );
} }
if (error || !product) {
return (
<div className="container mx-auto px-4 py-8">
<Empty className="min-h-[400px]">
<EmptyHeader>
<EmptyTitle>Product Not Found</EmptyTitle>
<EmptyDescription>
{error || 'The requested product could not be found.'}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={() => window.history.back()} variant="outline">
Go Back
</Button>
</EmptyContent>
</Empty>
</div>
);
}
return ( return (
<div className="bg-background"> <div className="bg-background">
<div className="max-w-screen-2xl mx-auto px-8 py-8"> <div className="max-w-screen-2xl mx-auto px-8 py-8">
@@ -26,8 +26,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
const close = useCallback(() => setZoomedIndex(null), []); const close = useCallback(() => setZoomedIndex(null), []);
// The slide whose left edge sits closest to the scroller's left edge is the
// one in view. Measuring rects keeps this correct whatever the gap or width.
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const scroller = scrollerRef.current; const scroller = scrollerRef.current;
if (!scroller) return; if (!scroller) return;
@@ -47,8 +45,7 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
setActiveIndex(nearest); setActiveIndex(nearest);
}, []); }, []);
// Touch already scrolls natively; this adds click-and-drag for pointers that // Add pointer dragging without disrupting native touch scrolling.
// don't (mouse at mobile widths), suspending snap so the drag stays smooth.
const drag = useRef<{ startX: number; startScroll: number } | null>(null); const drag = useRef<{ startX: number; startScroll: number } | null>(null);
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => { const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
@@ -74,7 +71,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
if (!drag.current || !scroller) return; if (!drag.current || !scroller) return;
drag.current = null; drag.current = null;
// Restoring snap lets the browser settle on the nearest slide.
scroller.style.scrollSnapType = ''; scroller.style.scrollSnapType = '';
}; };
@@ -88,7 +84,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
scroller.scrollTo({ left: scroller.scrollLeft + offset, behavior: 'smooth' }); scroller.scrollTo({ left: scroller.scrollLeft + offset, behavior: 'smooth' });
}; };
// Close on Escape, and keep the page behind the overlay from scrolling.
useEffect(() => { useEffect(() => {
if (!isZoomed) return; if (!isZoomed) return;
@@ -119,7 +114,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
return ( return (
<> <>
{/* Swipeable carousel on mobile, grid from sm up */}
<div <div
ref={scrollerRef} ref={scrollerRef}
onScroll={handleScroll} onScroll={handleScroll}
@@ -152,7 +146,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
))} ))}
</div> </div>
{/* Carousel pagination — the grid needs no dots, so mobile only */}
{!isSingle && ( {!isSingle && (
<div className="flex justify-center gap-2 pt-4 sm:hidden"> <div className="flex justify-center gap-2 pt-4 sm:hidden">
{images.map((_, index) => ( {images.map((_, index) => (
@@ -182,15 +175,10 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
<Image <Image
src={zoomedImage.url} src={zoomedImage.url}
alt={zoomedImage.altText || 'Product image'} alt={zoomedImage.altText || 'Product image'}
// Shopify gives us the intrinsic size; the square fallback only
// reserves space until CSS scales it down to fit the overlay.
width={zoomedImage.width || 1600} width={zoomedImage.width || 1600}
height={zoomedImage.height || 1600} height={zoomedImage.height || 1600}
sizes="100vw" sizes="100vw"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
// w/h-auto keeps the box at the image's own ratio; without it the
// width+height attributes make both axes definite and the element
// stretches to the overlay, swallowing backdrop clicks that close it.
className="max-h-full max-w-full w-auto h-auto object-contain" className="max-h-full max-w-full w-auto h-auto object-contain"
/> />
@@ -5,7 +5,7 @@ import { RiSubtractLine, RiAddLine } from '@remixicon/react';
import ShopPayButton from '@/components/shopify/shop-pay-button'; import ShopPayButton from '@/components/shopify/shop-pay-button';
import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products'; import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products';
import { isSwatchOptionName, swatchColorForName } from '@/config/swatches'; import { isSwatchOptionName, swatchColorForName } from '@/config/swatches';
import { isDefaultTitleOption } from '@/services/shopify/catalog'; import { isDefaultTitleOption } from '@/services/shopify/shop';
interface ProductPrice { interface ProductPrice {
amount: string; amount: string;
@@ -52,15 +52,12 @@ interface ProductDetailInfoProps {
handleAddToCart: () => void; handleAddToCart: () => void;
handleBuyNow?: () => void; handleBuyNow?: () => void;
onOptionChange: (optionName: string, value: string) => void; onOptionChange: (optionName: string, value: string) => void;
/** Whether an option value still has an in-stock variant behind it. */
isOptionValueAvailable?: (optionName: string, value: string) => boolean; isOptionValueAvailable?: (optionName: string, value: string) => boolean;
loading?: boolean; loading?: boolean;
buyingNow?: boolean; buyingNow?: boolean;
addToCartLabel?: string; addToCartLabel?: string;
} }
// A swatch comes from the option value's own swatch (colour or image) or the
// colour its name implies (see config/swatches) — never a variant photo.
const swatchStyle = ( const swatchStyle = (
value: ProductOptionValue value: ProductOptionValue
): { background?: string; image?: string } => { ): { background?: string; image?: string } => {
@@ -103,13 +100,11 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
const isSwatchOption = (option: ProductOption) => const isSwatchOption = (option: ProductOption) =>
isSwatchOptionName(option.name); isSwatchOptionName(option.name);
// Some products return Size before Color; show the swatches first either way. // Show swatches first and omit Shopify's synthetic single-SKU option.
// Single-SKU products expose a synthetic `Title: Default Title` option — drop it.
const orderedOptions = [...(product.options ?? [])] const orderedOptions = [...(product.options ?? [])]
.filter((option) => !isDefaultTitleOption(option)) .filter((option) => !isDefaultTitleOption(option))
.sort((a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a))); .sort((a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a)));
// `optionValues` carries the swatch data; fall back to plain `values`.
const optionValuesFor = (option: ProductOption): ProductOptionValue[] => const optionValuesFor = (option: ProductOption): ProductOptionValue[] =>
option.optionValues?.length option.optionValues?.length
? option.optionValues ? option.optionValues
@@ -131,7 +126,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
)} )}
</div> </div>
{/* Product Options — colour swatches lead, whatever order the API returns */}
{orderedOptions.map((option) => { {orderedOptions.map((option) => {
const isSwatch = isSwatchOption(option); const isSwatch = isSwatchOption(option);
const selected = selectedOptions[option.name]; const selected = selectedOptions[option.name];
@@ -172,12 +166,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
: 'ring-1 ring-border hover:ring-foreground/40' : 'ring-1 ring-border hover:ring-foreground/40'
} ${isSoldOut ? 'option-unavailable text-foreground/60 opacity-60' : ''}`} } ${isSoldOut ? 'option-unavailable text-foreground/60 opacity-60' : ''}`}
style={{ style={{
// With no colour and no image the circle would be fully
// transparent, leaving just a hairline ring that
// antialiases unevenly and reads as a speckled border.
// A neutral fill makes the initial-letter fallback look
// deliberate. Set inline so it beats the ghost variant's
// hover background.
backgroundColor: backgroundColor:
background ?? background ??
(image ? undefined : 'var(--color-muted)'), (image ? undefined : 'var(--color-muted)'),
@@ -215,7 +203,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
); );
})} })}
{/* Quantity + Add to Cart */}
<div className="mt-8 flex items-stretch gap-3"> <div className="mt-8 flex items-stretch gap-3">
<div className="flex items-center rounded-md border border-border h-11"> <div className="flex items-center rounded-md border border-border h-11">
<Button <Button
@@ -252,7 +239,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
</Button> </Button>
</div> </div>
{/* Cart permalink into Shop Pay; falls back to the cart checkout URL. */}
<ShopPayButton <ShopPayButton
className="mt-3" className="mt-3"
variants={ variants={
@@ -263,7 +249,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
onFallbackClick={handleBuyNow} onFallbackClick={handleBuyNow}
/> />
{/* Description */}
{(product.descriptionHtml || product.description) && ( {(product.descriptionHtml || product.description) && (
<div className="mt-10 text-sm leading-6 text-foreground product-description"> <div className="mt-10 text-sm leading-6 text-foreground product-description">
{product.descriptionHtml ? ( {product.descriptionHtml ? (
-12
View File
@@ -12,13 +12,10 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { RiCloseLine, RiCheckLine } from '@remixicon/react'; import { RiCloseLine, RiCheckLine } from '@remixicon/react';
import { swatchColorForName } from '@/config/swatches'; import { swatchColorForName } from '@/config/swatches';
// Shape of a Storefront facet — identical for `search.productFilters` and
// `collection.products.filters`, so both pages share this panel.
export interface ProductFilterValue { export interface ProductFilterValue {
id: string; id: string;
label: string; label: string;
count: number; count: number;
/** JSON string accepted back as a `ProductFilter` input. */
input: string; input: string;
} }
@@ -37,7 +34,6 @@ interface ProductFiltersProps {
onActiveFiltersChange: (filters: string[]) => void; onActiveFiltersChange: (filters: string[]) => void;
} }
// Colour facets render as swatches; everything else as a labelled list.
const isColorFilter = (filter: ProductFilterFacet) => const isColorFilter = (filter: ProductFilterFacet) =>
/colou?r/i.test(filter.label) || filter.id.toLowerCase().includes('color'); /colou?r/i.test(filter.label) || filter.id.toLowerCase().includes('color');
@@ -55,7 +51,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
const priceFilter = filters.find((filter) => filter.type === 'PRICE_RANGE'); const priceFilter = filters.find((filter) => filter.type === 'PRICE_RANGE');
const activeSet = new Set(activeFilters); const activeSet = new Set(activeFilters);
// Price is rebuilt from the inputs rather than toggled, so track it apart.
const activePriceInput = activeFilters.find((input) => const activePriceInput = activeFilters.find((input) =>
input.includes('"price"') input.includes('"price"')
); );
@@ -93,7 +88,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
onActiveFiltersChange([]); onActiveFiltersChange([]);
}; };
// Labels for the chips at the top of the panel.
const activeChips = filters const activeChips = filters
.flatMap((filter) => filter.values) .flatMap((filter) => filter.values)
.filter((value) => activeSet.has(value.input)); .filter((value) => activeSet.has(value.input));
@@ -118,7 +112,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
</SheetHeader> </SheetHeader>
<SheetBody className="px-5"> <SheetBody className="px-5">
{/* Active selections */}
{(activeChips.length > 0 || activePriceInput) && ( {(activeChips.length > 0 || activePriceInput) && (
<div className="mb-6 flex flex-wrap items-center gap-2"> <div className="mb-6 flex flex-wrap items-center gap-2">
{activeChips.map((value) => ( {activeChips.map((value) => (
@@ -147,7 +140,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
</p> </p>
)} )}
{/* Price */}
{priceFilter && ( {priceFilter && (
<div className="mb-8"> <div className="mb-8">
<h3 className="mb-3 text-base text-foreground">Price</h3> <h3 className="mb-3 text-base text-foreground">Price</h3>
@@ -183,7 +175,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
</div> </div>
)} )}
{/* Facets */}
{listFilters.map((filter) => ( {listFilters.map((filter) => (
<div key={filter.id} className="mb-8"> <div key={filter.id} className="mb-8">
<h3 className="mb-3 text-base text-foreground"> <h3 className="mb-3 text-base text-foreground">
@@ -209,9 +200,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
: 'ring-1 ring-border hover:ring-foreground/40' : 'ring-1 ring-border hover:ring-foreground/40'
}`} }`}
style={{ style={{
// A colourless swatch would otherwise be a fully
// transparent circle behind a hairline ring,
// which antialiases into a speckled border.
backgroundColor: color ?? 'var(--color-muted)', backgroundColor: color ?? 'var(--color-muted)',
}} }}
> >
@@ -23,7 +23,6 @@ const productRecommendationsEditor: ComponentConfig<ProductRecommendationsBlockP
title: { label: 'Title', type: 'text', contentEditable: true }, title: { label: 'Title', type: 'text', contentEditable: true },
product: { product: {
label: 'Seed product', label: 'Seed product',
// Empty follows the `[handle]` route segment; Shopify picks the rest.
type: 'shopifyProduct', type: 'shopifyProduct',
} as any, } as any,
limit: { label: 'Products shown', type: 'number', min: 2, max: 12 }, limit: { label: 'Products shown', type: 'number', min: 2, max: 12 },
@@ -10,10 +10,6 @@ import ProductCard from './product-card';
interface ProductRecommendationsProps { interface ProductRecommendationsProps {
productId?: string; productId?: string;
/**
* Seeds recommendations from a specific product. Left empty, it reads the
* `[handle]` segment, which is what the `/products/[handle]` template does.
*/
handle?: string; handle?: string;
title?: string; title?: string;
limit?: number; limit?: number;
-3
View File
@@ -21,8 +21,6 @@ interface ProductToolbarProps {
activeFilterCount?: number; activeFilterCount?: number;
} }
// Filters trigger on the left, item count and sort menu on the right — shared
// by the search results and collection pages.
const ProductToolbar: React.FC<ProductToolbarProps> = ({ const ProductToolbar: React.FC<ProductToolbarProps> = ({
totalCount, totalCount,
onOpenFilters, onOpenFilters,
@@ -69,7 +67,6 @@ const ProductToolbar: React.FC<ProductToolbarProps> = ({
{sortOpen && ( {sortOpen && (
<> <>
{/* Click-away layer sits under the menu, above the page. */}
<div <div
className="fixed inset-0 z-40" className="fixed inset-0 z-40"
onClick={() => setSortOpen(false)} onClick={() => setSortOpen(false)}
@@ -10,10 +10,6 @@ export type ProductsBlockProps = {
limit?: number; limit?: number;
}; };
/**
* Column counts, gutters and the card design are fixed in `products.tsx`. The
* editor picks *which* products appear and what the section says about them.
*/
const productsEditor: ComponentConfig<ProductsBlockProps> = { const productsEditor: ComponentConfig<ProductsBlockProps> = {
label: 'Product grid', label: 'Product grid',
icon: <LayoutGrid size={16} />, icon: <LayoutGrid size={16} />,
@@ -30,8 +26,6 @@ const productsEditor: ComponentConfig<ProductsBlockProps> = {
subtitle: { label: 'Subtitle', type: 'textarea', contentEditable: true }, subtitle: { label: 'Subtitle', type: 'textarea', contentEditable: true },
collection: { collection: {
label: 'Collection', label: 'Collection',
// Registered by createShopifyPlugin — a live search against the store.
// Leave empty to show the newest products across the whole catalogue.
type: 'shopifyCollection', type: 'shopifyCollection',
} as any, } as any,
limit: { label: 'Products shown', type: 'number', min: 2, max: 48 }, limit: { label: 'Products shown', type: 'number', min: 2, max: 48 },
-8
View File
@@ -52,10 +52,6 @@ interface ProductsProps {
subtitle?: string; subtitle?: string;
limit?: number; limit?: number;
showLoadMore?: boolean; showLoadMore?: boolean;
/**
* Narrows the grid to one collection. Empty shows the newest products across
* the whole catalogue, which is what the home page does.
*/
collectionHandle?: string; collectionHandle?: string;
} }
@@ -73,8 +69,6 @@ const Products: React.FC<ProductsProps> = ({
const [hasMoreProducts, setHasMoreProducts] = useState(true); const [hasMoreProducts, setHasMoreProducts] = useState(true);
const [cursor, setCursor] = useState<string | null>(null); const [cursor, setCursor] = useState<string | null>(null);
// Paging is cursor-based: without `after`, Shopify returns the same first
// page every time and "load more" appends nothing.
const fetchProducts = async (loadMore = false) => { const fetchProducts = async (loadMore = false) => {
try { try {
if (loadMore) { if (loadMore) {
@@ -84,8 +78,6 @@ const Products: React.FC<ProductsProps> = ({
setError(null); setError(null);
} }
// A pinned collection uses the collection query so its own ordering
// applies; otherwise fall back to newest-first across the catalogue.
const page = collectionHandle const page = collectionHandle
? await getCollectionProductsPage(collectionHandle, { ? await getCollectionProductsPage(collectionHandle, {
first: limit, first: limit,
@@ -4,21 +4,20 @@ import SearchResults from '@/components/shopify/search-results';
export type SearchResultsBlockProps = { export type SearchResultsBlockProps = {
title?: string; title?: string;
placeholder?: string;
}; };
/**
* The result set comes from the `?q=` param and the shopper's own filter and
* sort choices, so the heading is the only thing left for the editor to own.
*/
const searchResultsEditor: ComponentConfig<SearchResultsBlockProps> = { const searchResultsEditor: ComponentConfig<SearchResultsBlockProps> = {
label: 'Search results', label: 'Search results',
icon: <Search size={16} />, icon: <Search size={16} />,
category: 'commerce', category: 'commerce',
defaultProps: { defaultProps: {
title: 'Search', title: 'Search',
placeholder: 'Search products',
}, },
fields: { fields: {
title: { label: 'Title', type: 'text', contentEditable: true }, title: { label: 'Title', type: 'text', contentEditable: true },
placeholder: { label: 'Search placeholder', type: 'text' },
}, },
render: (props) => <SearchResults {...props} />, render: (props) => <SearchResults {...props} />,
}; };
+58 -9
View File
@@ -1,12 +1,14 @@
'use client'; 'use client';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import ProductCard from './product-card'; import ProductCard from './product-card';
import ProductFilters from './product-filters'; import ProductFilters from './product-filters';
import ProductToolbar from './product-toolbar'; import ProductToolbar from './product-toolbar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Loader } from '@/components/ui/loader'; import { Loader } from '@/components/ui/loader';
import { RiSearchLine, RiCloseLine } from '@remixicon/react';
import { import {
searchProducts, searchProducts,
type SearchFilter, type SearchFilter,
@@ -30,11 +32,17 @@ const SORT_OPTIONS: SortOption[] = [
interface SearchResultsProps { interface SearchResultsProps {
title?: string; title?: string;
placeholder?: string;
} }
const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => { const SearchResults: React.FC<SearchResultsProps> = ({
title = 'Search',
placeholder = 'Search products',
}) => {
const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const query = searchParams.get('q') ?? ''; const query = searchParams.get('q') ?? '';
const [term, setTerm] = useState(query);
const [products, setProducts] = useState<Product[]>([]); const [products, setProducts] = useState<Product[]>([]);
const [filters, setFilters] = useState<SearchFilter[]>([]); const [filters, setFilters] = useState<SearchFilter[]>([]);
@@ -50,9 +58,27 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
const [activeFilters, setActiveFilters] = useState<string[]>([]); const [activeFilters, setActiveFilters] = useState<string[]>([]);
const sort = SORT_OPTIONS[sortIndex]; const sort = SORT_OPTIONS[sortIndex];
// Serialised so the effect re-runs when the selection changes, not the array. // Stabilize the effect dependency by filter contents.
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]); const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
// Keep the field in step with the URL, which also changes from the header
// search dialog and from back/forward navigation.
useEffect(() => {
setTerm(query);
}, [query]);
const submitSearch = (event: React.FormEvent) => {
event.preventDefault();
const next = term.trim();
if (next === query) return;
router.push(next ? `/search?q=${encodeURIComponent(next)}` : '/search');
};
const clearSearch = () => {
setTerm('');
if (query) router.push('/search');
};
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -62,8 +88,6 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
setError(null); setError(null);
const result = await searchProducts({ const result = await searchProducts({
// An empty term still returns the catalogue, which is what an
// unqualified /search visit should show.
query, query,
first: PAGE_SIZE, first: PAGE_SIZE,
sortKey: sort.sortKey, sortKey: sort.sortKey,
@@ -77,8 +101,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
setTotalCount(result.totalCount); setTotalCount(result.totalCount);
setCursor(result.endCursor); setCursor(result.endCursor);
setHasNextPage(result.hasNextPage); setHasNextPage(result.hasNextPage);
// Facet counts change with the result set, but keep the panel stable // Do not remove active facet choices as result counts change.
// while filters are applied so options don't vanish mid-selection.
if (activeFilters.length === 0) setFilters(result.filters); if (activeFilters.length === 0) setFilters(result.filters);
} catch (err) { } catch (err) {
if (cancelled) return; if (cancelled) return;
@@ -129,7 +152,34 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
{title} {title}
</h1> </h1>
<div className="mt-6"> <form onSubmit={submitSearch} className="mt-6 max-w-xl">
<div className="relative">
<RiSearchLine
size={18}
className="pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
type="text"
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder={placeholder}
aria-label="Search products"
className="h-11 rounded-none border-0 border-b border-border bg-transparent pl-7 pr-8 shadow-none focus-visible:border-foreground focus-visible:ring-0 dark:bg-transparent"
/>
{term && (
<button
type="button"
onClick={clearSearch}
aria-label="Clear search"
className="absolute right-0 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
>
<RiCloseLine size={18} />
</button>
)}
</div>
</form>
<div className="mt-8">
<ProductToolbar <ProductToolbar
totalCount={totalCount} totalCount={totalCount}
onOpenFilters={() => setFiltersOpen(true)} onOpenFilters={() => setFiltersOpen(true)}
@@ -140,7 +190,6 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
/> />
</div> </div>
{/* Results */}
<div className="mt-8"> <div className="mt-8">
{loading ? ( {loading ? (
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12"> <div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12">
-4
View File
@@ -8,9 +8,7 @@ import { cn } from '@/lib/utils';
interface ShopMenuProps { interface ShopMenuProps {
label?: string; label?: string;
/** Renders inline inside the mobile menu instead of as a floating panel. */
mobile?: boolean; mobile?: boolean;
/** Fires after a collection is picked, so the mobile menu can close itself. */
onNavigate?: () => void; onNavigate?: () => void;
} }
@@ -22,7 +20,6 @@ const ShopMenu: React.FC<ShopMenuProps> = ({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { collections, loading, error, load } = useCollectionsOnDemand(); const { collections, loading, error, load } = useCollectionsOnDemand();
// The collection list is only worth fetching once someone opens the menu.
const toggle = () => { const toggle = () => {
const next = !open; const next = !open;
setOpen(next); setOpen(next);
@@ -138,7 +135,6 @@ const ShopMenu: React.FC<ShopMenuProps> = ({
{open && ( {open && (
<> <>
{/* Catches the click that dismisses the panel. */}
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} /> <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute left-0 top-full z-50 mt-2 max-h-[70vh] w-64 overflow-y-auto rounded-md border border-border bg-background py-1 shadow-md"> <div className="absolute left-0 top-full z-50 mt-2 max-h-[70vh] w-64 overflow-y-auto rounded-md border border-border bg-background py-1 shadow-md">
{body} {body}
+1 -6
View File
@@ -16,11 +16,10 @@ interface ShopPayButtonProps {
disabled?: boolean; disabled?: boolean;
loading?: boolean; loading?: boolean;
className?: string; className?: string;
/** Used when no permalink can be built (missing domain or unusable IDs). */
onFallbackClick?: () => void; onFallbackClick?: () => void;
} }
// Cart permalinks need the bare numeric ID; the Storefront API returns GIDs. // Cart permalinks require numeric IDs rather than Storefront GIDs.
function toNumericVariantId(id: string): string | null { function toNumericVariantId(id: string): string | null {
const trimmed = id.trim(); const trimmed = id.trim();
const gid = trimmed.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)/); const gid = trimmed.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)/);
@@ -38,8 +37,6 @@ function toStoreUrl(domain?: string): string | null {
} }
} }
// https://{shop}/cart/{variantId}:{qty},{variantId}:{qty}?payment=shop_pay
// Loads the cart and drops the buyer straight into the Shop Pay checkout.
export function buildShopPayUrl(variants: ShopPayVariant[]): string | null { export function buildShopPayUrl(variants: ShopPayVariant[]): string | null {
const storeUrl = toStoreUrl(SHOPIFY_STORE_DOMAIN); const storeUrl = toStoreUrl(SHOPIFY_STORE_DOMAIN);
if (!storeUrl || variants.length === 0) return null; if (!storeUrl || variants.length === 0) return null;
@@ -72,8 +69,6 @@ const ShopPayButton: React.FC<ShopPayButtonProps> = ({
</> </>
); );
// An anchor keeps the checkout URL visible, openable in a new tab, and
// navigable without JS; the button only stands in when there's no URL.
if (shopPayUrl && !disabled) { if (shopPayUrl && !disabled) {
return ( return (
<a <a
-2
View File
@@ -1,7 +1,5 @@
import React from 'react'; import React from 'react';
// "Buy with shop" lockup — the wordmark and the Shop mark are both in the path
// data, so the button needs no additional text beyond a screen-reader label.
const ShopPayLogo: React.FC<{ className?: string }> = ({ const ShopPayLogo: React.FC<{ className?: string }> = ({
className = 'h-auto w-[98px]', className = 'h-auto w-[98px]',
}) => ( }) => (
@@ -4,11 +4,6 @@ import StoreAssistant from '@/components/shopify/store-assistant';
export type StoreAssistantBlockProps = Record<string, never>; export type StoreAssistantBlockProps = Record<string, never>;
/**
* The floating AI shopping assistant. It has no editable copy — its prompts and
* suggestions come from `/api/chat` — so it is registered purely so a page can
* choose whether to carry it.
*/
const storeAssistantEditor: ComponentConfig<StoreAssistantBlockProps> = { const storeAssistantEditor: ComponentConfig<StoreAssistantBlockProps> = {
label: 'Store assistant', label: 'Store assistant',
icon: <MessageCircle size={16} />, icon: <MessageCircle size={16} />,
-22
View File
@@ -64,8 +64,6 @@ import {
RiShoppingBag3Line, RiShoppingBag3Line,
} from '@remixicon/react'; } from '@remixicon/react';
// Feature flag. Written as a static member expression so Next inlines it at
// build time; the assistant is off unless the env var is explicitly "1".
const AI_ENABLED = process.env.NEXT_PUBLIC_ENABLE_AI === '1'; const AI_ENABLED = process.env.NEXT_PUBLIC_ENABLE_AI === '1';
const SUGGESTIONS = [ const SUGGESTIONS = [
@@ -101,8 +99,6 @@ const toolName = (type: string) =>
const plural = (count: number, noun: string) => const plural = (count: number, noun: string) =>
`${count} ${noun}${count === 1 ? '' : 's'}`; `${count} ${noun}${count === 1 ? '' : 's'}`;
// Turns a finished tool result into the one-line summary plus any products
// worth previewing.
const summariseTool = ( const summariseTool = (
name: string, name: string,
output: Record<string, unknown> | undefined output: Record<string, unknown> | undefined
@@ -166,7 +162,6 @@ const summariseTool = (
} }
}; };
// Storefront links stay in-app, so they skip Streamdown's external-link modal.
const isInternalLink = (url: string) => { const isInternalLink = (url: string) => {
if (url.startsWith('/')) return true; if (url.startsWith('/')) return true;
try { try {
@@ -229,7 +224,6 @@ const AttachmentItem = memo(({ attachment, onRemove }: AttachmentItemProps) => {
<AttachmentHoverCard key={attachment.id}> <AttachmentHoverCard key={attachment.id}>
<AttachmentHoverCardTrigger asChild> <AttachmentHoverCardTrigger asChild>
<Attachment data={attachment} onRemove={handleRemove}> <Attachment data={attachment} onRemove={handleRemove}>
{/* Thumbnail swaps to the remove button on hover. */}
<div className="group relative size-5 shrink-0"> <div className="group relative size-5 shrink-0">
<div className="absolute inset-0 transition-opacity group-hover:opacity-0"> <div className="absolute inset-0 transition-opacity group-hover:opacity-0">
<AttachmentPreview /> <AttachmentPreview />
@@ -270,7 +264,6 @@ const AttachmentItem = memo(({ attachment, onRemove }: AttachmentItemProps) => {
AttachmentItem.displayName = 'AttachmentItem'; AttachmentItem.displayName = 'AttachmentItem';
// Pending uploads, shown inline above the textarea.
const PromptInputAttachmentsDisplay = () => { const PromptInputAttachmentsDisplay = () => {
const attachments = usePromptInputAttachments(); const attachments = usePromptInputAttachments();
@@ -295,15 +288,12 @@ const PromptInputAttachmentsDisplay = () => {
}; };
const StoreAssistant: React.FC = () => { const StoreAssistant: React.FC = () => {
// Returns before any hooks run — safe because the flag is a build-time
// constant and cannot change between renders.
if (!AI_ENABLED) return null; if (!AI_ENABLED) return null;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
// Drives the launcher's slide-in on first paint.
useEffect(() => setMounted(true), []); useEffect(() => setMounted(true), []);
const { messages, sendMessage, status, error } = useChat({ const { messages, sendMessage, status, error } = useChat({
@@ -323,7 +313,6 @@ const StoreAssistant: React.FC = () => {
setInput(''); setInput('');
}; };
// Attachments arrive on the submitted message, so images go out with it.
const handleSubmit = (message: PromptInputMessage) => { const handleSubmit = (message: PromptInputMessage) => {
const text = (message.text ?? input).trim(); const text = (message.text ?? input).trim();
const files = message.files ?? []; const files = message.files ?? [];
@@ -335,7 +324,6 @@ const StoreAssistant: React.FC = () => {
return ( return (
<> <>
{/* Popover panel, anchored above the launcher */}
<div <div
aria-hidden={!open} aria-hidden={!open}
className={`fixed bottom-20 right-4 z-50 flex w-[calc(100vw-2rem)] max-w-sm flex-col overflow-hidden rounded-xl border border-border bg-background shadow-xl transition-all duration-200 sm:right-6 ${ className={`fixed bottom-20 right-4 z-50 flex w-[calc(100vw-2rem)] max-w-sm flex-col overflow-hidden rounded-xl border border-border bg-background shadow-xl transition-all duration-200 sm:right-6 ${
@@ -365,8 +353,6 @@ const StoreAssistant: React.FC = () => {
title="Ask about the store" title="Ask about the store"
description="Find products, compare options, browse collections." description="Find products, compare options, browse collections."
> >
{/* w-full + wrap so chips stack in the narrow popover rather
than scrolling off the edge. */}
<Suggestions className="mt-3 w-full flex-wrap justify-center"> <Suggestions className="mt-3 w-full flex-wrap justify-center">
{SUGGESTIONS.map((suggestion) => ( {SUGGESTIONS.map((suggestion) => (
<Suggestion <Suggestion
@@ -393,8 +379,6 @@ const StoreAssistant: React.FC = () => {
return ( return (
<MessageResponse <MessageResponse
key={index} key={index}
// Only repair half-written markdown while it streams;
// settled text is rendered exactly as sent.
isAnimating={ isAnimating={
status === 'streaming' && part.state === 'streaming' status === 'streaming' && part.state === 'streaming'
} }
@@ -432,8 +416,6 @@ const StoreAssistant: React.FC = () => {
}; };
const name = toolName(part.type); const name = toolName(part.type);
// Shimmer while the call is in flight; a quiet summary
// line once it returns.
if ( if (
toolPart.state === 'input-streaming' || toolPart.state === 'input-streaming' ||
toolPart.state === 'input-available' toolPart.state === 'input-available'
@@ -472,7 +454,6 @@ const StoreAssistant: React.FC = () => {
return null; return null;
})} })}
{/* Images the shopper attached, shown under their message. */}
{fileParts.length > 0 && ( {fileParts.length > 0 && (
<Attachments variant="grid" className="ml-0 justify-start"> <Attachments variant="grid" className="ml-0 justify-start">
{fileParts.map((part, index) => ( {fileParts.map((part, index) => (
@@ -544,9 +525,6 @@ const StoreAssistant: React.FC = () => {
</div> </div>
</div> </div>
{/* Launcher */}
{/* Rainbow treatment only while the assistant is open; otherwise the
launcher matches the rest of the site's buttons. */}
{open ? ( {open ? (
<RainbowButton <RainbowButton
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
-3
View File
@@ -36,8 +36,6 @@ function CommandDialog({
children, children,
className, className,
showCloseButton = true, showCloseButton = true,
// Forwarded to the inner Command so callers can drive filtering themselves
// (e.g. results already filtered server-side).
shouldFilter, shouldFilter,
...props ...props
}: React.ComponentProps<typeof Dialog> & { }: React.ComponentProps<typeof Dialog> & {
@@ -56,7 +54,6 @@ function CommandDialog({
<DialogContent <DialogContent
className={cn( className={cn(
"overflow-hidden p-0", "overflow-hidden p-0",
// No fade/zoom on the command palette — it should appear instantly.
"transition-none animate-none duration-0", "transition-none animate-none duration-0",
"data-[state=closed]:animate-none data-[state=open]:animate-none", "data-[state=closed]:animate-none data-[state=open]:animate-none",
className className
-3
View File
@@ -17,16 +17,13 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
"group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30", "group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30",
"h-9 min-w-0 has-[>textarea]:h-auto", "h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2", "has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2", "has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50", "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
// Error state.
"has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className className
-3
View File
@@ -1,11 +1,9 @@
import React from 'react'; import React from 'react';
// Utility function to combine classNames
function cn(...classes: (string | undefined | null | false)[]): string { function cn(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(' '); return classes.filter(Boolean).join(' ');
} }
// Item variants helper
function getItemVariants( function getItemVariants(
variant: 'default' | 'outline' | 'muted', variant: 'default' | 'outline' | 'muted',
size: 'default' | 'sm' size: 'default' | 'sm'
@@ -27,7 +25,6 @@ function getItemVariants(
return cn(baseStyles, variantStyles[variant], sizeStyles[size]); return cn(baseStyles, variantStyles[variant], sizeStyles[size]);
} }
// Item media variants helper
function getItemMediaVariants(variant: 'default' | 'icon' | 'image'): string { function getItemMediaVariants(variant: 'default' | 'icon' | 'image'): string {
const baseStyles = const baseStyles =
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5'; 'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5';
-1
View File
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
// Utility function to combine classNames
function cn(...classes: (string | undefined | null | false)[]): string { function cn(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(' '); return classes.filter(Boolean).join(' ');
} }
+1 -3
View File
@@ -16,12 +16,10 @@ const RemixIcon: React.FC<RemixIconProps> = ({
}) => { }) => {
if (!name) return null; if (!name) return null;
// Normalise: accept "RiTruckLine", "ri-truck-line", or "riTruckLine" // Accept component, kebab-case, or camelCase icon names.
const normalised = name const normalised = name
// ri-truck-line → RiTruckLine
.replace(/^ri-/, 'Ri') .replace(/^ri-/, 'Ri')
.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) .replace(/-([a-z])/g, (_, c) => c.toUpperCase())
// ensure first char is uppercase
.replace(/^./, (c) => c.toUpperCase()); .replace(/^./, (c) => c.toUpperCase());
const IconComponent = ( const IconComponent = (
+1 -14
View File
@@ -1,14 +1,5 @@
import { RootConfig } from '@reacteditor/core'; import { RootConfig } from '@reacteditor/core';
/**
* Root props are the page's *content*, not its design. Colours, fonts, radius
* and spacing all live in `app/globals.css` and in the components themselves —
* the editor only owns the words and the imagery. See `editor.config.tsx` for
* the same rule applied to every block.
*
* The field names below are the ones `pageMetadata()` reads, so editing a page
* in the editor is what changes its `<head>` tags.
*/
export type RootProps = { export type RootProps = {
title?: string; title?: string;
description?: string; description?: string;
@@ -16,10 +7,7 @@ export type RootProps = {
}; };
export const Root: RootConfig<{ props: RootProps }> = { export const Root: RootConfig<{ props: RootProps }> = {
// Root props default to being shared across pages. These are per-page SEO // SEO fields belong to each page rather than the global block store.
// tags — `pageMetadata()` reads them straight off each `page.json` — so
// opting out keeps every route's title and description its own. The header
// and footer blocks are the things that stay global.
global: false, global: false,
defaultProps: { defaultProps: {
title: 'Shop', title: 'Shop',
@@ -31,7 +19,6 @@ export const Root: RootConfig<{ props: RootProps }> = {
description: { label: 'Meta description', type: 'textarea' }, description: { label: 'Meta description', type: 'textarea' },
ogImage: { label: 'Social share image', type: 'image' }, ogImage: { label: 'Social share image', type: 'image' },
}, },
// No wrapper: the storefront's own layout and stylesheet own the chrome.
render: ({ children }) => <>{children}</>, render: ({ children }) => <>{children}</>,
}; };
-10
View File
@@ -1,17 +1,7 @@
/**
* Store-wide constants that aren't page content.
*
* Nav links, the logo and the footer used to live here; they're editor fields
* on the header/footer blocks now, so a merchant changes them in the editor
* rather than in code. What's left is the metadata fallback for routes that
* haven't set their own.
*/
export const site = { export const site = {
storeName: 'Shop', storeName: 'Shop',
description: description:
'An agent-friendly Shopify storefront built with Next.js and Hydrogen.', 'An agent-friendly Shopify storefront built with Next.js and Hydrogen.',
// Absolute origin behind `metadataBase`, so Open Graph images resolve to full
// URLs. Vercel injects VERCEL_PROJECT_PRODUCTION_URL on deployed builds.
url: url:
process.env.NEXT_PUBLIC_SITE_URL ?? process.env.NEXT_PUBLIC_SITE_URL ??
(process.env.VERCEL_PROJECT_PRODUCTION_URL (process.env.VERCEL_PROJECT_PRODUCTION_URL
-10
View File
@@ -1,15 +1,5 @@
// Swatch configuration for product option pickers.
//
// Shopify exposes a `swatch` on each option value, but plenty of products ship
// without one configured. These settings decide which options render as colour
// circles and what colour to use when the API doesn't supply one.
// Option names (case-insensitive) rendered as colour circles instead of pills.
export const SWATCH_OPTION_NAMES = ['color', 'colour']; export const SWATCH_OPTION_NAMES = ['color', 'colour'];
// Fallback colours, keyed by lowercased option value name. The first group
// mirrors hexes the store defines on other products, so a product missing its
// swatches still matches the ones that have them. Add your own names here.
export const SWATCH_COLORS: Record<string, string> = { export const SWATCH_COLORS: Record<string, string> = {
clay: '#5a4b3c', clay: '#5a4b3c',
green: '#81a69b', green: '#81a69b',

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