Update React Editor integration and clean up template

This commit is contained in:
Rami Bitar
2026-08-15 13:47:33 -04:00
parent 0a7ea0bf6b
commit 0fb6cef226
82 changed files with 113 additions and 913 deletions
+2 -13
View File
@@ -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<string | null>(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<string | null>(null);
const runLineAction = async (lineId: string, action: () => Promise<unknown>) => {
@@ -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 = () => {
<AnimatePresence>
{isOpen && (
<SheetContent className="w-full max-w-md" showCloseButton={false}>
{/* Header */}
<SheetHeader className="min-h-0 px-5 py-4 border-b-0">
<div className="flex items-center justify-between w-full">
<SheetTitle className="text-base font-medium flex items-center gap-x-2">
@@ -140,7 +136,6 @@ const CartDrawer: React.FC = () => {
</div>
</SheetHeader>
{/* Cart Items */}
<SheetBody className="px-5">
{loading && items.length === 0 ? (
<div className="flex items-center justify-center py-12">
@@ -169,7 +164,6 @@ const CartDrawer: React.FC = () => {
return (
<div key={item.id} className="flex items-start gap-x-3">
{/* Product Image */}
<div className="w-16 h-16 bg-zinc-100 overflow-hidden shrink-0">
{image ? (
<Image
@@ -186,7 +180,6 @@ const CartDrawer: React.FC = () => {
)}
</div>
{/* Product Details */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-x-3">
<h4 className="text-sm font-medium text-foreground line-clamp-2">
@@ -209,7 +202,6 @@ const CartDrawer: React.FC = () => {
</div>
)}
{/* Quantity Controls */}
<div className="flex items-center gap-x-2 mt-2">
<div className="inline-flex items-center rounded-full bg-secondary">
<Button
@@ -274,10 +266,8 @@ const CartDrawer: React.FC = () => {
)}
</SheetBody>
{/* Footer — discount, total, checkout */}
{items.length > 0 && (
<div className="px-5 py-5 space-y-4">
{/* Discount Code */}
<form onSubmit={handleApplyDiscount} className="flex gap-x-2">
<input
type="text"
@@ -309,7 +299,6 @@ const CartDrawer: React.FC = () => {
</p>
)}
{/* Estimated total */}
<div>
<div className="flex items-baseline justify-between">
<span className="text-base text-foreground">
-2
View File
@@ -25,7 +25,6 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
href={`/collections/${collection.handle}`}
className="group block h-full"
>
{/* Collection Image */}
<div className="relative aspect-square overflow-hidden">
{collection.image ? (
<Image
@@ -42,7 +41,6 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
)}
</div>
{/* Collection Info */}
<div className="flex flex-col flex-1 py-2.5">
<h3 className="text-sm font-medium text-foreground line-clamp-1">
{collection.title}
@@ -8,11 +8,6 @@ export type CollectionDetailBlockProps = {
title?: string;
};
/**
* Used two ways: dropped on `/collections/[handle]` it follows the route, so
* `collection` stays empty and the picker only drives the editor preview;
* dropped on any other page it pins to whichever collection is picked.
*/
const collectionDetailEditor: ComponentConfig<CollectionDetailBlockProps> = {
label: 'Collection page',
icon: <Boxes size={16} />,
+2 -11
View File
@@ -33,12 +33,7 @@ const SORT_OPTIONS: SortOption[] = [
];
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;
}
@@ -65,15 +60,12 @@ const CollectionDetail: React.FC<CollectionDetailProps> = ({
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())
: 'Collection';
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.
// The literal editor route pattern has no collection to load.
if (!handle || handle === '[handle]') {
setLoading(false);
setError(
@@ -106,8 +98,7 @@ const CollectionDetail: React.FC<CollectionDetailProps> = ({
setProducts(page.products);
setCursor(page.endCursor);
setHasNextPage(page.hasNextPage);
// Keep the facet list stable while a selection is active, so options
// don't disappear out from under the panel.
// Do not remove active facet choices as result counts change.
if (activeFilters.length === 0) setFilters(page.filters);
} catch (err) {
if (cancelled) return;
+2 -17
View File
@@ -6,21 +6,11 @@ import Image from 'next/image';
export interface ContentSectionProps {
eyebrow?: React.ReactNode;
heading?: React.ReactNode;
/**
* Plain text when it comes from a `page.json`; while the field is being
* edited inline the editor hands over a ReactNode instead, so this must not
* assume a string.
*/
body?: React.ReactNode;
imageUrl?: string;
imageAlt?: string;
}
/**
* Generic prose section for the non-commerce routes (about, landing copy).
* Typography matches the storefront's other sections — the editor supplies the
* words and the image, never the layout.
*/
const ContentSection: React.FC<ContentSectionProps> = ({
eyebrow,
heading,
@@ -48,8 +38,6 @@ const ContentSection: React.FC<ContentSectionProps> = ({
<div className="relative mt-10 aspect-[3/2] overflow-hidden">
<Image
src={imageUrl}
// `heading` is a node while it's edited inline, and alt text
// has to be a string — fall back to empty rather than stringify.
alt={imageAlt || (typeof heading === 'string' ? heading : '')}
fill
sizes="(min-width: 768px) 42rem, 100vw"
@@ -61,14 +49,11 @@ const ContentSection: React.FC<ContentSectionProps> = ({
{body && (
<div className="mt-8 flex flex-col gap-4 text-[15px] leading-7 text-foreground">
{typeof body === 'string'
? // Authored as plain paragraphs; blank lines separate them.
body
? body
.split(/\n{2,}/)
.filter((paragraph) => paragraph.trim())
.map((paragraph, index) => <p key={index}>{paragraph}</p>)
: // Mid-edit: render the editor's node as-is so inline editing
// keeps working. Paragraph splitting resumes once saved.
body}
: body}
</div>
)}
</div>
-3
View File
@@ -45,9 +45,6 @@ const Footer: React.FC<FooterProps> = ({
return (
<footer className="bg-background">
<div className="max-w-screen-2xl mx-auto px-8 py-10">
{/* Left-aligned: the bottom-right corner belongs to the floating
assistant launcher. Links and socials sit on separate rows so the
icons aren't crammed onto the end of the link list. */}
<div className="flex flex-col items-center gap-y-5 sm:items-start">
<Logo
src={logoUrl}
+1 -12
View File
@@ -11,16 +11,10 @@ export type HeaderBlockProps = {
links?: Array<{ label: string; url: string }>;
};
/**
* Content only: the store name, the logo, the announcement copy and the nav
* labels. Stickiness, backdrop blur, spacing and the cart/search/account
* affordances are the component's business, not the editor's.
*/
const headerEditor: ComponentConfig<HeaderBlockProps> = {
label: 'Header',
icon: <Menu size={16} />,
category: 'navigation',
// Shared across pages: edit it once and every page.json picks it up.
global: true,
defaultProps: {
storeName: 'Shop',
@@ -47,9 +41,7 @@ const headerEditor: ComponentConfig<HeaderBlockProps> = {
announcement: {
label: 'Announcement',
type: 'text',
// Deliberately not `contentEditable`: an inline-edited field arrives as a
// ReactNode, which stays truthy once emptied, so the bar would linger as
// an empty band. Keeping it a plain string makes "blank hides it" work.
// Keep this a string so blank copy can hide the announcement.
placeholder: 'Leave empty to hide the bar',
},
announcementUrl: { label: 'Announcement link', type: 'text' },
@@ -76,9 +68,6 @@ const headerEditor: ComponentConfig<HeaderBlockProps> = {
storeName={storeName}
logoUrl={logoUrl}
links={links}
// Header decides visibility: blank copy hides the bar, and the toggle
// hides it regardless — needed because an inline-edited field is a node,
// which can't be inspected for emptiness.
showAnnouncement={showAnnouncement}
announcement={announcement}
announcementUrl={announcementUrl}
+1 -11
View File
@@ -43,10 +43,8 @@ interface HeaderProps {
storeName?: string;
logoUrl?: string;
links?: NavLink[];
/** Thin bar above the nav. Blank copy hides it. */
announcement?: React.ReactNode;
announcementUrl?: string;
/** Hides the bar outright, whatever the copy says. */
showAnnouncement?: 'yes' | 'no';
}
@@ -63,9 +61,7 @@ const Header: React.FC<HeaderProps> = ({
}) => {
const [menuOpen, setMenuOpen] = useState(false);
// Whitespace-only copy counts as empty. While the field is edited inline the
// editor supplies a node rather than a string — it can't be inspected for
// emptiness, which is what the explicit toggle is for.
// Inline editing supplies a node, so only string values can be blank-checked.
const hasAnnouncementText =
typeof announcement === 'string'
? announcement.trim() !== ''
@@ -75,7 +71,6 @@ const Header: React.FC<HeaderProps> = ({
return (
<>
{/* Sits above the sticky nav, so it scrolls away on its own. */}
{showAnnouncementBar && (
<div className="bg-muted text-foreground">
<div className="max-w-screen-2xl mx-auto flex h-9 items-center justify-center px-8 text-center text-xs">
@@ -96,10 +91,8 @@ const Header: React.FC<HeaderProps> = ({
<nav className="bg-background/95 backdrop-blur-md sticky top-0 z-50">
<div className="max-w-screen-2xl mx-auto px-8">
<div className="flex justify-between items-center h-14">
{/* Logo */}
<Logo src={logoUrl} storeName={storeName} />
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-x-8 text-sm">
<ShopMenu />
{links.map((link, index) => (
@@ -113,12 +106,10 @@ const Header: React.FC<HeaderProps> = ({
))}
</div>
{/* Actions */}
<div className="flex items-center">
<SearchDialog />
<CartIcon />
{/* Mobile hamburger */}
<Button
onClick={() => setMenuOpen(!menuOpen)}
variant="ghost"
@@ -136,7 +127,6 @@ const Header: React.FC<HeaderProps> = ({
</div>
</div>
{/* Mobile Menu */}
{menuOpen && (
<div className="md:hidden bg-background">
<div className="max-w-screen-2xl mx-auto px-8 pb-6 flex flex-col gap-y-4 text-sm">
@@ -18,7 +18,6 @@ const policyBodyEditor: ComponentConfig<PolicyBodyProps> = {
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) => ({
-9
View File
@@ -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<PolicyBodyProps> = ({
handle: handleProp,
title: titleProp,
-2
View File
@@ -66,7 +66,6 @@ const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
href={`/products/${product.handle}`}
className="group block h-full"
>
{/* Product Image */}
<div className="relative aspect-square overflow-hidden">
{firstImage ? (
<Image
@@ -95,7 +94,6 @@ const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
)}
</div>
{/* Product Info */}
<div className="flex flex-col flex-1 py-2.5">
<h3 className="text-sm font-medium text-foreground line-clamp-1">
{truncate(product.title, 65)}
@@ -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<ProductDetailBlockProps> = {
label: 'Product page',
icon: <Tag size={16} />,
+1 -9
View File
@@ -60,7 +60,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
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<ProductDetailProps> = ({
}
}, [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<ProductDetailProps> = ({
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<ProductDetailProps> = ({
}
};
// Adds the item, then sends the shopper straight to the Shopify checkout
// (where Shop Pay is offered) rather than opening the cart drawer.
const handleBuyNow = async () => {
if (!selectedVariant || !product) return;
@@ -152,7 +146,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
return (
<div className="max-w-screen-2xl mx-auto px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10">
{/* Image Gallery Skeleton */}
<div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div
@@ -162,7 +155,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
))}
</div>
{/* Product Info Skeleton */}
<div className="lg:col-span-2 animate-pulse">
<div className="h-8 bg-zinc-100 w-2/3"></div>
<div className="h-5 bg-zinc-100 w-24 mt-2"></div>
@@ -26,8 +26,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
const close = useCallback(() => setZoomedIndex(null), []);
// 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<ProductDetailGalleryProps> = ({
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<HTMLDivElement>) => {
@@ -74,7 +71,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
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<ProductDetailGalleryProps> = ({
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<ProductDetailGalleryProps> = ({
return (
<>
{/* Swipeable carousel on mobile, grid from sm up */}
<div
ref={scrollerRef}
onScroll={handleScroll}
@@ -152,7 +146,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
))}
</div>
{/* Carousel pagination — the grid needs no dots, so mobile only */}
{!isSingle && (
<div className="flex justify-center gap-2 pt-4 sm:hidden">
{images.map((_, index) => (
@@ -182,15 +175,10 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
<Image
src={zoomedImage.url}
alt={zoomedImage.altText || 'Product image'}
// Shopify gives us the intrinsic size; the square fallback only
// reserves space until CSS scales it down to fit the overlay.
width={zoomedImage.width || 1600}
height={zoomedImage.height || 1600}
sizes="100vw"
onClick={(event) => event.stopPropagation()}
// w/h-auto keeps the box at the image's own ratio; without it the
// width+height attributes make both axes definite and the element
// stretches to the overlay, swallowing backdrop clicks that close it.
className="max-h-full max-w-full w-auto h-auto object-contain"
/>
@@ -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<ProductDetailInfoProps> = ({
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<ProductDetailInfoProps> = ({
)}
</div>
{/* 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<ProductDetailInfoProps> = ({
: '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<ProductDetailInfoProps> = ({
);
})}
{/* Quantity + Add to Cart */}
<div className="mt-8 flex items-stretch gap-3">
<div className="flex items-center rounded-md border border-border h-11">
<Button
@@ -252,7 +239,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
</Button>
</div>
{/* Cart permalink into Shop Pay; falls back to the cart checkout URL. */}
<ShopPayButton
className="mt-3"
variants={
@@ -263,7 +249,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
onFallbackClick={handleBuyNow}
/>
{/* Description */}
{(product.descriptionHtml || product.description) && (
<div className="mt-10 text-sm leading-6 text-foreground product-description">
{product.descriptionHtml ? (
-12
View File
@@ -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<ProductFiltersProps> = ({
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<ProductFiltersProps> = ({
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<ProductFiltersProps> = ({
</SheetHeader>
<SheetBody className="px-5">
{/* Active selections */}
{(activeChips.length > 0 || activePriceInput) && (
<div className="mb-6 flex flex-wrap items-center gap-2">
{activeChips.map((value) => (
@@ -147,7 +140,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
</p>
)}
{/* Price */}
{priceFilter && (
<div className="mb-8">
<h3 className="mb-3 text-base text-foreground">Price</h3>
@@ -183,7 +175,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
</div>
)}
{/* Facets */}
{listFilters.map((filter) => (
<div key={filter.id} className="mb-8">
<h3 className="mb-3 text-base text-foreground">
@@ -209,9 +200,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
: 'ring-1 ring-border hover:ring-foreground/40'
}`}
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)',
}}
>
@@ -23,7 +23,6 @@ const productRecommendationsEditor: ComponentConfig<ProductRecommendationsBlockP
title: { label: 'Title', type: 'text', contentEditable: true },
product: {
label: 'Seed product',
// Empty follows the `[handle]` route segment; Shopify picks the rest.
type: 'shopifyProduct',
} as any,
limit: { label: 'Products shown', type: 'number', min: 2, max: 12 },
@@ -10,10 +10,6 @@ import ProductCard from './product-card';
interface ProductRecommendationsProps {
productId?: string;
/**
* Seeds recommendations from a specific product. Left empty, it reads the
* `[handle]` segment, which is what the `/products/[handle]` template does.
*/
handle?: string;
title?: string;
limit?: number;
-3
View File
@@ -21,8 +21,6 @@ interface ProductToolbarProps {
activeFilterCount?: number;
}
// Filters trigger on the left, item count and sort menu on the right — shared
// by the search results and collection pages.
const ProductToolbar: React.FC<ProductToolbarProps> = ({
totalCount,
onOpenFilters,
@@ -69,7 +67,6 @@ const ProductToolbar: React.FC<ProductToolbarProps> = ({
{sortOpen && (
<>
{/* Click-away layer sits under the menu, above the page. */}
<div
className="fixed inset-0 z-40"
onClick={() => setSortOpen(false)}
-6
View File
@@ -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<ProductsBlockProps> = {
label: 'Product grid',
icon: <LayoutGrid size={16} />,
@@ -30,8 +26,6 @@ const productsEditor: ComponentConfig<ProductsBlockProps> = {
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 },
-8
View File
@@ -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<ProductsProps> = ({
const [hasMoreProducts, setHasMoreProducts] = useState(true);
const [cursor, setCursor] = useState<string | null>(null);
// Paging is cursor-based: without `after`, Shopify returns the same first
// page every time and "load more" appends nothing.
const fetchProducts = async (loadMore = false) => {
try {
if (loadMore) {
@@ -84,8 +78,6 @@ const Products: React.FC<ProductsProps> = ({
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,
@@ -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<SearchResultsBlockProps> = {
label: 'Search results',
icon: <Search size={16} />,
+2 -6
View File
@@ -50,7 +50,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const 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<SearchResultsProps> = ({ 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<SearchResultsProps> = ({ 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<SearchResultsProps> = ({ title = 'Search' }) => {
/>
</div>
{/* Results */}
<div className="mt-8">
{loading ? (
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12">
-4
View File
@@ -8,9 +8,7 @@ import { cn } from '@/lib/utils';
interface ShopMenuProps {
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<ShopMenuProps> = ({
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<ShopMenuProps> = ({
{open && (
<>
{/* Catches the click that dismisses the panel. */}
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute left-0 top-full z-50 mt-2 max-h-[70vh] w-64 overflow-y-auto rounded-md border border-border bg-background py-1 shadow-md">
{body}
+1 -6
View File
@@ -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<ShopPayButtonProps> = ({
</>
);
// An anchor keeps the checkout URL visible, openable in a new tab, and
// navigable without JS; the button only stands in when there's no URL.
if (shopPayUrl && !disabled) {
return (
<a
-2
View File
@@ -1,7 +1,5 @@
import React from 'react';
// "Buy with shop" lockup — the wordmark and the Shop mark are both in the path
// data, so the button needs no additional text beyond a screen-reader label.
const ShopPayLogo: React.FC<{ className?: string }> = ({
className = 'h-auto w-[98px]',
}) => (
@@ -4,11 +4,6 @@ import StoreAssistant from '@/components/shopify/store-assistant';
export type StoreAssistantBlockProps = Record<string, never>;
/**
* The floating AI shopping assistant. It has no editable copy — its prompts and
* suggestions come from `/api/chat` — so it is registered purely so a page can
* choose whether to carry it.
*/
const storeAssistantEditor: ComponentConfig<StoreAssistantBlockProps> = {
label: 'Store assistant',
icon: <MessageCircle size={16} />,
-22
View File
@@ -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<string, unknown> | undefined
@@ -166,7 +162,6 @@ const summariseTool = (
}
};
// Storefront links stay in-app, so they skip Streamdown's external-link modal.
const isInternalLink = (url: string) => {
if (url.startsWith('/')) return true;
try {
@@ -229,7 +224,6 @@ const AttachmentItem = memo(({ attachment, onRemove }: AttachmentItemProps) => {
<AttachmentHoverCard key={attachment.id}>
<AttachmentHoverCardTrigger asChild>
<Attachment data={attachment} onRemove={handleRemove}>
{/* Thumbnail swaps to the remove button on hover. */}
<div className="group relative size-5 shrink-0">
<div className="absolute inset-0 transition-opacity group-hover:opacity-0">
<AttachmentPreview />
@@ -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 */}
<div
aria-hidden={!open}
className={`fixed bottom-20 right-4 z-50 flex w-[calc(100vw-2rem)] max-w-sm flex-col overflow-hidden rounded-xl border border-border bg-background shadow-xl transition-all duration-200 sm:right-6 ${
@@ -365,8 +353,6 @@ const StoreAssistant: React.FC = () => {
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 className="mt-3 w-full flex-wrap justify-center">
{SUGGESTIONS.map((suggestion) => (
<Suggestion
@@ -393,8 +379,6 @@ const StoreAssistant: React.FC = () => {
return (
<MessageResponse
key={index}
// Only repair half-written markdown while it streams;
// settled text is rendered exactly as sent.
isAnimating={
status === 'streaming' && part.state === 'streaming'
}
@@ -432,8 +416,6 @@ const StoreAssistant: React.FC = () => {
};
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 && (
<Attachments variant="grid" className="ml-0 justify-start">
{fileParts.map((part, index) => (
@@ -544,9 +525,6 @@ const StoreAssistant: React.FC = () => {
</div>
</div>
{/* Launcher */}
{/* Rainbow treatment only while the assistant is open; otherwise the
launcher matches the rest of the site's buttons. */}
{open ? (
<RainbowButton
onClick={() => setOpen(false)}