@cimplify/sdk 0.60.0 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/testing.d.ts CHANGED
@@ -54,8 +54,8 @@ declare const CountryCodeSchema: z.ZodString;
54
54
  declare const LocaleSchema: z.ZodString;
55
55
  declare const PhoneE164Schema: z.ZodString;
56
56
  declare const SeedNameSchema: z.ZodEnum<{
57
- default: "default";
58
57
  empty: "empty";
58
+ default: "default";
59
59
  "reesa-storefront": "reesa-storefront";
60
60
  restaurant: "restaurant";
61
61
  retail: "retail";
@@ -71,11 +71,11 @@ declare const ProductTypeSchema: z.ZodEnum<{
71
71
  composite: "composite";
72
72
  }>;
73
73
  declare const LineTypeSchema: z.ZodEnum<{
74
+ simple: "simple";
74
75
  service: "service";
75
76
  digital: "digital";
76
77
  bundle: "bundle";
77
78
  composite: "composite";
78
- simple: "simple";
79
79
  }>;
80
80
  declare const ServiceStatusSchema: z.ZodEnum<{
81
81
  pending: "pending";
@@ -365,8 +365,8 @@ declare const BrandSchema: z.ZodObject<{
365
365
  }, z.core.$strip>;
366
366
  mock: z.ZodObject<{
367
367
  seed: z.ZodEnum<{
368
- default: "default";
369
368
  empty: "empty";
369
+ default: "default";
370
370
  "reesa-storefront": "reesa-storefront";
371
371
  restaurant: "restaurant";
372
372
  retail: "retail";
@@ -531,7 +531,7 @@ declare const assertBrand: (value: unknown) => asserts value is {
531
531
  summary: string;
532
532
  };
533
533
  mock: {
534
- seed: "default" | "empty" | "reesa-storefront" | "restaurant" | "retail" | "services" | "grocery" | "fashion";
534
+ seed: "empty" | "default" | "reesa-storefront" | "restaurant" | "retail" | "services" | "grocery" | "fashion";
535
535
  businessId: string;
536
536
  };
537
537
  trustItems?: {
@@ -780,11 +780,11 @@ declare const CartItemSchema: z.ZodObject<{
780
780
  quantity: z.ZodNumber;
781
781
  line_key: z.ZodString;
782
782
  line_type: z.ZodEnum<{
783
+ simple: "simple";
783
784
  service: "service";
784
785
  digital: "digital";
785
786
  bundle: "bundle";
786
787
  composite: "composite";
787
- simple: "simple";
788
788
  }>;
789
789
  name: z.ZodString;
790
790
  description: z.ZodOptional<z.ZodString>;
@@ -926,7 +926,7 @@ declare const assertCartItem: (value: unknown) => asserts value is {
926
926
  item_id: string;
927
927
  quantity: number;
928
928
  line_key: string;
929
- line_type: "service" | "digital" | "bundle" | "composite" | "simple";
929
+ line_type: "simple" | "service" | "digital" | "bundle" | "composite";
930
930
  name: string;
931
931
  is_available: boolean;
932
932
  base_price: string;
@@ -1076,11 +1076,11 @@ declare const CartSchema: z.ZodObject<{
1076
1076
  quantity: z.ZodNumber;
1077
1077
  line_key: z.ZodString;
1078
1078
  line_type: z.ZodEnum<{
1079
+ simple: "simple";
1079
1080
  service: "service";
1080
1081
  digital: "digital";
1081
1082
  bundle: "bundle";
1082
1083
  composite: "composite";
1083
- simple: "simple";
1084
1084
  }>;
1085
1085
  name: z.ZodString;
1086
1086
  description: z.ZodOptional<z.ZodString>;
@@ -1249,7 +1249,7 @@ declare const assertCart: (value: unknown) => asserts value is {
1249
1249
  item_id: string;
1250
1250
  quantity: number;
1251
1251
  line_key: string;
1252
- line_type: "service" | "digital" | "bundle" | "composite" | "simple";
1252
+ line_type: "simple" | "service" | "digital" | "bundle" | "composite";
1253
1253
  name: string;
1254
1254
  is_available: boolean;
1255
1255
  base_price: string;
@@ -1462,10 +1462,10 @@ declare const assertCheckoutBody: (value: unknown) => asserts value is {
1462
1462
  };
1463
1463
  declare const AuthorizationTypeSchema: z.ZodEnum<{
1464
1464
  phone: "phone";
1465
- address: "address";
1466
1465
  otp: "otp";
1467
1466
  pin: "pin";
1468
1467
  birthday: "birthday";
1468
+ address: "address";
1469
1469
  }>;
1470
1470
  declare const NextActionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
1471
1471
  type: z.ZodLiteral<"none">;
@@ -1485,10 +1485,10 @@ declare const CheckoutResponseSchema: z.ZodObject<{
1485
1485
  requires_authorization: z.ZodBoolean;
1486
1486
  authorization_type: z.ZodOptional<z.ZodEnum<{
1487
1487
  phone: "phone";
1488
- address: "address";
1489
1488
  otp: "otp";
1490
1489
  pin: "pin";
1491
1490
  birthday: "birthday";
1491
+ address: "address";
1492
1492
  }>>;
1493
1493
  next_action: z.ZodDiscriminatedUnion<[z.ZodObject<{
1494
1494
  type: z.ZodLiteral<"none">;
@@ -1525,7 +1525,7 @@ declare const assertCheckoutResponse: (value: unknown) => asserts value is {
1525
1525
  display_text?: string | undefined;
1526
1526
  };
1527
1527
  payment_reference?: string | undefined;
1528
- authorization_type?: "phone" | "address" | "otp" | "pin" | "birthday" | undefined;
1528
+ authorization_type?: "phone" | "otp" | "pin" | "birthday" | "address" | undefined;
1529
1529
  provider?: string | undefined;
1530
1530
  authorization_url?: string | undefined;
1531
1531
  display_text?: string | undefined;
@@ -1724,8 +1724,8 @@ declare const PUBLIC_SCHEMAS: {
1724
1724
  }, z.core.$strip>;
1725
1725
  mock: z.ZodObject<{
1726
1726
  seed: z.ZodEnum<{
1727
- default: "default";
1728
1727
  empty: "empty";
1728
+ default: "default";
1729
1729
  "reesa-storefront": "reesa-storefront";
1730
1730
  restaurant: "restaurant";
1731
1731
  retail: "retail";
@@ -1785,11 +1785,11 @@ declare const PUBLIC_SCHEMAS: {
1785
1785
  quantity: z.ZodNumber;
1786
1786
  line_key: z.ZodString;
1787
1787
  line_type: z.ZodEnum<{
1788
+ simple: "simple";
1788
1789
  service: "service";
1789
1790
  digital: "digital";
1790
1791
  bundle: "bundle";
1791
1792
  composite: "composite";
1792
- simple: "simple";
1793
1793
  }>;
1794
1794
  name: z.ZodString;
1795
1795
  description: z.ZodOptional<z.ZodString>;
@@ -1953,11 +1953,11 @@ declare const PUBLIC_SCHEMAS: {
1953
1953
  quantity: z.ZodNumber;
1954
1954
  line_key: z.ZodString;
1955
1955
  line_type: z.ZodEnum<{
1956
+ simple: "simple";
1956
1957
  service: "service";
1957
1958
  digital: "digital";
1958
1959
  bundle: "bundle";
1959
1960
  composite: "composite";
1960
- simple: "simple";
1961
1961
  }>;
1962
1962
  name: z.ZodString;
1963
1963
  description: z.ZodOptional<z.ZodString>;
@@ -2131,10 +2131,10 @@ declare const PUBLIC_SCHEMAS: {
2131
2131
  requires_authorization: z.ZodBoolean;
2132
2132
  authorization_type: z.ZodOptional<z.ZodEnum<{
2133
2133
  phone: "phone";
2134
- address: "address";
2135
2134
  otp: "otp";
2136
2135
  pin: "pin";
2137
2136
  birthday: "birthday";
2137
+ address: "address";
2138
2138
  }>>;
2139
2139
  next_action: z.ZodDiscriminatedUnion<[z.ZodObject<{
2140
2140
  type: z.ZodLiteral<"none">;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cimplify/sdk",
3
- "version": "0.60.0",
3
+ "version": "0.62.0",
4
4
  "description": "Cimplify Commerce SDK for storefronts",
5
5
  "keywords": [
6
6
  "cimplify",
@@ -152,6 +152,7 @@
152
152
  "libphonenumber-js": "1.12.41",
153
153
  "react-day-picker": "^9.14.0",
154
154
  "tailwind-merge": "^3.5.0",
155
+ "xss": "^1.0.15",
155
156
  "zod": "^4.4.3"
156
157
  }
157
158
  }
@@ -10,12 +10,13 @@
10
10
  "price-range",
11
11
  "availability-badge",
12
12
  "sale-badge",
13
+ "rich-text",
13
14
  "cn"
14
15
  ],
15
16
  "files": [
16
17
  {
17
18
  "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 { resolveGallery } from \"../../gallery\";\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 [selectedVariantId, setSelectedVariantId] = React.useState<string | undefined>();\n const images = resolveGallery(product, selectedVariantId);\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 onVariantChange={(variantId) => setSelectedVariantId(variantId)}\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
+ "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 { resolveGallery } from \"../../gallery\";\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 { RichText } from \"@cimplify/sdk/react\";\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 [selectedVariantId, setSelectedVariantId] = React.useState<string | undefined>();\n const images = resolveGallery(product, selectedVariantId);\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 <RichText html={product.description} data-cimplify-product-layout-description />\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 onVariantChange={(variantId) => setSelectedVariantId(variantId)}\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
  }
20
21
  ]
21
22
  }
@@ -7,12 +7,13 @@
7
7
  "product-image-gallery",
8
8
  "product-customizer",
9
9
  "price",
10
+ "rich-text",
10
11
  "cn"
11
12
  ],
12
13
  "files": [
13
14
  {
14
15
  "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
+ "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 { RichText } 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 <RichText html={product.description} data-cimplify-product-layout-description />\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
  }
17
18
  ]
18
19
  }
@@ -7,12 +7,13 @@
7
7
  "product-image-gallery",
8
8
  "product-customizer",
9
9
  "price",
10
+ "rich-text",
10
11
  "cn"
11
12
  ],
12
13
  "files": [
13
14
  {
14
15
  "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\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 tags = product.tags || [];\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 + Allergens */}\n <div className=\"flex flex-wrap gap-1.5\">\n {tags.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 {/* 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
+ "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 { RichText } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\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 tags = product.tags || [];\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 + Allergens */}\n <div className=\"flex flex-wrap gap-1.5\">\n {tags.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 {/* 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 <RichText html={product.description} data-cimplify-product-layout-description />\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
  }
17
18
  ]
18
19
  }
@@ -124,6 +124,15 @@
124
124
  "cn"
125
125
  ]
126
126
  },
127
+ {
128
+ "name": "rich-text",
129
+ "title": "RichText",
130
+ "description": "Renders sanitized merchant-authored HTML (descriptions) with theme-aware prose styling. Edge-safe XSS sanitization via the pure-JS xss library.",
131
+ "type": "component",
132
+ "registryDependencies": [
133
+ "cn"
134
+ ]
135
+ },
127
136
  {
128
137
  "name": "product-sheet",
129
138
  "title": "ProductSheet",
@@ -133,6 +142,7 @@
133
142
  "price",
134
143
  "product-image-gallery",
135
144
  "product-customizer",
145
+ "rich-text",
136
146
  "cn"
137
147
  ]
138
148
  },
@@ -555,6 +565,7 @@
555
565
  "price-range",
556
566
  "availability-badge",
557
567
  "sale-badge",
568
+ "rich-text",
558
569
  "cn"
559
570
  ]
560
571
  },
@@ -567,6 +578,7 @@
567
578
  "product-image-gallery",
568
579
  "product-customizer",
569
580
  "price",
581
+ "rich-text",
570
582
  "cn"
571
583
  ]
572
584
  },
@@ -581,6 +593,7 @@
581
593
  "price",
582
594
  "price-range",
583
595
  "volume-pricing",
596
+ "rich-text",
584
597
  "cn"
585
598
  ]
586
599
  },
@@ -593,6 +606,7 @@
593
606
  "product-image-gallery",
594
607
  "product-customizer",
595
608
  "price",
609
+ "rich-text",
596
610
  "cn"
597
611
  ]
598
612
  },
@@ -605,6 +619,7 @@
605
619
  "product-image-gallery",
606
620
  "product-customizer",
607
621
  "price",
622
+ "rich-text",
608
623
  "cn"
609
624
  ]
