@cimplify/sdk 0.23.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/advanced.js +21 -21
- package/dist/advanced.mjs +2 -2
- package/dist/{chunk-OANVYH27.mjs → chunk-E3HC2YAE.mjs} +1 -1
- package/dist/{chunk-3GSXQNWV.mjs → chunk-H34GSEGR.mjs} +2 -2
- package/dist/{chunk-TXQHTHY5.js → chunk-I4F4Y3D4.js} +20 -20
- package/dist/{chunk-RAKLT6J2.js → chunk-IYQGGFVQ.js} +39 -39
- package/dist/{chunk-KRNWMUBN.js → chunk-PEDLCL72.js} +1 -1
- package/dist/{chunk-BYQPMSH2.mjs → chunk-TBZLJPM4.mjs} +1 -1
- package/dist/{index-BodyCOun.d.mts → index-DCoxHrmr.d.mts} +2 -2
- package/dist/{index-IH1wOTmD.d.ts → index-DT0Mzy8v.d.ts} +2 -2
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +101 -101
- package/dist/index.mjs +3 -3
- package/dist/react.d.mts +78 -23
- package/dist/react.d.ts +78 -23
- package/dist/react.js +797 -105
- package/dist/react.mjs +725 -46
- package/dist/styles.css +1 -1
- package/dist/utils.d.mts +1 -1
- package/dist/utils.d.ts +1 -1
- package/dist/utils.js +28 -28
- package/dist/utils.mjs +1 -1
- package/package.json +1 -1
- package/registry/default-product-layout.json +21 -0
- package/registry/digital-product-layout.json +18 -0
- package/registry/food-product-layout.json +18 -0
- package/registry/index.json +88 -5
- package/registry/price-range.json +13 -0
- package/registry/product-customizer.json +1 -1
- package/registry/product-page.json +7 -6
- package/registry/service-product-layout.json +18 -0
- package/registry/volume-pricing.json +16 -0
- package/registry/wholesale-product-layout.json +20 -0
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "product-page",
|
|
3
3
|
"title": "ProductPage",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "Smart product page resolver with per-slug and per-type template routing.",
|
|
5
5
|
"type": "component",
|
|
6
6
|
"registryDependencies": [
|
|
7
|
-
"product-
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"product-
|
|
7
|
+
"default-product-layout",
|
|
8
|
+
"food-product-layout",
|
|
9
|
+
"wholesale-product-layout",
|
|
10
|
+
"service-product-layout",
|
|
11
|
+
"digital-product-layout",
|
|
11
12
|
"cn"
|
|
12
13
|
],
|
|
13
14
|
"files": [
|
|
14
15
|
{
|
|
15
16
|
"path": "product-page.tsx",
|
|
16
|
-
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { Product, ProductWithDetails } from \"@cimplify/sdk\";\nimport type {
|
|
17
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { Product, ProductWithDetails } from \"@cimplify/sdk\";\nimport type { ProductLayoutProps } from \"@cimplify/sdk/react\";\nimport { DefaultProductLayout } from \"@cimplify/sdk/react\";\nimport { FoodProductLayout } from \"@cimplify/sdk/react\";\nimport { WholesaleProductLayout } from \"@cimplify/sdk/react\";\nimport { ServiceProductLayout } from \"@cimplify/sdk/react\";\nimport { DigitalProductLayout } from \"@cimplify/sdk/react\";\nimport type { AddToCartOptions } from \"@cimplify/sdk/react\";\nimport { useProduct } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport type { ProductLayoutProps };\n\nexport enum ProductTemplate {\n Default = \"default\",\n Food = \"food\",\n Wholesale = \"wholesale\",\n Service = \"service\",\n Digital = \"digital\",\n Physical = \"physical\",\n}\n\nexport interface ProductPageClassNames {\n root?: string;\n loading?: string;\n}\n\nexport interface ProductPageProps {\n /** Product slug or ID — used for client-side fetch when `product` is not provided. */\n productId?: string;\n /** Pre-fetched product for SSR. Skips client-side fetch when provided. */\n product?: ProductWithDetails;\n /** Per-slug page map. Highest priority — maps a product slug to a custom component. */\n pages?: Record<string, React.ComponentType<ProductLayoutProps>>;\n /** Per-type template map. Overrides built-in layouts for specific template keys. */\n templates?: Partial<Record<ProductTemplate | string, React.ComponentType<ProductLayoutProps>>>;\n /** Override add-to-cart behavior. */\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Custom image renderer (e.g. Next.js Image). */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Breadcrumb renderer. Receives the product for context. */\n renderBreadcrumb?: (product: ProductWithDetails) => React.ReactNode;\n /** Pre-fetched related products. */\n relatedProducts?: Product[];\n /** Show related products section. Default: true. */\n showRelated?: boolean;\n className?: string;\n classNames?: ProductPageClassNames;\n}\n\nconst BUILT_IN_LAYOUTS: Record<string, React.ComponentType<ProductLayoutProps>> = {\n [ProductTemplate.Food]: FoodProductLayout,\n [ProductTemplate.Wholesale]: WholesaleProductLayout,\n [ProductTemplate.Service]: ServiceProductLayout,\n [ProductTemplate.Digital]: DigitalProductLayout,\n [ProductTemplate.Default]: DefaultProductLayout,\n};\n\nfunction resolveTemplateKey(product: ProductWithDetails): ProductTemplate | string {\n const metaTemplate = product.metadata?.page_template;\n if (typeof metaTemplate === \"string\" && metaTemplate.trim()) {\n return metaTemplate.trim();\n }\n\n if (product.quantity_pricing && product.quantity_pricing.length > 1) {\n return ProductTemplate.Wholesale;\n }\n\n if (product.type === \"service\") {\n return ProductTemplate.Service;\n }\n\n if (product.type === \"digital\") {\n return ProductTemplate.Digital;\n }\n\n if (product.render_hint === \"food\") {\n return ProductTemplate.Food;\n }\n\n if (product.render_hint === \"physical\") {\n return ProductTemplate.Physical;\n }\n\n return ProductTemplate.Default;\n}\n\nfunction resolveLayout(\n product: ProductWithDetails,\n pages?: Record<string, React.ComponentType<ProductLayoutProps>>,\n templates?: Partial<Record<string, React.ComponentType<ProductLayoutProps>>>,\n): React.ComponentType<ProductLayoutProps> {\n // 1. Per-slug page (AI-generated, highest priority)\n if (pages?.[product.slug]) {\n return pages[product.slug];\n }\n\n const key = resolveTemplateKey(product);\n\n // 2. Consumer-provided template override\n if (templates?.[key]) {\n return templates[key];\n }\n\n // 3. Built-in layout\n if (BUILT_IN_LAYOUTS[key]) {\n return BUILT_IN_LAYOUTS[key];\n }\n\n // 4. Fallback\n return DefaultProductLayout;\n}\n\nexport function ProductPage({\n productId,\n product: productProp,\n pages,\n templates,\n onAddToCart,\n renderImage,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n classNames,\n}: ProductPageProps): React.ReactElement {\n const resolvedId = productId || productProp?.slug || productProp?.id || \"\";\n const { product: fetched, isLoading } = useProduct(resolvedId, {\n enabled: !productProp && resolvedId.length > 0,\n });\n const product = productProp ?? fetched;\n\n if (isLoading && !product) {\n return (\n <div\n data-cimplify-product-page\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div className=\"grid lg:grid-cols-2 gap-8 lg:gap-12 animate-pulse\">\n <div className=\"aspect-square bg-muted\" />\n <div className=\"space-y-4 py-4\">\n <div className=\"h-8 w-3/5 bg-muted\" />\n <div className=\"h-6 w-2/5 bg-muted\" />\n <div className=\"h-4 w-4/5 bg-muted\" />\n <div className=\"h-4 w-3/5 bg-muted\" />\n <div className=\"h-14 w-full bg-muted mt-8\" />\n </div>\n </div>\n </div>\n );\n }\n\n if (!product) {\n return (\n <div data-cimplify-product-page className={cn(className, classNames?.root)}>\n <p className=\"text-muted-foreground\">Product not found.</p>\n </div>\n );\n }\n\n const Layout = resolveLayout(product, pages, templates);\n\n return (\n <div data-cimplify-product-page className={cn(className, classNames?.root)}>\n <Layout\n product={product}\n onAddToCart={onAddToCart}\n renderImage={renderImage}\n renderBreadcrumb={renderBreadcrumb}\n relatedProducts={relatedProducts}\n showRelated={showRelated}\n />\n </div>\n );\n}\n"
|
|
17
18
|
}
|
|
18
19
|
]
|
|
19
20
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "service-product-layout",
|
|
3
|
+
"title": "ServiceProductLayout",
|
|
4
|
+
"description": "Service booking layout with duration, deposit, staff requirements, and cancellation policy.",
|
|
5
|
+
"type": "component",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"product-image-gallery",
|
|
8
|
+
"product-customizer",
|
|
9
|
+
"price",
|
|
10
|
+
"cn"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "layouts/service-product-layout.tsx",
|
|
15
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { ProductLayoutProps } from \"@cimplify/sdk/react\";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n TagPills,\n} from \"@cimplify/sdk/react\";\nimport { ProductImageGallery } from \"@cimplify/sdk/react\";\nimport { ProductCustomizer } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nfunction formatMinutes(minutes: number): string {\n if (minutes >= 1440) return `${Math.floor(minutes / 1440)} day${minutes >= 2880 ? \"s\" : \"\"}`;\n if (minutes >= 60) {\n const h = Math.floor(minutes / 60);\n const m = minutes % 60;\n return m > 0 ? `${h}h ${m}min` : `${h} hour${h > 1 ? \"s\" : \"\"}`;\n }\n return `${minutes} min`;\n}\n\nexport function ServiceProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const hasDeposit = product.deposit_type && product.deposit_type !== \"none\" && product.deposit_amount;\n const depositAmount = hasDeposit ? parsePrice(product.deposit_amount!) : 0;\n const cancellationHours = product.cancellation_window_minutes\n ? Math.floor(product.cancellation_window_minutes / 60)\n : null;\n\n return (\n <div data-cimplify-product-layout=\"service\" className={cn(\"space-y-8\", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className=\"space-y-6\">\n {/* Tags */}\n <TagPills tags={product.tags || []} />\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className=\"text-3xl lg:text-4xl font-extrabold tracking-tight\">\n {product.name}\n </h1>\n <Price amount={product.default_price} className=\"text-2xl font-bold text-primary mt-2 block\" />\n </div>\n\n {/* Service info pills */}\n <div data-cimplify-product-layout-service-info className=\"flex flex-wrap gap-2\">\n {product.duration_minutes != null && (\n <span className=\"inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full\">\n {formatMinutes(product.duration_minutes)}\n </span>\n )}\n {hasDeposit && (\n <span className=\"inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full\">\n <Price amount={depositAmount} /> deposit\n </span>\n )}\n {product.general_service_capacity != null && product.general_service_capacity > 1 && (\n <span className=\"inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full\">\n Up to {product.general_service_capacity} people\n </span>\n )}\n {product.buffer_before_minutes != null && product.buffer_before_minutes > 0 && (\n <span className=\"inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full\">\n {product.buffer_before_minutes} min buffer\n </span>\n )}\n </div>\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className=\"text-muted-foreground leading-relaxed\">\n {product.description}\n </p>\n )}\n\n {/* Staff & Resource requirements */}\n {(product.requires_specific_staff || product.requires_specific_resource) && (\n <div data-cimplify-product-layout-requirements className=\"text-sm text-muted-foreground space-y-1\">\n {product.requires_specific_staff && (\n <p>Staff selection required</p>\n )}\n {product.requires_specific_resource && (\n <p>Resource selection required</p>\n )}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (variants, scheduling, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n showSpecialInstructions={false}\n />\n\n {/* Cancellation policy */}\n {(cancellationHours != null || product.no_show_fee) && (\n <div data-cimplify-product-layout-cancellation className=\"border border-border p-4 space-y-2 text-sm\">\n <h3 className=\"font-semibold\">Cancellation Policy</h3>\n {cancellationHours != null && (\n <p className=\"text-muted-foreground\">\n Free cancellation up to <strong className=\"text-foreground\">{cancellationHours} hours</strong> before the appointment\n </p>\n )}\n {product.no_show_fee && (\n <p className=\"text-muted-foreground\">\n No-show fee: <Price amount={product.no_show_fee} className=\"font-medium text-foreground\" />\n </p>\n )}\n {hasDeposit && (\n <p className=\"text-muted-foreground\">\n {product.deposit_type === \"fixed\"\n ? <><Price amount={depositAmount} className=\"font-medium text-foreground\" /> deposit charged at booking</>\n : <>{parsePrice(product.deposit_amount!)}% deposit charged at booking</>\n }\n </p>\n )}\n </div>\n )}\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title=\"Other services\"\n />\n )}\n </div>\n );\n}\n"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "volume-pricing",
|
|
3
|
+
"title": "VolumePricing",
|
|
4
|
+
"description": "Collapsible volume pricing tier table.",
|
|
5
|
+
"type": "component",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"price",
|
|
8
|
+
"cn"
|
|
9
|
+
],
|
|
10
|
+
"files": [
|
|
11
|
+
{
|
|
12
|
+
"path": "volume-pricing.tsx",
|
|
13
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { QuantityPricingTier } from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface VolumePricingClassNames {\n root?: string;\n trigger?: string;\n triggerIcon?: string;\n panel?: string;\n tier?: string;\n tierActive?: string;\n tierRange?: string;\n tierPrice?: string;\n}\n\nexport interface VolumePricingProps {\n tiers: QuantityPricingTier[];\n currentQuantity?: number;\n defaultOpen?: boolean;\n className?: string;\n classNames?: VolumePricingClassNames;\n}\n\nfunction formatRange(tier: QuantityPricingTier): string {\n if (tier.max_quantity != null) {\n return `${tier.min_quantity}–${tier.max_quantity} units`;\n }\n return `${tier.min_quantity}+ units`;\n}\n\nfunction isActiveTier(tier: QuantityPricingTier, quantity: number): boolean {\n if (quantity < tier.min_quantity) return false;\n if (tier.max_quantity != null && quantity > tier.max_quantity) return false;\n return true;\n}\n\nexport function VolumePricing({\n tiers,\n currentQuantity,\n defaultOpen = false,\n className,\n classNames,\n}: VolumePricingProps): React.ReactElement | null {\n if (tiers.length < 2) return null;\n\n const sorted = [...tiers].sort((a, b) => a.min_quantity - b.min_quantity);\n\n return (\n <details\n open={defaultOpen || undefined}\n data-cimplify-volume-pricing\n className={cn(\"border border-border\", className, classNames?.root)}\n >\n <summary\n data-cimplify-volume-pricing-trigger\n className={cn(\n \"flex items-center justify-between px-4 py-3 cursor-pointer select-none text-sm font-medium list-none [&::-webkit-details-marker]:hidden\",\n classNames?.trigger,\n )}\n >\n Volume pricing\n <svg\n viewBox=\"0 0 12 12\"\n fill=\"none\"\n aria-hidden=\"true\"\n className={cn(\n \"w-3.5 h-3.5 text-muted-foreground transition-transform [[open]>&]:rotate-180\",\n classNames?.triggerIcon,\n )}\n >\n <path d=\"M2 4.5l4 4 4-4\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n </summary>\n <div\n data-cimplify-volume-pricing-panel\n className={cn(\"border-t border-border divide-y divide-border\", classNames?.panel)}\n >\n {sorted.map((tier, i) => {\n const active = currentQuantity != null && isActiveTier(tier, currentQuantity);\n return (\n <div\n key={i}\n data-cimplify-volume-pricing-tier\n data-active={active || undefined}\n className={cn(\n \"flex items-center justify-between px-4 py-2.5 text-sm\",\n active\n ? cn(\"bg-primary/5 font-medium\", classNames?.tierActive)\n : classNames?.tier,\n )}\n >\n <span\n data-cimplify-volume-pricing-range\n className={cn(\"text-muted-foreground\", classNames?.tierRange)}\n >\n {formatRange(tier)}\n </span>\n <span data-cimplify-volume-pricing-price className={classNames?.tierPrice}>\n <Price amount={tier.unit_price} prefix=\"\" /> /ea\n </span>\n </div>\n );\n })}\n </div>\n </details>\n );\n}\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wholesale-product-layout",
|
|
3
|
+
"title": "WholesaleProductLayout",
|
|
4
|
+
"description": "B2B wholesale layout with price range, volume pricing, MOQ, and inventory.",
|
|
5
|
+
"type": "component",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"product-image-gallery",
|
|
8
|
+
"product-customizer",
|
|
9
|
+
"price",
|
|
10
|
+
"price-range",
|
|
11
|
+
"volume-pricing",
|
|
12
|
+
"cn"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "layouts/wholesale-product-layout.tsx",
|
|
17
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { ProductLayoutProps } from \"@cimplify/sdk/react\";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n PropertiesTable,\n TagPills,\n InventoryBadge,\n} from \"@cimplify/sdk/react\";\nimport { ProductImageGallery } from \"@cimplify/sdk/react\";\nimport { ProductCustomizer } from \"@cimplify/sdk/react\";\nimport { PriceRange } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { VolumePricing } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport function WholesaleProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const hasTiers = product.quantity_pricing && product.quantity_pricing.length > 1;\n\n return (\n <div data-cimplify-product-layout=\"wholesale\" className={cn(\"space-y-8\", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className=\"space-y-6\">\n {/* Tags */}\n <TagPills tags={product.tags || []} />\n\n {/* SKU + Name */}\n <div>\n {product.sku && (\n <p className=\"text-xs text-muted-foreground font-medium uppercase tracking-wider mb-1\">\n SKU: {product.sku}\n </p>\n )}\n <h1 data-cimplify-product-layout-name className=\"text-3xl lg:text-4xl font-extrabold tracking-tight\">\n {product.name}\n </h1>\n </div>\n\n {/* Price range */}\n <div>\n {hasTiers ? (\n <PriceRange product={product} className=\"text-2xl font-extrabold\" />\n ) : (\n <Price amount={product.default_price} className=\"text-2xl font-semibold\" />\n )}\n </div>\n\n {/* Inventory */}\n <InventoryBadge product={product} />\n\n {/* Min order notice */}\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <div data-cimplify-product-layout-moq className=\"flex items-center gap-3 p-3 bg-amber-50 border border-amber-200 text-sm text-amber-800\">\n Minimum order quantity: <strong>{product.min_order_quantity} units</strong>\n </div>\n )}\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className=\"text-muted-foreground leading-relaxed\">\n {product.description}\n </p>\n )}\n\n {/* Volume pricing — open by default for wholesale */}\n {hasTiers && (\n <VolumePricing\n tiers={product.quantity_pricing!}\n defaultOpen\n />\n )}\n\n {/* Product details */}\n {(product.sku || product.barcode || product.vendor) && (\n <div data-cimplify-product-layout-details className=\"space-y-1 text-sm text-muted-foreground\">\n {product.barcode && <p>Barcode: <span className=\"font-mono text-foreground\">{product.barcode}</span></p>}\n {product.vendor && <p>Supplier: <span className=\"text-foreground\">{product.vendor}</span></p>}\n {product.hs_code && <p>HS Code: <span className=\"font-mono text-foreground\">{product.hs_code}</span></p>}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Properties */}\n <PropertiesTable properties={product.properties || []} />\n\n {/* Customizer (variants, billing plans, quantity, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title=\"Related products\"\n />\n )}\n </div>\n );\n}\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|