From 0fb6cef22669284e6a13a2f353a67ad83b37dfba Mon Sep 17 00:00:00 2001 From: Rami Bitar Date: Sat, 15 Aug 2026 13:47:33 -0400 Subject: [PATCH] Update React Editor integration and clean up template --- .env.example | 12 -- README.md | 4 +- app/about/editor/page.tsx | 5 +- app/api/chat/route.ts | 10 +- app/collections/[handle]/editor/page.tsx | 10 +- app/collections/[handle]/page.tsx | 7 +- app/collections/editor/page.tsx | 5 +- app/editor/page.tsx | 5 +- app/globals.css | 29 +---- app/layout.tsx | 5 - app/policies/[handle]/editor/page.tsx | 7 +- app/products/[handle]/editor/page.tsx | 7 +- app/products/[handle]/page.tsx | 4 +- app/search/editor/page.tsx | 5 +- app/shop/collections/page.tsx | 2 - components/ai-elements/attachments.tsx | 44 ------- components/ai-elements/markdown.tsx | 34 +---- components/ai-elements/message.tsx | 3 - components/ai-elements/prompt-input.tsx | 72 +---------- components/ai-elements/reasoning.tsx | 4 - components/ai-elements/shimmer.tsx | 1 - components/logo.tsx | 5 - components/page-editor.tsx | 117 +++--------------- components/page-render.tsx | 14 --- components/shopify/cart-drawer.tsx | 15 +-- components/shopify/collection-card.tsx | 2 - .../shopify/collection-detail.editor.tsx | 5 - components/shopify/collection-detail.tsx | 13 +- components/shopify/content-section.tsx | 19 +-- components/shopify/footer.tsx | 3 - components/shopify/header.editor.tsx | 13 +- components/shopify/header.tsx | 12 +- components/shopify/policy-body.editor.tsx | 1 - components/shopify/policy-body.tsx | 9 -- components/shopify/product-card.tsx | 2 - components/shopify/product-detail.editor.tsx | 5 - components/shopify/product-detail/index.tsx | 10 +- .../product-detail/product-detail-gallery.tsx | 14 +-- .../product-detail/product-detail-info.tsx | 19 +-- components/shopify/product-filters.tsx | 12 -- .../product-recommendations.editor.tsx | 1 - .../shopify/product-recommendations.tsx | 4 - components/shopify/product-toolbar.tsx | 3 - components/shopify/products.editor.tsx | 6 - components/shopify/products.tsx | 8 -- components/shopify/search-results.editor.tsx | 4 - components/shopify/search-results.tsx | 8 +- components/shopify/shop-menu.tsx | 4 - components/shopify/shop-pay-button.tsx | 7 +- components/shopify/shop-pay-logo.tsx | 2 - components/shopify/store-assistant.editor.tsx | 5 - components/shopify/store-assistant.tsx | 22 ---- components/ui/command.tsx | 3 - components/ui/input-group.tsx | 3 - components/ui/item.tsx | 3 - components/ui/progress.tsx | 1 - components/ui/remix-icon.tsx | 4 +- config/root.tsx | 15 +-- config/site.ts | 10 -- config/swatches.ts | 10 -- editor.config.tsx | 7 -- graphql/cart.ts | 7 -- graphql/collections.ts | 2 - graphql/policies.ts | 2 - graphql/products.ts | 4 - graphql/search.ts | 5 - hooks/use-shopify-cart.ts | 31 +---- hooks/use-shopify-collections.ts | 15 +-- hooks/use-shopify-policies.ts | 4 - hooks/use-shopify-products.ts | 13 +- hooks/use-shopify-search.ts | 6 +- lib/cloud.ts | 3 - lib/page-metadata.ts | 12 +- lib/pages.ts | 64 ---------- lib/publish-page.ts | 37 ++---- next-env.d.ts | 6 - next.config.js | 4 +- package.json | 2 +- services/shopify/client.ts | 46 +------ services/shopify/config.ts | 10 +- services/shopify/{catalog.ts => shop.ts} | 36 +----- yarn.lock | 22 ++-- 82 files changed, 113 insertions(+), 913 deletions(-) delete mode 100644 .env.example delete mode 100644 lib/pages.ts delete mode 100644 next-env.d.ts rename services/shopify/{catalog.ts => shop.ts} (84%) diff --git a/.env.example b/.env.example deleted file mode 100644 index afffa50..0000000 --- a/.env.example +++ /dev/null @@ -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= diff --git a/README.md b/README.md index ef38390..dea99ef 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ -# React Editor Demo (Shopify) - -Standalone Vite SPA wiring up the Shopify-aware React Editor via `` from `@reacteditor/core`. +# Shopify Hydrogen ## What's here diff --git a/app/about/editor/page.tsx b/app/about/editor/page.tsx index 6d48ec2..6b8ffa1 100644 --- a/app/about/editor/page.tsx +++ b/app/about/editor/page.tsx @@ -1,9 +1,6 @@ import PageEditor from '@/components/page-editor'; import pageData from '../page.json'; -// Editor for /about. Sitting under the route it edits means the preview -// resolves the same params the public page gets, and `../page.json` is the -// very file publishing writes back to. export default function EditorPage() { - return ; + return ; } diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index a9ea3e2..d395c0e 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -9,10 +9,8 @@ import { getCollectionProductsPage, isDefaultTitleOption, 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; 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 or order data. Say so and point the shopper to the relevant page instead.`; -// Trim the Storefront payloads to what the model actually needs to answer. const summariseProduct = (product: { id: string; title: string; @@ -88,12 +85,9 @@ export async function POST(req: Request) { const openrouter = createOpenRouter({ apiKey }); const result = streamText({ - // `reasoning` asks OpenRouter to stream the model's thinking; the UI - // renders it via the Reasoning component. model: openrouter(MODEL, { reasoning: { enabled: true, effort: 'medium' } }), system: SYSTEM_PROMPT, messages: await convertToModelMessages(messages), - // Let the model call a tool, read the result, then answer. stopWhen: stepCountIs(5), tools: { searchCatalogue: tool({ @@ -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 }); } diff --git a/app/collections/[handle]/editor/page.tsx b/app/collections/[handle]/editor/page.tsx index 472bc1b..b5d8945 100644 --- a/app/collections/[handle]/editor/page.tsx +++ b/app/collections/[handle]/editor/page.tsx @@ -1,9 +1,11 @@ import PageEditor from '@/components/page-editor'; import pageData from '../page.json'; -// Editor for /collections/[handle]. Sitting under the route it edits means the preview -// resolves the same params the public page gets, and `../page.json` is the -// very file publishing writes back to. export default function EditorPage() { - return ; + return ( + + ); } diff --git a/app/collections/[handle]/page.tsx b/app/collections/[handle]/page.tsx index 8ca6453..242a1d4 100644 --- a/app/collections/[handle]/page.tsx +++ b/app/collections/[handle]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import PageRender from '@/components/page-render'; import { pageMetadata } from '@/lib/page-metadata'; -import { getCollectionProductsPage } from '@/services/shopify/catalog'; +import { getCollectionProductsPage } from '@/services/shopify/shop'; import { truncate } from '@/lib/utils'; import page from './page.json'; @@ -12,14 +12,11 @@ export async function generateMetadata({ }): Promise { const { handle } = await params; - // No collection-only query exists, so ask for the smallest page of products - // and use just the collection node off it. + // Shopify exposes collection metadata through the products connection. const { collection } = await getCollectionProductsPage(handle, { first: 1, }).catch(() => ({ collection: null })); - // The blocks fetch client-side and render their own empty state, so a failed - // lookup falls back to the page.json tags rather than a 500. if (!collection) return pageMetadata(page); return pageMetadata(page, { diff --git a/app/collections/editor/page.tsx b/app/collections/editor/page.tsx index eefa688..ed5f056 100644 --- a/app/collections/editor/page.tsx +++ b/app/collections/editor/page.tsx @@ -1,9 +1,6 @@ import PageEditor from '@/components/page-editor'; import pageData from '../page.json'; -// Editor for /collections. Sitting under the route it edits means the preview -// resolves the same params the public page gets, and `../page.json` is the -// very file publishing writes back to. export default function EditorPage() { - return ; + return ; } diff --git a/app/editor/page.tsx b/app/editor/page.tsx index 063dd43..901aaa8 100644 --- a/app/editor/page.tsx +++ b/app/editor/page.tsx @@ -1,9 +1,6 @@ import PageEditor from '@/components/page-editor'; import pageData from '../page.json'; -// Editor for /. Sitting under the route it edits means the preview -// resolves the same params the public page gets, and `../page.json` is the -// very file publishing writes back to. export default function EditorPage() { - return ; + return ; } diff --git a/app/globals.css b/app/globals.css index 9615c91..4651d81 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,53 +3,41 @@ @custom-variant dark (&:is(.dark *)); @theme { - /* Refined neutral modern palette */ --color-background: hsl(0 0% 100%); --color-foreground: hsl(240 10% 3.9%); - /* Card */ --color-card: hsl(0 0% 100%); --color-card-foreground: hsl(240 10% 3.9%); - /* Popover */ --color-popover: hsl(0 0% 100%); --color-popover-foreground: hsl(240 10% 3.9%); - /* Primary - Deep neutral slate for professional look */ --color-primary: hsl(240 5.9% 10%); --color-primary-foreground: hsl(0 0% 98%); - /* Secondary */ --color-secondary: hsl(240 4.8% 95.9%); --color-secondary-foreground: hsl(240 5.9% 10%); - /* Muted */ --color-muted: hsl(240 4.8% 95.9%); --color-muted-foreground: hsl(240 3.8% 46.1%); - /* Accent - Subtle warm gray */ --color-accent: hsl(30 6.7% 95%); --color-accent-foreground: hsl(240 5.9% 10%); - /* Destructive */ --color-destructive: hsl(0 84.2% 60.2%); --color-destructive-foreground: hsl(0 0% 98%); - /* Shop (accelerated checkout) purple */ --color-shop: #5a31f4; - /* Border */ --color-border: hsl(240 5.9% 90%); --color-input: hsl(240 5.9% 90%); --color-ring: hsl(240 5.9% 10%); - /* Radius - Modern larger corners */ --radius-sm: 0.5rem; --radius-md: 0.75rem; --radius-lg: 1rem; --radius-xl: 1.25rem; - /* Typography — Geist Sans / Geist Mono */ --font-sans: var(--font-geist-sans), system-ui, sans-serif; --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace; --font-heading: var(--font-geist-sans), system-ui, sans-serif; @@ -57,10 +45,7 @@ --font-poppins: var(--font-geist-sans), system-ui, sans-serif; } -/* Base styles for light, modern Shopify storefront */ -/* Tailwind v4 defaults an unqualified `border` to currentColor. shadcn - components (e.g. Button's outline variant) rely on this base layer to pick up - the theme's border colour instead of the text colour. */ +/* Tailwind v4 otherwise defaults unqualified borders to currentColor. */ @layer base { * { border-color: var(--color-border); @@ -75,7 +60,6 @@ body { -moz-osx-font-smoothing: grayscale; } -/* Modern card design for products and collections */ .card-modern { transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); border: 1px solid var(--color-border); @@ -87,7 +71,6 @@ body { border-color: hsl(240 5.9% 85%); } -/* Heading styles — regular weight, slightly tightened like the Geist reference */ h1, h2, .font-heading { @@ -95,7 +78,6 @@ h2, letter-spacing: -0.02em; } -/* Swipeable rows (mobile galleries) without a visible scrollbar */ .no-scrollbar { scrollbar-width: none; -ms-overflow-style: none; @@ -105,7 +87,6 @@ h2, display: none; } -/* Diagonal strike marking an unavailable product option (size pill, swatch) */ .option-unavailable { background-image: linear-gradient( to top right, @@ -116,7 +97,6 @@ h2, ); } -/* Product description (HTML returned by the Storefront API) */ .product-description p { margin-bottom: 1rem; } @@ -125,7 +105,6 @@ h2, margin-bottom: 0; } -/* Shop policy body (HTML returned by the Storefront API) */ .policy-body p, .policy-body ul, .policy-body ol { @@ -164,7 +143,6 @@ h2, margin-bottom: 0; } -/* Enhanced button styles */ button, .btn-modern { 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); } -/* Hero section styles */ .hero-bg { background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f5 100%); } -/* Product gallery improvements */ .product-gallery-main { transition: box-shadow 0.3s ease; } @@ -191,7 +167,6 @@ button, box-shadow: 0 0 0 1px hsl(240 5.9% 80%) inset; } -/* Clean price display */ .price-display { font-feature-settings: 'tnum'; } @@ -227,4 +202,4 @@ button, --color-3: oklch(69.6% 0.165 251); --color-4: oklch(80.2% 0.134 225); --color-5: oklch(90.7% 0.231 133); -} \ No newline at end of file +} diff --git a/app/layout.tsx b/app/layout.tsx index 9967943..a64d67f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -14,8 +14,6 @@ const geistMono = 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 = { metadataBase: new URL(site.url), title: { @@ -44,9 +42,6 @@ export default function RootLayout({ }) { return ( - {/* Header, footer and the store assistant are editable blocks now, so - each page.json carries its own chrome rather than the layout doing - it. Keeps what the editor shows identical to what the route ships. */}
{children}
diff --git a/app/policies/[handle]/editor/page.tsx b/app/policies/[handle]/editor/page.tsx index 1299fb1..e1e9241 100644 --- a/app/policies/[handle]/editor/page.tsx +++ b/app/policies/[handle]/editor/page.tsx @@ -1,9 +1,8 @@ import PageEditor from '@/components/page-editor'; import pageData from '../page.json'; -// Editor for /policies/[handle]. Sitting under the route it edits means the preview -// resolves the same params the public page gets, and `../page.json` is the -// very file publishing writes back to. export default function EditorPage() { - return ; + return ( + + ); } diff --git a/app/products/[handle]/editor/page.tsx b/app/products/[handle]/editor/page.tsx index 805f1e7..bba4c6b 100644 --- a/app/products/[handle]/editor/page.tsx +++ b/app/products/[handle]/editor/page.tsx @@ -1,9 +1,8 @@ import PageEditor from '@/components/page-editor'; import pageData from '../page.json'; -// Editor for /products/[handle]. Sitting under the route it edits means the preview -// resolves the same params the public page gets, and `../page.json` is the -// very file publishing writes back to. export default function EditorPage() { - return ; + return ( + + ); } diff --git a/app/products/[handle]/page.tsx b/app/products/[handle]/page.tsx index 337de48..eec52ae 100644 --- a/app/products/[handle]/page.tsx +++ b/app/products/[handle]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import PageRender from '@/components/page-render'; import { pageMetadata } from '@/lib/page-metadata'; -import { getProduct } from '@/services/shopify/catalog'; +import { getProduct } from '@/services/shopify/shop'; import { truncate } from '@/lib/utils'; import page from './page.json'; @@ -12,8 +12,6 @@ export async function generateMetadata({ }): Promise { const { handle } = await params; - // The blocks fetch client-side and render their own empty state, so a failed - // lookup falls back to the page.json tags rather than a 500. const product = await getProduct(handle).catch(() => null); if (!product) return pageMetadata(page); diff --git a/app/search/editor/page.tsx b/app/search/editor/page.tsx index fa127f6..667496b 100644 --- a/app/search/editor/page.tsx +++ b/app/search/editor/page.tsx @@ -1,9 +1,6 @@ import PageEditor from '@/components/page-editor'; import pageData from '../page.json'; -// Editor for /search. Sitting under the route it edits means the preview -// resolves the same params the public page gets, and `../page.json` is the -// very file publishing writes back to. export default function EditorPage() { - return ; + return ; } diff --git a/app/shop/collections/page.tsx b/app/shop/collections/page.tsx index 82c6364..7c25929 100644 --- a/app/shop/collections/page.tsx +++ b/app/shop/collections/page.tsx @@ -1,12 +1,10 @@ import type { Metadata } from 'next'; import PageRender from '@/components/page-render'; import { pageMetadata } from '@/lib/page-metadata'; -// Alias of /collections — same page.json, so editing that route updates both. import page from '../../collections/page.json'; export const metadata: Metadata = { ...pageMetadata(page), - // Point crawlers at the canonical listing. alternates: { canonical: '/collections' }, }; diff --git a/components/ai-elements/attachments.tsx b/components/ai-elements/attachments.tsx index d4e4e34..4f70459 100644 --- a/components/ai-elements/attachments.tsx +++ b/components/ai-elements/attachments.tsx @@ -20,10 +20,6 @@ import { import type { ComponentProps, HTMLAttributes, ReactNode } from "react"; import { createContext, useCallback, useContext, useMemo } from "react"; -// ============================================================================ -// Types -// ============================================================================ - export type AttachmentData = | (FileUIPart & { id: string }) | (SourceDocumentUIPart & { id: string }); @@ -47,10 +43,6 @@ const mediaCategoryIcons: Record = { video: VideoIcon, }; -// ============================================================================ -// Utility Functions -// ============================================================================ - export const getMediaCategory = ( data: AttachmentData ): AttachmentMediaCategory => { @@ -108,10 +100,6 @@ const renderAttachmentImage = ( /> ); -// ============================================================================ -// Contexts -// ============================================================================ - interface AttachmentsContextValue { variant: AttachmentVariant; } @@ -127,10 +115,6 @@ interface AttachmentContextValue { const AttachmentContext = createContext(null); -// ============================================================================ -// Hooks -// ============================================================================ - export const useAttachmentsContext = () => useContext(AttachmentsContext) ?? { variant: "grid" as const }; @@ -142,10 +126,6 @@ export const useAttachmentContext = () => { return ctx; }; -// ============================================================================ -// Attachments - Container -// ============================================================================ - export type AttachmentsProps = HTMLAttributes & { variant?: AttachmentVariant; }; @@ -175,10 +155,6 @@ export const Attachments = ({ ); }; -// ============================================================================ -// Attachment - Item -// ============================================================================ - export type AttachmentProps = HTMLAttributes & { data: AttachmentData; onRemove?: () => void; @@ -225,10 +201,6 @@ export const Attachment = ({ ); }; -// ============================================================================ -// AttachmentPreview - Media preview -// ============================================================================ - export type AttachmentPreviewProps = HTMLAttributes & { fallbackIcon?: ReactNode; }; @@ -275,10 +247,6 @@ export const AttachmentPreview = ({ ); }; -// ============================================================================ -// AttachmentInfo - Name and type display -// ============================================================================ - export type AttachmentInfoProps = HTMLAttributes & { showMediaType?: boolean; }; @@ -307,10 +275,6 @@ export const AttachmentInfo = ({ ); }; -// ============================================================================ -// AttachmentRemove - Remove button -// ============================================================================ - export type AttachmentRemoveProps = ComponentProps & { label?: string; }; @@ -365,10 +329,6 @@ export const AttachmentRemove = ({ ); }; -// ============================================================================ -// AttachmentHoverCard - Hover preview -// ============================================================================ - export type AttachmentHoverCardProps = ComponentProps; export const AttachmentHoverCard = ({ @@ -403,10 +363,6 @@ export const AttachmentHoverCardContent = ({ /> ); -// ============================================================================ -// AttachmentEmpty - Empty state -// ============================================================================ - export type AttachmentEmptyProps = HTMLAttributes; export const AttachmentEmpty = ({ diff --git a/components/ai-elements/markdown.tsx b/components/ai-elements/markdown.tsx index 8fc8219..73146ee 100644 --- a/components/ai-elements/markdown.tsx +++ b/components/ai-elements/markdown.tsx @@ -24,16 +24,12 @@ import remarkRehype from "remark-rehype"; import remend from "remend"; import { unified } from "unified"; -// Raw HTML never reaches the tree: remark-rehype drops it (allowDangerousHtml -// is off by default) and rehype-sanitize is the second line of defence, mainly -// for its href protocol allow-list — that is what stops `javascript:` URLs. -// Relative hrefs carry no protocol, so storefront links pass through untouched. +// Sanitize model output and reject unsafe URL protocols. const schema = { ...defaultSchema, attributes: { ...defaultSchema.attributes, - // GFM task lists render as disabled checkboxes. The default schema allows - // `type` and `disabled` but not `checked`, so every box would read unticked. + // Preserve checked state for sanitized GFM task lists. input: [...(defaultSchema.attributes?.input ?? []), "checked"], }, }; @@ -44,22 +40,15 @@ const processor = unified() .use(remarkRehype) .use(rehypeSanitize, schema); -// `text-only` leaves a half-streamed `[label](htt` as plain text. remend's -// default instead emits a `streamdown:incomplete-link` placeholder href, which -// the sanitizer would strip anyway. +// Keep incomplete links as text while streaming. const REMEND_OPTIONS = { linkMode: "text-only" } as const; -// remend only ever runs on text that is still arriving. On finished text it can -// do damage: it reads `*$89*` as an unclosed italic (the `$` throws off its -// closing-delimiter scan) and appends a stray `*`, which then parses as an empty -// bullet. Prices in italics are ordinary storefront copy, so settled messages -// render verbatim and only the in-flight one gets repaired. +// Repair only streaming text; remend can corrupt settled italicized prices. const repair = (markdown: string, isStreaming: boolean) => isStreaming ? remend(markdown, REMEND_OPTIONS) : markdown; export interface LinkSafetyConfig { enabled: boolean; - /** Return true for links that may open without a confirmation step. */ onLinkCheck?: (url: string) => boolean; } @@ -71,9 +60,6 @@ type AnchorProps = ComponentProps<"a"> & { const LINK_CLASS = "font-medium underline underline-offset-4 hover:text-foreground"; -// Route-relative hrefs are the storefront links the assistant emits. Deciding -// this from the string alone keeps the server and client passes identical; -// `linkSafety.onLinkCheck` does the origin-aware check later, at click time. const isRouteHref = (href: string) => /^[/#?]/.test(href); const MarkdownLink = ({ @@ -82,8 +68,6 @@ const MarkdownLink = ({ onUntrusted, ...props }: AnchorProps) => { - // Runs on click rather than on render so it can read `window.location`, - // which is unavailable during the server pass. const handleClick = useCallback( (event: MouseEvent) => { if (!href || !linkSafety?.enabled || !linkSafety.onLinkCheck) return; @@ -95,8 +79,6 @@ const MarkdownLink = ({ [href, linkSafety, onUntrusted] ); - // In-app destinations navigate client-side in the same tab; sending a shopper - // to a product page in a new tab would strand the conversation behind it. if (href && isRouteHref(href)) { return ; } @@ -113,7 +95,6 @@ const MarkdownLink = ({ ); }; -// Fenced code renders as plain preformatted text — no tokenizer, no themes. const buildComponents = ( linkSafety: LinkSafetyConfig | undefined, onUntrusted: (url: string) => void @@ -128,8 +109,6 @@ const buildComponents = ( /> ), code: ({ className, ...props }: ComponentProps<"code">) => { - // Only fenced blocks carry a language class, and those already sit inside a - //
, so they must not get the inline pill treatment.
     const isBlock =
       typeof className === "string" && className.includes("language-");
 
