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 */}