@cimplify/sdk 0.24.0 → 0.24.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.d.mts +13 -7
- package/dist/react.d.ts +13 -7
- package/dist/react.js +19 -12
- package/dist/react.mjs +19 -12
- package/package.json +1 -1
- package/registry/default-product-layout.json +21 -0
- package/registry/digital-product-layout.json +18 -0
- package/registry/food-product-layout.json +18 -0
- package/registry/index.json +88 -5
- package/registry/price-range.json +13 -0
- package/registry/price.json +1 -1
- package/registry/product-customizer.json +1 -1
- package/registry/product-page.json +7 -6
- package/registry/service-product-layout.json +18 -0
- package/registry/volume-pricing.json +16 -0
- package/registry/wholesale-product-layout.json +20 -0
package/dist/react.d.mts
CHANGED
|
@@ -2,7 +2,7 @@ import React, { ReactNode } from 'react';
|
|
|
2
2
|
import { A as AdSlot, a as AdPosition, e as AdContextValue } from './ads-BxbWrwqp.mjs';
|
|
3
3
|
export { c as AdConfig, g as getVariantDisplayName } from './ads-BxbWrwqp.mjs';
|
|
4
4
|
import { C as CimplifyClient, a8 as ProcessCheckoutResult, aa as CheckoutStatus, ab as CheckoutStatusContext, bK as Location, bH as Business, k as ReorderResult, aR as OrderStatus, b2 as Order, d as QuoteBundleSelectionInput, Q as QuoteCompositeSelectionInput, P as PriceQuote, g as QuoteUiMessage, bX as Service, c0 as AvailableSlot, c1 as DayAvailability, c6 as CustomerBooking, cc as CancelBookingResult, w as ActivityStateResponse, u as SessionMessage, x as ActivityRecommendation, E as DeliveryFeeResponse, cO as FxRateResponse, bR as StorefrontBootstrap, Y as ElementsOptions, d0 as AuthenticatedData, cY as AddressInfo, cZ as PaymentMethodInfo, K as CimplifyElements, d2 as ElementsCheckoutResult, a7 as ProcessCheckoutOptions, b1 as LineItem, bY as Staff, bP as Room } from './index-DIHoZUz_.mjs';
|
|
5
|
-
import { J as Product, d as Pagination, h as CimplifyError, L as ProductWithDetails, a6 as Category, M as Money, b1 as BundleSelectionInput, aw as ComponentSelectionInput, b4 as BundleSelectionData, b7 as CompositeSelectionData, a8 as Collection, aE as ProductTaxonomy, aF as TaxonomyWithChildren, aD as ProductTimeProfile, aI as Deal, aJ as ProductDealInfo, aK as DiscountValidation, ad as BundleSummary, ax as CompositePriceResult, y as CustomAttributeDefinition, G as PropertyFacet, bJ as ProductBillingPlan, N as VariantView, X as VariantAxisWithValues,
|
|
5
|
+
import { a as CurrencyCode, J as Product, d as Pagination, h as CimplifyError, L as ProductWithDetails, a6 as Category, M as Money, b1 as BundleSelectionInput, aw as ComponentSelectionInput, b4 as BundleSelectionData, b7 as CompositeSelectionData, a8 as Collection, aE as ProductTaxonomy, aF as TaxonomyWithChildren, aD as ProductTimeProfile, aI as Deal, aJ as ProductDealInfo, aK as DiscountValidation, ad as BundleSummary, ax as CompositePriceResult, y as CustomAttributeDefinition, G as PropertyFacet, bJ as ProductBillingPlan, N as VariantView, X as VariantAxisWithValues, a2 as AddOnWithOptions, ai as BundleComponentView, ab as BundlePriceType, at as CompositeGroupView, bc as DisplayCart, z as CustomAttributeValue, F as ProductProperty, Q as QuantityPricingTier } from './payment-DqiwuLma.mjs';
|
|
6
6
|
import { P as ProductWithPrice } from './price-DVQrpE7j.mjs';
|
|
7
7
|
import { ClassValue } from 'clsx';
|
|
8
8
|
|
|
@@ -84,18 +84,23 @@ declare function CimplifyCheckout({ client, businessId, cartId, locationId, link
|
|
|
84
84
|
interface PriceProps {
|
|
85
85
|
/** The amount in base (business) currency. */
|
|
86
86
|
amount: number | string;
|
|
87
|
+
/** Explicit currency — skips provider lookup when provided. */
|
|
88
|
+
currency?: CurrencyCode;
|
|
87
89
|
/** Optional CSS class name for the wrapping span. */
|
|
88
90
|
className?: string;
|
|
89
91
|
/** Optional prefix rendered before the formatted price (e.g. "+"). */
|
|
90
92
|
prefix?: string;
|
|
91
93
|
}
|
|
92
94
|
/**
|
|
93
|
-
* Price — renders a
|
|
95
|
+
* Price — renders a formatted price value.
|
|
96
|
+
*
|
|
97
|
+
* When used inside CimplifyProvider: reads displayCurrency and convertPrice
|
|
98
|
+
* from context for FX conversion.
|
|
94
99
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
100
|
+
* When used outside a provider (or with explicit `currency` prop): formats
|
|
101
|
+
* directly — no provider required. Works in any React environment.
|
|
97
102
|
*/
|
|
98
|
-
declare function Price({ amount, className, prefix }: PriceProps): React.ReactElement;
|
|
103
|
+
declare function Price({ amount, currency, className, prefix }: PriceProps): React.ReactElement;
|
|
99
104
|
|
|
100
105
|
interface CimplifyClientContextValue {
|
|
101
106
|
client: CimplifyClient;
|
|
@@ -2337,10 +2342,11 @@ interface VolumePricingProps {
|
|
|
2337
2342
|
declare function VolumePricing({ tiers, currentQuantity, defaultOpen, className, classNames, }: VolumePricingProps): React.ReactElement | null;
|
|
2338
2343
|
|
|
2339
2344
|
interface PriceRangeProps {
|
|
2340
|
-
product: Product;
|
|
2345
|
+
product: Product | ProductWithDetails;
|
|
2346
|
+
currency?: CurrencyCode;
|
|
2341
2347
|
className?: string;
|
|
2342
2348
|
}
|
|
2343
|
-
declare function PriceRange({ product, className }: PriceRangeProps): React.ReactElement | null;
|
|
2349
|
+
declare function PriceRange({ product, currency, className }: PriceRangeProps): React.ReactElement | null;
|
|
2344
2350
|
|
|
2345
2351
|
interface DeliveryEstimateClassNames {
|
|
2346
2352
|
root?: string;
|
package/dist/react.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import React, { ReactNode } from 'react';
|
|
|
2
2
|
import { A as AdSlot, a as AdPosition, e as AdContextValue } from './ads-BxbWrwqp.js';
|
|
3
3
|
export { c as AdConfig, g as getVariantDisplayName } from './ads-BxbWrwqp.js';
|
|
4
4
|
import { C as CimplifyClient, a8 as ProcessCheckoutResult, aa as CheckoutStatus, ab as CheckoutStatusContext, bK as Location, bH as Business, k as ReorderResult, aR as OrderStatus, b2 as Order, d as QuoteBundleSelectionInput, Q as QuoteCompositeSelectionInput, P as PriceQuote, g as QuoteUiMessage, bX as Service, c0 as AvailableSlot, c1 as DayAvailability, c6 as CustomerBooking, cc as CancelBookingResult, w as ActivityStateResponse, u as SessionMessage, x as ActivityRecommendation, E as DeliveryFeeResponse, cO as FxRateResponse, bR as StorefrontBootstrap, Y as ElementsOptions, d0 as AuthenticatedData, cY as AddressInfo, cZ as PaymentMethodInfo, K as CimplifyElements, d2 as ElementsCheckoutResult, a7 as ProcessCheckoutOptions, b1 as LineItem, bY as Staff, bP as Room } from './index-CJZMsbDi.js';
|
|
5
|
-
import { J as Product, d as Pagination, h as CimplifyError, L as ProductWithDetails, a6 as Category, M as Money, b1 as BundleSelectionInput, aw as ComponentSelectionInput, b4 as BundleSelectionData, b7 as CompositeSelectionData, a8 as Collection, aE as ProductTaxonomy, aF as TaxonomyWithChildren, aD as ProductTimeProfile, aI as Deal, aJ as ProductDealInfo, aK as DiscountValidation, ad as BundleSummary, ax as CompositePriceResult, y as CustomAttributeDefinition, G as PropertyFacet, bJ as ProductBillingPlan, N as VariantView, X as VariantAxisWithValues,
|
|
5
|
+
import { a as CurrencyCode, J as Product, d as Pagination, h as CimplifyError, L as ProductWithDetails, a6 as Category, M as Money, b1 as BundleSelectionInput, aw as ComponentSelectionInput, b4 as BundleSelectionData, b7 as CompositeSelectionData, a8 as Collection, aE as ProductTaxonomy, aF as TaxonomyWithChildren, aD as ProductTimeProfile, aI as Deal, aJ as ProductDealInfo, aK as DiscountValidation, ad as BundleSummary, ax as CompositePriceResult, y as CustomAttributeDefinition, G as PropertyFacet, bJ as ProductBillingPlan, N as VariantView, X as VariantAxisWithValues, a2 as AddOnWithOptions, ai as BundleComponentView, ab as BundlePriceType, at as CompositeGroupView, bc as DisplayCart, z as CustomAttributeValue, F as ProductProperty, Q as QuantityPricingTier } from './payment-DqiwuLma.js';
|
|
6
6
|
import { P as ProductWithPrice } from './price-BZlddg1A.js';
|
|
7
7
|
import { ClassValue } from 'clsx';
|
|
8
8
|
|
|
@@ -84,18 +84,23 @@ declare function CimplifyCheckout({ client, businessId, cartId, locationId, link
|
|
|
84
84
|
interface PriceProps {
|
|
85
85
|
/** The amount in base (business) currency. */
|
|
86
86
|
amount: number | string;
|
|
87
|
+
/** Explicit currency — skips provider lookup when provided. */
|
|
88
|
+
currency?: CurrencyCode;
|
|
87
89
|
/** Optional CSS class name for the wrapping span. */
|
|
88
90
|
className?: string;
|
|
89
91
|
/** Optional prefix rendered before the formatted price (e.g. "+"). */
|
|
90
92
|
prefix?: string;
|
|
91
93
|
}
|
|
92
94
|
/**
|
|
93
|
-
* Price — renders a
|
|
95
|
+
* Price — renders a formatted price value.
|
|
96
|
+
*
|
|
97
|
+
* When used inside CimplifyProvider: reads displayCurrency and convertPrice
|
|
98
|
+
* from context for FX conversion.
|
|
94
99
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
100
|
+
* When used outside a provider (or with explicit `currency` prop): formats
|
|
101
|
+
* directly — no provider required. Works in any React environment.
|
|
97
102
|
*/
|
|
98
|
-
declare function Price({ amount, className, prefix }: PriceProps): React.ReactElement;
|
|
103
|
+
declare function Price({ amount, currency, className, prefix }: PriceProps): React.ReactElement;
|
|
99
104
|
|
|
100
105
|
interface CimplifyClientContextValue {
|
|
101
106
|
client: CimplifyClient;
|
|
@@ -2337,10 +2342,11 @@ interface VolumePricingProps {
|
|
|
2337
2342
|
declare function VolumePricing({ tiers, currentQuantity, defaultOpen, className, classNames, }: VolumePricingProps): React.ReactElement | null;
|
|
2338
2343
|
|
|
2339
2344
|
interface PriceRangeProps {
|
|
2340
|
-
product: Product;
|
|
2345
|
+
product: Product | ProductWithDetails;
|
|
2346
|
+
currency?: CurrencyCode;
|
|
2341
2347
|
className?: string;
|
|
2342
2348
|
}
|
|
2343
|
-
declare function PriceRange({ product, className }: PriceRangeProps): React.ReactElement | null;
|
|
2349
|
+
declare function PriceRange({ product, currency, className }: PriceRangeProps): React.ReactElement | null;
|
|
2344
2350
|
|
|
2345
2351
|
interface DeliveryEstimateClassNames {
|
|
2346
2352
|
root?: string;
|
package/dist/react.js
CHANGED
|
@@ -1036,7 +1036,7 @@ function useCart(options = {}) {
|
|
|
1036
1036
|
() => snapshot.items.reduce((sum, item) => sum + item.quantity, 0),
|
|
1037
1037
|
[snapshot.items]
|
|
1038
1038
|
);
|
|
1039
|
-
return {
|
|
1039
|
+
return React3.useMemo(() => ({
|
|
1040
1040
|
items: snapshot.items,
|
|
1041
1041
|
itemCount,
|
|
1042
1042
|
subtotal: snapshot.subtotal,
|
|
@@ -1051,7 +1051,7 @@ function useCart(options = {}) {
|
|
|
1051
1051
|
clearCart,
|
|
1052
1052
|
sync,
|
|
1053
1053
|
reorder
|
|
1054
|
-
};
|
|
1054
|
+
}), [snapshot.items, itemCount, snapshot.subtotal, snapshot.tax, snapshot.total, snapshot.currency, snapshot.isLoading, addItem, removeItem, updateQuantity, clearCart, sync, reorder]);
|
|
1055
1055
|
}
|
|
1056
1056
|
var LOCATION_STORAGE_KEY = "cimplify_location_id";
|
|
1057
1057
|
var DISPLAY_CURRENCY_STORAGE_KEY = "cimplify_display_currency";
|
|
@@ -1702,11 +1702,14 @@ function CimplifyCheckout({
|
|
|
1702
1702
|
errorMessage && /* @__PURE__ */ jsxRuntime.jsx("p", { "data-cimplify-error": "", style: { marginTop: SPACE.sm, fontSize: 13, color: colors.error }, children: errorMessage })
|
|
1703
1703
|
] });
|
|
1704
1704
|
}
|
|
1705
|
-
function Price({ amount, className, prefix }) {
|
|
1706
|
-
const
|
|
1705
|
+
function Price({ amount, currency, className, prefix }) {
|
|
1706
|
+
const context = useOptionalCimplify();
|
|
1707
|
+
const resolvedCurrency = currency ?? context?.displayCurrency ?? "USD";
|
|
1708
|
+
const convertedAmount = !currency && context?.convertPrice ? context.convertPrice(amount) : amount;
|
|
1709
|
+
const resolvedAmount = typeof convertedAmount === "string" ? parseFloat(convertedAmount) || 0 : convertedAmount;
|
|
1707
1710
|
return /* @__PURE__ */ jsxRuntime.jsxs("span", { className, children: [
|
|
1708
1711
|
prefix,
|
|
1709
|
-
chunkPEDLCL72_js.formatPrice(
|
|
1712
|
+
chunkPEDLCL72_js.formatPrice(resolvedAmount, resolvedCurrency)
|
|
1710
1713
|
] });
|
|
1711
1714
|
}
|
|
1712
1715
|
function createCachedQuery(config) {
|
|
@@ -4806,7 +4809,9 @@ function ProductCustomizer({
|
|
|
4806
4809
|
},
|
|
4807
4810
|
[]
|
|
4808
4811
|
);
|
|
4809
|
-
const
|
|
4812
|
+
const addedTimerRef = React3.useRef(void 0);
|
|
4813
|
+
React3.useEffect(() => () => clearTimeout(addedTimerRef.current), []);
|
|
4814
|
+
const handleAddToCart = React3.useCallback(async () => {
|
|
4810
4815
|
if (isSubmitting) return;
|
|
4811
4816
|
setIsSubmitting(true);
|
|
4812
4817
|
const options = {
|
|
@@ -4832,15 +4837,16 @@ function ProductCustomizer({
|
|
|
4832
4837
|
await cart.addItem(product, quantity, options);
|
|
4833
4838
|
}
|
|
4834
4839
|
setIsAdded(true);
|
|
4835
|
-
|
|
4840
|
+
clearTimeout(addedTimerRef.current);
|
|
4841
|
+
addedTimerRef.current = setTimeout(() => {
|
|
4836
4842
|
setIsAdded(false);
|
|
4837
|
-
setQuantity(1);
|
|
4843
|
+
setQuantity(product.min_order_quantity ?? 1);
|
|
4838
4844
|
}, 2e3);
|
|
4839
4845
|
} catch {
|
|
4840
4846
|
} finally {
|
|
4841
4847
|
setIsSubmitting(false);
|
|
4842
4848
|
}
|
|
4843
|
-
};
|
|
4849
|
+
}, [product, quantity, selectedVariantId, selectedVariant, quoteId, normalizedAddOnOptionIds, selectedAddOnOptions, isComposite, compositeSelections, isBundle, bundleSelections, selectedBillingPlan, specialInstructions, isSubmitting, onAddToCart, cart]);
|
|
4844
4850
|
return /* @__PURE__ */ jsxRuntime.jsxs("div", { "data-cimplify-customizer": true, className: cn("space-y-6", className, classNames?.root), children: [
|
|
4845
4851
|
isComposite && product.groups && product.composite_id && /* @__PURE__ */ jsxRuntime.jsx(
|
|
4846
4852
|
CompositeSelector,
|
|
@@ -7626,11 +7632,12 @@ function getMetadataStringList(metadata, key) {
|
|
|
7626
7632
|
if (!Array.isArray(value)) return [];
|
|
7627
7633
|
return value.filter((item) => typeof item === "string");
|
|
7628
7634
|
}
|
|
7629
|
-
function PriceRange({ product, className }) {
|
|
7630
|
-
const
|
|
7635
|
+
function PriceRange({ product, currency, className }) {
|
|
7636
|
+
const context = useOptionalCimplify();
|
|
7637
|
+
const resolvedCurrency = currency ?? context?.displayCurrency ?? "USD";
|
|
7631
7638
|
const range = chunkPEDLCL72_js.getPriceRange(product);
|
|
7632
7639
|
if (!range) return null;
|
|
7633
|
-
return /* @__PURE__ */ jsxRuntime.jsx("span", { "data-cimplify-price-range": true, className, children: chunkPEDLCL72_js.formatPriceRange(range.min, range.max,
|
|
7640
|
+
return /* @__PURE__ */ jsxRuntime.jsx("span", { "data-cimplify-price-range": true, className, children: chunkPEDLCL72_js.formatPriceRange(range.min, range.max, resolvedCurrency) });
|
|
7634
7641
|
}
|
|
7635
7642
|
function DefaultProductLayout({
|
|
7636
7643
|
product,
|
package/dist/react.mjs
CHANGED
|
@@ -1031,7 +1031,7 @@ function useCart(options = {}) {
|
|
|
1031
1031
|
() => snapshot.items.reduce((sum, item) => sum + item.quantity, 0),
|
|
1032
1032
|
[snapshot.items]
|
|
1033
1033
|
);
|
|
1034
|
-
return {
|
|
1034
|
+
return useMemo(() => ({
|
|
1035
1035
|
items: snapshot.items,
|
|
1036
1036
|
itemCount,
|
|
1037
1037
|
subtotal: snapshot.subtotal,
|
|
@@ -1046,7 +1046,7 @@ function useCart(options = {}) {
|
|
|
1046
1046
|
clearCart,
|
|
1047
1047
|
sync,
|
|
1048
1048
|
reorder
|
|
1049
|
-
};
|
|
1049
|
+
}), [snapshot.items, itemCount, snapshot.subtotal, snapshot.tax, snapshot.total, snapshot.currency, snapshot.isLoading, addItem, removeItem, updateQuantity, clearCart, sync, reorder]);
|
|
1050
1050
|
}
|
|
1051
1051
|
var LOCATION_STORAGE_KEY = "cimplify_location_id";
|
|
1052
1052
|
var DISPLAY_CURRENCY_STORAGE_KEY = "cimplify_display_currency";
|
|
@@ -1697,11 +1697,14 @@ function CimplifyCheckout({
|
|
|
1697
1697
|
errorMessage && /* @__PURE__ */ jsx("p", { "data-cimplify-error": "", style: { marginTop: SPACE.sm, fontSize: 13, color: colors.error }, children: errorMessage })
|
|
1698
1698
|
] });
|
|
1699
1699
|
}
|
|
1700
|
-
function Price({ amount, className, prefix }) {
|
|
1701
|
-
const
|
|
1700
|
+
function Price({ amount, currency, className, prefix }) {
|
|
1701
|
+
const context = useOptionalCimplify();
|
|
1702
|
+
const resolvedCurrency = currency ?? context?.displayCurrency ?? "USD";
|
|
1703
|
+
const convertedAmount = !currency && context?.convertPrice ? context.convertPrice(amount) : amount;
|
|
1704
|
+
const resolvedAmount = typeof convertedAmount === "string" ? parseFloat(convertedAmount) || 0 : convertedAmount;
|
|
1702
1705
|
return /* @__PURE__ */ jsxs("span", { className, children: [
|
|
1703
1706
|
prefix,
|
|
1704
|
-
formatPrice(
|
|
1707
|
+
formatPrice(resolvedAmount, resolvedCurrency)
|
|
1705
1708
|
] });
|
|
1706
1709
|
}
|
|
1707
1710
|
function createCachedQuery(config) {
|
|
@@ -4801,7 +4804,9 @@ function ProductCustomizer({
|
|
|
4801
4804
|
},
|
|
4802
4805
|
[]
|
|
4803
4806
|
);
|
|
4804
|
-
const
|
|
4807
|
+
const addedTimerRef = useRef(void 0);
|
|
4808
|
+
useEffect(() => () => clearTimeout(addedTimerRef.current), []);
|
|
4809
|
+
const handleAddToCart = useCallback(async () => {
|
|
4805
4810
|
if (isSubmitting) return;
|
|
4806
4811
|
setIsSubmitting(true);
|
|
4807
4812
|
const options = {
|
|
@@ -4827,15 +4832,16 @@ function ProductCustomizer({
|
|
|
4827
4832
|
await cart.addItem(product, quantity, options);
|
|
4828
4833
|
}
|
|
4829
4834
|
setIsAdded(true);
|
|
4830
|
-
|
|
4835
|
+
clearTimeout(addedTimerRef.current);
|
|
4836
|
+
addedTimerRef.current = setTimeout(() => {
|
|
4831
4837
|
setIsAdded(false);
|
|
4832
|
-
setQuantity(1);
|
|
4838
|
+
setQuantity(product.min_order_quantity ?? 1);
|
|
4833
4839
|
}, 2e3);
|
|
4834
4840
|
} catch {
|
|
4835
4841
|
} finally {
|
|
4836
4842
|
setIsSubmitting(false);
|
|
4837
4843
|
}
|
|
4838
|
-
};
|
|
4844
|
+
}, [product, quantity, selectedVariantId, selectedVariant, quoteId, normalizedAddOnOptionIds, selectedAddOnOptions, isComposite, compositeSelections, isBundle, bundleSelections, selectedBillingPlan, specialInstructions, isSubmitting, onAddToCart, cart]);
|
|
4839
4845
|
return /* @__PURE__ */ jsxs("div", { "data-cimplify-customizer": true, className: cn("space-y-6", className, classNames?.root), children: [
|
|
4840
4846
|
isComposite && product.groups && product.composite_id && /* @__PURE__ */ jsx(
|
|
4841
4847
|
CompositeSelector,
|
|
@@ -7621,11 +7627,12 @@ function getMetadataStringList(metadata, key) {
|
|
|
7621
7627
|
if (!Array.isArray(value)) return [];
|
|
7622
7628
|
return value.filter((item) => typeof item === "string");
|
|
7623
7629
|
}
|
|
7624
|
-
function PriceRange({ product, className }) {
|
|
7625
|
-
const
|
|
7630
|
+
function PriceRange({ product, currency, className }) {
|
|
7631
|
+
const context = useOptionalCimplify();
|
|
7632
|
+
const resolvedCurrency = currency ?? context?.displayCurrency ?? "USD";
|
|
7626
7633
|
const range = getPriceRange(product);
|
|
7627
7634
|
if (!range) return null;
|
|
7628
|
-
return /* @__PURE__ */ jsx("span", { "data-cimplify-price-range": true, className, children: formatPriceRange(range.min, range.max,
|
|
7635
|
+
return /* @__PURE__ */ jsx("span", { "data-cimplify-price-range": true, className, children: formatPriceRange(range.min, range.max, resolvedCurrency) });
|
|
7629
7636
|
}
|
|
7630
7637
|
function DefaultProductLayout({
|
|
7631
7638
|
product,
|
package/package.json
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "default-product-layout",
|
|
3
|
+
"title": "DefaultProductLayout",
|
|
4
|
+
"description": "Two-column product layout for retail/physical products with sale badges, specs, and properties.",
|
|
5
|
+
"type": "component",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"product-image-gallery",
|
|
8
|
+
"product-customizer",
|
|
9
|
+
"price",
|
|
10
|
+
"price-range",
|
|
11
|
+
"availability-badge",
|
|
12
|
+
"sale-badge",
|
|
13
|
+
"cn"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "layouts/default-product-layout.tsx",
|
|
18
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { ProductLayoutProps } from \"@cimplify/sdk/react\";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n PropertiesTable,\n TagPills,\n InventoryBadge,\n} from \"@cimplify/sdk/react\";\nimport { ProductImageGallery } from \"@cimplify/sdk/react\";\nimport { ProductCustomizer } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { PriceRange } from \"@cimplify/sdk/react\";\nimport { AvailabilityBadge } from \"@cimplify/sdk/react\";\nimport { SaleBadge } from \"@cimplify/sdk/react\";\nimport { parsePrice, isOnSale, getBasePrice, getDiscountPercentage } from \"@cimplify/sdk\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport function DefaultProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const hasRange = (product.quantity_pricing && product.quantity_pricing.length > 1)\n || (product.variants && product.variants.length > 1);\n const onSale = isOnSale(product);\n const hasPhysicalDetails = product.sku || product.vendor || product.material;\n\n return (\n <div data-cimplify-product-layout=\"default\" className={cn(\"space-y-8\", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className=\"space-y-6\">\n {/* Brand + Tags */}\n <div className=\"flex flex-wrap items-center gap-2\">\n {product.vendor && (\n <span className=\"text-xs font-semibold text-muted-foreground uppercase tracking-widest\">\n {product.vendor}\n </span>\n )}\n <TagPills tags={product.tags || []} />\n </div>\n\n {/* Name */}\n <h1 data-cimplify-product-layout-name className=\"text-3xl lg:text-4xl font-extrabold tracking-tight\">\n {product.name}\n </h1>\n\n {/* Price */}\n <div className=\"flex items-baseline gap-3\">\n {hasRange ? (\n <PriceRange product={product} className=\"text-2xl font-extrabold\" />\n ) : onSale ? (\n <>\n <Price amount={product.default_price} className=\"text-2xl font-extrabold\" />\n <Price amount={getBasePrice(product)} className=\"text-lg text-muted-foreground line-through\" />\n <span className=\"text-sm font-bold text-destructive bg-destructive/10 px-2 py-0.5\">\n Save {getDiscountPercentage(product)}%\n </span>\n </>\n ) : (\n <Price amount={product.default_price} className=\"text-2xl font-semibold\" />\n )}\n </div>\n\n {/* Inventory */}\n <InventoryBadge product={product} />\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className=\"text-muted-foreground leading-relaxed\">\n {product.description}\n </p>\n )}\n\n {/* Physical product details */}\n {hasPhysicalDetails && (\n <div data-cimplify-product-layout-details className=\"space-y-1 text-sm text-muted-foreground\">\n {product.sku && <p>SKU: <span className=\"font-mono text-foreground\">{product.sku}</span></p>}\n {product.barcode && <p>Barcode: <span className=\"font-mono text-foreground\">{product.barcode}</span></p>}\n {product.material && <p>Material: <span className=\"text-foreground\">{product.material}</span></p>}\n {product.vendor && <p>Brand: <span className=\"text-foreground\">{product.vendor}</span></p>}\n {(product.length_mm || product.width_mm || product.height_mm) && (\n <p>Dimensions: <span className=\"text-foreground\">\n {[product.length_mm, product.width_mm, product.height_mm]\n .filter(Boolean)\n .map((d) => `${(d! / 10).toFixed(1)} cm`)\n .join(\" x \")}\n </span></p>\n )}\n {product.item_condition && product.item_condition !== \"new\" && (\n <p>Condition: <span className=\"text-foreground capitalize\">{product.item_condition}</span></p>\n )}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Properties */}\n <PropertiesTable properties={product.properties || []} />\n\n {/* Customizer */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n />\n )}\n </div>\n );\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "digital-product-layout",
|
|
3
|
+
"title": "DigitalProductLayout",
|
|
4
|
+
"description": "Digital product layout with file details, download limits, event info, and access passes.",
|
|
5
|
+
"type": "component",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"product-image-gallery",
|
|
8
|
+
"product-customizer",
|
|
9
|
+
"price",
|
|
10
|
+
"cn"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "layouts/digital-product-layout.tsx",
|
|
15
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { ProductLayoutProps } from \"@cimplify/sdk/react\";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n TagPills,\n} from \"@cimplify/sdk/react\";\nimport { ProductImageGallery } from \"@cimplify/sdk/react\";\nimport { ProductCustomizer } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nconst DIGITAL_TYPE_LABELS: Record<string, string> = {\n download: \"Digital download\",\n license: \"Software license\",\n event_ticket: \"Event ticket\",\n access_pass: \"Access pass\",\n gift_card: \"Gift card\",\n};\n\nexport function DigitalProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const typeLabel = product.digital_type\n ? DIGITAL_TYPE_LABELS[product.digital_type] || product.digital_type\n : \"Digital product\";\n\n const isTicket = product.digital_type === \"event_ticket\";\n const isAccessPass = product.digital_type === \"access_pass\";\n const isDownload = product.digital_type === \"download\" || product.digital_type === \"license\";\n\n return (\n <div data-cimplify-product-layout=\"digital\" className={cn(\"space-y-8\", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className=\"space-y-6\">\n {/* Type badge + tags */}\n <div className=\"flex flex-wrap items-center gap-2\">\n <span data-cimplify-product-layout-badge className=\"text-xs font-semibold uppercase tracking-wider text-primary bg-primary/10 px-2.5 py-1 rounded-full\">\n {typeLabel}\n </span>\n <TagPills tags={product.tags || []} />\n </div>\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className=\"text-3xl lg:text-4xl font-extrabold tracking-tight\">\n {product.name}\n </h1>\n <Price amount={product.default_price} className=\"text-2xl font-bold mt-2 block\" />\n </div>\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className=\"text-muted-foreground leading-relaxed\">\n {product.description}\n </p>\n )}\n\n {/* Delivery notice */}\n {isDownload && (\n <div data-cimplify-product-layout-delivery className=\"flex items-center gap-3 px-4 py-3 bg-muted rounded-lg\">\n <svg className=\"w-5 h-5 text-primary shrink-0\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M13 10V3L4 14h7v7l9-11h-7z\" />\n </svg>\n <div>\n <p className=\"text-sm font-medium\">Instant delivery</p>\n <p className=\"text-xs text-muted-foreground\">Download immediately after purchase</p>\n </div>\n </div>\n )}\n\n {/* Event info */}\n {isTicket && (product.event_date || product.venue) && (\n <div data-cimplify-product-layout-event className=\"border border-border p-4 space-y-2\">\n {product.event_date && (\n <div className=\"flex items-center gap-2 text-sm\">\n <span className=\"text-muted-foreground\">Date:</span>\n <span className=\"font-medium\">{new Date(product.event_date).toLocaleDateString(undefined, { weekday: \"long\", year: \"numeric\", month: \"long\", day: \"numeric\" })}</span>\n </div>\n )}\n {product.venue && (\n <div className=\"flex items-center gap-2 text-sm\">\n <span className=\"text-muted-foreground\">Venue:</span>\n <span className=\"font-medium\">{product.venue}</span>\n </div>\n )}\n {product.ticket_type && (\n <div className=\"flex items-center gap-2 text-sm\">\n <span className=\"text-muted-foreground\">Type:</span>\n <span className=\"font-medium capitalize\">{product.ticket_type}</span>\n </div>\n )}\n </div>\n )}\n\n {/* Access pass info */}\n {isAccessPass && (product.access_type || product.access_duration_days) && (\n <div data-cimplify-product-layout-access className=\"border border-border p-4 space-y-2\">\n {product.access_type && (\n <div className=\"flex items-center gap-2 text-sm\">\n <span className=\"text-muted-foreground\">Access:</span>\n <span className=\"font-medium capitalize\">{product.access_type}</span>\n </div>\n )}\n {product.access_level && (\n <div className=\"flex items-center gap-2 text-sm\">\n <span className=\"text-muted-foreground\">Level:</span>\n <span className=\"font-medium capitalize\">{product.access_level}</span>\n </div>\n )}\n {product.access_duration_days && (\n <div className=\"flex items-center gap-2 text-sm\">\n <span className=\"text-muted-foreground\">Duration:</span>\n <span className=\"font-medium\">{product.access_duration_days} days</span>\n </div>\n )}\n </div>\n )}\n\n {/* File details */}\n {isDownload && (product.file_size_mb || product.file_type || product.version || product.max_downloads) && (\n <div data-cimplify-product-layout-file-details className=\"grid grid-cols-2 gap-3\">\n {product.file_type && (\n <div className=\"px-3 py-2 bg-muted text-center\">\n <p className=\"text-xs text-muted-foreground\">Format</p>\n <p className=\"text-sm font-semibold\">{product.file_type.toUpperCase()}</p>\n </div>\n )}\n {product.file_size_mb != null && (\n <div className=\"px-3 py-2 bg-muted text-center\">\n <p className=\"text-xs text-muted-foreground\">Size</p>\n <p className=\"text-sm font-semibold\">{product.file_size_mb >= 1024 ? `${(product.file_size_mb / 1024).toFixed(1)} GB` : `${product.file_size_mb} MB`}</p>\n </div>\n )}\n {product.max_downloads != null && (\n <div className=\"px-3 py-2 bg-muted text-center\">\n <p className=\"text-xs text-muted-foreground\">Downloads</p>\n <p className=\"text-sm font-semibold\">{product.max_downloads}</p>\n </div>\n )}\n {product.version && (\n <div className=\"px-3 py-2 bg-muted text-center\">\n <p className=\"text-xs text-muted-foreground\">Version</p>\n <p className=\"text-sm font-semibold\">v{product.version}</p>\n </div>\n )}\n {product.download_expires_days != null && (\n <div className=\"px-3 py-2 bg-muted text-center\">\n <p className=\"text-xs text-muted-foreground\">Expires</p>\n <p className=\"text-sm font-semibold\">{product.download_expires_days} days</p>\n </div>\n )}\n {product.max_activations != null && (\n <div className=\"px-3 py-2 bg-muted text-center\">\n <p className=\"text-xs text-muted-foreground\">Activations</p>\n <p className=\"text-sm font-semibold\">{product.max_activations}</p>\n </div>\n )}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (license variants, billing plans, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n showSpecialInstructions={false}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title=\"More digital products\"\n />\n )}\n </div>\n );\n}\n"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "food-product-layout",
|
|
3
|
+
"title": "FoodProductLayout",
|
|
4
|
+
"description": "Restaurant product layout with allergens, ingredients, pairings, and dietary tags.",
|
|
5
|
+
"type": "component",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"product-image-gallery",
|
|
8
|
+
"product-customizer",
|
|
9
|
+
"price",
|
|
10
|
+
"cn"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "layouts/food-product-layout.tsx",
|
|
15
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { ProductLayoutProps } from \"@cimplify/sdk/react\";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n MetadataStringList,\n CustomAttributesTable,\n TagPills,\n getMetadataStringList,\n} from \"@cimplify/sdk/react\";\nimport { ProductImageGallery } from \"@cimplify/sdk/react\";\nimport { ProductCustomizer } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nconst DIETARY_TAGS = new Set([\n \"vegetarian\", \"vegan\", \"gluten-free\", \"dairy-free\", \"nut-free\",\n \"spicy\", \"halal\", \"kosher\", \"organic\", \"sugar-free\",\n]);\n\nexport function FoodProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const allTags = product.tags || [];\n const dietaryTags = allTags.filter((t) => DIETARY_TAGS.has(t));\n const otherTags = allTags.filter((t) => !DIETARY_TAGS.has(t));\n const ingredients = getMetadataStringList(product.metadata, \"ingredients\");\n const pairings = getMetadataStringList(product.metadata, \"pairings\");\n\n return (\n <div data-cimplify-product-layout=\"food\" className={cn(\"space-y-8\", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className=\"space-y-6\">\n {/* Tags */}\n <div className=\"flex flex-wrap gap-1.5\">\n {dietaryTags.map((tag) => (\n <span key={tag} className=\"inline-flex items-center gap-1 px-2.5 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full\">\n {tag}\n </span>\n ))}\n {product.allergies && product.allergies.map((allergy) => (\n <span key={allergy} className=\"inline-flex items-center gap-1 px-2.5 py-1 bg-amber-100 text-amber-800 text-xs font-medium rounded-full\">\n Contains {allergy}\n </span>\n ))}\n </div>\n\n <TagPills tags={otherTags} />\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className=\"text-3xl lg:text-4xl font-bold tracking-tight\">\n {product.name}\n </h1>\n <Price amount={product.default_price} className=\"text-2xl font-semibold mt-2 block\" />\n </div>\n\n {/* Quick stats */}\n {(product.calories != null || product.preparation_time_minutes != null) && (\n <div data-cimplify-product-layout-stats className=\"flex flex-wrap gap-4 py-3 border-y border-border\">\n {product.preparation_time_minutes != null && (\n <span className=\"flex items-center gap-1.5 text-sm text-muted-foreground\">\n <strong className=\"text-foreground\">{product.preparation_time_minutes} min</strong> prep time\n </span>\n )}\n {product.calories != null && (\n <span className=\"flex items-center gap-1.5 text-sm text-muted-foreground\">\n <strong className=\"text-foreground\">{product.calories}</strong> calories\n </span>\n )}\n </div>\n )}\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className=\"text-muted-foreground leading-relaxed\">\n {product.description}\n </p>\n )}\n\n {/* Ingredients + Pairings */}\n {(ingredients.length > 0 || pairings.length > 0) && (\n <div className=\"grid sm:grid-cols-2 gap-4\">\n <MetadataStringList items={ingredients} label=\"Key Ingredients\" />\n <MetadataStringList items={pairings} label=\"Pairings\" />\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (variants, add-ons, composites, bundles, billing, volume) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title=\"You may also enjoy\"\n />\n )}\n </div>\n );\n}\n"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
package/registry/index.json
CHANGED
|
@@ -231,13 +231,14 @@
|
|
|
231
231
|
{
|
|
232
232
|
"name": "product-page",
|
|
233
233
|
"title": "ProductPage",
|
|
234
|
-
"description": "
|
|
234
|
+
"description": "Smart product page resolver with per-slug and per-type template routing.",
|
|
235
235
|
"type": "component",
|
|
236
236
|
"registryDependencies": [
|
|
237
|
-
"product-
|
|
238
|
-
"
|
|
239
|
-
"
|
|
240
|
-
"product-
|
|
237
|
+
"default-product-layout",
|
|
238
|
+
"food-product-layout",
|
|
239
|
+
"wholesale-product-layout",
|
|
240
|
+
"service-product-layout",
|
|
241
|
+
"digital-product-layout",
|
|
241
242
|
"cn"
|
|
242
243
|
]
|
|
243
244
|
},
|
|
@@ -406,6 +407,88 @@
|
|
|
406
407
|
"service-card",
|
|
407
408
|
"cn"
|
|
408
409
|
]
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
"name": "volume-pricing",
|
|
413
|
+
"title": "VolumePricing",
|
|
414
|
+
"description": "Collapsible volume pricing tier table.",
|
|
415
|
+
"type": "component",
|
|
416
|
+
"registryDependencies": [
|
|
417
|
+
"price",
|
|
418
|
+
"cn"
|
|
419
|
+
]
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
"name": "price-range",
|
|
423
|
+
"title": "PriceRange",
|
|
424
|
+
"description": "Displays min-max price range for products with variants or tiers.",
|
|
425
|
+
"type": "component",
|
|
426
|
+
"registryDependencies": []
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
"name": "default-product-layout",
|
|
430
|
+
"title": "DefaultProductLayout",
|
|
431
|
+
"description": "Two-column product layout for retail/physical products with sale badges, specs, and properties.",
|
|
432
|
+
"type": "component",
|
|
433
|
+
"registryDependencies": [
|
|
434
|
+
"product-image-gallery",
|
|
435
|
+
"product-customizer",
|
|
436
|
+
"price",
|
|
437
|
+
"price-range",
|
|
438
|
+
"availability-badge",
|
|
439
|
+
"sale-badge",
|
|
440
|
+
"cn"
|
|
441
|
+
]
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
"name": "food-product-layout",
|
|
445
|
+
"title": "FoodProductLayout",
|
|
446
|
+
"description": "Restaurant product layout with allergens, ingredients, pairings, and dietary tags.",
|
|
447
|
+
"type": "component",
|
|
448
|
+
"registryDependencies": [
|
|
449
|
+
"product-image-gallery",
|
|
450
|
+
"product-customizer",
|
|
451
|
+
"price",
|
|
452
|
+
"cn"
|
|
453
|
+
]
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
"name": "wholesale-product-layout",
|
|
457
|
+
"title": "WholesaleProductLayout",
|
|
458
|
+
"description": "B2B wholesale layout with price range, volume pricing, MOQ, and inventory.",
|
|
459
|
+
"type": "component",
|
|
460
|
+
"registryDependencies": [
|
|
461
|
+
"product-image-gallery",
|
|
462
|
+
"product-customizer",
|
|
463
|
+
"price",
|
|
464
|
+
"price-range",
|
|
465
|
+
"volume-pricing",
|
|
466
|
+
"cn"
|
|
467
|
+
]
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
"name": "service-product-layout",
|
|
471
|
+
"title": "ServiceProductLayout",
|
|
472
|
+
"description": "Service booking layout with duration, deposit, staff requirements, and cancellation policy.",
|
|
473
|
+
"type": "component",
|
|
474
|
+
"registryDependencies": [
|
|
475
|
+
"product-image-gallery",
|
|
476
|
+
"product-customizer",
|
|
477
|
+
"price",
|
|
478
|
+
"cn"
|
|
479
|
+
]
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
"name": "digital-product-layout",
|
|
483
|
+
"title": "DigitalProductLayout",
|
|
484
|
+
"description": "Digital product layout with file details, download limits, event info, and access passes.",
|
|
485
|
+
"type": "component",
|
|
486
|
+
"registryDependencies": [
|
|
487
|
+
"product-image-gallery",
|
|
488
|
+
"product-customizer",
|
|
489
|
+
"price",
|
|
490
|
+
"cn"
|
|
491
|
+
]
|
|
409
492
|
}
|
|
410
493
|
]
|
|
411
494
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "price-range",
|
|
3
|
+
"title": "PriceRange",
|
|
4
|
+
"description": "Displays min-max price range for products with variants or tiers.",
|
|
5
|
+
"type": "component",
|
|
6
|
+
"registryDependencies": [],
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"path": "price-range.tsx",
|
|
10
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { Product, ProductWithDetails } from \"@cimplify/sdk\";\nimport type { CurrencyCode } from \"@cimplify/sdk\";\nimport { getPriceRange, formatPriceRange } from \"@cimplify/sdk\";\nimport { useOptionalCimplify } from \"@cimplify/sdk/react\";\n\nexport interface PriceRangeProps {\n product: Product | ProductWithDetails;\n currency?: CurrencyCode;\n className?: string;\n}\n\nexport function PriceRange({ product, currency, className }: PriceRangeProps): React.ReactElement | null {\n const context = useOptionalCimplify();\n const resolvedCurrency = currency ?? (context?.displayCurrency as CurrencyCode | undefined) ?? \"USD\";\n const range = getPriceRange(product);\n\n if (!range) return null;\n\n return (\n <span data-cimplify-price-range className={className}>\n {formatPriceRange(range.min, range.max, resolvedCurrency)}\n </span>\n );\n}\n"
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
package/registry/price.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"files": [
|
|
8
8
|
{
|
|
9
9
|
"path": "price.tsx",
|
|
10
|
-
"content": "\"use client\";\n\nimport React from \"react\";\nimport { formatPrice } from \"@cimplify/sdk\";\nimport type { CurrencyCode } from \"@cimplify/sdk\";\nimport {
|
|
10
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport { formatPrice } from \"@cimplify/sdk\";\nimport type { CurrencyCode } from \"@cimplify/sdk\";\nimport { useOptionalCimplify } from \"@cimplify/sdk/react\";\n\nexport interface PriceProps {\n /** The amount in base (business) currency. */\n amount: number | string;\n /** Explicit currency — skips provider lookup when provided. */\n currency?: CurrencyCode;\n /** Optional CSS class name for the wrapping span. */\n className?: string;\n /** Optional prefix rendered before the formatted price (e.g. \"+\"). */\n prefix?: string;\n}\n\n/**\n * Price — renders a formatted price value.\n *\n * When used inside CimplifyProvider: reads displayCurrency and convertPrice\n * from context for FX conversion.\n *\n * When used outside a provider (or with explicit `currency` prop): formats\n * directly — no provider required. Works in any React environment.\n */\nexport function Price({ amount, currency, className, prefix }: PriceProps): React.ReactElement {\n const context = useOptionalCimplify();\n const resolvedCurrency = currency ?? (context?.displayCurrency as CurrencyCode | undefined) ?? \"USD\";\n const convertedAmount = !currency && context?.convertPrice ? context.convertPrice(amount) : amount;\n const resolvedAmount = typeof convertedAmount === \"string\" ? parseFloat(convertedAmount) || 0 : convertedAmount;\n\n return (\n <span className={className}>\n {prefix}\n {formatPrice(resolvedAmount, resolvedCurrency)}\n </span>\n );\n}\n"
|
|
11
11
|
}
|
|
12
12
|
]
|
|
13
13
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
{
|
|
16
16
|
"path": "product-customizer.tsx",
|
|
17
|
-
"content": "\"use client\";\n\nimport React, { useState, useCallback, useMemo, useEffect } from \"react\";\nimport { Button } from \"@base-ui/react/button\";\nimport type {\n ProductWithDetails,\n VariantView,\n AddOnOption,\n ComponentSelectionInput,\n CompositePriceResult,\n} from \"@cimplify/sdk\";\nimport type { ProductBillingPlan } from \"../types/subscription\";\nimport type { BundleSelectionInput } from \"@cimplify/sdk\";\nimport { parsePrice, getUnitPriceAtQuantity } from \"@cimplify/sdk\";\nimport { useCart, useQuote } from \"@cimplify/sdk/react\";\nimport type { AddToCartOptions } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { QuantitySelector } from \"@cimplify/sdk/react\";\nimport { VariantSelector } from \"@cimplify/sdk/react\";\nimport { AddOnSelector } from \"@cimplify/sdk/react\";\nimport { CompositeSelector } from \"@cimplify/sdk/react\";\nimport { BundleSelector } from \"@cimplify/sdk/react\";\nimport { BillingPlanSelector } from \"./billing-plan-selector\";\nimport { VolumePricing } from \"./volume-pricing\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface ProductCustomizerClassNames {\n root?: string;\n actions?: string;\n submitButton?: string;\n submitButtonAdded?: string;\n validation?: string;\n specialInstructions?: string;\n depositInfo?: string;\n allergens?: string;\n duration?: string;\n}\n\nexport interface ProductCustomizerProps {\n product: ProductWithDetails;\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n showSpecialInstructions?: boolean;\n className?: string;\n classNames?: ProductCustomizerClassNames;\n}\n\nexport function ProductCustomizer({\n product,\n onAddToCart,\n showSpecialInstructions = true,\n className,\n classNames,\n}: ProductCustomizerProps): React.ReactElement {\n const [quantity, setQuantity] = useState(product.min_order_quantity ?? 1);\n const [isAdded, setIsAdded] = useState(false);\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [selectedVariantId, setSelectedVariantId] = useState<string | undefined>();\n const [selectedVariant, setSelectedVariant] = useState<VariantView | undefined>();\n const [selectedAddOnOptionIds, setSelectedAddOnOptionIds] = useState<string[]>([]);\n\n const [compositeSelections, setCompositeSelections] = useState<ComponentSelectionInput[]>([]);\n const [compositePrice, setCompositePrice] = useState<CompositePriceResult | null>(null);\n const [compositeReady, setCompositeReady] = useState(false);\n\n const [bundleSelections, setBundleSelections] = useState<BundleSelectionInput[]>([]);\n const [bundleTotalPrice, setBundleTotalPrice] = useState<number | null>(null);\n const [bundleReady, setBundleReady] = useState(false);\n const [selectedBillingPlan, setSelectedBillingPlan] = useState<ProductBillingPlan | null>(null);\n const [specialInstructions, setSpecialInstructions] = useState(\"\");\n\n const cart = useCart();\n\n const productType = product.type || \"product\";\n const isComposite = productType === \"composite\";\n const isBundle = productType === \"bundle\";\n const isDigital = productType === \"digital\";\n const isService = productType === \"service\";\n const isStandard = !isComposite && !isBundle;\n\n const hasVariants = isStandard && product.variants && product.variants.length > 0;\n const hasAddOns = isStandard && product.add_ons && product.add_ons.length > 0;\n\n useEffect(() => {\n setQuantity(product.min_order_quantity ?? 1);\n setIsAdded(false);\n setIsSubmitting(false);\n setSelectedVariantId(undefined);\n setSelectedVariant(undefined);\n setSelectedAddOnOptionIds([]);\n setCompositeSelections([]);\n setCompositePrice(null);\n setCompositeReady(false);\n setBundleSelections([]);\n setBundleTotalPrice(null);\n setBundleReady(false);\n setSelectedBillingPlan(null);\n setSpecialInstructions(\"\");\n }, [product.id, product.min_order_quantity]);\n\n const selectedAddOnOptions = useMemo(() => {\n if (!product.add_ons) return [];\n const options: AddOnOption[] = [];\n for (const addOn of product.add_ons) {\n for (const option of addOn.options) {\n if (selectedAddOnOptionIds.includes(option.id)) {\n options.push(option);\n }\n }\n }\n return options;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const normalizedAddOnOptionIds = useMemo(() => {\n if (selectedAddOnOptionIds.length === 0) return [];\n return Array.from(new Set(selectedAddOnOptionIds.map((id) => id.trim()).filter(Boolean))).sort();\n }, [selectedAddOnOptionIds]);\n\n const localTotalPrice = useMemo(() => {\n if (isComposite && compositePrice) {\n return parsePrice(compositePrice.final_price) * quantity;\n }\n\n if (isBundle && bundleTotalPrice != null) {\n return bundleTotalPrice * quantity;\n }\n\n let price = parsePrice(product.default_price);\n\n if (selectedVariant?.price_adjustment) {\n price += parsePrice(selectedVariant.price_adjustment);\n }\n\n for (const option of selectedAddOnOptions) {\n if (option.default_price) {\n price += parsePrice(option.default_price);\n }\n }\n\n return price * quantity;\n }, [product.default_price, selectedVariant, selectedAddOnOptions, quantity, isComposite, compositePrice, isBundle, bundleTotalPrice]);\n\n const requiredAddOnsSatisfied = useMemo(() => {\n if (!product.add_ons) return true;\n\n for (const addOn of product.add_ons) {\n if (addOn.is_required) {\n const selectedInGroup = selectedAddOnOptionIds.filter((id) =>\n addOn.options.some((opt) => opt.id === id),\n ).length;\n\n const minRequired = addOn.min_selections || 1;\n if (selectedInGroup < minRequired) {\n return false;\n }\n }\n }\n return true;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const quoteInput = useMemo(\n () => ({\n productId: product.id,\n quantity,\n variantId: selectedVariantId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n compositeSelections: isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n }),\n [product.id, quantity, selectedVariantId, normalizedAddOnOptionIds, isBundle, bundleSelections, isComposite, compositeSelections],\n );\n\n const quoteEnabled = isComposite\n ? compositeReady\n : isBundle\n ? bundleReady\n : requiredAddOnsSatisfied;\n\n const { quote } = useQuote(quoteInput, {\n enabled: quoteEnabled,\n });\n\n const quoteId = quote?.quote_id;\n const quotedTotalPrice = useMemo(() => {\n if (!quote) return undefined;\n\n const priceInfo = quote.quoted_total_price_info ?? quote.final_price_info;\n const perUnit = priceInfo.pre_tax_price;\n if (perUnit === undefined || perUnit === null) return undefined;\n return parsePrice(perUnit) * quantity;\n }, [quote, quantity]);\n\n const displayTotalPrice = quotedTotalPrice ?? localTotalPrice;\n\n const handleVariantChange = useCallback(\n (variantId: string | undefined, variant: VariantView | undefined) => {\n setSelectedVariantId(variantId);\n setSelectedVariant(variant);\n },\n [],\n );\n\n const handleAddToCart = async () => {\n if (isSubmitting) return;\n\n setIsSubmitting(true);\n\n const options: AddToCartOptions = {\n variantId: selectedVariantId,\n variant: selectedVariant\n ? { id: selectedVariant.id, name: selectedVariant.name || \"\", price_adjustment: selectedVariant.price_adjustment }\n : undefined,\n quoteId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n addOnOptions: selectedAddOnOptions.length > 0\n ? selectedAddOnOptions.map((opt) => ({\n id: opt.id,\n name: opt.name,\n add_on_id: opt.add_on_id,\n default_price: opt.default_price,\n }))\n : undefined,\n compositeSelections: isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n billingPlanId: selectedBillingPlan?.id,\n specialInstructions: specialInstructions.trim() || undefined,\n };\n\n try {\n if (onAddToCart) {\n await onAddToCart(product, quantity, options);\n } else {\n await cart.addItem(product, quantity, options);\n }\n setIsAdded(true);\n setTimeout(() => {\n setIsAdded(false);\n setQuantity(1);\n }, 2000);\n } catch {\n // Caller handles errors via onAddToCart or cart hook error state\n } finally {\n setIsSubmitting(false);\n }\n };\n\n return (\n <div data-cimplify-customizer className={cn(\"space-y-6\", className, classNames?.root)}>\n {isComposite && product.groups && product.composite_id && (\n <CompositeSelector\n compositeId={product.composite_id}\n groups={product.groups}\n onSelectionsChange={setCompositeSelections}\n onPriceChange={setCompositePrice}\n onReady={setCompositeReady}\n skipPriceFetch\n />\n )}\n\n {isBundle && product.components && (\n <BundleSelector\n components={product.components}\n bundlePrice={product.bundle_price}\n discountValue={product.discount_value}\n pricingType={product.pricing_type}\n onSelectionsChange={setBundleSelections}\n onPriceChange={setBundleTotalPrice}\n onReady={setBundleReady}\n />\n )}\n\n {hasVariants && (\n <VariantSelector\n variants={product.variants!}\n variantAxes={product.variant_axes}\n basePrice={product.default_price}\n selectedVariantId={selectedVariantId}\n onVariantChange={handleVariantChange}\n productName={product.name}\n />\n )}\n\n {hasAddOns && (\n <AddOnSelector\n addOns={product.add_ons!}\n selectedOptions={selectedAddOnOptionIds}\n onOptionsChange={setSelectedAddOnOptionIds}\n />\n )}\n\n {/* Billing plans */}\n {product.billing_plans && product.billing_plans.length > 0 && (\n <BillingPlanSelector\n productId={product.id}\n plans={product.billing_plans}\n onPlanSelect={setSelectedBillingPlan}\n selectedPlanId={selectedBillingPlan?.id ?? null}\n showOneTimePurchase\n />\n )}\n\n {/* Volume pricing tiers */}\n {product.quantity_pricing && product.quantity_pricing.length > 1 && (\n <VolumePricing\n tiers={product.quantity_pricing}\n currentQuantity={quantity}\n />\n )}\n\n {/* Deposit info for service products */}\n {product.deposit_type && product.deposit_type !== \"none\" && product.deposit_amount && (\n <div data-cimplify-customizer-deposit className={cn(\"text-sm text-muted-foreground\", classNames?.depositInfo)}>\n {product.deposit_type === \"fixed\" ? (\n <span>Deposit required: <Price amount={product.deposit_amount} /></span>\n ) : (\n <span>Deposit required: {parsePrice(product.deposit_amount)}%</span>\n )}\n </div>\n )}\n\n {/* Allergens */}\n {product.allergies && product.allergies.length > 0 && (\n <div data-cimplify-customizer-allergens className={cn(\"flex flex-wrap gap-1.5\", classNames?.allergens)}>\n {product.allergies.map((allergen) => (\n <span\n key={allergen}\n data-cimplify-allergen-tag\n className=\"inline-block text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground\"\n >\n {allergen}\n </span>\n ))}\n </div>\n )}\n\n {/* Service duration */}\n {isService && product.duration_minutes != null && (\n <div data-cimplify-customizer-duration className={cn(\"text-sm text-muted-foreground\", classNames?.duration)}>\n Duration: {product.duration_minutes} {product.duration_unit ?? \"minutes\"}\n </div>\n )}\n\n {/* Special instructions */}\n {showSpecialInstructions && !isDigital && (\n <div data-cimplify-customizer-special-instructions>\n <textarea\n value={specialInstructions}\n onChange={(e) => setSpecialInstructions(e.target.value)}\n placeholder=\"Special instructions (e.g., no onions, extra sauce)\"\n rows={2}\n data-cimplify-customizer-textarea\n className={cn(\n \"w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring resize-none\",\n classNames?.specialInstructions,\n )}\n />\n </div>\n )}\n\n <div\n data-cimplify-customizer-actions\n className={cn(\"pt-4 border-t border-border\", classNames?.actions)}\n >\n {!quoteEnabled && (\n <p\n id=\"cimplify-customizer-validation\"\n data-cimplify-customizer-validation\n className={cn(\"text-sm text-destructive mb-3\", classNames?.validation)}\n >\n Please select all required options\n </p>\n )}\n <div className=\"flex items-center gap-4\">\n <div className=\"flex flex-col gap-1\">\n <QuantitySelector\n value={quantity}\n onChange={setQuantity}\n min={product.min_order_quantity ?? 1}\n />\n {product.min_order_quantity && product.min_order_quantity > 1 && (\n <span data-cimplify-customizer-min-hint className=\"text-xs text-muted-foreground text-center\">\n Min. {product.min_order_quantity}\n </span>\n )}\n </div>\n\n {product.quantity_pricing && product.quantity_pricing.length > 0 && (\n <div data-cimplify-customizer-unit-price className=\"text-sm text-muted-foreground text-right shrink-0\">\n <Price amount={getUnitPriceAtQuantity(product.quantity_pricing, quantity, parsePrice(product.default_price))} className=\"font-medium text-foreground\" /> ea.\n </div>\n )}\n\n <Button\n onClick={handleAddToCart}\n disabled={isAdded || isSubmitting || !quoteEnabled}\n aria-describedby={!quoteEnabled ? \"cimplify-customizer-validation\" : undefined}\n data-cimplify-customizer-submit\n className={cn(\n \"flex-1 h-14 text-base bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed rounded-full\",\n isAdded && classNames?.submitButtonAdded,\n classNames?.submitButton,\n )}\n >\n {isAdded ? \"Added to Cart\" : (\n <>\n Add to Cart · <Price amount={displayTotalPrice} />\n </>\n )}\n </Button>\n </div>\n </div>\n\n </div>\n );\n}\n"
|
|
17
|
+
"content": "\"use client\";\n\nimport React, { useState, useCallback, useMemo, useEffect, useRef } from \"react\";\nimport { Button } from \"@base-ui/react/button\";\nimport type {\n ProductWithDetails,\n VariantView,\n AddOnOption,\n ComponentSelectionInput,\n CompositePriceResult,\n} from \"@cimplify/sdk\";\nimport type { ProductBillingPlan } from \"@cimplify/sdk\";\nimport type { BundleSelectionInput } from \"@cimplify/sdk\";\nimport { parsePrice, getUnitPriceAtQuantity } from \"@cimplify/sdk\";\nimport { useCart, useQuote } from \"@cimplify/sdk/react\";\nimport type { AddToCartOptions } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { QuantitySelector } from \"@cimplify/sdk/react\";\nimport { VariantSelector } from \"@cimplify/sdk/react\";\nimport { AddOnSelector } from \"@cimplify/sdk/react\";\nimport { CompositeSelector } from \"@cimplify/sdk/react\";\nimport { BundleSelector } from \"@cimplify/sdk/react\";\nimport { BillingPlanSelector } from \"@cimplify/sdk/react\";\nimport { VolumePricing } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface ProductCustomizerClassNames {\n root?: string;\n actions?: string;\n submitButton?: string;\n submitButtonAdded?: string;\n validation?: string;\n specialInstructions?: string;\n depositInfo?: string;\n allergens?: string;\n duration?: string;\n}\n\nexport interface ProductCustomizerProps {\n product: ProductWithDetails;\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n showSpecialInstructions?: boolean;\n className?: string;\n classNames?: ProductCustomizerClassNames;\n}\n\nexport function ProductCustomizer({\n product,\n onAddToCart,\n showSpecialInstructions = true,\n className,\n classNames,\n}: ProductCustomizerProps): React.ReactElement {\n const [quantity, setQuantity] = useState(product.min_order_quantity ?? 1);\n const [isAdded, setIsAdded] = useState(false);\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [selectedVariantId, setSelectedVariantId] = useState<string | undefined>();\n const [selectedVariant, setSelectedVariant] = useState<VariantView | undefined>();\n const [selectedAddOnOptionIds, setSelectedAddOnOptionIds] = useState<string[]>([]);\n\n const [compositeSelections, setCompositeSelections] = useState<ComponentSelectionInput[]>([]);\n const [compositePrice, setCompositePrice] = useState<CompositePriceResult | null>(null);\n const [compositeReady, setCompositeReady] = useState(false);\n\n const [bundleSelections, setBundleSelections] = useState<BundleSelectionInput[]>([]);\n const [bundleTotalPrice, setBundleTotalPrice] = useState<number | null>(null);\n const [bundleReady, setBundleReady] = useState(false);\n const [selectedBillingPlan, setSelectedBillingPlan] = useState<ProductBillingPlan | null>(null);\n const [specialInstructions, setSpecialInstructions] = useState(\"\");\n\n const cart = useCart();\n\n const productType = product.type || \"product\";\n const isComposite = productType === \"composite\";\n const isBundle = productType === \"bundle\";\n const isDigital = productType === \"digital\";\n const isService = productType === \"service\";\n const isStandard = !isComposite && !isBundle;\n\n const hasVariants = isStandard && product.variants && product.variants.length > 0;\n const hasAddOns = isStandard && product.add_ons && product.add_ons.length > 0;\n\n useEffect(() => {\n setQuantity(product.min_order_quantity ?? 1);\n setIsAdded(false);\n setIsSubmitting(false);\n setSelectedVariantId(undefined);\n setSelectedVariant(undefined);\n setSelectedAddOnOptionIds([]);\n setCompositeSelections([]);\n setCompositePrice(null);\n setCompositeReady(false);\n setBundleSelections([]);\n setBundleTotalPrice(null);\n setBundleReady(false);\n setSelectedBillingPlan(null);\n setSpecialInstructions(\"\");\n }, [product.id, product.min_order_quantity]);\n\n const selectedAddOnOptions = useMemo(() => {\n if (!product.add_ons) return [];\n const options: AddOnOption[] = [];\n for (const addOn of product.add_ons) {\n for (const option of addOn.options) {\n if (selectedAddOnOptionIds.includes(option.id)) {\n options.push(option);\n }\n }\n }\n return options;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const normalizedAddOnOptionIds = useMemo(() => {\n if (selectedAddOnOptionIds.length === 0) return [];\n return Array.from(new Set(selectedAddOnOptionIds.map((id) => id.trim()).filter(Boolean))).sort();\n }, [selectedAddOnOptionIds]);\n\n const localTotalPrice = useMemo(() => {\n if (isComposite && compositePrice) {\n return parsePrice(compositePrice.final_price) * quantity;\n }\n\n if (isBundle && bundleTotalPrice != null) {\n return bundleTotalPrice * quantity;\n }\n\n let price = parsePrice(product.default_price);\n\n if (selectedVariant?.price_adjustment) {\n price += parsePrice(selectedVariant.price_adjustment);\n }\n\n for (const option of selectedAddOnOptions) {\n if (option.default_price) {\n price += parsePrice(option.default_price);\n }\n }\n\n return price * quantity;\n }, [product.default_price, selectedVariant, selectedAddOnOptions, quantity, isComposite, compositePrice, isBundle, bundleTotalPrice]);\n\n const requiredAddOnsSatisfied = useMemo(() => {\n if (!product.add_ons) return true;\n\n for (const addOn of product.add_ons) {\n if (addOn.is_required) {\n const selectedInGroup = selectedAddOnOptionIds.filter((id) =>\n addOn.options.some((opt) => opt.id === id),\n ).length;\n\n const minRequired = addOn.min_selections || 1;\n if (selectedInGroup < minRequired) {\n return false;\n }\n }\n }\n return true;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const quoteInput = useMemo(\n () => ({\n productId: product.id,\n quantity,\n variantId: selectedVariantId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n compositeSelections: isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n }),\n [product.id, quantity, selectedVariantId, normalizedAddOnOptionIds, isBundle, bundleSelections, isComposite, compositeSelections],\n );\n\n const quoteEnabled = isComposite\n ? compositeReady\n : isBundle\n ? bundleReady\n : requiredAddOnsSatisfied;\n\n const { quote } = useQuote(quoteInput, {\n enabled: quoteEnabled,\n });\n\n const quoteId = quote?.quote_id;\n const quotedTotalPrice = useMemo(() => {\n if (!quote) return undefined;\n\n const priceInfo = quote.quoted_total_price_info ?? quote.final_price_info;\n const perUnit = priceInfo.pre_tax_price;\n if (perUnit === undefined || perUnit === null) return undefined;\n return parsePrice(perUnit) * quantity;\n }, [quote, quantity]);\n\n const displayTotalPrice = quotedTotalPrice ?? localTotalPrice;\n\n const handleVariantChange = useCallback(\n (variantId: string | undefined, variant: VariantView | undefined) => {\n setSelectedVariantId(variantId);\n setSelectedVariant(variant);\n },\n [],\n );\n\n const addedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(addedTimerRef.current), []);\n\n const handleAddToCart = useCallback(async () => {\n if (isSubmitting) return;\n\n setIsSubmitting(true);\n\n const options: AddToCartOptions = {\n variantId: selectedVariantId,\n variant: selectedVariant\n ? { id: selectedVariant.id, name: selectedVariant.name || \"\", price_adjustment: selectedVariant.price_adjustment }\n : undefined,\n quoteId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n addOnOptions: selectedAddOnOptions.length > 0\n ? selectedAddOnOptions.map((opt) => ({\n id: opt.id,\n name: opt.name,\n add_on_id: opt.add_on_id,\n default_price: opt.default_price,\n }))\n : undefined,\n compositeSelections: isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n billingPlanId: selectedBillingPlan?.id,\n specialInstructions: specialInstructions.trim() || undefined,\n };\n\n try {\n if (onAddToCart) {\n await onAddToCart(product, quantity, options);\n } else {\n await cart.addItem(product, quantity, options);\n }\n setIsAdded(true);\n clearTimeout(addedTimerRef.current);\n addedTimerRef.current = setTimeout(() => {\n setIsAdded(false);\n setQuantity(product.min_order_quantity ?? 1);\n }, 2000);\n } catch {\n // Caller handles errors via onAddToCart or cart hook error state\n } finally {\n setIsSubmitting(false);\n }\n }, [product, quantity, selectedVariantId, selectedVariant, quoteId, normalizedAddOnOptionIds, selectedAddOnOptions, isComposite, compositeSelections, isBundle, bundleSelections, selectedBillingPlan, specialInstructions, isSubmitting, onAddToCart, cart]);\n\n return (\n <div data-cimplify-customizer className={cn(\"space-y-6\", className, classNames?.root)}>\n {isComposite && product.groups && product.composite_id && (\n <CompositeSelector\n compositeId={product.composite_id}\n groups={product.groups}\n onSelectionsChange={setCompositeSelections}\n onPriceChange={setCompositePrice}\n onReady={setCompositeReady}\n skipPriceFetch\n />\n )}\n\n {isBundle && product.components && (\n <BundleSelector\n components={product.components}\n bundlePrice={product.bundle_price}\n discountValue={product.discount_value}\n pricingType={product.pricing_type}\n onSelectionsChange={setBundleSelections}\n onPriceChange={setBundleTotalPrice}\n onReady={setBundleReady}\n />\n )}\n\n {hasVariants && (\n <VariantSelector\n variants={product.variants!}\n variantAxes={product.variant_axes}\n basePrice={product.default_price}\n selectedVariantId={selectedVariantId}\n onVariantChange={handleVariantChange}\n productName={product.name}\n />\n )}\n\n {hasAddOns && (\n <AddOnSelector\n addOns={product.add_ons!}\n selectedOptions={selectedAddOnOptionIds}\n onOptionsChange={setSelectedAddOnOptionIds}\n />\n )}\n\n {/* Billing plans */}\n {product.billing_plans && product.billing_plans.length > 0 && (\n <BillingPlanSelector\n productId={product.id}\n plans={product.billing_plans}\n onPlanSelect={setSelectedBillingPlan}\n selectedPlanId={selectedBillingPlan?.id ?? null}\n showOneTimePurchase\n />\n )}\n\n {/* Volume pricing tiers */}\n {product.quantity_pricing && product.quantity_pricing.length > 1 && (\n <VolumePricing\n tiers={product.quantity_pricing}\n currentQuantity={quantity}\n />\n )}\n\n {/* Deposit info for service products */}\n {product.deposit_type && product.deposit_type !== \"none\" && product.deposit_amount && (\n <div data-cimplify-customizer-deposit className={cn(\"text-sm text-muted-foreground\", classNames?.depositInfo)}>\n {product.deposit_type === \"fixed\" ? (\n <span>Deposit required: <Price amount={product.deposit_amount} /></span>\n ) : (\n <span>Deposit required: {parsePrice(product.deposit_amount)}%</span>\n )}\n </div>\n )}\n\n {/* Allergens */}\n {product.allergies && product.allergies.length > 0 && (\n <div data-cimplify-customizer-allergens className={cn(\"flex flex-wrap gap-1.5\", classNames?.allergens)}>\n {product.allergies.map((allergen) => (\n <span\n key={allergen}\n data-cimplify-allergen-tag\n className=\"inline-block text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground\"\n >\n {allergen}\n </span>\n ))}\n </div>\n )}\n\n {/* Service duration */}\n {isService && product.duration_minutes != null && (\n <div data-cimplify-customizer-duration className={cn(\"text-sm text-muted-foreground\", classNames?.duration)}>\n Duration: {product.duration_minutes} {product.duration_unit ?? \"minutes\"}\n </div>\n )}\n\n {/* Special instructions */}\n {showSpecialInstructions && !isDigital && (\n <div data-cimplify-customizer-special-instructions>\n <textarea\n value={specialInstructions}\n onChange={(e) => setSpecialInstructions(e.target.value)}\n placeholder=\"Special instructions (e.g., no onions, extra sauce)\"\n rows={2}\n data-cimplify-customizer-textarea\n className={cn(\n \"w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring resize-none\",\n classNames?.specialInstructions,\n )}\n />\n </div>\n )}\n\n <div\n data-cimplify-customizer-actions\n className={cn(\"pt-4 border-t border-border\", classNames?.actions)}\n >\n {!quoteEnabled && (\n <p\n id=\"cimplify-customizer-validation\"\n data-cimplify-customizer-validation\n className={cn(\"text-sm text-destructive mb-3\", classNames?.validation)}\n >\n Please select all required options\n </p>\n )}\n <div className=\"flex items-center gap-4\">\n <div className=\"flex flex-col gap-1\">\n <QuantitySelector\n value={quantity}\n onChange={setQuantity}\n min={product.min_order_quantity ?? 1}\n />\n {product.min_order_quantity && product.min_order_quantity > 1 && (\n <span data-cimplify-customizer-min-hint className=\"text-xs text-muted-foreground text-center\">\n Min. {product.min_order_quantity}\n </span>\n )}\n </div>\n\n {product.quantity_pricing && product.quantity_pricing.length > 0 && (\n <div data-cimplify-customizer-unit-price className=\"text-sm text-muted-foreground text-right shrink-0\">\n <Price amount={getUnitPriceAtQuantity(product.quantity_pricing, quantity, parsePrice(product.default_price))} className=\"font-medium text-foreground\" /> ea.\n </div>\n )}\n\n <Button\n onClick={handleAddToCart}\n disabled={isAdded || isSubmitting || !quoteEnabled}\n aria-describedby={!quoteEnabled ? \"cimplify-customizer-validation\" : undefined}\n data-cimplify-customizer-submit\n className={cn(\n \"flex-1 h-14 text-base bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed rounded-full\",\n isAdded && classNames?.submitButtonAdded,\n classNames?.submitButton,\n )}\n >\n {isAdded ? \"Added to Cart\" : (\n <>\n Add to Cart · <Price amount={displayTotalPrice} />\n </>\n )}\n </Button>\n </div>\n </div>\n\n </div>\n );\n}\n"
|
|
18
18
|
}
|
|
19
19
|
]
|
|
20
20
|
}
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "product-page",
|
|
3
3
|
"title": "ProductPage",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "Smart product page resolver with per-slug and per-type template routing.",
|
|
5
5
|
"type": "component",
|
|
6
6
|
"registryDependencies": [
|
|
7
|
-
"product-
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"product-
|
|
7
|
+
"default-product-layout",
|
|
8
|
+
"food-product-layout",
|
|
9
|
+
"wholesale-product-layout",
|
|
10
|
+
"service-product-layout",
|
|
11
|
+
"digital-product-layout",
|
|
11
12
|
"cn"
|
|
12
13
|
],
|
|
13
14
|
"files": [
|
|
14
15
|
{
|
|
15
16
|
"path": "product-page.tsx",
|
|
16
|
-
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { Product, ProductWithDetails } from \"@cimplify/sdk\";\nimport type { ProductLayoutProps } from \"
|
|
17
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { Product, ProductWithDetails } from \"@cimplify/sdk\";\nimport type { ProductLayoutProps } from \"@cimplify/sdk/react\";\nimport { DefaultProductLayout } from \"@cimplify/sdk/react\";\nimport { FoodProductLayout } from \"@cimplify/sdk/react\";\nimport { WholesaleProductLayout } from \"@cimplify/sdk/react\";\nimport { ServiceProductLayout } from \"@cimplify/sdk/react\";\nimport { DigitalProductLayout } from \"@cimplify/sdk/react\";\nimport type { AddToCartOptions } from \"@cimplify/sdk/react\";\nimport { useProduct } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport type { ProductLayoutProps };\n\nexport enum ProductTemplate {\n Default = \"default\",\n Food = \"food\",\n Wholesale = \"wholesale\",\n Service = \"service\",\n Digital = \"digital\",\n Physical = \"physical\",\n}\n\nexport interface ProductPageClassNames {\n root?: string;\n loading?: string;\n}\n\nexport interface ProductPageProps {\n /** Product slug or ID — used for client-side fetch when `product` is not provided. */\n productId?: string;\n /** Pre-fetched product for SSR. Skips client-side fetch when provided. */\n product?: ProductWithDetails;\n /** Per-slug page map. Highest priority — maps a product slug to a custom component. */\n pages?: Record<string, React.ComponentType<ProductLayoutProps>>;\n /** Per-type template map. Overrides built-in layouts for specific template keys. */\n templates?: Partial<Record<ProductTemplate | string, React.ComponentType<ProductLayoutProps>>>;\n /** Override add-to-cart behavior. */\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Custom image renderer (e.g. Next.js Image). */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Breadcrumb renderer. Receives the product for context. */\n renderBreadcrumb?: (product: ProductWithDetails) => React.ReactNode;\n /** Pre-fetched related products. */\n relatedProducts?: Product[];\n /** Show related products section. Default: true. */\n showRelated?: boolean;\n className?: string;\n classNames?: ProductPageClassNames;\n}\n\nconst BUILT_IN_LAYOUTS: Record<string, React.ComponentType<ProductLayoutProps>> = {\n [ProductTemplate.Food]: FoodProductLayout,\n [ProductTemplate.Wholesale]: WholesaleProductLayout,\n [ProductTemplate.Service]: ServiceProductLayout,\n [ProductTemplate.Digital]: DigitalProductLayout,\n [ProductTemplate.Default]: DefaultProductLayout,\n};\n\nfunction resolveTemplateKey(product: ProductWithDetails): ProductTemplate | string {\n const metaTemplate = product.metadata?.page_template;\n if (typeof metaTemplate === \"string\" && metaTemplate.trim()) {\n return metaTemplate.trim();\n }\n\n if (product.quantity_pricing && product.quantity_pricing.length > 1) {\n return ProductTemplate.Wholesale;\n }\n\n if (product.type === \"service\") {\n return ProductTemplate.Service;\n }\n\n if (product.type === \"digital\") {\n return ProductTemplate.Digital;\n }\n\n if (product.render_hint === \"food\") {\n return ProductTemplate.Food;\n }\n\n if (product.render_hint === \"physical\") {\n return ProductTemplate.Physical;\n }\n\n return ProductTemplate.Default;\n}\n\nfunction resolveLayout(\n product: ProductWithDetails,\n pages?: Record<string, React.ComponentType<ProductLayoutProps>>,\n templates?: Partial<Record<string, React.ComponentType<ProductLayoutProps>>>,\n): React.ComponentType<ProductLayoutProps> {\n // 1. Per-slug page (AI-generated, highest priority)\n if (pages?.[product.slug]) {\n return pages[product.slug];\n }\n\n const key = resolveTemplateKey(product);\n\n // 2. Consumer-provided template override\n if (templates?.[key]) {\n return templates[key];\n }\n\n // 3. Built-in layout\n if (BUILT_IN_LAYOUTS[key]) {\n return BUILT_IN_LAYOUTS[key];\n }\n\n // 4. Fallback\n return DefaultProductLayout;\n}\n\nexport function ProductPage({\n productId,\n product: productProp,\n pages,\n templates,\n onAddToCart,\n renderImage,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n classNames,\n}: ProductPageProps): React.ReactElement {\n const resolvedId = productId || productProp?.slug || productProp?.id || \"\";\n const { product: fetched, isLoading } = useProduct(resolvedId, {\n enabled: !productProp && resolvedId.length > 0,\n });\n const product = productProp ?? fetched;\n\n if (isLoading && !product) {\n return (\n <div\n data-cimplify-product-page\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div className=\"grid lg:grid-cols-2 gap-8 lg:gap-12 animate-pulse\">\n <div className=\"aspect-square bg-muted\" />\n <div className=\"space-y-4 py-4\">\n <div className=\"h-8 w-3/5 bg-muted\" />\n <div className=\"h-6 w-2/5 bg-muted\" />\n <div className=\"h-4 w-4/5 bg-muted\" />\n <div className=\"h-4 w-3/5 bg-muted\" />\n <div className=\"h-14 w-full bg-muted mt-8\" />\n </div>\n </div>\n </div>\n );\n }\n\n if (!product) {\n return (\n <div data-cimplify-product-page className={cn(className, classNames?.root)}>\n <p className=\"text-muted-foreground\">Product not found.</p>\n </div>\n );\n }\n\n const Layout = resolveLayout(product, pages, templates);\n\n return (\n <div data-cimplify-product-page className={cn(className, classNames?.root)}>\n <Layout\n product={product}\n onAddToCart={onAddToCart}\n renderImage={renderImage}\n renderBreadcrumb={renderBreadcrumb}\n relatedProducts={relatedProducts}\n showRelated={showRelated}\n />\n </div>\n );\n}\n"
|
|
17
18
|
}
|
|
18
19
|
]
|
|
19
20
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "service-product-layout",
|
|
3
|
+
"title": "ServiceProductLayout",
|
|
4
|
+
"description": "Service booking layout with duration, deposit, staff requirements, and cancellation policy.",
|
|
5
|
+
"type": "component",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"product-image-gallery",
|
|
8
|
+
"product-customizer",
|
|
9
|
+
"price",
|
|
10
|
+
"cn"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "layouts/service-product-layout.tsx",
|
|
15
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { ProductLayoutProps } from \"@cimplify/sdk/react\";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n TagPills,\n} from \"@cimplify/sdk/react\";\nimport { ProductImageGallery } from \"@cimplify/sdk/react\";\nimport { ProductCustomizer } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nfunction formatMinutes(minutes: number): string {\n if (minutes >= 1440) return `${Math.floor(minutes / 1440)} day${minutes >= 2880 ? \"s\" : \"\"}`;\n if (minutes >= 60) {\n const h = Math.floor(minutes / 60);\n const m = minutes % 60;\n return m > 0 ? `${h}h ${m}min` : `${h} hour${h > 1 ? \"s\" : \"\"}`;\n }\n return `${minutes} min`;\n}\n\nexport function ServiceProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const hasDeposit = product.deposit_type && product.deposit_type !== \"none\" && product.deposit_amount;\n const depositAmount = hasDeposit ? parsePrice(product.deposit_amount!) : 0;\n const cancellationHours = product.cancellation_window_minutes\n ? Math.floor(product.cancellation_window_minutes / 60)\n : null;\n\n return (\n <div data-cimplify-product-layout=\"service\" className={cn(\"space-y-8\", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className=\"space-y-6\">\n {/* Tags */}\n <TagPills tags={product.tags || []} />\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className=\"text-3xl lg:text-4xl font-extrabold tracking-tight\">\n {product.name}\n </h1>\n <Price amount={product.default_price} className=\"text-2xl font-bold text-primary mt-2 block\" />\n </div>\n\n {/* Service info pills */}\n <div data-cimplify-product-layout-service-info className=\"flex flex-wrap gap-2\">\n {product.duration_minutes != null && (\n <span className=\"inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full\">\n {formatMinutes(product.duration_minutes)}\n </span>\n )}\n {hasDeposit && (\n <span className=\"inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full\">\n <Price amount={depositAmount} /> deposit\n </span>\n )}\n {product.general_service_capacity != null && product.general_service_capacity > 1 && (\n <span className=\"inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full\">\n Up to {product.general_service_capacity} people\n </span>\n )}\n {product.buffer_before_minutes != null && product.buffer_before_minutes > 0 && (\n <span className=\"inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full\">\n {product.buffer_before_minutes} min buffer\n </span>\n )}\n </div>\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className=\"text-muted-foreground leading-relaxed\">\n {product.description}\n </p>\n )}\n\n {/* Staff & Resource requirements */}\n {(product.requires_specific_staff || product.requires_specific_resource) && (\n <div data-cimplify-product-layout-requirements className=\"text-sm text-muted-foreground space-y-1\">\n {product.requires_specific_staff && (\n <p>Staff selection required</p>\n )}\n {product.requires_specific_resource && (\n <p>Resource selection required</p>\n )}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (variants, scheduling, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n showSpecialInstructions={false}\n />\n\n {/* Cancellation policy */}\n {(cancellationHours != null || product.no_show_fee) && (\n <div data-cimplify-product-layout-cancellation className=\"border border-border p-4 space-y-2 text-sm\">\n <h3 className=\"font-semibold\">Cancellation Policy</h3>\n {cancellationHours != null && (\n <p className=\"text-muted-foreground\">\n Free cancellation up to <strong className=\"text-foreground\">{cancellationHours} hours</strong> before the appointment\n </p>\n )}\n {product.no_show_fee && (\n <p className=\"text-muted-foreground\">\n No-show fee: <Price amount={product.no_show_fee} className=\"font-medium text-foreground\" />\n </p>\n )}\n {hasDeposit && (\n <p className=\"text-muted-foreground\">\n {product.deposit_type === \"fixed\"\n ? <><Price amount={depositAmount} className=\"font-medium text-foreground\" /> deposit charged at booking</>\n : <>{parsePrice(product.deposit_amount!)}% deposit charged at booking</>\n }\n </p>\n )}\n </div>\n )}\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title=\"Other services\"\n />\n )}\n </div>\n );\n}\n"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "volume-pricing",
|
|
3
|
+
"title": "VolumePricing",
|
|
4
|
+
"description": "Collapsible volume pricing tier table.",
|
|
5
|
+
"type": "component",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"price",
|
|
8
|
+
"cn"
|
|
9
|
+
],
|
|
10
|
+
"files": [
|
|
11
|
+
{
|
|
12
|
+
"path": "volume-pricing.tsx",
|
|
13
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { QuantityPricingTier } from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface VolumePricingClassNames {\n root?: string;\n trigger?: string;\n triggerIcon?: string;\n panel?: string;\n tier?: string;\n tierActive?: string;\n tierRange?: string;\n tierPrice?: string;\n}\n\nexport interface VolumePricingProps {\n tiers: QuantityPricingTier[];\n currentQuantity?: number;\n defaultOpen?: boolean;\n className?: string;\n classNames?: VolumePricingClassNames;\n}\n\nfunction formatRange(tier: QuantityPricingTier): string {\n if (tier.max_quantity != null) {\n return `${tier.min_quantity}–${tier.max_quantity} units`;\n }\n return `${tier.min_quantity}+ units`;\n}\n\nfunction isActiveTier(tier: QuantityPricingTier, quantity: number): boolean {\n if (quantity < tier.min_quantity) return false;\n if (tier.max_quantity != null && quantity > tier.max_quantity) return false;\n return true;\n}\n\nexport function VolumePricing({\n tiers,\n currentQuantity,\n defaultOpen = false,\n className,\n classNames,\n}: VolumePricingProps): React.ReactElement | null {\n if (tiers.length < 2) return null;\n\n const sorted = [...tiers].sort((a, b) => a.min_quantity - b.min_quantity);\n\n return (\n <details\n open={defaultOpen || undefined}\n data-cimplify-volume-pricing\n className={cn(\"border border-border\", className, classNames?.root)}\n >\n <summary\n data-cimplify-volume-pricing-trigger\n className={cn(\n \"flex items-center justify-between px-4 py-3 cursor-pointer select-none text-sm font-medium list-none [&::-webkit-details-marker]:hidden\",\n classNames?.trigger,\n )}\n >\n Volume pricing\n <svg\n viewBox=\"0 0 12 12\"\n fill=\"none\"\n aria-hidden=\"true\"\n className={cn(\n \"w-3.5 h-3.5 text-muted-foreground transition-transform [[open]>&]:rotate-180\",\n classNames?.triggerIcon,\n )}\n >\n <path d=\"M2 4.5l4 4 4-4\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n </summary>\n <div\n data-cimplify-volume-pricing-panel\n className={cn(\"border-t border-border divide-y divide-border\", classNames?.panel)}\n >\n {sorted.map((tier, i) => {\n const active = currentQuantity != null && isActiveTier(tier, currentQuantity);\n return (\n <div\n key={i}\n data-cimplify-volume-pricing-tier\n data-active={active || undefined}\n className={cn(\n \"flex items-center justify-between px-4 py-2.5 text-sm\",\n active\n ? cn(\"bg-primary/5 font-medium\", classNames?.tierActive)\n : classNames?.tier,\n )}\n >\n <span\n data-cimplify-volume-pricing-range\n className={cn(\"text-muted-foreground\", classNames?.tierRange)}\n >\n {formatRange(tier)}\n </span>\n <span data-cimplify-volume-pricing-price className={classNames?.tierPrice}>\n <Price amount={tier.unit_price} prefix=\"\" /> /ea\n </span>\n </div>\n );\n })}\n </div>\n </details>\n );\n}\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wholesale-product-layout",
|
|
3
|
+
"title": "WholesaleProductLayout",
|
|
4
|
+
"description": "B2B wholesale layout with price range, volume pricing, MOQ, and inventory.",
|
|
5
|
+
"type": "component",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"product-image-gallery",
|
|
8
|
+
"product-customizer",
|
|
9
|
+
"price",
|
|
10
|
+
"price-range",
|
|
11
|
+
"volume-pricing",
|
|
12
|
+
"cn"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "layouts/wholesale-product-layout.tsx",
|
|
17
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { ProductLayoutProps } from \"@cimplify/sdk/react\";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n PropertiesTable,\n TagPills,\n InventoryBadge,\n} from \"@cimplify/sdk/react\";\nimport { ProductImageGallery } from \"@cimplify/sdk/react\";\nimport { ProductCustomizer } from \"@cimplify/sdk/react\";\nimport { PriceRange } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { VolumePricing } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport function WholesaleProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const hasTiers = product.quantity_pricing && product.quantity_pricing.length > 1;\n\n return (\n <div data-cimplify-product-layout=\"wholesale\" className={cn(\"space-y-8\", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className=\"space-y-6\">\n {/* Tags */}\n <TagPills tags={product.tags || []} />\n\n {/* SKU + Name */}\n <div>\n {product.sku && (\n <p className=\"text-xs text-muted-foreground font-medium uppercase tracking-wider mb-1\">\n SKU: {product.sku}\n </p>\n )}\n <h1 data-cimplify-product-layout-name className=\"text-3xl lg:text-4xl font-extrabold tracking-tight\">\n {product.name}\n </h1>\n </div>\n\n {/* Price range */}\n <div>\n {hasTiers ? (\n <PriceRange product={product} className=\"text-2xl font-extrabold\" />\n ) : (\n <Price amount={product.default_price} className=\"text-2xl font-semibold\" />\n )}\n </div>\n\n {/* Inventory */}\n <InventoryBadge product={product} />\n\n {/* Min order notice */}\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <div data-cimplify-product-layout-moq className=\"flex items-center gap-3 p-3 bg-amber-50 border border-amber-200 text-sm text-amber-800\">\n Minimum order quantity: <strong>{product.min_order_quantity} units</strong>\n </div>\n )}\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className=\"text-muted-foreground leading-relaxed\">\n {product.description}\n </p>\n )}\n\n {/* Volume pricing — open by default for wholesale */}\n {hasTiers && (\n <VolumePricing\n tiers={product.quantity_pricing!}\n defaultOpen\n />\n )}\n\n {/* Product details */}\n {(product.sku || product.barcode || product.vendor) && (\n <div data-cimplify-product-layout-details className=\"space-y-1 text-sm text-muted-foreground\">\n {product.barcode && <p>Barcode: <span className=\"font-mono text-foreground\">{product.barcode}</span></p>}\n {product.vendor && <p>Supplier: <span className=\"text-foreground\">{product.vendor}</span></p>}\n {product.hs_code && <p>HS Code: <span className=\"font-mono text-foreground\">{product.hs_code}</span></p>}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Properties */}\n <PropertiesTable properties={product.properties || []} />\n\n {/* Customizer (variants, billing plans, quantity, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title=\"Related products\"\n />\n )}\n </div>\n );\n}\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|