@@ -167,8 +146,6 @@ const buildComponents = (
   hr: (props: ComponentProps<"hr">) => (
     
), - // GFM task-list boxes are display only; readOnly silences React's warning - // about a `checked` input with no change handler. input: (props: ComponentProps<"input">) => ( ), @@ -213,7 +190,6 @@ const buildComponents = ( export type MarkdownProps = Omit, "children"> & { children: string; - /** True while tokens are still arriving; enables incomplete-syntax repair. */ isAnimating?: boolean; linkSafety?: LinkSafetyConfig; }; @@ -231,8 +207,6 @@ export const Markdown = memo( const handleUntrusted = useCallback((url: string) => setPending(url), []); const content = useMemo(() => { - // While streaming, close syntax the model has not finished emitting so a - // partial `**bold` renders as bold rather than as literal asterisks. const source = repair(children ?? "", isAnimating === true); const tree = processor.runSync(processor.parse(source)) as Nodes; diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx index 0906a23..dc9a521 100644 --- a/components/ai-elements/message.tsx +++ b/components/ai-elements/message.tsx @@ -202,7 +202,6 @@ export const MessageBranchContent = ({ [children] ); - // Use useEffect to update branches when they change useEffect(() => { if (branches.length !== childrenArray.length) { setBranches(childrenArray); @@ -231,7 +230,6 @@ export const MessageBranchSelector = ({ }: MessageBranchSelectorProps) => { const { totalBranches } = useMessageBranch(); - // Don't render if there's only one branch if (totalBranches <= 1) { return null; } @@ -317,7 +315,6 @@ export const MessageBranchPage = ({ export type MessageResponseProps = ComponentProps; -// Markdown already memoises on children/isAnimating. export const MessageResponse = Markdown; export type MessageToolbarProps = ComponentProps<"div">; diff --git a/components/ai-elements/prompt-input.tsx b/components/ai-elements/prompt-input.tsx index 5be6019..a63ca80 100644 --- a/components/ai-elements/prompt-input.tsx +++ b/components/ai-elements/prompt-input.tsx @@ -74,15 +74,10 @@ import { useState, } from "react"; -// ============================================================================ -// Helpers -// ============================================================================ - const convertBlobUrlToDataUrl = async (url: string): Promise => { try { const response = await fetch(url); const blob = await response.blob(); - // FileReader uses callback-based API, wrapping in Promise is necessary // oxlint-disable-next-line eslint-plugin-promise(avoid-new) return new Promise((resolve) => { const reader = new FileReader(); @@ -118,7 +113,6 @@ const captureScreenshot = async (): Promise => { video.srcObject = stream; - // Video element uses callback-based API, wrapping in Promise is necessary // oxlint-disable-next-line eslint-plugin-promise(avoid-new) await new Promise((resolve, reject) => { // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener) @@ -144,7 +138,6 @@ const captureScreenshot = async (): Promise => { } 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) const blob = await new Promise((resolve) => { canvas.toBlob(resolve, "image/png"); @@ -174,10 +167,6 @@ const captureScreenshot = async (): Promise => { } }; -// ============================================================================ -// Provider Context & Types -// ============================================================================ - export interface AttachmentsContext { files: (FileUIPart & { id: string })[]; add: (files: File[] | FileList) => void; @@ -196,7 +185,6 @@ export interface TextInputContext { export interface PromptInputControllerProps { textInput: TextInputContext; attachments: AttachmentsContext; - /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ __registerFileInput: ( ref: RefObject, open: () => void @@ -220,7 +208,6 @@ export const usePromptInputController = () => { return ctx; }; -// Optional variants (do NOT throw). Useful for dual-mode components. const useOptionalPromptInputController = () => useContext(PromptInputController); @@ -241,19 +228,14 @@ export type PromptInputProviderProps = PropsWithChildren<{ initialInput?: string; }>; -/** - * Optional global provider that lifts PromptInput state outside of PromptInput. - * If you don't use it, PromptInput stays fully self-managed. - */ +/** Lifts prompt state when shared control is needed. */ export const PromptInputProvider = ({ initialInput: initialTextInput = "", children, }: PromptInputProviderProps) => { - // ----- textInput state const [textInput, setTextInput] = useState(initialTextInput); const clearInput = useCallback(() => setTextInput(""), []); - // ----- attachments state (global when wrapped) const [attachmentFiles, setAttachmentFiles] = useState< (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); useEffect(() => { attachmentsRef.current = attachmentFiles; }, [attachmentFiles]); - // Cleanup blob URLs on unmount to prevent memory leaks useEffect( () => () => { for (const f of attachmentsRef.current) { @@ -365,14 +346,9 @@ export const PromptInputProvider = ({ ); }; -// ============================================================================ -// Component Context & Hooks -// ============================================================================ - const LocalAttachmentsContext = createContext(null); export const usePromptInputAttachments = () => { - // Prefer local context (inside PromptInput) as it has validation, fall back to provider const provider = useOptionalProviderAttachments(); const local = useContext(LocalAttachmentsContext); const context = local ?? provider; @@ -384,10 +360,6 @@ export const usePromptInputAttachments = () => { return context; }; -// ============================================================================ -// Referenced Sources (Local to PromptInput) -// ============================================================================ - export interface ReferencedSourcesContext { sources: (SourceDocumentUIPart & { id: string })[]; add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void; @@ -490,16 +462,11 @@ export type PromptInputProps = Omit< HTMLAttributes, "onSubmit" | "onError" > & { - // e.g., "image/*" or leave undefined for any accept?: string; multiple?: boolean; - // When true, accepts drops anywhere on document. Default false (opt-in). globalDrop?: boolean; - // Render a hidden input with given name and keep it in sync for native form posts. Default false. syncHiddenInput?: boolean; - // Minimal constraints maxFiles?: number; - // bytes maxFileSize?: number; onError?: (err: { code: "max_files" | "max_file_size" | "accept"; @@ -524,24 +491,19 @@ export const PromptInput = ({ children, ...props }: PromptInputProps) => { - // Try to use a provider controller if present const controller = useOptionalPromptInputController(); const usingProvider = !!controller; - // Refs const inputRef = useRef(null); const formRef = useRef(null); - // ----- Local attachments (only used when no provider) const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); const files = usingProvider ? controller.attachments.files : items; - // ----- Local referenced sources (always local to PromptInput) const [referencedSources, setReferencedSources] = useState< (SourceDocumentUIPart & { id: string })[] >([]); - // Keep a ref to files for cleanup on unmount (avoids stale closure) const filesRef = useRef(files); useEffect(() => { @@ -565,7 +527,6 @@ export const PromptInput = ({ return patterns.some((pattern) => { if (pattern.endsWith("/*")) { - // e.g: image/* -> image/ const prefix = pattern.slice(0, -1); return f.type.startsWith(prefix); } @@ -638,7 +599,6 @@ export const PromptInput = ({ [] ); - // Wrapper that validates files before calling provider's add const addWithProviderValidation = useCallback( (fileList: File[] | FileList) => { const incoming = [...fileList]; @@ -713,7 +673,6 @@ export const PromptInput = ({ clearReferencedSources(); }, [clearAttachments, clearReferencedSources]); - // Let provider know about our hidden file input so external menus can call openFileDialog() useEffect(() => { if (!usingProvider) { return; @@ -721,22 +680,19 @@ export const PromptInput = ({ controller.__registerFileInput(inputRef, () => inputRef.current?.click()); }, [usingProvider, controller]); - // Note: File input cannot be programmatically set for security reasons - // The syncHiddenInput prop is no longer functional + // Browsers forbid programmatically populating file inputs. useEffect(() => { if (syncHiddenInput && inputRef.current && files.length === 0) { inputRef.current.value = ""; } }, [files, syncHiddenInput]); - // Attach drop handlers on nearest form and document (opt-in) useEffect(() => { const form = formRef.current; if (!form) { return; } if (globalDrop) { - // when global drop is on, let the document-level handler own drops return; } @@ -805,7 +761,6 @@ export const PromptInput = ({ if (event.currentTarget.files) { add(event.currentTarget.files); } - // Reset input value to allow selecting files that were previously removed event.currentTarget.value = ""; }, [add] @@ -853,19 +808,16 @@ export const PromptInput = ({ return (formData.get("message") as string) || ""; })(); - // Reset form immediately after capturing text to avoid race condition - // where user input during async blob conversion would be lost + // Reset before async conversion so new input is not cleared afterward. if (!usingProvider) { form.reset(); } try { - // Convert blob URLs to data URLs asynchronously const convertedFiles: FileUIPart[] = await Promise.all( files.map(async ({ id: _id, ...item }) => { if (item.url?.startsWith("blob:")) { const dataUrl = await convertBlobUrlToDataUrl(item.url); - // If conversion failed, keep the original blob URL return { ...item, url: dataUrl ?? item.url, @@ -877,7 +829,6 @@ export const PromptInput = ({ const result = onSubmit({ files: convertedFiles, text }, event); - // Handle both sync and async onSubmit if (result instanceof Promise) { try { await result; @@ -886,23 +837,21 @@ export const PromptInput = ({ controller.textInput.clear(); } } catch { - // Don't clear on error - user may want to retry + // Preserve failed submissions for retry. } } else { - // Sync function completed without throwing, clear inputs clear(); if (usingProvider) { controller.textInput.clear(); } } } catch { - // Don't clear on error - user may want to retry + // Preserve failed submissions for retry. } }, [usingProvider, controller, files, onSubmit, clear] ); - // Render with or without local provider const inner = ( <> ); - // Always provide LocalAttachmentsContext so children get validated add function return ( {withReferencedSources} @@ -966,10 +914,8 @@ export const PromptInputTextarea = ({ const handleKeyDown: KeyboardEventHandler = useCallback( (e) => { - // Call the external onKeyDown handler first onKeyDown?.(e); - // If the external handler prevented default, don't run internal logic if (e.defaultPrevented) { return; } @@ -983,7 +929,6 @@ export const PromptInputTextarea = ({ } e.preventDefault(); - // Check if the submit button is disabled before submitting const { form } = e.currentTarget; const submitButton = form?.querySelector( 'button[type="submit"]' @@ -995,7 +940,6 @@ export const PromptInputTextarea = ({ form?.requestSubmit(); } - // Remove last attachment when Backspace is pressed and textarea is empty if ( e.key === "Backspace" && e.currentTarget.value === "" && @@ -1205,9 +1149,6 @@ export const PromptInputActionMenuItem = ({ ); -// 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 & { status?: ChatStatus; onStop?: () => void; @@ -1364,7 +1305,6 @@ export const PromptInputTabLabel = ({ className, ...props }: PromptInputTabLabelProps) => ( - // Content provided via children in props // oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)

{ const resolvedDefaultOpen = defaultOpen ?? isStreaming; - // Track if defaultOpen was explicitly set to false (to prevent auto-open) const isExplicitlyClosed = defaultOpen === false; const [isOpen, setIsOpen] = useControllableState({ @@ -80,7 +79,6 @@ export const Reasoning = memo( const [hasAutoClosed, setHasAutoClosed] = useState(false); const startTimeRef = useRef(null); - // Track when streaming starts and compute duration useEffect(() => { if (isStreaming) { hasEverStreamedRef.current = true; @@ -93,14 +91,12 @@ export const Reasoning = memo( } }, [isStreaming, setDuration]); - // Auto-open when streaming starts (unless explicitly closed) useEffect(() => { if (isStreaming && !isOpen && !isExplicitlyClosed) { setIsOpen(true); } }, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]); - // Auto-close when streaming ends (once only, and only if it ever streamed) useEffect(() => { if ( hasEverStreamedRef.current && diff --git a/components/ai-elements/shimmer.tsx b/components/ai-elements/shimmer.tsx index 6b635d0..fad969b 100644 --- a/components/ai-elements/shimmer.tsx +++ b/components/ai-elements/shimmer.tsx @@ -8,7 +8,6 @@ import { memo, useMemo } from "react"; type MotionHTMLProps = MotionProps & Record; -// Cache motion components at module level to avoid creating during render const motionComponentCache = new Map< keyof JSX.IntrinsicElements, React.ComponentType diff --git a/components/logo.tsx b/components/logo.tsx index d019b3f..aebeb56 100644 --- a/components/logo.tsx +++ b/components/logo.tsx @@ -2,15 +2,10 @@ import React from 'react'; import Link from 'next/link'; interface LogoProps { - /** Logo image URL. Falls back to the store name as a wordmark when absent. */ src?: string | null; - /** Wordmark text, and the image's alt text. */ storeName?: string; - /** Where the logo links to. Pass null to render it unwrapped. */ href?: string | null; - /** Sizing for the image — height only, so the aspect ratio is preserved. */ imageClassName?: string; - /** Sizing and weight for the wordmark fallback. */ textClassName?: string; } diff --git a/components/page-editor.tsx b/components/page-editor.tsx index 4d52d9d..2fb11be 100644 --- a/components/page-editor.tsx +++ b/components/page-editor.tsx @@ -1,132 +1,51 @@ 'use client'; -import { useCallback, useMemo, useState } from 'react'; -import { useParams, usePathname, useRouter } from 'next/navigation'; +import { useCallback, useMemo } from 'react'; import { Editor, outlinePlugin, type Data } from '@reacteditor/core'; import createTailwindCdnPlugin from '@reacteditor/plugin-tailwind-cdn'; import { createShopifyPlugin } from '@reacteditor/plugin-shopify'; import { appConfig } from '@/editor.config'; -import { ROUTE_KEYS, editorHref, findPageRoute } from '@/lib/pages'; import { publishPage } from '@/lib/publish-page'; import globals from '@/app.globals.json'; -// Plugin instances must keep a stable identity across renders, same as -// `appConfig`, so they are built once at module scope. -// -// The Tailwind CDN plugin only styles the editor's preview iframe; the public -// routes still get their utilities from the compiled `app/globals.css`. -const tailwindCdn = createTailwindCdnPlugin(); - -// Registers the `shopifyProduct` and `shopifyCollection` field types used by -// the commerce blocks. Credentials are the same public storefront pair the -// rendered components read — safe in the browser by definition. -const shopify = createShopifyPlugin({ - storeDomain: process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? 'mock.shop', - publicAccessToken: - process.env.NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN ?? undefined, - apiVersion: process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION ?? '2026-07', -}); - -// Adds the outline panel — the tree of blocks on the page, for selecting and -// reordering without hunting through the preview. -const outline = outlinePlugin(); - -const plugins = [outline, tailwindCdn, shopify]; +const plugins = [ + outlinePlugin(), + createTailwindCdnPlugin(), + createShopifyPlugin({ + storeDomain: process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? 'mock.shop', + publicAccessToken: + process.env.NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN ?? undefined, + apiVersion: process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION ?? '2026-07', + }), +]; export interface PageEditorProps { - /** Route key from `lib/pages.ts`, e.g. `/products/[handle]`. */ - 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. - */ + pagePath: string; page: Record; } -/** - * 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(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. +export default function PageEditor({ pagePath, page }: PageEditorProps) { const data = useMemo( () => ({ ...page, globals }) as unknown as Data, [page] ); - // The editor lives at `/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 = {}; - for (const [key, value] of Object.entries(params ?? {})) { - if (typeof value === 'string') entries[key] = value; - } - return entries; - }, [params]); - const handlePublish = useCallback( async (published: Data) => { - setStatus('Saving…'); - - try { - const result = await publishPage(routeKey, published); - - setStatus( - result.error ? result.error : `Saved to ${result.file}` - ); - } catch { - setStatus('Could not reach the server.'); - } + const result = await publishPage(pagePath, published); + if (result.error) throw new Error(result.error); }, - [routeKey] + [pagePath] ); return ( { - // The picker hands back a route key; ignore anything not registered. - if (findPageRoute(nextKey)) router.push(editorHref(nextKey)); - }} onPublish={handlePublish} - headerTitle={findPageRoute(routeKey)?.label ?? routeKey} - // The concrete URL being previewed. The editor's own URL bar shows the - // route key (the template being edited); this is the resolved path. - headerPath={publicPath} - renderHeaderActions={({ state }) => ( - - {status ?? `${state.data.content.length} blocks`} - - )} /> ); } diff --git a/components/page-render.tsx b/components/page-render.tsx index bd91813..f561312 100644 --- a/components/page-render.tsx +++ b/components/page-render.tsx @@ -10,20 +10,6 @@ export type PageData = { 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 () => ; - * - * The same `appConfig` backs `PageEditor`, so what an editor sees is what the - * route ships. - * - * `app.globals.json` carries the props of blocks marked `global: true` — the - * header and footer. Every page.json references them via `"synced": true`, so - * editing the header once updates all thirteen routes. - */ export default function PageRender({ page }: { page: PageData }) { const data = { root: page.root, content: page.content, globals }; return ; diff --git a/components/shopify/cart-drawer.tsx b/components/shopify/cart-drawer.tsx index ce98e4e..7ce1aef 100644 --- a/components/shopify/cart-drawer.tsx +++ b/components/shopify/cart-drawer.tsx @@ -26,7 +26,7 @@ import { EmptyDescription, EmptyContent, } from '@/components/ui/empty'; -import { isDefaultTitleSelection } from '@/services/shopify/catalog'; +import { isDefaultTitleSelection } from '@/services/shopify/shop'; const CartDrawer: React.FC = () => { const isOpen = useCartStore((s) => s.isOpen); @@ -40,8 +40,7 @@ const CartDrawer: React.FC = () => { const [discountCode, setDiscountCode] = useState(''); const [discountError, setDiscountError] = useState(null); const [applyingDiscount, setApplyingDiscount] = useState(false); - // The store's `loading` flag is global, so track the specific line being - // changed to keep the other rows interactive. + // Track row-level work separately from the cart's global loading state. const [pendingLineId, setPendingLineId] = useState(null); const runLineAction = async (lineId: string, action: () => Promise) => { @@ -80,7 +79,6 @@ const CartDrawer: React.FC = () => { setDiscountError(null); const updatedCart = await applyDiscountCode(code); - // Shopify accepts unknown codes silently, flagging them as inapplicable. const accepted = updatedCart.discountCodes?.some( (discount) => discount.applicable && @@ -104,7 +102,6 @@ const CartDrawer: React.FC = () => { }; const getSelectedOptions = (item: (typeof items)[0]) => { - // Single-SKU items carry a synthetic `Title: Default Title` — not worth a line. return (item.merchandise.selectedOptions ?? []).filter( (option) => !isDefaultTitleSelection(option) ); @@ -119,7 +116,6 @@ const CartDrawer: React.FC = () => { {isOpen && ( - {/* Header */}
@@ -140,7 +136,6 @@ const CartDrawer: React.FC = () => {
- {/* Cart Items */} {loading && items.length === 0 ? (
@@ -169,7 +164,6 @@ const CartDrawer: React.FC = () => { return (
- {/* Product Image */}
{image ? ( { )}
- {/* Product Details */}

@@ -209,7 +202,6 @@ const CartDrawer: React.FC = () => {

)} - {/* Quantity Controls */}
- {/* Mobile Menu */} {menuOpen && (
diff --git a/components/shopify/policy-body.editor.tsx b/components/shopify/policy-body.editor.tsx index 5876c37..a599c66 100644 --- a/components/shopify/policy-body.editor.tsx +++ b/components/shopify/policy-body.editor.tsx @@ -18,7 +18,6 @@ const policyBodyEditor: ComponentConfig = { handle: { label: 'Policy', type: 'select', - // Empty follows the `[handle]` route segment on /policies/[handle]. options: [ { label: 'Follow the page URL', value: '' }, ...POLICY_HANDLES.map((handle) => ({ diff --git a/components/shopify/policy-body.tsx b/components/shopify/policy-body.tsx index 6170029..f0535cf 100644 --- a/components/shopify/policy-body.tsx +++ b/components/shopify/policy-body.tsx @@ -5,20 +5,11 @@ import { useParams } from 'next/navigation'; import { getShopPolicy, type ShopPolicy } from '@/hooks/use-shopify-policies'; 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; - /** Overrides the policy's own title. Empty falls back to Shopify's. */ title?: 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 = ({ handle: handleProp, title: titleProp, diff --git a/components/shopify/product-card.tsx b/components/shopify/product-card.tsx index bb06cb5..3167152 100644 --- a/components/shopify/product-card.tsx +++ b/components/shopify/product-card.tsx @@ -66,7 +66,6 @@ const ProductCard: React.FC = ({ product }) => { href={`/products/${product.handle}`} className="group block h-full" > - {/* Product Image */}
{firstImage ? ( = ({ product }) => { )}
- {/* Product Info */}

{truncate(product.title, 65)} diff --git a/components/shopify/product-detail.editor.tsx b/components/shopify/product-detail.editor.tsx index 455e660..17bbf84 100644 --- a/components/shopify/product-detail.editor.tsx +++ b/components/shopify/product-detail.editor.tsx @@ -8,11 +8,6 @@ export type ProductDetailBlockProps = { 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 = { label: 'Product page', icon: , diff --git a/components/shopify/product-detail/index.tsx b/components/shopify/product-detail/index.tsx index 8212950..4c83938 100644 --- a/components/shopify/product-detail/index.tsx +++ b/components/shopify/product-detail/index.tsx @@ -60,7 +60,6 @@ const ProductDetail: React.FC = ({ const [addingToCart, setAddingToCart] = useState(false); const [buyingNow, setBuyingNow] = useState(false); - // Initialize variant when product loads useEffect(() => { if (product) { const firstVariant = product.variants.edges[0]?.node; @@ -78,9 +77,7 @@ const ProductDetail: React.FC = ({ } }, [product]); - // A value is available if some in-stock variant carries it alongside the - // other currently-selected options. Options the shopper hasn't chosen yet - // act as wildcards, so nothing is struck through before a full selection. + // Unselected options act as wildcards when checking variant availability. const isOptionValueAvailable = (optionName: string, value: string) => { const variants = product?.variants.edges ?? []; if (variants.length === 0) return true; @@ -100,7 +97,6 @@ const ProductDetail: React.FC = ({ const newOptions = { ...selectedOptions, [optionName]: value }; setSelectedOptions(newOptions); - // Find matching variant const matchingVariant = product?.variants.edges.find(({ node }) => { return node.selectedOptions.every( (option) => newOptions[option.name] === option.value @@ -126,8 +122,6 @@ const ProductDetail: React.FC = ({ } }; - // 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 () => { if (!selectedVariant || !product) return; @@ -152,7 +146,6 @@ const ProductDetail: React.FC = ({ return (
- {/* Image Gallery Skeleton */}
{Array.from({ length: 4 }).map((_, i) => (
= ({ ))}
- {/* Product Info Skeleton */}
diff --git a/components/shopify/product-detail/product-detail-gallery.tsx b/components/shopify/product-detail/product-detail-gallery.tsx index d1f671f..fca064c 100644 --- a/components/shopify/product-detail/product-detail-gallery.tsx +++ b/components/shopify/product-detail/product-detail-gallery.tsx @@ -26,8 +26,6 @@ const ProductDetailGallery: React.FC = ({ 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 scroller = scrollerRef.current; if (!scroller) return; @@ -47,8 +45,7 @@ const ProductDetailGallery: React.FC = ({ setActiveIndex(nearest); }, []); - // Touch already scrolls natively; this adds click-and-drag for pointers that - // don't (mouse at mobile widths), suspending snap so the drag stays smooth. + // Add pointer dragging without disrupting native touch scrolling. const drag = useRef<{ startX: number; startScroll: number } | null>(null); const onPointerDown = (event: React.PointerEvent) => { @@ -74,7 +71,6 @@ const ProductDetailGallery: React.FC = ({ if (!drag.current || !scroller) return; drag.current = null; - // Restoring snap lets the browser settle on the nearest slide. scroller.style.scrollSnapType = ''; }; @@ -88,7 +84,6 @@ const ProductDetailGallery: React.FC = ({ scroller.scrollTo({ left: scroller.scrollLeft + offset, behavior: 'smooth' }); }; - // Close on Escape, and keep the page behind the overlay from scrolling. useEffect(() => { if (!isZoomed) return; @@ -119,7 +114,6 @@ const ProductDetailGallery: React.FC = ({ return ( <> - {/* Swipeable carousel on mobile, grid from sm up */}
= ({ ))}
- {/* Carousel pagination — the grid needs no dots, so mobile only */} {!isSingle && (
{images.map((_, index) => ( @@ -182,15 +175,10 @@ const ProductDetailGallery: React.FC = ({ {zoomedImage.altText 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" /> diff --git a/components/shopify/product-detail/product-detail-info.tsx b/components/shopify/product-detail/product-detail-info.tsx index a41f7d3..c5d58dc 100644 --- a/components/shopify/product-detail/product-detail-info.tsx +++ b/components/shopify/product-detail/product-detail-info.tsx @@ -5,7 +5,7 @@ import { RiSubtractLine, RiAddLine } from '@remixicon/react'; import ShopPayButton from '@/components/shopify/shop-pay-button'; import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products'; import { isSwatchOptionName, swatchColorForName } from '@/config/swatches'; -import { isDefaultTitleOption } from '@/services/shopify/catalog'; +import { isDefaultTitleOption } from '@/services/shopify/shop'; interface ProductPrice { amount: string; @@ -52,15 +52,12 @@ interface ProductDetailInfoProps { handleAddToCart: () => void; handleBuyNow?: () => 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; loading?: boolean; buyingNow?: boolean; 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 = ( value: ProductOptionValue ): { background?: string; image?: string } => { @@ -103,13 +100,11 @@ const ProductDetailInfo: React.FC = ({ const isSwatchOption = (option: ProductOption) => isSwatchOptionName(option.name); - // Some products return Size before Color; show the swatches first either way. - // Single-SKU products expose a synthetic `Title: Default Title` option — drop it. + // Show swatches first and omit Shopify's synthetic single-SKU option. const orderedOptions = [...(product.options ?? [])] .filter((option) => !isDefaultTitleOption(option)) .sort((a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a))); - // `optionValues` carries the swatch data; fall back to plain `values`. const optionValuesFor = (option: ProductOption): ProductOptionValue[] => option.optionValues?.length ? option.optionValues @@ -131,7 +126,6 @@ const ProductDetailInfo: React.FC = ({ )}
- {/* Product Options — colour swatches lead, whatever order the API returns */} {orderedOptions.map((option) => { const isSwatch = isSwatchOption(option); const selected = selectedOptions[option.name]; @@ -172,12 +166,6 @@ const ProductDetailInfo: React.FC = ({ : 'ring-1 ring-border hover:ring-foreground/40' } ${isSoldOut ? 'option-unavailable text-foreground/60 opacity-60' : ''}`} 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: background ?? (image ? undefined : 'var(--color-muted)'), @@ -215,7 +203,6 @@ const ProductDetailInfo: React.FC = ({ ); })} - {/* Quantity + Add to Cart */}
- {/* Cart permalink into Shop Pay; falls back to the cart checkout URL. */} = ({ onFallbackClick={handleBuyNow} /> - {/* Description */} {(product.descriptionHtml || product.description) && (
{product.descriptionHtml ? ( diff --git a/components/shopify/product-filters.tsx b/components/shopify/product-filters.tsx index 73bd412..807bf33 100644 --- a/components/shopify/product-filters.tsx +++ b/components/shopify/product-filters.tsx @@ -12,13 +12,10 @@ import { import { Button } from '@/components/ui/button'; import { RiCloseLine, RiCheckLine } from '@remixicon/react'; 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 { id: string; label: string; count: number; - /** JSON string accepted back as a `ProductFilter` input. */ input: string; } @@ -37,7 +34,6 @@ interface ProductFiltersProps { onActiveFiltersChange: (filters: string[]) => void; } -// Colour facets render as swatches; everything else as a labelled list. const isColorFilter = (filter: ProductFilterFacet) => /colou?r/i.test(filter.label) || filter.id.toLowerCase().includes('color'); @@ -55,7 +51,6 @@ const ProductFilters: React.FC = ({ const priceFilter = filters.find((filter) => filter.type === 'PRICE_RANGE'); const activeSet = new Set(activeFilters); - // Price is rebuilt from the inputs rather than toggled, so track it apart. const activePriceInput = activeFilters.find((input) => input.includes('"price"') ); @@ -93,7 +88,6 @@ const ProductFilters: React.FC = ({ onActiveFiltersChange([]); }; - // Labels for the chips at the top of the panel. const activeChips = filters .flatMap((filter) => filter.values) .filter((value) => activeSet.has(value.input)); @@ -118,7 +112,6 @@ const ProductFilters: React.FC = ({ - {/* Active selections */} {(activeChips.length > 0 || activePriceInput) && (
{activeChips.map((value) => ( @@ -147,7 +140,6 @@ const ProductFilters: React.FC = ({

)} - {/* Price */} {priceFilter && (

Price

@@ -183,7 +175,6 @@ const ProductFilters: React.FC = ({
)} - {/* Facets */} {listFilters.map((filter) => (

@@ -209,9 +200,6 @@ const ProductFilters: React.FC = ({ : 'ring-1 ring-border hover:ring-foreground/40' }`} 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)', }} > diff --git a/components/shopify/product-recommendations.editor.tsx b/components/shopify/product-recommendations.editor.tsx index 1b4200e..44e8caa 100644 --- a/components/shopify/product-recommendations.editor.tsx +++ b/components/shopify/product-recommendations.editor.tsx @@ -23,7 +23,6 @@ const productRecommendationsEditor: ComponentConfig = ({ totalCount, onOpenFilters, @@ -69,7 +67,6 @@ const ProductToolbar: React.FC = ({ {sortOpen && ( <> - {/* Click-away layer sits under the menu, above the page. */}
setSortOpen(false)} diff --git a/components/shopify/products.editor.tsx b/components/shopify/products.editor.tsx index 4dca78d..2780527 100644 --- a/components/shopify/products.editor.tsx +++ b/components/shopify/products.editor.tsx @@ -10,10 +10,6 @@ export type ProductsBlockProps = { 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 = { label: 'Product grid', icon: , @@ -30,8 +26,6 @@ const productsEditor: ComponentConfig = { subtitle: { label: 'Subtitle', type: 'textarea', contentEditable: true }, 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', } as any, limit: { label: 'Products shown', type: 'number', min: 2, max: 48 }, diff --git a/components/shopify/products.tsx b/components/shopify/products.tsx index 837fc03..17e219c 100644 --- a/components/shopify/products.tsx +++ b/components/shopify/products.tsx @@ -52,10 +52,6 @@ interface ProductsProps { subtitle?: string; limit?: number; 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; } @@ -73,8 +69,6 @@ const Products: React.FC = ({ const [hasMoreProducts, setHasMoreProducts] = useState(true); const [cursor, setCursor] = useState(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) => { try { if (loadMore) { @@ -84,8 +78,6 @@ const Products: React.FC = ({ 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 ? await getCollectionProductsPage(collectionHandle, { first: limit, diff --git a/components/shopify/search-results.editor.tsx b/components/shopify/search-results.editor.tsx index 1a63f89..81bda55 100644 --- a/components/shopify/search-results.editor.tsx +++ b/components/shopify/search-results.editor.tsx @@ -6,10 +6,6 @@ export type SearchResultsBlockProps = { 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 = { label: 'Search results', icon: , diff --git a/components/shopify/search-results.tsx b/components/shopify/search-results.tsx index 2e883ce..91bcbe7 100644 --- a/components/shopify/search-results.tsx +++ b/components/shopify/search-results.tsx @@ -50,7 +50,7 @@ const SearchResults: React.FC = ({ title = 'Search' }) => { const [activeFilters, setActiveFilters] = useState([]); 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]); useEffect(() => { @@ -62,8 +62,6 @@ const SearchResults: React.FC = ({ title = 'Search' }) => { setError(null); const result = await searchProducts({ - // An empty term still returns the catalogue, which is what an - // unqualified /search visit should show. query, first: PAGE_SIZE, sortKey: sort.sortKey, @@ -77,8 +75,7 @@ const SearchResults: React.FC = ({ title = 'Search' }) => { setTotalCount(result.totalCount); setCursor(result.endCursor); setHasNextPage(result.hasNextPage); - // Facet counts change with the result set, but keep the panel stable - // while filters are applied so options don't vanish mid-selection. + // Do not remove active facet choices as result counts change. if (activeFilters.length === 0) setFilters(result.filters); } catch (err) { if (cancelled) return; @@ -140,7 +137,6 @@ const SearchResults: React.FC = ({ title = 'Search' }) => { />
- {/* Results */}
{loading ? (
diff --git a/components/shopify/shop-menu.tsx b/components/shopify/shop-menu.tsx index 9fabe05..e5a87f3 100644 --- a/components/shopify/shop-menu.tsx +++ b/components/shopify/shop-menu.tsx @@ -8,9 +8,7 @@ import { cn } from '@/lib/utils'; interface ShopMenuProps { label?: string; - /** Renders inline inside the mobile menu instead of as a floating panel. */ mobile?: boolean; - /** Fires after a collection is picked, so the mobile menu can close itself. */ onNavigate?: () => void; } @@ -22,7 +20,6 @@ const ShopMenu: React.FC = ({ const [open, setOpen] = useState(false); const { collections, loading, error, load } = useCollectionsOnDemand(); - // The collection list is only worth fetching once someone opens the menu. const toggle = () => { const next = !open; setOpen(next); @@ -138,7 +135,6 @@ const ShopMenu: React.FC = ({ {open && ( <> - {/* Catches the click that dismisses the panel. */}
setOpen(false)} />
{body} diff --git a/components/shopify/shop-pay-button.tsx b/components/shopify/shop-pay-button.tsx index f207c72..e1dc2c5 100644 --- a/components/shopify/shop-pay-button.tsx +++ b/components/shopify/shop-pay-button.tsx @@ -16,11 +16,10 @@ interface ShopPayButtonProps { disabled?: boolean; loading?: boolean; className?: string; - /** Used when no permalink can be built (missing domain or unusable IDs). */ 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 { const trimmed = id.trim(); 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 { const storeUrl = toStoreUrl(SHOPIFY_STORE_DOMAIN); if (!storeUrl || variants.length === 0) return null; @@ -72,8 +69,6 @@ const ShopPayButton: React.FC = ({ ); - // 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) { return ( = ({ className = 'h-auto w-[98px]', }) => ( diff --git a/components/shopify/store-assistant.editor.tsx b/components/shopify/store-assistant.editor.tsx index bf9e845..3ae316a 100644 --- a/components/shopify/store-assistant.editor.tsx +++ b/components/shopify/store-assistant.editor.tsx @@ -4,11 +4,6 @@ import StoreAssistant from '@/components/shopify/store-assistant'; export type StoreAssistantBlockProps = Record; -/** - * 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 = { label: 'Store assistant', icon: , diff --git a/components/shopify/store-assistant.tsx b/components/shopify/store-assistant.tsx index 308a395..7254ec0 100644 --- a/components/shopify/store-assistant.tsx +++ b/components/shopify/store-assistant.tsx @@ -64,8 +64,6 @@ import { RiShoppingBag3Line, } 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 SUGGESTIONS = [ @@ -101,8 +99,6 @@ const toolName = (type: string) => const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`; -// Turns a finished tool result into the one-line summary plus any products -// worth previewing. const summariseTool = ( name: string, output: Record | undefined @@ -166,7 +162,6 @@ const summariseTool = ( } }; -// Storefront links stay in-app, so they skip Streamdown's external-link modal. const isInternalLink = (url: string) => { if (url.startsWith('/')) return true; try { @@ -229,7 +224,6 @@ const AttachmentItem = memo(({ attachment, onRemove }: AttachmentItemProps) => { - {/* Thumbnail swaps to the remove button on hover. */}
@@ -270,7 +264,6 @@ const AttachmentItem = memo(({ attachment, onRemove }: AttachmentItemProps) => { AttachmentItem.displayName = 'AttachmentItem'; -// Pending uploads, shown inline above the textarea. const PromptInputAttachmentsDisplay = () => { const attachments = usePromptInputAttachments(); @@ -295,15 +288,12 @@ const PromptInputAttachmentsDisplay = () => { }; 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; const [open, setOpen] = useState(false); const [mounted, setMounted] = useState(false); const [input, setInput] = useState(''); - // Drives the launcher's slide-in on first paint. useEffect(() => setMounted(true), []); const { messages, sendMessage, status, error } = useChat({ @@ -323,7 +313,6 @@ const StoreAssistant: React.FC = () => { setInput(''); }; - // Attachments arrive on the submitted message, so images go out with it. const handleSubmit = (message: PromptInputMessage) => { const text = (message.text ?? input).trim(); const files = message.files ?? []; @@ -335,7 +324,6 @@ const StoreAssistant: React.FC = () => { return ( <> - {/* Popover panel, anchored above the launcher */}
{ title="Ask about the store" description="Find products, compare options, browse collections." > - {/* w-full + wrap so chips stack in the narrow popover rather - than scrolling off the edge. */} {SUGGESTIONS.map((suggestion) => ( { return ( { }; const name = toolName(part.type); - // Shimmer while the call is in flight; a quiet summary - // line once it returns. if ( toolPart.state === 'input-streaming' || toolPart.state === 'input-available' @@ -472,7 +454,6 @@ const StoreAssistant: React.FC = () => { return null; })} - {/* Images the shopper attached, shown under their message. */} {fileParts.length > 0 && ( {fileParts.map((part, index) => ( @@ -544,9 +525,6 @@ const StoreAssistant: React.FC = () => {
- {/* Launcher */} - {/* Rainbow treatment only while the assistant is open; otherwise the - launcher matches the rest of the site's buttons. */} {open ? ( setOpen(false)} diff --git a/components/ui/command.tsx b/components/ui/command.tsx index 7cf28c5..28abf66 100644 --- a/components/ui/command.tsx +++ b/components/ui/command.tsx @@ -36,8 +36,6 @@ function CommandDialog({ children, className, showCloseButton = true, - // Forwarded to the inner Command so callers can drive filtering themselves - // (e.g. results already filtered server-side). shouldFilter, ...props }: React.ComponentProps & { @@ -56,7 +54,6 @@ function CommandDialog({ ) { "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", - // Variants based on alignment. "has-[>[data-align=inline-start]]:[&>input]:pl-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-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", - // 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", className diff --git a/components/ui/item.tsx b/components/ui/item.tsx index 88360f0..0797a77 100644 --- a/components/ui/item.tsx +++ b/components/ui/item.tsx @@ -1,11 +1,9 @@ import React from 'react'; -// Utility function to combine classNames function cn(...classes: (string | undefined | null | false)[]): string { return classes.filter(Boolean).join(' '); } -// Item variants helper function getItemVariants( variant: 'default' | 'outline' | 'muted', size: 'default' | 'sm' @@ -27,7 +25,6 @@ function getItemVariants( return cn(baseStyles, variantStyles[variant], sizeStyles[size]); } -// Item media variants helper function getItemMediaVariants(variant: 'default' | 'icon' | 'image'): string { 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'; diff --git a/components/ui/progress.tsx b/components/ui/progress.tsx index 85c867e..a5ac706 100644 --- a/components/ui/progress.tsx +++ b/components/ui/progress.tsx @@ -1,6 +1,5 @@ import React from 'react'; -// Utility function to combine classNames function cn(...classes: (string | undefined | null | false)[]): string { return classes.filter(Boolean).join(' '); } diff --git a/components/ui/remix-icon.tsx b/components/ui/remix-icon.tsx index 831cf3c..fc5976d 100644 --- a/components/ui/remix-icon.tsx +++ b/components/ui/remix-icon.tsx @@ -16,12 +16,10 @@ const RemixIcon: React.FC = ({ }) => { if (!name) return null; - // Normalise: accept "RiTruckLine", "ri-truck-line", or "riTruckLine" + // Accept component, kebab-case, or camelCase icon names. const normalised = name - // ri-truck-line → RiTruckLine .replace(/^ri-/, 'Ri') .replace(/-([a-z])/g, (_, c) => c.toUpperCase()) - // ensure first char is uppercase .replace(/^./, (c) => c.toUpperCase()); const IconComponent = ( diff --git a/config/root.tsx b/config/root.tsx index 42f96b8..3cea246 100644 --- a/config/root.tsx +++ b/config/root.tsx @@ -1,14 +1,5 @@ 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 `` tags. - */ export type RootProps = { title?: string; description?: string; @@ -16,10 +7,7 @@ export type RootProps = { }; export const Root: RootConfig<{ props: RootProps }> = { - // Root props default to being shared across pages. These are per-page SEO - // 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. + // SEO fields belong to each page rather than the global block store. global: false, defaultProps: { title: 'Shop', @@ -31,7 +19,6 @@ export const Root: RootConfig<{ props: RootProps }> = { description: { label: 'Meta description', type: 'textarea' }, ogImage: { label: 'Social share image', type: 'image' }, }, - // No wrapper: the storefront's own layout and stylesheet own the chrome. render: ({ children }) => <>{children}, }; diff --git a/config/site.ts b/config/site.ts index 94507fd..ed692ff 100644 --- a/config/site.ts +++ b/config/site.ts @@ -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 = { storeName: 'Shop', description: '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: process.env.NEXT_PUBLIC_SITE_URL ?? (process.env.VERCEL_PROJECT_PRODUCTION_URL diff --git a/config/swatches.ts b/config/swatches.ts index 85925ab..bf01e43 100644 --- a/config/swatches.ts +++ b/config/swatches.ts @@ -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']; -// 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 = { clay: '#5a4b3c', green: '#81a69b', diff --git a/editor.config.tsx b/editor.config.tsx index 2c39433..a426cb8 100644 --- a/editor.config.tsx +++ b/editor.config.tsx @@ -19,13 +19,6 @@ const categories = { 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 = { root: Root, categories, diff --git a/graphql/cart.ts b/graphql/cart.ts index d95959d..87dec31 100644 --- a/graphql/cart.ts +++ b/graphql/cart.ts @@ -1,6 +1,5 @@ import { gql } from '@shopify/hydrogen'; -// Cart Fragment for consistent cart data const CartFragment = gql(` fragment CartFragment on Cart { id @@ -68,7 +67,6 @@ const CartFragment = gql(` } `); -// Create a new cart export const CREATE_CART_MUTATION = gql( ` mutation CreateCart($lines: [CartLineInput!], $country: CountryCode, $language: LanguageCode) @@ -87,7 +85,6 @@ export const CREATE_CART_MUTATION = gql( [CartFragment] ); -// Add lines to cart export const ADD_CART_LINES_MUTATION = gql( ` mutation AddCartLines($cartId: ID!, $lines: [CartLineInput!]!, $country: CountryCode, $language: LanguageCode) @@ -106,7 +103,6 @@ export const ADD_CART_LINES_MUTATION = gql( [CartFragment] ); -// Update cart lines export const UPDATE_CART_LINES_MUTATION = gql( ` mutation UpdateCartLines($cartId: ID!, $lines: [CartLineUpdateInput!]!, $country: CountryCode, $language: LanguageCode) @@ -125,7 +121,6 @@ export const UPDATE_CART_LINES_MUTATION = gql( [CartFragment] ); -// Remove lines from cart export const REMOVE_CART_LINES_MUTATION = gql( ` mutation RemoveCartLines($cartId: ID!, $lineIds: [ID!]!, $country: CountryCode, $language: LanguageCode) @@ -144,7 +139,6 @@ export const REMOVE_CART_LINES_MUTATION = gql( [CartFragment] ); -// Apply (or clear) discount codes on the cart export const UPDATE_CART_DISCOUNT_CODES_MUTATION = gql( ` mutation UpdateCartDiscountCodes($cartId: ID!, $discountCodes: [String!]!, $country: CountryCode, $language: LanguageCode) @@ -163,7 +157,6 @@ export const UPDATE_CART_DISCOUNT_CODES_MUTATION = gql( [CartFragment] ); -// Get cart by ID export const GET_CART_QUERY = gql( ` query GetCart($cartId: ID!, $country: CountryCode, $language: LanguageCode) diff --git a/graphql/collections.ts b/graphql/collections.ts index 6523afc..acb6db7 100644 --- a/graphql/collections.ts +++ b/graphql/collections.ts @@ -1,7 +1,6 @@ import { gql } from '@shopify/hydrogen'; import { ProductFragment } from '@/graphql/products'; -// Get all collections export const GET_COLLECTIONS_QUERY = gql(` query GetCollections($first: Int!, $country: CountryCode, $language: LanguageCode) @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( ` query GetCollectionProducts( diff --git a/graphql/policies.ts b/graphql/policies.ts index c2b710b..96c8959 100644 --- a/graphql/policies.ts +++ b/graphql/policies.ts @@ -1,7 +1,5 @@ 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(` query GetShopPolicies { shop { diff --git a/graphql/products.ts b/graphql/products.ts index 6f12e19..8e9c530 100644 --- a/graphql/products.ts +++ b/graphql/products.ts @@ -1,6 +1,5 @@ import { gql } from '@shopify/hydrogen'; -// Product Fragment for consistent product data export const ProductFragment = gql(` fragment ProductFragment on Product { id @@ -102,7 +101,6 @@ export const ProductFragment = gql(` } `); -// Get multiple products export const GET_PRODUCTS_QUERY = gql( ` 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] ); -// Get a single product by handle export const GET_PRODUCT_QUERY = gql( ` query GetProduct($handle: String!, $country: CountryCode, $language: LanguageCode) @@ -136,7 +133,6 @@ export const GET_PRODUCT_QUERY = gql( [ProductFragment] ); -// Get product recommendations export const QUERY_PRODUCT_RECOMMENDATIONS = gql( ` query GetProductRecommendations($productId: ID!, $country: CountryCode, $language: LanguageCode) diff --git a/graphql/search.ts b/graphql/search.ts index f247b2d..30ba4ab 100644 --- a/graphql/search.ts +++ b/graphql/search.ts @@ -1,9 +1,6 @@ import { gql } from '@shopify/hydrogen'; 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( ` query SearchProducts( @@ -57,8 +54,6 @@ export const SEARCH_PRODUCTS_QUERY = gql( [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(` query SearchSuggestions($query: String!, $first: Int!, $country: CountryCode, $language: LanguageCode) @inContext(country: $country, language: $language) { diff --git a/hooks/use-shopify-cart.ts b/hooks/use-shopify-cart.ts index e3f3d10..193319d 100644 --- a/hooks/use-shopify-cart.ts +++ b/hooks/use-shopify-cart.ts @@ -12,8 +12,6 @@ import { } from '@/graphql/cart'; import { useEffect } from 'react'; -// ─── Types ─────────────────────────────────────────────────────────── - export interface CartLineInput { merchandiseId: string; quantity: number; @@ -91,13 +89,7 @@ export interface Cart { }; } -// ─── Shopify API functions ─────────────────────────────────────────── - -/** - * 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. - */ +/** Normalizes nullable Shopify cart mutation payloads. */ function unwrapCartPayload( payload: | { @@ -200,12 +192,9 @@ export function redirectToCheckout(checkoutUrl: string): void { } } -// ─── Zustand Store ─────────────────────────────────────────────────── - const CART_ID_KEY = 'cartId'; interface CartState { - // State isOpen: boolean; cartId: string | null; cart: Cart | null; @@ -213,10 +202,6 @@ interface CartState { error: string | null; _initialized: boolean; - // Computed (derived in the hook) - // items, itemCount, totalAmount, checkoutUrl - - // Actions openCart: () => void; closeCart: () => void; toggleCart: () => void; @@ -229,7 +214,6 @@ interface CartState { } export const useCartStore = create((set, get) => ({ - // Initial state isOpen: false, cartId: null, cart: null, @@ -237,12 +221,10 @@ export const useCartStore = create((set, get) => ({ error: null, _initialized: false, - // UI actions openCart: () => set({ isOpen: true }), closeCart: () => set({ isOpen: false }), toggleCart: () => set((s) => ({ isOpen: !s.isOpen })), - // Initialize cart from localStorage initCart: () => { if (get()._initialized) return; set({ _initialized: true }); @@ -276,12 +258,10 @@ export const useCartStore = create((set, get) => ({ }); }, - // Add item to cart (creates cart if needed) addItem: async (variantId: string, quantity: number = 1) => { try { set({ loading: true, error: null }); - // Get or create cart let currentCartId = get().cartId; if (!currentCartId) { const storedCartId = localStorage.getItem(CART_ID_KEY); @@ -309,7 +289,6 @@ export const useCartStore = create((set, get) => ({ } }, - // Remove item from cart removeItem: async (lineId: string) => { const { cartId } = get(); if (!cartId) throw new Error('No cart exists'); @@ -327,7 +306,6 @@ export const useCartStore = create((set, get) => ({ } }, - // Update item quantity updateItemQuantity: async (lineId: string, quantity: number) => { const { cartId, removeItem } = get(); if (!cartId) throw new Error('No cart exists'); @@ -351,8 +329,7 @@ export const useCartStore = create((set, get) => ({ } }, - // Apply a discount code. Shopify accepts unknown codes and reports them back - // as `applicable: false`, so callers should check the returned cart. + // Invalid discount codes return `applicable: false` instead of an error. applyDiscountCode: async (code: string) => { const { cartId } = get(); if (!cartId) throw new Error('No cart exists'); @@ -374,7 +351,6 @@ export const useCartStore = create((set, get) => ({ } }, - // Refresh cart from Shopify refreshCart: async () => { const storedCartId = localStorage.getItem(CART_ID_KEY); if (!storedCartId) { @@ -403,12 +379,9 @@ export const useCartStore = create((set, get) => ({ }, })); -// ─── Hook (backwards-compatible API) ───────────────────────────────── - export function useShopifyCart() { const store = useCartStore(); - // Initialize cart safely in an effect, not during render useEffect(() => { if (!store._initialized) { store.initCart(); diff --git a/hooks/use-shopify-collections.ts b/hooks/use-shopify-collections.ts index 756225e..a5acb73 100644 --- a/hooks/use-shopify-collections.ts +++ b/hooks/use-shopify-collections.ts @@ -4,26 +4,26 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { getCollections, getCollectionProducts, -} from '@/services/shopify/catalog'; +} from '@/services/shopify/shop'; export { getCollections, getCollectionProducts, getCollectionProductsPage, -} from '@/services/shopify/catalog'; +} from '@/services/shopify/shop'; export type { Collection, CollectionWithProducts, CollectionSortKey, CollectionProductsPage, ProductFilterFacet, -} from '@/services/shopify/catalog'; +} from '@/services/shopify/shop'; import type { Collection, CollectionWithProducts, CollectionSortKey, -} from '@/services/shopify/catalog'; +} from '@/services/shopify/shop'; interface UseCollectionProductsOptions { first?: number; @@ -33,7 +33,6 @@ interface UseCollectionProductsOptions { filterInputs?: string[]; } -// Hook for fetching all collections export function useCollections(first = 50) { const [collections, setCollections] = useState([]); const [loading, setLoading] = useState(true); @@ -60,9 +59,7 @@ export function useCollections(first = 50) { return { collections, loading, error, refetch: fetchCollections }; } -// Deferred variant of useCollections: nothing is requested until `load` runs, -// so a menu can hold off until it's actually opened. The fetch happens once — -// re-opening reuses what's already in state. +// Fetch once on demand for menus that may never open. export function useCollectionsOnDemand(first = 50) { const [collections, setCollections] = useState([]); const [loading, setLoading] = useState(false); @@ -79,7 +76,6 @@ export function useCollectionsOnDemand(first = 50) { setCollections(await getCollections(first)); } catch (err) { console.error('Error fetching collections:', err); - // Let the next open (or a retry) try again. requested.current = false; setError(err instanceof Error ? err.message : 'Failed to load collections'); } finally { @@ -90,7 +86,6 @@ export function useCollectionsOnDemand(first = 50) { return { collections, loading, error, load }; } -// Hook for fetching products in a collection export function useCollectionProducts( handle: string | null, options: UseCollectionProductsOptions = {} diff --git a/hooks/use-shopify-policies.ts b/hooks/use-shopify-policies.ts index c914a48..ea5c0a4 100644 --- a/hooks/use-shopify-policies.ts +++ b/hooks/use-shopify-policies.ts @@ -12,7 +12,6 @@ export interface ShopPolicy { url: string; } -// Handles Shopify uses for each policy — also the routes under /policies/[handle]. export const POLICY_HANDLES = [ 'terms-of-service', 'privacy-policy', @@ -21,8 +20,6 @@ export const POLICY_HANDLES = [ 'subscription-policy', ] 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 { try { const data = unwrapStorefrontResult( @@ -34,7 +31,6 @@ export async function getShopPolicies(): Promise { (policy): policy is ShopPolicy => Boolean(policy?.handle) ); } catch (err) { - // A storefront without policies configured shouldn't break the footer. console.error('Failed to load shop policies:', err); return []; } diff --git a/hooks/use-shopify-products.ts b/hooks/use-shopify-products.ts index 914e647..11fa402 100644 --- a/hooks/use-shopify-products.ts +++ b/hooks/use-shopify-products.ts @@ -5,24 +5,22 @@ import { getProducts, getProduct, 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 { getProducts, getProductsPage, getProduct, getProductRecommendations, -} from '@/services/shopify/catalog'; +} from '@/services/shopify/shop'; export type { Product, ProductOption, ProductOptionValue, 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 { first?: number; @@ -39,7 +37,6 @@ interface UseProductsReturn { refetch: () => Promise; } -// Hook for fetching multiple products export function useProducts(options: UseProductsOptions = {}): UseProductsReturn { const [products, setProducts] = useState([]); const [loading, setLoading] = useState(true); @@ -66,7 +63,6 @@ export function useProducts(options: UseProductsOptions = {}): UseProductsReturn return { products, loading, error, refetch: fetchProducts }; } -// Hook for fetching a single product export function useProduct(handle: string | null) { const [product, setProduct] = useState(null); const [loading, setLoading] = useState(true); @@ -101,7 +97,6 @@ export function useProduct(handle: string | null) { return { product, loading, error, refetch: fetchProduct }; } -// Hook for fetching product recommendations export function useProductRecommendations(productId: string | null) { const [recommendations, setRecommendations] = useState([]); const [loading, setLoading] = useState(true); diff --git a/hooks/use-shopify-search.ts b/hooks/use-shopify-search.ts index f3caa85..2527188 100644 --- a/hooks/use-shopify-search.ts +++ b/hooks/use-shopify-search.ts @@ -1,10 +1,8 @@ -// Re-exported from the server-safe catalogue module so both client components -// and Route Handlers can search the storefront. -export { searchProducts, searchSuggestions } from '@/services/shopify/catalog'; +export { searchProducts, searchSuggestions } from '@/services/shopify/shop'; export type { SearchSortKey, SearchFilter, SearchFilterValue, SearchProductsResult, SearchSuggestion, -} from '@/services/shopify/catalog'; +} from '@/services/shopify/shop'; diff --git a/lib/cloud.ts b/lib/cloud.ts index cb3b1a0..59214c1 100644 --- a/lib/cloud.ts +++ b/lib/cloud.ts @@ -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"; diff --git a/lib/page-metadata.ts b/lib/page-metadata.ts index 9765bcf..eb49c8a 100644 --- a/lib/page-metadata.ts +++ b/lib/page-metadata.ts @@ -1,7 +1,6 @@ import type { Metadata } from 'next'; import { site } from '@/config/site'; -/** The subset of root props (see config/root.tsx) that describes a page. */ interface EditorPage { root?: { props?: { @@ -16,25 +15,16 @@ interface MetadataOverrides { title?: string; description?: string; image?: string; - /** Root-relative path, used for the canonical and og:url. */ 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( page: EditorPage, overrides: MetadataOverrides = {} ): Metadata { const props = page.root?.props ?? {}; - // Editor-authored titles already carry the brand ("About — Shop"), so they - // opt out of the root template with `absolute`. A fetched override is a bare - // product or collection name and flows through the template instead. + // Editor titles already include the brand; fetched titles use the template. const title = overrides.title ?? props.title ?? site.storeName; const socialTitle = overrides.title ? `${title} — ${site.storeName}` : title; const description = diff --git a/lib/pages.ts b/lib/pages.ts deleted file mode 100644 index 3017a5e..0000000 --- a/lib/pages.ts +++ /dev/null @@ -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 { - const base = - routeKey === '/' - ? '' - : routeKey.replace( - /\[(\w+)\]/g, - (segment, name: string) => params[name] ?? segment - ); - - return `${base}/editor`; -} diff --git a/lib/publish-page.ts b/lib/publish-page.ts index b00c93a..a003f46 100644 --- a/lib/publish-page.ts +++ b/lib/publish-page.ts @@ -2,50 +2,35 @@ import { writeFile } from 'node:fs/promises'; 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'); export interface PublishResult { - /** Path of the written file, relative to the project root. */ file?: string; error?: string; } export async function publishPage( - routeKey: string, + pagePath: string, page: unknown ): Promise { - const route = findPageRoute(routeKey); - if (!route) return { error: `Unknown route: ${routeKey}` }; - if (!page || typeof page !== 'object') { return { error: 'Expected a page object.' }; } 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 (file !== path.join(appDir, 'page.json') && !file.startsWith(appDir + path.sep)) { - return { error: `Unknown route: ${routeKey}` }; + if ( + !relativeToApp || + 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; try { @@ -61,8 +46,6 @@ export async function publishPage( return { file: path.relative(process.cwd(), file) }; } catch (err) { - // Read-only filesystems (most serverless hosts) land here. Say so plainly - // rather than reporting a save that did not happen. console.error(`Failed to write ${file}:`, err); return { error: diff --git a/next-env.d.ts b/next-env.d.ts deleted file mode 100644 index c4b7818..0000000 --- a/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -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. diff --git a/next.config.js b/next.config.js index f5579a1..b0a239b 100644 --- a/next.config.js +++ b/next.config.js @@ -3,8 +3,7 @@ import { fileURLToPath } from 'node:url'; export default { reactStrictMode: true, - // Sibling templates each carry a lockfile, so pin the tracing root here - // rather than letting Next infer one from a parent directory. + // Prevent sibling lockfiles from changing Next's inferred tracing root. outputFileTracingRoot: dirname(fileURLToPath(import.meta.url)), typescript: { ignoreBuildErrors: true, @@ -15,7 +14,6 @@ export default { protocol: 'https', hostname: 'images.unsplash.com', }, - // Covers cdn.shopify.com plus any other Shopify-hosted image subdomain. { protocol: 'https', hostname: '**.shopify.com', diff --git a/package.json b/package.json index a657255..89190ad 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@openrouter/ai-sdk-provider": "^3.0.0", "@radix-ui/react-slot": "^1.3.3", "@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-tailwind-cdn": "0.0.3-alpha.2", "@remixicon/react": "^4.9.0", diff --git a/services/shopify/client.ts b/services/shopify/client.ts index 4b3b896..5c9b97b 100644 --- a/services/shopify/client.ts +++ b/services/shopify/client.ts @@ -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 { createShopifyRequestContext, createStorefrontClient, @@ -16,31 +9,15 @@ import { SHOPIFY_STORE_DOMAIN, } from '@/services/shopify/config'; -// No incoming request and no buyer context: these clients are module-scoped and -// 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. +// Shared clients must not capture per-request buyer state. const requestContext = createShopifyRequestContext({ request: { headers: new Headers() }, i18n: { country: 'US', language: 'EN' }, }); -/** - * 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). - */ +// Real stores reject this Hydrogen header during browser CORS preflight. 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 => ((url, init) => { const headers = new Headers(init?.headers); @@ -58,34 +35,21 @@ const config = { publicStorefrontToken: SHOPIFY_PUBLIC_ACCESS_TOKEN, }; -/** - * Default client. Uncached, because carts and customer reads must never serve a - * stale response. - */ +/** Uncached client for carts and customer data. */ export const storefront = createStorefrontClient({ type: 'public', requestContext, config: { ...config, fetch: fetchWith({ cache: 'no-store' }) }, }); -/** - * Client for data that changes rarely (shop policies, and anything else safe to - * serve from Next's data cache for an hour). - */ +/** One-hour cache for stable storefront data. */ export const cachedStorefront = createStorefrontClient({ type: 'public', requestContext, config: { ...config, fetch: fetchWith({ next: { revalidate: 3600 } }) }, }); -/** - * 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". - */ +/** Returns GraphQL data or throws the first Shopify error. */ export function unwrapStorefrontResult( result: { data: TData | null; errors?: GraphQLFormattedError[] }, operation: string diff --git a/services/shopify/config.ts b/services/shopify/config.ts index 2cbb729..cf38e94 100644 --- a/services/shopify/config.ts +++ b/services/shopify/config.ts @@ -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; -/** - * 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. - */ +// Never assign a private token to this public environment variable. export const SHOPIFY_PUBLIC_ACCESS_TOKEN = process.env.NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN; diff --git a/services/shopify/catalog.ts b/services/shopify/shop.ts similarity index 84% rename from services/shopify/catalog.ts rename to services/shopify/shop.ts index 18e0c41..2a47e8d 100644 --- a/services/shopify/catalog.ts +++ b/services/shopify/shop.ts @@ -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 { storefront, unwrapStorefrontResult } from '@/services/shopify/client'; import { @@ -19,8 +14,6 @@ import { SEARCH_SUGGESTIONS_QUERY, } 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 { url: string; altText?: string | null; @@ -67,10 +60,7 @@ export interface ProductOption { optionValues?: ProductOptionValue[]; } -/** - * 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. - */ +/** Detects Shopify's synthetic option for single-variant products. */ export const isDefaultTitleOption = (option: { name: string; values: string[]; @@ -79,7 +69,6 @@ export const isDefaultTitleOption = (option: { option.values.length === 1 && option.values[0] === 'Default Title'; -/** Same synthetic option, as it appears on a variant's `selectedOptions`. */ export const isDefaultTitleSelection = (selection: { name: string; value: string; @@ -113,7 +102,6 @@ export interface Product { interface UseProductsOptions { first?: number; - /** Cursor from a previous page's `endCursor`; omit for the first page. */ after?: string | null; query?: string; sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE'; @@ -126,14 +114,6 @@ export interface ProductsPage { endCursor: string | null; } -interface UseProductsReturn { - products: Product[]; - loading: boolean; - error: string | null; - refetch: () => Promise; -} - -// Fetch multiple products export async function getProducts( options: UseProductsOptions = {} ): Promise { @@ -141,7 +121,6 @@ export async function getProducts( return products; } -// Same fetch, but keeps the cursor so callers can page through the catalogue. export async function getProductsPage({ first = 20, after = null, @@ -165,7 +144,6 @@ export async function getProductsPage({ }; } -// Fetch a single product by handle export async function getProduct(handle: string): Promise { const data = unwrapStorefrontResult( await storefront.graphql(GET_PRODUCT_QUERY, { variables: { handle } }), @@ -175,7 +153,6 @@ export async function getProduct(handle: string): Promise { return data.product; } -// Fetch product recommendations export async function getProductRecommendations(productId: string): Promise { const data = unwrapStorefrontResult( await storefront.graphql(QUERY_PRODUCT_RECOMMENDATIONS, { @@ -218,7 +195,6 @@ interface UseCollectionProductsOptions { after?: string | null; sortKey?: CollectionSortKey; reverse?: boolean; - /** Raw `input` strings from the connection's `filters` facets. */ filterInputs?: string[]; } @@ -242,7 +218,6 @@ export interface ProductFilterFacet { }>; } -// Fetch all collections export async function getCollections(first = 50): Promise { const data = unwrapStorefrontResult( await storefront.graphql(GET_COLLECTIONS_QUERY, { variables: { first } }), @@ -252,7 +227,6 @@ export async function getCollections(first = 50): Promise { return data.collections.edges.map((edge) => edge.node); } -// Fetch products in a collection by handle export async function getCollectionProducts( handle: string, options: UseCollectionProductsOptions = {} @@ -263,7 +237,6 @@ export async function getCollectionProducts( return { ...page.collection, products: page.products }; } -// Same fetch, but keeps the cursor and facet list for filtering and paging. export async function getCollectionProductsPage( handle: string, { @@ -319,7 +292,6 @@ export interface SearchFilterValue { id: string; label: string; count: number; - /** JSON string accepted back as a `ProductFilter` input. */ input: string; } @@ -360,18 +332,14 @@ interface SearchProductsOptions { after?: string | null; sortKey?: SearchSortKey; reverse?: boolean; - /** Raw `input` strings from the facets, parsed back into filter objects. */ 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< StorefrontApi.VariablesOf['filters'] >[number]; -// Facet `input` values are opaque JSON strings produced by Shopify and handed -// straight back as filter inputs, so they are parsed, not constructed. +// Shopify facet inputs are opaque JSON values intended for round-tripping. function parseFilterInputs(inputs: string[]): ProductFilterInput[] { return inputs.flatMap((input) => { try { diff --git a/yarn.lock b/yarn.lock index 94fa6b1..71ab915 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2316,9 +2316,9 @@ __metadata: languageName: node linkType: hard -"@reacteditor/core@npm:0.0.41-alpha.3": - version: 0.0.41-alpha.3 - resolution: "@reacteditor/core@npm:0.0.41-alpha.3" +"@reacteditor/core@npm:0.0.41-alpha.4": + version: 0.0.41-alpha.4 + resolution: "@reacteditor/core@npm:0.0.41-alpha.4" dependencies: "@chakra-ui/react": "npm:^3.35.0" "@dnd-kit/abstract": "npm:0.4.0" @@ -2361,7 +2361,7 @@ __metadata: zustand: "npm:^5.0.13" peerDependencies: react: ^18.0.0 || ^19.0.0 - checksum: 10c0/0d9ab9e02b7f639603c94b9c6e9068e60d7662eb2744896f20481a1a0bc93727204a79857fffdbdd1ae37b1e76d470a238d6528ebe25de7c08a25e23c0923240 + checksum: 10c0/df326a5e43e7398e64a835d47f4a4daf6aea2715bc37dd61c6435e155178310ea342bc2eba4d781baa43f6e3520dc1fae8edd55f094d26052444f0737f093204 languageName: node linkType: hard @@ -2377,13 +2377,13 @@ __metadata: languageName: node linkType: hard -"@reacteditor/plugin-tailwind-cdn@npm:0.0.3-alpha.1": - version: 0.0.3-alpha.1 - resolution: "@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.2 + resolution: "@reacteditor/plugin-tailwind-cdn@npm:0.0.3-alpha.2" peerDependencies: - "@reacteditor/core": ">=0.0.28" + "@reacteditor/core": ">=0.0.41-alpha.3" react: ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/94215939cdc994e9323bf34123df4319a96f04d6422d6d10efcf5d56bb03f38eba3db637a35566c954fd4fc7ad344dc01926559dcb8060966a2a2c7a225f0034 + checksum: 10c0/b49110df1640efa132bc1a48c0d38fe41f0fa775472b323dd31bb6c67cf926e3ee3bee495348ce344e9339474048846fda09332504b5011b01e16d6107f9c033 languageName: node linkType: hard @@ -5823,9 +5823,9 @@ __metadata: "@openrouter/ai-sdk-provider": "npm:^3.0.0" "@radix-ui/react-slot": "npm:^1.3.3" "@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-tailwind-cdn": "npm:0.0.3-alpha.1" + "@reacteditor/plugin-tailwind-cdn": "npm:0.0.3-alpha.2" "@remixicon/react": "npm:^4.9.0" "@shopify/hydrogen": "npm:0.0.0-preview-116d5d7-20260730141607" "@shopify/storefront-api-client": "npm:^1.0.0"