610
625
  },
@@ -7,12 +7,13 @@
7
7
  "price",
8
8
  "product-image-gallery",
9
9
  "product-customizer",
10
+ "rich-text",
10
11
  "cn"
11
12
  ],
12
13
  "files": [
13
14
  {
14
15
  "path": "product-sheet.tsx",
15
- "content": "\"use client\";\n\nimport React, { useState } from \"react\";\nimport type { Product, ProductWithDetails, VariantView } from \"@cimplify/sdk\";\nimport type { AddToCartOptions } from \"@cimplify/sdk/react\";\nimport { useProduct } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { ProductImageGallery } from \"@cimplify/sdk/react\";\nimport { ProductCustomizer } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface ProductSheetClassNames {\n root?: string;\n image?: string;\n header?: string;\n name?: string;\n price?: string;\n description?: string;\n customizer?: string;\n loading?: string;\n}\n\nexport interface ProductSheetProps {\n /** A slim Product (triggers lazy-fetch) or a fully-loaded ProductWithDetails. */\n product: Product | ProductWithDetails;\n /** Called when the sheet should close. */\n onClose?: () => void;\n /** Override the default 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: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n className?: string;\n classNames?: ProductSheetClassNames;\n}\n\nfunction isProductWithDetails(\n product: Product | ProductWithDetails,\n): product is ProductWithDetails {\n return \"variants\" in product;\n}\n\n/**\n * ProductSheet — full product detail view composing gallery, header, and customizer.\n *\n * When given a slim `Product`, it lazy-fetches the full details via `useProduct`.\n * When given a `ProductWithDetails`, it skips the fetch entirely.\n */\nexport function ProductSheet({\n product,\n onClose,\n onAddToCart,\n renderImage,\n className,\n classNames,\n}: ProductSheetProps): React.ReactElement {\n const needsFetch = !isProductWithDetails(product);\n const { product: fetched, isLoading } = useProduct(\n product.slug ?? product.id,\n { enabled: needsFetch },\n );\n const fullProduct = needsFetch ? fetched : (product as ProductWithDetails);\n\n // Loading state\n if (isLoading && !fullProduct) {\n return (\n <div\n data-cimplify-product-sheet\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div\n data-cimplify-product-sheet-skeleton\n style={{\n display: \"flex\",\n flexDirection: \"column\",\n gap: \"1rem\",\n }}\n >\n <div\n style={{\n aspectRatio: \"4/3\",\n backgroundColor: \"rgba(0,0,0,0.06)\",\n borderRadius: \"0.5rem\",\n }}\n />\n <div\n style={{\n height: \"1.5rem\",\n width: \"60%\",\n backgroundColor: \"rgba(0,0,0,0.06)\",\n borderRadius: \"0.25rem\",\n }}\n />\n <div\n style={{\n height: \"1rem\",\n width: \"30%\",\n backgroundColor: \"rgba(0,0,0,0.06)\",\n borderRadius: \"0.25rem\",\n }}\n />\n </div>\n </div>\n );\n }\n\n // Error state\n if (!fullProduct) {\n return (\n <div\n data-cimplify-product-sheet\n className={cn(className, classNames?.root)}\n >\n <p>Product not found.</p>\n </div>\n );\n }\n\n const [selectedVariant, setSelectedVariant] = useState<VariantView | undefined>(\n undefined,\n );\n\n const variantImages = selectedVariant?.images?.filter(Boolean) ?? [];\n const productImages: string[] = [];\n if (fullProduct.images && fullProduct.images.length > 0) {\n productImages.push(...fullProduct.images.filter(Boolean));\n } else if (fullProduct.image_url) {\n productImages.push(fullProduct.image_url);\n }\n const images: string[] =\n variantImages.length > 0 ? variantImages : productImages;\n\n const hasMultipleImages = images.length > 1;\n const singleImage = images[0];\n\n return (\n <div\n data-cimplify-product-sheet\n className={cn(className, classNames?.root)}\n style={{ display: \"flex\", flexDirection: \"column\", gap: \"1rem\" }}\n >\n {/* Image area */}\n {hasMultipleImages ? (\n <ProductImageGallery\n images={images}\n productName={fullProduct.name}\n className={classNames?.image}\n />\n ) : singleImage ? (\n <div data-cimplify-product-sheet-image className={classNames?.image}>\n {renderImage ? (\n renderImage({ src: singleImage, alt: fullProduct.name })\n ) : (\n <img\n src={singleImage}\n alt={fullProduct.name}\n style={{ width: \"100%\", height: \"auto\", objectFit: \"cover\" }}\n />\n )}\n </div>\n ) : null}\n\n {/* Header */}\n <div data-cimplify-product-sheet-header className={classNames?.header}>\n <h2\n data-cimplify-product-sheet-name\n className={classNames?.name}\n style={{ margin: 0 }}\n >\n {fullProduct.name}\n </h2>\n <Price\n amount={fullProduct.default_price}\n className={classNames?.price}\n />\n </div>\n\n {/* Description — merchant-authored HTML from the catalogue. */}\n {fullProduct.description && (\n <div\n data-cimplify-product-sheet-description\n className={cn(\"text-sm leading-relaxed text-muted-foreground [&_p]:m-0 [&_p+p]:mt-2\", classNames?.description)}\n dangerouslySetInnerHTML={{ __html: fullProduct.description }}\n />\n )}\n\n {/* Customizer */}\n <ProductCustomizer\n product={fullProduct}\n onAddToCart={onAddToCart}\n onVariantChange={(_id, variant) => setSelectedVariant(variant)}\n className={classNames?.customizer}\n />\n </div>\n );\n}\n"
16
+ "content": "\"use client\";\n\nimport React, { useState } from \"react\";\nimport type { Product, ProductWithDetails, VariantView } from \"@cimplify/sdk\";\nimport type { AddToCartOptions } from \"@cimplify/sdk/react\";\nimport { useProduct } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { ProductImageGallery } from \"@cimplify/sdk/react\";\nimport { ProductCustomizer } from \"@cimplify/sdk/react\";\nimport { RichText } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface ProductSheetClassNames {\n root?: string;\n image?: string;\n header?: string;\n name?: string;\n price?: string;\n description?: string;\n customizer?: string;\n loading?: string;\n}\n\nexport interface ProductSheetProps {\n /** A slim Product (triggers lazy-fetch) or a fully-loaded ProductWithDetails. */\n product: Product | ProductWithDetails;\n /** Called when the sheet should close. */\n onClose?: () => void;\n /** Override the default 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: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n className?: string;\n classNames?: ProductSheetClassNames;\n}\n\nfunction isProductWithDetails(\n product: Product | ProductWithDetails,\n): product is ProductWithDetails {\n return \"variants\" in product;\n}\n\n/**\n * ProductSheet — full product detail view composing gallery, header, and customizer.\n *\n * When given a slim `Product`, it lazy-fetches the full details via `useProduct`.\n * When given a `ProductWithDetails`, it skips the fetch entirely.\n */\nexport function ProductSheet({\n product,\n onClose,\n onAddToCart,\n renderImage,\n className,\n classNames,\n}: ProductSheetProps): React.ReactElement {\n const needsFetch = !isProductWithDetails(product);\n const { product: fetched, isLoading } = useProduct(\n product.slug ?? product.id,\n { enabled: needsFetch },\n );\n const fullProduct = needsFetch ? fetched : (product as ProductWithDetails);\n\n // Loading state\n if (isLoading && !fullProduct) {\n return (\n <div\n data-cimplify-product-sheet\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div\n data-cimplify-product-sheet-skeleton\n style={{\n display: \"flex\",\n flexDirection: \"column\",\n gap: \"1rem\",\n }}\n >\n <div\n style={{\n aspectRatio: \"4/3\",\n backgroundColor: \"rgba(0,0,0,0.06)\",\n borderRadius: \"0.5rem\",\n }}\n />\n <div\n style={{\n height: \"1.5rem\",\n width: \"60%\",\n backgroundColor: \"rgba(0,0,0,0.06)\",\n borderRadius: \"0.25rem\",\n }}\n />\n <div\n style={{\n height: \"1rem\",\n width: \"30%\",\n backgroundColor: \"rgba(0,0,0,0.06)\",\n borderRadius: \"0.25rem\",\n }}\n />\n </div>\n </div>\n );\n }\n\n // Error state\n if (!fullProduct) {\n return (\n <div\n data-cimplify-product-sheet\n className={cn(className, classNames?.root)}\n >\n <p>Product not found.</p>\n </div>\n );\n }\n\n const [selectedVariant, setSelectedVariant] = useState<VariantView | undefined>(\n undefined,\n );\n\n const variantImages = selectedVariant?.images?.filter(Boolean) ?? [];\n const productImages: string[] = [];\n if (fullProduct.images && fullProduct.images.length > 0) {\n productImages.push(...fullProduct.images.filter(Boolean));\n } else if (fullProduct.image_url) {\n productImages.push(fullProduct.image_url);\n }\n const images: string[] =\n variantImages.length > 0 ? variantImages : productImages;\n\n const hasMultipleImages = images.length > 1;\n const singleImage = images[0];\n\n return (\n <div\n data-cimplify-product-sheet\n className={cn(className, classNames?.root)}\n style={{ display: \"flex\", flexDirection: \"column\", gap: \"1rem\" }}\n >\n {/* Image area */}\n {hasMultipleImages ? (\n <ProductImageGallery\n images={images}\n productName={fullProduct.name}\n className={classNames?.image}\n />\n ) : singleImage ? (\n <div data-cimplify-product-sheet-image className={classNames?.image}>\n {renderImage ? (\n renderImage({ src: singleImage, alt: fullProduct.name })\n ) : (\n <img\n src={singleImage}\n alt={fullProduct.name}\n style={{ width: \"100%\", height: \"auto\", objectFit: \"cover\" }}\n />\n )}\n </div>\n ) : null}\n\n {/* Header */}\n <div data-cimplify-product-sheet-header className={classNames?.header}>\n <h2\n data-cimplify-product-sheet-name\n className={classNames?.name}\n style={{ margin: 0 }}\n >\n {fullProduct.name}\n </h2>\n <Price\n amount={fullProduct.default_price}\n className={classNames?.price}\n />\n </div>\n\n {/* Description — merchant-authored HTML from the catalogue. */}\n <RichText\n html={fullProduct.description}\n data-cimplify-product-sheet-description\n className={cn(\"text-sm\", classNames?.description)}\n />\n\n {/* Customizer */}\n <ProductCustomizer\n product={fullProduct}\n onAddToCart={onAddToCart}\n onVariantChange={(_id, variant) => setSelectedVariant(variant)}\n className={classNames?.customizer}\n />\n </div>\n );\n}\n"
16
17
  }
17
18
  ]
