@cimplify/sdk 0.14.2 → 0.15.0

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.d.mts CHANGED
@@ -901,7 +901,7 @@ interface ProductCardProps {
901
901
  /**
902
902
  * ProductCard — a product display card with two modes:
903
903
  *
904
- * - **card** (default): clickable button that opens a native `<dialog>` modal with a ProductSheet
904
+ * - **card** (default): clickable button that opens a Base UI Dialog modal
905
905
  * - **page**: a plain `<a>` link for SEO-friendly product pages
906
906
  */
907
907
  declare function ProductCard({ product, displayMode, href, renderModal, renderImage, renderLink, children, aspectRatio, className, classNames, }: ProductCardProps): React.ReactElement;
package/dist/react.d.ts CHANGED
@@ -901,7 +901,7 @@ interface ProductCardProps {
901
901
  /**
902
902
  * ProductCard — a product display card with two modes:
903
903
  *
904
- * - **card** (default): clickable button that opens a native `<dialog>` modal with a ProductSheet
904
+ * - **card** (default): clickable button that opens a Base UI Dialog modal
905
905
  * - **page**: a plain `<a>` link for SEO-friendly product pages
906
906
  */
907
907
  declare function ProductCard({ product, displayMode, href, renderModal, renderImage, renderLink, children, aspectRatio, className, classNames, }: ProductCardProps): React.ReactElement;
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 dialog = require('@base-ui/react/dialog');
12
13
  var field = require('@base-ui/react/field');
13
14
  var input = require('@base-ui/react/input');
14
15
  var tabs = require('@base-ui/react/tabs');
@@ -9024,9 +9025,13 @@ function ProductCustomizer({
9024
9025
  const quoteId = quote?.quote_id;
9025
9026
  const quotedTotalPrice = React3.useMemo(() => {
9026
9027
  if (!quote) return void 0;
9027
- const quotedTotal = quote.quoted_total_price_info?.final_price ?? quote.final_price_info.final_price;
9028
- return quotedTotal === void 0 || quotedTotal === null ? void 0 : parsePrice(quotedTotal);
9029
- }, [quote]);
9028
+ if (quote.quoted_total_price_info?.final_price != null) {
9029
+ return parsePrice(quote.quoted_total_price_info.final_price);
9030
+ }
9031
+ const perUnit = quote.final_price_info.final_price;
9032
+ if (perUnit === void 0 || perUnit === null) return void 0;
9033
+ return parsePrice(perUnit) * quantity;
9034
+ }, [quote, quantity]);
9030
9035
  const displayTotalPrice = quotedTotalPrice ?? localTotalPrice;
9031
9036
  const handleVariantChange = React3.useCallback(
9032
9037
  (variantId, variant) => {
@@ -9115,46 +9120,48 @@ function ProductCustomizer({
9115
9120
  "div",
9116
9121
  {
9117
9122
  "data-cimplify-customizer-actions": true,
9118
- className: cn("flex items-center gap-4 pt-4 border-t border-border", classNames?.actions),
9123
+ className: cn("pt-4 border-t border-border", classNames?.actions),
9119
9124
  children: [
9120
- /* @__PURE__ */ jsxRuntime.jsx(
9121
- QuantitySelector,
9125
+ !quoteEnabled && /* @__PURE__ */ jsxRuntime.jsx(
9126
+ "p",
9122
9127
  {
9123
- value: quantity,
9124
- onChange: setQuantity,
9125
- min: 1
9128
+ id: "cimplify-customizer-validation",
9129
+ "data-cimplify-customizer-validation": true,
9130
+ className: cn("text-sm text-destructive mb-3", classNames?.validation),
9131
+ children: "Please select all required options"
9126
9132
  }
9127
9133
  ),
9128
- /* @__PURE__ */ jsxRuntime.jsx(
9129
- "button",
9130
- {
9131
- type: "button",
9132
- onClick: handleAddToCart,
9133
- disabled: isAdded || isSubmitting || !quoteEnabled,
9134
- "aria-describedby": !quoteEnabled ? "cimplify-customizer-validation" : void 0,
9135
- "data-cimplify-customizer-submit": true,
9136
- className: cn(
9137
- "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",
9138
- isAdded && classNames?.submitButtonAdded,
9139
- classNames?.submitButton
9140
- ),
9141
- children: isAdded ? "Added to Cart" : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
9142
- "Add to Cart \xB7 ",
9143
- /* @__PURE__ */ jsxRuntime.jsx(Price, { amount: displayTotalPrice })
9144
- ] })
9145
- }
9146
- )
9134
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-4", children: [
9135
+ /* @__PURE__ */ jsxRuntime.jsx(
9136
+ QuantitySelector,
9137
+ {
9138
+ value: quantity,
9139
+ onChange: setQuantity,
9140
+ min: 1
9141
+ }
9142
+ ),
9143
+ /* @__PURE__ */ jsxRuntime.jsx(
9144
+ "button",
9145
+ {
9146
+ type: "button",
9147
+ onClick: handleAddToCart,
9148
+ disabled: isAdded || isSubmitting || !quoteEnabled,
9149
+ "aria-describedby": !quoteEnabled ? "cimplify-customizer-validation" : void 0,
9150
+ "data-cimplify-customizer-submit": true,
9151
+ className: cn(
9152
+ "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",
9153
+ isAdded && classNames?.submitButtonAdded,
9154
+ classNames?.submitButton
9155
+ ),
9156
+ children: isAdded ? "Added to Cart" : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
9157
+ "Add to Cart \xB7 ",
9158
+ /* @__PURE__ */ jsxRuntime.jsx(Price, { amount: displayTotalPrice })
9159
+ ] })
9160
+ }
9161
+ )
9162
+ ] })
9147
9163
  ]
