@cimplify/sdk 0.14.0 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/react.js +78 -45
- package/dist/react.mjs +78 -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
|
@@ -6238,6 +6238,8 @@ function useQuote(input, options = {}) {
|
|
|
6238
6238
|
}
|
|
6239
6239
|
}
|
|
6240
6240
|
setIsLoading(true);
|
|
6241
|
+
setQuote(null);
|
|
6242
|
+
setMessages([]);
|
|
6241
6243
|
try {
|
|
6242
6244
|
const existing = quoteInflight.get(cacheKey);
|
|
6243
6245
|
const promise = existing ?? (async () => {
|
|
@@ -8340,14 +8342,14 @@ function BundleSelector({
|
|
|
8340
8342
|
}
|
|
8341
8343
|
return /* @__PURE__ */ jsxRuntime.jsxs("div", { "data-cimplify-bundle-selector": true, className: cn("space-y-4", className, classNames?.root), children: [
|
|
8342
8344
|
/* @__PURE__ */ jsxRuntime.jsx(
|
|
8343
|
-
"
|
|
8345
|
+
"div",
|
|
8344
8346
|
{
|
|
8345
8347
|
"data-cimplify-bundle-heading": true,
|
|
8346
|
-
className: cn("
|
|
8347
|
-
children: "Included in this bundle"
|
|
8348
|
+
className: cn("flex items-center justify-between py-3", classNames?.heading),
|
|
8349
|
+
children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-base font-bold", children: "Included in this bundle" })
|
|
8348
8350
|
}
|
|
8349
8351
|
),
|
|
8350
|
-
/* @__PURE__ */ jsxRuntime.jsx("div", { "data-cimplify-bundle-components": true, className: cn("
|
|
8352
|
+
/* @__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
8353
|
BundleComponentCard,
|
|
8352
8354
|
{
|
|
8353
8355
|
component: comp,
|
|
@@ -8410,20 +8412,20 @@ function BundleComponentCard({
|
|
|
8410
8412
|
"div",
|
|
8411
8413
|
{
|
|
8412
8414
|
"data-cimplify-bundle-component": true,
|
|
8413
|
-
className: cn("
|
|
8415
|
+
className: cn("py-4", classNames?.component),
|
|
8414
8416
|
children: [
|
|
8415
8417
|
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
8416
8418
|
"div",
|
|
8417
8419
|
{
|
|
8418
8420
|
"data-cimplify-bundle-component-header": true,
|
|
8419
|
-
className: cn("flex items-
|
|
8421
|
+
className: cn("flex items-center justify-between gap-3", classNames?.componentHeader),
|
|
8420
8422
|
children: [
|
|
8421
|
-
/* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
|
|
8423
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
|
|
8422
8424
|
component.quantity > 1 && /* @__PURE__ */ jsxRuntime.jsxs(
|
|
8423
8425
|
"span",
|
|
8424
8426
|
{
|
|
8425
8427
|
"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),
|
|
8428
|
+
className: cn("text-xs font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded", classNames?.componentQty),
|
|
8427
8429
|
children: [
|
|
8428
8430
|
"\xD7",
|
|
8429
8431
|
component.quantity
|
|
@@ -8435,12 +8437,12 @@ function BundleComponentCard({
|
|
|
8435
8437
|
{
|
|
8436
8438
|
id: labelId,
|
|
8437
8439
|
"data-cimplify-bundle-component-name": true,
|
|
8438
|
-
className: cn("
|
|
8440
|
+
className: cn("text-sm", classNames?.componentName),
|
|
8439
8441
|
children: component.product_name
|
|
8440
8442
|
}
|
|
8441
8443
|
)
|
|
8442
8444
|
] }),
|
|
8443
|
-
/* @__PURE__ */ jsxRuntime.jsx(Price, { amount: displayPrice })
|
|
8445
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm text-muted-foreground", children: /* @__PURE__ */ jsxRuntime.jsx(Price, { amount: displayPrice }) })
|
|
8444
8446
|
]
|
|
8445
8447
|
}
|
|
8446
8448
|
),
|
|
@@ -8453,7 +8455,7 @@ function BundleComponentCard({
|
|
|
8453
8455
|
onVariantChange(value);
|
|
8454
8456
|
},
|
|
8455
8457
|
"data-cimplify-bundle-variant-picker": true,
|
|
8456
|
-
className: cn("mt-3
|
|
8458
|
+
className: cn("mt-3 divide-y divide-border", classNames?.variantPicker),
|
|
8457
8459
|
children: component.available_variants.map((variant) => {
|
|
8458
8460
|
const isSelected = selectedVariantId === variant.id;
|
|
8459
8461
|
const adjustment = parsePrice(variant.price_adjustment);
|
|
@@ -8464,17 +8466,26 @@ function BundleComponentCard({
|
|
|
8464
8466
|
"data-cimplify-bundle-variant-option": true,
|
|
8465
8467
|
"data-selected": isSelected || void 0,
|
|
8466
8468
|
className: cn(
|
|
8467
|
-
"
|
|
8468
|
-
isSelected && "bg-primary text-primary-foreground border-primary",
|
|
8469
|
+
"w-full flex items-center gap-3 py-3 transition-colors cursor-pointer",
|
|
8469
8470
|
isSelected ? classNames?.variantOptionSelected : classNames?.variantOption
|
|
8470
8471
|
),
|
|
8471
8472
|
children: [
|
|
8472
|
-
|
|
8473
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
8474
|
+
"span",
|
|
8475
|
+
{
|
|
8476
|
+
className: cn(
|
|
8477
|
+
"w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
|
|
8478
|
+
isSelected ? "border-primary" : "border-muted-foreground/30"
|
|
8479
|
+
),
|
|
8480
|
+
children: isSelected && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "w-2.5 h-2.5 rounded-full bg-primary" })
|
|
8481
|
+
}
|
|
8482
|
+
),
|
|
8483
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "flex-1 text-sm", children: variant.display_name }),
|
|
8473
8484
|
adjustment !== 0 && /* @__PURE__ */ jsxRuntime.jsxs(
|
|
8474
8485
|
"span",
|
|
8475
8486
|
{
|
|
8476
8487
|
"data-cimplify-bundle-variant-adjustment": true,
|
|
8477
|
-
className: cn("
|
|
8488
|
+
className: cn("text-sm text-muted-foreground", classNames?.variantAdjustment),
|
|
8478
8489
|
children: [
|
|
8479
8490
|
adjustment > 0 ? "+" : "",
|
|
8480
8491
|
/* @__PURE__ */ jsxRuntime.jsx(Price, { amount: variant.price_adjustment })
|
|
@@ -8631,41 +8642,28 @@ function CompositeSelector({
|
|
|
8631
8642
|
"div",
|
|
8632
8643
|
{
|
|
8633
8644
|
"data-cimplify-composite-group": true,
|
|
8634
|
-
className: cn(
|
|
8645
|
+
className: cn(classNames?.group),
|
|
8635
8646
|
children: [
|
|
8636
8647
|
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
8637
8648
|
"div",
|
|
8638
8649
|
{
|
|
8639
8650
|
"data-cimplify-composite-group-header": true,
|
|
8640
|
-
className: cn("flex items-center justify-between
|
|
8651
|
+
className: cn("flex items-center justify-between py-3", classNames?.groupHeader),
|
|
8641
8652
|
children: [
|
|
8642
8653
|
/* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
|
|
8643
|
-
/* @__PURE__ */ jsxRuntime.
|
|
8654
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
8644
8655
|
"span",
|
|
8645
8656
|
{
|
|
8646
8657
|
"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
|
-
]
|
|
8658
|
+
className: cn("text-base font-bold", classNames?.groupName),
|
|
8659
|
+
children: group.name
|
|
8662
8660
|
}
|
|
8663
8661
|
),
|
|
8664
8662
|
group.description && /* @__PURE__ */ jsxRuntime.jsx(
|
|
8665
8663
|
"span",
|
|
8666
8664
|
{
|
|
8667
8665
|
"data-cimplify-composite-group-description": true,
|
|
8668
|
-
className: cn("text-xs text-muted-foreground
|
|
8666
|
+
className: cn("block text-xs text-muted-foreground mt-0.5", classNames?.groupDescription),
|
|
8669
8667
|
children: group.description
|
|
8670
8668
|
}
|
|
8671
8669
|
),
|
|
@@ -8673,16 +8671,20 @@ function CompositeSelector({
|
|
|
8673
8671
|
"span",
|
|
8674
8672
|
{
|
|
8675
8673
|
"data-cimplify-composite-group-constraint": true,
|
|
8676
|
-
className: cn("text-xs text-muted-foreground
|
|
8674
|
+
className: cn("block text-xs text-muted-foreground mt-0.5", classNames?.groupConstraint),
|
|
8677
8675
|
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
8676
|
}
|
|
8679
8677
|
)
|
|
8680
8678
|
] }),
|
|
8681
|
-
|
|
8679
|
+
group.min_selections > 0 && /* @__PURE__ */ jsxRuntime.jsx(
|
|
8682
8680
|
"span",
|
|
8683
8681
|
{
|
|
8684
|
-
"data-cimplify-composite-
|
|
8685
|
-
className: cn(
|
|
8682
|
+
"data-cimplify-composite-required": true,
|
|
8683
|
+
className: cn(
|
|
8684
|
+
"text-xs font-semibold px-2.5 py-1 rounded shrink-0",
|
|
8685
|
+
!minMet ? "text-destructive bg-destructive/10" : "text-destructive bg-destructive/10",
|
|
8686
|
+
classNames?.required
|
|
8687
|
+
),
|
|
8686
8688
|
children: "Required"
|
|
8687
8689
|
}
|
|
8688
8690
|
)
|
|
@@ -8695,7 +8697,7 @@ function CompositeSelector({
|
|
|
8695
8697
|
"data-cimplify-composite-components": true,
|
|
8696
8698
|
role: isSingleSelect ? "radiogroup" : "group",
|
|
8697
8699
|
"aria-label": group.name,
|
|
8698
|
-
className: cn("
|
|
8700
|
+
className: cn("divide-y divide-border", classNames?.components),
|
|
8699
8701
|
children: group.components.filter((c) => c.is_available && !c.is_archived).sort((a, b) => a.display_order - b.display_order).map((component) => {
|
|
8700
8702
|
const qty = groupSels[component.id] || 0;
|
|
8701
8703
|
const isSelected = qty > 0;
|
|
@@ -8709,8 +8711,7 @@ function CompositeSelector({
|
|
|
8709
8711
|
"data-cimplify-composite-component": true,
|
|
8710
8712
|
"data-selected": isSelected || void 0,
|
|
8711
8713
|
className: cn(
|
|
8712
|
-
"w-full flex items-center gap-3
|
|
8713
|
-
isSelected && "bg-primary/5 border-primary",
|
|
8714
|
+
"w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer",
|
|
8714
8715
|
isSelected ? classNames?.componentSelected : classNames?.component
|
|
8715
8716
|
),
|
|
8716
8717
|
children: [
|
|
@@ -8721,6 +8722,27 @@ function CompositeSelector({
|
|
|
8721
8722
|
keepMounted: false
|
|
8722
8723
|
}
|
|
8723
8724
|
),
|
|
8725
|
+
isSingleSelect ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
8726
|
+
"span",
|
|
8727
|
+
{
|
|
8728
|
+
"data-cimplify-composite-radio": true,
|
|
8729
|
+
className: cn(
|
|
8730
|
+
"w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
|
|
8731
|
+
isSelected ? "border-primary" : "border-muted-foreground/30"
|
|
8732
|
+
),
|
|
8733
|
+
children: isSelected && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "w-2.5 h-2.5 rounded-full bg-primary" })
|
|
8734
|
+
}
|
|
8735
|
+
) : /* @__PURE__ */ jsxRuntime.jsx(
|
|
8736
|
+
"span",
|
|
8737
|
+
{
|
|
8738
|
+
"data-cimplify-composite-checkbox": true,
|
|
8739
|
+
className: cn(
|
|
8740
|
+
"w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors",
|
|
8741
|
+
isSelected ? "border-primary bg-primary" : "border-muted-foreground/30"
|
|
8742
|
+
),
|
|
8743
|
+
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" }) })
|
|
8744
|
+
}
|
|
8745
|
+
),
|
|
8724
8746
|
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
8725
8747
|
"div",
|
|
8726
8748
|
{
|
|
@@ -8731,7 +8753,7 @@ function CompositeSelector({
|
|
|
8731
8753
|
"span",
|
|
8732
8754
|
{
|
|
8733
8755
|
"data-cimplify-composite-component-name": true,
|
|
8734
|
-
className: cn("text-sm
|
|
8756
|
+
className: cn("text-sm", classNames?.componentName),
|
|
8735
8757
|
children: displayName
|
|
8736
8758
|
}
|
|
8737
8759
|
),
|
|
@@ -8755,7 +8777,7 @@ function CompositeSelector({
|
|
|
8755
8777
|
"span",
|
|
8756
8778
|
{
|
|
8757
8779
|
"data-cimplify-composite-component-description": true,
|
|
8758
|
-
className: cn("text-xs text-muted-foreground truncate", classNames?.componentDescription),
|
|
8780
|
+
className: cn("block text-xs text-muted-foreground truncate", classNames?.componentDescription),
|
|
8759
8781
|
children: component.display_description
|
|
8760
8782
|
}
|
|
8761
8783
|
),
|
|
@@ -8763,7 +8785,7 @@ function CompositeSelector({
|
|
|
8763
8785
|
"span",
|
|
8764
8786
|
{
|
|
8765
8787
|
"data-cimplify-composite-component-calories": true,
|
|
8766
|
-
className: cn("text-xs text-muted-foreground/60", classNames?.componentCalories),
|
|
8788
|
+
className: cn("block text-xs text-muted-foreground/60", classNames?.componentCalories),
|
|
8767
8789
|
children: [
|
|
8768
8790
|
component.calories,
|
|
8769
8791
|
" cal"
|
|
@@ -8820,7 +8842,10 @@ function CompositeSelector({
|
|
|
8820
8842
|
)
|
|
8821
8843
|
}
|
|
8822
8844
|
),
|
|
8823
|
-
component.price != null &&
|
|
8845
|
+
component.price != null && /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-sm text-muted-foreground shrink-0", children: [
|
|
8846
|
+
"+",
|
|
8847
|
+
/* @__PURE__ */ jsxRuntime.jsx(Price, { amount: component.price })
|
|
8848
|
+
] })
|
|
8824
8849
|
]
|
|
8825
8850
|
},
|
|
8826
8851
|
component.id
|
|
@@ -9578,6 +9603,14 @@ function ProductCard({
|
|
|
9578
9603
|
const [isOpen, setIsOpen] = React3.useState(false);
|
|
9579
9604
|
const [shouldFetch, setShouldFetch] = React3.useState(false);
|
|
9580
9605
|
const dialogRef = React3.useRef(null);
|
|
9606
|
+
React3.useEffect(() => {
|
|
9607
|
+
if (!isOpen) return;
|
|
9608
|
+
const original = document.body.style.overflow;
|
|
9609
|
+
document.body.style.overflow = "hidden";
|
|
9610
|
+
return () => {
|
|
9611
|
+
document.body.style.overflow = original;
|
|
9612
|
+
};
|
|
9613
|
+
}, [isOpen]);
|
|
9581
9614
|
const { product: productDetails } = useProduct(
|
|
9582
9615
|
product.slug ?? product.id,
|
|
9583
9616
|
{ enabled: shouldFetch || isOpen }
|
package/dist/react.mjs
CHANGED
|
@@ -6232,6 +6232,8 @@ function useQuote(input, options = {}) {
|
|
|
6232
6232
|
}
|
|
6233
6233
|
}
|
|
6234
6234
|
setIsLoading(true);
|
|
6235
|
+
setQuote(null);
|
|
6236
|
+
setMessages([]);
|
|
6235
6237
|
try {
|
|
6236
6238
|
const existing = quoteInflight.get(cacheKey);
|
|
6237
6239
|
const promise = existing ?? (async () => {
|
|
@@ -8334,14 +8336,14 @@ function BundleSelector({
|
|
|
8334
8336
|
}
|
|
8335
8337
|
return /* @__PURE__ */ jsxs("div", { "data-cimplify-bundle-selector": true, className: cn("space-y-4", className, classNames?.root), children: [
|
|
8336
8338
|
/* @__PURE__ */ jsx(
|
|
8337
|
-
"
|
|
8339
|
+
"div",
|
|
8338
8340
|
{
|
|
8339
8341
|
"data-cimplify-bundle-heading": true,
|
|
8340
|
-
className: cn("
|
|
8341
|
-
children: "Included in this bundle"
|
|
8342
|
+
className: cn("flex items-center justify-between py-3", classNames?.heading),
|
|
8343
|
+
children: /* @__PURE__ */ jsx("span", { className: "text-base font-bold", children: "Included in this bundle" })
|
|
8342
8344
|
}
|
|
8343
8345
|
),
|
|
8344
|
-
/* @__PURE__ */ jsx("div", { "data-cimplify-bundle-components": true, className: cn("
|
|
8346
|
+
/* @__PURE__ */ jsx("div", { "data-cimplify-bundle-components": true, className: cn("divide-y divide-border", classNames?.components), children: components.map((comp) => /* @__PURE__ */ jsx(
|
|
8345
8347
|
BundleComponentCard,
|
|
8346
8348
|
{
|
|
8347
8349
|
component: comp,
|
|
@@ -8404,20 +8406,20 @@ function BundleComponentCard({
|
|
|
8404
8406
|
"div",
|
|
8405
8407
|
{
|
|
8406
8408
|
"data-cimplify-bundle-component": true,
|
|
8407
|
-
className: cn("
|
|
8409
|
+
className: cn("py-4", classNames?.component),
|
|
8408
8410
|
children: [
|
|
8409
8411
|
/* @__PURE__ */ jsxs(
|
|
8410
8412
|
"div",
|
|
8411
8413
|
{
|
|
8412
8414
|
"data-cimplify-bundle-component-header": true,
|
|
8413
|
-
className: cn("flex items-
|
|
8415
|
+
className: cn("flex items-center justify-between gap-3", classNames?.componentHeader),
|
|
8414
8416
|
children: [
|
|
8415
|
-
/* @__PURE__ */ jsxs("div", { children: [
|
|
8417
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
8416
8418
|
component.quantity > 1 && /* @__PURE__ */ jsxs(
|
|
8417
8419
|
"span",
|
|
8418
8420
|
{
|
|
8419
8421
|
"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),
|
|
8422
|
+
className: cn("text-xs font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded", classNames?.componentQty),
|
|
8421
8423
|
children: [
|
|
8422
8424
|
"\xD7",
|
|
8423
8425
|
component.quantity
|
|
@@ -8429,12 +8431,12 @@ function BundleComponentCard({
|
|
|
8429
8431
|
{
|
|
8430
8432
|
id: labelId,
|
|
8431
8433
|
"data-cimplify-bundle-component-name": true,
|
|
8432
|
-
className: cn("
|
|
8434
|
+
className: cn("text-sm", classNames?.componentName),
|
|
8433
8435
|
children: component.product_name
|
|
8434
8436
|
}
|
|
8435
8437
|
)
|
|
8436
8438
|
] }),
|
|
8437
|
-
/* @__PURE__ */ jsx(Price, { amount: displayPrice })
|
|
8439
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm text-muted-foreground", children: /* @__PURE__ */ jsx(Price, { amount: displayPrice }) })
|
|
8438
8440
|
]
|
|
8439
8441
|
}
|
|
8440
8442
|
),
|
|
@@ -8447,7 +8449,7 @@ function BundleComponentCard({
|
|
|
8447
8449
|
onVariantChange(value);
|
|
8448
8450
|
},
|
|
8449
8451
|
"data-cimplify-bundle-variant-picker": true,
|
|
8450
|
-
className: cn("mt-3
|
|
8452
|
+
className: cn("mt-3 divide-y divide-border", classNames?.variantPicker),
|
|
8451
8453
|
children: component.available_variants.map((variant) => {
|
|
8452
8454
|
const isSelected = selectedVariantId === variant.id;
|
|
8453
8455
|
const adjustment = parsePrice(variant.price_adjustment);
|
|
@@ -8458,17 +8460,26 @@ function BundleComponentCard({
|
|
|
8458
8460
|
"data-cimplify-bundle-variant-option": true,
|
|
8459
8461
|
"data-selected": isSelected || void 0,
|
|
8460
8462
|
className: cn(
|
|
8461
|
-
"
|
|
8462
|
-
isSelected && "bg-primary text-primary-foreground border-primary",
|
|
8463
|
+
"w-full flex items-center gap-3 py-3 transition-colors cursor-pointer",
|
|
8463
8464
|
isSelected ? classNames?.variantOptionSelected : classNames?.variantOption
|
|
8464
8465
|
),
|
|
8465
8466
|
children: [
|
|
8466
|
-
|
|
8467
|
+
/* @__PURE__ */ jsx(
|
|
8468
|
+
"span",
|
|
8469
|
+
{
|
|
8470
|
+
className: cn(
|
|
8471
|
+
"w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
|
|
8472
|
+
isSelected ? "border-primary" : "border-muted-foreground/30"
|
|
8473
|
+
),
|
|
8474
|
+
children: isSelected && /* @__PURE__ */ jsx("span", { className: "w-2.5 h-2.5 rounded-full bg-primary" })
|
|
8475
|
+
}
|
|
8476
|
+
),
|
|
8477
|
+
/* @__PURE__ */ jsx("span", { className: "flex-1 text-sm", children: variant.display_name }),
|
|
8467
8478
|
adjustment !== 0 && /* @__PURE__ */ jsxs(
|
|
8468
8479
|
"span",
|
|
8469
8480
|
{
|
|
8470
8481
|
"data-cimplify-bundle-variant-adjustment": true,
|
|
8471
|
-
className: cn("
|
|
8482
|
+
className: cn("text-sm text-muted-foreground", classNames?.variantAdjustment),
|
|
8472
8483
|
children: [
|
|
8473
8484
|
adjustment > 0 ? "+" : "",
|
|
8474
8485
|
/* @__PURE__ */ jsx(Price, { amount: variant.price_adjustment })
|
|
@@ -8625,41 +8636,28 @@ function CompositeSelector({
|
|
|
8625
8636
|
"div",
|
|
8626
8637
|
{
|
|
8627
8638
|
"data-cimplify-composite-group": true,
|
|
8628
|
-
className: cn(
|
|
8639
|
+
className: cn(classNames?.group),
|
|
8629
8640
|
children: [
|
|
8630
8641
|
/* @__PURE__ */ jsxs(
|
|
8631
8642
|
"div",
|
|
8632
8643
|
{
|
|
8633
8644
|
"data-cimplify-composite-group-header": true,
|
|
8634
|
-
className: cn("flex items-center justify-between
|
|
8645
|
+
className: cn("flex items-center justify-between py-3", classNames?.groupHeader),
|
|
8635
8646
|
children: [
|
|
8636
8647
|
/* @__PURE__ */ jsxs("div", { children: [
|
|
8637
|
-
/* @__PURE__ */
|
|
8648
|
+
/* @__PURE__ */ jsx(
|
|
8638
8649
|
"span",
|
|
8639
8650
|
{
|
|
8640
8651
|
"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
|
-
]
|
|
8652
|
+
className: cn("text-base font-bold", classNames?.groupName),
|
|
8653
|
+
children: group.name
|
|
8656
8654
|
}
|
|
8657
8655
|
),
|
|
8658
8656
|
group.description && /* @__PURE__ */ jsx(
|
|
8659
8657
|
"span",
|
|
8660
8658
|
{
|
|
8661
8659
|
"data-cimplify-composite-group-description": true,
|
|
8662
|
-
className: cn("text-xs text-muted-foreground
|
|
8660
|
+
className: cn("block text-xs text-muted-foreground mt-0.5", classNames?.groupDescription),
|
|
8663
8661
|
children: group.description
|
|
8664
8662
|
}
|
|
8665
8663
|
),
|
|
@@ -8667,16 +8665,20 @@ function CompositeSelector({
|
|
|
8667
8665
|
"span",
|
|
8668
8666
|
{
|
|
8669
8667
|
"data-cimplify-composite-group-constraint": true,
|
|
8670
|
-
className: cn("text-xs text-muted-foreground
|
|
8668
|
+
className: cn("block text-xs text-muted-foreground mt-0.5", classNames?.groupConstraint),
|
|
8671
8669
|
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
8670
|
}
|
|
8673
8671
|
)
|
|
8674
8672
|
] }),
|
|
8675
|
-
|
|
8673
|
+
group.min_selections > 0 && /* @__PURE__ */ jsx(
|
|
8676
8674
|
"span",
|
|
8677
8675
|
{
|
|
8678
|
-
"data-cimplify-composite-
|
|
8679
|
-
className: cn(
|
|
8676
|
+
"data-cimplify-composite-required": true,
|
|
8677
|
+
className: cn(
|
|
8678
|
+
"text-xs font-semibold px-2.5 py-1 rounded shrink-0",
|
|
8679
|
+
!minMet ? "text-destructive bg-destructive/10" : "text-destructive bg-destructive/10",
|
|
8680
|
+
classNames?.required
|
|
8681
|
+
),
|
|
8680
8682
|
children: "Required"
|
|
8681
8683
|
}
|
|
8682
8684
|
)
|
|
@@ -8689,7 +8691,7 @@ function CompositeSelector({
|
|
|
8689
8691
|
"data-cimplify-composite-components": true,
|
|
8690
8692
|
role: isSingleSelect ? "radiogroup" : "group",
|
|
8691
8693
|
"aria-label": group.name,
|
|
8692
|
-
className: cn("
|
|
8694
|
+
className: cn("divide-y divide-border", classNames?.components),
|
|
8693
8695
|
children: group.components.filter((c) => c.is_available && !c.is_archived).sort((a, b) => a.display_order - b.display_order).map((component) => {
|
|
8694
8696
|
const qty = groupSels[component.id] || 0;
|
|
8695
8697
|
const isSelected = qty > 0;
|
|
@@ -8703,8 +8705,7 @@ function CompositeSelector({
|
|
|
8703
8705
|
"data-cimplify-composite-component": true,
|
|
8704
8706
|
"data-selected": isSelected || void 0,
|
|
8705
8707
|
className: cn(
|
|
8706
|
-
"w-full flex items-center gap-3
|
|
8707
|
-
isSelected && "bg-primary/5 border-primary",
|
|
8708
|
+
"w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer",
|
|
8708
8709
|
isSelected ? classNames?.componentSelected : classNames?.component
|
|
8709
8710
|
),
|
|
8710
8711
|
children: [
|
|
@@ -8715,6 +8716,27 @@ function CompositeSelector({
|
|
|
8715
8716
|
keepMounted: false
|
|
8716
8717
|
}
|
|
8717
8718
|
),
|
|
8719
|
+
isSingleSelect ? /* @__PURE__ */ jsx(
|
|
8720
|
+
"span",
|
|
8721
|
+
{
|
|
8722
|
+
"data-cimplify-composite-radio": true,
|
|
8723
|
+
className: cn(
|
|
8724
|
+
"w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
|
|
8725
|
+
isSelected ? "border-primary" : "border-muted-foreground/30"
|
|
8726
|
+
),
|
|
8727
|
+
children: isSelected && /* @__PURE__ */ jsx("span", { className: "w-2.5 h-2.5 rounded-full bg-primary" })
|
|
8728
|
+
}
|
|
8729
|
+
) : /* @__PURE__ */ jsx(
|
|
8730
|
+
"span",
|
|
8731
|
+
{
|
|
8732
|
+
"data-cimplify-composite-checkbox": true,
|
|
8733
|
+
className: cn(
|
|
8734
|
+
"w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors",
|
|
8735
|
+
isSelected ? "border-primary bg-primary" : "border-muted-foreground/30"
|
|
8736
|
+
),
|
|
8737
|
+
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" }) })
|
|
8738
|
+
}
|
|
8739
|
+
),
|
|
8718
8740
|
/* @__PURE__ */ jsxs(
|
|
8719
8741
|
"div",
|
|
8720
8742
|
{
|
|
@@ -8725,7 +8747,7 @@ function CompositeSelector({
|
|
|
8725
8747
|
"span",
|
|
8726
8748
|
{
|
|
8727
8749
|
"data-cimplify-composite-component-name": true,
|
|
8728
|
-
className: cn("text-sm
|
|
8750
|
+
className: cn("text-sm", classNames?.componentName),
|
|
8729
8751
|
children: displayName
|
|
8730
8752
|
}
|
|
8731
8753
|
),
|
|
@@ -8749,7 +8771,7 @@ function CompositeSelector({
|
|
|
8749
8771
|
"span",
|
|
8750
8772
|
{
|
|
8751
8773
|
"data-cimplify-composite-component-description": true,
|
|
8752
|
-
className: cn("text-xs text-muted-foreground truncate", classNames?.componentDescription),
|
|
8774
|
+
className: cn("block text-xs text-muted-foreground truncate", classNames?.componentDescription),
|
|
8753
8775
|
children: component.display_description
|
|
8754
8776
|
}
|
|
8755
8777
|
),
|
|
@@ -8757,7 +8779,7 @@ function CompositeSelector({
|
|
|
8757
8779
|
"span",
|
|
8758
8780
|
{
|
|
8759
8781
|
"data-cimplify-composite-component-calories": true,
|
|
8760
|
-
className: cn("text-xs text-muted-foreground/60", classNames?.componentCalories),
|
|
8782
|
+
className: cn("block text-xs text-muted-foreground/60", classNames?.componentCalories),
|
|
8761
8783
|
children: [
|
|
8762
8784
|
component.calories,
|
|
8763
8785
|
" cal"
|
|
@@ -8814,7 +8836,10 @@ function CompositeSelector({
|
|
|
8814
8836
|
)
|
|
8815
8837
|
}
|
|
8816
8838
|
),
|
|
8817
|
-
component.price != null &&
|
|
8839
|
+
component.price != null && /* @__PURE__ */ jsxs("span", { className: "text-sm text-muted-foreground shrink-0", children: [
|
|
8840
|
+
"+",
|
|
8841
|
+
/* @__PURE__ */ jsx(Price, { amount: component.price })
|
|
8842
|
+
] })
|
|
8818
8843
|
]
|
|
8819
8844
|
},
|
|
8820
8845
|
component.id
|
|
@@ -9572,6 +9597,14 @@ function ProductCard({
|
|
|
9572
9597
|
const [isOpen, setIsOpen] = useState(false);
|
|
9573
9598
|
const [shouldFetch, setShouldFetch] = useState(false);
|
|
9574
9599
|
const dialogRef = useRef(null);
|
|
9600
|
+
useEffect(() => {
|
|
9601
|
+
if (!isOpen) return;
|
|
9602
|
+
const original = document.body.style.overflow;
|
|
9603
|
+
document.body.style.overflow = "hidden";
|
|
9604
|
+
return () => {
|
|
9605
|
+
document.body.style.overflow = original;
|
|
9606
|
+
};
|
|
9607
|
+
}, [isOpen]);
|
|
9575
9608
|
const { product: productDetails } = useProduct(
|
|
9576
9609
|
product.slug ?? product.id,
|
|
9577
9610
|
{ 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
|
}
|