Update React Editor integration and clean up template

This commit is contained in:
Rami Bitar
2026-08-15 13:47:33 -04:00
parent 0a7ea0bf6b
commit 0fb6cef226
82 changed files with 113 additions and 913 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=
+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
+1 -4
View File
@@ -1,9 +1,6 @@
import PageEditor from '@/components/page-editor'; import PageEditor from '@/components/page-editor';
import pageData from '../page.json'; 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, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() { export default function EditorPage() {
return <PageEditor routeKey="/about" page={pageData} />; return <PageEditor pagePath="app/about/page.json" page={pageData} />;
} }
+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 });
} }
+6 -4
View File
@@ -1,9 +1,11 @@
import PageEditor from '@/components/page-editor'; import PageEditor from '@/components/page-editor';
import pageData from '../page.json'; 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, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() { export default function EditorPage() {
return <PageEditor routeKey="/collections/[handle]" page={pageData} />; 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, {
+1 -4
View File
@@ -1,9 +1,6 @@
import PageEditor from '@/components/page-editor'; import PageEditor from '@/components/page-editor';
import pageData from '../page.json'; 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, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() { export default function EditorPage() {
return <PageEditor routeKey="/collections" page={pageData} />; return <PageEditor pagePath="app/collections/page.json" page={pageData} />;
} }
+1 -4
View File
@@ -1,9 +1,6 @@
import PageEditor from '@/components/page-editor'; import PageEditor from '@/components/page-editor';
import pageData from '../page.json'; import pageData from '../page.json';
// Editor for /. Sitting under the route it edits means the preview
// resolves the same params the public page gets, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() { export default function EditorPage() {
return <PageEditor routeKey="/" page={pageData} />; 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>
+3 -4
View File
@@ -1,9 +1,8 @@
import PageEditor from '@/components/page-editor'; import PageEditor from '@/components/page-editor';
import pageData from '../page.json'; 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, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() { export default function EditorPage() {
return <PageEditor routeKey="/policies/[handle]" page={pageData} />; return (
<PageEditor pagePath="app/policies/[handle]/page.json" page={pageData} />
);
} }
+3 -4
View File
@@ -1,9 +1,8 @@
import PageEditor from '@/components/page-editor'; import PageEditor from '@/components/page-editor';
import pageData from '../page.json'; 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, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() { export default function EditorPage() {
return <PageEditor routeKey="/products/[handle]" page={pageData} />; 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);
+1 -4
View File
@@ -1,9 +1,6 @@
import PageEditor from '@/components/page-editor'; import PageEditor from '@/components/page-editor';
import pageData from '../page.json'; 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, and `../page.json` is the
// very file publishing writes back to.
export default function EditorPage() { export default function EditorPage() {
return <PageEditor routeKey="/search" page={pageData} />; return <PageEditor pagePath="app/search/page.json" page={pageData} />;
} }
-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;
} }
+14 -95
View File
@@ -1,132 +1,51 @@
'use client'; 'use client';
import { useCallback, 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 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 { publishPage } from '@/lib/publish-page';
import globals from '@/app.globals.json'; import globals from '@/app.globals.json';
// 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`.
const tailwindCdn = createTailwindCdnPlugin();
// Registers the `shopifyProduct` and `shopifyCollection` field types used by
// the commerce blocks. Credentials are the same public storefront pair the
// rendered components read — safe in the browser by definition.
const shopify = createShopifyPlugin({
storeDomain: process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? 'mock.shop', storeDomain: process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? 'mock.shop',
publicAccessToken: publicAccessToken:
process.env.NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN ?? undefined, process.env.NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN ?? undefined,
apiVersion: process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION ?? '2026-07', 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];
export interface PageEditorProps { export interface PageEditorProps {
/** Route key from `lib/pages.ts`, e.g. `/products/[handle]`. */ pagePath: string;
routeKey: string;
/**
* The route's own `page.json`, imported by the `editor/page.tsx` that mounts
* this — `import pageData from '../page.json'`. Bundled at build time, so the
* editor opens with the page already in hand and no request to wait on.
*/
page: Record<string, unknown>; page: Record<string, unknown>;
} }
/** export default function PageEditor({ pagePath, page }: PageEditorProps) {
* Shared editor shell, mounted by each route's `editor/page.tsx` child. The
* public routes mount `PageRender` against the same `appConfig`, so the two
* never drift.
*
* Being a child of the route it edits is what makes the preview real: the
* editor sits on the same dynamic segments as the public page, so a block
* calling `useParams()` inside the preview iframe sees the actual handle from
* `/products/warrior-club-hoodie/editor` — no stand-in data required.
*
* Data comes in as a prop — the route's own `page.json`, imported by the
* `editor/page.tsx` above it — and publishing writes that same file back to
* disk through the `publishPage` server action.
*/
export default function PageEditor({ routeKey, page }: PageEditorProps) {
const router = useRouter();
const pathname = usePathname();
const params = useParams();
const [status, setStatus] = useState<string | null>(null);
// `app.globals.json` holds the props of blocks marked `global: true` (header,
// footer), which every page.json references via `"synced": true`. The editor
// needs them alongside the page so those blocks render their real content.
const data = useMemo( const data = useMemo(
() => ({ ...page, globals }) as unknown as Data, () => ({ ...page, globals }) as unknown as Data,
[page] [page]
); );
// 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]);
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 result = await publishPage(routeKey, published);
setStatus(
result.error ? result.error : `Saved to ${result.file}`
);
} catch {
setStatus('Could not reach the server.');
}
}, },
[routeKey] [pagePath]
); );
return ( return (
<Editor <Editor
key={routeKey} theme="light"
color="teal"
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} />;
+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} />,
+2 -11
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;
+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>
-3
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}
+1 -12
View File
@@ -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 -11
View File
@@ -43,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';
} }
@@ -63,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() !== ''
@@ -75,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">
@@ -96,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) => (
@@ -113,12 +106,10 @@ const Header: React.FC<HeaderProps> = ({
))} ))}
</div> </div>
{/* Actions */}
<div className="flex items-center"> <div className="flex items-center">
<SearchDialog /> <SearchDialog />
<CartIcon /> <CartIcon />
{/* Mobile hamburger */}
<Button <Button
onClick={() => setMenuOpen(!menuOpen)} onClick={() => setMenuOpen(!menuOpen)}
variant="ghost" variant="ghost"
@@ -136,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">
@@ -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} />,
+1 -9
View File
@@ -60,7 +60,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 +77,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 +97,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 +122,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;
@@ -152,7 +146,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
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 +155,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>
@@ -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)}
-6
View File
@@ -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,
@@ -6,10 +6,6 @@ export type SearchResultsBlockProps = {
title?: string; title?: 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} />,
+2 -6
View File
@@ -50,7 +50,7 @@ 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]);
useEffect(() => { useEffect(() => {
@@ -62,8 +62,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 +75,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;
@@ -140,7 +137,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',
-7
View File
@@ -19,13 +19,6 @@ const categories = {
content: { title: 'Content' }, content: { title: 'Content' },
}; };
/**
* The block library. Every entry pairs a storefront component with a config
* that exposes *content only* — copy, imagery, and which Shopify product or
* collection to show. Design decisions (columns, spacing, tone, typography)
* stay inside the components and `app/globals.css`, so editing a page can
* never take the storefront off-brand.
*/
export const appConfig: UserConfig = { export const appConfig: UserConfig = {
root: Root, root: Root,
categories, categories,
-7
View File
@@ -1,6 +1,5 @@
import { gql } from '@shopify/hydrogen'; import { gql } from '@shopify/hydrogen';
// Cart Fragment for consistent cart data
const CartFragment = gql(` const CartFragment = gql(`
fragment CartFragment on Cart { fragment CartFragment on Cart {
id id
@@ -68,7 +67,6 @@ const CartFragment = gql(`
} }
`); `);
// Create a new cart
export const CREATE_CART_MUTATION = gql( export const CREATE_CART_MUTATION = gql(
` `
mutation CreateCart($lines: [CartLineInput!], $country: CountryCode, $language: LanguageCode) mutation CreateCart($lines: [CartLineInput!], $country: CountryCode, $language: LanguageCode)
@@ -87,7 +85,6 @@ export const CREATE_CART_MUTATION = gql(
[CartFragment] [CartFragment]
); );
// Add lines to cart
export const ADD_CART_LINES_MUTATION = gql( export const ADD_CART_LINES_MUTATION = gql(
` `
mutation AddCartLines($cartId: ID!, $lines: [CartLineInput!]!, $country: CountryCode, $language: LanguageCode) mutation AddCartLines($cartId: ID!, $lines: [CartLineInput!]!, $country: CountryCode, $language: LanguageCode)
@@ -106,7 +103,6 @@ export const ADD_CART_LINES_MUTATION = gql(
[CartFragment] [CartFragment]
); );
// Update cart lines
export const UPDATE_CART_LINES_MUTATION = gql( export const UPDATE_CART_LINES_MUTATION = gql(
` `
mutation UpdateCartLines($cartId: ID!, $lines: [CartLineUpdateInput!]!, $country: CountryCode, $language: LanguageCode) mutation UpdateCartLines($cartId: ID!, $lines: [CartLineUpdateInput!]!, $country: CountryCode, $language: LanguageCode)
@@ -125,7 +121,6 @@ export const UPDATE_CART_LINES_MUTATION = gql(
[CartFragment] [CartFragment]
); );
// Remove lines from cart
export const REMOVE_CART_LINES_MUTATION = gql( export const REMOVE_CART_LINES_MUTATION = gql(
` `
mutation RemoveCartLines($cartId: ID!, $lineIds: [ID!]!, $country: CountryCode, $language: LanguageCode) mutation RemoveCartLines($cartId: ID!, $lineIds: [ID!]!, $country: CountryCode, $language: LanguageCode)
@@ -144,7 +139,6 @@ export const REMOVE_CART_LINES_MUTATION = gql(
[CartFragment] [CartFragment]
); );
// Apply (or clear) discount codes on the cart
export const UPDATE_CART_DISCOUNT_CODES_MUTATION = gql( export const UPDATE_CART_DISCOUNT_CODES_MUTATION = gql(
` `
mutation UpdateCartDiscountCodes($cartId: ID!, $discountCodes: [String!]!, $country: CountryCode, $language: LanguageCode) mutation UpdateCartDiscountCodes($cartId: ID!, $discountCodes: [String!]!, $country: CountryCode, $language: LanguageCode)
@@ -163,7 +157,6 @@ export const UPDATE_CART_DISCOUNT_CODES_MUTATION = gql(
[CartFragment] [CartFragment]
); );
// Get cart by ID
export const GET_CART_QUERY = gql( export const GET_CART_QUERY = gql(
` `
query GetCart($cartId: ID!, $country: CountryCode, $language: LanguageCode) query GetCart($cartId: ID!, $country: CountryCode, $language: LanguageCode)
-2
View File
@@ -1,7 +1,6 @@
import { gql } from '@shopify/hydrogen'; import { gql } from '@shopify/hydrogen';
import { ProductFragment } from '@/graphql/products'; import { ProductFragment } from '@/graphql/products';
// Get all collections
export const GET_COLLECTIONS_QUERY = gql(` export const GET_COLLECTIONS_QUERY = gql(`
query GetCollections($first: Int!, $country: CountryCode, $language: LanguageCode) query GetCollections($first: Int!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) { @inContext(country: $country, language: $language) {
@@ -30,7 +29,6 @@ export const GET_COLLECTIONS_QUERY = gql(`
} }
`); `);
// Get products in a collection
export const GET_COLLECTION_PRODUCTS_QUERY = gql( export const GET_COLLECTION_PRODUCTS_QUERY = gql(
` `
query GetCollectionProducts( query GetCollectionProducts(
-2
View File
@@ -1,7 +1,5 @@
import { gql } from '@shopify/hydrogen'; import { gql } from '@shopify/hydrogen';
// Shop policies are exposed on the `shop` object of the Storefront API.
// There is no lookup-by-handle field, so we fetch all of them and match.
export const GET_SHOP_POLICIES_QUERY = gql(` export const GET_SHOP_POLICIES_QUERY = gql(`
query GetShopPolicies { query GetShopPolicies {
shop { shop {
-4
View File
@@ -1,6 +1,5 @@
import { gql } from '@shopify/hydrogen'; import { gql } from '@shopify/hydrogen';
// Product Fragment for consistent product data
export const ProductFragment = gql(` export const ProductFragment = gql(`
fragment ProductFragment on Product { fragment ProductFragment on Product {
id id
@@ -102,7 +101,6 @@ export const ProductFragment = gql(`
} }
`); `);
// Get multiple products
export const GET_PRODUCTS_QUERY = gql( export const GET_PRODUCTS_QUERY = gql(
` `
query GetProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean, $country: CountryCode, $language: LanguageCode) query GetProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean, $country: CountryCode, $language: LanguageCode)
@@ -123,7 +121,6 @@ export const GET_PRODUCTS_QUERY = gql(
[ProductFragment] [ProductFragment]
); );
// Get a single product by handle
export const GET_PRODUCT_QUERY = gql( export const GET_PRODUCT_QUERY = gql(
` `
query GetProduct($handle: String!, $country: CountryCode, $language: LanguageCode) query GetProduct($handle: String!, $country: CountryCode, $language: LanguageCode)
@@ -136,7 +133,6 @@ export const GET_PRODUCT_QUERY = gql(
[ProductFragment] [ProductFragment]
); );
// Get product recommendations
export const QUERY_PRODUCT_RECOMMENDATIONS = gql( export const QUERY_PRODUCT_RECOMMENDATIONS = gql(
` `
query GetProductRecommendations($productId: ID!, $country: CountryCode, $language: LanguageCode) query GetProductRecommendations($productId: ID!, $country: CountryCode, $language: LanguageCode)
-5
View File
@@ -1,9 +1,6 @@
import { gql } from '@shopify/hydrogen'; import { gql } from '@shopify/hydrogen';
import { ProductFragment } from '@/graphql/products'; import { ProductFragment } from '@/graphql/products';
// Storefront search over products. `productFilters` accepts the raw `input`
// values returned in `productFilters[].values[].input`, so facets round-trip
// without the client needing to know each filter's shape.
export const SEARCH_PRODUCTS_QUERY = gql( export const SEARCH_PRODUCTS_QUERY = gql(
` `
query SearchProducts( query SearchProducts(
@@ -57,8 +54,6 @@ export const SEARCH_PRODUCTS_QUERY = gql(
[ProductFragment] [ProductFragment]
); );
// Lightweight variant for the autocomplete dropdown — just enough to render a
// row, so the dialog stays responsive while typing.
export const SEARCH_SUGGESTIONS_QUERY = gql(` export const SEARCH_SUGGESTIONS_QUERY = gql(`
query SearchSuggestions($query: String!, $first: Int!, $country: CountryCode, $language: LanguageCode) query SearchSuggestions($query: String!, $first: Int!, $country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) { @inContext(country: $country, language: $language) {
+2 -29
View File
@@ -12,8 +12,6 @@ import {
} from '@/graphql/cart'; } from '@/graphql/cart';
import { useEffect } from 'react'; import { useEffect } from 'react';
// ─── Types ───────────────────────────────────────────────────────────
export interface CartLineInput { export interface CartLineInput {
merchandiseId: string; merchandiseId: string;
quantity: number; quantity: number;
@@ -91,13 +89,7 @@ export interface Cart {
}; };
} }
// ─── Shopify API functions ─────────────────────────────────────────── /** Normalizes nullable Shopify cart mutation payloads. */
/**
* Every cart mutation returns the same `{ cart, userErrors }` payload, and both
* the payload and the cart inside it are nullable — Shopify returns no cart when
* the mutation could not be applied. Callers want a cart or an exception.
*/
function unwrapCartPayload<T>( function unwrapCartPayload<T>(
payload: payload:
| { | {
@@ -200,12 +192,9 @@ export function redirectToCheckout(checkoutUrl: string): void {
} }
} }
// ─── Zustand Store ───────────────────────────────────────────────────
const CART_ID_KEY = 'cartId'; const CART_ID_KEY = 'cartId';
interface CartState { interface CartState {
// State
isOpen: boolean; isOpen: boolean;
cartId: string | null; cartId: string | null;
cart: Cart | null; cart: Cart | null;
@@ -213,10 +202,6 @@ interface CartState {
error: string | null; error: string | null;
_initialized: boolean; _initialized: boolean;
// Computed (derived in the hook)
// items, itemCount, totalAmount, checkoutUrl
// Actions
openCart: () => void; openCart: () => void;
closeCart: () => void; closeCart: () => void;
toggleCart: () => void; toggleCart: () => void;
@@ -229,7 +214,6 @@ interface CartState {
} }
export const useCartStore = create<CartState>((set, get) => ({ export const useCartStore = create<CartState>((set, get) => ({
// Initial state
isOpen: false, isOpen: false,
cartId: null, cartId: null,
cart: null, cart: null,
@@ -237,12 +221,10 @@ export const useCartStore = create<CartState>((set, get) => ({
error: null, error: null,
_initialized: false, _initialized: false,
// UI actions
openCart: () => set({ isOpen: true }), openCart: () => set({ isOpen: true }),
closeCart: () => set({ isOpen: false }), closeCart: () => set({ isOpen: false }),
toggleCart: () => set((s) => ({ isOpen: !s.isOpen })), toggleCart: () => set((s) => ({ isOpen: !s.isOpen })),
// Initialize cart from localStorage
initCart: () => { initCart: () => {
if (get()._initialized) return; if (get()._initialized) return;
set({ _initialized: true }); set({ _initialized: true });
@@ -276,12 +258,10 @@ export const useCartStore = create<CartState>((set, get) => ({
}); });
}, },
// Add item to cart (creates cart if needed)
addItem: async (variantId: string, quantity: number = 1) => { addItem: async (variantId: string, quantity: number = 1) => {
try { try {
set({ loading: true, error: null }); set({ loading: true, error: null });
// Get or create cart
let currentCartId = get().cartId; let currentCartId = get().cartId;
if (!currentCartId) { if (!currentCartId) {
const storedCartId = localStorage.getItem(CART_ID_KEY); const storedCartId = localStorage.getItem(CART_ID_KEY);
@@ -309,7 +289,6 @@ export const useCartStore = create<CartState>((set, get) => ({
} }
}, },
// Remove item from cart
removeItem: async (lineId: string) => { removeItem: async (lineId: string) => {
const { cartId } = get(); const { cartId } = get();
if (!cartId) throw new Error('No cart exists'); if (!cartId) throw new Error('No cart exists');
@@ -327,7 +306,6 @@ export const useCartStore = create<CartState>((set, get) => ({
} }
}, },
// Update item quantity
updateItemQuantity: async (lineId: string, quantity: number) => { updateItemQuantity: async (lineId: string, quantity: number) => {
const { cartId, removeItem } = get(); const { cartId, removeItem } = get();
if (!cartId) throw new Error('No cart exists'); if (!cartId) throw new Error('No cart exists');
@@ -351,8 +329,7 @@ export const useCartStore = create<CartState>((set, get) => ({
} }
}, },
// Apply a discount code. Shopify accepts unknown codes and reports them back // Invalid discount codes return `applicable: false` instead of an error.
// as `applicable: false`, so callers should check the returned cart.
applyDiscountCode: async (code: string) => { applyDiscountCode: async (code: string) => {
const { cartId } = get(); const { cartId } = get();
if (!cartId) throw new Error('No cart exists'); if (!cartId) throw new Error('No cart exists');
@@ -374,7 +351,6 @@ export const useCartStore = create<CartState>((set, get) => ({
} }
}, },
// Refresh cart from Shopify
refreshCart: async () => { refreshCart: async () => {
const storedCartId = localStorage.getItem(CART_ID_KEY); const storedCartId = localStorage.getItem(CART_ID_KEY);
if (!storedCartId) { if (!storedCartId) {
@@ -403,12 +379,9 @@ export const useCartStore = create<CartState>((set, get) => ({
}, },
})); }));
// ─── Hook (backwards-compatible API) ─────────────────────────────────
export function useShopifyCart() { export function useShopifyCart() {
const store = useCartStore(); const store = useCartStore();
// Initialize cart safely in an effect, not during render
useEffect(() => { useEffect(() => {
if (!store._initialized) { if (!store._initialized) {
store.initCart(); store.initCart();
+5 -10
View File
@@ -4,26 +4,26 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { import {
getCollections, getCollections,
getCollectionProducts, getCollectionProducts,
} from '@/services/shopify/catalog'; } from '@/services/shopify/shop';
export { export {
getCollections, getCollections,
getCollectionProducts, getCollectionProducts,
getCollectionProductsPage, getCollectionProductsPage,
} from '@/services/shopify/catalog'; } from '@/services/shopify/shop';
export type { export type {
Collection, Collection,
CollectionWithProducts, CollectionWithProducts,
CollectionSortKey, CollectionSortKey,
CollectionProductsPage, CollectionProductsPage,
ProductFilterFacet, ProductFilterFacet,
} from '@/services/shopify/catalog'; } from '@/services/shopify/shop';
import type { import type {
Collection, Collection,
CollectionWithProducts, CollectionWithProducts,
CollectionSortKey, CollectionSortKey,
} from '@/services/shopify/catalog'; } from '@/services/shopify/shop';
interface UseCollectionProductsOptions { interface UseCollectionProductsOptions {
first?: number; first?: number;
@@ -33,7 +33,6 @@ interface UseCollectionProductsOptions {
filterInputs?: string[]; filterInputs?: string[];
} }
// Hook for fetching all collections
export function useCollections(first = 50) { export function useCollections(first = 50) {
const [collections, setCollections] = useState<Collection[]>([]); const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -60,9 +59,7 @@ export function useCollections(first = 50) {
return { collections, loading, error, refetch: fetchCollections }; return { collections, loading, error, refetch: fetchCollections };
} }
// Deferred variant of useCollections: nothing is requested until `load` runs, // Fetch once on demand for menus that may never open.
// so a menu can hold off until it's actually opened. The fetch happens once —
// re-opening reuses what's already in state.
export function useCollectionsOnDemand(first = 50) { export function useCollectionsOnDemand(first = 50) {
const [collections, setCollections] = useState<Collection[]>([]); const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -79,7 +76,6 @@ export function useCollectionsOnDemand(first = 50) {
setCollections(await getCollections(first)); setCollections(await getCollections(first));
} catch (err) { } catch (err) {
console.error('Error fetching collections:', err); console.error('Error fetching collections:', err);
// Let the next open (or a retry) try again.
requested.current = false; requested.current = false;
setError(err instanceof Error ? err.message : 'Failed to load collections'); setError(err instanceof Error ? err.message : 'Failed to load collections');
} finally { } finally {
@@ -90,7 +86,6 @@ export function useCollectionsOnDemand(first = 50) {
return { collections, loading, error, load }; return { collections, loading, error, load };
} }
// Hook for fetching products in a collection
export function useCollectionProducts( export function useCollectionProducts(
handle: string | null, handle: string | null,
options: UseCollectionProductsOptions = {} options: UseCollectionProductsOptions = {}
-4
View File
@@ -12,7 +12,6 @@ export interface ShopPolicy {
url: string; url: string;
} }
// Handles Shopify uses for each policy — also the routes under /policies/[handle].
export const POLICY_HANDLES = [ export const POLICY_HANDLES = [
'terms-of-service', 'terms-of-service',
'privacy-policy', 'privacy-policy',
@@ -21,8 +20,6 @@ export const POLICY_HANDLES = [
'subscription-policy', 'subscription-policy',
] as const; ] as const;
// Policies change rarely, so this reads through the cached client (revalidated
// hourly) rather than the no-store one used for carts and products.
export async function getShopPolicies(): Promise<ShopPolicy[]> { export async function getShopPolicies(): Promise<ShopPolicy[]> {
try { try {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
@@ -34,7 +31,6 @@ export async function getShopPolicies(): Promise<ShopPolicy[]> {
(policy): policy is ShopPolicy => Boolean(policy?.handle) (policy): policy is ShopPolicy => Boolean(policy?.handle)
); );
} catch (err) { } catch (err) {
// A storefront without policies configured shouldn't break the footer.
console.error('Failed to load shop policies:', err); console.error('Failed to load shop policies:', err);
return []; return [];
} }
+4 -9
View File
@@ -5,24 +5,22 @@ import {
getProducts, getProducts,
getProduct, getProduct,
getProductRecommendations, getProductRecommendations,
} from '@/services/shopify/catalog'; } from '@/services/shopify/shop';
// Pure fetchers live in services/shopify/catalog so server code can use them
// too; re-exported here so existing imports keep working.
export { export {
getProducts, getProducts,
getProductsPage, getProductsPage,
getProduct, getProduct,
getProductRecommendations, getProductRecommendations,
} from '@/services/shopify/catalog'; } from '@/services/shopify/shop';
export type { export type {
Product, Product,
ProductOption, ProductOption,
ProductOptionValue, ProductOptionValue,
ProductsPage, ProductsPage,
} from '@/services/shopify/catalog'; } from '@/services/shopify/shop';
import type { Product } from '@/services/shopify/catalog'; import type { Product } from '@/services/shopify/shop';
interface UseProductsOptions { interface UseProductsOptions {
first?: number; first?: number;
@@ -39,7 +37,6 @@ interface UseProductsReturn {
refetch: () => Promise<void>; refetch: () => Promise<void>;
} }
// Hook for fetching multiple products
export function useProducts(options: UseProductsOptions = {}): UseProductsReturn { export function useProducts(options: UseProductsOptions = {}): UseProductsReturn {
const [products, setProducts] = useState<Product[]>([]); const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -66,7 +63,6 @@ export function useProducts(options: UseProductsOptions = {}): UseProductsReturn
return { products, loading, error, refetch: fetchProducts }; return { products, loading, error, refetch: fetchProducts };
} }
// Hook for fetching a single product
export function useProduct(handle: string | null) { export function useProduct(handle: string | null) {
const [product, setProduct] = useState<Product | null>(null); const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -101,7 +97,6 @@ export function useProduct(handle: string | null) {
return { product, loading, error, refetch: fetchProduct }; return { product, loading, error, refetch: fetchProduct };
} }
// Hook for fetching product recommendations
export function useProductRecommendations(productId: string | null) { export function useProductRecommendations(productId: string | null) {
const [recommendations, setRecommendations] = useState<Product[]>([]); const [recommendations, setRecommendations] = useState<Product[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
+2 -4
View File
@@ -1,10 +1,8 @@
// Re-exported from the server-safe catalogue module so both client components export { searchProducts, searchSuggestions } from '@/services/shopify/shop';
// and Route Handlers can search the storefront.
export { searchProducts, searchSuggestions } from '@/services/shopify/catalog';
export type { export type {
SearchSortKey, SearchSortKey,
SearchFilter, SearchFilter,
SearchFilterValue, SearchFilterValue,
SearchProductsResult, SearchProductsResult,
SearchSuggestion, SearchSuggestion,
} from '@/services/shopify/catalog'; } from '@/services/shopify/shop';
-3
View File
@@ -1,6 +1,3 @@
// Server-only config for the Frontend Cloud proxy routes.
// The API key lives here (read from a non-public env var) so it is never
// shipped to the browser bundle.
export const CLOUD_BASE = "https://cloud.frontend.co"; export const CLOUD_BASE = "https://cloud.frontend.co";
+1 -11
View File
@@ -1,7 +1,6 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { site } from '@/config/site'; import { site } from '@/config/site';
/** The subset of root props (see config/root.tsx) that describes a page. */
interface EditorPage { interface EditorPage {
root?: { root?: {
props?: { props?: {
@@ -16,25 +15,16 @@ interface MetadataOverrides {
title?: string; title?: string;
description?: string; description?: string;
image?: string; image?: string;
/** Root-relative path, used for the canonical and og:url. */
path?: string; path?: string;
} }
/**
* Turns a page.json's root props into Next metadata. The editor exposes
* title/description/ogImage on the root, so editing a page in the editor is
* what changes its tags; `overrides` lets dynamic routes layer fetched product,
* collection or policy data on top.
*/
export function pageMetadata( export function pageMetadata(
page: EditorPage, page: EditorPage,
overrides: MetadataOverrides = {} overrides: MetadataOverrides = {}
): Metadata { ): Metadata {
const props = page.root?.props ?? {}; const props = page.root?.props ?? {};
// Editor-authored titles already carry the brand ("About — Shop"), so they // Editor titles already include the brand; fetched titles use the template.
// opt out of the root template with `absolute`. A fetched override is a bare
// product or collection name and flows through the template instead.
const title = overrides.title ?? props.title ?? site.storeName; const title = overrides.title ?? props.title ?? site.storeName;
const socialTitle = overrides.title ? `${title} — ${site.storeName}` : title; const socialTitle = overrides.title ? `${title} — ${site.storeName}` : title;
const description = const description =
-64
View File
@@ -1,64 +0,0 @@
/**
* Every editor-driven route in the app.
*
* `key` is what the editor's page picker shows and what `onPublish` hands back;
* `dir` is the folder under `app/` holding that route's `page.json`. Keeping
* the list here means the publish action can validate a route key against it
* rather than trusting a path from the browser.
*/
export interface PageRoute {
key: string;
label: string;
/** Path under `app/`, relative and without a leading slash. */
dir: string;
}
export const PAGE_ROUTES: PageRoute[] = [
{ key: '/', label: 'Home', dir: '' },
{ key: '/about', label: 'About', dir: 'about' },
{ key: '/collections', label: 'Collections', dir: 'collections' },
{
key: '/collections/[handle]',
label: 'Collection detail',
dir: 'collections/[handle]',
},
{
key: '/products/[handle]',
label: 'Product detail',
dir: 'products/[handle]',
},
{ key: '/search', label: 'Search', dir: 'search' },
{ key: '/policies/[handle]', label: 'Policy', dir: 'policies/[handle]' },
];
export const ROUTE_KEYS = PAGE_ROUTES.map((route) => route.key);
export function findPageRoute(key: string): PageRoute | undefined {
return PAGE_ROUTES.find((route) => route.key === key);
}
/**
* URL of the editor for a route: every public route has an `/editor` child, so
* `/products/warrior-club-hoodie` is edited at
* `/products/warrior-club-hoodie/editor`.
*
* Dynamic segments are filled from `params` when the caller knows them. The
* page picker doesn't — switching to `/products/[handle]` there lands on the
* literal `[handle]`, where the blocks render their empty state until a product
* is picked. Opening the editor from a real product page is the path that
* gives them concrete params.
*/
export function editorHref(
routeKey: string,
params: Record<string, string | undefined> = {}
): string {
const base =
routeKey === '/'
? ''
: routeKey.replace(
/\[(\w+)\]/g,
(segment, name: string) => params[name] ?? segment
);
return `${base}/editor`;
}
+10 -27
View File
@@ -2,50 +2,35 @@
import { writeFile } from 'node:fs/promises'; import { writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { findPageRoute } from '@/lib/pages';
/**
* Writes a published page straight to its `page.json` on disk.
*
* A server action rather than a route handler: the editor calls it like a
* function, and there is no HTTP endpoint sitting in front of the filesystem.
*
* The path is built from the registry in `lib/pages.ts`, never from the caller,
* so an unknown or crafted route key is rejected outright rather than escaping
* the `app/` directory.
*/
// 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'); const GLOBALS_FILE = path.join(process.cwd(), 'app.globals.json');
export interface PublishResult { export interface PublishResult {
/** Path of the written file, relative to the project root. */
file?: string; file?: string;
error?: string; error?: string;
} }
export async function publishPage( export async function publishPage(
routeKey: string, pagePath: string,
page: unknown page: unknown
): Promise<PublishResult> { ): Promise<PublishResult> {
const route = findPageRoute(routeKey);
if (!route) return { error: `Unknown route: ${routeKey}` };
if (!page || typeof page !== 'object') { if (!page || typeof page !== 'object') {
return { error: 'Expected a page object.' }; return { error: 'Expected a page object.' };
} }
const appDir = path.join(process.cwd(), 'app'); const appDir = path.join(process.cwd(), 'app');
const file = path.join(appDir, route.dir, 'page.json'); const file = path.resolve(process.cwd(), pagePath);
const relativeToApp = path.relative(appDir, file);
// Belt-and-braces against a registry entry with a traversing `dir`. if (
if (file !== path.join(appDir, 'page.json') && !file.startsWith(appDir + path.sep)) { !relativeToApp ||
return { error: `Unknown route: ${routeKey}` }; relativeToApp.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativeToApp) ||
path.basename(file) !== 'page.json'
) {
return { error: 'Invalid page file.' };
} }
// 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, ...pageData } = page as Record<string, unknown>; const { globals, ...pageData } = page as Record<string, unknown>;
try { try {
@@ -61,8 +46,6 @@ export async function publishPage(
return { file: path.relative(process.cwd(), file) }; return { file: path.relative(process.cwd(), file) };
} catch (err) { } 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); console.error(`Failed to write ${file}:`, err);
return { return {
error: error:
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -3
View File
@@ -3,8 +3,7 @@ import { fileURLToPath } from 'node:url';
export default { export default {
reactStrictMode: true, reactStrictMode: true,
// Sibling templates each carry a lockfile, so pin the tracing root here // Prevent sibling lockfiles from changing Next's inferred tracing root.
// rather than letting Next infer one from a parent directory.
outputFileTracingRoot: dirname(fileURLToPath(import.meta.url)), outputFileTracingRoot: dirname(fileURLToPath(import.meta.url)),
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
@@ -15,7 +14,6 @@ export default {
protocol: 'https', protocol: 'https',
hostname: 'images.unsplash.com', hostname: 'images.unsplash.com',
}, },
// Covers cdn.shopify.com plus any other Shopify-hosted image subdomain.
{ {
protocol: 'https', protocol: 'https',
hostname: '**.shopify.com', hostname: '**.shopify.com',
+1 -1
View File
@@ -17,7 +17,7 @@
"@openrouter/ai-sdk-provider": "^3.0.0", "@openrouter/ai-sdk-provider": "^3.0.0",
"@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-use-controllable-state": "^1.2.6", "@radix-ui/react-use-controllable-state": "^1.2.6",
"@reacteditor/core": "0.0.41-alpha.3", "@reacteditor/core": "0.0.41-alpha.4",
"@reacteditor/plugin-shopify": "0.0.1-alpha.1", "@reacteditor/plugin-shopify": "0.0.1-alpha.1",
"@reacteditor/plugin-tailwind-cdn": "0.0.3-alpha.2", "@reacteditor/plugin-tailwind-cdn": "0.0.3-alpha.2",
"@remixicon/react": "^4.9.0", "@remixicon/react": "^4.9.0",
+5 -41
View File
@@ -1,10 +1,3 @@
// Storefront API clients, built on `@shopify/hydrogen`.
//
// Call sites use the package's own API — `storefront.graphql(DOCUMENT, {
// variables })` — and pass the result through `unwrapStorefrontResult`, which
// applies this app's error policy: fail loudly. Hydrogen deliberately does not
// do that itself, because a 200 carrying partial data and GraphQL errors is a
// valid response that some callers want to render.
import { import {
createShopifyRequestContext, createShopifyRequestContext,
createStorefrontClient, createStorefrontClient,
@@ -16,31 +9,15 @@ import {
SHOPIFY_STORE_DOMAIN, SHOPIFY_STORE_DOMAIN,
} from '@/services/shopify/config'; } from '@/services/shopify/config';
// No incoming request and no buyer context: these clients are module-scoped and // Shared clients must not capture per-request buyer state.
// serve both server rendering and the browser-side hooks, so they must not close
// over per-request state. `$country`/`$language` are injected from this i18n.
const requestContext = createShopifyRequestContext({ const requestContext = createShopifyRequestContext({
request: { headers: new Headers() }, request: { headers: new Headers() },
i18n: { country: 'US', language: 'EN' }, i18n: { country: 'US', language: 'EN' },
}); });
/** // Real stores reject this Hydrogen header during browser CORS preflight.
* Workaround for a bug in this preview build of `@shopify/hydrogen`.
*
* The client tags every request with `X-Hydrogen-Version`, but the Storefront
* API does not list that header in its CORS `access-control-allow-headers`.
* Browsers therefore reject the preflight and `fetch` throws, which hydrogen
* reports as the generic "SFAPI request failed". It only bites against real
* stores — `mock.shop` answers `access-control-allow-headers: *`.
*
* Stripped in the browser only: server-side requests are not subject to CORS,
* so they keep sending the header. Remove this once the API allows it (or once
* these queries move server-side, which is the better long-term fix).
*/
const CORS_BLOCKED_HEADERS = ['X-Hydrogen-Version']; const CORS_BLOCKED_HEADERS = ['X-Hydrogen-Version'];
// Hydrogen calls `fetch(url, init, cacheOptions)`; Next's caching hints ride
// along on `init`, which is how the two ways of caching get to coexist.
const fetchWith = (overrides: RequestInit): typeof globalThis.fetch => const fetchWith = (overrides: RequestInit): typeof globalThis.fetch =>
((url, init) => { ((url, init) => {
const headers = new Headers(init?.headers); const headers = new Headers(init?.headers);
@@ -58,34 +35,21 @@ const config = {
publicStorefrontToken: SHOPIFY_PUBLIC_ACCESS_TOKEN, publicStorefrontToken: SHOPIFY_PUBLIC_ACCESS_TOKEN,
}; };
/** /** Uncached client for carts and customer data. */
* Default client. Uncached, because carts and customer reads must never serve a
* stale response.
*/
export const storefront = createStorefrontClient({ export const storefront = createStorefrontClient({
type: 'public', type: 'public',
requestContext, requestContext,
config: { ...config, fetch: fetchWith({ cache: 'no-store' }) }, config: { ...config, fetch: fetchWith({ cache: 'no-store' }) },
}); });
/** /** One-hour cache for stable storefront data. */
* Client for data that changes rarely (shop policies, and anything else safe to
* serve from Next's data cache for an hour).
*/
export const cachedStorefront = createStorefrontClient({ export const cachedStorefront = createStorefrontClient({
type: 'public', type: 'public',
requestContext, requestContext,
config: { ...config, fetch: fetchWith({ next: { revalidate: 3600 } }) }, config: { ...config, fetch: fetchWith({ next: { revalidate: 3600 } }) },
}); });
/** /** Returns GraphQL data or throws the first Shopify error. */
* Returns the data from a `graphql()` result, throwing if Shopify reported any
* GraphQL errors. Transport failures — non-200, timeouts, unparseable bodies —
* have already thrown as `StorefrontApiError` by this point.
*
* `operation` names the query in the log and the thrown message, so a failure
* points at the call site rather than just at "Shopify".
*/
export function unwrapStorefrontResult<TData>( export function unwrapStorefrontResult<TData>(
result: { data: TData | null; errors?: GraphQLFormattedError[] }, result: { data: TData | null; errors?: GraphQLFormattedError[] },
operation: string operation: string
+1 -9
View File
@@ -1,14 +1,6 @@
// Storefront configuration, read from the environment in one place.
//
// These are all `NEXT_PUBLIC_*`, so Next inlines them at build time and they are
// safe to read from client components as well as server code.
export const SHOPIFY_STORE_DOMAIN = process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN; export const SHOPIFY_STORE_DOMAIN = process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN;
/** // Never assign a private token to this public environment variable.
* Public Storefront API access token. Safe to expose to the browser — that is
* what "public" means here. Omitted for tokenless storefronts such as
* `mock.shop`. Never put a *private* token behind a `NEXT_PUBLIC_` name.
*/
export const SHOPIFY_PUBLIC_ACCESS_TOKEN = export const SHOPIFY_PUBLIC_ACCESS_TOKEN =
process.env.NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN; process.env.NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN;
@@ -1,8 +1,3 @@
// Server-safe Shopify catalogue access.
//
// These are plain async functions with no React imports, so they can be called
// from Route Handlers (see app/api/chat/route.ts) as well as from the client
// hooks in hooks/use-shopify-*.ts, which re-export them.
import type { StorefrontApi } from '@shopify/hydrogen'; import type { StorefrontApi } from '@shopify/hydrogen';
import { storefront, unwrapStorefrontResult } from '@/services/shopify/client'; import { storefront, unwrapStorefrontResult } from '@/services/shopify/client';
import { import {
@@ -19,8 +14,6 @@ import {
SEARCH_SUGGESTIONS_QUERY, SEARCH_SUGGESTIONS_QUERY,
} from '@/graphql/search'; } from '@/graphql/search';
// Optional fields are `| null` rather than just optional: the Storefront API
// returns explicit nulls, and the typed `gql()` documents now surface that.
interface ProductImage { interface ProductImage {
url: string; url: string;
altText?: string | null; altText?: string | null;
@@ -67,10 +60,7 @@ export interface ProductOption {
optionValues?: ProductOptionValue[]; optionValues?: ProductOptionValue[];
} }
/** /** Detects Shopify's synthetic option for single-variant products. */
* Single-variant products still carry one synthetic option — `Title` with the
* lone value `Default Title`. It isn't a real choice, so keep it out of the UI.
*/
export const isDefaultTitleOption = (option: { export const isDefaultTitleOption = (option: {
name: string; name: string;
values: string[]; values: string[];
@@ -79,7 +69,6 @@ export const isDefaultTitleOption = (option: {
option.values.length === 1 && option.values.length === 1 &&
option.values[0] === 'Default Title'; option.values[0] === 'Default Title';
/** Same synthetic option, as it appears on a variant's `selectedOptions`. */
export const isDefaultTitleSelection = (selection: { export const isDefaultTitleSelection = (selection: {
name: string; name: string;
value: string; value: string;
@@ -113,7 +102,6 @@ export interface Product {
interface UseProductsOptions { interface UseProductsOptions {
first?: number; first?: number;
/** Cursor from a previous page's `endCursor`; omit for the first page. */
after?: string | null; after?: string | null;
query?: string; query?: string;
sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE'; sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE';
@@ -126,14 +114,6 @@ export interface ProductsPage {
endCursor: string | null; endCursor: string | null;
} }
interface UseProductsReturn {
products: Product[];
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
// Fetch multiple products
export async function getProducts( export async function getProducts(
options: UseProductsOptions = {} options: UseProductsOptions = {}
): Promise<Product[]> { ): Promise<Product[]> {
@@ -141,7 +121,6 @@ export async function getProducts(
return products; return products;
} }
// Same fetch, but keeps the cursor so callers can page through the catalogue.
export async function getProductsPage({ export async function getProductsPage({
first = 20, first = 20,
after = null, after = null,
@@ -165,7 +144,6 @@ export async function getProductsPage({
}; };
} }
// Fetch a single product by handle
export async function getProduct(handle: string): Promise<Product | null> { export async function getProduct(handle: string): Promise<Product | null> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(GET_PRODUCT_QUERY, { variables: { handle } }), await storefront.graphql(GET_PRODUCT_QUERY, { variables: { handle } }),
@@ -175,7 +153,6 @@ export async function getProduct(handle: string): Promise<Product | null> {
return data.product; return data.product;
} }
// Fetch product recommendations
export async function getProductRecommendations(productId: string): Promise<Product[]> { export async function getProductRecommendations(productId: string): Promise<Product[]> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(QUERY_PRODUCT_RECOMMENDATIONS, { await storefront.graphql(QUERY_PRODUCT_RECOMMENDATIONS, {
@@ -218,7 +195,6 @@ interface UseCollectionProductsOptions {
after?: string | null; after?: string | null;
sortKey?: CollectionSortKey; sortKey?: CollectionSortKey;
reverse?: boolean; reverse?: boolean;
/** Raw `input` strings from the connection's `filters` facets. */
filterInputs?: string[]; filterInputs?: string[];
} }
@@ -242,7 +218,6 @@ export interface ProductFilterFacet {
}>; }>;
} }
// Fetch all collections
export async function getCollections(first = 50): Promise<Collection[]> { export async function getCollections(first = 50): Promise<Collection[]> {
const data = unwrapStorefrontResult( const data = unwrapStorefrontResult(
await storefront.graphql(GET_COLLECTIONS_QUERY, { variables: { first } }), await storefront.graphql(GET_COLLECTIONS_QUERY, { variables: { first } }),
@@ -252,7 +227,6 @@ export async function getCollections(first = 50): Promise<Collection[]> {
return data.collections.edges.map((edge) => edge.node); return data.collections.edges.map((edge) => edge.node);
} }
// Fetch products in a collection by handle
export async function getCollectionProducts( export async function getCollectionProducts(
handle: string, handle: string,
options: UseCollectionProductsOptions = {} options: UseCollectionProductsOptions = {}
@@ -263,7 +237,6 @@ export async function getCollectionProducts(
return { ...page.collection, products: page.products }; return { ...page.collection, products: page.products };
} }
// Same fetch, but keeps the cursor and facet list for filtering and paging.
export async function getCollectionProductsPage( export async function getCollectionProductsPage(
handle: string, handle: string,
{ {
@@ -319,7 +292,6 @@ export interface SearchFilterValue {
id: string; id: string;
label: string; label: string;
count: number; count: number;
/** JSON string accepted back as a `ProductFilter` input. */
input: string; input: string;
} }
@@ -360,18 +332,14 @@ interface SearchProductsOptions {
after?: string | null; after?: string | null;
sortKey?: SearchSortKey; sortKey?: SearchSortKey;
reverse?: boolean; reverse?: boolean;
/** Raw `input` strings from the facets, parsed back into filter objects. */
filterInputs?: string[]; filterInputs?: string[];
} }
// The `ProductFilter` input shape, taken from the query that consumes it so it
// tracks the schema rather than being restated here.
type ProductFilterInput = NonNullable< type ProductFilterInput = NonNullable<
StorefrontApi.VariablesOf<typeof GET_COLLECTION_PRODUCTS_QUERY>['filters'] StorefrontApi.VariablesOf<typeof GET_COLLECTION_PRODUCTS_QUERY>['filters']
>[number]; >[number];
// Facet `input` values are opaque JSON strings produced by Shopify and handed // Shopify facet inputs are opaque JSON values intended for round-tripping.
// straight back as filter inputs, so they are parsed, not constructed.
function parseFilterInputs(inputs: string[]): ProductFilterInput[] { function parseFilterInputs(inputs: string[]): ProductFilterInput[] {
return inputs.flatMap((input) => { return inputs.flatMap((input) => {
try { try {
+11 -11
View File
@@ -2316,9 +2316,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@reacteditor/core@npm:0.0.41-alpha.3": "@reacteditor/core@npm:0.0.41-alpha.4":
version: 0.0.41-alpha.3 version: 0.0.41-alpha.4
resolution: "@reacteditor/core@npm:0.0.41-alpha.3" resolution: "@reacteditor/core@npm:0.0.41-alpha.4"
dependencies: dependencies:
"@chakra-ui/react": "npm:^3.35.0" "@chakra-ui/react": "npm:^3.35.0"
"@dnd-kit/abstract": "npm:0.4.0" "@dnd-kit/abstract": "npm:0.4.0"
@@ -2361,7 +2361,7 @@ __metadata:
zustand: "npm:^5.0.13" zustand: "npm:^5.0.13"
peerDependencies: peerDependencies:
react: ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0
checksum: 10c0/0d9ab9e02b7f639603c94b9c6e9068e60d7662eb2744896f20481a1a0bc93727204a79857fffdbdd1ae37b1e76d470a238d6528ebe25de7c08a25e23c0923240 checksum: 10c0/df326a5e43e7398e64a835d47f4a4daf6aea2715bc37dd61c6435e155178310ea342bc2eba4d781baa43f6e3520dc1fae8edd55f094d26052444f0737f093204
languageName: node languageName: node
linkType: hard linkType: hard
@@ -2377,13 +2377,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@reacteditor/plugin-tailwind-cdn@npm:0.0.3-alpha.1": "@reacteditor/plugin-tailwind-cdn@npm:0.0.3-alpha.2":
version: 0.0.3-alpha.1 version: 0.0.3-alpha.2
resolution: "@reacteditor/plugin-tailwind-cdn@npm:0.0.3-alpha.1" resolution: "@reacteditor/plugin-tailwind-cdn@npm:0.0.3-alpha.2"
peerDependencies: peerDependencies:
"@reacteditor/core": ">=0.0.28" "@reacteditor/core": ">=0.0.41-alpha.3"
react: ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/94215939cdc994e9323bf34123df4319a96f04d6422d6d10efcf5d56bb03f38eba3db637a35566c954fd4fc7ad344dc01926559dcb8060966a2a2c7a225f0034 checksum: 10c0/b49110df1640efa132bc1a48c0d38fe41f0fa775472b323dd31bb6c67cf926e3ee3bee495348ce344e9339474048846fda09332504b5011b01e16d6107f9c033
languageName: node languageName: node
linkType: hard linkType: hard
@@ -5823,9 +5823,9 @@ __metadata:
"@openrouter/ai-sdk-provider": "npm:^3.0.0" "@openrouter/ai-sdk-provider": "npm:^3.0.0"
"@radix-ui/react-slot": "npm:^1.3.3" "@radix-ui/react-slot": "npm:^1.3.3"
"@radix-ui/react-use-controllable-state": "npm:^1.2.6" "@radix-ui/react-use-controllable-state": "npm:^1.2.6"
"@reacteditor/core": "npm:0.0.41-alpha.3" "@reacteditor/core": "npm:0.0.41-alpha.4"
"@reacteditor/plugin-shopify": "npm:0.0.1-alpha.1" "@reacteditor/plugin-shopify": "npm:0.0.1-alpha.1"
"@reacteditor/plugin-tailwind-cdn": "npm:0.0.3-alpha.1" "@reacteditor/plugin-tailwind-cdn": "npm:0.0.3-alpha.2"
"@remixicon/react": "npm:^4.9.0" "@remixicon/react": "npm:^4.9.0"
"@shopify/hydrogen": "npm:0.0.0-preview-116d5d7-20260730141607" "@shopify/hydrogen": "npm:0.0.0-preview-116d5d7-20260730141607"
"@shopify/storefront-api-client": "npm:^1.0.0" "@shopify/storefront-api-client": "npm:^1.0.0"