9148
9164
  }
9149
- ),
9150
- !quoteEnabled && /* @__PURE__ */ jsxRuntime.jsx(
9151
- "p",
9152
- {
9153
- id: "cimplify-customizer-validation",
9154
- "data-cimplify-customizer-validation": true,
9155
- className: cn("text-sm text-destructive mt-2", classNames?.validation),
9156
- children: "Please select all required options"
9157
- }
9158
9165
  )
9159
9166
  ] });
9160
9167
  }
@@ -9602,15 +9609,6 @@ function ProductCard({
9602
9609
  const mode = displayMode ?? product.display_mode ?? "card";
9603
9610
  const [isOpen, setIsOpen] = React3.useState(false);
9604
9611
  const [shouldFetch, setShouldFetch] = React3.useState(false);
9605
- const dialogRef = React3.useRef(null);
9606
- React3.useEffect(() => {
9607
- if (!isOpen) return;
9608
- const original = document.body.style.overflow;
9609
- document.body.style.overflow = "hidden";
9610
- return () => {
9611
- document.body.style.overflow = original;
9612
- };
9613
- }, [isOpen]);
9614
9612
  const { product: productDetails } = useProduct(
9615
9613
  product.slug ?? product.id,
9616
9614
  { enabled: shouldFetch || isOpen }
@@ -9618,26 +9616,15 @@ function ProductCard({
9618
9616
  const handlePrefetch = React3.useCallback(() => {
9619
9617
  setShouldFetch(true);
9620
9618
  }, []);
9621
- const handleOpen = React3.useCallback(() => {
9622
- setIsOpen(true);
9623
- setShouldFetch(true);
9624
- dialogRef.current?.showModal();
9619
+ const handleOpenChange = React3.useCallback((open) => {
9620
+ setIsOpen(open);
9621
+ if (open) {
9622
+ setShouldFetch(true);
9623
+ }
9625
9624
  }, []);
9626
9625
  const handleClose = React3.useCallback(() => {
9627
- dialogRef.current?.close();
9628
- setIsOpen(false);
9629
- }, []);
9630
- const handleCancel = React3.useCallback(() => {
9631
9626
  setIsOpen(false);
9632
9627
  }, []);
9633
- const handleBackdropClick = React3.useCallback(
9634
- (e) => {
9635
- if (e.target === dialogRef.current) {
9636
- handleClose();
9637
- }
9638
- },
9639
- [handleClose]
9640
- );
9641
9628
  const imageUrl = product.image_url || product.images?.[0];
9642
9629
  const cardBody = children ?? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
9643
9630
  imageUrl && /* @__PURE__ */ jsxRuntime.jsx(
@@ -9721,14 +9708,11 @@ function ProductCard({
9721
9708
  }
9722
9709
  );
9723
9710
  }
9724
- return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
9711
+ return /* @__PURE__ */ jsxRuntime.jsxs(dialog.Dialog.Root, { open: isOpen, onOpenChange: handleOpenChange, children: [
9725
9712
  /* @__PURE__ */ jsxRuntime.jsx(
9726
- "button",
9713
+ dialog.Dialog.Trigger,
9727
9714
  {
9728
- type: "button",
9729
- "aria-haspopup": "dialog",
9730
9715
  onPointerEnter: handlePrefetch,
9731
- onClick: handleOpen,
9732
9716
  "data-cimplify-product-card": true,
9733
9717
  "data-display-mode": "card",
9734
9718
  className: cn(
@@ -9739,38 +9723,47 @@ function ProductCard({
9739
9723
  children: cardBody
9740
9724
  }
9741
9725
  ),
9742
- /* @__PURE__ */ jsxRuntime.jsx(
9743
- "dialog",
9744
- {
9745
- ref: dialogRef,
9746
- onCancel: handleCancel,
9747
- onClick: handleBackdropClick,
9748
- "data-cimplify-product-card-modal": true,
9749
- className: cn(
9750
- "border-none rounded-2xl p-0 max-w-lg w-full max-h-[85vh] overflow-auto bg-background shadow-2xl backdrop:bg-black/50 backdrop:backdrop-blur-sm open:animate-in open:fade-in-0 open:slide-in-from-bottom-4",
9751
- classNames?.modal
9752
- ),
9753
- children: isOpen && (productDetails ? renderModal ? renderModal(productDetails, handleClose) : /* @__PURE__ */ jsxRuntime.jsx(
9754
- ProductSheet,
9755
- {
9756
- product: productDetails,
9757
- onClose: handleClose,
9758
- renderImage
9759
- }
9760
- ) : /* @__PURE__ */ jsxRuntime.jsxs(
9761
- "div",
9762
- {
9763
- "data-cimplify-product-card-modal-loading": true,
9764
- "aria-busy": "true",
9765
- className: "flex flex-col gap-4 p-6",
9766
- children: [
9767
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "aspect-[4/3] bg-muted rounded-lg" }),
9768
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "h-6 w-3/5 bg-muted rounded" })
9769
- ]
9770
- }
9771
- ))
9772
- }
9773
- )
9726
+ /* @__PURE__ */ jsxRuntime.jsxs(dialog.Dialog.Portal, { children: [
9727
+ /* @__PURE__ */ jsxRuntime.jsx(
9728
+ dialog.Dialog.Backdrop,
9729
+ {
9730
+ "data-cimplify-product-card-backdrop": true,
9731
+ className: cn(
9732
+ "fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity",
9733
+ classNames?.modalOverlay
9734
+ )
9735
+ }
9736
+ ),
9737
+ /* @__PURE__ */ jsxRuntime.jsx(
9738
+ dialog.Dialog.Popup,
9739
+ {
9740
+ "data-cimplify-product-card-modal": true,
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",
9743
+ classNames?.modal
9744
+ ),
9745
+ children: isOpen && (productDetails ? renderModal ? renderModal(productDetails, handleClose) : /* @__PURE__ */ jsxRuntime.jsx(
9746
+ ProductSheet,
9747
+ {
9748
+ product: productDetails,
9749
+ onClose: handleClose,
9750
+ renderImage
9751
+ }
9752
+ ) : /* @__PURE__ */ jsxRuntime.jsxs(
9753
+ "div",
9754
+ {
9755
+ "data-cimplify-product-card-modal-loading": true,
9756
+ "aria-busy": "true",
9757
+ className: "flex flex-col gap-4 p-6",
9758
+ children: [
9759
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "aspect-[4/3] bg-muted rounded-lg animate-pulse" }),
9760
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "h-6 w-3/5 bg-muted rounded animate-pulse" })
9761
+ ]
9762
+ }
9763
+ ))
9764
+ }
9765
+ )
9766
+ ] })
9774
9767
  ] });
9775
9768
  }
