@cimplify/sdk 0.14.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/react.js +76 -45
- package/dist/react.mjs +76 -45
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/registry/bundle-selector.json +1 -1
- package/registry/composite-selector.json +1 -1
- package/registry/product-card.json +1 -1
package/dist/react.js
CHANGED
|
@@ -8340,14 +8340,14 @@ function BundleSelector({
|
|
|
8340
8340
|
}
|
|
8341
8341
|
return /* @__PURE__ */ jsxRuntime.jsxs("div", { "data-cimplify-bundle-selector": true, className: cn("space-y-4", className, classNames?.root), children: [
|
|
8342
8342
|
/* @__PURE__ */ jsxRuntime.jsx(
|
|
8343
|
-
"
|
|
8343
|
+
"div",
|
|
8344
8344
|
{
|
|
8345
8345
|
"data-cimplify-bundle-heading": true,
|
|
8346
|
-
className: cn("
|
|
8347
|
-
children: "Included in this bundle"
|
|
8346
|
+
className: cn("flex items-center justify-between py-3", classNames?.heading),
|
|
8347
|
+
children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-base font-bold", children: "Included in this bundle" })
|
|
8348
8348
|
}
|
|
8349
8349
|
),
|
|
8350
|
-
/* @__PURE__ */ jsxRuntime.jsx("div", { "data-cimplify-bundle-components": true, className: cn("
|
|
8350
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { "data-cimplify-bundle-components": true, className: cn("divide-y divide-border", classNames?.components), children: components.map((comp) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
8351
8351
|
BundleComponentCard,
|
|
8352
8352
|
{
|
|
8353
8353
|
component: comp,
|
|
@@ -8410,20 +8410,20 @@ function BundleComponentCard({
|
|
|
8410
8410
|
"div",
|
|
8411
8411
|
{
|
|
8412
8412
|
"data-cimplify-bundle-component": true,
|
|
8413
|
-
className: cn("
|
|
8413
|
+
className: cn("py-4", classNames?.component),
|
|
8414
8414
|
children: [
|
|
8415
8415
|
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
8416
8416
|
"div",
|
|
8417
8417
|
{
|
|
8418
8418
|
"data-cimplify-bundle-component-header": true,
|
|
8419
|
-
className: cn("flex items-
|
|
8419
|
+
className: cn("flex items-center justify-between gap-3", classNames?.componentHeader),
|
|
8420
8420
|
children: [
|
|
8421
|
-
/* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
|
|
8421
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
|
|
8422
8422
|
component.quantity > 1 && /* @__PURE__ */ jsxRuntime.jsxs(
|
|
8423
8423
|
"span",
|
|
8424
8424
|
{
|
|
8425
8425
|
"data-cimplify-bundle-component-qty": true,
|
|
8426
|
-
className: cn("text-xs font-medium text-primary bg-primary/10 px-1.5 py-0.5", classNames?.componentQty),
|
|
8426
|
+
className: cn("text-xs font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded", classNames?.componentQty),
|
|
8427
8427
|
children: [
|
|
8428
8428
|
"\xD7",
|
|
8429
8429
|
component.quantity
|
|
@@ -8435,12 +8435,12 @@ function BundleComponentCard({
|
|
|
8435
8435
|
{
|
|
8436
8436
|
id: labelId,
|
|
8437
8437
|
"data-cimplify-bundle-component-name": true,
|
|
8438
|
-
className: cn("
|
|
8438
|
+
className: cn("text-sm", classNames?.componentName),
|
|
8439
8439
|
children: component.product_name
|
|
8440
8440
|
}
|
|
8441
8441
|
)
|
|
8442
8442
|
] }),
|
|
8443
|
-
/* @__PURE__ */ jsxRuntime.jsx(Price, { amount: displayPrice })
|
|
8443
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm text-muted-foreground", children: /* @__PURE__ */ jsxRuntime.jsx(Price, { amount: displayPrice }) })
|
|
8444
8444
|
]
|
|
8445
8445
|
}
|
|
8446
8446
|
),
|
|
@@ -8453,7 +8453,7 @@ function BundleComponentCard({
|
|
|
8453
8453
|
onVariantChange(value);
|
|
8454
8454
|
},
|
|
8455
8455
|
"data-cimplify-bundle-variant-picker": true,
|
|
8456
|
-
className: cn("mt-3
|
|
8456
|
+
className: cn("mt-3 divide-y divide-border", classNames?.variantPicker),
|
|
8457
8457
|
children: component.available_variants.map((variant) => {
|
|
8458
8458
|
const isSelected = selectedVariantId === variant.id;
|
|
8459
8459
|
const adjustment = parsePrice(variant.price_adjustment);
|
|
@@ -8464,17 +8464,26 @@ function BundleComponentCard({
|
|
|
8464
8464
|
"data-cimplify-bundle-variant-option": true,
|
|
8465
8465
|
"data-selected": isSelected || void 0,
|
|
8466
8466
|
className: cn(
|
|
8467
|
-
"
|
|
8468
|
-
isSelected && "bg-primary text-primary-foreground border-primary",
|
|
8467
|
+
"w-full flex items-center gap-3 py-3 transition-colors cursor-pointer",
|
|
8469
8468
|
isSelected ? classNames?.variantOptionSelected : classNames?.variantOption
|
|
8470
8469
|
),
|
|
8471
8470
|
children: [
|
|
8472
|
-
|
|
8471
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
8472
|
+
"span",
|
|
8473
|
+
{
|
|
8474
|
+
className: cn(
|
|
8475
|
+
"w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
|
|
8476
|
+
isSelected ? "border-primary" : "border-muted-foreground/30"
|
|
8477
|
+
),
|
|
8478
|
+
children: isSelected && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "w-2.5 h-2.5 rounded-full bg-primary" })
|
|
8479
|
+
}
|
|
8480
|
+
),
|
|
8481
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "flex-1 text-sm", children: variant.display_name }),
|
|
8473
8482
|
adjustment !== 0 && /* @__PURE__ */ jsxRuntime.jsxs(
|
|
8474
8483
|
"span",
|
|
8475
8484
|
{
|
|
8476
8485
|
"data-cimplify-bundle-variant-adjustment": true,
|
|
8477
|
-
className: cn("
|
|
8486
|
+
className: cn("text-sm text-muted-foreground", classNames?.variantAdjustment),
|
|
8478
8487
|
children: [
|
|
8479
8488
|
adjustment > 0 ? "+" : "",
|
|
8480
8489
|
/* @__PURE__ */ jsxRuntime.jsx(Price, { amount: variant.price_adjustment })
|
|
@@ -8631,41 +8640,28 @@ function CompositeSelector({
|
|
|
8631
8640
|
"div",
|
|
8632
8641
|
{
|
|
8633
8642
|
"data-cimplify-composite-group": true,
|
|
8634
|
-
className: cn(
|
|
8643
|
+
className: cn(classNames?.group),
|
|
8635
8644
|
children: [
|
|
8636
8645
|
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
8637
8646
|
"div",
|
|
8638
8647
|
{
|
|
8639
8648
|
"data-cimplify-composite-group-header": true,
|
|
8640
|
-
className: cn("flex items-center justify-between
|
|
8649
|
+
className: cn("flex items-center justify-between py-3", classNames?.groupHeader),
|
|
8641
8650
|
children: [
|
|
8642
8651
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
|
|
8643
|
-
/* @__PURE__ */ jsxRuntime.
|
|
8652
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
8644
8653
|
"span",
|
|
8645
8654
|
{
|
|
8646
8655
|
"data-cimplify-composite-group-name": true,
|
|
8647
|
-
className: cn("text-
|
|
8648
|
-
children:
|
|
8649
|
-
group.name,
|
|
8650
|
-
group.min_selections > 0 && /* @__PURE__ */ jsxRuntime.jsxs(
|
|
8651
|
-
"span",
|
|
8652
|
-
{
|
|
8653
|
-
"data-cimplify-composite-required": true,
|
|
8654
|
-
className: cn("text-destructive ml-1", classNames?.required),
|
|
8655
|
-
children: [
|
|
8656
|
-
" ",
|
|
8657
|
-
"*"
|
|
8658
|
-
]
|
|
8659
|
-
}
|
|
8660
|
-
)
|
|
8661
|
-
]
|
|
8656
|
+
className: cn("text-base font-bold", classNames?.groupName),
|
|
8657
|
+
children: group.name
|
|
8662
8658
|
}
|
|
8663
8659
|
),
|
|
8664
8660
|
group.description && /* @__PURE__ */ jsxRuntime.jsx(
|
|
8665
8661
|
"span",
|
|
8666
8662
|
{
|
|
8667
8663
|
"data-cimplify-composite-group-description": true,
|
|
8668
|
-
className: cn("text-xs text-muted-foreground
|
|
8664
|
+
className: cn("block text-xs text-muted-foreground mt-0.5", classNames?.groupDescription),
|
|
8669
8665
|
children: group.description
|
|
8670
8666
|
}
|
|
8671
8667
|
),
|
|
@@ -8673,16 +8669,20 @@ function CompositeSelector({
|
|
|
8673
8669
|
"span",
|
|
8674
8670
|
{
|
|
8675
8671
|
"data-cimplify-composite-group-constraint": true,
|
|
8676
|
-
className: cn("text-xs text-muted-foreground
|
|
8672
|
+
className: cn("block text-xs text-muted-foreground mt-0.5", classNames?.groupConstraint),
|
|
8677
8673
|
children: group.min_selections > 0 && group.max_selections ? `Choose ${group.min_selections}\u2013${group.max_selections}` : group.min_selections > 0 ? `Choose at least ${group.min_selections}` : group.max_selections ? `Choose up to ${group.max_selections}` : "Choose as many as you like"
|
|
8678
8674
|
}
|
|
8679
8675
|
)
|
|
8680
8676
|
] }),
|
|
8681
|
-
|
|
8677
|
+
group.min_selections > 0 && /* @__PURE__ */ jsxRuntime.jsx(
|
|
8682
8678
|
"span",
|
|
8683
8679
|
{
|
|
8684
|
-
"data-cimplify-composite-
|
|
8685
|
-
className: cn(
|
|
8680
|
+
"data-cimplify-composite-required": true,
|
|
8681
|
+
className: cn(
|
|
8682
|
+
"text-xs font-semibold px-2.5 py-1 rounded shrink-0",
|
|
8683
|
+
!minMet ? "text-destructive bg-destructive/10" : "text-destructive bg-destructive/10",
|
|
8684
|
+
classNames?.required
|
|
8685
|
+
),
|
|
8686
8686
|
children: "Required"
|
|
8687
8687
|
}
|
|
8688
8688
|
)
|
|
@@ -8695,7 +8695,7 @@ function CompositeSelector({
|
|
|
8695
8695
|
"data-cimplify-composite-components": true,
|
|
8696
8696
|
role: isSingleSelect ? "radiogroup" : "group",
|
|
8697
8697
|
"aria-label": group.name,
|
|
8698
|
-
className: cn("
|
|
8698
|
+
className: cn("divide-y divide-border", classNames?.components),
|
|
8699
8699
|
children: group.components.filter((c) => c.is_available && !c.is_archived).sort((a, b) => a.display_order - b.display_order).map((component) => {
|
|
8700
8700
|
const qty = groupSels[component.id] || 0;
|
|
8701
8701
|
const isSelected = qty > 0;
|
|
@@ -8709,8 +8709,7 @@ function CompositeSelector({
|
|
|
8709
8709
|
"data-cimplify-composite-component": true,
|
|
8710
8710
|
"data-selected": isSelected || void 0,
|
|
8711
8711
|
className: cn(
|
|
8712
|
-
"w-full flex items-center gap-3
|
|
8713
|
-
isSelected && "bg-primary/5 border-primary",
|
|
8712
|
+
"w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer",
|
|
8714
8713
|
isSelected ? classNames?.componentSelected : classNames?.component
|
|
8715
8714
|
),
|
|
8716
8715
|
children: [
|
|
@@ -8721,6 +8720,27 @@ function CompositeSelector({
|
|
|
8721
8720
|
keepMounted: false
|
|
8722
8721
|
}
|
|
8723
8722
|
),
|
|
8723
|
+
isSingleSelect ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
8724
|
+
"span",
|
|
8725
|
+
{
|
|
8726
|
+
"data-cimplify-composite-radio": true,
|
|
8727
|
+
className: cn(
|
|
8728
|
+
"w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
|
|
8729
|
+
isSelected ? "border-primary" : "border-muted-foreground/30"
|
|
8730
|
+
),
|
|
8731
|
+
children: isSelected && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "w-2.5 h-2.5 rounded-full bg-primary" })
|
|
8732
|
+
}
|
|
8733
|
+
) : /* @__PURE__ */ jsxRuntime.jsx(
|
|
8734
|
+
"span",
|
|
8735
|
+
{
|
|
8736
|
+
"data-cimplify-composite-checkbox": true,
|
|
8737
|
+
className: cn(
|
|
8738
|
+
"w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors",
|
|
8739
|
+
isSelected ? "border-primary bg-primary" : "border-muted-foreground/30"
|
|
8740
|
+
),
|
|
8741
|
+
children: isSelected && /* @__PURE__ */ jsxRuntime.jsx("svg", { viewBox: "0 0 12 12", className: "w-3 h-3 text-primary-foreground", fill: "none", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M2 6l3 3 5-5", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) })
|
|
8742
|
+
}
|
|
8743
|
+
),
|
|
8724
8744
|
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
8725
8745
|
"div",
|
|
8726
8746
|
{
|
|
@@ -8731,7 +8751,7 @@ function CompositeSelector({
|
|
|
8731
8751
|
"span",
|
|
8732
8752
|
{
|
|
8733
8753
|
"data-cimplify-composite-component-name": true,
|
|
8734
|
-
className: cn("text-sm
|
|
8754
|
+
className: cn("text-sm", classNames?.componentName),
|
|
8735
8755
|
children: displayName
|
|
8736
8756
|
}
|
|
8737
8757
|
),
|
|
@@ -8755,7 +8775,7 @@ function CompositeSelector({
|
|
|
8755
8775
|
"span",
|
|
8756
8776
|
{
|
|
8757
8777
|
"data-cimplify-composite-component-description": true,
|
|
8758
|
-
className: cn("text-xs text-muted-foreground truncate", classNames?.componentDescription),
|
|
8778
|
+
className: cn("block text-xs text-muted-foreground truncate", classNames?.componentDescription),
|
|
8759
8779
|
children: component.display_description
|
|
8760
8780
|
}
|
|
8761
8781
|
),
|
|
@@ -8763,7 +8783,7 @@ function CompositeSelector({
|
|
|
8763
8783
|
"span",
|
|
8764
8784
|
{
|
|
8765
8785
|
"data-cimplify-composite-component-calories": true,
|
|
8766
|
-
className: cn("text-xs text-muted-foreground/60", classNames?.componentCalories),
|
|
8786
|
+
className: cn("block text-xs text-muted-foreground/60", classNames?.componentCalories),
|
|
8767
8787
|
children: [
|
|
8768
8788
|
component.calories,
|
|
8769
8789
|
" cal"
|
|
@@ -8820,7 +8840,10 @@ function CompositeSelector({
|
|
|
8820
8840
|
)
|
|
8821
8841
|
}
|
|
8822
8842
|
),
|
|
8823
|
-
component.price != null &&
|
|
8843
|
+
component.price != null && /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-sm text-muted-foreground shrink-0", children: [
|
|
8844
|
+
"+",
|
|
8845
|
+
/* @__PURE__ */ jsxRuntime.jsx(Price, { amount: component.price })
|
|
8846
|
+
] })
|
|
8824
8847
|
]
|
|
8825
8848
|
},
|
|
8826
8849
|
component.id
|
|
@@ -9578,6 +9601,14 @@ function ProductCard({
|
|
|
9578
9601
|
const [isOpen, setIsOpen] = React3.useState(false);
|
|
9579
9602
|
const [shouldFetch, setShouldFetch] = React3.useState(false);
|
|
9580
9603
|
const dialogRef = React3.useRef(null);
|
|
9604
|
+
React3.useEffect(() => {
|
|
9605
|
+
if (!isOpen) return;
|
|
9606
|
+
const original = document.body.style.overflow;
|
|
9607
|
+
document.body.style.overflow = "hidden";
|
|
9608
|
+
return () => {
|
|
9609
|
+
document.body.style.overflow = original;
|
|
9610
|
+
};
|
|
9611
|
+
}, [isOpen]);
|
|
9581
9612
|
const { product: productDetails } = useProduct(
|
|
9582
9613
|
product.slug ?? product.id,
|
|
9583
9614
|
{ enabled: shouldFetch || isOpen }
|
package/dist/react.mjs
CHANGED
|
@@ -8334,14 +8334,14 @@ function BundleSelector({
|
|
|
8334
8334
|
}
|
|
8335
8335
|
return /* @__PURE__ */ jsxs("div", { "data-cimplify-bundle-selector": true, className: cn("space-y-4", className, classNames?.root), children: [
|
|
8336
8336
|
/* @__PURE__ */ jsx(
|
|
8337
|
-
"
|
|
8337
|
+
"div",
|
|
8338
8338
|
{
|
|
8339
8339
|
"data-cimplify-bundle-heading": true,
|
|
8340
|
-
className: cn("
|
|
8341
|
-
children: "Included in this bundle"
|
|
8340
|
+
className: cn("flex items-center justify-between py-3", classNames?.heading),
|
|
8341
|
+
children: /* @__PURE__ */ jsx("span", { className: "text-base font-bold", children: "Included in this bundle" })
|
|
8342
8342
|
}
|
|
8343
8343
|
),
|
|
8344
|
-
/* @__PURE__ */ jsx("div", { "data-cimplify-bundle-components": true, className: cn("
|
|
8344
|
+
/* @__PURE__ */ jsx("div", { "data-cimplify-bundle-components": true, className: cn("divide-y divide-border", classNames?.components), children: components.map((comp) => /* @__PURE__ */ jsx(
|
|
8345
8345
|
BundleComponentCard,
|
|
8346
8346
|
{
|
|
8347
8347
|
component: comp,
|
|
@@ -8404,20 +8404,20 @@ function BundleComponentCard({
|
|
|
8404
8404
|
"div",
|
|
8405
8405
|
{
|
|
8406
8406
|
"data-cimplify-bundle-component": true,
|
|
8407
|
-
className: cn("
|
|
8407
|
+
className: cn("py-4", classNames?.component),
|
|
8408
8408
|
children: [
|
|
8409
8409
|
/* @__PURE__ */ jsxs(
|
|
8410
8410
|
"div",
|
|
8411
8411
|
{
|
|
8412
8412
|
"data-cimplify-bundle-component-header": true,
|
|
8413
|
-
className: cn("flex items-
|
|
8413
|
+
className: cn("flex items-center justify-between gap-3", classNames?.componentHeader),
|
|
8414
8414
|
children: [
|
|
8415
|
-
/* @__PURE__ */ jsxs("div", { children: [
|
|
8415
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
8416
8416
|
component.quantity > 1 && /* @__PURE__ */ jsxs(
|
|
8417
8417
|
"span",
|
|
8418
8418
|
{
|
|
8419
8419
|
"data-cimplify-bundle-component-qty": true,
|
|
8420
|
-
className: cn("text-xs font-medium text-primary bg-primary/10 px-1.5 py-0.5", classNames?.componentQty),
|
|
8420
|
+
className: cn("text-xs font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded", classNames?.componentQty),
|
|
8421
8421
|
children: [
|
|
8422
8422
|
"\xD7",
|
|
8423
8423
|
component.quantity
|
|
@@ -8429,12 +8429,12 @@ function BundleComponentCard({
|
|
|
8429
8429
|
{
|
|
8430
8430
|
id: labelId,
|
|
8431
8431
|
"data-cimplify-bundle-component-name": true,
|
|
8432
|
-
className: cn("
|
|
8432
|
+
className: cn("text-sm", classNames?.componentName),
|
|
8433
8433
|
children: component.product_name
|
|
8434
8434
|
}
|
|
8435
8435
|
)
|
|
8436
8436
|
] }),
|
|
8437
|
-
/* @__PURE__ */ jsx(Price, { amount: displayPrice })
|
|
8437
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm text-muted-foreground", children: /* @__PURE__ */ jsx(Price, { amount: displayPrice }) })
|
|
8438
8438
|
]
|
|
8439
8439
|
}
|
|
8440
8440
|
),
|
|
@@ -8447,7 +8447,7 @@ function BundleComponentCard({
|
|
|
8447
8447
|
onVariantChange(value);
|
|
8448
8448
|
},
|
|
8449
8449
|
"data-cimplify-bundle-variant-picker": true,
|
|
8450
|
-
className: cn("mt-3
|
|
8450
|
+
className: cn("mt-3 divide-y divide-border", classNames?.variantPicker),
|
|
8451
8451
|
children: component.available_variants.map((variant) => {
|
|
8452
8452
|
const isSelected = selectedVariantId === variant.id;
|
|
8453
8453
|
const adjustment = parsePrice(variant.price_adjustment);
|
|
@@ -8458,17 +8458,26 @@ function BundleComponentCard({
|
|
|
8458
8458
|
"data-cimplify-bundle-variant-option": true,
|
|
8459
8459
|
"data-selected": isSelected || void 0,
|
|
8460
8460
|
className: cn(
|
|
8461
|
-
"
|
|
8462
|
-
isSelected && "bg-primary text-primary-foreground border-primary",
|
|
8461
|
+
"w-full flex items-center gap-3 py-3 transition-colors cursor-pointer",
|
|
8463
8462
|
isSelected ? classNames?.variantOptionSelected : classNames?.variantOption
|
|
8464
8463
|
),
|
|
8465
8464
|
children: [
|
|
8466
|
-
|
|
8465
|
+
/* @__PURE__ */ jsx(
|
|
8466
|
+
"span",
|
|
8467
|
+
{
|
|
8468
|
+
className: cn(
|
|
8469
|
+
"w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
|
|
8470
|
+
isSelected ? "border-primary" : "border-muted-foreground/30"
|
|
8471
|
+
),
|
|
8472
|
+
children: isSelected && /* @__PURE__ */ jsx("span", { className: "w-2.5 h-2.5 rounded-full bg-primary" })
|
|
8473
|
+
}
|
|
8474
|
+
),
|
|
8475
|
+
/* @__PURE__ */ jsx("span", { className: "flex-1 text-sm", children: variant.display_name }),
|
|
8467
8476
|
adjustment !== 0 && /* @__PURE__ */ jsxs(
|
|
8468
8477
|
"span",
|
|
8469
8478
|
{
|
|
8470
8479
|
"data-cimplify-bundle-variant-adjustment": true,
|
|
8471
|
-
className: cn("
|
|
8480
|
+
className: cn("text-sm text-muted-foreground", classNames?.variantAdjustment),
|
|
8472
8481
|
children: [
|
|
8473
8482
|
adjustment > 0 ? "+" : "",
|
|
8474
8483
|
/* @__PURE__ */ jsx(Price, { amount: variant.price_adjustment })
|
|
@@ -8625,41 +8634,28 @@ function CompositeSelector({
|
|
|
8625
8634
|
"div",
|
|
8626
8635
|
{
|
|
8627
8636
|
"data-cimplify-composite-group": true,
|
|
8628
|
-
className: cn(
|
|
8637
|
+
className: cn(classNames?.group),
|
|
8629
8638
|
children: [
|
|
8630
8639
|
/* @__PURE__ */ jsxs(
|
|
8631
8640
|
"div",
|
|
8632
8641
|
{
|
|
8633
8642
|
"data-cimplify-composite-group-header": true,
|
|
8634
|
-
className: cn("flex items-center justify-between
|
|
8643
|
+
className: cn("flex items-center justify-between py-3", classNames?.groupHeader),
|
|
8635
8644
|
children: [
|
|
8636
8645
|
/* @__PURE__ */ jsxs("div", { children: [
|
|
8637
|
-
/* @__PURE__ */
|
|
8646
|
+
/* @__PURE__ */ jsx(
|
|
8638
8647
|
"span",
|
|
8639
8648
|
{
|
|
8640
8649
|
"data-cimplify-composite-group-name": true,
|
|
8641
|
-
className: cn("text-
|
|
8642
|
-
children:
|
|
8643
|
-
group.name,
|
|
8644
|
-
group.min_selections > 0 && /* @__PURE__ */ jsxs(
|
|
8645
|
-
"span",
|
|
8646
|
-
{
|
|
8647
|
-
"data-cimplify-composite-required": true,
|
|
8648
|
-
className: cn("text-destructive ml-1", classNames?.required),
|
|
8649
|
-
children: [
|
|
8650
|
-
" ",
|
|
8651
|
-
"*"
|
|
8652
|
-
]
|
|
8653
|
-
}
|
|
8654
|
-
)
|
|
8655
|
-
]
|
|
8650
|
+
className: cn("text-base font-bold", classNames?.groupName),
|
|
8651
|
+
children: group.name
|
|
8656
8652
|
}
|
|
8657
8653
|
),
|
|
8658
8654
|
group.description && /* @__PURE__ */ jsx(
|
|
8659
8655
|
"span",
|
|
8660
8656
|
{
|
|
8661
8657
|
"data-cimplify-composite-group-description": true,
|
|
8662
|
-
className: cn("text-xs text-muted-foreground
|
|
8658
|
+
className: cn("block text-xs text-muted-foreground mt-0.5", classNames?.groupDescription),
|
|
8663
8659
|
children: group.description
|
|
8664
8660
|
}
|
|
8665
8661
|
),
|
|
@@ -8667,16 +8663,20 @@ function CompositeSelector({
|
|
|
8667
8663
|
"span",
|
|
8668
8664
|
{
|
|
8669
8665
|
"data-cimplify-composite-group-constraint": true,
|
|
8670
|
-
className: cn("text-xs text-muted-foreground
|
|
8666
|
+
className: cn("block text-xs text-muted-foreground mt-0.5", classNames?.groupConstraint),
|
|
8671
8667
|
children: group.min_selections > 0 && group.max_selections ? `Choose ${group.min_selections}\u2013${group.max_selections}` : group.min_selections > 0 ? `Choose at least ${group.min_selections}` : group.max_selections ? `Choose up to ${group.max_selections}` : "Choose as many as you like"
|
|
8672
8668
|
}
|
|
8673
8669
|
)
|
|
8674
8670
|
] }),
|
|
8675
|
-
|
|
8671
|
+
group.min_selections > 0 && /* @__PURE__ */ jsx(
|
|
8676
8672
|
"span",
|
|
8677
8673
|
{
|
|
8678
|
-
"data-cimplify-composite-
|
|
8679
|
-
className: cn(
|
|
8674
|
+
"data-cimplify-composite-required": true,
|
|
8675
|
+
className: cn(
|
|
8676
|
+
"text-xs font-semibold px-2.5 py-1 rounded shrink-0",
|
|
8677
|
+
!minMet ? "text-destructive bg-destructive/10" : "text-destructive bg-destructive/10",
|
|
8678
|
+
classNames?.required
|
|
8679
|
+
),
|
|
8680
8680
|
children: "Required"
|
|
8681
8681
|
}
|
|
8682
8682
|
)
|
|
@@ -8689,7 +8689,7 @@ function CompositeSelector({
|
|
|
8689
8689
|
"data-cimplify-composite-components": true,
|
|
8690
8690
|
role: isSingleSelect ? "radiogroup" : "group",
|
|
8691
8691
|
"aria-label": group.name,
|
|
8692
|
-
className: cn("
|
|
8692
|
+
className: cn("divide-y divide-border", classNames?.components),
|
|
8693
8693
|
children: group.components.filter((c) => c.is_available && !c.is_archived).sort((a, b) => a.display_order - b.display_order).map((component) => {
|
|
8694
8694
|
const qty = groupSels[component.id] || 0;
|
|
8695
8695
|
const isSelected = qty > 0;
|
|
@@ -8703,8 +8703,7 @@ function CompositeSelector({
|
|
|
8703
8703
|
"data-cimplify-composite-component": true,
|
|
8704
8704
|
"data-selected": isSelected || void 0,
|
|
8705
8705
|
className: cn(
|
|
8706
|
-
"w-full flex items-center gap-3
|
|
8707
|
-
isSelected && "bg-primary/5 border-primary",
|
|
8706
|
+
"w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer",
|
|
8708
8707
|
isSelected ? classNames?.componentSelected : classNames?.component
|
|
8709
8708
|
),
|
|
8710
8709
|
children: [
|
|
@@ -8715,6 +8714,27 @@ function CompositeSelector({
|
|
|
8715
8714
|
keepMounted: false
|
|
8716
8715
|
}
|
|
8717
8716
|
),
|
|
8717
|
+
isSingleSelect ? /* @__PURE__ */ jsx(
|
|
8718
|
+
"span",
|
|
8719
|
+
{
|
|
8720
|
+
"data-cimplify-composite-radio": true,
|
|
8721
|
+
className: cn(
|
|
8722
|
+
"w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
|
|
8723
|
+
isSelected ? "border-primary" : "border-muted-foreground/30"
|
|
8724
|
+
),
|
|
8725
|
+
children: isSelected && /* @__PURE__ */ jsx("span", { className: "w-2.5 h-2.5 rounded-full bg-primary" })
|
|
8726
|
+
}
|
|
8727
|
+
) : /* @__PURE__ */ jsx(
|
|
8728
|
+
"span",
|
|
8729
|
+
{
|
|
8730
|
+
"data-cimplify-composite-checkbox": true,
|
|
8731
|
+
className: cn(
|
|
8732
|
+
"w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors",
|
|
8733
|
+
isSelected ? "border-primary bg-primary" : "border-muted-foreground/30"
|
|
8734
|
+
),
|
|
8735
|
+
children: isSelected && /* @__PURE__ */ jsx("svg", { viewBox: "0 0 12 12", className: "w-3 h-3 text-primary-foreground", fill: "none", children: /* @__PURE__ */ jsx("path", { d: "M2 6l3 3 5-5", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" }) })
|
|
8736
|
+
}
|
|
8737
|
+
),
|
|
8718
8738
|
/* @__PURE__ */ jsxs(
|
|
8719
8739
|
"div",
|
|
8720
8740
|
{
|
|
@@ -8725,7 +8745,7 @@ function CompositeSelector({
|
|
|
8725
8745
|
"span",
|
|
8726
8746
|
{
|
|
8727
8747
|
"data-cimplify-composite-component-name": true,
|
|
8728
|
-
className: cn("text-sm
|
|
8748
|
+
className: cn("text-sm", classNames?.componentName),
|
|
8729
8749
|
children: displayName
|
|
8730
8750
|
}
|
|
8731
8751
|
),
|
|
@@ -8749,7 +8769,7 @@ function CompositeSelector({
|
|
|
8749
8769
|
"span",
|
|
8750
8770
|
{
|
|
8751
8771
|
"data-cimplify-composite-component-description": true,
|
|
8752
|
-
className: cn("text-xs text-muted-foreground truncate", classNames?.componentDescription),
|
|
8772
|
+
className: cn("block text-xs text-muted-foreground truncate", classNames?.componentDescription),
|
|
8753
8773
|
children: component.display_description
|
|
8754
8774
|
}
|
|
8755
8775
|
),
|
|
@@ -8757,7 +8777,7 @@ function CompositeSelector({
|
|
|
8757
8777
|
"span",
|
|
8758
8778
|
{
|
|
8759
8779
|
"data-cimplify-composite-component-calories": true,
|
|
8760
|
-
className: cn("text-xs text-muted-foreground/60", classNames?.componentCalories),
|
|
8780
|
+
className: cn("block text-xs text-muted-foreground/60", classNames?.componentCalories),
|
|
8761
8781
|
children: [
|
|
8762
8782
|
component.calories,
|
|
8763
8783
|
" cal"
|
|
@@ -8814,7 +8834,10 @@ function CompositeSelector({
|
|
|
8814
8834
|
)
|
|
8815
8835
|
}
|
|
8816
8836
|
),
|
|
8817
|
-
component.price != null &&
|
|
8837
|
+
component.price != null && /* @__PURE__ */ jsxs("span", { className: "text-sm text-muted-foreground shrink-0", children: [
|
|
8838
|
+
"+",
|
|
8839
|
+
/* @__PURE__ */ jsx(Price, { amount: component.price })
|
|
8840
|
+
] })
|
|
8818
8841
|
]
|
|
8819
8842
|
},
|
|
8820
8843
|
component.id
|
|
@@ -9572,6 +9595,14 @@ function ProductCard({
|
|
|
9572
9595
|
const [isOpen, setIsOpen] = useState(false);
|
|
9573
9596
|
const [shouldFetch, setShouldFetch] = useState(false);
|
|
9574
9597
|
const dialogRef = useRef(null);
|
|
9598
|
+
useEffect(() => {
|
|
9599
|
+
if (!isOpen) return;
|
|
9600
|
+
const original = document.body.style.overflow;
|
|
9601
|
+
document.body.style.overflow = "hidden";
|
|
9602
|
+
return () => {
|
|
9603
|
+
document.body.style.overflow = original;
|
|
9604
|
+
};
|
|
9605
|
+
}, [isOpen]);
|
|
9575
9606
|
const { product: productDetails } = useProduct(
|
|
9576
9607
|
product.slug ?? product.id,
|
|
9577
9608
|
{ enabled: shouldFetch || isOpen }
|
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}.
|
|
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}
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "bundle-selector.tsx",
|
|
12
|
-
"content": "\"use client\";\n\nimport React, { useState, useCallback, useMemo, useEffect, useRef, useId } from \"react\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport { Radio } from \"@base-ui/react/radio\";\nimport type { BundleComponentView, BundleComponentVariantView, BundlePriceType } from \"@cimplify/sdk\";\nimport type { Money } from \"@cimplify/sdk\";\nimport type { BundleSelectionInput } from \"@cimplify/sdk\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface BundleSelectorClassNames {\n root?: string;\n heading?: string;\n components?: string;\n component?: string;\n componentHeader?: string;\n componentQty?: string;\n componentName?: string;\n variantPicker?: string;\n variantOption?: string;\n variantOptionSelected?: string;\n variantAdjustment?: string;\n summary?: string;\n savings?: string;\n}\n\nexport interface BundleSelectorProps {\n components: BundleComponentView[];\n bundlePrice?: Money;\n discountValue?: Money;\n pricingType?: BundlePriceType;\n onSelectionsChange: (selections: BundleSelectionInput[]) => void;\n onPriceChange?: (price: number) => void;\n onReady?: (ready: boolean) => void;\n className?: string;\n classNames?: BundleSelectorClassNames;\n}\n\nexport function BundleSelector({\n components,\n bundlePrice,\n discountValue,\n pricingType,\n onSelectionsChange,\n onPriceChange,\n onReady,\n className,\n classNames,\n}: BundleSelectorProps): React.ReactElement | null {\n const [variantChoices, setVariantChoices] = useState<Record<string, string>>({});\n const lastComponentIds = useRef(\"\");\n\n useEffect(() => {\n const ids = components.map((c) => c.id).sort().join();\n if (ids === lastComponentIds.current) return;\n lastComponentIds.current = ids;\n\n const defaults: Record<string, string> = {};\n for (const comp of components) {\n if (comp.variant_id) {\n defaults[comp.id] = comp.variant_id;\n } else if (comp.available_variants.length > 0) {\n const defaultVariant =\n comp.available_variants.find((v) => v.is_default) || comp.available_variants[0];\n if (defaultVariant) {\n defaults[comp.id] = defaultVariant.id;\n }\n }\n }\n setVariantChoices(defaults);\n }, [components]);\n\n const selections = useMemo((): BundleSelectionInput[] => {\n return components.map((comp) => ({\n component_id: comp.id,\n variant_id: variantChoices[comp.id],\n quantity: comp.quantity,\n }));\n }, [components, variantChoices]);\n\n useEffect(() => {\n onSelectionsChange(selections);\n }, [selections, onSelectionsChange]);\n\n useEffect(() => {\n onReady?.(components.length > 0 && selections.length > 0);\n }, [components, selections, onReady]);\n\n const totalPrice = useMemo(() => {\n if (pricingType === \"fixed\" && bundlePrice) {\n return parsePrice(bundlePrice);\n }\n const componentsTotal = components.reduce((sum, comp) => {\n return sum + getComponentPrice(comp, variantChoices[comp.id]) * comp.quantity;\n }, 0);\n if (pricingType === \"percentage_discount\" && discountValue) {\n return componentsTotal * (1 - parsePrice(discountValue) / 100);\n }\n if (pricingType === \"fixed_discount\" && discountValue) {\n return componentsTotal - parsePrice(discountValue);\n }\n return componentsTotal;\n }, [components, variantChoices, pricingType, bundlePrice, discountValue]);\n\n useEffect(() => {\n onPriceChange?.(totalPrice);\n }, [totalPrice, onPriceChange]);\n\n const handleVariantChange = useCallback(\n (componentId: string, variantId: string) => {\n setVariantChoices((prev) => ({ ...prev, [componentId]: variantId }));\n },\n [],\n );\n\n if (components.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-bundle-selector className={cn(\"space-y-4\", className, classNames?.root)}>\n <span\n data-cimplify-bundle-heading\n className={cn(\"text-xs font-medium uppercase tracking-wider text-muted-foreground\", classNames?.heading)}\n >\n Included in this bundle\n </span>\n\n <div data-cimplify-bundle-components className={cn(\"space-y-3\", classNames?.components)}>\n {components.map((comp) => (\n <BundleComponentCard\n key={comp.id}\n component={comp}\n selectedVariantId={variantChoices[comp.id]}\n onVariantChange={(variantId) =>\n handleVariantChange(comp.id, variantId)\n }\n classNames={classNames}\n />\n ))}\n </div>\n\n {bundlePrice && (\n <div\n data-cimplify-bundle-summary\n className={cn(\"border-t border-border pt-4 flex justify-between text-sm\", classNames?.summary)}\n >\n <span className=\"text-muted-foreground\">Bundle price</span>\n <Price amount={bundlePrice} className=\"font-medium text-primary\" />\n </div>\n )}\n {discountValue && (\n <div\n data-cimplify-bundle-savings\n className={cn(\"flex justify-between text-sm\", classNames?.savings)}\n >\n <span className=\"text-muted-foreground\">You save</span>\n <Price amount={discountValue} className=\"text-green-600 font-medium\" />\n </div>\n )}\n </div>\n );\n}\n\nfunction getComponentPrice(\n component: BundleComponentView,\n selectedVariantId: string | undefined,\n): number {\n if (!selectedVariantId || component.available_variants.length === 0) {\n return parsePrice(component.effective_price);\n }\n if (selectedVariantId === component.variant_id) {\n return parsePrice(component.effective_price);\n }\n const bakedAdj = component.variant_id\n ? component.available_variants.find((v) => v.id === component.variant_id)\n : undefined;\n const selectedAdj = component.available_variants.find((v) => v.id === selectedVariantId);\n if (!selectedAdj) return parsePrice(component.effective_price);\n return parsePrice(component.effective_price)\n - parsePrice(bakedAdj?.price_adjustment ?? \"0\")\n + parsePrice(selectedAdj.price_adjustment);\n}\n\ninterface BundleComponentCardProps {\n component: BundleComponentView;\n selectedVariantId?: string;\n onVariantChange: (variantId: string) => void;\n classNames?: BundleSelectorClassNames;\n}\n\nfunction BundleComponentCard({\n component,\n selectedVariantId,\n onVariantChange,\n classNames,\n}: BundleComponentCardProps): React.ReactElement {\n const idPrefix = useId();\n const showVariantPicker =\n component.allow_variant_choice && component.available_variants.length > 1;\n\n const displayPrice = useMemo(\n () => getComponentPrice(component, selectedVariantId),\n [component, selectedVariantId],\n );\n\n const labelId = `${idPrefix}-bundle-component-${component.id}`;\n\n return (\n <div\n data-cimplify-bundle-component\n className={cn(\"border border-border p-4\", classNames?.component)}\n >\n <div\n data-cimplify-bundle-component-header\n className={cn(\"flex items-start justify-between gap-3\", classNames?.componentHeader)}\n >\n <div>\n {component.quantity > 1 && (\n <span\n data-cimplify-bundle-component-qty\n className={cn(\"text-xs font-medium text-primary bg-primary/10 px-1.5 py-0.5\", classNames?.componentQty)}\n >\n ×{component.quantity}\n </span>\n )}\n <span\n id={labelId}\n data-cimplify-bundle-component-name\n className={cn(\"font-medium text-sm\", classNames?.componentName)}\n >\n {component.product_name}\n </span>\n </div>\n <Price amount={displayPrice} />\n </div>\n\n {showVariantPicker && (\n <RadioGroup\n aria-labelledby={labelId}\n value={selectedVariantId ?? \"\"}\n onValueChange={(value) => {\n onVariantChange(value);\n }}\n data-cimplify-bundle-variant-picker\n className={cn(\"mt-3 flex flex-wrap gap-2\", classNames?.variantPicker)}\n >\n {component.available_variants.map((variant: BundleComponentVariantView) => {\n const isSelected = selectedVariantId === variant.id;\n const adjustment = parsePrice(variant.price_adjustment);\n\n return (\n <Radio.Root\n key={variant.id}\n value={variant.id}\n data-cimplify-bundle-variant-option\n data-selected={isSelected || undefined}\n className={cn(\n \"px-3 py-1.5 border text-xs font-medium transition-colors border-border hover:border-primary/50\",\n isSelected && \"bg-primary text-primary-foreground border-primary\",\n isSelected ? classNames?.variantOptionSelected : classNames?.variantOption,\n )}\n >\n {variant.display_name}\n {adjustment !== 0 && (\n <span\n data-cimplify-bundle-variant-adjustment\n className={cn(\"ml-1 opacity-70\", classNames?.variantAdjustment)}\n >\n {adjustment > 0 ? \"+\" : \"\"}\n <Price amount={variant.price_adjustment} />\n </span>\n )}\n </Radio.Root>\n );\n })}\n </RadioGroup>\n )}\n </div>\n );\n}\n"
|
|
12
|
+
"content": "\"use client\";\n\nimport React, { useState, useCallback, useMemo, useEffect, useRef, useId } from \"react\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport { Radio } from \"@base-ui/react/radio\";\nimport type { BundleComponentView, BundleComponentVariantView, BundlePriceType } from \"@cimplify/sdk\";\nimport type { Money } from \"@cimplify/sdk\";\nimport type { BundleSelectionInput } from \"@cimplify/sdk\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface BundleSelectorClassNames {\n root?: string;\n heading?: string;\n components?: string;\n component?: string;\n componentHeader?: string;\n componentQty?: string;\n componentName?: string;\n variantPicker?: string;\n variantOption?: string;\n variantOptionSelected?: string;\n variantAdjustment?: string;\n summary?: string;\n savings?: string;\n}\n\nexport interface BundleSelectorProps {\n components: BundleComponentView[];\n bundlePrice?: Money;\n discountValue?: Money;\n pricingType?: BundlePriceType;\n onSelectionsChange: (selections: BundleSelectionInput[]) => void;\n onPriceChange?: (price: number) => void;\n onReady?: (ready: boolean) => void;\n className?: string;\n classNames?: BundleSelectorClassNames;\n}\n\nexport function BundleSelector({\n components,\n bundlePrice,\n discountValue,\n pricingType,\n onSelectionsChange,\n onPriceChange,\n onReady,\n className,\n classNames,\n}: BundleSelectorProps): React.ReactElement | null {\n const [variantChoices, setVariantChoices] = useState<Record<string, string>>({});\n const lastComponentIds = useRef(\"\");\n\n useEffect(() => {\n const ids = components.map((c) => c.id).sort().join();\n if (ids === lastComponentIds.current) return;\n lastComponentIds.current = ids;\n\n const defaults: Record<string, string> = {};\n for (const comp of components) {\n if (comp.variant_id) {\n defaults[comp.id] = comp.variant_id;\n } else if (comp.available_variants.length > 0) {\n const defaultVariant =\n comp.available_variants.find((v) => v.is_default) || comp.available_variants[0];\n if (defaultVariant) {\n defaults[comp.id] = defaultVariant.id;\n }\n }\n }\n setVariantChoices(defaults);\n }, [components]);\n\n const selections = useMemo((): BundleSelectionInput[] => {\n return components.map((comp) => ({\n component_id: comp.id,\n variant_id: variantChoices[comp.id],\n quantity: comp.quantity,\n }));\n }, [components, variantChoices]);\n\n useEffect(() => {\n onSelectionsChange(selections);\n }, [selections, onSelectionsChange]);\n\n useEffect(() => {\n onReady?.(components.length > 0 && selections.length > 0);\n }, [components, selections, onReady]);\n\n const totalPrice = useMemo(() => {\n if (pricingType === \"fixed\" && bundlePrice) {\n return parsePrice(bundlePrice);\n }\n const componentsTotal = components.reduce((sum, comp) => {\n return sum + getComponentPrice(comp, variantChoices[comp.id]) * comp.quantity;\n }, 0);\n if (pricingType === \"percentage_discount\" && discountValue) {\n return componentsTotal * (1 - parsePrice(discountValue) / 100);\n }\n if (pricingType === \"fixed_discount\" && discountValue) {\n return componentsTotal - parsePrice(discountValue);\n }\n return componentsTotal;\n }, [components, variantChoices, pricingType, bundlePrice, discountValue]);\n\n useEffect(() => {\n onPriceChange?.(totalPrice);\n }, [totalPrice, onPriceChange]);\n\n const handleVariantChange = useCallback(\n (componentId: string, variantId: string) => {\n setVariantChoices((prev) => ({ ...prev, [componentId]: variantId }));\n },\n [],\n );\n\n if (components.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-bundle-selector className={cn(\"space-y-4\", className, classNames?.root)}>\n <div\n data-cimplify-bundle-heading\n className={cn(\"flex items-center justify-between py-3\", classNames?.heading)}\n >\n <span className=\"text-base font-bold\">Included in this bundle</span>\n </div>\n\n <div data-cimplify-bundle-components className={cn(\"divide-y divide-border\", classNames?.components)}>\n {components.map((comp) => (\n <BundleComponentCard\n key={comp.id}\n component={comp}\n selectedVariantId={variantChoices[comp.id]}\n onVariantChange={(variantId) =>\n handleVariantChange(comp.id, variantId)\n }\n classNames={classNames}\n />\n ))}\n </div>\n\n {bundlePrice && (\n <div\n data-cimplify-bundle-summary\n className={cn(\"border-t border-border pt-4 flex justify-between text-sm\", classNames?.summary)}\n >\n <span className=\"text-muted-foreground\">Bundle price</span>\n <Price amount={bundlePrice} className=\"font-medium text-primary\" />\n </div>\n )}\n {discountValue && (\n <div\n data-cimplify-bundle-savings\n className={cn(\"flex justify-between text-sm\", classNames?.savings)}\n >\n <span className=\"text-muted-foreground\">You save</span>\n <Price amount={discountValue} className=\"text-green-600 font-medium\" />\n </div>\n )}\n </div>\n );\n}\n\nfunction getComponentPrice(\n component: BundleComponentView,\n selectedVariantId: string | undefined,\n): number {\n if (!selectedVariantId || component.available_variants.length === 0) {\n return parsePrice(component.effective_price);\n }\n if (selectedVariantId === component.variant_id) {\n return parsePrice(component.effective_price);\n }\n const bakedAdj = component.variant_id\n ? component.available_variants.find((v) => v.id === component.variant_id)\n : undefined;\n const selectedAdj = component.available_variants.find((v) => v.id === selectedVariantId);\n if (!selectedAdj) return parsePrice(component.effective_price);\n return parsePrice(component.effective_price)\n - parsePrice(bakedAdj?.price_adjustment ?? \"0\")\n + parsePrice(selectedAdj.price_adjustment);\n}\n\ninterface BundleComponentCardProps {\n component: BundleComponentView;\n selectedVariantId?: string;\n onVariantChange: (variantId: string) => void;\n classNames?: BundleSelectorClassNames;\n}\n\nfunction BundleComponentCard({\n component,\n selectedVariantId,\n onVariantChange,\n classNames,\n}: BundleComponentCardProps): React.ReactElement {\n const idPrefix = useId();\n const showVariantPicker =\n component.allow_variant_choice && component.available_variants.length > 1;\n\n const displayPrice = useMemo(\n () => getComponentPrice(component, selectedVariantId),\n [component, selectedVariantId],\n );\n\n const labelId = `${idPrefix}-bundle-component-${component.id}`;\n\n return (\n <div\n data-cimplify-bundle-component\n className={cn(\"py-4\", classNames?.component)}\n >\n <div\n data-cimplify-bundle-component-header\n className={cn(\"flex items-center justify-between gap-3\", classNames?.componentHeader)}\n >\n <div className=\"flex items-center gap-2\">\n {component.quantity > 1 && (\n <span\n data-cimplify-bundle-component-qty\n className={cn(\"text-xs font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded\", classNames?.componentQty)}\n >\n ×{component.quantity}\n </span>\n )}\n <span\n id={labelId}\n data-cimplify-bundle-component-name\n className={cn(\"text-sm\", classNames?.componentName)}\n >\n {component.product_name}\n </span>\n </div>\n <span className=\"text-sm text-muted-foreground\">\n <Price amount={displayPrice} />\n </span>\n </div>\n\n {showVariantPicker && (\n <RadioGroup\n aria-labelledby={labelId}\n value={selectedVariantId ?? \"\"}\n onValueChange={(value) => {\n onVariantChange(value);\n }}\n data-cimplify-bundle-variant-picker\n className={cn(\"mt-3 divide-y divide-border\", classNames?.variantPicker)}\n >\n {component.available_variants.map((variant: BundleComponentVariantView) => {\n const isSelected = selectedVariantId === variant.id;\n const adjustment = parsePrice(variant.price_adjustment);\n\n return (\n <Radio.Root\n key={variant.id}\n value={variant.id}\n data-cimplify-bundle-variant-option\n data-selected={isSelected || undefined}\n className={cn(\n \"w-full flex items-center gap-3 py-3 transition-colors cursor-pointer\",\n isSelected ? classNames?.variantOptionSelected : classNames?.variantOption,\n )}\n >\n <span\n className={cn(\n \"w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors\",\n isSelected ? \"border-primary\" : \"border-muted-foreground/30\",\n )}\n >\n {isSelected && <span className=\"w-2.5 h-2.5 rounded-full bg-primary\" />}\n </span>\n <span className=\"flex-1 text-sm\">\n {variant.display_name}\n </span>\n {adjustment !== 0 && (\n <span\n data-cimplify-bundle-variant-adjustment\n className={cn(\"text-sm text-muted-foreground\", classNames?.variantAdjustment)}\n >\n {adjustment > 0 ? \"+\" : \"\"}\n <Price amount={variant.price_adjustment} />\n </span>\n )}\n </Radio.Root>\n );\n })}\n </RadioGroup>\n )}\n </div>\n );\n}\n"
|
|
13
13
|
}
|
|
14
14
|
]
|
|
15
15
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "composite-selector.tsx",
|
|
12
|
-
"content": "\"use client\";\n\nimport React, { useState, useCallback, useMemo, useEffect } from \"react\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport { NumberField } from \"@base-ui/react/number-field\";\nimport type {\n CompositeGroupView,\n CompositeComponentView,\n ComponentSelectionInput,\n CompositePriceResult,\n} from \"@cimplify/sdk\";\nimport { useCimplify } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface CompositeSelectorClassNames {\n root?: string;\n group?: string;\n groupHeader?: string;\n groupName?: string;\n required?: string;\n groupDescription?: string;\n groupConstraint?: string;\n validation?: string;\n components?: string;\n component?: string;\n componentSelected?: string;\n componentInfo?: string;\n componentName?: string;\n badgePopular?: string;\n badgePremium?: string;\n componentDescription?: string;\n componentCalories?: string;\n qty?: string;\n qtyButton?: string;\n qtyValue?: string;\n summary?: string;\n summaryLine?: string;\n summaryTotal?: string;\n calculating?: string;\n priceError?: string;\n}\n\nexport interface CompositeSelectorProps {\n compositeId: string;\n groups: CompositeGroupView[];\n onSelectionsChange: (selections: ComponentSelectionInput[]) => void;\n onPriceChange?: (price: CompositePriceResult | null) => void;\n onReady?: (ready: boolean) => void;\n skipPriceFetch?: boolean;\n className?: string;\n classNames?: CompositeSelectorClassNames;\n}\n\nexport function CompositeSelector({\n compositeId,\n groups,\n onSelectionsChange,\n onPriceChange,\n onReady,\n skipPriceFetch,\n className,\n classNames,\n}: CompositeSelectorProps): React.ReactElement | null {\n const { client } = useCimplify();\n\n const [groupSelections, setGroupSelections] = useState<\n Record<string, Record<string, number>>\n >({});\n const [priceResult, setPriceResult] = useState<CompositePriceResult | null>(null);\n const [isPriceLoading, setIsPriceLoading] = useState(false);\n const [priceError, setPriceError] = useState(false);\n\n const selections = useMemo((): ComponentSelectionInput[] => {\n const result: ComponentSelectionInput[] = [];\n for (const groupSels of Object.values(groupSelections)) {\n for (const [componentId, qty] of Object.entries(groupSels)) {\n if (qty > 0) {\n result.push({ component_id: componentId, quantity: qty });\n }\n }\n }\n return result;\n }, [groupSelections]);\n\n useEffect(() => {\n onSelectionsChange(selections);\n }, [selections, onSelectionsChange]);\n\n useEffect(() => {\n onPriceChange?.(priceResult);\n }, [priceResult, onPriceChange]);\n\n const allGroupsSatisfied = useMemo(() => {\n for (const group of groups) {\n const groupSels = groupSelections[group.id] || {};\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n if (totalSelected < group.min_selections) return false;\n }\n return true;\n }, [groups, groupSelections]);\n\n useEffect(() => {\n onReady?.(allGroupsSatisfied);\n }, [allGroupsSatisfied, onReady]);\n\n useEffect(() => {\n if (skipPriceFetch || !allGroupsSatisfied || selections.length === 0) return;\n\n let cancelled = false;\n const timer = setTimeout(() => {\n void (async () => {\n setIsPriceLoading(true);\n setPriceError(false);\n try {\n const result = await client.catalogue.calculateCompositePrice(compositeId, selections);\n if (cancelled) return;\n if (result.ok) {\n setPriceResult(result.value);\n } else {\n setPriceError(true);\n }\n } catch {\n if (!cancelled) setPriceError(true);\n } finally {\n if (!cancelled) setIsPriceLoading(false);\n }\n })();\n }, 300);\n\n return () => {\n cancelled = true;\n clearTimeout(timer);\n };\n }, [selections, allGroupsSatisfied, compositeId, client, skipPriceFetch]);\n\n const toggleComponent = useCallback(\n (group: CompositeGroupView, component: CompositeComponentView) => {\n setGroupSelections((prev) => {\n const groupSels = { ...(prev[group.id] || {}) };\n const currentQty = groupSels[component.id] || 0;\n\n if (currentQty > 0) {\n if (group.min_selections > 0) {\n const totalOthers = Object.entries(groupSels)\n .filter(([id]) => id !== component.id)\n .reduce((sum, [, q]) => sum + q, 0);\n if (totalOthers < group.min_selections) {\n return prev;\n }\n }\n delete groupSels[component.id];\n } else {\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n if (group.max_selections && totalSelected >= group.max_selections) {\n if (group.max_selections === 1) {\n return { ...prev, [group.id]: { [component.id]: 1 } };\n }\n return prev;\n }\n groupSels[component.id] = 1;\n }\n\n return { ...prev, [group.id]: groupSels };\n });\n },\n [],\n );\n\n const updateQuantity = useCallback(\n (group: CompositeGroupView, componentId: string, newValue: number) => {\n setGroupSelections((prev) => {\n const groupSels = { ...(prev[group.id] || {}) };\n const current = groupSels[componentId] || 0;\n const next = Math.max(0, newValue);\n\n if (next === current) return prev;\n\n const delta = next - current;\n\n if (group.max_quantity_per_component && next > group.max_quantity_per_component) {\n return prev;\n }\n\n const totalAfter = Object.entries(groupSels)\n .reduce((sum, [id, q]) => sum + (id === componentId ? next : q), 0);\n\n if (delta > 0 && group.max_selections && totalAfter > group.max_selections) {\n return prev;\n }\n\n if (delta < 0 && totalAfter < group.min_selections) {\n return prev;\n }\n\n if (next === 0) {\n delete groupSels[componentId];\n } else {\n groupSels[componentId] = next;\n }\n\n return { ...prev, [group.id]: groupSels };\n });\n },\n [],\n );\n\n if (groups.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-composite-selector className={cn(\"space-y-6\", className, classNames?.root)}>\n {[...groups]\n .sort((a, b) => a.display_order - b.display_order)\n .map((group) => {\n const groupSels = groupSelections[group.id] || {};\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n const minMet = totalSelected >= group.min_selections;\n const isSingleSelect = group.max_selections === 1;\n\n return (\n <div\n key={group.id}\n data-cimplify-composite-group\n className={cn(\"border border-border p-5\", classNames?.group)}\n >\n <div\n data-cimplify-composite-group-header\n className={cn(\"flex items-center justify-between mb-4\", classNames?.groupHeader)}\n >\n <div>\n <span\n data-cimplify-composite-group-name\n className={cn(\"text-xs font-medium uppercase tracking-wider text-muted-foreground\", classNames?.groupName)}\n >\n {group.name}\n {group.min_selections > 0 && (\n <span\n data-cimplify-composite-required\n className={cn(\"text-destructive ml-1\", classNames?.required)}\n >\n {\" \"}*\n </span>\n )}\n </span>\n {group.description && (\n <span\n data-cimplify-composite-group-description\n className={cn(\"text-xs text-muted-foreground/70 mt-0.5\", classNames?.groupDescription)}\n >\n {group.description}\n </span>\n )}\n <span\n data-cimplify-composite-group-constraint\n className={cn(\"text-xs text-muted-foreground/70 mt-1\", classNames?.groupConstraint)}\n >\n {group.min_selections > 0 && group.max_selections\n ? `Choose ${group.min_selections}\\u2013${group.max_selections}`\n : group.min_selections > 0\n ? `Choose at least ${group.min_selections}`\n : group.max_selections\n ? `Choose up to ${group.max_selections}`\n : \"Choose as many as you like\"}\n </span>\n </div>\n {!minMet && (\n <span\n data-cimplify-composite-validation\n className={cn(\"text-xs text-destructive font-medium\", classNames?.validation)}\n >\n Required\n </span>\n )}\n </div>\n\n <div\n data-cimplify-composite-components\n role={isSingleSelect ? \"radiogroup\" : \"group\"}\n aria-label={group.name}\n className={cn(\"space-y-1\", classNames?.components)}\n >\n {group.components\n .filter((c) => c.is_available && !c.is_archived)\n .sort((a, b) => a.display_order - b.display_order)\n .map((component) => {\n const qty = groupSels[component.id] || 0;\n const isSelected = qty > 0;\n const displayName = component.display_name || component.id;\n\n return (\n <Checkbox.Root\n key={component.id}\n checked={isSelected}\n onCheckedChange={() => toggleComponent(group, component)}\n value={component.id}\n data-cimplify-composite-component\n data-selected={isSelected || undefined}\n className={cn(\n \"w-full flex items-center gap-3 px-4 py-3 border transition-colors text-left border-transparent hover:bg-muted/50\",\n isSelected && \"bg-primary/5 border-primary\",\n isSelected ? classNames?.componentSelected : classNames?.component,\n )}\n >\n <Checkbox.Indicator\n className=\"hidden\"\n keepMounted={false}\n />\n\n <div\n data-cimplify-composite-component-info\n className={cn(\"flex-1 min-w-0\", classNames?.componentInfo)}\n >\n <span\n data-cimplify-composite-component-name\n className={cn(\"text-sm font-medium\", isSelected && \"text-primary\", classNames?.componentName)}\n >\n {displayName}\n </span>\n {component.is_popular && (\n <span\n data-cimplify-composite-badge=\"popular\"\n className={cn(\"text-[10px] uppercase tracking-wider text-primary font-medium\", classNames?.badgePopular)}\n >\n Popular\n </span>\n )}\n {component.is_premium && (\n <span\n data-cimplify-composite-badge=\"premium\"\n className={cn(\"text-[10px] uppercase tracking-wider text-amber-600 font-medium\", classNames?.badgePremium)}\n >\n Premium\n </span>\n )}\n {component.display_description && (\n <span\n data-cimplify-composite-component-description\n className={cn(\"text-xs text-muted-foreground truncate\", classNames?.componentDescription)}\n >\n {component.display_description}\n </span>\n )}\n {component.calories != null && (\n <span\n data-cimplify-composite-component-calories\n className={cn(\"text-xs text-muted-foreground/60\", classNames?.componentCalories)}\n >\n {component.calories} cal\n </span>\n )}\n </div>\n\n {group.allow_quantity && isSelected && (\n <NumberField.Root\n value={qty}\n onValueChange={(val) => {\n if (val != null) {\n updateQuantity(group, component.id, val);\n }\n }}\n min={0}\n max={group.max_quantity_per_component || undefined}\n step={1}\n >\n <NumberField.Group\n data-cimplify-composite-qty\n onClick={(e: React.MouseEvent) => e.stopPropagation()}\n className={cn(\"flex items-center gap-2\", classNames?.qty)}\n >\n <NumberField.Decrement\n aria-label={`Decrease ${displayName} quantity`}\n className={cn(\"w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30\", classNames?.qtyButton)}\n >\n −\n </NumberField.Decrement>\n <NumberField.Input\n readOnly\n className={cn(\"w-4 text-center text-sm font-medium bg-transparent border-none outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\", classNames?.qtyValue)}\n />\n <NumberField.Increment\n aria-label={`Increase ${displayName} quantity`}\n className={cn(\"w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30\", classNames?.qtyButton)}\n >\n +\n </NumberField.Increment>\n </NumberField.Group>\n </NumberField.Root>\n )}\n\n {component.price != null && parsePrice(component.price) !== 0 && (\n <Price amount={component.price} prefix=\"+\" />\n )}\n </Checkbox.Root>\n );\n })}\n </div>\n </div>\n );\n })}\n\n {priceResult && (\n <div\n data-cimplify-composite-summary\n className={cn(\"border-t border-border pt-4 space-y-1 text-sm\", classNames?.summary)}\n >\n {parsePrice(priceResult.base_price) !== 0 && (\n <div\n data-cimplify-composite-summary-line\n className={cn(\"flex justify-between text-muted-foreground\", classNames?.summaryLine)}\n >\n <span>Base</span>\n <Price amount={priceResult.base_price} />\n </div>\n )}\n {parsePrice(priceResult.components_total) !== 0 && (\n <div\n data-cimplify-composite-summary-line\n className={cn(\"flex justify-between text-muted-foreground\", classNames?.summaryLine)}\n >\n <span>Selections</span>\n <Price amount={priceResult.components_total} />\n </div>\n )}\n <div\n data-cimplify-composite-summary-total\n className={cn(\"flex justify-between font-medium pt-1 border-t border-border\", classNames?.summaryTotal)}\n >\n <span>Total</span>\n <Price amount={priceResult.final_price} className=\"text-primary\" />\n </div>\n </div>\n )}\n\n {isPriceLoading && (\n <div\n data-cimplify-composite-calculating\n className={cn(\"flex items-center gap-2 text-sm text-muted-foreground\", classNames?.calculating)}\n >\n Calculating price...\n </div>\n )}\n\n {priceError && !isPriceLoading && (\n <div\n data-cimplify-composite-price-error\n className={cn(\"text-sm text-destructive\", classNames?.priceError)}\n >\n Unable to calculate price\n </div>\n )}\n </div>\n );\n}\n"
|
|
12
|
+
"content": "\"use client\";\n\nimport React, { useState, useCallback, useMemo, useEffect } from \"react\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport { NumberField } from \"@base-ui/react/number-field\";\nimport type {\n CompositeGroupView,\n CompositeComponentView,\n ComponentSelectionInput,\n CompositePriceResult,\n} from \"@cimplify/sdk\";\nimport { useCimplify } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface CompositeSelectorClassNames {\n root?: string;\n group?: string;\n groupHeader?: string;\n groupName?: string;\n required?: string;\n groupDescription?: string;\n groupConstraint?: string;\n validation?: string;\n components?: string;\n component?: string;\n componentSelected?: string;\n componentInfo?: string;\n componentName?: string;\n badgePopular?: string;\n badgePremium?: string;\n componentDescription?: string;\n componentCalories?: string;\n qty?: string;\n qtyButton?: string;\n qtyValue?: string;\n summary?: string;\n summaryLine?: string;\n summaryTotal?: string;\n calculating?: string;\n priceError?: string;\n}\n\nexport interface CompositeSelectorProps {\n compositeId: string;\n groups: CompositeGroupView[];\n onSelectionsChange: (selections: ComponentSelectionInput[]) => void;\n onPriceChange?: (price: CompositePriceResult | null) => void;\n onReady?: (ready: boolean) => void;\n skipPriceFetch?: boolean;\n className?: string;\n classNames?: CompositeSelectorClassNames;\n}\n\nexport function CompositeSelector({\n compositeId,\n groups,\n onSelectionsChange,\n onPriceChange,\n onReady,\n skipPriceFetch,\n className,\n classNames,\n}: CompositeSelectorProps): React.ReactElement | null {\n const { client } = useCimplify();\n\n const [groupSelections, setGroupSelections] = useState<\n Record<string, Record<string, number>>\n >({});\n const [priceResult, setPriceResult] = useState<CompositePriceResult | null>(null);\n const [isPriceLoading, setIsPriceLoading] = useState(false);\n const [priceError, setPriceError] = useState(false);\n\n const selections = useMemo((): ComponentSelectionInput[] => {\n const result: ComponentSelectionInput[] = [];\n for (const groupSels of Object.values(groupSelections)) {\n for (const [componentId, qty] of Object.entries(groupSels)) {\n if (qty > 0) {\n result.push({ component_id: componentId, quantity: qty });\n }\n }\n }\n return result;\n }, [groupSelections]);\n\n useEffect(() => {\n onSelectionsChange(selections);\n }, [selections, onSelectionsChange]);\n\n useEffect(() => {\n onPriceChange?.(priceResult);\n }, [priceResult, onPriceChange]);\n\n const allGroupsSatisfied = useMemo(() => {\n for (const group of groups) {\n const groupSels = groupSelections[group.id] || {};\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n if (totalSelected < group.min_selections) return false;\n }\n return true;\n }, [groups, groupSelections]);\n\n useEffect(() => {\n onReady?.(allGroupsSatisfied);\n }, [allGroupsSatisfied, onReady]);\n\n useEffect(() => {\n if (skipPriceFetch || !allGroupsSatisfied || selections.length === 0) return;\n\n let cancelled = false;\n const timer = setTimeout(() => {\n void (async () => {\n setIsPriceLoading(true);\n setPriceError(false);\n try {\n const result = await client.catalogue.calculateCompositePrice(compositeId, selections);\n if (cancelled) return;\n if (result.ok) {\n setPriceResult(result.value);\n } else {\n setPriceError(true);\n }\n } catch {\n if (!cancelled) setPriceError(true);\n } finally {\n if (!cancelled) setIsPriceLoading(false);\n }\n })();\n }, 300);\n\n return () => {\n cancelled = true;\n clearTimeout(timer);\n };\n }, [selections, allGroupsSatisfied, compositeId, client, skipPriceFetch]);\n\n const toggleComponent = useCallback(\n (group: CompositeGroupView, component: CompositeComponentView) => {\n setGroupSelections((prev) => {\n const groupSels = { ...(prev[group.id] || {}) };\n const currentQty = groupSels[component.id] || 0;\n\n if (currentQty > 0) {\n if (group.min_selections > 0) {\n const totalOthers = Object.entries(groupSels)\n .filter(([id]) => id !== component.id)\n .reduce((sum, [, q]) => sum + q, 0);\n if (totalOthers < group.min_selections) {\n return prev;\n }\n }\n delete groupSels[component.id];\n } else {\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n if (group.max_selections && totalSelected >= group.max_selections) {\n if (group.max_selections === 1) {\n return { ...prev, [group.id]: { [component.id]: 1 } };\n }\n return prev;\n }\n groupSels[component.id] = 1;\n }\n\n return { ...prev, [group.id]: groupSels };\n });\n },\n [],\n );\n\n const updateQuantity = useCallback(\n (group: CompositeGroupView, componentId: string, newValue: number) => {\n setGroupSelections((prev) => {\n const groupSels = { ...(prev[group.id] || {}) };\n const current = groupSels[componentId] || 0;\n const next = Math.max(0, newValue);\n\n if (next === current) return prev;\n\n const delta = next - current;\n\n if (group.max_quantity_per_component && next > group.max_quantity_per_component) {\n return prev;\n }\n\n const totalAfter = Object.entries(groupSels)\n .reduce((sum, [id, q]) => sum + (id === componentId ? next : q), 0);\n\n if (delta > 0 && group.max_selections && totalAfter > group.max_selections) {\n return prev;\n }\n\n if (delta < 0 && totalAfter < group.min_selections) {\n return prev;\n }\n\n if (next === 0) {\n delete groupSels[componentId];\n } else {\n groupSels[componentId] = next;\n }\n\n return { ...prev, [group.id]: groupSels };\n });\n },\n [],\n );\n\n if (groups.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-composite-selector className={cn(\"space-y-6\", className, classNames?.root)}>\n {[...groups]\n .sort((a, b) => a.display_order - b.display_order)\n .map((group) => {\n const groupSels = groupSelections[group.id] || {};\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n const minMet = totalSelected >= group.min_selections;\n const isSingleSelect = group.max_selections === 1;\n\n return (\n <div\n key={group.id}\n data-cimplify-composite-group\n className={cn(classNames?.group)}\n >\n <div\n data-cimplify-composite-group-header\n className={cn(\"flex items-center justify-between py-3\", classNames?.groupHeader)}\n >\n <div>\n <span\n data-cimplify-composite-group-name\n className={cn(\"text-base font-bold\", classNames?.groupName)}\n >\n {group.name}\n </span>\n {group.description && (\n <span\n data-cimplify-composite-group-description\n className={cn(\"block text-xs text-muted-foreground mt-0.5\", classNames?.groupDescription)}\n >\n {group.description}\n </span>\n )}\n <span\n data-cimplify-composite-group-constraint\n className={cn(\"block text-xs text-muted-foreground mt-0.5\", classNames?.groupConstraint)}\n >\n {group.min_selections > 0 && group.max_selections\n ? `Choose ${group.min_selections}\\u2013${group.max_selections}`\n : group.min_selections > 0\n ? `Choose at least ${group.min_selections}`\n : group.max_selections\n ? `Choose up to ${group.max_selections}`\n : \"Choose as many as you like\"}\n </span>\n </div>\n {group.min_selections > 0 && (\n <span\n data-cimplify-composite-required\n className={cn(\n \"text-xs font-semibold px-2.5 py-1 rounded shrink-0\",\n !minMet\n ? \"text-destructive bg-destructive/10\"\n : \"text-destructive bg-destructive/10\",\n classNames?.required,\n )}\n >\n Required\n </span>\n )}\n </div>\n\n <div\n data-cimplify-composite-components\n role={isSingleSelect ? \"radiogroup\" : \"group\"}\n aria-label={group.name}\n className={cn(\"divide-y divide-border\", classNames?.components)}\n >\n {group.components\n .filter((c) => c.is_available && !c.is_archived)\n .sort((a, b) => a.display_order - b.display_order)\n .map((component) => {\n const qty = groupSels[component.id] || 0;\n const isSelected = qty > 0;\n const displayName = component.display_name || component.id;\n\n return (\n <Checkbox.Root\n key={component.id}\n checked={isSelected}\n onCheckedChange={() => toggleComponent(group, component)}\n value={component.id}\n data-cimplify-composite-component\n data-selected={isSelected || undefined}\n className={cn(\n \"w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer\",\n isSelected ? classNames?.componentSelected : classNames?.component,\n )}\n >\n <Checkbox.Indicator\n className=\"hidden\"\n keepMounted={false}\n />\n\n {/* Visual indicator: radio circle for single-select, checkbox square for multi-select */}\n {isSingleSelect ? (\n <span\n data-cimplify-composite-radio\n className={cn(\n \"w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors\",\n isSelected ? \"border-primary\" : \"border-muted-foreground/30\",\n )}\n >\n {isSelected && <span className=\"w-2.5 h-2.5 rounded-full bg-primary\" />}\n </span>\n ) : (\n <span\n data-cimplify-composite-checkbox\n className={cn(\n \"w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors\",\n isSelected ? \"border-primary bg-primary\" : \"border-muted-foreground/30\",\n )}\n >\n {isSelected && (\n <svg viewBox=\"0 0 12 12\" className=\"w-3 h-3 text-primary-foreground\" fill=\"none\">\n <path d=\"M2 6l3 3 5-5\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"/>\n </svg>\n )}\n </span>\n )}\n\n <div\n data-cimplify-composite-component-info\n className={cn(\"flex-1 min-w-0\", classNames?.componentInfo)}\n >\n <span\n data-cimplify-composite-component-name\n className={cn(\"text-sm\", classNames?.componentName)}\n >\n {displayName}\n </span>\n {component.is_popular && (\n <span\n data-cimplify-composite-badge=\"popular\"\n className={cn(\"text-[10px] uppercase tracking-wider text-primary font-medium\", classNames?.badgePopular)}\n >\n Popular\n </span>\n )}\n {component.is_premium && (\n <span\n data-cimplify-composite-badge=\"premium\"\n className={cn(\"text-[10px] uppercase tracking-wider text-amber-600 font-medium\", classNames?.badgePremium)}\n >\n Premium\n </span>\n )}\n {component.display_description && (\n <span\n data-cimplify-composite-component-description\n className={cn(\"block text-xs text-muted-foreground truncate\", classNames?.componentDescription)}\n >\n {component.display_description}\n </span>\n )}\n {component.calories != null && (\n <span\n data-cimplify-composite-component-calories\n className={cn(\"block text-xs text-muted-foreground/60\", classNames?.componentCalories)}\n >\n {component.calories} cal\n </span>\n )}\n </div>\n\n {group.allow_quantity && isSelected && (\n <NumberField.Root\n value={qty}\n onValueChange={(val) => {\n if (val != null) {\n updateQuantity(group, component.id, val);\n }\n }}\n min={0}\n max={group.max_quantity_per_component || undefined}\n step={1}\n >\n <NumberField.Group\n data-cimplify-composite-qty\n onClick={(e: React.MouseEvent) => e.stopPropagation()}\n className={cn(\"flex items-center gap-2\", classNames?.qty)}\n >\n <NumberField.Decrement\n aria-label={`Decrease ${displayName} quantity`}\n className={cn(\"w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30\", classNames?.qtyButton)}\n >\n −\n </NumberField.Decrement>\n <NumberField.Input\n readOnly\n className={cn(\"w-4 text-center text-sm font-medium bg-transparent border-none outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\", classNames?.qtyValue)}\n />\n <NumberField.Increment\n aria-label={`Increase ${displayName} quantity`}\n className={cn(\"w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30\", classNames?.qtyButton)}\n >\n +\n </NumberField.Increment>\n </NumberField.Group>\n </NumberField.Root>\n )}\n\n {component.price != null && (\n <span className=\"text-sm text-muted-foreground shrink-0\">\n +<Price amount={component.price} />\n </span>\n )}\n </Checkbox.Root>\n );\n })}\n </div>\n </div>\n );\n })}\n\n {priceResult && (\n <div\n data-cimplify-composite-summary\n className={cn(\"border-t border-border pt-4 space-y-1 text-sm\", classNames?.summary)}\n >\n {parsePrice(priceResult.base_price) !== 0 && (\n <div\n data-cimplify-composite-summary-line\n className={cn(\"flex justify-between text-muted-foreground\", classNames?.summaryLine)}\n >\n <span>Base</span>\n <Price amount={priceResult.base_price} />\n </div>\n )}\n {parsePrice(priceResult.components_total) !== 0 && (\n <div\n data-cimplify-composite-summary-line\n className={cn(\"flex justify-between text-muted-foreground\", classNames?.summaryLine)}\n >\n <span>Selections</span>\n <Price amount={priceResult.components_total} />\n </div>\n )}\n <div\n data-cimplify-composite-summary-total\n className={cn(\"flex justify-between font-medium pt-1 border-t border-border\", classNames?.summaryTotal)}\n >\n <span>Total</span>\n <Price amount={priceResult.final_price} className=\"text-primary\" />\n </div>\n </div>\n )}\n\n {isPriceLoading && (\n <div\n data-cimplify-composite-calculating\n className={cn(\"flex items-center gap-2 text-sm text-muted-foreground\", classNames?.calculating)}\n >\n Calculating price...\n </div>\n )}\n\n {priceError && !isPriceLoading && (\n <div\n data-cimplify-composite-price-error\n className={cn(\"text-sm text-destructive\", classNames?.priceError)}\n >\n Unable to calculate price\n </div>\n )}\n </div>\n );\n}\n"
|
|
13
13
|
}
|
|
14
14
|
]
|
|
15
15
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
{
|
|
13
13
|
"path": "product-card.tsx",
|
|
14
|
-
"content": "\"use client\";\n\nimport React, { useCallback, 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 // 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, 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"
|
|
15
15
|
}
|
|
16
16
|
]
|
|
17
17
|
}
|