@cimplify/sdk 0.100.0 → 0.100.2
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.d.mts +3 -3
- package/dist/advanced.d.ts +3 -3
- package/dist/advanced.js +22 -22
- package/dist/advanced.mjs +3 -3
- package/dist/{chunk-X6G6QERR.js → chunk-6JEKKL3M.js} +53 -38
- package/dist/{chunk-7TEMLVDT.mjs → chunk-B2YOCOLH.mjs} +2 -2
- package/dist/{chunk-V5TU5JKA.mjs → chunk-DLW3RWRK.mjs} +30 -6
- package/dist/{chunk-FECGCIVV.js → chunk-IFAQLFAJ.js} +4 -4
- package/dist/{chunk-J3UYIBKH.js → chunk-IJI7KMTG.js} +89 -82
- package/dist/{chunk-AETVRBKI.js → chunk-NIOUSKQQ.js} +1 -0
- package/dist/{chunk-K5EN2W2Q.mjs → chunk-O3YUHLZL.mjs} +1 -0
- package/dist/{chunk-GWKIDFN6.mjs → chunk-PJUXAUM2.mjs} +20 -14
- package/dist/{chunk-ZA5VKVUB.js → chunk-QRBBSIAY.js} +4 -4
- package/dist/{chunk-OX5L6XE7.mjs → chunk-TIQLOH2B.mjs} +30 -15
- package/dist/{chunk-64EDCDBR.js → chunk-WMEC5JWN.js} +32 -7
- package/dist/{chunk-I7L4VRHI.mjs → chunk-YWNJJO2U.mjs} +2 -2
- package/dist/{client-DQpvwf-p.d.mts → client-C6wb92AD.d.mts} +2 -2
- package/dist/{client-DmR3Z6pQ.d.ts → client-CgXkRu2o.d.ts} +2 -2
- package/dist/{client-C1vS5pf0.d.ts → client-D8XcrONA.d.mts} +38 -26
- package/dist/{client-B5MeD8da.d.mts → client-LPg_AUUr.d.ts} +38 -26
- package/dist/{index-Br-vFygC.d.ts → index-B_RYmvlF.d.ts} +3 -3
- package/dist/{index-DUOCn1Cs.d.mts → index-D-LUYk1Y.d.mts} +3 -3
- package/dist/index.d.mts +7 -7
- package/dist/index.d.ts +7 -7
- package/dist/index.js +117 -117
- package/dist/index.mjs +5 -5
- package/dist/mock/cli.mjs +1 -1
- package/dist/mock/library.js +1 -1
- package/dist/mock/library.mjs +1 -1
- package/dist/mock/msw.js +1 -1
- package/dist/mock/msw.mjs +1 -1
- package/dist/{payment-BIpS9oPG.d.mts → payment-BSzy7sly.d.mts} +1 -1
- package/dist/{payment-CsuBKHqg.d.ts → payment-jK8ztICx.d.ts} +1 -1
- package/dist/{price-Ml_uPoiY.d.ts → price-CJLfcYKd.d.ts} +1 -1
- package/dist/{price-IOOoVYS-.d.mts → price-D96h6c6L.d.mts} +1 -1
- package/dist/{product-BKV6TBj_.d.ts → product-CeV8euw4.d.mts} +23 -11
- package/dist/{product-BKV6TBj_.d.mts → product-CeV8euw4.d.ts} +23 -11
- package/dist/react.d.mts +10 -5
- package/dist/react.d.ts +10 -5
- package/dist/react.js +856 -450
- package/dist/react.mjs +737 -331
- package/dist/{server-C0iEk55_.d.mts → server--RyVAfPN.d.mts} +1 -1
- package/dist/{server-CfpYhSV_.d.ts → server-CNyy47HL.d.ts} +1 -1
- package/dist/server.d.mts +4 -4
- package/dist/server.d.ts +4 -4
- package/dist/server.js +10 -10
- package/dist/server.mjs +4 -4
- package/dist/testing/msw.d.mts +2 -2
- package/dist/testing/msw.d.ts +2 -2
- package/dist/testing/msw.js +3 -3
- package/dist/testing/msw.mjs +2 -2
- package/dist/testing/suite.d.mts +6 -6
- package/dist/testing/suite.d.ts +6 -6
- package/dist/testing/suite.js +25 -25
- package/dist/testing/suite.mjs +6 -6
- package/dist/testing.d.mts +5 -5
- package/dist/testing.d.ts +5 -5
- package/dist/testing.js +81 -81
- package/dist/testing.mjs +7 -7
- package/dist/utils.d.mts +4 -4
- package/dist/utils.d.ts +4 -4
- package/dist/utils.js +29 -29
- package/dist/utils.mjs +2 -2
- package/package.json +1 -1
- package/registry/cart-summary.json +1 -1
- package/registry/product-customizer.json +1 -1
- package/registry/sale-badge.json +1 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
{
|
|
12
12
|
"path": "cart-summary.tsx",
|
|
13
|
-
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { UseCartItem } from \"@cimplify/sdk/react\";\nimport type { DisplayCart } from \"@cimplify/sdk\";\nimport { useCart } from \"@cimplify/sdk/react\";\nimport { getVariantDisplayName } from \"@cimplify/sdk\";\nimport { INPUT_FIELD_TYPE } from \"@cimplify/sdk\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { QuantitySelector } from \"@cimplify/sdk/react\";\nimport { useProductPrice } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface CartSummaryClassNames {\n root?: string;\n item?: string;\n itemName?: string;\n itemPrice?: string;\n quantity?: string;\n removeButton?: string;\n totals?: string;\n subtotal?: string;\n tax?: string;\n deliveryFee?: string;\n serviceCharge?: string;\n discount?: string;\n total?: string;\n checkout?: string;\n empty?: string;\n serviceInfo?: string;\n digitalInfo?: string;\n bundleBreakdown?: string;\n compositeBreakdown?: string;\n customerInputs?: string;\n}\n\nexport interface CartSummaryProps {\n /** Optional server cart for extended pricing details (delivery fee, service charge, discounts). */\n cart?: DisplayCart;\n onCheckout?: () => void;\n onItemRemove?: (itemId: string) => void;\n onQuantityChange?: (itemId: string, quantity: number) => void;\n emptyMessage?: string;\n /** Render the totals block (subtotal/tax/total) below the items. Default true. Hosts that show their own totals (drawer, page sidebar) should pass false. */\n showTotals?: boolean;\n /** Render the \"Proceed to Checkout\" button. Defaults to true if `onCheckout` is provided. */\n showCheckoutButton?: boolean;\n className?: string;\n classNames?: CartSummaryClassNames;\n}\n\nfunction CartLineItemRow({\n item,\n onRemove,\n onQuantityChange,\n classNames,\n}: {\n item: UseCartItem;\n onRemove: (itemId: string) => void;\n onQuantityChange: (itemId: string, qty: number) => void;\n classNames?: CartSummaryClassNames;\n}): React.ReactElement {\n const { unitPrice } = useProductPrice({\n product: item.product,\n variant: item.variant,\n addOnOptions: item.addOnOptions,\n });\n const hasComposite = item.compositeSelections && item.compositeSelections.length > 0;\n const hasBundle = item.bundleSelections && item.bundleSelections.length > 0;\n\n const isOptimistic = (item as { isOptimistic?: boolean }).isOptimistic === true;\n\n const lineTotal = parsePrice(unitPrice) * item.quantity;\n const imageUrl = item.product.image_url;\n const variantLabel = item.variant ? getVariantDisplayName(item.variant, item.product.name) : null;\n\n return (\n <div\n data-cimplify-cart-item\n data-optimistic={isOptimistic ? \"true\" : undefined}\n className={cn(\n \"group relative flex gap-3 py-4 border-b border-border last:border-b-0 transition-opacity\",\n isOptimistic && \"opacity-60\",\n classNames?.item,\n )}\n >\n {/* Thumbnail */}\n <div\n data-cimplify-cart-item-image\n className=\"relative w-20 h-20 shrink-0 rounded-md overflow-hidden bg-muted\"\n >\n {imageUrl ? (\n <img\n src={imageUrl}\n alt=\"\"\n className=\"w-full h-full object-cover\"\n loading=\"lazy\"\n />\n ) : (\n <div className=\"w-full h-full grid place-items-center text-muted-foreground/40\">\n <svg className=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" viewBox=\"0 0 24 24\" aria-hidden>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z\" />\n </svg>\n </div>\n )}\n </div>\n\n {/* Info column */}\n <div data-cimplify-cart-item-info className=\"flex-1 min-w-0 flex flex-col\">\n <div className=\"flex items-start justify-between gap-3\">\n <h3\n data-cimplify-cart-item-name\n className={cn(\"text-sm font-medium leading-tight m-0 truncate\", classNames?.itemName)}\n >\n {item.product.name}\n </h3>\n <button\n type=\"button\"\n onClick={() => onRemove(item.id)}\n data-cimplify-cart-item-remove\n className={cn(\n \"shrink-0 -mr-1 -mt-1 grid place-items-center w-7 h-7 rounded-full text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors\",\n classNames?.removeButton,\n )}\n aria-label={`Remove ${item.product.name}`}\n >\n <svg className=\"w-3.5 h-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" viewBox=\"0 0 24 24\" aria-hidden>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\n </svg>\n </button>\n </div>\n\n {/* Pills row: variant, add-ons, badges */}\n {(variantLabel || (item.addOnOptions && item.addOnOptions.length > 0) || hasComposite || hasBundle) && (\n <div data-cimplify-cart-item-pills className=\"flex flex-wrap gap-1.5 mt-1.5\">\n {variantLabel && (\n <span\n data-cimplify-cart-item-variant\n className=\"inline-flex items-center px-2 py-0.5 rounded-full bg-muted text-[11px] font-medium text-foreground\"\n >\n {variantLabel}\n </span>\n )}\n {item.addOnOptions?.map((opt) => (\n <span\n key={opt.id}\n data-cimplify-cart-item-addon\n className=\"inline-flex items-center px-2 py-0.5 rounded-full bg-muted text-[11px] font-medium text-muted-foreground\"\n >\n + {opt.name}\n </span>\n ))}\n {(hasComposite || hasBundle) && (\n <span\n data-cimplify-cart-item-badge\n className=\"inline-flex items-center px-2 py-0.5 rounded-full bg-primary/10 text-[11px] font-semibold uppercase tracking-wider text-primary\"\n >\n {hasComposite ? \"Custom\" : \"Bundle\"}\n </span>\n )}\n </div>\n )}\n\n {/* Special instructions */}\n {item.specialInstructions && (\n <p\n data-cimplify-cart-item-instructions\n className=\"mt-1.5 text-xs italic text-muted-foreground line-clamp-2\"\n >\n \\u201C{item.specialInstructions}\\u201D\n </p>\n )}\n\n {/* Customer input values */}\n {item.customerInputs && item.customerInputs.length > 0 && (\n <dl data-cimplify-cart-customer-inputs className={cn(\"mt-1.5 text-xs space-y-0.5\", classNames?.customerInputs)}>\n {item.customerInputs.map((input) => (\n <div key={input.field_id} data-cimplify-cart-input className=\"flex gap-1.5\">\n <dt data-cimplify-cart-input-label className=\"text-muted-foreground\">{input.field_name}:</dt>\n <dd data-cimplify-cart-input-value className=\"text-foreground m-0 truncate\">\n {input.field_type === INPUT_FIELD_TYPE.File || input.field_type === INPUT_FIELD_TYPE.Image\n ? String(input.value).split(\"/\").pop() || \"Uploaded file\"\n : String(input.value)}\n </dd>\n </div>\n ))}\n </dl>\n )}\n\n {/* Service: scheduled details */}\n {(item.lineType === \"service\" || item.product.type === \"service\") && item.scheduledStart && (\n <div data-cimplify-cart-service-info className={cn(\"mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground\", classNames?.serviceInfo)}>\n <svg className=\"w-3.5 h-3.5 shrink-0\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" viewBox=\"0 0 24 24\" aria-hidden>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\" />\n </svg>\n <span>\n {new Date(item.scheduledStart).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" })} at{\" \"}\n {new Date(item.scheduledStart).toLocaleTimeString([], { hour: \"2-digit\", minute: \"2-digit\" })}\n </span>\n {item.serviceStatus && (\n <span data-status={item.serviceStatus} className=\"ml-auto px-1.5 py-0.5 rounded-full bg-muted text-[10px] uppercase tracking-wider\">\n {item.serviceStatus}\n </span>\n )}\n </div>\n )}\n\n {/* Digital: instant-delivery badge */}\n {(item.lineType === \"digital\" || item.product.type === \"digital\") && (\n <div data-cimplify-cart-digital-info className={cn(\"mt-1.5 flex items-center gap-1 text-xs text-muted-foreground\", classNames?.digitalInfo)}>\n <svg className=\"w-3.5 h-3.5 shrink-0\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" viewBox=\"0 0 24 24\" aria-hidden>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M13 10V3L4 14h7v7l9-11h-7z\" />\n </svg>\n <span>Instant digital delivery</span>\n </div>\n )}\n\n {/* Bundle / composite component breakdown */}\n {(item.bundleResolved || item.compositeResolved) && (\n <ul\n data-cimplify-cart-composite-breakdown={item.compositeResolved ? \"\" : undefined}\n data-cimplify-cart-bundle-breakdown={item.bundleResolved ? \"\" : undefined}\n className={cn(\"mt-1.5 pl-3 border-l border-border space-y-0.5 text-xs text-muted-foreground\", item.compositeResolved ? classNames?.compositeBreakdown : classNames?.bundleBreakdown)}\n >\n {((item.bundleResolved?.selections ?? item.compositeResolved?.selections ?? []) as Array<{\n component_id?: string;\n product_name?: string;\n component_name?: string;\n quantity: number;\n product_type?: string;\n }>).map((sel) => {\n const name = sel.product_name ?? sel.component_name ?? \"\";\n const compId = sel.component_id ?? `${name}-${sel.quantity}`;\n return (\n <li key={compId} data-product-type={sel.product_type} className=\"flex items-baseline gap-1.5 m-0\">\n <span className=\"font-medium text-foreground/80\">{sel.quantity}\\u00D7</span>\n <span className=\"truncate\">{name}</span>\n </li>\n );\n })}\n </ul>\n )}\n\n {/* Bottom row: line price + qty stepper */}\n <div className=\"mt-3 flex items-center justify-between gap-3\">\n <Price\n amount={lineTotal}\n className={cn(\"text-sm font-semibold tabular-nums\", classNames?.itemPrice)}\n />\n <div data-cimplify-cart-item-controls className={classNames?.quantity}>\n <QuantitySelector\n value={item.quantity}\n onChange={(qty) => onQuantityChange(item.id, qty)}\n min={0}\n />\n </div>\n </div>\n </div>\n </div>\n );\n}\n\n/**\n * CartSummary — renders cart line items + totals.\n *\n * NOT a drawer or modal — just the cart content. Templates wrap this in\n * their own drawer/modal shell with animations.\n */\nexport function CartSummary({\n cart: serverCart,\n onCheckout,\n onItemRemove,\n onQuantityChange,\n emptyMessage = \"Your cart is empty\",\n showTotals = true,\n showCheckoutButton,\n className,\n classNames,\n}: CartSummaryProps): React.ReactElement {\n const {\n items,\n itemCount,\n subtotal,\n tax,\n total,\n isEmpty,\n isOptimistic,\n pendingOpCount,\n removeItem,\n updateQuantity,\n } = useCart();\n\n const deliveryFee = serverCart?.delivery_fee;\n const serviceCharge = serverCart?.service_charge;\n const totalDiscounts = serverCart?.total_discounts;\n\n const handleRemove = (itemId: string) => {\n if (onItemRemove) {\n onItemRemove(itemId);\n } else {\n void removeItem(itemId);\n }\n };\n\n const handleQuantityChange = (itemId: string, qty: number) => {\n if (onQuantityChange) {\n onQuantityChange(itemId, qty);\n } else {\n void updateQuantity(itemId, qty);\n }\n };\n\n return (\n <div\n data-cimplify-cart-summary\n data-optimistic={isOptimistic ? \"true\" : undefined}\n data-pending-ops={pendingOpCount > 0 ? pendingOpCount : undefined}\n className={cn(className, classNames?.root)}\n >\n {isEmpty ? (\n <div data-cimplify-cart-empty className={classNames?.empty}>\n <p>{emptyMessage}</p>\n </div>\n ) : (\n <>\n {/* Line items */}\n <div data-cimplify-cart-items>\n {items.map((item) => (\n <CartLineItemRow\n key={item.id}\n item={item}\n onRemove={handleRemove}\n onQuantityChange={handleQuantityChange}\n classNames={classNames}\n />\n ))}\n </div>\n\n {/* Totals */}\n {showTotals && (\n <div\n data-cimplify-cart-totals\n className={cn(\"mt-6 pt-4 border-t border-border space-y-2 text-sm\", classNames?.totals)}\n >\n <div data-cimplify-cart-subtotal className={cn(\"flex items-baseline justify-between\", classNames?.subtotal)}>\n <span className=\"text-muted-foreground\">\n Subtotal ({itemCount} {itemCount === 1 ? \"item\" : \"items\"})\n </span>\n <Price amount={subtotal} className=\"tabular-nums\" />\n </div>\n\n {totalDiscounts != null && parsePrice(totalDiscounts) > 0 && (\n <div data-cimplify-cart-discount className={cn(\"flex items-baseline justify-between\", classNames?.discount)}>\n <span className=\"text-muted-foreground\">Discount</span>\n <Price amount={totalDiscounts} prefix=\"-\" className=\"tabular-nums text-primary\" />\n </div>\n )}\n\n {deliveryFee != null && (\n <div data-cimplify-cart-delivery-fee className={cn(\"flex items-baseline justify-between\", classNames?.deliveryFee)}>\n <span className=\"text-muted-foreground\">Delivery</span>\n {parsePrice(deliveryFee) > 0 ? (\n <Price amount={deliveryFee} className=\"tabular-nums\" />\n ) : (\n <span className=\"font-medium text-primary\">Free</span>\n )}\n </div>\n )}\n\n {serviceCharge != null && parsePrice(serviceCharge) > 0 && (\n <div data-cimplify-cart-service-charge className={cn(\"flex items-baseline justify-between\", classNames?.serviceCharge)}>\n <span className=\"text-muted-foreground\">Service charge</span>\n <Price amount={serviceCharge} className=\"tabular-nums\" />\n </div>\n )}\n\n <div data-cimplify-cart-tax className={cn(\"flex items-baseline justify-between\", classNames?.tax)}>\n <span className=\"text-muted-foreground\">Tax</span>\n <Price amount={tax} className=\"tabular-nums\" />\n </div>\n\n <div data-cimplify-cart-total className={cn(\"flex items-baseline justify-between pt-3 mt-1 border-t border-border\", classNames?.total)}>\n <span className=\"font-semibold\">Total</span>\n <Price amount={total} className=\"text-base font-bold tabular-nums\" />\n </div>\n </div>\n )}\n\n {(showCheckoutButton ?? Boolean(onCheckout)) && onCheckout && (\n <button\n type=\"button\"\n onClick={onCheckout}\n data-cimplify-cart-checkout\n className={cn(\n \"mt-4 w-full h-12 rounded-full bg-foreground text-background font-semibold text-sm hover:bg-foreground/90 active:scale-[0.99] transition-all\",\n classNames?.checkout,\n )}\n >\n Proceed to Checkout\n </button>\n )}\n </>\n )}\n </div>\n );\n}\n"
|
|
13
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { UseCartItem } from \"@cimplify/sdk/react\";\nimport type { DisplayCart } from \"@cimplify/sdk\";\nimport { useCart } from \"@cimplify/sdk/react\";\nimport { getVariantDisplayName } from \"@cimplify/sdk\";\nimport { INPUT_FIELD_TYPE } from \"@cimplify/sdk\";\nimport { currencyCode, type CurrencyCode } from \"@cimplify/sdk\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { QuantitySelector } from \"@cimplify/sdk/react\";\nimport { useProductPrice } from \"@cimplify/sdk/react\";\nimport type { InternalUseCartItem } from \"./hooks/use-cart-types\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface CartSummaryClassNames {\n root?: string;\n item?: string;\n itemName?: string;\n itemPrice?: string;\n quantity?: string;\n removeButton?: string;\n totals?: string;\n subtotal?: string;\n tax?: string;\n deliveryFee?: string;\n serviceCharge?: string;\n discount?: string;\n total?: string;\n checkout?: string;\n empty?: string;\n serviceInfo?: string;\n digitalInfo?: string;\n bundleBreakdown?: string;\n compositeBreakdown?: string;\n customerInputs?: string;\n}\n\nexport interface CartSummaryProps {\n /** Optional server cart for extended pricing details (delivery fee, service charge, discounts). */\n cart?: DisplayCart;\n onCheckout?: () => void;\n onItemRemove?: (itemId: string) => void;\n onQuantityChange?: (itemId: string, quantity: number) => void;\n emptyMessage?: string;\n /** Render the totals block (subtotal/tax/total) below the items. Default true. Hosts that show their own totals (drawer, page sidebar) should pass false. */\n showTotals?: boolean;\n /** Render the \"Proceed to Checkout\" button. Defaults to true if `onCheckout` is provided. */\n showCheckoutButton?: boolean;\n className?: string;\n classNames?: CartSummaryClassNames;\n}\n\nfunction CartLineItemRow({\n item,\n onRemove,\n onQuantityChange,\n currency,\n classNames,\n}: {\n item: UseCartItem;\n onRemove: (itemId: string) => void;\n onQuantityChange: (itemId: string, qty: number) => void;\n currency: CurrencyCode;\n classNames?: CartSummaryClassNames;\n}): React.ReactElement {\n const { unitPrice: locallyResolvedUnitPrice } = useProductPrice({\n product: item.product,\n variant: item.variant,\n addOnOptions: item.addOnOptions,\n });\n const pinnedUnitPrice = (item as InternalUseCartItem)._quotedUnitPrice;\n const unitPrice =\n pinnedUnitPrice === undefined ? locallyResolvedUnitPrice : parsePrice(pinnedUnitPrice);\n const hasComposite = item.compositeSelections && item.compositeSelections.length > 0;\n const hasBundle = item.bundleSelections && item.bundleSelections.length > 0;\n\n const isOptimistic = (item as { isOptimistic?: boolean }).isOptimistic === true;\n\n const lineTotal = parsePrice(unitPrice) * item.quantity;\n const imageUrl = item.product.image_url;\n const variantLabel = item.variant ? getVariantDisplayName(item.variant, item.product.name) : null;\n\n return (\n <div\n data-cimplify-cart-item\n data-optimistic={isOptimistic ? \"true\" : undefined}\n className={cn(\n \"group relative flex gap-3 py-4 border-b border-border last:border-b-0 transition-opacity\",\n isOptimistic && \"opacity-60\",\n classNames?.item,\n )}\n >\n {/* Thumbnail */}\n <div\n data-cimplify-cart-item-image\n className=\"relative w-20 h-20 shrink-0 rounded-md overflow-hidden bg-muted\"\n >\n {imageUrl ? (\n <img src={imageUrl} alt=\"\" className=\"w-full h-full object-cover\" loading=\"lazy\" />\n ) : (\n <div className=\"w-full h-full grid place-items-center text-muted-foreground/40\">\n <svg\n className=\"w-6 h-6\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n viewBox=\"0 0 24 24\"\n aria-hidden\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n d=\"M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z\"\n />\n </svg>\n </div>\n )}\n </div>\n\n {/* Info column */}\n <div data-cimplify-cart-item-info className=\"flex-1 min-w-0 flex flex-col\">\n <div className=\"flex items-start justify-between gap-3\">\n <h3\n data-cimplify-cart-item-name\n className={cn(\"text-sm font-medium leading-tight m-0 truncate\", classNames?.itemName)}\n >\n {item.product.name}\n </h3>\n <button\n type=\"button\"\n onClick={() => onRemove(item.id)}\n data-cimplify-cart-item-remove\n className={cn(\n \"shrink-0 -mr-1 -mt-1 grid place-items-center w-7 h-7 rounded-full text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors\",\n classNames?.removeButton,\n )}\n aria-label={`Remove ${item.product.name}`}\n >\n <svg\n className=\"w-3.5 h-3.5\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n aria-hidden\n >\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\n </svg>\n </button>\n </div>\n\n {/* Pills row: variant, add-ons, badges */}\n {(variantLabel ||\n (item.addOnOptions && item.addOnOptions.length > 0) ||\n hasComposite ||\n hasBundle) && (\n <div data-cimplify-cart-item-pills className=\"flex flex-wrap gap-1.5 mt-1.5\">\n {variantLabel && (\n <span\n data-cimplify-cart-item-variant\n className=\"inline-flex items-center px-2 py-0.5 rounded-full bg-muted text-[11px] font-medium text-foreground\"\n >\n {variantLabel}\n </span>\n )}\n {item.addOnOptions?.map((opt) => (\n <span\n key={opt.id}\n data-cimplify-cart-item-addon\n className=\"inline-flex items-center px-2 py-0.5 rounded-full bg-muted text-[11px] font-medium text-muted-foreground\"\n >\n + {opt.name}\n </span>\n ))}\n {(hasComposite || hasBundle) && (\n <span\n data-cimplify-cart-item-badge\n className=\"inline-flex items-center px-2 py-0.5 rounded-full bg-primary/10 text-[11px] font-semibold uppercase tracking-wider text-primary\"\n >\n {hasComposite ? \"Custom\" : \"Bundle\"}\n </span>\n )}\n </div>\n )}\n\n {/* Special instructions */}\n {item.specialInstructions && (\n <p\n data-cimplify-cart-item-instructions\n className=\"mt-1.5 text-xs italic text-muted-foreground line-clamp-2\"\n >\n \\u201C{item.specialInstructions}\\u201D\n </p>\n )}\n\n {/* Customer input values */}\n {item.customerInputs && item.customerInputs.length > 0 && (\n <dl\n data-cimplify-cart-customer-inputs\n className={cn(\"mt-1.5 text-xs space-y-0.5\", classNames?.customerInputs)}\n >\n {item.customerInputs.map((input) => (\n <div key={input.field_id} data-cimplify-cart-input className=\"flex gap-1.5\">\n <dt data-cimplify-cart-input-label className=\"text-muted-foreground\">\n {input.field_name}:\n </dt>\n <dd data-cimplify-cart-input-value className=\"text-foreground m-0 truncate\">\n {input.field_type === INPUT_FIELD_TYPE.File ||\n input.field_type === INPUT_FIELD_TYPE.Image\n ? String(input.value).split(\"/\").pop() || \"Uploaded file\"\n : String(input.value)}\n </dd>\n </div>\n ))}\n </dl>\n )}\n\n {/* Service: scheduled details */}\n {(item.lineType === \"service\" || item.product.type === \"service\") &&\n item.scheduledStart && (\n <div\n data-cimplify-cart-service-info\n className={cn(\n \"mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground\",\n classNames?.serviceInfo,\n )}\n >\n <svg\n className=\"w-3.5 h-3.5 shrink-0\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n aria-hidden\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n d=\"M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\"\n />\n </svg>\n <span>\n {new Date(item.scheduledStart).toLocaleDateString(undefined, {\n month: \"short\",\n day: \"numeric\",\n })}{\" \"}\n at{\" \"}\n {new Date(item.scheduledStart).toLocaleTimeString([], {\n hour: \"2-digit\",\n minute: \"2-digit\",\n })}\n </span>\n {item.serviceStatus && (\n <span\n data-status={item.serviceStatus}\n className=\"ml-auto px-1.5 py-0.5 rounded-full bg-muted text-[10px] uppercase tracking-wider\"\n >\n {item.serviceStatus}\n </span>\n )}\n </div>\n )}\n\n {/* Digital: instant-delivery badge */}\n {(item.lineType === \"digital\" || item.product.type === \"digital\") && (\n <div\n data-cimplify-cart-digital-info\n className={cn(\n \"mt-1.5 flex items-center gap-1 text-xs text-muted-foreground\",\n classNames?.digitalInfo,\n )}\n >\n <svg\n className=\"w-3.5 h-3.5 shrink-0\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n viewBox=\"0 0 24 24\"\n aria-hidden\n >\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M13 10V3L4 14h7v7l9-11h-7z\" />\n </svg>\n <span>Instant digital delivery</span>\n </div>\n )}\n\n {/* Bundle / composite component breakdown */}\n {(item.bundleResolved || item.compositeResolved) && (\n <ul\n data-cimplify-cart-composite-breakdown={item.compositeResolved ? \"\" : undefined}\n data-cimplify-cart-bundle-breakdown={item.bundleResolved ? \"\" : undefined}\n className={cn(\n \"mt-1.5 pl-3 border-l border-border space-y-0.5 text-xs text-muted-foreground\",\n item.compositeResolved ? classNames?.compositeBreakdown : classNames?.bundleBreakdown,\n )}\n >\n {(\n (item.bundleResolved?.selections ??\n item.compositeResolved?.selections ??\n []) as Array<{\n component_id?: string;\n product_name?: string;\n component_name?: string;\n quantity: number;\n product_type?: string;\n }>\n ).map((sel) => {\n const name = sel.product_name ?? sel.component_name ?? \"\";\n const compId = sel.component_id ?? `${name}-${sel.quantity}`;\n return (\n <li\n key={compId}\n data-product-type={sel.product_type}\n className=\"flex items-baseline gap-1.5 m-0\"\n >\n <span className=\"font-medium text-foreground/80\">{sel.quantity}\\u00D7</span>\n <span className=\"truncate\">{name}</span>\n </li>\n );\n })}\n </ul>\n )}\n\n {/* Bottom row: line price + qty stepper */}\n <div className=\"mt-3 flex items-center justify-between gap-3\">\n <Price\n amount={lineTotal}\n currency={currency}\n className={cn(\"text-sm font-semibold tabular-nums\", classNames?.itemPrice)}\n />\n <div data-cimplify-cart-item-controls className={classNames?.quantity}>\n <QuantitySelector\n value={item.quantity}\n onChange={(qty) => onQuantityChange(item.id, qty)}\n min={0}\n />\n </div>\n </div>\n </div>\n </div>\n );\n}\n\n/**\n * CartSummary — renders cart line items + totals.\n *\n * NOT a drawer or modal — just the cart content. Templates wrap this in\n * their own drawer/modal shell with animations.\n */\nexport function CartSummary({\n cart: serverCart,\n onCheckout,\n onItemRemove,\n onQuantityChange,\n emptyMessage = \"Your cart is empty\",\n showTotals = true,\n showCheckoutButton,\n className,\n classNames,\n}: CartSummaryProps): React.ReactElement {\n const {\n items,\n itemCount,\n subtotal,\n tax,\n total,\n isEmpty,\n isOptimistic,\n pendingOpCount,\n currency,\n removeItem,\n updateQuantity,\n } = useCart();\n const displayCurrency = serverCart?.currency ?? currencyCode(currency);\n\n const deliveryFee = serverCart?.delivery_fee;\n const serviceCharge = serverCart?.service_charge;\n const totalDiscounts = serverCart?.total_discounts;\n\n const handleRemove = (itemId: string) => {\n if (onItemRemove) {\n onItemRemove(itemId);\n } else {\n void removeItem(itemId);\n }\n };\n\n const handleQuantityChange = (itemId: string, qty: number) => {\n if (onQuantityChange) {\n onQuantityChange(itemId, qty);\n } else {\n void updateQuantity(itemId, qty);\n }\n };\n\n return (\n <div\n data-cimplify-cart-summary\n data-optimistic={isOptimistic ? \"true\" : undefined}\n data-pending-ops={pendingOpCount > 0 ? pendingOpCount : undefined}\n className={cn(className, classNames?.root)}\n >\n {isEmpty ? (\n <div data-cimplify-cart-empty className={classNames?.empty}>\n <p>{emptyMessage}</p>\n </div>\n ) : (\n <>\n {/* Line items */}\n <div data-cimplify-cart-items>\n {items.map((item) => (\n <CartLineItemRow\n key={item.id}\n item={item}\n onRemove={handleRemove}\n onQuantityChange={handleQuantityChange}\n currency={displayCurrency}\n classNames={classNames}\n />\n ))}\n </div>\n\n {/* Totals */}\n {showTotals && (\n <div\n data-cimplify-cart-totals\n className={cn(\n \"mt-6 pt-4 border-t border-border space-y-2 text-sm\",\n classNames?.totals,\n )}\n >\n <div\n data-cimplify-cart-subtotal\n className={cn(\"flex items-baseline justify-between\", classNames?.subtotal)}\n >\n <span className=\"text-muted-foreground\">\n Subtotal ({itemCount} {itemCount === 1 ? \"item\" : \"items\"})\n </span>\n <Price amount={subtotal} currency={displayCurrency} className=\"tabular-nums\" />\n </div>\n\n {totalDiscounts != null && parsePrice(totalDiscounts) > 0 && (\n <div\n data-cimplify-cart-discount\n className={cn(\"flex items-baseline justify-between\", classNames?.discount)}\n >\n <span className=\"text-muted-foreground\">Discount</span>\n <Price\n amount={totalDiscounts}\n currency={displayCurrency}\n prefix=\"-\"\n className=\"tabular-nums text-primary\"\n />\n </div>\n )}\n\n {deliveryFee != null && (\n <div\n data-cimplify-cart-delivery-fee\n className={cn(\"flex items-baseline justify-between\", classNames?.deliveryFee)}\n >\n <span className=\"text-muted-foreground\">Delivery</span>\n {parsePrice(deliveryFee) > 0 ? (\n <Price\n amount={deliveryFee}\n currency={displayCurrency}\n className=\"tabular-nums\"\n />\n ) : (\n <span className=\"font-medium text-primary\">Free</span>\n )}\n </div>\n )}\n\n {serviceCharge != null && parsePrice(serviceCharge) > 0 && (\n <div\n data-cimplify-cart-service-charge\n className={cn(\"flex items-baseline justify-between\", classNames?.serviceCharge)}\n >\n <span className=\"text-muted-foreground\">Service charge</span>\n <Price\n amount={serviceCharge}\n currency={displayCurrency}\n className=\"tabular-nums\"\n />\n </div>\n )}\n\n <div\n data-cimplify-cart-tax\n className={cn(\"flex items-baseline justify-between\", classNames?.tax)}\n >\n <span className=\"text-muted-foreground\">Tax</span>\n <Price amount={tax} currency={displayCurrency} className=\"tabular-nums\" />\n </div>\n\n <div\n data-cimplify-cart-total\n className={cn(\n \"flex items-baseline justify-between pt-3 mt-1 border-t border-border\",\n classNames?.total,\n )}\n >\n <span className=\"font-semibold\">Total</span>\n <Price\n amount={total}\n currency={displayCurrency}\n className=\"text-base font-bold tabular-nums\"\n />\n </div>\n </div>\n )}\n\n {(showCheckoutButton ?? Boolean(onCheckout)) && onCheckout && (\n <button\n type=\"button\"\n onClick={onCheckout}\n data-cimplify-cart-checkout\n className={cn(\n \"mt-4 w-full h-12 rounded-full bg-foreground text-background font-semibold text-sm hover:bg-foreground/90 active:scale-[0.99] transition-all\",\n classNames?.checkout,\n )}\n >\n Proceed to Checkout\n </button>\n )}\n </>\n )}\n </div>\n );\n}\n"
|
|
14
14
|
}
|
|
15
15
|
]
|
|
16
16
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
{
|
|
16
16
|
"path": "product-customizer.tsx",
|
|
17
|
-
"content": "\"use client\";\n\nimport React, { useState, useCallback, useMemo, useEffect, useRef } from \"react\";\nimport { Button } from \"@base-ui/react/button\";\nimport type {\n ProductWithDetails,\n VariantView,\n AddOnOption,\n ComponentSelectionInput,\n CompositePriceResult,\n} from \"@cimplify/sdk\";\nimport type { ProductBillingPlan } from \"@cimplify/sdk\";\nimport type { BundleSelectionInput } from \"@cimplify/sdk\";\nimport { parsePrice, getUnitPriceAtQuantity, getProductCurrency } from \"@cimplify/sdk\";\nimport { isMultiDayService, serviceBillingMultiplier } from \"../utils/price-basis\";\nimport { formatDuration } from \"./utils/format-duration\";\nimport { useCart, useQuote } from \"@cimplify/sdk/react\";\nimport type { AddToCartOptions } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { QuantitySelector } from \"@cimplify/sdk/react\";\nimport { VariantSelector } from \"@cimplify/sdk/react\";\nimport { AddOnSelector } from \"@cimplify/sdk/react\";\nimport { StaffPicker } from \"@cimplify/sdk/react\";\nimport { BookingSummary } from \"./booking-summary\";\nimport type { Staff } from \"@cimplify/sdk\";\nimport { CompositeSelector } from \"@cimplify/sdk/react\";\nimport { BundleSelector } from \"@cimplify/sdk/react\";\nimport { BillingPlanSelector } from \"@cimplify/sdk/react\";\nimport { VolumePricing } from \"@cimplify/sdk/react\";\nimport { CustomerInputFields } from \"./customer-input-fields\";\nimport { DateSlotPicker } from \"@cimplify/sdk/react\";\nimport type { AvailableSlot } from \"@cimplify/sdk\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface ProductCustomizerClassNames {\n root?: string;\n actions?: string;\n submitButton?: string;\n submitButtonAdded?: string;\n validation?: string;\n specialInstructions?: string;\n depositInfo?: string;\n units?: string;\n allergens?: string;\n duration?: string;\n}\n\nexport interface ProductCustomizerProps {\n product: ProductWithDetails;\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Lets the parent swap its gallery for `variant.images` on selection. */\n onVariantChange?: (\n variantId: string | undefined,\n variant: VariantView | undefined,\n ) => void;\n showSpecialInstructions?: boolean;\n /** Submit button verb; the running total is appended. Defaults to \"Add to Cart\". */\n submitLabel?: string;\n className?: string;\n classNames?: ProductCustomizerClassNames;\n}\n\nexport function ProductCustomizer({\n product,\n onAddToCart,\n onVariantChange,\n showSpecialInstructions = true,\n submitLabel,\n className,\n classNames,\n}: ProductCustomizerProps): React.ReactElement {\n const [quantity, setQuantity] = useState(product.min_order_quantity ?? 1);\n const [isAdded, setIsAdded] = useState(false);\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [selectedVariantId, setSelectedVariantId] = useState<string | undefined>();\n const [selectedVariant, setSelectedVariant] = useState<VariantView | undefined>();\n const [selectedAddOnOptionIds, setSelectedAddOnOptionIds] = useState<string[]>([]);\n\n const [compositeSelections, setCompositeSelections] = useState<ComponentSelectionInput[]>([]);\n const [compositePrice, setCompositePrice] = useState<CompositePriceResult | null>(null);\n const [compositeReady, setCompositeReady] = useState(false);\n\n const [bundleSelections, setBundleSelections] = useState<BundleSelectionInput[]>([]);\n const [bundleTotalPrice, setBundleTotalPrice] = useState<number | null>(null);\n const [bundleReady, setBundleReady] = useState(false);\n const [selectedBillingPlan, setSelectedBillingPlan] = useState<ProductBillingPlan | null>(null);\n const [customerInputValues, setCustomerInputValues] = useState<Record<string, unknown>>({});\n const [specialInstructions, setSpecialInstructions] = useState(\"\");\n const [selectedSlot, setSelectedSlot] = useState<AvailableSlot | null>(null);\n const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);\n const [unitsCount, setUnitsCount] = useState(1);\n\n const cart = useCart();\n\n const currency = getProductCurrency(product);\n const productType = product.type || \"product\";\n const isComposite = productType === \"composite\";\n const isBundle = productType === \"bundle\";\n const isDigital = productType === \"digital\";\n const isService = productType === \"service\";\n // Whole-stay services can reserve N interchangeable units (rooms, kayaks,\n // pitches) per booking — mirrors desk's wizard, which gates the input the\n // same way; intraday party size is already the quantity.\n const offersUnits = isService && isMultiDayService(product);\n const isStandard = !isComposite && !isBundle;\n\n const hasVariants = isStandard && product.variants && product.variants.length > 0;\n const hasAddOns = isStandard && product.add_ons && product.add_ons.length > 0;\n\n useEffect(() => {\n setQuantity(product.min_order_quantity ?? 1);\n setIsAdded(false);\n setIsSubmitting(false);\n setSelectedVariantId(undefined);\n setSelectedVariant(undefined);\n setSelectedAddOnOptionIds([]);\n setCompositeSelections([]);\n setCompositePrice(null);\n setCompositeReady(false);\n setBundleSelections([]);\n setBundleTotalPrice(null);\n setBundleReady(false);\n setSelectedBillingPlan(null);\n setCustomerInputValues({});\n setSpecialInstructions(\"\");\n setSelectedSlot(null);\n setSelectedStaffId(null);\n setUnitsCount(1);\n }, [product.id, product.min_order_quantity]);\n\n // Staff offered for the chosen slot. SlotStaffInfo carries id + name; richer\n // profiles (avatars/bios) would come from a staff roster fetch.\n const slotStaff = useMemo<Staff[]>(\n () =>\n (selectedSlot?.available_staff ?? []).map((member) => ({\n id: member.staff_id,\n name: member.name,\n })),\n [selectedSlot],\n );\n const selectedStaffName = selectedStaffId\n ? (slotStaff.find((member) => member.id === selectedStaffId)?.name ?? null)\n : null;\n\n const selectedAddOnOptions = useMemo(() => {\n if (!product.add_ons) return [];\n const options: AddOnOption[] = [];\n for (const addOn of product.add_ons) {\n for (const option of addOn.options) {\n if (selectedAddOnOptionIds.includes(option.id)) {\n options.push(option);\n }\n }\n }\n return options;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const normalizedAddOnOptionIds = useMemo(() => {\n if (selectedAddOnOptionIds.length === 0) return [];\n return Array.from(new Set(selectedAddOnOptionIds.map((id) => id.trim()).filter(Boolean))).sort();\n }, [selectedAddOnOptionIds]);\n\n const localTotalPrice = useMemo(() => {\n if (isComposite && compositePrice) {\n return parsePrice(compositePrice.final_price) * quantity;\n }\n\n if (isBundle && bundleTotalPrice != null) {\n return bundleTotalPrice * quantity;\n }\n\n let price = parsePrice(product.default_price);\n\n if (selectedVariant?.price_adjustment) {\n price += parsePrice(selectedVariant.price_adjustment);\n }\n\n for (const option of selectedAddOnOptions) {\n if (option.default_price) {\n price += parsePrice(option.default_price);\n }\n }\n\n return price * quantity;\n }, [product.default_price, selectedVariant, selectedAddOnOptions, quantity, isComposite, compositePrice, isBundle, bundleTotalPrice]);\n\n const requiredAddOnsSatisfied = useMemo(() => {\n if (!product.add_ons) return true;\n\n for (const addOn of product.add_ons) {\n if (addOn.is_required) {\n const selectedInGroup = selectedAddOnOptionIds.filter((id) =>\n addOn.options.some((opt) => opt.id === id),\n ).length;\n\n const minRequired = addOn.min_selections || 1;\n if (selectedInGroup < minRequired) {\n return false;\n }\n }\n }\n return true;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const quoteInput = useMemo(\n () => ({\n productId: product.id,\n quantity,\n variantId: selectedVariantId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n compositeSelections: isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n }),\n [product.id, quantity, selectedVariantId, normalizedAddOnOptionIds, isBundle, bundleSelections, isComposite, compositeSelections],\n );\n\n const requiredInputsSatisfied = useMemo(() => {\n if (!product.input_fields || product.input_fields.length === 0) return true;\n return product.input_fields.every((field) => {\n if (!field.is_required) return true;\n const val = customerInputValues[field.id];\n return val !== undefined && val !== \"\" && val !== null;\n });\n }, [product.input_fields, customerInputValues]);\n\n const quoteEnabled = isComposite\n ? compositeReady && requiredInputsSatisfied\n : isBundle\n ? bundleReady && requiredInputsSatisfied\n : requiredAddOnsSatisfied && requiredInputsSatisfied;\n\n const { quote } = useQuote(quoteInput, {\n enabled: quoteEnabled,\n });\n\n const quoteId = quote?.quote_id;\n const quotedTotalPrice = useMemo(() => {\n if (!quote) return undefined;\n\n const priceInfo = quote.quoted_total_price_info ?? quote.final_price_info;\n const perUnit = priceInfo.pre_tax_price;\n if (perUnit === undefined || perUnit === null) return undefined;\n return parsePrice(perUnit) * quantity;\n }, [quote, quantity]);\n\n // Services bill by basis × periods × units server-side, not by quantity —\n // mirror billing_multiplier so the preview matches the cart. Both totals\n // above already include × quantity, so divide it back out to the unit\n // price before applying the real multiplier.\n const displayTotalPrice = useMemo(() => {\n const total = quotedTotalPrice ?? localTotalPrice;\n const multiplier = serviceBillingMultiplier(product, {\n quantity,\n units: unitsCount,\n scheduledStart: selectedSlot?.start_time,\n scheduledEnd: selectedSlot?.end_time,\n });\n if (multiplier === null) return total;\n return (total / Math.max(1, quantity)) * multiplier;\n }, [quotedTotalPrice, localTotalPrice, product, quantity, unitsCount, selectedSlot]);\n\n const handleVariantChange = useCallback(\n (variantId: string | undefined, variant: VariantView | undefined) => {\n setSelectedVariantId(variantId);\n setSelectedVariant(variant);\n onVariantChange?.(variantId, variant);\n },\n [onVariantChange],\n );\n\n const addedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(addedTimerRef.current), []);\n\n const handleAddToCart = useCallback(async () => {\n if (isSubmitting) return;\n\n setIsSubmitting(true);\n\n const options: AddToCartOptions = {\n variantId: selectedVariantId,\n variant: selectedVariant\n ? { id: selectedVariant.id, name: selectedVariant.name || \"\", price_adjustment: selectedVariant.price_adjustment }\n : undefined,\n quoteId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n addOnOptions: selectedAddOnOptions.length > 0\n ? selectedAddOnOptions.map((opt) => ({\n id: opt.id,\n name: opt.name,\n add_on_id: opt.add_on_id,\n default_price: opt.default_price,\n }))\n : undefined,\n compositeSelections: isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n billingPlanId: selectedBillingPlan?.id,\n ...(isService && selectedSlot\n ? {\n scheduledStart: selectedSlot.start_time,\n scheduledEnd: selectedSlot.end_time,\n staffId: selectedStaffId ?? selectedSlot.available_staff?.[0]?.staff_id,\n resourceId: selectedSlot.available_resources?.[0]?.resource_id,\n units: unitsCount > 1 ? unitsCount : undefined,\n }\n : {}),\n customerInputs: Object.keys(customerInputValues).length > 0\n ? Object.entries(customerInputValues)\n .filter(([, v]) => v !== undefined && v !== \"\")\n .map(([fieldId, value]) => ({ field_id: fieldId, value }))\n : undefined,\n specialInstructions: specialInstructions.trim() || undefined,\n };\n\n try {\n if (onAddToCart) {\n await onAddToCart(product, quantity, options);\n } else {\n await cart.addItem(product, quantity, options);\n }\n setIsAdded(true);\n clearTimeout(addedTimerRef.current);\n addedTimerRef.current = setTimeout(() => {\n setIsAdded(false);\n setQuantity(product.min_order_quantity ?? 1);\n }, 2000);\n } catch {\n // Caller handles errors via onAddToCart or cart hook error state\n } finally {\n setIsSubmitting(false);\n }\n }, [product, quantity, selectedVariantId, selectedVariant, quoteId, normalizedAddOnOptionIds, selectedAddOnOptions, isComposite, compositeSelections, isBundle, bundleSelections, selectedBillingPlan, customerInputValues, specialInstructions, isService, selectedSlot, selectedStaffId, unitsCount, isSubmitting, onAddToCart, cart]);\n\n return (\n <div data-cimplify-customizer className={cn(\"space-y-6\", className, classNames?.root)}>\n {isComposite && product.groups && product.composite_id && (\n <CompositeSelector\n compositeId={product.composite_id}\n groups={product.groups}\n currency={currency}\n onSelectionsChange={setCompositeSelections}\n onPriceChange={setCompositePrice}\n onReady={setCompositeReady}\n skipPriceFetch\n />\n )}\n\n {isBundle && product.components && (\n <BundleSelector\n components={product.components}\n bundlePrice={product.bundle_price}\n discountValue={product.discount_value}\n pricingType={product.pricing_type}\n currency={currency}\n onSelectionsChange={setBundleSelections}\n onPriceChange={setBundleTotalPrice}\n onReady={setBundleReady}\n />\n )}\n\n {isService && (\n <DateSlotPicker\n serviceId={product.id}\n selectedSlot={selectedSlot}\n onSlotSelect={(slot) => {\n setSelectedSlot(slot);\n setSelectedStaffId(null);\n }}\n participantCount={quantity}\n units={offersUnits ? unitsCount : undefined}\n currency={currency}\n // Without the resolved mode the picker renders intraday slots for\n // whole-stay services — stays need the stay-range treatment.\n schedulingMode={product.scheduling_mode ?? (offersUnits ? \"multi_day\" : \"intraday\")}\n durationUnit={product.duration_unit}\n durationValue={product.duration_value}\n />\n )}\n\n {offersUnits && (\n <div data-cimplify-customizer-units className={classNames?.units}>\n <label className=\"block text-xs font-medium uppercase tracking-wider text-muted-foreground mb-3\">\n Units\n </label>\n <QuantitySelector value={unitsCount} onChange={setUnitsCount} min={1} />\n </div>\n )}\n\n {isService && slotStaff.length > 0 && (\n <div data-cimplify-customizer-staff>\n <label className=\"block text-xs font-medium uppercase tracking-wider text-muted-foreground mb-3\">\n Choose your specialist\n </label>\n <StaffPicker\n staff={slotStaff}\n selectedStaffId={selectedStaffId}\n onStaffSelect={setSelectedStaffId}\n className=\"flex flex-wrap gap-2\"\n classNames={{\n option:\n \"inline-flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors hover:border-foreground/40 cursor-pointer data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background\",\n avatar: \"w-6 h-6 rounded-full object-cover\",\n }}\n />\n </div>\n )}\n\n {hasVariants && (\n <VariantSelector\n variants={product.variants!}\n variantAxes={product.variant_axes}\n basePrice={product.default_price}\n currency={currency}\n selectedVariantId={selectedVariantId}\n onVariantChange={handleVariantChange}\n productName={product.name}\n />\n )}\n\n {hasAddOns && (\n <AddOnSelector\n addOns={product.add_ons!}\n selectedOptions={selectedAddOnOptionIds}\n onOptionsChange={setSelectedAddOnOptionIds}\n currency={currency}\n />\n )}\n\n {/* Billing plans */}\n {product.billing_plans && product.billing_plans.length > 0 && (\n <BillingPlanSelector\n productId={product.id}\n plans={product.billing_plans}\n currency={currency}\n onPlanSelect={setSelectedBillingPlan}\n selectedPlanId={selectedBillingPlan?.id ?? null}\n showOneTimePurchase\n />\n )}\n\n {/* Volume pricing tiers */}\n {product.quantity_pricing && product.quantity_pricing.length > 1 && (\n <VolumePricing\n tiers={product.quantity_pricing}\n currentQuantity={quantity}\n currency={currency}\n />\n )}\n\n {/* Customer input fields */}\n {product.input_fields && product.input_fields.length > 0 && (\n <CustomerInputFields\n fields={product.input_fields}\n values={customerInputValues}\n onChange={setCustomerInputValues}\n currency={currency}\n />\n )}\n\n {/* Deposit info for service products */}\n {product.deposit_type && product.deposit_type !== \"none\" && product.deposit_amount && (\n <div data-cimplify-customizer-deposit className={cn(\"text-sm text-muted-foreground\", classNames?.depositInfo)}>\n {product.deposit_type === \"fixed\" ? (\n <span>Deposit required: <Price amount={product.deposit_amount} currency={currency} /></span>\n ) : (\n <span>Deposit required: {parsePrice(product.deposit_amount)}%</span>\n )}\n </div>\n )}\n\n {/* Allergens */}\n {product.allergies && product.allergies.length > 0 && (\n <div data-cimplify-customizer-allergens className={cn(\"flex flex-wrap gap-1.5\", classNames?.allergens)}>\n {product.allergies.map((allergen) => (\n <span\n key={allergen}\n data-cimplify-allergen-tag\n className=\"inline-block text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground\"\n >\n {allergen}\n </span>\n ))}\n </div>\n )}\n\n {/* Service duration */}\n {isService && product.duration_minutes != null && (\n <div data-cimplify-customizer-duration className={cn(\"text-sm text-muted-foreground\", classNames?.duration)}>\n Duration: {formatDuration(product.duration_minutes, product.duration_unit)}\n </div>\n )}\n\n {/* Special instructions */}\n {showSpecialInstructions && !isDigital && (\n <div data-cimplify-customizer-special-instructions>\n <textarea\n value={specialInstructions}\n onChange={(e) => setSpecialInstructions(e.target.value)}\n placeholder=\"Special instructions (e.g., no onions, extra sauce)\"\n rows={2}\n data-cimplify-customizer-textarea\n className={cn(\n \"w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring resize-none\",\n classNames?.specialInstructions,\n )}\n />\n </div>\n )}\n\n {isService && selectedSlot && (\n <BookingSummary\n slot={selectedSlot}\n total={displayTotalPrice}\n staffName={selectedStaffName}\n durationMinutes={product.duration_minutes}\n depositType={product.deposit_type}\n depositAmount={product.deposit_amount}\n currency={currency}\n />\n )}\n\n <div\n data-cimplify-customizer-actions\n className={cn(\"pt-4 border-t border-border\", classNames?.actions)}\n >\n {!quoteEnabled && (\n <p\n id=\"cimplify-customizer-validation\"\n data-cimplify-customizer-validation\n className={cn(\"text-sm text-destructive mb-3\", classNames?.validation)}\n >\n Please select all required options\n </p>\n )}\n <div className=\"flex items-center gap-4\">\n <div className=\"flex flex-col gap-1\">\n <QuantitySelector\n value={quantity}\n onChange={setQuantity}\n min={product.min_order_quantity ?? 1}\n />\n {product.min_order_quantity && product.min_order_quantity > 1 && (\n <span data-cimplify-customizer-min-hint className=\"text-xs text-muted-foreground text-center\">\n Min. {product.min_order_quantity}\n </span>\n )}\n </div>\n\n {product.quantity_pricing && product.quantity_pricing.length > 0 && (\n <div data-cimplify-customizer-unit-price className=\"text-sm text-muted-foreground text-right shrink-0\">\n <Price amount={getUnitPriceAtQuantity(product.quantity_pricing, quantity, parsePrice(product.default_price))} currency={currency} className=\"font-medium text-foreground\" /> ea.\n </div>\n )}\n\n <Button\n onClick={handleAddToCart}\n disabled={isAdded || isSubmitting || !quoteEnabled}\n aria-describedby={!quoteEnabled ? \"cimplify-customizer-validation\" : undefined}\n data-cimplify-customizer-submit\n className={cn(\n \"flex-1 h-14 text-base bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed rounded-full\",\n isAdded && classNames?.submitButtonAdded,\n classNames?.submitButton,\n )}\n >\n {isAdded ? \"Added\" : (\n <>\n {submitLabel ?? \"Add to Cart\"} ·{\" \"}\n <Price amount={displayTotalPrice} currency={currency} />\n </>\n )}\n </Button>\n </div>\n </div>\n\n </div>\n );\n}\n"
|
|
17
|
+
"content": "\"use client\";\n\nimport React, { useState, useCallback, useMemo, useEffect, useRef } from \"react\";\nimport { Button } from \"@base-ui/react/button\";\nimport type {\n ProductWithDetails,\n VariantView,\n AddOnOption,\n ComponentSelectionInput,\n CompositePriceResult,\n} from \"@cimplify/sdk\";\nimport type { ProductBillingPlan } from \"@cimplify/sdk\";\nimport type { BundleSelectionInput } from \"@cimplify/sdk\";\nimport {\n getProductCurrency,\n getUnitPriceAtQuantity,\n getVariantUnitPrice,\n parsePrice,\n} from \"@cimplify/sdk\";\nimport { isMultiDayService, serviceBillingMultiplier } from \"../utils/price-basis\";\nimport { formatDuration } from \"./utils/format-duration\";\nimport { useCart, useQuote } from \"@cimplify/sdk/react\";\nimport type { AddToCartOptions } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { QuantitySelector } from \"@cimplify/sdk/react\";\nimport { VariantSelector } from \"@cimplify/sdk/react\";\nimport { AddOnSelector } from \"@cimplify/sdk/react\";\nimport { StaffPicker } from \"@cimplify/sdk/react\";\nimport { BookingSummary } from \"./booking-summary\";\nimport type { Staff } from \"@cimplify/sdk\";\nimport { CompositeSelector } from \"@cimplify/sdk/react\";\nimport { BundleSelector } from \"@cimplify/sdk/react\";\nimport { BillingPlanSelector } from \"@cimplify/sdk/react\";\nimport { VolumePricing } from \"@cimplify/sdk/react\";\nimport { CustomerInputFields } from \"./customer-input-fields\";\nimport { DateSlotPicker } from \"@cimplify/sdk/react\";\nimport type { AvailableSlot } from \"@cimplify/sdk\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface ProductCustomizerClassNames {\n root?: string;\n actions?: string;\n submitButton?: string;\n submitButtonAdded?: string;\n validation?: string;\n specialInstructions?: string;\n depositInfo?: string;\n units?: string;\n allergens?: string;\n duration?: string;\n}\n\nexport interface ProductCustomizerProps {\n product: ProductWithDetails;\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Lets the parent swap its gallery for `variant.images` on selection. */\n onVariantChange?: (variantId: string | undefined, variant: VariantView | undefined) => void;\n showSpecialInstructions?: boolean;\n /** Submit button verb; the running total is appended. Defaults to \"Add to Cart\". */\n submitLabel?: string;\n className?: string;\n classNames?: ProductCustomizerClassNames;\n}\n\ninterface VariantSelection {\n id?: string;\n variant?: VariantView;\n}\n\nconst REQUIRED_OPTIONS_MESSAGE = \"Please select all required options\";\nconst QUOTE_LOADING_MESSAGE = \"Updating price…\";\nconst QUOTE_UNAVAILABLE_MESSAGE = \"Price is unavailable. Please try again.\";\nconst CUSTOMIZER_VALIDATION_ID = \"cimplify-customizer-validation\";\n\nfunction initialVariantSelection(product: ProductWithDetails): VariantSelection {\n const variant =\n product.variants?.find((candidate) => candidate.is_default) ?? product.variants?.[0];\n return { id: variant?.id, variant };\n}\n\nexport function ProductCustomizer(props: ProductCustomizerProps): React.ReactElement {\n // A product change represents a new customization session. Keying the\n // stateful implementation resets it synchronously and avoids an effect that\n // briefly renders the previous product's variant and quote.\n return <ProductCustomizerContent key={props.product.id} {...props} />;\n}\n\nfunction ProductCustomizerContent({\n product,\n onAddToCart,\n onVariantChange,\n showSpecialInstructions = true,\n submitLabel,\n className,\n classNames,\n}: ProductCustomizerProps): React.ReactElement {\n const [quantity, setQuantity] = useState(product.min_order_quantity ?? 1);\n const [isAdded, setIsAdded] = useState(false);\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [variantSelection, setVariantSelection] = useState<VariantSelection>(() =>\n initialVariantSelection(product),\n );\n const selectedVariantId = variantSelection.id;\n const selectedVariant = variantSelection.variant;\n const [selectedAddOnOptionIds, setSelectedAddOnOptionIds] = useState<string[]>([]);\n\n const [compositeSelections, setCompositeSelections] = useState<ComponentSelectionInput[]>([]);\n const [compositePrice, setCompositePrice] = useState<CompositePriceResult | null>(null);\n const [compositeReady, setCompositeReady] = useState(false);\n\n const [bundleSelections, setBundleSelections] = useState<BundleSelectionInput[]>([]);\n const [bundleTotalPrice, setBundleTotalPrice] = useState<number | null>(null);\n const [bundleReady, setBundleReady] = useState(false);\n const [selectedBillingPlan, setSelectedBillingPlan] = useState<ProductBillingPlan | null>(null);\n const [customerInputValues, setCustomerInputValues] = useState<Record<string, unknown>>({});\n const [specialInstructions, setSpecialInstructions] = useState(\"\");\n const [selectedSlot, setSelectedSlot] = useState<AvailableSlot | null>(null);\n const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);\n const [unitsCount, setUnitsCount] = useState(1);\n\n const cart = useCart();\n\n const productType = product.type || \"product\";\n const isComposite = productType === \"composite\";\n const isBundle = productType === \"bundle\";\n const isDigital = productType === \"digital\";\n const isService = productType === \"service\";\n // Whole-stay services can reserve N interchangeable units (rooms, kayaks,\n // pitches) per booking — mirrors desk's wizard, which gates the input the\n // same way; intraday party size is already the quantity.\n const offersUnits = isService && isMultiDayService(product);\n const isStandard = !isComposite && !isBundle;\n\n const hasVariants = isStandard && product.variants && product.variants.length > 0;\n const hasAddOns = isStandard && product.add_ons && product.add_ons.length > 0;\n\n // Staff offered for the chosen slot. SlotStaffInfo carries id + name; richer\n // profiles (avatars/bios) would come from a staff roster fetch.\n const slotStaff = useMemo<Staff[]>(\n () =>\n (selectedSlot?.available_staff ?? []).map((member) => ({\n id: member.staff_id,\n name: member.name,\n })),\n [selectedSlot],\n );\n const selectedStaffName = selectedStaffId\n ? (slotStaff.find((member) => member.id === selectedStaffId)?.name ?? null)\n : null;\n\n const selectedAddOnOptions = useMemo(() => {\n if (!product.add_ons) return [];\n const options: AddOnOption[] = [];\n for (const addOn of product.add_ons) {\n for (const option of addOn.options) {\n if (selectedAddOnOptionIds.includes(option.id)) {\n options.push(option);\n }\n }\n }\n return options;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const normalizedAddOnOptionIds = useMemo(() => {\n if (selectedAddOnOptionIds.length === 0) return [];\n return Array.from(\n new Set(selectedAddOnOptionIds.map((id) => id.trim()).filter(Boolean)),\n ).sort();\n }, [selectedAddOnOptionIds]);\n\n const localTotalPrice = useMemo(() => {\n if (isComposite && compositePrice) {\n return parsePrice(compositePrice.final_price) * quantity;\n }\n\n if (isBundle && bundleTotalPrice != null) {\n return bundleTotalPrice * quantity;\n }\n\n let price = getVariantUnitPrice(product, selectedVariant);\n\n for (const option of selectedAddOnOptions) {\n if (option.default_price) {\n price += parsePrice(option.default_price);\n }\n }\n\n return price * quantity;\n }, [\n product.default_price,\n selectedVariant,\n selectedAddOnOptions,\n quantity,\n isComposite,\n compositePrice,\n isBundle,\n bundleTotalPrice,\n ]);\n\n const requiredAddOnsSatisfied = useMemo(() => {\n if (!product.add_ons) return true;\n\n for (const addOn of product.add_ons) {\n if (addOn.is_required) {\n const selectedInGroup = selectedAddOnOptionIds.filter((id) =>\n addOn.options.some((opt) => opt.id === id),\n ).length;\n\n const minRequired = addOn.min_selections || 1;\n if (selectedInGroup < minRequired) {\n return false;\n }\n }\n }\n return true;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const quoteInput = useMemo(\n () => ({\n productId: product.id,\n quantity,\n variantId: selectedVariantId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n compositeSelections:\n isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n }),\n [\n product.id,\n quantity,\n selectedVariantId,\n normalizedAddOnOptionIds,\n isBundle,\n bundleSelections,\n isComposite,\n compositeSelections,\n ],\n );\n\n const requiredInputsSatisfied = useMemo(() => {\n if (!product.input_fields || product.input_fields.length === 0) return true;\n return product.input_fields.every((field) => {\n if (!field.is_required) return true;\n const val = customerInputValues[field.id];\n return val !== undefined && val !== \"\" && val !== null;\n });\n }, [product.input_fields, customerInputValues]);\n\n const quoteEnabled = isComposite\n ? compositeReady && requiredInputsSatisfied\n : isBundle\n ? bundleReady && requiredInputsSatisfied\n : requiredAddOnsSatisfied && requiredInputsSatisfied;\n\n const {\n quote,\n isLoading: isQuoteLoading,\n error: quoteError,\n isExpired: isQuoteExpired,\n } = useQuote(quoteInput, {\n enabled: quoteEnabled,\n });\n\n const currency = quote?.currency ?? getProductCurrency(product);\n const quoteId = quote?.quote_id;\n const quotedUnitPrice = (quote?.quoted_total_price_info ?? quote?.final_price_info)?.final_price;\n const quotedTotalPrice = useMemo(() => {\n if (quotedUnitPrice === undefined || quotedUnitPrice === null) return undefined;\n return parsePrice(quotedUnitPrice) * quantity;\n }, [quotedUnitPrice, quantity]);\n\n const quoteIsReady =\n quoteEnabled && quote !== null && !isQuoteLoading && !quoteError && !isQuoteExpired;\n const validationMessage = !quoteEnabled\n ? REQUIRED_OPTIONS_MESSAGE\n : quoteError\n ? QUOTE_UNAVAILABLE_MESSAGE\n : !quoteIsReady\n ? QUOTE_LOADING_MESSAGE\n : null;\n\n // Services bill by basis × periods × units server-side, not by quantity —\n // mirror billing_multiplier so the preview matches the cart. Both totals\n // above already include × quantity, so divide it back out to the unit\n // price before applying the real multiplier.\n const displayTotalPrice = useMemo(() => {\n const total = quotedTotalPrice ?? localTotalPrice;\n const multiplier = serviceBillingMultiplier(product, {\n quantity,\n units: unitsCount,\n scheduledStart: selectedSlot?.start_time,\n scheduledEnd: selectedSlot?.end_time,\n });\n if (multiplier === null) return total;\n return (total / Math.max(1, quantity)) * multiplier;\n }, [quotedTotalPrice, localTotalPrice, product, quantity, unitsCount, selectedSlot]);\n\n const handleVariantChange = useCallback(\n (variantId: string | undefined, variant: VariantView | undefined) => {\n setVariantSelection({ id: variantId, variant });\n onVariantChange?.(variantId, variant);\n },\n [onVariantChange],\n );\n\n const addedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(addedTimerRef.current), []);\n\n const handleAddToCart = useCallback(async () => {\n if (\n isSubmitting ||\n !quoteIsReady ||\n !quote ||\n quotedUnitPrice === undefined ||\n quotedUnitPrice === null\n ) {\n return;\n }\n\n setIsSubmitting(true);\n\n const options: AddToCartOptions = {\n locationId: quote.location_id ?? undefined,\n variantId: selectedVariantId,\n variant: selectedVariant\n ? {\n id: selectedVariant.id,\n name: selectedVariant.name || \"\",\n price_adjustment: selectedVariant.price_adjustment,\n price_info: selectedVariant.price_info,\n }\n : undefined,\n quoteId,\n quotedUnitPrice,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n addOnOptions:\n selectedAddOnOptions.length > 0\n ? selectedAddOnOptions.map((opt) => ({\n id: opt.id,\n name: opt.name,\n add_on_id: opt.add_on_id,\n default_price: opt.default_price,\n }))\n : undefined,\n compositeSelections:\n isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n billingPlanId: selectedBillingPlan?.id,\n ...(isService && selectedSlot\n ? {\n scheduledStart: selectedSlot.start_time,\n scheduledEnd: selectedSlot.end_time,\n staffId: selectedStaffId ?? selectedSlot.available_staff?.[0]?.staff_id,\n resourceId: selectedSlot.available_resources?.[0]?.resource_id,\n units: unitsCount > 1 ? unitsCount : undefined,\n }\n : {}),\n customerInputs:\n Object.keys(customerInputValues).length > 0\n ? Object.entries(customerInputValues)\n .filter(([, v]) => v !== undefined && v !== \"\")\n .map(([fieldId, value]) => ({ field_id: fieldId, value }))\n : undefined,\n specialInstructions: specialInstructions.trim() || undefined,\n };\n\n try {\n if (onAddToCart) {\n await onAddToCart(product, quantity, options);\n } else {\n await cart.addItem(product, quantity, options);\n }\n setIsAdded(true);\n clearTimeout(addedTimerRef.current);\n addedTimerRef.current = setTimeout(() => {\n setIsAdded(false);\n setQuantity(product.min_order_quantity ?? 1);\n }, 2000);\n } catch {\n // Caller handles errors via onAddToCart or cart hook error state\n } finally {\n setIsSubmitting(false);\n }\n }, [\n product,\n quantity,\n selectedVariantId,\n selectedVariant,\n quoteId,\n quotedUnitPrice,\n normalizedAddOnOptionIds,\n selectedAddOnOptions,\n isComposite,\n compositeSelections,\n isBundle,\n bundleSelections,\n selectedBillingPlan,\n customerInputValues,\n specialInstructions,\n isService,\n selectedSlot,\n selectedStaffId,\n unitsCount,\n isSubmitting,\n onAddToCart,\n cart,\n quoteIsReady,\n quote,\n ]);\n\n return (\n <div data-cimplify-customizer className={cn(\"space-y-6\", className, classNames?.root)}>\n {isComposite && product.groups && product.composite_id && (\n <CompositeSelector\n compositeId={product.composite_id}\n groups={product.groups}\n currency={currency}\n onSelectionsChange={setCompositeSelections}\n onPriceChange={setCompositePrice}\n onReady={setCompositeReady}\n skipPriceFetch\n />\n )}\n\n {isBundle && product.components && (\n <BundleSelector\n components={product.components}\n bundlePrice={product.bundle_price}\n discountValue={product.discount_value}\n pricingType={product.pricing_type}\n currency={currency}\n onSelectionsChange={setBundleSelections}\n onPriceChange={setBundleTotalPrice}\n onReady={setBundleReady}\n />\n )}\n\n {isService && (\n <DateSlotPicker\n serviceId={product.id}\n selectedSlot={selectedSlot}\n onSlotSelect={(slot) => {\n setSelectedSlot(slot);\n setSelectedStaffId(null);\n }}\n participantCount={quantity}\n units={offersUnits ? unitsCount : undefined}\n currency={currency}\n // Without the resolved mode the picker renders intraday slots for\n // whole-stay services — stays need the stay-range treatment.\n schedulingMode={product.scheduling_mode ?? (offersUnits ? \"multi_day\" : \"intraday\")}\n durationUnit={product.duration_unit}\n durationValue={product.duration_value}\n />\n )}\n\n {offersUnits && (\n <div data-cimplify-customizer-units className={classNames?.units}>\n <label className=\"block text-xs font-medium uppercase tracking-wider text-muted-foreground mb-3\">\n Units\n </label>\n <QuantitySelector value={unitsCount} onChange={setUnitsCount} min={1} />\n </div>\n )}\n\n {isService && slotStaff.length > 0 && (\n <div data-cimplify-customizer-staff>\n <label className=\"block text-xs font-medium uppercase tracking-wider text-muted-foreground mb-3\">\n Choose your specialist\n </label>\n <StaffPicker\n staff={slotStaff}\n selectedStaffId={selectedStaffId}\n onStaffSelect={setSelectedStaffId}\n className=\"flex flex-wrap gap-2\"\n classNames={{\n option:\n \"inline-flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors hover:border-foreground/40 cursor-pointer data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background\",\n avatar: \"w-6 h-6 rounded-full object-cover\",\n }}\n />\n </div>\n )}\n\n {hasVariants && (\n <VariantSelector\n variants={product.variants!}\n variantAxes={product.variant_axes}\n basePrice={product.default_price}\n currency={currency}\n selectedVariantId={selectedVariantId}\n onVariantChange={handleVariantChange}\n productName={product.name}\n />\n )}\n\n {hasAddOns && (\n <AddOnSelector\n addOns={product.add_ons!}\n selectedOptions={selectedAddOnOptionIds}\n onOptionsChange={setSelectedAddOnOptionIds}\n currency={currency}\n />\n )}\n\n {/* Billing plans */}\n {product.billing_plans && product.billing_plans.length > 0 && (\n <BillingPlanSelector\n productId={product.id}\n plans={product.billing_plans}\n currency={currency}\n onPlanSelect={setSelectedBillingPlan}\n selectedPlanId={selectedBillingPlan?.id ?? null}\n showOneTimePurchase\n />\n )}\n\n {/* Volume pricing tiers */}\n {product.quantity_pricing && product.quantity_pricing.length > 1 && (\n <VolumePricing\n tiers={product.quantity_pricing}\n currentQuantity={quantity}\n currency={currency}\n />\n )}\n\n {/* Customer input fields */}\n {product.input_fields && product.input_fields.length > 0 && (\n <CustomerInputFields\n fields={product.input_fields}\n values={customerInputValues}\n onChange={setCustomerInputValues}\n currency={currency}\n />\n )}\n\n {/* Deposit info for service products */}\n {product.deposit_type && product.deposit_type !== \"none\" && product.deposit_amount && (\n <div\n data-cimplify-customizer-deposit\n className={cn(\"text-sm text-muted-foreground\", classNames?.depositInfo)}\n >\n {product.deposit_type === \"fixed\" ? (\n <span>\n Deposit required: <Price amount={product.deposit_amount} currency={currency} />\n </span>\n ) : (\n <span>Deposit required: {parsePrice(product.deposit_amount)}%</span>\n )}\n </div>\n )}\n\n {/* Allergens */}\n {product.allergies && product.allergies.length > 0 && (\n <div\n data-cimplify-customizer-allergens\n className={cn(\"flex flex-wrap gap-1.5\", classNames?.allergens)}\n >\n {product.allergies.map((allergen) => (\n <span\n key={allergen}\n data-cimplify-allergen-tag\n className=\"inline-block text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground\"\n >\n {allergen}\n </span>\n ))}\n </div>\n )}\n\n {/* Service duration */}\n {isService && product.duration_minutes != null && (\n <div\n data-cimplify-customizer-duration\n className={cn(\"text-sm text-muted-foreground\", classNames?.duration)}\n >\n Duration: {formatDuration(product.duration_minutes, product.duration_unit)}\n </div>\n )}\n\n {/* Special instructions */}\n {showSpecialInstructions && !isDigital && (\n <div data-cimplify-customizer-special-instructions>\n <textarea\n value={specialInstructions}\n onChange={(e) => setSpecialInstructions(e.target.value)}\n placeholder=\"Special instructions (e.g., no onions, extra sauce)\"\n rows={2}\n data-cimplify-customizer-textarea\n className={cn(\n \"w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring resize-none\",\n classNames?.specialInstructions,\n )}\n />\n </div>\n )}\n\n {isService && selectedSlot && (\n <BookingSummary\n slot={selectedSlot}\n total={displayTotalPrice}\n staffName={selectedStaffName}\n durationMinutes={product.duration_minutes}\n depositType={product.deposit_type}\n depositAmount={product.deposit_amount}\n currency={currency}\n />\n )}\n\n <div\n data-cimplify-customizer-actions\n className={cn(\"pt-4 border-t border-border\", classNames?.actions)}\n >\n {validationMessage && (\n <p\n id={CUSTOMIZER_VALIDATION_ID}\n data-cimplify-customizer-validation\n className={cn(\n \"text-sm mb-3\",\n quoteError ? \"text-destructive\" : \"text-muted-foreground\",\n classNames?.validation,\n )}\n >\n {validationMessage}\n </p>\n )}\n <div className=\"flex items-center gap-4\">\n <div className=\"flex flex-col gap-1\">\n <QuantitySelector\n value={quantity}\n onChange={setQuantity}\n min={product.min_order_quantity ?? 1}\n />\n {product.min_order_quantity && product.min_order_quantity > 1 && (\n <span\n data-cimplify-customizer-min-hint\n className=\"text-xs text-muted-foreground text-center\"\n >\n Min. {product.min_order_quantity}\n </span>\n )}\n </div>\n\n {product.quantity_pricing && product.quantity_pricing.length > 0 && (\n <div\n data-cimplify-customizer-unit-price\n className=\"text-sm text-muted-foreground text-right shrink-0\"\n >\n <Price\n amount={getUnitPriceAtQuantity(\n product.quantity_pricing,\n quantity,\n parsePrice(product.default_price),\n )}\n currency={currency}\n className=\"font-medium text-foreground\"\n />{\" \"}\n ea.\n </div>\n )}\n\n <Button\n onClick={handleAddToCart}\n disabled={isAdded || isSubmitting || !quoteIsReady}\n aria-describedby={validationMessage ? CUSTOMIZER_VALIDATION_ID : undefined}\n data-cimplify-customizer-submit\n className={cn(\n \"flex-1 h-14 text-base bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed rounded-full\",\n isAdded && classNames?.submitButtonAdded,\n classNames?.submitButton,\n )}\n >\n {isAdded ? (\n \"Added\"\n ) : !quoteEnabled ? (\n (submitLabel ?? \"Add to Cart\")\n ) : !quoteIsReady ? (\n quoteError ? (\n QUOTE_UNAVAILABLE_MESSAGE\n ) : (\n QUOTE_LOADING_MESSAGE\n )\n ) : (\n <>\n {submitLabel ?? \"Add to Cart\"} ·{\" \"}\n <Price amount={displayTotalPrice} currency={currency} />\n </>\n )}\n </Button>\n </div>\n </div>\n </div>\n );\n}\n"
|
|
18
18
|
}
|
|
19
19
|
]
|
|
20
20
|
}
|
package/registry/sale-badge.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
{
|
|
12
12
|
"path": "sale-badge.tsx",
|
|
13
|
-
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { Product, ProductDealInfo } from \"@cimplify/sdk\";\nimport type { ProductWithPrice } from \"@cimplify/sdk\";\nimport {\n isOnSale,\n getDiscountPercentage,\n getBasePrice,\n getProductCurrency,\n parsePrice,\n} from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface SaleBadgeClassNames {\n root?: string;\n percentage?: string;\n label?: string;\n originalPrice?: string;\n}\n\nexport interface SaleBadgeProps {\n /** Product, optionally enriched with price_info for sale detection. */\n product: Product & Partial<ProductWithPrice>;\n /** Deal info from useProductDeals / useProductsOnSale. */\n dealInfo?: ProductDealInfo;\n /** Override badge text entirely. */\n label?: string;\n /** Show the original (pre-discount) price with strikethrough styling. */\n showOriginalPrice?: boolean;\n /** Show the percentage off. Default: true. */\n showPercentage?: boolean;\n className?: string;\n classNames?: SaleBadgeClassNames;\n}\n\n/**\n * SaleBadge — shows a sale/discount indicator for a product.\n *\n * Returns `null` when there's no deal, no sale price difference, and no label override,\n * so it's safe to render unconditionally — it simply won't show for non-sale products.\n */\nexport function SaleBadge({\n product,\n dealInfo,\n label,\n showOriginalPrice = false,\n showPercentage = true,\n className,\n classNames,\n}: SaleBadgeProps): React.ReactElement | null {\n const onSale = isOnSale(product);\n const hasDeal = dealInfo !== undefined;\n\n if (!hasDeal && !onSale && !label) {\n return null;\n }\n\n //
|
|
13
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { Product, ProductDealInfo } from \"@cimplify/sdk\";\nimport type { ProductWithPrice } from \"@cimplify/sdk\";\nimport {\n isOnSale,\n getDiscountPercentage,\n getBasePrice,\n getProductCurrency,\n parsePrice,\n} from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface SaleBadgeClassNames {\n root?: string;\n percentage?: string;\n label?: string;\n originalPrice?: string;\n}\n\nexport interface SaleBadgeProps {\n /** Product, optionally enriched with price_info for sale detection. */\n product: Product & Partial<ProductWithPrice>;\n /** Deal info from useProductDeals / useProductsOnSale. */\n dealInfo?: ProductDealInfo;\n /** Override badge text entirely. */\n label?: string;\n /** Show the original (pre-discount) price with strikethrough styling. */\n showOriginalPrice?: boolean;\n /** Show the percentage off. Default: true. */\n showPercentage?: boolean;\n className?: string;\n classNames?: SaleBadgeClassNames;\n}\n\n/**\n * SaleBadge — shows a sale/discount indicator for a product.\n *\n * Returns `null` when there's no deal, no sale price difference, and no label override,\n * so it's safe to render unconditionally — it simply won't show for non-sale products.\n */\nexport function SaleBadge({\n product,\n dealInfo,\n label,\n showOriginalPrice = false,\n showPercentage = true,\n className,\n classNames,\n}: SaleBadgeProps): React.ReactElement | null {\n const onSale = isOnSale(product);\n const hasDeal = dealInfo !== undefined;\n\n if (!hasDeal && !onSale && !label) {\n return null;\n }\n\n // A live server sale is the canonical pricing decision. Deal metadata and\n // scalar price differences are compatibility fallbacks only.\n let percentage: number | null = null;\n if (product.sale) {\n percentage = parsePrice(product.sale.percent_off);\n } else if (hasDeal && dealInfo.benefit_type === \"percentage\") {\n percentage = parsePrice(dealInfo.value);\n } else if (onSale) {\n percentage = getDiscountPercentage(product);\n }\n\n // Badge text: explicit label > deal label > computed \"X% off\"\n const badgeText =\n label ??\n product.sale?.badge_text ??\n dealInfo?.label ??\n (percentage != null && percentage > 0 ? `${percentage}% off` : null);\n\n if (!badgeText) {\n return null;\n }\n\n return (\n <span\n data-cimplify-sale-badge\n className={cn(className, classNames?.root)}\n style={{ display: \"inline-flex\", alignItems: \"center\", gap: \"0.375rem\" }}\n >\n {showPercentage && percentage != null && percentage > 0 && (\n <span data-cimplify-sale-percentage className={classNames?.percentage}>\n -{percentage}%\n </span>\n )}\n <span data-cimplify-sale-label className={classNames?.label}>\n {badgeText}\n </span>\n {showOriginalPrice && onSale && (\n <span data-cimplify-sale-original-price className={classNames?.originalPrice}>\n <Price amount={getBasePrice(product)} currency={getProductCurrency(product)} />\n </span>\n )}\n </span>\n );\n}\n"
|
|
14
14
|
}
|
|
15
15
|
]
|
|
16
16
|
}
|