9776
9769
  function ProductGrid({
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 { Dialog } from '@base-ui/react/dialog';
10
11
  import { Field } from '@base-ui/react/field';
11
12
  import { Input } from '@base-ui/react/input';
12
13
  import { Tabs } from '@base-ui/react/tabs';
@@ -9018,9 +9019,13 @@ function ProductCustomizer({
9018
9019
  const quoteId = quote?.quote_id;
9019
9020
  const quotedTotalPrice = useMemo(() => {
9020
9021
  if (!quote) return void 0;
9021
- const quotedTotal = quote.quoted_total_price_info?.final_price ?? quote.final_price_info.final_price;
9022
- return quotedTotal === void 0 || quotedTotal === null ? void 0 : parsePrice(quotedTotal);
9023
- }, [quote]);
9022
+ if (quote.quoted_total_price_info?.final_price != null) {
9023
+ return parsePrice(quote.quoted_total_price_info.final_price);
9024
+ }
9025
+ const perUnit = quote.final_price_info.final_price;
9026
+ if (perUnit === void 0 || perUnit === null) return void 0;
9027
+ return parsePrice(perUnit) * quantity;
9028
+ }, [quote, quantity]);
9024
9029
  const displayTotalPrice = quotedTotalPrice ?? localTotalPrice;
9025
9030
  const handleVariantChange = useCallback(
9026
9031
  (variantId, variant) => {
@@ -9109,46 +9114,48 @@ function ProductCustomizer({
9109
9114
  "div",
9110
9115
  {
9111
9116
  "data-cimplify-customizer-actions": true,
9112
- className: cn("flex items-center gap-4 pt-4 border-t border-border", classNames?.actions),
9117
+ className: cn("pt-4 border-t border-border", classNames?.actions),
9113
9118
  children: [
9114
- /* @__PURE__ */ jsx(
9115
- QuantitySelector,
9119
+ !quoteEnabled && /* @__PURE__ */ jsx(
9120
+ "p",
9116
9121
  {
9117
- value: quantity,
9118
- onChange: setQuantity,
9119
- min: 1
9122
+ id: "cimplify-customizer-validation",
9123
+ "data-cimplify-customizer-validation": true,
9124
+ className: cn("text-sm text-destructive mb-3", classNames?.validation),
9125
+ children: "Please select all required options"
9120
9126
  }
9121
9127
  ),
9122
- /* @__PURE__ */ jsx(
9123
- "button",
9124
- {
9125
- type: "button",
9126
- onClick: handleAddToCart,
9127
- disabled: isAdded || isSubmitting || !quoteEnabled,
9128
- "aria-describedby": !quoteEnabled ? "cimplify-customizer-validation" : void 0,
9129
- "data-cimplify-customizer-submit": true,
9130
- className: cn(
9131
- "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",
9132
- isAdded && classNames?.submitButtonAdded,
9133
- classNames?.submitButton
9134
- ),
9135
- children: isAdded ? "Added to Cart" : /* @__PURE__ */ jsxs(Fragment, { children: [
9136
- "Add to Cart \xB7 ",
9137
- /* @__PURE__ */ jsx(Price, { amount: displayTotalPrice })
9138
- ] })
9139
- }
9140
- )
9128
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4", children: [
9129
+ /* @__PURE__ */ jsx(
9130
+ QuantitySelector,
9131
+ {
9132
+ value: quantity,
9133
+ onChange: setQuantity,
9134
+ min: 1
9135
+ }
9136
+ ),
9137
+ /* @__PURE__ */ jsx(
9138
+ "button",
9139
+ {
9140
+ type: "button",
9141
+ onClick: handleAddToCart,
9142
+ disabled: isAdded || isSubmitting || !quoteEnabled,
9143
+ "aria-describedby": !quoteEnabled ? "cimplify-customizer-validation" : void 0,
9144
+ "data-cimplify-customizer-submit": true,
9145
+ className: cn(
9146
+ "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",
9147
+ isAdded && classNames?.submitButtonAdded,
9148
+ classNames?.submitButton
9149
+ ),
9150
+ children: isAdded ? "Added to Cart" : /* @__PURE__ */ jsxs(Fragment, { children: [
9151
+ "Add to Cart \xB7 ",
9152
+ /* @__PURE__ */ jsx(Price, { amount: displayTotalPrice })
9153
+ ] })
9154
+ }
9155
+ )
9156
+ ] })
9141
9157
  ]
9142
9158
  }
9143
- ),
9144
- !quoteEnabled && /* @__PURE__ */ jsx(
9145
- "p",
9146
- {
9147
- id: "cimplify-customizer-validation",
9148
- "data-cimplify-customizer-validation": true,
9149
- className: cn("text-sm text-destructive mt-2", classNames?.validation),
9150
- children: "Please select all required options"
9151
- }
9152
9159
  )
9153
9160
  ] });
