@cimplify/sdk 0.15.0 → 0.15.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/react.js +4 -4
- package/dist/react.mjs +4 -4
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/registry/product-card.json +1 -1
- package/registry/product-customizer.json +1 -1
package/dist/react.js
CHANGED
|
@@ -9,6 +9,7 @@ var tailwindMerge = require('tailwind-merge');
|
|
|
9
9
|
var radioGroup = require('@base-ui/react/radio-group');
|
|
10
10
|
var radio = require('@base-ui/react/radio');
|
|
11
11
|
var checkbox = require('@base-ui/react/checkbox');
|
|
12
|
+
var button = require('@base-ui/react/button');
|
|
12
13
|
var dialog = require('@base-ui/react/dialog');
|
|
13
14
|
var field = require('@base-ui/react/field');
|
|
14
15
|
var input = require('@base-ui/react/input');
|
|
@@ -9141,9 +9142,8 @@ function ProductCustomizer({
|
|
|
9141
9142
|
}
|
|
9142
9143
|
),
|
|
9143
9144
|
/* @__PURE__ */ jsxRuntime.jsx(
|
|
9144
|
-
|
|
9145
|
+
button.Button,
|
|
9145
9146
|
{
|
|
9146
|
-
type: "button",
|
|
9147
9147
|
onClick: handleAddToCart,
|
|
9148
9148
|
disabled: isAdded || isSubmitting || !quoteEnabled,
|
|
9149
9149
|
"aria-describedby": !quoteEnabled ? "cimplify-customizer-validation" : void 0,
|
|
@@ -9729,7 +9729,7 @@ function ProductCard({
|
|
|
9729
9729
|
{
|
|
9730
9730
|
"data-cimplify-product-card-backdrop": true,
|
|
9731
9731
|
className: cn(
|
|
9732
|
-
"fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity",
|
|
9732
|
+
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm transition-opacity",
|
|
9733
9733
|
classNames?.modalOverlay
|
|
9734
9734
|
)
|
|
9735
9735
|
}
|
|
@@ -9739,7 +9739,7 @@ function ProductCard({
|
|
|
9739
9739
|
{
|
|
9740
9740
|
"data-cimplify-product-card-modal": true,
|
|
9741
9741
|
className: cn(
|
|
9742
|
-
"fixed rounded-2xl p-0 max-w-lg w-full max-h-[85vh] overflow-auto bg-background shadow-2xl outline-none",
|
|
9742
|
+
"fixed z-50 rounded-2xl p-0 max-w-lg w-full max-h-[85vh] overflow-auto bg-background shadow-2xl outline-none",
|
|
9743
9743
|
classNames?.modal
|
|
9744
9744
|
),
|
|
9745
9745
|
children: isOpen && (productDetails ? renderModal ? renderModal(productDetails, handleClose) : /* @__PURE__ */ jsxRuntime.jsx(
|
package/dist/react.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { twMerge } from 'tailwind-merge';
|
|
|
7
7
|
import { RadioGroup } from '@base-ui/react/radio-group';
|
|
8
8
|
import { Radio } from '@base-ui/react/radio';
|
|
9
9
|
import { Checkbox } from '@base-ui/react/checkbox';
|
|
10
|
+
import { Button } from '@base-ui/react/button';
|
|
10
11
|
import { Dialog } from '@base-ui/react/dialog';
|
|
11
12
|
import { Field } from '@base-ui/react/field';
|
|
12
13
|
import { Input } from '@base-ui/react/input';
|
|
@@ -9135,9 +9136,8 @@ function ProductCustomizer({
|
|
|
9135
9136
|
}
|
|
9136
9137
|
),
|
|
9137
9138
|
/* @__PURE__ */ jsx(
|
|
9138
|
-
|
|
9139
|
+
Button,
|
|
9139
9140
|
{
|
|
9140
|
-
type: "button",
|
|
9141
9141
|
onClick: handleAddToCart,
|
|
9142
9142
|
disabled: isAdded || isSubmitting || !quoteEnabled,
|
|
9143
9143
|
"aria-describedby": !quoteEnabled ? "cimplify-customizer-validation" : void 0,
|
|
@@ -9723,7 +9723,7 @@ function ProductCard({
|
|
|
9723
9723
|
{
|
|
9724
9724
|
"data-cimplify-product-card-backdrop": true,
|
|
9725
9725
|
className: cn(
|
|
9726
|
-
"fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity",
|
|
9726
|
+
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm transition-opacity",
|
|
9727
9727
|
classNames?.modalOverlay
|
|
9728
9728
|
)
|
|
9729
9729
|
}
|
|
@@ -9733,7 +9733,7 @@ function ProductCard({
|
|
|
9733
9733
|
{
|
|
9734
9734
|
"data-cimplify-product-card-modal": true,
|
|
9735
9735
|
className: cn(
|
|
9736
|
-
"fixed rounded-2xl p-0 max-w-lg w-full max-h-[85vh] overflow-auto bg-background shadow-2xl outline-none",
|
|
9736
|
+
"fixed z-50 rounded-2xl p-0 max-w-lg w-full max-h-[85vh] overflow-auto bg-background shadow-2xl outline-none",
|
|
9737
9737
|
classNames?.modal
|
|
9738
9738
|
),
|
|
9739
9739
|
children: isOpen && (productDetails ? renderModal ? renderModal(productDetails, handleClose) : /* @__PURE__ */ jsx(
|
package/dist/styles.css
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */
|
|
2
|
-
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.container{width:100%}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.aspect-\[4\/3\]{aspect-ratio:4/3}.max-h-\[85vh\]{max-height:85vh}.w-3\/5{width:60%}.w-full{width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.\[appearance\:textfield\]{appearance:textfield}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-border>:not(:last-child)){border-color:var(--color-border,oklch(90% 0 0))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.rounded{border-radius:var(--radius,.5rem)}.rounded-full{border-radius:3.40282e38px}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-none{--tw-border-style:none;border-style:none}.border-border{border-color:var(--color-border,oklch(90% 0 0))}.border-muted-foreground\/30{border-color:#6363634d}@supports (color:color-mix(in lab, red, red)){.border-muted-foreground\/30{border-color:color-mix(in oklab, var(--color-muted-foreground,oklch(50% 0 0)) 30%, transparent)}}.border-primary{border-color:var(--color-primary,oklch(50% .1 35))}.bg-background{background-color:var(--color-background,oklch(99% 0 0))}.bg-destructive\/10{background-color:#bb061e1a}@supports (color:color-mix(in lab, red, red)){.bg-destructive\/10{background-color:color-mix(in oklab, var(--color-destructive,oklch(50% .2 25)) 10%, transparent)}}.bg-muted{background-color:var(--color-muted,oklch(95% 0 0))}.bg-primary{background-color:var(--color-primary,oklch(50% .1 35))}.bg-primary\/10{background-color:#934c3a1a}@supports (color:color-mix(in lab, red, red)){.bg-primary\/10{background-color:color-mix(in oklab, var(--color-primary,oklch(50% .1 35)) 10%, transparent)}}.bg-transparent{background-color:#0000}.text-center{text-align:center}.text-left{text-align:left}.font-\[inherit\]{font-family:inherit}.text-\[10px\]{font-size:10px}.text-\[inherit\]{color:inherit}.text-destructive{color:var(--color-destructive,oklch(50% .2 25))}.text-muted-foreground{color:var(--color-muted-foreground,oklch(50% 0 0))}.text-muted-foreground\/60{color:#63636399}@supports (color:color-mix(in lab, red, red)){.text-muted-foreground\/60{color:color-mix(in oklab, var(--color-muted-foreground,oklch(50% 0 0)) 60%, transparent)}}.text-primary{color:var(--color-primary,oklch(50% .1 35))}.text-primary-foreground{color:var(--color-primary-foreground,oklch(99% 0 0))}.uppercase{text-transform:uppercase}.no-underline{text-decoration-line:none}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,ease);transition-duration:var(--tw-duration,0s)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,ease);transition-duration:var(--tw-duration,0s)}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,ease);transition-duration:var(--tw-duration,0s)}.outline-none{--tw-outline-style:none;outline-style:none}.\[cimplify\:checkout\]{cimplify:checkout}@media (hover:hover){.hover\:border-primary\/50:hover{border-color:#934c3a80}@supports (color:color-mix(in lab, red, red)){.hover\:border-primary\/50:hover{border-color:color-mix(in oklab, var(--color-primary,oklch(50% .1 35)) 50%, transparent)}}.hover\:bg-muted:hover{background-color:var(--color-muted,oklch(95% 0 0))}.hover\:bg-primary\/90:hover{background-color:#934c3ae6}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab, var(--color-primary,oklch(50% .1 35)) 90%, transparent)}}.hover\:text-primary:hover{color:var(--color-primary,oklch(50% .1 35))}}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-50:disabled{opacity:.5}.\[\&\:\:-webkit-inner-spin-button\]\:appearance-none::-webkit-inner-spin-button{appearance:none}.\[\&\:\:-webkit-outer-spin-button\]\:appearance-none::-webkit-outer-spin-button{appearance:none}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
|
|
2
|
+
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.z-50{z-index:50}.container{width:100%}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.aspect-\[4\/3\]{aspect-ratio:4/3}.max-h-\[85vh\]{max-height:85vh}.w-3\/5{width:60%}.w-full{width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.\[appearance\:textfield\]{appearance:textfield}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-border>:not(:last-child)){border-color:var(--color-border,oklch(90% 0 0))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.rounded{border-radius:var(--radius,.5rem)}.rounded-full{border-radius:3.40282e38px}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-none{--tw-border-style:none;border-style:none}.border-border{border-color:var(--color-border,oklch(90% 0 0))}.border-muted-foreground\/30{border-color:#6363634d}@supports (color:color-mix(in lab, red, red)){.border-muted-foreground\/30{border-color:color-mix(in oklab, var(--color-muted-foreground,oklch(50% 0 0)) 30%, transparent)}}.border-primary{border-color:var(--color-primary,oklch(50% .1 35))}.bg-background{background-color:var(--color-background,oklch(99% 0 0))}.bg-destructive\/10{background-color:#bb061e1a}@supports (color:color-mix(in lab, red, red)){.bg-destructive\/10{background-color:color-mix(in oklab, var(--color-destructive,oklch(50% .2 25)) 10%, transparent)}}.bg-muted{background-color:var(--color-muted,oklch(95% 0 0))}.bg-primary{background-color:var(--color-primary,oklch(50% .1 35))}.bg-primary\/10{background-color:#934c3a1a}@supports (color:color-mix(in lab, red, red)){.bg-primary\/10{background-color:color-mix(in oklab, var(--color-primary,oklch(50% .1 35)) 10%, transparent)}}.bg-transparent{background-color:#0000}.text-center{text-align:center}.text-left{text-align:left}.font-\[inherit\]{font-family:inherit}.text-\[10px\]{font-size:10px}.text-\[inherit\]{color:inherit}.text-destructive{color:var(--color-destructive,oklch(50% .2 25))}.text-muted-foreground{color:var(--color-muted-foreground,oklch(50% 0 0))}.text-muted-foreground\/60{color:#63636399}@supports (color:color-mix(in lab, red, red)){.text-muted-foreground\/60{color:color-mix(in oklab, var(--color-muted-foreground,oklch(50% 0 0)) 60%, transparent)}}.text-primary{color:var(--color-primary,oklch(50% .1 35))}.text-primary-foreground{color:var(--color-primary-foreground,oklch(99% 0 0))}.uppercase{text-transform:uppercase}.no-underline{text-decoration-line:none}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,ease);transition-duration:var(--tw-duration,0s)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,ease);transition-duration:var(--tw-duration,0s)}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,ease);transition-duration:var(--tw-duration,0s)}.outline-none{--tw-outline-style:none;outline-style:none}.\[cimplify\:checkout\]{cimplify:checkout}@media (hover:hover){.hover\:border-primary\/50:hover{border-color:#934c3a80}@supports (color:color-mix(in lab, red, red)){.hover\:border-primary\/50:hover{border-color:color-mix(in oklab, var(--color-primary,oklch(50% .1 35)) 50%, transparent)}}.hover\:bg-muted:hover{background-color:var(--color-muted,oklch(95% 0 0))}.hover\:bg-primary\/90:hover{background-color:#934c3ae6}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab, var(--color-primary,oklch(50% .1 35)) 90%, transparent)}}.hover\:text-primary:hover{color:var(--color-primary,oklch(50% .1 35))}}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-50:disabled{opacity:.5}.\[\&\:\:-webkit-inner-spin-button\]\:appearance-none::-webkit-inner-spin-button{appearance:none}.\[\&\:\:-webkit-outer-spin-button\]\:appearance-none::-webkit-outer-spin-button{appearance:none}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
|
package/package.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
{
|
|
13
13
|
"path": "product-card.tsx",
|
|
14
|
-
"content": "\"use client\";\n\nimport React, { useCallback, useState } from \"react\";\nimport { Dialog } from \"@base-ui/react/dialog\";\nimport type { Product, ProductWithDetails } from \"@cimplify/sdk\";\nimport { useProduct } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { ProductSheet } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nconst ASPECT_STYLES: Record<string, React.CSSProperties> = {\n square: { aspectRatio: \"1/1\" },\n \"4/3\": { aspectRatio: \"4/3\" },\n \"16/10\": { aspectRatio: \"16/10\" },\n \"3/4\": { aspectRatio: \"3/4\" },\n};\n\nexport interface ProductCardClassNames {\n root?: string;\n imageContainer?: string;\n image?: string;\n body?: string;\n name?: string;\n description?: string;\n price?: string;\n badges?: string;\n badge?: string;\n modal?: string;\n modalOverlay?: string;\n}\n\nexport interface ProductCardProps {\n /** The product to display. */\n product: Product;\n /** Display mode: \"card\" opens a modal, \"page\" renders as a link. Auto-detected from product.display_mode. */\n displayMode?: \"card\" | \"page\";\n /** Link href for page mode. Default: `/menu/${product.slug}` */\n href?: string;\n /** Custom modal content renderer. Receives the fully-loaded product and a close callback. */\n renderModal?: (product: ProductWithDetails, onClose: () => void) => React.ReactNode;\n /** Custom image renderer (e.g. Next.js Image). */\n renderImage?: (props: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n /** Custom link renderer for page mode (e.g. Next.js Link). */\n renderLink?: (props: {\n href: string;\n className?: string;\n children: React.ReactNode;\n }) => React.ReactElement;\n /** Replace the entire default card body. */\n children?: React.ReactNode;\n /** Image aspect ratio. Default: \"4/3\". */\n aspectRatio?: \"square\" | \"4/3\" | \"16/10\" | \"3/4\";\n className?: string;\n classNames?: ProductCardClassNames;\n}\n\n/**\n * ProductCard — a product display card with two modes:\n *\n * - **card** (default): clickable button that opens a Base UI Dialog modal\n * - **page**: a plain `<a>` link for SEO-friendly product pages\n */\nexport function ProductCard({\n product,\n displayMode,\n href,\n renderModal,\n renderImage,\n renderLink,\n children,\n aspectRatio = \"4/3\",\n className,\n classNames,\n}: ProductCardProps): React.ReactElement {\n const mode = displayMode ?? product.display_mode ?? \"card\";\n const [isOpen, setIsOpen] = useState(false);\n const [shouldFetch, setShouldFetch] = useState(false);\n\n // Prefetch on pointer enter, always fetch when open\n const { product: productDetails } = useProduct(\n product.slug ?? product.id,\n { enabled: shouldFetch || isOpen },\n );\n\n const handlePrefetch = useCallback(() => {\n setShouldFetch(true);\n }, []);\n\n const handleOpenChange = useCallback((open: boolean) => {\n setIsOpen(open);\n if (open) {\n setShouldFetch(true);\n }\n }, []);\n\n const handleClose = useCallback(() => {\n setIsOpen(false);\n }, []);\n\n const imageUrl = product.image_url || product.images?.[0];\n\n const cardBody = children ?? (\n <>\n {/* Image */}\n {imageUrl && (\n <div\n data-cimplify-product-card-image-container\n className={classNames?.imageContainer}\n style={{\n overflow: \"hidden\",\n ...ASPECT_STYLES[aspectRatio],\n }}\n >\n {renderImage ? (\n renderImage({\n src: imageUrl,\n alt: product.name,\n className: classNames?.image,\n })\n ) : (\n <img\n src={imageUrl}\n alt={product.name}\n className={classNames?.image}\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n data-cimplify-product-card-image\n />\n )}\n </div>\n )}\n\n {/* Body */}\n <div\n data-cimplify-product-card-body\n className={classNames?.body}\n >\n <span\n data-cimplify-product-card-name\n className={classNames?.name}\n >\n {product.name}\n </span>\n {product.description && (\n <span\n data-cimplify-product-card-description\n className={classNames?.description}\n style={{\n display: \"-webkit-box\",\n WebkitLineClamp: 2,\n WebkitBoxOrient: \"vertical\",\n overflow: \"hidden\",\n }}\n >\n {product.description}\n </span>\n )}\n <Price\n amount={product.default_price}\n className={classNames?.price}\n />\n </div>\n </>\n );\n\n // Page mode — render as a link\n if (mode === \"page\") {\n const linkHref = href ?? `/menu/${product.slug}`;\n const linkClassName = cn(\"block no-underline text-[inherit]\", className, classNames?.root);\n\n if (renderLink) {\n return renderLink({ href: linkHref, className: linkClassName, children: cardBody });\n }\n\n return (\n <a\n href={linkHref}\n data-cimplify-product-card\n data-display-mode=\"page\"\n className={linkClassName}\n >\n {cardBody}\n </a>\n );\n }\n\n // Card mode — render as Base UI Dialog\n return (\n <Dialog.Root open={isOpen} onOpenChange={handleOpenChange}>\n <Dialog.Trigger\n onPointerEnter={handlePrefetch}\n data-cimplify-product-card\n data-display-mode=\"card\"\n className={cn(\n \"block w-full text-left bg-transparent border-none p-0 cursor-pointer font-[inherit] text-[inherit]\",\n className,\n classNames?.root,\n )}\n >\n {cardBody}\n </Dialog.Trigger>\n\n <Dialog.Portal>\n <Dialog.Backdrop\n data-cimplify-product-card-backdrop\n className={cn(\n \"fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity\",\n classNames?.modalOverlay,\n )}\n />\n <Dialog.Popup\n data-cimplify-product-card-modal\n className={cn(\n \"fixed rounded-2xl p-0 max-w-lg w-full max-h-[85vh] overflow-auto bg-background shadow-2xl outline-none\",\n classNames?.modal,\n )}\n >\n {isOpen && (\n productDetails ? (\n renderModal ? (\n renderModal(productDetails, handleClose)\n ) : (\n <ProductSheet\n product={productDetails}\n onClose={handleClose}\n renderImage={renderImage}\n />\n )\n ) : (\n <div\n data-cimplify-product-card-modal-loading\n aria-busy=\"true\"\n className=\"flex flex-col gap-4 p-6\"\n >\n <div className=\"aspect-[4/3] bg-muted rounded-lg animate-pulse\" />\n <div className=\"h-6 w-3/5 bg-muted rounded animate-pulse\" />\n </div>\n )\n )}\n </Dialog.Popup>\n </Dialog.Portal>\n </Dialog.Root>\n );\n}\n"
|
|
14
|
+
"content": "\"use client\";\n\nimport React, { useCallback, useState } from \"react\";\nimport { Dialog } from \"@base-ui/react/dialog\";\nimport type { Product, ProductWithDetails } from \"@cimplify/sdk\";\nimport { useProduct } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { ProductSheet } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nconst ASPECT_STYLES: Record<string, React.CSSProperties> = {\n square: { aspectRatio: \"1/1\" },\n \"4/3\": { aspectRatio: \"4/3\" },\n \"16/10\": { aspectRatio: \"16/10\" },\n \"3/4\": { aspectRatio: \"3/4\" },\n};\n\nexport interface ProductCardClassNames {\n root?: string;\n imageContainer?: string;\n image?: string;\n body?: string;\n name?: string;\n description?: string;\n price?: string;\n badges?: string;\n badge?: string;\n modal?: string;\n modalOverlay?: string;\n}\n\nexport interface ProductCardProps {\n /** The product to display. */\n product: Product;\n /** Display mode: \"card\" opens a modal, \"page\" renders as a link. Auto-detected from product.display_mode. */\n displayMode?: \"card\" | \"page\";\n /** Link href for page mode. Default: `/menu/${product.slug}` */\n href?: string;\n /** Custom modal content renderer. Receives the fully-loaded product and a close callback. */\n renderModal?: (product: ProductWithDetails, onClose: () => void) => React.ReactNode;\n /** Custom image renderer (e.g. Next.js Image). */\n renderImage?: (props: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n /** Custom link renderer for page mode (e.g. Next.js Link). */\n renderLink?: (props: {\n href: string;\n className?: string;\n children: React.ReactNode;\n }) => React.ReactElement;\n /** Replace the entire default card body. */\n children?: React.ReactNode;\n /** Image aspect ratio. Default: \"4/3\". */\n aspectRatio?: \"square\" | \"4/3\" | \"16/10\" | \"3/4\";\n className?: string;\n classNames?: ProductCardClassNames;\n}\n\n/**\n * ProductCard — a product display card with two modes:\n *\n * - **card** (default): clickable button that opens a Base UI Dialog modal\n * - **page**: a plain `<a>` link for SEO-friendly product pages\n */\nexport function ProductCard({\n product,\n displayMode,\n href,\n renderModal,\n renderImage,\n renderLink,\n children,\n aspectRatio = \"4/3\",\n className,\n classNames,\n}: ProductCardProps): React.ReactElement {\n const mode = displayMode ?? product.display_mode ?? \"card\";\n const [isOpen, setIsOpen] = useState(false);\n const [shouldFetch, setShouldFetch] = useState(false);\n\n // Prefetch on pointer enter, always fetch when open\n const { product: productDetails } = useProduct(\n product.slug ?? product.id,\n { enabled: shouldFetch || isOpen },\n );\n\n const handlePrefetch = useCallback(() => {\n setShouldFetch(true);\n }, []);\n\n const handleOpenChange = useCallback((open: boolean) => {\n setIsOpen(open);\n if (open) {\n setShouldFetch(true);\n }\n }, []);\n\n const handleClose = useCallback(() => {\n setIsOpen(false);\n }, []);\n\n const imageUrl = product.image_url || product.images?.[0];\n\n const cardBody = children ?? (\n <>\n {/* Image */}\n {imageUrl && (\n <div\n data-cimplify-product-card-image-container\n className={classNames?.imageContainer}\n style={{\n overflow: \"hidden\",\n ...ASPECT_STYLES[aspectRatio],\n }}\n >\n {renderImage ? (\n renderImage({\n src: imageUrl,\n alt: product.name,\n className: classNames?.image,\n })\n ) : (\n <img\n src={imageUrl}\n alt={product.name}\n className={classNames?.image}\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n data-cimplify-product-card-image\n />\n )}\n </div>\n )}\n\n {/* Body */}\n <div\n data-cimplify-product-card-body\n className={classNames?.body}\n >\n <span\n data-cimplify-product-card-name\n className={classNames?.name}\n >\n {product.name}\n </span>\n {product.description && (\n <span\n data-cimplify-product-card-description\n className={classNames?.description}\n style={{\n display: \"-webkit-box\",\n WebkitLineClamp: 2,\n WebkitBoxOrient: \"vertical\",\n overflow: \"hidden\",\n }}\n >\n {product.description}\n </span>\n )}\n <Price\n amount={product.default_price}\n className={classNames?.price}\n />\n </div>\n </>\n );\n\n // Page mode — render as a link\n if (mode === \"page\") {\n const linkHref = href ?? `/menu/${product.slug}`;\n const linkClassName = cn(\"block no-underline text-[inherit]\", className, classNames?.root);\n\n if (renderLink) {\n return renderLink({ href: linkHref, className: linkClassName, children: cardBody });\n }\n\n return (\n <a\n href={linkHref}\n data-cimplify-product-card\n data-display-mode=\"page\"\n className={linkClassName}\n >\n {cardBody}\n </a>\n );\n }\n\n // Card mode — render as Base UI Dialog\n return (\n <Dialog.Root open={isOpen} onOpenChange={handleOpenChange}>\n <Dialog.Trigger\n onPointerEnter={handlePrefetch}\n data-cimplify-product-card\n data-display-mode=\"card\"\n className={cn(\n \"block w-full text-left bg-transparent border-none p-0 cursor-pointer font-[inherit] text-[inherit]\",\n className,\n classNames?.root,\n )}\n >\n {cardBody}\n </Dialog.Trigger>\n\n <Dialog.Portal>\n <Dialog.Backdrop\n data-cimplify-product-card-backdrop\n className={cn(\n \"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm transition-opacity\",\n classNames?.modalOverlay,\n )}\n />\n <Dialog.Popup\n data-cimplify-product-card-modal\n className={cn(\n \"fixed z-50 rounded-2xl p-0 max-w-lg w-full max-h-[85vh] overflow-auto bg-background shadow-2xl outline-none\",\n classNames?.modal,\n )}\n >\n {isOpen && (\n productDetails ? (\n renderModal ? (\n renderModal(productDetails, handleClose)\n ) : (\n <ProductSheet\n product={productDetails}\n onClose={handleClose}\n renderImage={renderImage}\n />\n )\n ) : (\n <div\n data-cimplify-product-card-modal-loading\n aria-busy=\"true\"\n className=\"flex flex-col gap-4 p-6\"\n >\n <div className=\"aspect-[4/3] bg-muted rounded-lg animate-pulse\" />\n <div className=\"h-6 w-3/5 bg-muted rounded animate-pulse\" />\n </div>\n )\n )}\n </Dialog.Popup>\n </Dialog.Portal>\n </Dialog.Root>\n );\n}\n"
|
|
15
15
|
}
|
|
16
16
|
]
|
|
17
17
|
}
|
|
@@ -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 } from \"react\";\nimport type {\n ProductWithDetails,\n ProductVariant,\n AddOnOption,\n ComponentSelectionInput,\n CompositePriceResult,\n} from \"@cimplify/sdk\";\nimport type { BundleSelectionInput } from \"@cimplify/sdk\";\nimport { parsePrice } from \"@cimplify/sdk\";\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 { CompositeSelector } from \"@cimplify/sdk/react\";\nimport { BundleSelector } from \"@cimplify/sdk/react\";\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}\n\nexport interface ProductCustomizerProps {\n product: ProductWithDetails;\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n className?: string;\n classNames?: ProductCustomizerClassNames;\n}\n\nexport function ProductCustomizer({\n product,\n onAddToCart,\n className,\n classNames,\n}: ProductCustomizerProps): React.ReactElement {\n const [quantity, setQuantity] = useState(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<ProductVariant | 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\n const cart = useCart();\n\n const productType = product.type || \"product\";\n const isComposite = productType === \"composite\";\n const isBundle = productType === \"bundle\";\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(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 }, [product.id]);\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 quoteEnabled = isComposite\n ? compositeReady\n : isBundle\n ? bundleReady\n : requiredAddOnsSatisfied;\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 // quoted_total_price_info already includes quantity multiplication\n if (quote.quoted_total_price_info?.final_price != null) {\n return parsePrice(quote.quoted_total_price_info.final_price);\n }\n\n // final_price_info is per-unit — multiply by quantity\n const perUnit = quote.final_price_info.final_price;\n if (perUnit === undefined || perUnit === null) return undefined;\n return parsePrice(perUnit) * quantity;\n }, [quote, quantity]);\n\n const displayTotalPrice = quotedTotalPrice ?? localTotalPrice;\n\n const handleVariantChange = useCallback(\n (variantId: string | undefined, variant: ProductVariant | undefined) => {\n setSelectedVariantId(variantId);\n setSelectedVariant(variant);\n },\n [],\n );\n\n const handleAddToCart = 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 };\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 setTimeout(() => {\n setIsAdded(false);\n setQuantity(1);\n }, 2000);\n } catch {\n // Caller handles errors via onAddToCart or cart hook error state\n } finally {\n setIsSubmitting(false);\n }\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 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 onSelectionsChange={setBundleSelections}\n onPriceChange={setBundleTotalPrice}\n onReady={setBundleReady}\n />\n )}\n\n {hasVariants && (\n <VariantSelector\n variants={product.variants!}\n variantAxes={product.variant_axes}\n basePrice={product.default_price}\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 />\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 <QuantitySelector\n value={quantity}\n onChange={setQuantity}\n min={1}\n />\n\n <button\n type=\"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 to Cart\" : (\n <>\n Add to Cart · <Price amount={displayTotalPrice} />\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 } from \"react\";\nimport { Button } from \"@base-ui/react/button\";\nimport type {\n ProductWithDetails,\n ProductVariant,\n AddOnOption,\n ComponentSelectionInput,\n CompositePriceResult,\n} from \"@cimplify/sdk\";\nimport type { BundleSelectionInput } from \"@cimplify/sdk\";\nimport { parsePrice } from \"@cimplify/sdk\";\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 { CompositeSelector } from \"@cimplify/sdk/react\";\nimport { BundleSelector } from \"@cimplify/sdk/react\";\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}\n\nexport interface ProductCustomizerProps {\n product: ProductWithDetails;\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n className?: string;\n classNames?: ProductCustomizerClassNames;\n}\n\nexport function ProductCustomizer({\n product,\n onAddToCart,\n className,\n classNames,\n}: ProductCustomizerProps): React.ReactElement {\n const [quantity, setQuantity] = useState(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<ProductVariant | 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\n const cart = useCart();\n\n const productType = product.type || \"product\";\n const isComposite = productType === \"composite\";\n const isBundle = productType === \"bundle\";\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(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 }, [product.id]);\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 quoteEnabled = isComposite\n ? compositeReady\n : isBundle\n ? bundleReady\n : requiredAddOnsSatisfied;\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 // quoted_total_price_info already includes quantity multiplication\n if (quote.quoted_total_price_info?.final_price != null) {\n return parsePrice(quote.quoted_total_price_info.final_price);\n }\n\n // final_price_info is per-unit — multiply by quantity\n const perUnit = quote.final_price_info.final_price;\n if (perUnit === undefined || perUnit === null) return undefined;\n return parsePrice(perUnit) * quantity;\n }, [quote, quantity]);\n\n const displayTotalPrice = quotedTotalPrice ?? localTotalPrice;\n\n const handleVariantChange = useCallback(\n (variantId: string | undefined, variant: ProductVariant | undefined) => {\n setSelectedVariantId(variantId);\n setSelectedVariant(variant);\n },\n [],\n );\n\n const handleAddToCart = 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 };\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 setTimeout(() => {\n setIsAdded(false);\n setQuantity(1);\n }, 2000);\n } catch {\n // Caller handles errors via onAddToCart or cart hook error state\n } finally {\n setIsSubmitting(false);\n }\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 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 onSelectionsChange={setBundleSelections}\n onPriceChange={setBundleTotalPrice}\n onReady={setBundleReady}\n />\n )}\n\n {hasVariants && (\n <VariantSelector\n variants={product.variants!}\n variantAxes={product.variant_axes}\n basePrice={product.default_price}\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 />\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 <QuantitySelector\n value={quantity}\n onChange={setQuantity}\n min={1}\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 to Cart\" : (\n <>\n Add to Cart · <Price amount={displayTotalPrice} />\n </>\n )}\n </Button>\n </div>\n </div>\n\n </div>\n );\n}\n"
|
|
18
18
|
}
|
|
19
19
|
]
|
|
20
20
|
}
|