18
19
  }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "rich-text",
3
+ "title": "RichText",
4
+ "description": "Renders sanitized merchant-authored HTML (descriptions) with theme-aware prose styling. Edge-safe XSS sanitization via the pure-JS xss library.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "cn"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "rich-text.tsx",
12
+ "content": "\"use client\";\n\nimport React, { useMemo } from \"react\";\nimport { FilterXSS, safeAttrValue as baseSafeAttrValue } from \"xss\";\nimport { cn } from \"@cimplify/sdk/react\";\n\n// Hosts allowed for <iframe> embeds. Everything else has its src blanked.\nconst ALLOWED_EMBED_HOSTS = new Set<string>([\n \"www.youtube.com\",\n \"youtube.com\",\n \"www.youtube-nocookie.com\",\n \"youtube-nocookie.com\",\n \"player.vimeo.com\",\n]);\n\n// Inline CSS properties we permit (via the xss css filter). Anything else —\n// position, z-index, behaviours, url() exfil vectors — is dropped.\nconst ALLOWED_CSS_PROPERTIES: Record<string, boolean> = {\n \"text-align\": true,\n color: true,\n \"background-color\": true,\n \"font-weight\": true,\n \"font-style\": true,\n \"text-decoration\": true,\n \"vertical-align\": true,\n width: true,\n height: true,\n};\n\nconst STYLEABLE = [\"style\", \"class\"];\n\n/**\n * Allowlist covering Amazon/Shopify-grade product copy: rich text, spec and\n * comparison tables, figures, definition lists, collapsible sections, and\n * host-locked video embeds. Tags outside this set are dropped; `javascript:`\n * URLs, event handlers, and unlisted CSS properties are stripped.\n */\nconst RICH_TEXT_WHITELIST: Record<string, string[]> = {\n p: STYLEABLE,\n br: [],\n div: STYLEABLE,\n section: [\"class\"],\n span: STYLEABLE,\n strong: [],\n b: [],\n em: [],\n i: [],\n u: [],\n s: [],\n strike: [],\n del: [],\n ins: [],\n mark: [],\n small: [],\n sub: [],\n sup: [],\n h1: STYLEABLE,\n h2: STYLEABLE,\n h3: STYLEABLE,\n h4: STYLEABLE,\n h5: STYLEABLE,\n h6: STYLEABLE,\n ul: [\"class\"],\n ol: [\"start\", \"type\", \"class\"],\n li: [\"class\"],\n dl: [\"class\"],\n dt: [\"class\"],\n dd: [\"class\"],\n blockquote: [\"class\"],\n code: [],\n pre: [\"class\"],\n hr: [],\n a: [\"href\", \"title\", \"target\", \"rel\"],\n img: [\"src\", \"alt\", \"title\", \"width\", \"height\", \"loading\", \"class\", \"style\"],\n figure: [\"class\"],\n figcaption: [\"class\"],\n details: [],\n summary: [],\n table: [\"class\", \"style\"],\n thead: [],\n tbody: [],\n tfoot: [],\n caption: [],\n colgroup: [],\n col: [\"span\", \"style\"],\n tr: [\"class\"],\n th: [\"colspan\", \"rowspan\", \"scope\", \"align\", \"class\", \"style\"],\n td: [\"colspan\", \"rowspan\", \"align\", \"class\", \"style\"],\n iframe: [\"src\", \"width\", \"height\", \"allow\", \"allowfullscreen\", \"frameborder\", \"title\", \"loading\"],\n};\n\nfunction safeAttrValue(\n tag: string,\n name: string,\n value: string,\n cssFilter: Parameters<typeof baseSafeAttrValue>[3],\n): string {\n if (tag === \"iframe\" && name === \"src\") {\n try {\n const url = new URL(value);\n if (url.protocol === \"https:\" && ALLOWED_EMBED_HOSTS.has(url.hostname)) {\n return value;\n }\n } catch {\n // malformed URL — fall through to blank\n }\n return \"\";\n }\n return baseSafeAttrValue(tag, name, value, cssFilter);\n}\n\n// A single configured filter instance, reused across every call.\nconst FILTER = new FilterXSS({\n whiteList: RICH_TEXT_WHITELIST,\n css: { whiteList: ALLOWED_CSS_PROPERTIES },\n safeAttrValue,\n stripIgnoreTag: true,\n stripIgnoreTagBody: [\"script\", \"style\"],\n allowCommentTag: false,\n});\n\n/**\n * Sanitize merchant-authored HTML for safe rendering. Runs identically on the\n * Cloudflare Workers edge (SSR) and in the browser — no DOM dependency.\n */\nexport function sanitizeRichTextHtml(html: string): string {\n return FILTER.process(html);\n}\n\n// Theme-aware prose styling. Wired to the consumer's own design tokens\n// (`foreground`, `muted-foreground`, `primary`, `border`, `muted`) so rich\n// text inherits each storefront's type scale and colours.\nconst RICH_TEXT_PROSE = cn(\n \"text-muted-foreground leading-relaxed\",\n \"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0\",\n \"[&_p]:my-3\",\n \"[&_h1]:mt-6 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:leading-snug [&_h1]:text-foreground\",\n \"[&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:leading-snug [&_h2]:text-foreground\",\n \"[&_h3]:mt-4 [&_h3]:mb-1.5 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-foreground\",\n \"[&_h4]:mt-4 [&_h4]:mb-1.5 [&_h4]:text-sm [&_h4]:font-semibold [&_h4]:text-foreground\",\n \"[&_h5]:mt-3 [&_h5]:mb-1 [&_h5]:font-semibold [&_h5]:text-foreground\",\n \"[&_h6]:mt-3 [&_h6]:mb-1 [&_h6]:font-semibold [&_h6]:text-foreground\",\n \"[&_strong]:font-semibold [&_strong]:text-foreground [&_b]:font-semibold [&_b]:text-foreground\",\n \"[&_em]:italic [&_i]:italic\",\n \"[&_u]:underline [&_ins]:underline [&_s]:line-through [&_del]:line-through\",\n \"[&_mark]:rounded [&_mark]:px-0.5 [&_sub]:align-sub [&_sub]:text-[0.75em] [&_sup]:align-super [&_sup]:text-[0.75em]\",\n \"[&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2\",\n \"[&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:space-y-1\",\n \"[&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:space-y-1\",\n \"[&_li]:pl-1 [&_li>p]:my-0 [&_ul_ul]:my-1 [&_ol_ol]:my-1 [&_ul_ul]:list-[circle]\",\n \"[&_dl]:my-3 [&_dt]:font-semibold [&_dt]:text-foreground [&_dd]:mb-2 [&_dd]:pl-4\",\n \"[&_blockquote]:my-3 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:italic\",\n \"[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em]\",\n \"[&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-[0.85em]\",\n \"[&_pre>code]:bg-transparent [&_pre>code]:p-0\",\n \"[&_hr]:my-4 [&_hr]:border-border\",\n \"[&_figure]:my-4 [&_figcaption]:mt-2 [&_figcaption]:text-center [&_figcaption]:text-xs [&_figcaption]:text-muted-foreground\",\n \"[&_img]:my-3 [&_img]:h-auto [&_img]:max-w-full [&_img]:rounded-md\",\n \"[&_details]:my-3 [&_details]:rounded-md [&_details]:border [&_details]:border-border [&_details]:p-3\",\n \"[&_summary]:cursor-pointer [&_summary]:font-medium [&_summary]:text-foreground\",\n \"[&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_table]:text-sm\",\n \"[&_caption]:caption-bottom [&_caption]:mt-2 [&_caption]:text-xs [&_caption]:text-muted-foreground\",\n \"[&_th]:border [&_th]:border-border [&_th]:bg-muted [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold [&_th]:text-foreground\",\n \"[&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2 [&_td]:align-top\",\n \"[&_iframe]:my-4 [&_iframe]:aspect-video [&_iframe]:w-full [&_iframe]:rounded-md\",\n);\n\nexport interface RichTextProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"dangerouslySetInnerHTML\" | \"children\"> {\n /** Merchant-authored HTML (e.g. a product description). Sanitized before render. */\n html?: string | null;\n}\n\n/**\n * RichText — renders sanitized merchant-authored HTML with theme-aware prose\n * styling. Headings, lists, tables, figures, quotes, code, collapsible\n * sections and host-locked embeds all display correctly; scripts, event\n * handlers and unlisted CSS are stripped.\n *\n * Returns `null` when there's no renderable content.\n */\nexport function RichText({ html, className, ...rest }: RichTextProps): React.ReactElement | null {\n const clean = useMemo(() => (html ? sanitizeRichTextHtml(html) : \"\"), [html]);\n\n if (!clean) {\n return null;\n }\n\n return (\n <div\n data-cimplify-rich-text\n className={cn(RICH_TEXT_PROSE, className)}\n dangerouslySetInnerHTML={{ __html: clean }}\n {...rest}\n />\n );\n}\n"
13
+ }
14
+ ]
15
+ }
@@ -7,12 +7,13 @@
7
7
  "product-image-gallery",
8
8
  "product-customizer",
9
9
  "price",
10
+ "rich-text",
10
11
  "cn"
11
12
  ],
12
13
  "files": [
13
14
  {
14
15
  "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\";\nimport { formatDuration } from \"../utils/format-duration\";\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 {formatDuration(product.duration_minutes, product.duration_unit)}\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 booking\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
+ "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 { RichText } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\nimport { formatDuration } from \"../utils/format-duration\";\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 {formatDuration(product.duration_minutes, product.duration_unit)}\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 <RichText html={product.description} data-cimplify-product-layout-description />\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 booking\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
  }
17
18
  ]
18
19
  }
@@ -9,12 +9,13 @@
9
9
  "price",
10
10
  "price-range",
11
11
  "volume-pricing",
12
+ "rich-text",
12
13
  "cn"
13
14
  ],
14
15
  "files": [
15
16
  {
16
17
  "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
+ "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 { RichText } 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 <RichText html={product.description} data-cimplify-product-layout-description />\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
  }
19
20
  ]
20
21
  }