9154
9161
  }
@@ -9596,15 +9603,6 @@ function ProductCard({
9596
9603
  const mode = displayMode ?? product.display_mode ?? "card";
9597
9604
  const [isOpen, setIsOpen] = useState(false);
9598
9605
  const [shouldFetch, setShouldFetch] = useState(false);
9599
- const dialogRef = useRef(null);
9600
- useEffect(() => {
9601
- if (!isOpen) return;
9602
- const original = document.body.style.overflow;
9603
- document.body.style.overflow = "hidden";
9604
- return () => {
9605
- document.body.style.overflow = original;
9606
- };
9607
- }, [isOpen]);
9608
9606
  const { product: productDetails } = useProduct(
9609
9607
  product.slug ?? product.id,
9610
9608
  { enabled: shouldFetch || isOpen }
@@ -9612,26 +9610,15 @@ function ProductCard({
9612
9610
  const handlePrefetch = useCallback(() => {
9613
9611
  setShouldFetch(true);
9614
9612
  }, []);
9615
- const handleOpen = useCallback(() => {
9616
- setIsOpen(true);
9617
- setShouldFetch(true);
9618
- dialogRef.current?.showModal();
9613
+ const handleOpenChange = useCallback((open) => {
9614
+ setIsOpen(open);
9615
+ if (open) {
9616
+ setShouldFetch(true);
9617
+ }
9619
9618
  }, []);
9620
9619
  const handleClose = useCallback(() => {
9621
- dialogRef.current?.close();
9622
- setIsOpen(false);
9623
- }, []);
9624
- const handleCancel = useCallback(() => {
9625
9620
  setIsOpen(false);
9626
9621
  }, []);
9627
- const handleBackdropClick = useCallback(
9628
- (e) => {
9629
- if (e.target === dialogRef.current) {
9630
- handleClose();
9631
- }
9632
- },
9633
- [handleClose]
9634
- );
9635
9622
  const imageUrl = product.image_url || product.images?.[0];
9636
9623
  const cardBody = children ?? /* @__PURE__ */ jsxs(Fragment, { children: [
9637
9624
  imageUrl && /* @__PURE__ */ jsx(
@@ -9715,14 +9702,11 @@ function ProductCard({
9715
9702
  }
9716
9703
  );
9717
9704
  }
9718
- return /* @__PURE__ */ jsxs(Fragment, { children: [
9705
+ return /* @__PURE__ */ jsxs(Dialog.Root, { open: isOpen, onOpenChange: handleOpenChange, children: [
9719
9706
  /* @__PURE__ */ jsx(
9720
- "button",
9707
+ Dialog.Trigger,
9721
9708
  {
9722
- type: "button",
9723
- "aria-haspopup": "dialog",
9724
9709
  onPointerEnter: handlePrefetch,
9725
- onClick: handleOpen,
9726
9710
  "data-cimplify-product-card": true,
9727
9711
  "data-display-mode": "card",
9728
9712
  className: cn(
@@ -9733,38 +9717,47 @@ function ProductCard({
9733
9717
  children: cardBody
9734
9718
  }
9735
9719
  ),
9736
- /* @__PURE__ */ jsx(
9737
- "dialog",
9738
- {
9739
- ref: dialogRef,
9740
- onCancel: handleCancel,
9741
- onClick: handleBackdropClick,
9742
- "data-cimplify-product-card-modal": true,
9743
- className: cn(
9744
- "border-none rounded-2xl p-0 max-w-lg w-full max-h-[85vh] overflow-auto bg-background shadow-2xl backdrop:bg-black/50 backdrop:backdrop-blur-sm open:animate-in open:fade-in-0 open:slide-in-from-bottom-4",
9745
- classNames?.modal
9746
- ),
9747
- children: isOpen && (productDetails ? renderModal ? renderModal(productDetails, handleClose) : /* @__PURE__ */ jsx(
9748
- ProductSheet,
9749
- {
9750
- product: productDetails,
9751
- onClose: handleClose,
9752
- renderImage
9753
- }
9754
- ) : /* @__PURE__ */ jsxs(
9755
- "div",
9756
- {
9757
- "data-cimplify-product-card-modal-loading": true,
9758
- "aria-busy": "true",
9759
- className: "flex flex-col gap-4 p-6",
9760
- children: [
9761
- /* @__PURE__ */ jsx("div", { className: "aspect-[4/3] bg-muted rounded-lg" }),
9762
- /* @__PURE__ */ jsx("div", { className: "h-6 w-3/5 bg-muted rounded" })
9763
- ]
9764
- }
9765
- ))
9766
- }
9767
- )
9720
+ /* @__PURE__ */ jsxs(Dialog.Portal, { children: [
9721
+ /* @__PURE__ */ jsx(
9722
+ Dialog.Backdrop,
9723
+ {
9724
+ "data-cimplify-product-card-backdrop": true,
9725
+ className: cn(
9726
+ "fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity",
9727
+ classNames?.modalOverlay
9728
+ )
9729
+ }
9730
+ ),
9731
+ /* @__PURE__ */ jsx(
9732
+ Dialog.Popup,
9733
+ {
9734
+ "data-cimplify-product-card-modal": true,
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",
9737
+ classNames?.modal
9738
+ ),
9739
+ children: isOpen && (productDetails ? renderModal ? renderModal(productDetails, handleClose) : /* @__PURE__ */ jsx(
9740
+ ProductSheet,
9741
+ {
9742
+ product: productDetails,
9743
+ onClose: handleClose,
9744
+ renderImage
9745
+ }
9746
+ ) : /* @__PURE__ */ jsxs(
9747
+ "div",
9748
+ {
9749
+ "data-cimplify-product-card-modal-loading": true,
9750
+ "aria-busy": "true",
9751
+ className: "flex flex-col gap-4 p-6",
9752
+ children: [
9753
+ /* @__PURE__ */ jsx("div", { className: "aspect-[4/3] bg-muted rounded-lg animate-pulse" }),
9754
+ /* @__PURE__ */ jsx("div", { className: "h-6 w-3/5 bg-muted rounded animate-pulse" })
9755
+ ]
9756
+ }
9757
+ ))
9758
+ }
9759
+ )
9760
+ ] })
9768
9761
  ] });
9769
9762
  }
9770
9763
  function ProductGrid({
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)}.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}.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cimplify/sdk",
3
- "version": "0.14.2",
3
+ "version": "0.15.0",
4
4
  "description": "Cimplify Commerce SDK for storefronts",
5
5
  "keywords": [
6
6
  "cimplify",
@@ -11,7 +11,7 @@
11
11
  "files": [
12
12
  {
13
13
  "path": "product-card.tsx",
14
- "content": "\"use client\";\n\nimport React, { useCallback, useEffect, useRef, useState } from \"react\";\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 native `<dialog>` modal with a ProductSheet\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 const dialogRef = useRef<HTMLDialogElement>(null);\n\n // Lock body scroll when dialog is open (mobile Safari workaround)\n useEffect(() => {\n if (!isOpen) return;\n const original = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n return () => {\n document.body.style.overflow = original;\n };\n }, [isOpen]);\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 handleOpen = useCallback(() => {\n setIsOpen(true);\n setShouldFetch(true);\n dialogRef.current?.showModal();\n }, []);\n\n const handleClose = useCallback(() => {\n dialogRef.current?.close();\n setIsOpen(false);\n }, []);\n\n const handleCancel = useCallback(() => {\n setIsOpen(false);\n }, []);\n\n const handleBackdropClick = useCallback(\n (e: React.MouseEvent<HTMLDialogElement>) => {\n if (e.target === dialogRef.current) {\n handleClose();\n }\n },\n [handleClose],\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 button + native dialog\n return (\n <>\n <button\n type=\"button\"\n aria-haspopup=\"dialog\"\n onPointerEnter={handlePrefetch}\n onClick={handleOpen}\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 </button>\n\n <dialog\n ref={dialogRef}\n onCancel={handleCancel}\n onClick={handleBackdropClick}\n data-cimplify-product-card-modal\n className={cn(\n \"border-none rounded-2xl p-0 max-w-lg w-full max-h-[85vh] overflow-auto bg-background shadow-2xl backdrop:bg-black/50 backdrop:backdrop-blur-sm open:animate-in open:fade-in-0 open:slide-in-from-bottom-4\",\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\" />\n <div className=\"h-6 w-3/5 bg-muted rounded\" />\n </div>\n )\n )}\n </dialog>\n </>\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 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"
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 const quotedTotal =\n quote.quoted_total_price_info?.final_price ?? quote.final_price_info.final_price;\n return quotedTotal === undefined || quotedTotal === null ? undefined : parsePrice(quotedTotal);\n }, [quote]);\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(\"flex items-center gap-4 pt-4 border-t border-border\", classNames?.actions)}\n >\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\",\n isAdded && classNames?.submitButtonAdded,\n classNames?.submitButton,\n )}\n >\n {isAdded ? \"Added to Cart\" : (\n <>\n Add to Cart &middot; <Price amount={displayTotalPrice} />\n </>\n )}\n </button>\n </div>\n\n {!quoteEnabled && (\n <p\n id=\"cimplify-customizer-validation\"\n data-cimplify-customizer-validation\n className={cn(\"text-sm text-destructive mt-2\", classNames?.validation)}\n >\n Please select all required options\n </p>\n )}\n </div>\n );\n}\n"
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 &middot; <Price amount={displayTotalPrice} />\n </>\n )}\n </button>\n </div>\n </div>\n\n </div>\n );\n}\n"
18
18
  }
19
19
  ]
20
20
  }