UNPKG

merchify-ui

Version:

React components for merchandise visualization and customization

1 lines 262 kB
{"version":3,"sources":["../src/utils/dev-warnings.ts","../src/index.ts","../src/primitives/Button.tsx","../src/primitives/ColorSwatch.tsx","../src/primitives/ProductPrice.tsx","../src/patterns/Product.tsx","../src/patterns/ShopProvider.tsx","../src/primitives/FloatingActionGroup.tsx","../src/primitives/ThemeToggle.tsx","../src/primitives/DragHintAnimation.tsx","../src/composed/ProductOptions.tsx","../src/composed/ProductImage.tsx","../src/hooks/usePlacementsProcessor.ts","../src/composed/ProductCard.tsx","../src/composed/ProductList.tsx","../src/hooks/useProductGallery.ts","../src/composed/ProductGallery.tsx","../src/composed/AddToCart.tsx","../src/composed/TileCount.tsx","../src/composed/CurrentSelectionDisplay.tsx","../src/composed/ArtSelector.tsx","../src/composed/RealtimeMockup.tsx","../src/composed/Lightbox.tsx","../src/contexts/ThemeContext.tsx","../src/composed/ArtworkCustomizer.tsx","../src/composed/ArtAlignment.tsx"],"sourcesContent":["/**\n * Development-only warnings for detecting configuration issues\n *\n * This utility helps catch common setup problems early by checking\n * if Tailwind styles are properly applied to components.\n */\n\nlet hasCheckedConfig = false;\nlet configCheckPassed = false;\n\n/**\n * Check if Tailwind CSS is properly configured\n * This runs once per page load in development mode\n */\nexport function checkTailwindConfig() {\n // Only run in development\n if (process.env.NODE_ENV !== 'development') return true;\n\n // Only check once\n if (hasCheckedConfig) return configCheckPassed;\n hasCheckedConfig = true;\n\n try {\n // Create a test element with Tailwind classes\n const testElement = document.createElement('div');\n testElement.className = 'bg-background text-foreground';\n testElement.style.position = 'absolute';\n testElement.style.visibility = 'hidden';\n testElement.style.pointerEvents = 'none';\n document.body.appendChild(testElement);\n\n // Check if styles are applied\n const styles = window.getComputedStyle(testElement);\n const hasBackgroundColor = styles.backgroundColor && styles.backgroundColor !== 'rgba(0, 0, 0, 0)';\n const hasTextColor = styles.color && styles.color !== 'rgb(0, 0, 0)';\n\n // Clean up\n document.body.removeChild(testElement);\n\n if (!hasBackgroundColor || !hasTextColor) {\n console.error(\n '%c🚨 merchify-ui: Tailwind styles not detected!',\n 'font-size: 14px; font-weight: bold; color: #ef4444;',\n '\\n\\n' +\n '❌ Your components may look unstyled.\\n\\n' +\n 'Common causes:\\n' +\n ' 1. Missing or incorrect @source paths in globals.css\\n' +\n ' 2. Forgot to restart dev server after changing globals.css\\n' +\n ' 3. Tailwind CSS not imported in globals.css\\n\\n' +\n 'Quick fix:\\n' +\n ' Run: npx merchify-cli init\\n\\n' +\n 'Or manually check:\\n' +\n ' https://merchify-site-staging.driuqzy.workers.dev/llm-kit/latest/theme-setup.md\\n'\n );\n configCheckPassed = false;\n return false;\n }\n\n console.log(\n '%c✅ merchify-ui: Tailwind configuration verified',\n 'font-size: 12px; color: #10b981;'\n );\n configCheckPassed = true;\n return true;\n\n } catch (error) {\n console.warn('merchify-ui: Could not verify Tailwind configuration', error);\n configCheckPassed = false;\n return false;\n }\n}\n\n/**\n * Check if a specific component has styles applied\n * Use this in individual components to provide more specific warnings\n */\nexport function checkComponentStyles(\n element: HTMLElement | null,\n componentName: string\n): boolean {\n // Only run in development\n if (process.env.NODE_ENV !== 'development') return true;\n if (!element) return false;\n\n try {\n const styles = window.getComputedStyle(element);\n\n // Check if any Tailwind styles are applied\n // Look for non-default values on common properties\n const hasStyles =\n (styles.backgroundColor && styles.backgroundColor !== 'rgba(0, 0, 0, 0)') ||\n (styles.padding && styles.padding !== '0px') ||\n (styles.margin && styles.margin !== '0px') ||\n (styles.display && !['inline', 'block'].includes(styles.display));\n\n if (!hasStyles) {\n console.warn(\n `%c⚠️ ${componentName}: Missing Tailwind styles`,\n 'font-size: 12px; color: #f59e0b;',\n '\\n' +\n 'Component may not render correctly.\\n' +\n 'Check @source configuration in globals.css or run: npx merchify-cli init'\n );\n return false;\n }\n\n return true;\n\n } catch (error) {\n return false;\n }\n}\n\n/**\n * Check if required CSS variables (theme tokens) are defined\n */\nexport function checkThemeTokens(): boolean {\n // Only run in development\n if (process.env.NODE_ENV !== 'development') return true;\n\n try {\n const requiredTokens = [\n '--color-background',\n '--color-foreground',\n '--color-primary',\n '--color-card',\n ];\n\n const rootStyles = getComputedStyle(document.documentElement);\n const missingTokens = requiredTokens.filter(\n (token) => !rootStyles.getPropertyValue(token)\n );\n\n if (missingTokens.length > 0) {\n console.warn(\n '%c⚠️ merchify-ui: Missing theme tokens',\n 'font-size: 12px; color: #f59e0b;',\n '\\n' +\n `Missing: ${missingTokens.join(', ')}\\n\\n` +\n 'Components may not render correctly.\\n' +\n 'Add theme tokens to globals.css:\\n' +\n ' https://merchify-site-staging.driuqzy.workers.dev/llm-kit/latest/themes.css'\n );\n return false;\n }\n\n return true;\n\n } catch (error) {\n return false;\n }\n}\n\n/**\n * Run all configuration checks\n * Call this once when the app initializes\n */\nexport function runConfigChecks(): boolean {\n if (process.env.NODE_ENV !== 'development') return true;\n\n const tailwindOk = checkTailwindConfig();\n const themesOk = checkThemeTokens();\n\n return tailwindOk && themesOk;\n}\n","// Primitives - Basic building blocks\nexport * from \"./primitives/Button\";\nexport * from \"./primitives/ColorSwatch\";\nexport * from \"./primitives/ProductPrice\";\nexport * from \"./primitives/FloatingActionGroup\";\nexport * from \"./primitives/ThemeToggle\";\nexport * from \"./primitives/DragHintAnimation\";\n\n// Composed - Pre-built component combinations\nexport * from \"./composed/ProductOptions\";\nexport * from \"./composed/ProductCard\";\nexport * from \"./composed/ProductList\";\nexport * from \"./composed/ProductGallery\";\nexport * from \"./composed/AddToCart\";\nexport * from \"./composed/TileCount\";\nexport * from \"./composed/CurrentSelectionDisplay\";\nexport * from \"./composed/ArtSelector\";\nexport * from \"./composed/RealtimeMockup\";\nexport type { RealtimeMockupProps } from './composed/RealtimeMockup';\nexport { Lightbox } from './composed/Lightbox.index';\nexport type { LightboxProps } from './composed/Lightbox.index';\n\n// Hooks\nexport * from \"./hooks/useProductGallery\";\nexport * from \"./hooks/usePlacementsProcessor\";\n\n// Patterns - Context providers and shared patterns\nexport * from \"./patterns/ShopProvider\";\nexport { useShopOptional } from \"./patterns/ShopProvider\";\nexport * from \"./patterns/Product\";\nexport type { PlacementDesign, Artwork } from \"./patterns/Product\";\n\n// Contexts - Theme and other contexts\nexport * from \"./contexts/ThemeContext\";\nexport type { ThemeConfig, ThemeVariant, ThemeContextType } from \"./contexts/ThemeContext\";\n\n// Composed - Product-related composed components (moved from product/)\nexport * from \"./composed/ProductImage\";\nexport type { RegularArtwork, SeamlessPattern, Artwork as ProductImageArtwork } from \"./composed/ProductImage\";\n// ArtAlignment is NOT exported - use ArtworkCustomizer instead!\nexport * from \"./composed/ArtworkCustomizer\";\nexport type { ArtworkCustomizerProps } from \"./composed/ArtworkCustomizer\";\n\n// Re-export types from SDK for convenience\nexport type {\n ProductPlacement,\n ProductVariant,\n ProductMockupData,\n ArtworkData,\n ImageAlignment,\n ProductData,\n ProductArtAlignmentOptions,\n ProductArtAlignmentContext,\n} from \"merchify\";\n\n// Re-export utility functions from SDK that are commonly used with React components\nexport {\n describeProductArtAlignment,\n getSnapPoints,\n} from \"merchify\";\n\n// Development utilities (tree-shaken in production)\nexport {\n runConfigChecks,\n checkTailwindConfig,\n checkThemeTokens,\n checkComponentStyles,\n} from \"./utils/dev-warnings\";\n","\"use client\";\n\nimport React, { ButtonHTMLAttributes, forwardRef } from \"react\";\n\n/**\n * Button - A flexible button primitive with multiple variants\n *\n * Provides consistent button styles across the application with support for:\n * - Multiple visual variants (primary, outline, ghost, option-text, option-swatch)\n * - Disabled and loading states\n * - Theme-aware styling\n * - Full accessibility support\n *\n * @example\n * ```tsx\n * // Primary button\n * <Button variant=\"primary\">Add to Cart</Button>\n *\n * // Option text button (for size selectors)\n * <Button\n * variant=\"option-text\"\n * selected={isSelected}\n * onClick={() => setSize(\"8x10\")}\n * >\n * 8x10\n * </Button>\n *\n * // Option swatch button (for color/frame selectors)\n * <Button\n * variant=\"option-swatch\"\n * selected={isSelected}\n * style={{ backgroundColor: '#8B4513' }}\n * />\n * ```\n */\n\nexport interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n /**\n * Visual variant of the button\n * - primary: Filled button with primary color\n * - outline: Button with border only\n * - ghost: Transparent button with hover effect\n * - secondary: Muted background button\n * - option-text: Rectangular option button for text choices (sizes, styles)\n * - option-swatch: Circular option button for visual choices (colors, frames)\n */\n variant?:\n | \"primary\"\n | \"outline\"\n | \"ghost\"\n | \"secondary\"\n | \"option-text\"\n | \"option-swatch\";\n\n /**\n * Whether this option is currently selected (for option-text and option-swatch variants)\n */\n selected?: boolean;\n\n /**\n * Whether the button is in a loading state\n */\n loading?: boolean;\n\n /**\n * Full width button (spans container width)\n */\n fullWidth?: boolean;\n}\n\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n (\n {\n variant = \"primary\",\n selected = false,\n loading = false,\n fullWidth = false,\n disabled = false,\n className = \"\",\n children,\n onMouseEnter,\n onMouseLeave,\n style,\n ...props\n },\n ref\n ) => {\n // Base classes shared by all variants\n const baseClasses = \"font-semibold transition-all\";\n\n // Variant-specific classes\n const variantClasses = {\n primary: `bg-primary hover:bg-primary/90 text-on-primary py-2 px-6 rounded-lg ${\n disabled ? \"opacity-50 cursor-not-allowed\" : \"\"\n }`,\n\n outline: `border-2 border-border hover:border-primary text-foreground py-2 px-6 rounded-lg ${\n disabled ? \"opacity-50 cursor-not-allowed\" : \"\"\n }`,\n\n ghost: `hover:bg-muted text-foreground py-2 px-6 rounded-lg ${\n disabled ? \"opacity-50 cursor-not-allowed\" : \"\"\n }`,\n\n secondary: `bg-muted hover:bg-muted/80 text-foreground py-2 px-6 rounded-lg ${\n disabled ? \"opacity-50 cursor-not-allowed\" : \"\"\n }`,\n\n \"option-text\": `px-4 py-2 text-sm font-medium rounded-sm cursor-pointer border-2 bg-background min-w-[60px] ${\n selected\n ? \"border-primary text-foreground opacity-100\"\n : \"border-border text-foreground/80 hover:text-foreground hover:border-primary/50\"\n } ${\n disabled\n ? \"opacity-50 line-through cursor-not-allowed\"\n : \"\"\n }`,\n\n \"option-swatch\": `relative h-10 w-10 rounded-full cursor-pointer ${\n selected ? \"scale-105 border-2 border-primary\" : \"ring-1 ring-border/30\"\n } ${disabled ? \"opacity-50\" : \"\"}`,\n };\n\n // Full width class\n const widthClass = fullWidth ? \"w-full\" : \"\";\n\n return (\n <button\n ref={ref}\n type=\"button\"\n className={`${baseClasses} ${variantClasses[variant]} ${widthClass} ${className}`}\n disabled={disabled || loading}\n onMouseEnter={onMouseEnter}\n onMouseLeave={onMouseLeave}\n style={style}\n {...props}\n >\n {loading && variant === \"primary\" && (\n <span className=\"inline-block w-4 h-4 border-2 border-on-primary border-t-transparent rounded-full animate-spin mr-2\" />\n )}\n {children}\n </button>\n );\n }\n);\n\nButton.displayName = \"Button\";\n","import { useState, useEffect } from \"react\";\n\nexport interface ColorSwatchChoice {\n value: string;\n label: string;\n hex?: string;\n imageUrl?: string;\n selected?: boolean;\n disabled?: boolean;\n}\n\nexport interface ColorSwatchProps {\n /** Array of color choices to display */\n choices: ColorSwatchChoice[];\n /** Callback when a color is selected */\n onChange: (value: string) => void;\n /** Optional ARIA label for the color group */\n ariaLabel?: string;\n /** Show tooltip on selection (default: true) */\n showTooltip?: boolean;\n /** Custom size in pixels (default: 40) */\n size?: number;\n /** Custom className for the container */\n className?: string;\n}\n\n/**\n * ColorSwatch - Interactive color selector with visual feedback\n *\n * A primitive component for displaying color choices as clickable swatches with\n * support for hex colors, image thumbnails, selection states, and tooltips.\n * Designed for maximum accessibility and visual clarity.\n *\n * Features:\n * - Hex color or image thumbnail backgrounds\n * - Visual selection indicators (checkmark, rings)\n * - Disabled state with diagonal slash\n * - Hover scaling for interactivity\n * - Tooltip feedback on selection\n * - Screen reader announcements\n * - Accessible with ARIA roles and labels\n * - Dark mode support\n * - Customizable size\n * - Colorblind-friendly selection indicators\n *\n * **Visual States:**\n * - Default: Simple round swatch with subtle border\n * - Selected: Double ring + checkmark icon + pattern overlay\n * - Disabled: Diagonal slash overlay\n * - Hover: Slight scale animation\n *\n * **Accessibility:**\n * - Keyboard navigable\n * - Screen reader friendly with status announcements\n * - Visible selection indicators for colorblind users\n * - Proper ARIA roles and labels\n *\n * @example\n * ```tsx\n * // Basic color swatches\n * <ColorSwatch\n * choices={[\n * { value: \"red\", label: \"Red\", hex: \"#ff0000\", selected: true },\n * { value: \"blue\", label: \"Blue\", hex: \"#0000ff\" },\n * { value: \"green\", label: \"Green\", hex: \"#00ff00\", disabled: true }\n * ]}\n * onChange={(value) => console.log('Selected:', value)}\n * ariaLabel=\"Choose product color\"\n * />\n * ```\n *\n * @example\n * ```tsx\n * // With image thumbnails (patterns/textures)\n * <ColorSwatch\n * choices={[\n * { value: \"marble\", label: \"Marble\", imageUrl: \"/textures/marble.jpg\" },\n * { value: \"wood\", label: \"Wood Grain\", imageUrl: \"/textures/wood.jpg\" }\n * ]}\n * onChange={handleMaterialChange}\n * size={50}\n * />\n * ```\n *\n * @example\n * ```tsx\n * // Custom styling and no tooltips\n * <ColorSwatch\n * choices={colorOptions}\n * onChange={setColor}\n * showTooltip={false}\n * size={60}\n * className=\"gap-4 justify-center\"\n * />\n * ```\n *\n * @param choices - Array of color/pattern choices to display\n * @param onChange - Callback when a swatch is clicked (receives choice value)\n * @param ariaLabel - Accessible label for the color group\n * @param showTooltip - Show selection tooltip (default: true)\n * @param size - Swatch diameter in pixels (default: 40)\n * @param className - Additional CSS classes for container\n */\nexport function ColorSwatch({\n choices,\n onChange,\n ariaLabel,\n showTooltip = true,\n size = 40,\n className = \"\",\n}: ColorSwatchProps) {\n const [activeTooltip, setActiveTooltip] = useState<string | null>(null);\n\n useEffect(() => {\n if (activeTooltip && showTooltip) {\n const timer = setTimeout(() => {\n setActiveTooltip(null);\n }, 2000);\n return () => clearTimeout(timer);\n }\n }, [activeTooltip, showTooltip]);\n\n const handleSwatchClick = (choice: ColorSwatchChoice) => {\n onChange(choice.value);\n if (showTooltip) {\n setActiveTooltip(choice.value);\n }\n };\n\n return (\n <>\n <div\n className={`flex flex-wrap gap-2 ${className}`}\n role=\"group\"\n aria-label={ariaLabel}\n >\n {choices.map((choice) => (\n <div key={choice.value} className=\"relative\">\n <button\n type=\"button\"\n onClick={() => handleSwatchClick(choice)}\n className={`relative rounded-full transition-all duration-200 flex-shrink-0 ${\n choice.disabled ? \"cursor-not-allowed\" : \"cursor-pointer\"\n } ${\n choice.selected ? \"scale-105 border-2 border-primary\" : \"ring-1 ring-border\"\n }`}\n style={{\n width: `${size}px`,\n height: `${size}px`,\n }}\n aria-label={`${choice.label} color${choice.selected ? ', selected' : ''}`}\n aria-pressed={choice.selected}\n disabled={choice.disabled}\n title={choice.label}\n >\n <span\n className={`absolute ${\n choice.selected ? \"inset-[3px]\" : \"inset-0\"\n } rounded-full block bg-cover bg-center transition-all duration-200`}\n style={{\n backgroundColor: choice.hex || \"#ccc\",\n backgroundImage: choice.imageUrl\n ? `url(${choice.imageUrl})`\n : undefined,\n }}\n />\n {choice.disabled && (\n <>\n <span\n className=\"absolute inset-0 rounded-full pointer-events-none opacity-80\"\n style={{\n background:\n \"linear-gradient(to bottom right, transparent calc(50% - 3px), var(--color-foreground) calc(50% - 3px), var(--color-foreground) calc(50% + 3px), transparent calc(50% + 3px))\",\n }}\n />\n <span\n className=\"absolute inset-0 rounded-full pointer-events-none bg-background opacity-40\"\n />\n </>\n )}\n </button>\n </div>\n ))}\n </div>\n\n {/* Screen reader announcement and visual tooltip for swatch selection */}\n {activeTooltip && showTooltip && (\n <>\n <div\n className=\"sr-only\"\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n >\n {ariaLabel}: {\n choices.find((c) => c.value === activeTooltip)?.label\n } selected\n </div>\n {/* Visual tooltip for sighted users */}\n <div\n className=\"fixed bg-primary text-primary-foreground text-sm px-3 py-2 rounded-lg shadow-xl pointer-events-none\"\n style={{\n top: \"20px\",\n left: \"50%\",\n transform: \"translateX(-50%)\",\n zIndex: 9999,\n maxWidth: \"90vw\",\n }}\n aria-hidden=\"true\"\n >\n {ariaLabel}: {\n choices.find((c) => c.value === activeTooltip)?.label\n } selected\n </div>\n </>\n )}\n </>\n );\n}\n","import React from \"react\";\nimport {\n describeProductPrice,\n formatPrice,\n type ProductPriceOptions,\n} from \"merchify\";\nimport { useProduct } from \"../patterns/Product\";\n\nexport interface ProductPriceProps extends ProductPriceOptions {\n contextPrice?: number;\n showCents?: boolean;\n}\n\n/**\n * ProductPrice - Formatted price display with currency and locale support\n *\n * A primitive component for displaying product prices with automatic currency\n * formatting, smart cents display, and seamless Product context integration.\n * Uses semantic HTML for machine-readable price data.\n *\n * Features:\n * - Automatic currency formatting based on locale\n * - Smart cents display (hides .00, shows actual cents)\n * - Semantic HTML using `<data>` element with value attribute\n * - Accessible with ARIA labels for screen readers\n * - Works standalone or within Product context\n * - Automatically uses currentPrice from context (reflects variant selection)\n * - Supports international currencies and locales\n * - Machine-readable price in value attribute for SEO/scrapers\n *\n * **Price Format:**\n * - Prices are stored in cents to avoid floating point issues\n * - Example: 2999 cents = $29.99\n * - Automatically converts to display format\n *\n * **Context Integration:**\n * - When used in `Product` context, automatically shows current price\n * - Updates when user selects different variants\n * - Falls back to prop value when used standalone\n *\n * @example\n * ```tsx\n * // Standalone usage with explicit price\n * <ProductPrice price={2999} showCents={true} />\n * // Output: $29.99\n * ```\n *\n * @example\n * ```tsx\n * // Within Product context (automatically uses current price)\n * <Product productId=\"shirt-123\">\n * <ProductOptions />\n * <ProductPrice showCents={false} />\n * </Product>\n * // Output: $29 (or $35 if user selects a pricier variant)\n * ```\n *\n * @example\n * ```tsx\n * // International currency and locale\n * <ProductPrice\n * price={2999}\n * currency=\"EUR\"\n * locale=\"de-DE\"\n * showCurrency={true}\n * />\n * // Output: €29,99\n * ```\n *\n * @example\n * ```tsx\n * // Custom styling for sale prices\n * <div className=\"flex items-center gap-2\">\n * <ProductPrice\n * price={1999}\n * className=\"text-2xl font-bold text-green-600\"\n * />\n * <ProductPrice\n * price={2999}\n * showCents={false}\n * className=\"text-sm line-through text-gray-400\"\n * />\n * </div>\n * ```\n *\n * @param price - Price in cents (e.g., 2999 = $29.99). Optional if used within Product context\n * @param currency - ISO 4217 currency code (default: \"USD\")\n * @param locale - BCP 47 locale string for formatting (default: \"en-US\")\n * @param showCurrency - Display full currency symbol (default: false, shows $ only)\n * @param showCents - Display cents portion (default: true, hides if .00)\n * @param className - Additional CSS classes for styling\n * @param contextPrice - Internal: Override context price (used by Product component)\n */\nexport function ProductPrice({\n price,\n currency = \"USD\",\n locale = \"en-US\",\n showCurrency = false,\n className,\n contextPrice,\n showCents = true,\n}: ProductPriceProps) {\n // Try to get price from context\n let priceValue = contextPrice || price;\n if (!priceValue) {\n try {\n const context = useProduct();\n // Use the currentPrice from context which is already calculated\n priceValue = context.currentPrice || context.product?.price;\n } catch {\n // Not in a Product context, that's OK\n }\n }\n\n const descriptor = describeProductPrice(\n { price, currency, locale, showCurrency, className },\n priceValue\n );\n\n if (!descriptor) {\n return null;\n }\n\n // Convert cents to dollars for display\n const dollars = Math.floor(descriptor.price / 100);\n const cents = descriptor.price % 100;\n\n // Format with currency symbol if needed\n const currencySymbol = showCurrency\n ? new Intl.NumberFormat(descriptor.locale, {\n style: \"currency\",\n currency: descriptor.currency,\n })\n .format(0)\n .replace(/[\\d.,\\s]/g, \"\")\n : \"$\";\n\n // If showing cents and there are cents to show\n if (showCents && cents > 0) {\n return (\n <data\n value={descriptor.price / 100}\n className={descriptor.className}\n aria-label={`Price: ${currencySymbol}${dollars}.${cents.toString().padStart(2, \"0\")}`}\n >\n <span aria-hidden=\"true\">\n {currencySymbol}\n {dollars}.{cents.toString().padStart(2, \"0\")}\n </span>\n </data>\n );\n }\n\n // Just show dollars\n return (\n <data\n value={descriptor.price / 100}\n className={descriptor.className}\n aria-label={`Price: ${currencySymbol}${dollars}`}\n >\n <span aria-hidden=\"true\">\n {currencySymbol}\n {dollars}\n </span>\n </data>\n );\n}\n","import React, {\n createContext,\n useState,\n useEffect,\n useMemo,\n useCallback,\n} from \"react\";\nimport type { CatalogProduct } from \"merchify\";\nimport { getProduct, listProducts } from \"merchify\";\nimport { createDevFetcher } from \"merchify/dev-fetcher\";\nimport type {\n OptionAttribute,\n Combination,\n OptionSelection,\n ProductContext as CoreProductContext,\n ImageAlignment,\n} from \"merchify\";\nimport {\n deriveDefaultSelection,\n toOptionAttributes,\n toCombinations,\n findBestCombination,\n UniversalContextProvider,\n createUniversalProvider,\n} from \"merchify\";\nimport { useShopOptional } from \"../patterns/ShopProvider\";\n\n// Design-related interfaces (previously in DesignContext)\nexport interface PlacementDesign {\n imageUrl: string;\n alignment: ImageAlignment;\n tiles?: 0.25 | 0.5 | 1 | 2 | 4;\n // Future fields can be added here:\n // filters?: string[];\n // etc.\n}\n\nexport type RegularArtwork = {\n type: \"regular\";\n src: string;\n};\n\nexport type SeamlessPattern = {\n type: \"pattern\";\n src: string;\n tileCount: 0.25 | 0.5 | 1 | 2 | 4;\n};\n\nexport type Artwork = RegularArtwork | SeamlessPattern;\n\n// Extended React Context with update methods and design functionality\nexport interface ReactProductContext extends CoreProductContext {\n updateSelection?: (selection: OptionSelection) => void;\n currentPrice?: number;\n\n // Design functionality (previously in DesignProvider)\n placements: Record<string, PlacementDesign>;\n artworks: Artwork[];\n selectedArtwork?: Artwork;\n\n // Design methods\n setPlacementDesign: (\n placement: string,\n design: Partial<PlacementDesign>\n ) => void;\n getPlacementDesign: (placement: string) => PlacementDesign | undefined;\n addArtwork: (artwork: Artwork) => void;\n setSelectedArtwork: (artwork: Artwork | undefined) => void;\n applyArtworkToPlacement: (placement: string, artwork?: Artwork) => void;\n}\n\n// React Context\nexport const ProductContext = createContext<ReactProductContext | undefined>(\n undefined\n);\n\nexport interface ProductProviderProps {\n children: React.ReactNode;\n productId?: string;\n productData?: CatalogProduct; // Allow passing product data directly to skip fetching\n endpoint?: string;\n source?: string;\n fetcher?: typeof fetch;\n className?: string;\n initialSelection?: OptionSelection;\n initialPlacements?: Record<string, PlacementDesign>;\n renderLoading?: () => React.ReactNode;\n renderError?: (error: any) => React.ReactNode;\n}\n\n/**\n * Product - Context provider for product data, variant selection, and design management\n *\n * A composed pattern component that provides React context for product information,\n * variant selection, pricing, artwork management, and placement designs. Acts as the\n * central state container for product-related components.\n *\n * Features:\n * - Automatic product data fetching from API endpoint\n * - Variant selection and combination management\n * - Dynamic price calculation based on selected options\n * - Design/artwork management with placement-specific designs\n * - Integration with Shop context for shared artwork state\n * - Loading and error states with custom render props\n * - Universal provider sync for cross-framework compatibility\n * - SSR-friendly with optional data prop to skip fetching\n *\n * **Context Provided:**\n * - Product data and metadata\n * - Option attributes and combinations\n * - Current selection and price\n * - Artwork collection and selected artwork\n * - Placement designs (alignment, tiles, etc.)\n * - Methods to update selection, artwork, and placements\n *\n * **Integration with Shop:**\n * - Inherits endpoint from Shop context if available\n * - Shares artwork state across all Product instances in a Shop\n * - Falls back to local state if used standalone\n *\n * @example\n * ```tsx\n * // Basic usage - fetch product by ID\n * <Product productId=\"shirt-123\">\n * <ProductImage />\n * <ProductOptions />\n * <AddToCart />\n * </Product>\n * ```\n *\n * @example\n * ```tsx\n * // With initial selection and custom loading\n * <Product\n * productId=\"shirt-123\"\n * initialSelection={{ Size: 'M', Color: 'Blue' }}\n * renderLoading={() => <Spinner />}\n * renderError={(err) => <ErrorMessage error={err} />}\n * >\n * <ProductCard />\n * </Product>\n * ```\n *\n * @example\n * ```tsx\n * // Standalone with explicit data (no fetching)\n * <Product\n * productData={myProductData}\n * initialPlacements={{\n * Front: { imageUrl: 'logo.png', alignment: 'center' },\n * Back: { imageUrl: 'text.png', alignment: 'top' }\n * }}\n * >\n * <ArtAlignment placement=\"Front\" />\n * <ProductImage />\n * </Product>\n * ```\n *\n * @example\n * ```tsx\n * // With Shop context\n * <Shop endpoint=\"http://localhost:3000\">\n * <Product productId=\"shirt-123\">\n * <ProductImage />\n * </Product>\n * <Product productId=\"mug-456\">\n * <ProductImage />\n * </Product>\n * </Shop>\n * // Both products share artwork state from Shop\n * ```\n *\n * @param children - Child components that consume product context\n * @param productId - Product identifier to fetch\n * @param productData - Explicit product data (skips fetching if provided)\n * @param endpoint - API endpoint URL (overrides Shop context)\n * @param source - Source identifier for data fetching (default: \"auto\")\n * @param fetcher - Custom fetch function (e.g., for auth headers)\n * @param className - Additional CSS classes for wrapper div\n * @param initialSelection - Pre-select variant options (e.g., { Size: 'M', Color: 'Blue' })\n * @param initialPlacements - Pre-configure placement designs\n * @param renderLoading - Custom loading UI component\n * @param renderError - Custom error UI component\n */\nexport function Product({\n children,\n productId,\n productData,\n endpoint,\n source = \"auto\",\n fetcher,\n className,\n initialSelection,\n initialPlacements,\n renderLoading,\n renderError,\n}: ProductProviderProps) {\n const [product, setProduct] = useState<CatalogProduct | undefined>(productData);\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<any>(null);\n const [selection, setSelection] = useState<OptionSelection>({});\n\n // Check if we have a parent Shop context\n const shopContext = useShopOptional();\n\n // Use shop context values if available, otherwise use props\n const effectiveEndpoint = endpoint ?? shopContext?.endpoint;\n\n // Design state - use shop context if available, otherwise maintain local state\n // Key the placements by productId to ensure they reset when product changes\n // Initialize with initialPlacements if provided\n const [placementsById, setPlacementsById] = useState<\n Record<string, Record<string, PlacementDesign>>\n >(() => {\n if (initialPlacements && productId) {\n return { [productId]: initialPlacements };\n }\n return {};\n });\n\n // Store alignment settings per artwork URL\n // Key structure: { productId: { placement: { artworkUrl: ImageAlignment } } }\n const [alignmentByArtwork, setAlignmentByArtwork] = useState<\n Record<string, Record<string, Record<string, ImageAlignment>>>\n >({});\n\n // Get current product's placements\n const placements = productId ? placementsById[productId] || {} : {};\n const [localArtworks, setLocalArtworks] = useState<Artwork[]>([]);\n const [localSelectedArtwork, setLocalSelectedArtwork] = useState<\n Artwork | undefined\n >();\n\n // Use shop context if available, otherwise use local state\n const artworks = shopContext?.artworks ?? localArtworks;\n const selectedArtwork = shopContext?.selectedArtwork ?? localSelectedArtwork;\n\n // Create universal provider\n const universalProvider = useMemo(() => {\n return createUniversalProvider({\n endpoint: effectiveEndpoint,\n productId,\n fetcher,\n autoLoad: false,\n });\n }, [effectiveEndpoint, productId]);\n\n useEffect(() => {\n // Skip fetching if productData was provided\n if (productData) {\n setProduct(productData);\n\n // Apply initial placements if provided\n if (initialPlacements && (productId || productData.id)) {\n const id = productId || productData.id;\n setPlacementsById((prev) => {\n if (!prev[id]) {\n return {\n ...prev,\n [id]: initialPlacements,\n };\n }\n return prev;\n });\n }\n\n // Derive initial selection\n const attrs = toOptionAttributes(productData);\n const combos = toCombinations(productData);\n const defaultSelection =\n initialSelection || deriveDefaultSelection(attrs, combos);\n setSelection(defaultSelection);\n return;\n }\n\n const loadProduct = async () => {\n setLoading(true);\n setError(null);\n\n const baseUrl = effectiveEndpoint;\n const customFetcher = fetcher;\n\n try {\n let loadedProduct: CatalogProduct | undefined;\n\n if (productId) {\n loadedProduct = await getProduct(productId, {\n baseUrl,\n fetcher: customFetcher as any,\n });\n } else {\n const { items } = await listProducts({\n baseUrl,\n fetcher: customFetcher as any,\n });\n loadedProduct = items?.[0];\n }\n\n if (loadedProduct) {\n setProduct(loadedProduct);\n\n // Apply initial placements if provided and not already set\n if (initialPlacements && productId) {\n setPlacementsById((prev) => {\n // Only set if not already present for this product\n if (!prev[productId]) {\n return {\n ...prev,\n [productId]: initialPlacements,\n };\n }\n return prev;\n });\n }\n\n // Derive initial selection - use provided initialSelection or default\n const attrs = toOptionAttributes(loadedProduct);\n const combos = toCombinations(loadedProduct);\n const defaultSelection =\n initialSelection || deriveDefaultSelection(attrs, combos);\n setSelection(defaultSelection);\n }\n } catch (err) {\n console.error(\"Failed to load product:\", err);\n setError(err);\n } finally {\n setLoading(false);\n }\n };\n\n loadProduct();\n }, [\n productId,\n productData,\n effectiveEndpoint,\n fetcher,\n initialSelection,\n initialPlacements,\n ]);\n\n // Compute derived values\n const optionAttributes = useMemo(\n () => (product ? toOptionAttributes(product) : {}),\n [product]\n );\n\n const combinations = useMemo(\n () => (product ? toCombinations(product) : []),\n [product]\n );\n\n // Update selection handler\n const updateSelection = (newSelection: OptionSelection) => {\n setSelection(newSelection);\n };\n\n // Design methods (previously in DesignProvider)\n const setPlacementDesign = useCallback(\n (placement: string, design: Partial<PlacementDesign>) => {\n if (!productId) return;\n\n setPlacementsById((prev) => ({\n ...prev,\n [productId]: {\n ...prev[productId],\n [placement]: {\n ...prev[productId]?.[placement],\n ...design,\n } as PlacementDesign,\n },\n }));\n\n // If alignment is being set and we have a selected artwork, save it per artwork\n if (design.alignment && selectedArtwork) {\n setAlignmentByArtwork((prev) => ({\n ...prev,\n [productId]: {\n ...prev[productId],\n [placement]: {\n ...prev[productId]?.[placement],\n [selectedArtwork.src]: design.alignment!,\n },\n },\n }));\n }\n },\n [productId, selectedArtwork]\n );\n\n const getPlacementDesign = useCallback(\n (placement: string): PlacementDesign | undefined => {\n const design = placements[placement];\n return design;\n },\n [placements]\n );\n\n // Use shop context methods if available, otherwise use local methods\n const addArtwork = useCallback(\n (artwork: Artwork) => {\n if (shopContext) {\n shopContext.addArtwork(artwork);\n } else {\n setLocalArtworks((prev) => {\n const exists = prev.some((a) => a.src === artwork.src);\n if (exists) {\n return prev.map((a) => (a.src === artwork.src ? artwork : a));\n }\n return [...prev, artwork];\n });\n }\n },\n [shopContext]\n );\n\n const setSelectedArtwork = useCallback(\n (artwork: Artwork | undefined) => {\n if (shopContext) {\n shopContext.setSelectedArtwork(artwork);\n } else {\n setLocalSelectedArtwork(artwork);\n }\n },\n [shopContext]\n );\n\n // Update placements when selected artwork changes\n useEffect(() => {\n if (selectedArtwork && productId) {\n setPlacementsById((prev) => {\n const currentPlacements = prev[productId] || {};\n const updated = { ...currentPlacements };\n const savedAlignments = alignmentByArtwork[productId] || {};\n\n Object.keys(updated).forEach((key) => {\n // Check if we have a saved alignment for this artwork on this placement\n const savedAlignment = savedAlignments[key]?.[selectedArtwork.src];\n\n updated[key] = {\n ...updated[key],\n imageUrl: selectedArtwork.src,\n // Restore saved alignment if it exists, otherwise reset to default (center)\n alignment: savedAlignment || 'center',\n };\n });\n return {\n ...prev,\n [productId]: updated,\n };\n });\n }\n }, [selectedArtwork, productId, alignmentByArtwork]);\n\n const applyArtworkToPlacement = useCallback(\n (placement: string, artwork?: Artwork) => {\n if (artwork) {\n setPlacementDesign(placement, { imageUrl: artwork.src });\n }\n },\n [setPlacementDesign]\n );\n\n // Calculate current price based on selection\n const currentPrice = useMemo(() => {\n if (!product) return undefined;\n\n // Use findBestCombination from ui-core for consistent logic\n const bestCombination = findBestCombination(\n selection,\n combinations,\n optionAttributes\n );\n\n return bestCombination?.price || product.price;\n }, [product, combinations, selection, optionAttributes]);\n\n // Create context value with update method and design functionality\n const contextValue: ReactProductContext = {\n product,\n optionAttributes,\n combinations,\n selection,\n loading,\n error,\n updateSelection,\n currentPrice,\n\n // Design functionality\n placements,\n artworks,\n selectedArtwork,\n setPlacementDesign,\n getPlacementDesign,\n addArtwork,\n setSelectedArtwork,\n applyArtworkToPlacement,\n };\n\n // Sync with universal provider\n useEffect(() => {\n if (product) {\n universalProvider.getContext().product = product;\n universalProvider.getContext().optionAttributes = optionAttributes;\n universalProvider.getContext().combinations = combinations;\n universalProvider.getContext().selection = selection;\n }\n }, [product, optionAttributes, combinations, selection, universalProvider]);\n\n // Handle loading state\n if (loading && renderLoading) {\n return <>{renderLoading()}</>;\n }\n\n // Handle error state\n if (error) {\n if (renderError) {\n return <>{renderError(error)}</>;\n }\n // Default error UI\n return <div className=\"merchify-error\">Failed to load product</div>;\n }\n\n return (\n <ProductContext.Provider value={contextValue}>\n <div\n className={`merchify-product ${className || \"\"}`}\n data-merchify-provider\n >\n {children}\n </div>\n </ProductContext.Provider>\n );\n}\n\n// Hook to use product context\nexport function useProduct() {\n const context = React.useContext(ProductContext);\n if (!context) {\n throw new Error(\"useProduct must be used within a Product provider\");\n }\n return context;\n}\n\n// Optional hook that doesn't throw when not in a Product provider\nexport function useProductOptional() {\n return React.useContext(ProductContext);\n}\n\n// Hook specifically for design functionality (alias for useProduct for backwards compatibility)\nexport function useDesign() {\n const context = React.useContext(ProductContext);\n if (!context) {\n throw new Error(\"useDesign must be used within a Product provider\");\n }\n return context;\n}\n\n// Optional design hook that doesn't throw when not in a Product provider\nexport function useDesignOptional() {\n return React.useContext(ProductContext);\n}\n","import React, {\n createContext,\n useState,\n useCallback,\n ReactNode,\n useRef,\n useEffect,\n} from \"react\";\nimport type { Artwork } from \"../patterns/Product\";\nimport type { CatalogProduct } from \"merchify\";\nimport { config } from \"merchify\";\n\n// Image update request for queuing\nexport interface ImageUpdateRequest {\n id: string; // Unique identifier for this request\n execute: () => void; // Function to execute the update\n priority?: number; // Optional priority (higher = more important)\n url?: string; // Optional URL to check against cache\n}\n\nexport interface ShopContextValue {\n // Shop configuration\n endpoint?: string;\n\n // Artwork selection\n artworks: Artwork[];\n selectedArtwork?: Artwork;\n addArtwork: (artwork: Artwork) => void;\n setSelectedArtwork: (artwork: Artwork | undefined) => void;\n\n // Image generation throttling\n queueImageUpdate: (request: ImageUpdateRequest) => void;\n cancelImageUpdate: (id: string) => void;\n\n // Product data caching (for performance optimization)\n getProduct: (productId: string) => CatalogProduct | undefined;\n addProduct: (product: CatalogProduct) => void;\n addProducts: (products: CatalogProduct[]) => void;\n clearProductCache: () => void;\n\n // Future shop-level features:\n // currency?: string;\n // theme?: 'light' | 'dark';\n // cartItems?: CartItem[];\n // addToCart?: (item: CartItem) => void;\n}\n\nexport const ShopContext = createContext<ShopContextValue | undefined>(\n undefined\n);\n\nexport interface ShopProps {\n children: ReactNode;\n endpoint?: string;\n mockupUrl?: string;\n accountId?: string;\n}\n\n/**\n * Shop - Global shop context provider with artwork and image throttling\n *\n * A pattern component that provides centralized state management for shop-wide\n * concerns like artwork selection, product caching, and coordinated image generation\n * throttling. Wrap your entire app (or shop section) in this provider.\n *\n * Features:\n * - Centralized artwork state shared across all Product instances\n * - Image update queueing with intelligent throttling (5 images/sec)\n * - Product data caching for performance\n * - LRU URL cache to bypass throttling for repeated requests\n * - Per-component throttling (500ms) to prevent rapid updates\n * - Priority-based queue processing\n * - Automatic SDK configuration\n * - Mode/endpoint configuration cascade to child components\n *\n * **Image Throttling:**\n * - Limits mockup generation to 5 images per second (non-cached)\n * - Cached URLs bypass throttling entirely\n * - Per-component throttle prevents same component from spamming\n * - Priority queue ensures important images load first\n * - LRU cache (max 100 URLs) remembers recent requests\n *\n * **Artwork Management:**\n * - Centralized artwork collection\n * - Shared selected artwork across all products\n * - Automatic sync between Product instances\n *\n * **Product Caching:**\n * - In-memory product cache to reduce API calls\n * - Add/get/clear cache methods\n * - Useful for product lists and galleries\n *\n * @example\n * ```tsx\n * // Basic shop setup\n * <Shop endpoint=\"http://localhost:3000\">\n * <ProductList limit={12}>\n * <ProductCard variant=\"overlay\" />\n * </ProductList>\n * </Shop>\n * ```\n *\n * @example\n * ```tsx\n * // Multiple products sharing artwork state\n * <Shop mockupUrl=\"https://api.example.com\">\n * <ArtSelector />\n *\n * <div className=\"grid grid-cols-2 gap-4\">\n * <Product productId=\"shirt-123\">\n * <ProductImage />\n * </Product>\n * <Product productId=\"mug-456\">\n * <ProductImage />\n * </Product>\n * </div>\n * </Shop>\n * ```\n *\n * @example\n * ```tsx\n * // With environment variables (Next.js)\n * // .env.local:\n * // NEXT_PUBLIC_MERCHIFY_ENDPOINT=http://localhost:3000\n * // NEXT_PUBLIC_MERCHIFY_MOCKUP_URL=https://mockup.example.com\n * // NEXT_PUBLIC_MERCHIFY_ACCOUNT_ID=acc_123\n *\n * <Shop>\n * <YourApp />\n * </Shop>\n * ```\n *\n * @example\n * ```tsx\n * // Access shop context in child components\n * function MyComponent() {\n * const shop = useShopOptional();\n *\n * return (\n * <div>\n * <p>Artworks: {shop?.artworks.length}</p>\n *\n * <button onClick={() => shop?.setSelectedArtwork(artwork)}>\n * Select Artwork\n * </button>\n * </div>\n * );\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Product caching for performance\n * function ProductCatalog() {\n * const shop = useShop();\n *\n * useEffect(() => {\n * // Cache products after fetching\n * fetchProducts().then(products => {\n * shop.addProducts(products);\n * });\n * }, []);\n *\n * // Later, retrieve from cache\n * const cachedProduct = shop.getProduct('shirt-123');\n * }\n * ```\n *\n * @param children - Child components that will have access to shop context\n * @param endpoint - API endpoint URL (falls back to NEXT_PUBLIC_MERCHIFY_ENDPOINT)\n * @param mockupUrl - Mockup generation API URL (falls back to NEXT_PUBLIC_MERCHIFY_MOCKUP_URL)\n * @param accountId - Account identifier (falls back to NEXT_PUBLIC_MERCHIFY_ACCOUNT_ID)\n */\nexport function Shop({\n children,\n endpoint,\n mockupUrl,\n accountId,\n}: ShopProps) {\n // Configure SDK on mount - reads from environment variables if not explicitly provided\n useEffect(() => {\n config({\n mockupUrl: mockupUrl || (typeof process !== 'undefined' ? process.env.NEXT_PUBLIC_MERCHIFY_MOCKUP_URL : undefined),\n endpoint: endpoint || (typeof process !== 'undefined' ? process.env.NEXT_PUBLIC_MERCHIFY_ENDPOINT : undefined),\n accountId: accountId || (typeof process !== 'undefined' ? process.env.NEXT_PUBLIC_MERCHIFY_ACCOUNT_ID : undefined),\n });\n }, [mockupUrl, endpoint, accountId]);\n\n // Run configuration checks in development\n useEffect(() => {\n if (process.env.NODE_ENV === 'development' && typeof window !== 'undefined') {\n // Delay check to ensure DOM is ready\n setTimeout(() => {\n import('../utils/dev-warnings.js').then(({ runConfigChecks }) => {\n runConfigChecks();\n });\n }, 1000);\n }\n }, []);\n const [artworks, setArtworks] = useState<Artwork[]>([]);\n const [selectedArtwork, setSelectedArtworkState] = useState<\n Artwork | undefined\n >();\n\n // Product data cache (use ref to avoid changing reference)\n const productCacheRef = useRef<Map<string, CatalogProduct>>(new Map());\n\n // Image update queue management\n const updateQueueRef = useRef<Map<string, ImageUpdateRequest>>(new Map());\n const processingRef = useRef<boolean>(false);\n const processTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n const recentProcessTimesRef = useRef<number[]>([]);\n const componentLastUpdateRef = useRef<Map<string, number>>(new Map());\n\n // URL cache for tracking recently requested images (LRU with max 100 entries)\n const urlCacheRef = useRef<Set<string>>(new Set());\n const urlCacheOrderRef = useRef<string[]>([]);\n\n const addArtwork = useCallback((artwork: Artwork) => {\n setArtworks((prev) => {\n const exists = prev.some((a) => a.src === artwork.src);\n if (exists) {\n return prev.map((a) => (a.src === artwork.src ? artwork : a));\n }\n return [...prev, artwork];\n });\n }, []);\n\n const setSelectedArtwork = useCallback((artwork: Artwork | undefined) => {\n setSelectedArtworkState(artwork);\n }, []);\n\n // Product cache methods\n const getProduct = useCallback((productId: string) => {\n return productCacheRef.current.get(productId);\n }, []);\n\n const addProduct = useCallback((product: CatalogProduct) => {\n productCacheRef.current.set(product.id, product);\n }, []);\n\n const addProducts = useCallback((products: Cat