-
-
- );
-}
diff --git a/components/page-editor.tsx b/components/page-editor.tsx
new file mode 100644
index 0000000..d0c84e9
--- /dev/null
+++ b/components/page-editor.tsx
@@ -0,0 +1,164 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useParams, usePathname, useRouter } from 'next/navigation';
+import { Editor, outlinePlugin, type Data } from '@reacteditor/core';
+import { Loader } from '@/components/ui/loader';
+import createTailwindCdnPlugin from '@reacteditor/plugin-tailwind-cdn';
+import { createShopifyPlugin } from '@reacteditor/plugin-shopify';
+import { appConfig } from '@/editor.config';
+import { ROUTE_KEYS, editorHref, findPageRoute } from '@/lib/pages';
+
+// Plugin instances must keep a stable identity across renders, same as
+// `appConfig`, so they are built once at module scope.
+//
+// The Tailwind CDN plugin only styles the editor's preview iframe; the public
+// routes still get their utilities from the compiled `app/globals.css`.
+const tailwindCdn = createTailwindCdnPlugin();
+
+// Registers the `shopifyProduct` and `shopifyCollection` field types used by
+// the commerce blocks. Credentials are the same public storefront pair the
+// rendered components read — safe in the browser by definition.
+const shopify = createShopifyPlugin({
+ storeDomain: process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? 'mock.shop',
+ publicAccessToken:
+ process.env.NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN ?? undefined,
+ apiVersion: process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION ?? '2026-07',
+});
+
+// Adds the outline panel — the tree of blocks on the page, for selecting and
+// reordering without hunting through the preview.
+const outline = outlinePlugin();
+
+const plugins = [outline, tailwindCdn, shopify];
+
+const EMPTY_PAGE: Data = { root: { props: { title: 'Untitled' } }, content: [] };
+
+export interface PageEditorProps {
+ /** Route key from `lib/pages.ts`, e.g. `/products/[handle]`. */
+ routeKey: string;
+}
+
+/**
+ * Shared editor shell, mounted by each route's `editor/page.tsx` child. The
+ * public routes mount `PageRender` against the same `appConfig`, so the two
+ * never drift.
+ *
+ * Being a child of the route it edits is what makes the preview real: the
+ * editor sits on the same dynamic segments as the public page, so a block
+ * calling `useParams()` inside the preview iframe sees the actual handle from
+ * `/products/warrior-club-hoodie/editor` — no stand-in data required.
+ *
+ * Data is read from and published back to the route's own `page.json` through
+ * `/api/pages`, which writes the file on disk.
+ */
+export default function PageEditor({ routeKey }: PageEditorProps) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const params = useParams();
+
+ const [data, setData] = useState(null);
+ const [status, setStatus] = useState(null);
+
+ // 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]);
+
+ useEffect(() => {
+ let cancelled = false;
+ setData(null);
+
+ fetch(`/api/pages?route=${encodeURIComponent(routeKey)}`)
+ .then((response) => response.json())
+ .then((body) => {
+ if (!cancelled) setData(body.page ?? EMPTY_PAGE);
+ })
+ .catch(() => {
+ if (!cancelled) setData(EMPTY_PAGE);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [routeKey]);
+
+ const handlePublish = useCallback(
+ async (published: Data) => {
+ setStatus('Saving…');
+
+ try {
+ const response = await fetch('/api/pages', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ route: routeKey, page: published }),
+ });
+ const body = await response.json().catch(() => ({}));
+
+ if (!response.ok) {
+ setStatus(body.error ?? 'Could not save this page.');
+ return;
+ }
+
+ setStatus(`Saved to ${body.file}`);
+ } catch {
+ setStatus('Could not reach the server.');
+ }
+ },
+ [routeKey]
+ );
+
+ // Wait for the fetch: handing a placeholder and then swapping it
+ // would seed the undo history with a page the author never wrote.
+ //
+ // Uses the local `components/ui/loader`, not core's export of the same name:
+ // core's is built on Chakra's Spinner and reads a context that only exists
+ // inside ``, so it throws when used for a pre-Editor wait state.
+ // This one is provider-free SVG and renders anywhere.
+ if (!data) {
+ return (
+
+
+
+ );
+ }
+
+ 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 7e86f35..bd91813 100644
--- a/components/page-render.tsx
+++ b/components/page-render.tsx
@@ -1,20 +1,28 @@
-"use client";
+'use client';
-import { Render } from "@reacteditor/core/render";
-import { appConfig } from "@/editor.config";
-import globals from "@/app.globals.json";
+import { Render } from '@reacteditor/core/render';
+import { appConfig } from '@/editor.config';
+import globals from '@/app.globals.json';
export type PageData = {
root?: unknown;
content?: unknown;
+ globals?: unknown;
};
/**
- * Shared renderer for a route. Drop a `page.json` next to a route's
- * `page.tsx`, import it, and hand it here:
+ * Shared renderer for a route. Drop a `page.json` next to a route's `page.tsx`,
+ * import it, and hand it here:
*
* import page from "./page.json";
* export default () => ;
+ *
+ * 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 };
diff --git a/components/shopify/account-form.tsx b/components/shopify/account-form.tsx
new file mode 100644
index 0000000..871ad74
--- /dev/null
+++ b/components/shopify/account-form.tsx
@@ -0,0 +1,142 @@
+'use client';
+
+import React, { useState } from 'react';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { Button } from '@/components/ui/button';
+import { Loader } from '@/components/ui/loader';
+
+export interface AccountFormField {
+ name: string;
+ label: string;
+ type?: 'text' | 'email' | 'password';
+ autoComplete?: string;
+ required?: boolean;
+}
+
+interface AccountFormProps {
+ title: string;
+ description?: string;
+ fields: AccountFormField[];
+ submitLabel: string;
+ endpoint: string;
+ /** Merged into the request body alongside the field values. */
+ extraPayload?: Record;
+ /** Where to go on success; omit to show `successMessage` instead. */
+ redirectTo?: string;
+ successMessage?: string;
+ footer?: React.ReactNode;
+}
+
+const AccountForm: React.FC = ({
+ title,
+ description,
+ fields,
+ submitLabel,
+ endpoint,
+ extraPayload,
+ redirectTo,
+ successMessage,
+ footer,
+}) => {
+ const router = useRouter();
+ const [values, setValues] = useState>({});
+ const [error, setError] = useState(null);
+ const [done, setDone] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ if (submitting) return;
+
+ try {
+ setSubmitting(true);
+ setError(null);
+
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...values, ...extraPayload }),
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ setError(data.error ?? 'Something went wrong. Please try again.');
+ return;
+ }
+
+ if (redirectTo) {
+ // refresh() so server components re-read the new session cookie.
+ router.push(redirectTo);
+ router.refresh();
+ } else {
+ setDone(true);
+ }
+ } catch {
+ setError('Could not reach the server. Please try again.');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+ {done && successMessage ? (
+
{successMessage}
+ ) : (
+
+ )}
+
+ {footer && (
+
+ {footer}
+
+ )}
+
+ );
+};
+
+export const AccountFormLink: React.FC<{ href: string; children: React.ReactNode }> = ({
+ href,
+ children,
+}) => (
+
+ {children}
+
+);
+
+export default AccountForm;
diff --git a/components/shopify/account-menu.tsx b/components/shopify/account-menu.tsx
new file mode 100644
index 0000000..7697cbe
--- /dev/null
+++ b/components/shopify/account-menu.tsx
@@ -0,0 +1,112 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { Button } from '@/components/ui/button';
+import { RiUserLine } from '@remixicon/react';
+
+interface SessionCustomer {
+ displayName: string;
+ email: string;
+ firstName?: string | null;
+}
+
+const AccountMenu: React.FC = () => {
+ const router = useRouter();
+ const [customer, setCustomer] = useState(null);
+ const [open, setOpen] = useState(false);
+
+ // The token lives in an httpOnly cookie, so the signed-in state has to come
+ // from the server rather than being read directly.
+ useEffect(() => {
+ let cancelled = false;
+
+ fetch('/api/account/me')
+ .then((response) => response.json())
+ .then((data) => {
+ if (!cancelled) setCustomer(data.customer ?? null);
+ })
+ .catch(() => {
+ if (!cancelled) setCustomer(null);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const handleSignOut = async () => {
+ setOpen(false);
+ await fetch('/api/account/logout', { method: 'POST' });
+ setCustomer(null);
+ router.push('/');
+ router.refresh();
+ };
+
+ // Signed out: straight to the sign-in page, no menu.
+ if (!customer) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {open && (
+ <>
+
setOpen(false)} />
+
+
+
+ {customer.firstName || customer.displayName}
+
+
+ {customer.email}
+
+
+
+
+
+ setOpen(false)}
+ className="block px-4 py-2 text-sm text-foreground hover:bg-accent"
+ >
+ Order history
+
+
+
+ >
+ )}
+
+ );
+};
+
+export default AccountMenu;
diff --git a/components/shopify/account-orders.editor.tsx b/components/shopify/account-orders.editor.tsx
new file mode 100644
index 0000000..8191cdc
--- /dev/null
+++ b/components/shopify/account-orders.editor.tsx
@@ -0,0 +1,34 @@
+import { ComponentConfig } from '@reacteditor/core';
+import { Receipt } from 'lucide-react';
+import AccountOrders, {
+ type AccountOrdersProps,
+} from '@/components/shopify/account-orders';
+
+const accountOrdersEditor: ComponentConfig = {
+ label: 'Order history',
+ icon: ,
+ category: 'account',
+ defaultProps: {
+ title: 'Order history',
+ signedOutMessage: 'Sign in to see your orders.',
+ emptyMessage: "You haven't placed any orders yet.",
+ limit: 20,
+ },
+ fields: {
+ title: { label: 'Title', type: 'text', contentEditable: true },
+ signedOutMessage: {
+ label: 'Signed-out message',
+ type: 'text',
+ contentEditable: true,
+ },
+ emptyMessage: {
+ label: 'Empty message',
+ type: 'text',
+ contentEditable: true,
+ },
+ limit: { label: 'Orders shown', type: 'number', min: 1, max: 50 },
+ },
+ render: (props) => ,
+};
+
+export default accountOrdersEditor;
diff --git a/components/shopify/account-orders.tsx b/components/shopify/account-orders.tsx
new file mode 100644
index 0000000..273021e
--- /dev/null
+++ b/components/shopify/account-orders.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import OrderHistory from '@/components/shopify/order-history';
+import type { Customer } from '@/services/shopify/customer';
+
+export interface AccountOrdersProps {
+ title?: string;
+ /** Shown while signed out — the route guard normally redirects first. */
+ signedOutMessage?: string;
+ emptyMessage?: string;
+ limit?: number;
+}
+
+/**
+ * Client-side wrapper so order history can live in a page.json alongside the
+ * other blocks. `/account/page.tsx` still guards the route server-side; this
+ * re-reads the session through `/api/account/orders` so the block works the
+ * same whether it is rendered by the page or previewed in the editor.
+ */
+const AccountOrders: React.FC = ({
+ title = 'Order history',
+ signedOutMessage = 'Sign in to see your orders.',
+ emptyMessage = "You haven't placed any orders yet.",
+ limit = 20,
+}) => {
+ const [customer, setCustomer] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ fetch(`/api/account/orders?orders=${limit}`)
+ .then((response) => response.json())
+ .then((data) => {
+ if (!cancelled) setCustomer(data.customer ?? null);
+ })
+ .catch(() => {
+ if (!cancelled) setCustomer(null);
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [limit]);
+
+ const orderCount = customer?.orders?.edges.length ?? 0;
+
+ return (
+
+
);
};
-export default CollectionCard;
\ No newline at end of file
+export default CollectionCard;
diff --git a/components/shopify/collection-detail.editor.tsx b/components/shopify/collection-detail.editor.tsx
new file mode 100644
index 0000000..efb08a2
--- /dev/null
+++ b/components/shopify/collection-detail.editor.tsx
@@ -0,0 +1,41 @@
+import { ComponentConfig } from '@reacteditor/core';
+import type { ShopifyCollection } from '@reacteditor/plugin-shopify';
+import { Boxes } from 'lucide-react';
+import CollectionDetail from '@/components/shopify/collection-detail';
+
+export type CollectionDetailBlockProps = {
+ collection?: ShopifyCollection | null;
+ title?: string;
+};
+
+/**
+ * Used two ways: dropped on `/collections/[handle]` it follows the route, so
+ * `collection` stays empty and the picker only drives the editor preview;
+ * dropped on any other page it pins to whichever collection is picked.
+ */
+const collectionDetailEditor: ComponentConfig = {
+ label: 'Collection page',
+ icon: ,
+ category: 'commerce',
+ defaultProps: {
+ collection: null,
+ title: '',
+ },
+ fields: {
+ collection: {
+ label: 'Collection',
+ type: 'shopifyCollection',
+ } as any,
+ title: {
+ label: 'Title override',
+ type: 'text',
+ placeholder: "Leave empty to use the collection's own title",
+ contentEditable: true,
+ },
+ },
+ render: ({ collection, title }) => (
+
+ ),
+};
+
+export default collectionDetailEditor;
diff --git a/components/shopify/collection-detail.tsx b/components/shopify/collection-detail.tsx
index 65fce92..c7d2f2b 100644
--- a/components/shopify/collection-detail.tsx
+++ b/components/shopify/collection-detail.tsx
@@ -1,94 +1,229 @@
'use client';
-import React from 'react';
-import { useCollectionProducts } from '@/hooks/use-shopify-collections';
+import React, { useEffect, useMemo, useState } from 'react';
+import { useParams } from 'next/navigation';
import ProductCard from './product-card';
-import { Skeleton } from '@/components/ui/skeleton';
+import ProductFilters, { type ProductFilterFacet } from './product-filters';
+import ProductToolbar from './product-toolbar';
+import { Button } from '@/components/ui/button';
+import { Loader } from '@/components/ui/loader';
+import {
+ getCollectionProductsPage,
+ type CollectionSortKey,
+} from '@/hooks/use-shopify-collections';
+import type { Product } from '@/hooks/use-shopify-products';
-const CollectionDetail: React.FC<{ handle?: string }> = ({ handle: handleProp }) => {
- const handle = handleProp ?? '';
+const PAGE_SIZE = 24;
- const { collection, loading, error, refetch } = useCollectionProducts(handle);
+const GRID_CLASSES =
+ 'grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12';
- // Format title from handle
+interface SortOption {
+ label: string;
+ sortKey: CollectionSortKey;
+ reverse: boolean;
+}
+
+const SORT_OPTIONS: SortOption[] = [
+ { label: 'Featured', sortKey: 'COLLECTION_DEFAULT', reverse: false },
+ { label: 'Best Selling', sortKey: 'BEST_SELLING', reverse: false },
+ { label: 'Price: Low to High', sortKey: 'PRICE', reverse: false },
+ { label: 'Price: High to Low', sortKey: 'PRICE', reverse: true },
+ { label: 'Newest', sortKey: 'CREATED', reverse: true },
+];
+
+interface CollectionDetailProps {
+ /**
+ * Pins the block to one collection. Left empty, it reads the `[handle]`
+ * segment instead, which is what the `/collections/[handle]` template does.
+ */
+ handle?: string;
+ /** Overrides the collection's own title. Empty falls back to Shopify's. */
+ title?: string;
+}
+
+const CollectionDetail: React.FC = ({
+ handle: handleProp,
+ title: titleProp,
+}) => {
+ const params = useParams();
+ const handle = handleProp || (params?.handle as string);
+
+ const [products, setProducts] = useState([]);
+ const [filters, setFilters] = useState([]);
+ const [title, setTitle] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [error, setError] = useState(null);
+ const [cursor, setCursor] = useState(null);
+ const [hasNextPage, setHasNextPage] = useState(false);
+
+ const [sortIndex, setSortIndex] = useState(0);
+ const [filtersOpen, setFiltersOpen] = useState(false);
+ const [activeFilters, setActiveFilters] = useState([]);
+
+ const sort = SORT_OPTIONS[sortIndex];
+ const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
+
+ // Fall back to the handle until the collection's real title arrives.
const formattedTitle = handle
- ? handle.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
+ ? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
: 'Collection';
- if (loading || !handle) {
- return (
-
-
-
-
-
+ useEffect(() => {
+ // Reached when the editor was opened on the literal `[handle]` pattern
+ // rather than a real collection URL. Drop the skeleton and say so rather
+ // than spinning forever.
+ if (!handle || handle === '[handle]') {
+ setLoading(false);
+ setError(
+ 'Open a collection page and add /editor to preview it, or pick a collection above.'
+ );
+ return;
+ }
+ let cancelled = false;
-
- {images.map((image, index) => (
+ {/* Carousel pagination — the grid needs no dots, so mobile only */}
+ {!isSingle && (
+
+ {images.map((_, index) => (
+ />
))}
)}
-
+
+ {zoomedImage && (
+
+ 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"
+ />
+
+
+
+ )}
+ >
);
};
-export default ProductDetailGallery;
\ No newline at end of file
+export default ProductDetailGallery;
diff --git a/components/shopify/product-detail/product-detail-info.tsx b/components/shopify/product-detail/product-detail-info.tsx
index 07c2d27..a41f7d3 100644
--- a/components/shopify/product-detail/product-detail-info.tsx
+++ b/components/shopify/product-detail/product-detail-info.tsx
@@ -1,8 +1,47 @@
import React from 'react';
-import { Product, ProductVariant } from './index.tsx';
import { Button } from '@/components/ui/button';
-import { Badge } from '@/components/ui/badge';
-import { Spinner } from '@/components/ui/spinner';
+import { Loader } from '@/components/ui/loader';
+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';
+
+interface ProductPrice {
+ amount: string;
+ currencyCode: string;
+}
+
+interface ProductVariant {
+ id: string;
+ title: string;
+ price: ProductPrice;
+ availableForSale: boolean;
+ selectedOptions: Array<{
+ name: string;
+ value: string;
+ }>;
+}
+
+interface Product {
+ id: string;
+ title: string;
+ description?: string;
+ descriptionHtml?: string;
+ handle: string;
+ priceRange: {
+ minVariantPrice: ProductPrice;
+ };
+ compareAtPriceRange?: {
+ minVariantPrice: ProductPrice;
+ };
+ options: ProductOption[];
+}
+
+export interface ProductFeature {
+ icon: string;
+ label: string;
+}
interface ProductDetailInfoProps {
product: Product;
@@ -11,10 +50,31 @@ interface ProductDetailInfoProps {
quantity: number;
setQuantity: (quantity: number) => void;
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 } => {
+ if (value.swatch?.color) return { background: value.swatch.color };
+
+ const swatchImage = value.swatch?.image?.previewImage?.url;
+ if (swatchImage) return { image: swatchImage };
+
+ const namedColor = swatchColorForName(value.name);
+ if (namedColor) return { background: namedColor };
+
+ return {};
+};
+
const ProductDetailInfo: React.FC = ({
product,
selectedVariant,
@@ -22,137 +82,201 @@ const ProductDetailInfo: React.FC = ({
quantity,
setQuantity,
handleAddToCart,
+ handleBuyNow,
onOptionChange,
+ isOptionValueAvailable,
loading = false,
+ buyingNow = false,
+ addToCartLabel = 'Add to Cart',
}) => {
- const formatPrice = (price: { amount: string; currencyCode: string }) => {
- return new Intl.NumberFormat('en-US', {
- style: 'currency',
- currency: 'USD',
- }).format(parseFloat(price.amount));
+ const formatPrice = (amount: string) => {
+ return `$${parseFloat(amount).toFixed(2)}`;
};
const price = selectedVariant?.price || product.priceRange.minVariantPrice;
const compareAtPrice = product.compareAtPriceRange?.minVariantPrice;
- const hasDiscount = compareAtPrice && parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
+ const hasDiscount =
+ compareAtPrice &&
+ parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
+ const isAvailable = selectedVariant?.availableForSale ?? false;
+
+ 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.
+ 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
+ : option.values.map((value) => ({ id: value, name: value }));
return (