@cimplify/sdk 0.44.12 → 0.44.16

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.
Files changed (99) hide show
  1. package/dist/advanced.d.mts +1 -1
  2. package/dist/advanced.d.ts +1 -1
  3. package/dist/advanced.js +21 -21
  4. package/dist/advanced.mjs +2 -2
  5. package/dist/{chunk-6TVQQYQ5.mjs → chunk-6AZZ6AOF.mjs} +21 -1
  6. package/dist/{chunk-EZK2FXTM.mjs → chunk-7NML226B.mjs} +1 -1
  7. package/dist/{chunk-BAWKQRIB.js → chunk-CFD4J32N.js} +21 -21
  8. package/dist/{chunk-OMAP327U.js → chunk-MBGQP6FZ.js} +62 -61
  9. package/dist/{chunk-V2O5AO7I.js → chunk-MNIKXG7W.js} +26 -0
  10. package/dist/{chunk-3WD6U5RD.mjs → chunk-UXLQ7WXI.mjs} +4 -3
  11. package/dist/chunk-XL4CX75J.js +9370 -0
  12. package/dist/chunk-ZLEFMQC6.mjs +9318 -0
  13. package/dist/cli/commands/deploy.mjs +9 -1
  14. package/dist/{client-lvLvB1_K.d.ts → client-D6ZisKco.d.ts} +7 -0
  15. package/dist/{client-CkGlXZRd.d.mts → client-Dh-bHyhU.d.mts} +7 -0
  16. package/dist/index.d.mts +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +106 -106
  19. package/dist/index.mjs +3 -3
  20. package/dist/mock/cli.mjs +172 -23
  21. package/dist/mock/library.d.mts +2 -2
  22. package/dist/mock/library.d.ts +2 -2
  23. package/dist/mock/library.js +172 -23
  24. package/dist/mock/library.mjs +172 -23
  25. package/dist/mock/msw.d.mts +1 -1
  26. package/dist/mock/msw.d.ts +1 -1
  27. package/dist/mock/msw.js +172 -23
  28. package/dist/mock/msw.mjs +172 -23
  29. package/dist/react.d.mts +1 -1
  30. package/dist/react.d.ts +1 -1
  31. package/dist/react.js +80 -80
  32. package/dist/react.mjs +3 -3
  33. package/dist/server-CWpUlLOQ.d.mts +1003 -0
  34. package/dist/server-CWpUlLOQ.d.ts +1003 -0
  35. package/dist/{server-CUbuQxOV.d.ts → server-CnECAQDQ.d.mts} +28 -6
  36. package/dist/{server-CUbuQxOV.d.mts → server-CnECAQDQ.d.ts} +28 -6
  37. package/dist/server.d.mts +2 -2
  38. package/dist/server.d.ts +2 -2
  39. package/dist/server.js +5 -5
  40. package/dist/server.mjs +3 -3
  41. package/dist/testing/suite.d.mts +60 -0
  42. package/dist/testing/suite.d.ts +60 -0
  43. package/dist/testing/suite.js +169 -0
  44. package/dist/testing/suite.mjs +165 -0
  45. package/dist/testing.d.mts +1359 -0
  46. package/dist/testing.d.ts +1359 -0
  47. package/dist/testing.js +213 -0
  48. package/dist/testing.mjs +4 -0
  49. package/dist/utils.js +28 -28
  50. package/dist/utils.mjs +1 -1
  51. package/package.json +18 -3
  52. package/registry/account.json +15 -0
  53. package/registry/billing-plan-selector.json +16 -0
  54. package/registry/cart-drawer.json +17 -0
  55. package/registry/currency-selector.json +15 -0
  56. package/registry/customer-input-fields.json +15 -0
  57. package/registry/delivery-estimate.json +16 -0
  58. package/registry/index.json +105 -0
  59. package/registry/location-picker.json +15 -0
  60. package/registry/recently-viewed.json +16 -0
  61. package/registry/recommendation-carousel.json +16 -0
  62. package/registry/resource-picker.json +15 -0
  63. package/registry/session-message-banner.json +15 -0
  64. package/templates/storefront-bakery/AGENTS.md +12 -0
  65. package/templates/storefront-bakery/__tests__/brand.test.ts +4 -0
  66. package/templates/storefront-bakery/__tests__/cart-flow.test.ts +4 -0
  67. package/templates/storefront-bakery/__tests__/contract.test.ts +4 -0
  68. package/templates/storefront-bakery/package.json +11 -3
  69. package/templates/storefront-bakery/vitest.config.ts +9 -0
  70. package/templates/storefront-fashion/AGENTS.md +21 -1
  71. package/templates/storefront-fashion/__tests__/brand.test.ts +4 -0
  72. package/templates/storefront-fashion/__tests__/cart-flow.test.ts +4 -0
  73. package/templates/storefront-fashion/__tests__/contract.test.ts +4 -0
  74. package/templates/storefront-fashion/package.json +11 -3
  75. package/templates/storefront-fashion/vitest.config.ts +9 -0
  76. package/templates/storefront-grocery/AGENTS.md +12 -0
  77. package/templates/storefront-grocery/__tests__/brand.test.ts +4 -0
  78. package/templates/storefront-grocery/__tests__/cart-flow.test.ts +4 -0
  79. package/templates/storefront-grocery/__tests__/contract.test.ts +4 -0
  80. package/templates/storefront-grocery/package.json +11 -3
  81. package/templates/storefront-grocery/vitest.config.ts +9 -0
  82. package/templates/storefront-restaurant/AGENTS.md +12 -0
  83. package/templates/storefront-restaurant/__tests__/brand.test.ts +4 -0
  84. package/templates/storefront-restaurant/__tests__/cart-flow.test.ts +4 -0
  85. package/templates/storefront-restaurant/__tests__/contract.test.ts +4 -0
  86. package/templates/storefront-restaurant/package.json +11 -3
  87. package/templates/storefront-restaurant/vitest.config.ts +9 -0
  88. package/templates/storefront-retail/AGENTS.md +12 -0
  89. package/templates/storefront-retail/__tests__/brand.test.ts +4 -0
  90. package/templates/storefront-retail/__tests__/cart-flow.test.ts +4 -0
  91. package/templates/storefront-retail/__tests__/contract.test.ts +4 -0
  92. package/templates/storefront-retail/package.json +11 -3
  93. package/templates/storefront-retail/vitest.config.ts +9 -0
  94. package/templates/storefront-services/AGENTS.md +12 -0
  95. package/templates/storefront-services/__tests__/brand.test.ts +4 -0
  96. package/templates/storefront-services/__tests__/cart-flow.test.ts +4 -0
  97. package/templates/storefront-services/__tests__/contract.test.ts +4 -0
  98. package/templates/storefront-services/package.json +11 -3
  99. package/templates/storefront-services/vitest.config.ts +9 -0
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "customer-input-fields",
3
+ "title": "CustomerInputFields",
4
+ "description": "Per-product custom input fields — text, number, date, time, file upload, image upload, single/multi-select.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "cn"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "customer-input-fields.tsx",
12
+ "content": "\"use client\";\n\nimport React, { useCallback } from \"react\";\nimport { INPUT_FIELD_TYPE, type AddressValue, type DateRangeValue, type LocationValue, type PhoneValue, type ProductInputField, type SignatureValue } from \"@cimplify/sdk\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { useOptionalCimplifyClient } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface CustomerInputFieldsClassNames {\n root?: string;\n field?: string;\n label?: string;\n required?: string;\n helpText?: string;\n input?: string;\n select?: string;\n textarea?: string;\n fileInput?: string;\n colorInput?: string;\n radioGroup?: string;\n radioOption?: string;\n checkboxLabel?: string;\n priceAdjustment?: string;\n addressInput?: string;\n phoneInput?: string;\n signatureCanvas?: string;\n multiSelectGroup?: string;\n multiSelectOption?: string;\n dateRangeInput?: string;\n locationInput?: string;\n}\n\nexport interface CustomerInputFieldsProps {\n fields: ProductInputField[];\n values: Record<string, unknown>;\n onChange: (values: Record<string, unknown>) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n className?: string;\n classNames?: CustomerInputFieldsClassNames;\n}\n\nexport function CustomerInputFields({\n fields,\n values,\n onChange,\n onFileUpload,\n className,\n classNames,\n}: CustomerInputFieldsProps): React.ReactElement | null {\n const clientContext = useOptionalCimplifyClient();\n\n if (fields.length === 0) return null;\n\n const sorted = [...fields].sort((a, b) => a.display_order - b.display_order);\n\n const setValue = useCallback(\n (fieldId: string, value: unknown) => {\n onChange({ ...values, [fieldId]: value });\n },\n [values, onChange],\n );\n\n const defaultFileUpload = useCallback(\n async (file: File): Promise<string> => {\n if (onFileUpload) return onFileUpload(file, {} as ProductInputField);\n if (!clientContext?.client) throw new Error(\"No upload provider available\");\n const result = await clientContext.client.uploads.upload(file);\n if (!result.ok) throw new Error(result.error.message);\n return result.value.url;\n },\n [onFileUpload, clientContext],\n );\n\n return (\n <div data-cimplify-customer-inputs className={cn(\"space-y-4\", className, classNames?.root)}>\n {sorted.map((field) => (\n <InputField\n key={field.id}\n field={field}\n value={values[field.id]}\n onValueChange={(value) => setValue(field.id, value)}\n onFileUpload={onFileUpload ?? (clientContext?.client ? (_file, _field) => defaultFileUpload(_file) : undefined)}\n classNames={classNames}\n />\n ))}\n </div>\n );\n}\n\nfunction InputField({\n field,\n value,\n onValueChange,\n onFileUpload,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const hasPriceAdjustment = field.price_adjustment != null && parsePrice(field.price_adjustment) > 0;\n\n return (\n <div data-cimplify-customer-input data-field-type={field.field_type} className={classNames?.field}>\n <div className=\"flex items-center gap-2 mb-1.5\">\n <label className={cn(\"text-sm font-semibold\", classNames?.label)}>\n {field.name}\n </label>\n {field.is_required && (\n <span className={cn(\"text-xs text-destructive\", classNames?.required)}>Required</span>\n )}\n {hasPriceAdjustment && (\n <span className={cn(\"text-xs text-muted-foreground\", classNames?.priceAdjustment)}>\n +<Price amount={field.price_adjustment!} />\n </span>\n )}\n </div>\n\n {field.help_text && (\n <p className={cn(\"text-xs text-muted-foreground mb-1.5\", classNames?.helpText)}>\n {field.help_text}\n </p>\n )}\n\n <FieldInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n onFileUpload={onFileUpload}\n classNames={classNames}\n />\n </div>\n );\n}\n\nfunction FieldInput({\n field,\n value,\n onValueChange,\n onFileUpload,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const inputClass = 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\",\n classNames?.input,\n );\n\n switch (field.field_type) {\n case INPUT_FIELD_TYPE.Text:\n case INPUT_FIELD_TYPE.Url:\n return (\n <input\n type={field.field_type === INPUT_FIELD_TYPE.Url ? \"url\" : \"text\"}\n value={typeof value === \"string\" ? value : \"\"}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder}\n required={field.is_required}\n maxLength={field.validation?.max_length ?? undefined}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Textarea:\n return (\n <textarea\n value={typeof value === \"string\" ? value : \"\"}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder}\n required={field.is_required}\n maxLength={field.validation?.max_length ?? undefined}\n rows={3}\n className={cn(inputClass, \"resize-none\", classNames?.textarea)}\n />\n );\n\n case INPUT_FIELD_TYPE.Number:\n return (\n <input\n type=\"number\"\n value={typeof value === \"number\" ? value : \"\"}\n onChange={(e) => onValueChange(e.target.value ? parseFloat(e.target.value) : undefined)}\n placeholder={field.placeholder}\n required={field.is_required}\n min={field.validation?.min_value ?? undefined}\n max={field.validation?.max_value ?? undefined}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Select:\n return (\n <select\n value={typeof value === \"string\" ? value : \"\"}\n onChange={(e) => onValueChange(e.target.value || undefined)}\n required={field.is_required}\n className={cn(inputClass, classNames?.select)}\n >\n <option value=\"\">{field.placeholder || \"Select...\"}</option>\n {(field.options || []).map((opt) => (\n <option key={opt} value={opt}>{opt}</option>\n ))}\n </select>\n );\n\n case INPUT_FIELD_TYPE.Radio:\n return (\n <div className={cn(\"space-y-2\", classNames?.radioGroup)}>\n {(field.options || []).map((opt) => (\n <label\n key={opt}\n className={cn(\n \"flex items-center gap-2 text-sm cursor-pointer\",\n classNames?.radioOption,\n )}\n >\n <input\n type=\"radio\"\n name={field.id}\n value={opt}\n checked={value === opt}\n onChange={() => onValueChange(opt)}\n className=\"accent-primary\"\n />\n {opt}\n </label>\n ))}\n </div>\n );\n\n case INPUT_FIELD_TYPE.Checkbox:\n return (\n <label className={cn(\"flex items-center gap-2 text-sm cursor-pointer\", classNames?.checkboxLabel)}>\n <input\n type=\"checkbox\"\n checked={value === true}\n onChange={(e) => onValueChange(e.target.checked)}\n className=\"accent-primary w-4 h-4\"\n />\n {field.placeholder || field.name}\n </label>\n );\n\n case INPUT_FIELD_TYPE.Color:\n return (\n <input\n type=\"color\"\n value={typeof value === \"string\" ? value : \"#000000\"}\n onChange={(e) => onValueChange(e.target.value)}\n className={cn(\"w-12 h-10 rounded-md border border-input cursor-pointer\", classNames?.colorInput)}\n />\n );\n\n case INPUT_FIELD_TYPE.Date:\n return (\n <input\n type=\"date\"\n value={typeof value === \"string\" ? value : \"\"}\n onChange={(e) => onValueChange(e.target.value || undefined)}\n required={field.is_required}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.File:\n case INPUT_FIELD_TYPE.Image:\n return (\n <FileUploadInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n onFileUpload={onFileUpload}\n classNames={classNames}\n />\n );\n\n case INPUT_FIELD_TYPE.Email:\n return (\n <input\n type=\"email\"\n value={typeof value === \"string\" ? value : \"\"}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder || \"email@example.com\"}\n required={field.is_required}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.DateTime: {\n const dtLocalValue = typeof value === \"string\" && value.includes(\"T\")\n ? value.slice(0, 16)\n : typeof value === \"string\" ? value : \"\";\n return (\n <input\n type=\"datetime-local\"\n value={dtLocalValue}\n onChange={(e) => {\n if (!e.target.value) { onValueChange(undefined); return; }\n const date = new Date(e.target.value);\n onValueChange(Number.isNaN(date.getTime()) ? e.target.value : date.toISOString());\n }}\n required={field.is_required}\n className={inputClass}\n />\n );\n }\n\n case INPUT_FIELD_TYPE.Time:\n return (\n <input\n type=\"time\"\n value={typeof value === \"string\" ? value : \"\"}\n onChange={(e) => onValueChange(e.target.value || undefined)}\n required={field.is_required}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.MultiSelect:\n return (\n <MultiSelectInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n />\n );\n\n case INPUT_FIELD_TYPE.DateRange:\n return (\n <DateRangeInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n inputClass={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Address:\n return (\n <AddressInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n inputClass={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Location:\n return (\n <LocationInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n inputClass={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Phone:\n return (\n <PhoneInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n inputClass={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Signature:\n return (\n <SignatureInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n />\n );\n\n default:\n return (\n <input\n type=\"text\"\n value={typeof value === \"string\" ? value : \"\"}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder}\n className={inputClass}\n />\n );\n }\n}\n\nfunction FileUploadInput({\n field,\n value,\n onValueChange,\n onFileUpload,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const [isUploading, setIsUploading] = React.useState(false);\n const fileUrl = typeof value === \"string\" ? value : undefined;\n const acceptedFormats = field.validation?.accepted_formats;\n const accept = acceptedFormats\n ? acceptedFormats.map((f) => `.${f}`).join(\",\")\n : field.field_type === INPUT_FIELD_TYPE.Image\n ? \"image/*\"\n : undefined;\n\n const handleFile = async (file: File) => {\n if (!onFileUpload) return;\n\n if (field.validation?.max_size_mb) {\n const maxBytes = field.validation.max_size_mb * 1024 * 1024;\n if (file.size > maxBytes) {\n return;\n }\n }\n\n setIsUploading(true);\n try {\n const url = await onFileUpload(file, field);\n onValueChange(url);\n } finally {\n setIsUploading(false);\n }\n };\n\n return (\n <div className={cn(\"space-y-2\", classNames?.fileInput)}>\n {fileUrl ? (\n <div className=\"flex items-center gap-3\">\n {field.field_type === INPUT_FIELD_TYPE.Image && (\n <img src={fileUrl} alt=\"Uploaded\" className=\"w-16 h-16 object-cover rounded-md border border-border\" />\n )}\n <div className=\"flex-1 min-w-0\">\n <p className=\"text-sm text-foreground truncate\">{fileUrl.split(\"/\").pop()}</p>\n </div>\n <button\n type=\"button\"\n onClick={() => onValueChange(undefined)}\n className=\"text-xs text-muted-foreground hover:text-destructive transition-colors\"\n >\n Remove\n </button>\n </div>\n ) : (\n <label className=\"flex flex-col items-center justify-center w-full h-24 border-2 border-dashed border-border rounded-lg cursor-pointer hover:border-primary/40 transition-colors\">\n <input\n type=\"file\"\n accept={accept}\n onChange={(e) => {\n const file = e.target.files?.[0];\n if (file) void handleFile(file);\n }}\n className=\"hidden\"\n disabled={isUploading}\n />\n {isUploading ? (\n <span className=\"text-sm text-muted-foreground\">Uploading...</span>\n ) : (\n <>\n <span className=\"text-sm text-muted-foreground\">\n {field.placeholder || `Upload ${field.field_type === INPUT_FIELD_TYPE.Image ? \"image\" : \"file\"}`}\n </span>\n {acceptedFormats && (\n <span className=\"text-xs text-muted-foreground/60 mt-1\">\n {acceptedFormats.map((f) => f.toUpperCase()).join(\", \")}\n {field.validation?.max_size_mb && ` · Max ${field.validation.max_size_mb}MB`}\n </span>\n )}\n </>\n )}\n </label>\n )}\n </div>\n );\n}\n\nfunction MultiSelectInput({\n field,\n value,\n onValueChange,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const selected = Array.isArray(value) ? (value as string[]) : [];\n const options = field.options || [];\n const maxSelections = field.validation?.max_selections;\n\n const toggle = (opt: string) => {\n const next = selected.includes(opt)\n ? selected.filter((s) => s !== opt)\n : maxSelections && selected.length >= maxSelections\n ? selected\n : [...selected, opt];\n onValueChange(next.length > 0 ? next : undefined);\n };\n\n return (\n <div className={cn(\"space-y-2\", classNames?.multiSelectGroup)}>\n {options.map((opt) => (\n <label\n key={opt}\n className={cn(\n \"flex items-center gap-2 text-sm cursor-pointer\",\n classNames?.multiSelectOption,\n )}\n >\n <input\n type=\"checkbox\"\n checked={selected.includes(opt)}\n onChange={() => toggle(opt)}\n className=\"accent-primary w-4 h-4\"\n />\n {opt}\n </label>\n ))}\n {maxSelections && (\n <p className=\"text-xs text-muted-foreground\">\n {selected.length}/{maxSelections} selected\n </p>\n )}\n </div>\n );\n}\n\nfunction DateRangeInput({\n field,\n value,\n onValueChange,\n classNames,\n inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\n}): React.ReactElement {\n const range = (value && typeof value === \"object\" ? value : {}) as DateRangeValue;\n\n const update = (key: \"start\" | \"end\", v: string) => {\n const next = { ...range, [key]: v };\n onValueChange(next.start || next.end ? next : undefined);\n };\n\n return (\n <div className={cn(\"grid grid-cols-2 gap-3\", classNames?.dateRangeInput)}>\n <div>\n <label className=\"text-xs text-muted-foreground mb-1 block\">Start</label>\n <input\n type=\"date\"\n value={range.start || \"\"}\n onChange={(e) => update(\"start\", e.target.value)}\n required={field.is_required}\n className={inputClass}\n />\n </div>\n <div>\n <label className=\"text-xs text-muted-foreground mb-1 block\">End</label>\n <input\n type=\"date\"\n value={range.end || \"\"}\n onChange={(e) => update(\"end\", e.target.value)}\n required={field.is_required}\n className={inputClass}\n />\n </div>\n </div>\n );\n}\n\nfunction AddressInput({\n field,\n value,\n onValueChange,\n classNames,\n inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\n}): React.ReactElement {\n const clientContext = useOptionalCimplifyClient();\n const addr = (value && typeof value === \"object\" ? value : null) as AddressValue | null;\n const [inputText, setInputText] = React.useState(addr?.formatted_address ?? \"\");\n const [suggestions, setSuggestions] = React.useState<Array<{ description: string; place_id: string }>>([]);\n const [isOpen, setIsOpen] = React.useState(false);\n const [highlightedIndex, setHighlightedIndex] = React.useState(-1);\n const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n const seqRef = React.useRef(0);\n const sessionTokenRef = React.useRef(createSessionToken());\n const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n\n React.useEffect(() => {\n return () => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n seqRef.current += 1;\n };\n }, []);\n\n const fetchSuggestions = React.useCallback(async (query: string) => {\n const client = clientContext?.client;\n if (!client || query.length < 3) {\n setSuggestions([]);\n setIsOpen(false);\n return;\n }\n\n const seq = ++seqRef.current;\n const result = await client.places.autocomplete(query, sessionTokenRef.current);\n if (seqRef.current !== seq) return;\n\n if (result.ok && result.value.predictions.length > 0) {\n setSuggestions(result.value.predictions);\n setHighlightedIndex(0);\n setIsOpen(true);\n } else {\n setSuggestions([]);\n setIsOpen(false);\n }\n }, [clientContext]);\n\n const handleInputChange = (nextValue: string) => {\n setInputText(nextValue);\n if (debounceRef.current) clearTimeout(debounceRef.current);\n debounceRef.current = setTimeout(() => void fetchSuggestions(nextValue.trim()), 300);\n };\n\n const selectSuggestion = React.useCallback(async (suggestion: { description: string; place_id: string }) => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n\n const fallback = suggestion.description.split(\",\")[0]?.trim() || suggestion.description;\n setInputText(fallback);\n setSuggestions([]);\n setIsOpen(false);\n\n const client = clientContext?.client;\n if (!client) return;\n\n const result = await client.places.details(suggestion.place_id, sessionTokenRef.current);\n sessionTokenRef.current = createSessionToken();\n\n if (result.ok) {\n const d = result.value;\n const addressValue: AddressValue = {\n formatted_address: d.formatted_address || fallback,\n street_address: d.street_address,\n apartment: d.apartment,\n city: d.city,\n region: d.region,\n postal_code: d.postal_code,\n country: d.country,\n latitude: d.latitude,\n longitude: d.longitude,\n place_id: d.place_id,\n };\n setInputText(addressValue.formatted_address);\n onValueChange(addressValue);\n }\n }, [clientContext, onValueChange]);\n\n return (\n <div className={cn(\"space-y-2 relative\", classNames?.addressInput)}>\n <input\n type=\"text\"\n value={inputText}\n onChange={(e) => handleInputChange(e.target.value)}\n onFocus={() => { if (suggestions.length > 0) setIsOpen(true); }}\n onBlur={() => { blurTimeoutRef.current = setTimeout(() => setIsOpen(false), 120); }}\n onKeyDown={(e) => {\n if (e.key === \"Escape\") { setIsOpen(false); return; }\n if (!isOpen || suggestions.length === 0) return;\n if (e.key === \"ArrowDown\") { e.preventDefault(); setHighlightedIndex((i) => i < suggestions.length - 1 ? i + 1 : 0); }\n else if (e.key === \"ArrowUp\") { e.preventDefault(); setHighlightedIndex((i) => i > 0 ? i - 1 : suggestions.length - 1); }\n else if (e.key === \"Enter\" && highlightedIndex >= 0) { e.preventDefault(); void selectSuggestion(suggestions[highlightedIndex]); }\n }}\n placeholder={field.placeholder || \"Search for an address...\"}\n required={field.is_required}\n autoComplete=\"off\"\n aria-autocomplete=\"list\"\n aria-expanded={isOpen && suggestions.length > 0}\n className={inputClass}\n />\n {isOpen && suggestions.length > 0 && (\n <div role=\"listbox\" className=\"absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded-md border border-border bg-background shadow-lg\">\n {suggestions.map((s, i) => (\n <button\n key={s.place_id}\n type=\"button\"\n role=\"option\"\n aria-selected={i === highlightedIndex}\n onMouseDown={(e) => { e.preventDefault(); void selectSuggestion(s); }}\n className={cn(\"block w-full px-3 py-2.5 text-left text-sm transition-colors\", i === highlightedIndex ? \"bg-muted text-foreground\" : \"text-foreground hover:bg-muted/70\")}\n >\n {s.description}\n </button>\n ))}\n </div>\n )}\n {addr && (\n <input\n type=\"text\"\n value={addr.apartment || \"\"}\n onChange={(e) => onValueChange({ ...addr, apartment: e.target.value || undefined })}\n placeholder=\"Apt, suite, unit (optional)\"\n className={inputClass}\n />\n )}\n </div>\n );\n}\n\nfunction LocationInput({\n field,\n value,\n onValueChange,\n classNames,\n inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\n}): React.ReactElement {\n const clientContext = useOptionalCimplifyClient();\n const loc = (value && typeof value === \"object\" ? value : null) as LocationValue | null;\n const [inputText, setInputText] = React.useState(loc?.label ?? \"\");\n const [suggestions, setSuggestions] = React.useState<Array<{ description: string; place_id: string }>>([]);\n const [isOpen, setIsOpen] = React.useState(false);\n const [highlightedIndex, setHighlightedIndex] = React.useState(-1);\n const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n const seqRef = React.useRef(0);\n const sessionTokenRef = React.useRef(createSessionToken());\n const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n\n React.useEffect(() => {\n return () => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n seqRef.current += 1;\n };\n }, []);\n\n const fetchSuggestions = React.useCallback(async (query: string) => {\n const client = clientContext?.client;\n if (!client || query.length < 3) {\n setSuggestions([]);\n setIsOpen(false);\n return;\n }\n\n const seq = ++seqRef.current;\n const result = await client.places.autocomplete(query, sessionTokenRef.current);\n if (seqRef.current !== seq) return;\n\n if (result.ok && result.value.predictions.length > 0) {\n setSuggestions(result.value.predictions);\n setHighlightedIndex(0);\n setIsOpen(true);\n } else {\n setSuggestions([]);\n setIsOpen(false);\n }\n }, [clientContext]);\n\n const handleInputChange = (nextValue: string) => {\n setInputText(nextValue);\n if (debounceRef.current) clearTimeout(debounceRef.current);\n debounceRef.current = setTimeout(() => void fetchSuggestions(nextValue.trim()), 300);\n };\n\n const selectSuggestion = React.useCallback(async (suggestion: { description: string; place_id: string }) => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n\n const label = suggestion.description.split(\",\")[0]?.trim() || suggestion.description;\n setInputText(label);\n setSuggestions([]);\n setIsOpen(false);\n\n const client = clientContext?.client;\n if (!client) return;\n\n const result = await client.places.details(suggestion.place_id, sessionTokenRef.current);\n sessionTokenRef.current = createSessionToken();\n\n if (result.ok) {\n const d = result.value;\n onValueChange({\n latitude: d.latitude,\n longitude: d.longitude,\n label: d.formatted_address || label,\n } satisfies LocationValue);\n setInputText(d.formatted_address || label);\n }\n }, [clientContext, onValueChange]);\n\n return (\n <div className={cn(\"relative\", classNames?.locationInput)}>\n <input\n type=\"text\"\n value={inputText}\n onChange={(e) => handleInputChange(e.target.value)}\n onFocus={() => { if (suggestions.length > 0) setIsOpen(true); }}\n onBlur={() => { blurTimeoutRef.current = setTimeout(() => setIsOpen(false), 120); }}\n onKeyDown={(e) => {\n if (e.key === \"Escape\") { setIsOpen(false); return; }\n if (!isOpen || suggestions.length === 0) return;\n if (e.key === \"ArrowDown\") { e.preventDefault(); setHighlightedIndex((i) => i < suggestions.length - 1 ? i + 1 : 0); }\n else if (e.key === \"ArrowUp\") { e.preventDefault(); setHighlightedIndex((i) => i > 0 ? i - 1 : suggestions.length - 1); }\n else if (e.key === \"Enter\" && highlightedIndex >= 0) { e.preventDefault(); void selectSuggestion(suggestions[highlightedIndex]); }\n }}\n placeholder={field.placeholder || \"Search for a location...\"}\n required={field.is_required}\n autoComplete=\"off\"\n aria-autocomplete=\"list\"\n aria-expanded={isOpen && suggestions.length > 0}\n className={inputClass}\n />\n {isOpen && suggestions.length > 0 && (\n <div role=\"listbox\" className=\"absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded-md border border-border bg-background shadow-lg\">\n {suggestions.map((s, i) => (\n <button\n key={s.place_id}\n type=\"button\"\n role=\"option\"\n aria-selected={i === highlightedIndex}\n onMouseDown={(e) => { e.preventDefault(); void selectSuggestion(s); }}\n className={cn(\"block w-full px-3 py-2.5 text-left text-sm transition-colors\", i === highlightedIndex ? \"bg-muted text-foreground\" : \"text-foreground hover:bg-muted/70\")}\n >\n {s.description}\n </button>\n ))}\n </div>\n )}\n {loc && (\n <p className=\"text-xs text-muted-foreground mt-1\">\n {loc.latitude.toFixed(4)}, {loc.longitude.toFixed(4)}\n </p>\n )}\n </div>\n );\n}\n\nfunction PhoneInput({\n field,\n value,\n onValueChange,\n classNames,\n inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\n}): React.ReactElement {\n const phone = (value && typeof value === \"object\" ? value : null) as PhoneValue | null;\n const [code, setCode] = React.useState(phone?.country_code ?? \"+1\");\n const [number, setNumber] = React.useState(phone?.number ?? \"\");\n\n const update = (nextCode: string, nextNumber: string) => {\n setCode(nextCode);\n setNumber(nextNumber);\n if (nextNumber) {\n onValueChange({\n country_code: nextCode,\n number: nextNumber,\n formatted: `${nextCode} ${nextNumber}`,\n } satisfies PhoneValue);\n } else {\n onValueChange(undefined);\n }\n };\n\n return (\n <div className={cn(\"flex gap-2\", classNames?.phoneInput)}>\n <input\n type=\"text\"\n value={code}\n onChange={(e) => update(e.target.value, number)}\n placeholder=\"+1\"\n className={cn(inputClass, \"w-20 shrink-0\")}\n />\n <input\n type=\"tel\"\n value={number}\n onChange={(e) => update(code, e.target.value)}\n placeholder={field.placeholder || \"Phone number\"}\n required={field.is_required}\n className={inputClass}\n />\n </div>\n );\n}\n\nfunction SignatureInput({\n field,\n value,\n onValueChange,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const sig = (value && typeof value === \"object\" ? value : null) as SignatureValue | null;\n const canvasRef = React.useRef<HTMLCanvasElement>(null);\n const isDrawing = React.useRef(false);\n\n const getCtx = () => canvasRef.current?.getContext(\"2d\") ?? null;\n\n const startDraw = (e: React.PointerEvent) => {\n const ctx = getCtx();\n if (!ctx) return;\n isDrawing.current = true;\n const rect = canvasRef.current!.getBoundingClientRect();\n ctx.beginPath();\n ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);\n canvasRef.current!.setPointerCapture(e.pointerId);\n };\n\n const draw = (e: React.PointerEvent) => {\n if (!isDrawing.current) return;\n const ctx = getCtx();\n if (!ctx) return;\n const rect = canvasRef.current!.getBoundingClientRect();\n ctx.lineWidth = 2;\n ctx.lineCap = \"round\";\n const style = getComputedStyle(canvasRef.current!);\n ctx.strokeStyle = style.color || \"#000\";\n ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top);\n ctx.stroke();\n };\n\n const endDraw = () => {\n if (!isDrawing.current) return;\n isDrawing.current = false;\n const canvas = canvasRef.current;\n if (!canvas) return;\n onValueChange({\n data_url: canvas.toDataURL(\"image/png\"),\n signer_name: sig?.signer_name,\n } satisfies SignatureValue);\n };\n\n const clear = () => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);\n onValueChange(undefined);\n };\n\n return (\n <div className={cn(\"space-y-2\", classNames?.signatureCanvas)}>\n <div className=\"relative rounded-md border border-input overflow-hidden\">\n <canvas\n ref={canvasRef}\n width={400}\n height={150}\n className=\"w-full touch-none cursor-crosshair bg-background text-foreground\"\n onPointerDown={startDraw}\n onPointerMove={draw}\n onPointerUp={endDraw}\n onPointerLeave={endDraw}\n />\n {sig?.data_url && (\n <button\n type=\"button\"\n onClick={clear}\n className=\"absolute top-1.5 right-1.5 text-xs text-muted-foreground hover:text-destructive transition-colors\"\n >\n Clear\n </button>\n )}\n </div>\n {!sig?.data_url && (\n <p className=\"text-xs text-muted-foreground\">\n {field.placeholder || \"Draw your signature above\"}\n </p>\n )}\n </div>\n );\n}\n\nfunction createSessionToken(): string {\n if (typeof crypto !== \"undefined\" && crypto.randomUUID) {\n return crypto.randomUUID();\n }\n return `places_${Date.now()}_${Math.random().toString(16).slice(2)}`;\n}\n"
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "delivery-estimate",
3
+ "title": "DeliveryEstimate",
4
+ "description": "Delivery fee + ETA preview at the cart/checkout edge, sourced from /delivery/fee with location.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "price",
8
+ "cn"
9
+ ],
10
+ "files": [
11
+ {
12
+ "path": "delivery-estimate.tsx",
13
+ "content": "\"use client\";\n\nimport React, { useState, useEffect, useRef } from \"react\";\nimport type { DeliveryFeeResponse } from \"../delivery\";\nimport type { CimplifyError } from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { useOptionalCimplifyClient } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface DeliveryEstimateClassNames {\n root?: string;\n fee?: string;\n currency?: string;\n notServiceable?: string;\n loading?: string;\n error?: string;\n}\n\nexport interface DeliveryEstimateProps {\n /** Drop-off latitude. */\n latitude: number;\n /** Drop-off longitude. */\n longitude: number;\n /** Country code for regional fee lookup. */\n country?: string;\n /** Called when the delivery fee is successfully calculated. */\n onFeeCalculated?: (fee: DeliveryFeeResponse) => void;\n className?: string;\n classNames?: DeliveryEstimateClassNames;\n}\n\n/**\n * DeliveryEstimate — displays a pre-checkout delivery fee estimate.\n *\n * Fetches the delivery fee from `client.delivery.getFee` when coordinates change.\n * Shows fee amount with currency, a \"not serviceable\" message, or loading state.\n */\nexport function DeliveryEstimate({\n latitude,\n longitude,\n country,\n onFeeCalculated,\n className,\n classNames,\n}: DeliveryEstimateProps): React.ReactElement | null {\n const context = useOptionalCimplifyClient();\n const client = context?.client ?? null;\n\n const [result, setResult] = useState<DeliveryFeeResponse | null>(null);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState<CimplifyError | null>(null);\n const requestIdRef = useRef(0);\n\n useEffect(() => {\n if (!client) return;\n\n const nextRequestId = ++requestIdRef.current;\n setIsLoading(true);\n setError(null);\n\n void (async () => {\n const res = await client.delivery.getFee(latitude, longitude, country);\n\n if (nextRequestId !== requestIdRef.current) return;\n\n if (!res.ok) {\n setError(res.error);\n setResult(null);\n setIsLoading(false);\n return;\n }\n\n setResult(res.value);\n setError(null);\n setIsLoading(false);\n onFeeCalculated?.(res.value);\n })();\n }, [client, latitude, longitude, country]); // eslint-disable-line react-hooks/exhaustive-deps -- onFeeCalculated is a callback prop, not a reactive dependency\n\n if (!client) {\n return null;\n }\n\n if (isLoading) {\n return (\n <div\n data-cimplify-delivery-estimate\n data-serviceable=\"false\"\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (error) {\n return (\n <div\n data-cimplify-delivery-estimate\n data-serviceable=\"false\"\n className={cn(className, classNames?.root, classNames?.error)}\n >\n <span>{error.message}</span>\n </div>\n );\n }\n\n if (!result || !result.serviceable) {\n return (\n <div\n data-cimplify-delivery-estimate\n data-serviceable=\"false\"\n className={cn(className, classNames?.root, classNames?.notServiceable)}\n >\n <span>Not serviceable</span>\n </div>\n );\n }\n\n return (\n <div\n data-cimplify-delivery-estimate\n data-serviceable=\"true\"\n className={cn(className, classNames?.root)}\n >\n {result.fee != null && (\n <span data-cimplify-delivery-fee className={classNames?.fee}>\n <Price amount={result.fee} />\n </span>\n )}\n {result.currency && (\n <span data-cimplify-delivery-currency className={classNames?.currency}>\n {result.currency}\n </span>\n )}\n </div>\n );\n}\n"
14
+ }
15
+ ]
16
+ }
@@ -568,6 +568,111 @@
568
568
  "registryDependencies": [
569
569
  "cn"
570
570
  ]
571
+ },
572
+ {
573
+ "name": "cart-drawer",
574
+ "title": "CartDrawer",
575
+ "description": "Slide-in side cart drawer with provider context, free-shipping progress, animated subtotal, and empty state. Auto-opens on add-to-cart.",
576
+ "type": "component",
577
+ "registryDependencies": [
578
+ "cart-summary",
579
+ "price",
580
+ "cn"
581
+ ]
582
+ },
583
+ {
584
+ "name": "account",
585
+ "title": "CimplifyAccount",
586
+ "description": "Iframe wrapper for the Cimplify account portal — sign-in, orders, addresses, settings.",
587
+ "type": "component",
588
+ "registryDependencies": [
589
+ "cn"
590
+ ]
591
+ },
592
+ {
593
+ "name": "billing-plan-selector",
594
+ "title": "BillingPlanSelector",
595
+ "description": "Subscription / billing-plan picker — surfaces eligible plans with pricing and trial periods.",
596
+ "type": "component",
597
+ "registryDependencies": [
598
+ "price",
599
+ "cn"
600
+ ]
601
+ },
602
+ {
603
+ "name": "currency-selector",
604
+ "title": "CurrencySelector",
605
+ "description": "Multi-currency switcher backed by the FX provider — locks display currency and quote ID.",
606
+ "type": "component",
607
+ "registryDependencies": [
608
+ "cn"
609
+ ]
610
+ },
611
+ {
612
+ "name": "customer-input-fields",
613
+ "title": "CustomerInputFields",
614
+ "description": "Per-product custom input fields — text, number, date, time, file upload, image upload, single/multi-select.",
615
+ "type": "component",
616
+ "registryDependencies": [
617
+ "cn"
618
+ ]
619
+ },
620
+ {
621
+ "name": "delivery-estimate",
622
+ "title": "DeliveryEstimate",
623
+ "description": "Delivery fee + ETA preview at the cart/checkout edge, sourced from /delivery/fee with location.",
624
+ "type": "component",
625
+ "registryDependencies": [
626
+ "price",
627
+ "cn"
628
+ ]
629
+ },
630
+ {
631
+ "name": "location-picker",
632
+ "title": "LocationPicker",
633
+ "description": "Branch / pickup-point selector for businesses with multiple locations.",
634
+ "type": "component",
635
+ "registryDependencies": [
636
+ "cn"
637
+ ]
638
+ },
639
+ {
640
+ "name": "resource-picker",
641
+ "title": "ResourcePicker",
642
+ "description": "Staff / room / resource picker for bookable services — used by services and restaurant reservation flows.",
643
+ "type": "component",
644
+ "registryDependencies": [
645
+ "cn"
646
+ ]
647
+ },
648
+ {
649
+ "name": "recently-viewed",
650
+ "title": "RecentlyViewed",
651
+ "description": "Horizontally scrollable rail of recently viewed products, hydrated from local activity state.",
652
+ "type": "component",
653
+ "registryDependencies": [
654
+ "product-card",
655
+ "cn"
656
+ ]
657
+ },
658
+ {
659
+ "name": "recommendation-carousel",
660
+ "title": "RecommendationCarousel",
661
+ "description": "Personalized product carousel powered by /activity/recommendations — frequently bought, related, trending.",
662
+ "type": "component",
663
+ "registryDependencies": [
664
+ "product-card",
665
+ "cn"
666
+ ]
667
+ },
668
+ {
669
+ "name": "session-message-banner",
670
+ "title": "SessionMessageBanner",
671
+ "description": "Top-of-page banner for session-scoped messages (promo nudges, abandoned cart prompts, low-stock alerts) with dismiss tracking.",
672
+ "type": "component",
673
+ "registryDependencies": [
674
+ "cn"
675
+ ]
571
676
  }
572
677
  ]
573
678
  }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "location-picker",
3
+ "title": "LocationPicker",
4
+ "description": "Branch / pickup-point selector for businesses with multiple locations.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "cn"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "location-picker.tsx",
12
+ "content": "\"use client\";\n\nimport React from \"react\";\nimport type { Location } from \"../types/business\";\nimport { useLocations } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface LocationPickerClassNames {\n root?: string;\n item?: string;\n activeItem?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface LocationPickerProps {\n /** Override locations (skips useLocations fetch). For SSR, pass pre-fetched locations. */\n locations?: Location[];\n /** Override the current location. */\n currentLocation?: Location | null;\n /** Called when a location is selected. */\n onLocationChange?: (location: Location) => void;\n /** Custom location renderer. */\n renderLocation?: (location: Location, isActive: boolean) => React.ReactNode;\n /** Text shown when no locations are available. */\n emptyMessage?: string;\n className?: string;\n classNames?: LocationPickerClassNames;\n}\n\n/**\n * LocationPicker — renders a location selector for multi-location businesses.\n *\n * Uses `useLocations` to fetch locations unless overridden via props.\n * Returns `null` when there is only one location (no picker needed).\n */\nexport function LocationPicker({\n locations: locationsProp,\n currentLocation: currentLocationProp,\n onLocationChange,\n renderLocation,\n emptyMessage,\n className,\n classNames,\n}: LocationPickerProps): React.ReactElement | null {\n const {\n locations: fetched,\n currentLocation: fetchedCurrent,\n setCurrentLocation,\n isLoading,\n } = useLocations();\n\n const locations = locationsProp ?? fetched;\n const currentLocation = currentLocationProp ?? fetchedCurrent;\n\n const handleSelect = (location: Location): void => {\n if (onLocationChange) {\n onLocationChange(location);\n } else {\n setCurrentLocation(location);\n }\n };\n\n if (isLoading && locations.length === 0) {\n return (\n <div\n data-cimplify-location-picker\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n // No picker needed for single location\n if (locations.length <= 1) {\n return null;\n }\n\n if (locations.length === 0) {\n return (\n <div\n data-cimplify-location-picker\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage ?? \"No locations available\"}</p>\n </div>\n );\n }\n\n return (\n <div data-cimplify-location-picker className={cn(className, classNames?.root)}>\n {locations.map((location) => {\n const isActive = currentLocation?.id === location.id;\n return (\n <button\n key={location.id}\n type=\"button\"\n onClick={() => handleSelect(location)}\n data-cimplify-location-item\n data-active={isActive || undefined}\n aria-pressed={isActive}\n className={cn(classNames?.item, isActive && classNames?.activeItem)}\n >\n {renderLocation ? (\n renderLocation(location, isActive)\n ) : (\n <span>{location.name}</span>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n"
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "recently-viewed",
3
+ "title": "RecentlyViewed",
4
+ "description": "Horizontally scrollable rail of recently viewed products, hydrated from local activity state.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "product-card",
8
+ "cn"
9
+ ],
10
+ "files": [
11
+ {
12
+ "path": "recently-viewed.tsx",
13
+ "content": "\"use client\";\n\nimport React from \"react\";\nimport { useActivityState } from \"./hooks/use-activity-state\";\nimport { cn } from \"@cimplify/sdk/react\";\n\ninterface ViewedProduct {\n product_id: string;\n product_name?: string;\n category_id?: string;\n}\n\nexport interface RecentlyViewedClassNames {\n root?: string;\n item?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface RecentlyViewedProps {\n /** Maximum number of viewed products to display. */\n limit?: number;\n /** Called when a viewed product is clicked. */\n onProductClick?: (product: ViewedProduct) => void;\n /** Custom product renderer. */\n renderProduct?: (product: ViewedProduct) => React.ReactNode;\n /** Text shown when no viewed products exist. */\n emptyMessage?: string;\n className?: string;\n classNames?: RecentlyViewedClassNames;\n}\n\n/**\n * RecentlyViewed — displays products the user has recently viewed during their session.\n *\n * Extracts `state.activity.viewed_products` from the activity state.\n * Returns `null` when there are no viewed products.\n */\nexport function RecentlyViewed({\n limit,\n onProductClick,\n renderProduct,\n emptyMessage,\n className,\n classNames,\n}: RecentlyViewedProps): React.ReactElement | null {\n const { state, isLoading } = useActivityState();\n\n const rawViewed = state?.activity?.viewed_products ?? [];\n const viewed = limit ? rawViewed.slice(0, limit) : rawViewed;\n\n if (isLoading && viewed.length === 0) {\n return (\n <div\n data-cimplify-recently-viewed\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (viewed.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-recently-viewed className={cn(className, classNames?.root)}>\n {viewed.map((product) => (\n <button\n key={product.product_id}\n type=\"button\"\n onClick={() => onProductClick?.(product)}\n data-cimplify-recently-viewed-item\n className={classNames?.item}\n >\n {renderProduct ? (\n renderProduct(product)\n ) : (\n <span>{product.product_name ?? product.product_id}</span>\n )}\n </button>\n ))}\n </div>\n );\n}\n"
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "recommendation-carousel",
3
+ "title": "RecommendationCarousel",
4
+ "description": "Personalized product carousel powered by /activity/recommendations — frequently bought, related, trending.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "product-card",
8
+ "cn"
9
+ ],
10
+ "files": [
11
+ {
12
+ "path": "recommendation-carousel.tsx",
13
+ "content": "\"use client\";\n\nimport React from \"react\";\nimport type { ActivityRecommendation } from \"../activity\";\nimport { useRecommendations } from \"./hooks/use-recommendations\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface RecommendationCarouselClassNames {\n root?: string;\n item?: string;\n reason?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface RecommendationCarouselProps {\n /** Override recommendations (skips fetch). For SSR, pass pre-fetched data. */\n recommendations?: ActivityRecommendation[];\n /** Location ID for location-specific recommendations. */\n locationId?: string;\n /** Maximum recommendations to fetch. */\n limit?: number;\n /** Called when a product recommendation is clicked. */\n onProductClick?: (recommendation: ActivityRecommendation) => void;\n /** Custom product card renderer. */\n renderProduct?: (recommendation: ActivityRecommendation) => React.ReactNode;\n /** Text shown when no recommendations are available. */\n emptyMessage?: string;\n className?: string;\n classNames?: RecommendationCarouselClassNames;\n}\n\n/**\n * RecommendationCarousel — displays personalized product recommendations.\n *\n * Renders a horizontal scrollable list of product cards with reason labels.\n * Returns `null` when there are no recommendations.\n */\nexport function RecommendationCarousel({\n recommendations: recommendationsProp,\n locationId,\n limit,\n onProductClick,\n renderProduct,\n emptyMessage,\n className,\n classNames,\n}: RecommendationCarouselProps): React.ReactElement | null {\n const { recommendations: fetched, isLoading } = useRecommendations({\n locationId,\n limit,\n enabled: recommendationsProp === undefined,\n });\n\n const recommendations = recommendationsProp ?? fetched;\n\n if (isLoading && recommendations.length === 0) {\n return (\n <div\n data-cimplify-recommendation-carousel\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (recommendations.length === 0) {\n return null;\n }\n\n return (\n <div\n data-cimplify-recommendation-carousel\n className={cn(className, classNames?.root)}\n style={{ display: \"flex\", overflowX: \"auto\", gap: \"1rem\" }}\n >\n {recommendations.map((rec, index) => {\n const productId = rec.product?.id ?? String(index);\n return (\n <button\n key={String(productId)}\n type=\"button\"\n onClick={() => onProductClick?.(rec)}\n data-cimplify-recommendation-item\n className={classNames?.item}\n style={{ flexShrink: 0 }}\n >\n {renderProduct ? (\n renderProduct(rec)\n ) : (\n <>\n <span data-cimplify-recommendation-name>\n {rec.product?.name ?? rec.product?.id ?? \"Product\"}\n </span>\n {rec.reason && (\n <span data-cimplify-recommendation-reason className={classNames?.reason}>\n {rec.reason}\n </span>\n )}\n </>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n"
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "resource-picker",
3
+ "title": "ResourcePicker",
4
+ "description": "Staff / room / resource picker for bookable services — used by services and restaurant reservation flows.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "cn"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "resource-picker.tsx",
12
+ "content": "\"use client\";\n\nimport { Radio } from \"@base-ui/react/radio\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport React from \"react\";\nimport type { Room } from \"../types/business\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nconst ANY_VALUE = \"__any__\";\n\nexport interface Resource {\n id: string;\n name: string;\n description?: string;\n capacity?: number;\n floor?: string;\n image_url?: string;\n is_available?: boolean;\n}\n\nexport interface ResourcePickerClassNames {\n root?: string;\n option?: string;\n image?: string;\n name?: string;\n description?: string;\n meta?: string;\n capacity?: string;\n floor?: string;\n unavailable?: string;\n}\n\nexport interface ResourcePickerProps {\n /** List of available resources (rooms, equipment, etc.). */\n resources: Resource[];\n /** Currently selected resource ID, or null for \"Any available\". */\n selectedResourceId?: string | null;\n /** Called when a resource is selected. Passes null for \"Any available\". */\n onResourceSelect?: (resourceId: string | null) => void;\n /** Show \"Any available\" option. Default: true. */\n showAnyOption?: boolean;\n /** Label for the \"Any available\" option. */\n anyLabel?: string;\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?: ResourcePickerClassNames;\n}\n\nexport function roomToResource(room: Room): Resource {\n return {\n id: room.id,\n name: room.name,\n capacity: room.capacity,\n floor: room.floor,\n is_available: room.status === \"available\",\n };\n}\n\nexport function ResourcePicker({\n resources,\n selectedResourceId,\n onResourceSelect,\n showAnyOption = true,\n anyLabel = \"Any available\",\n renderImage,\n className,\n classNames,\n}: ResourcePickerProps): React.ReactElement {\n const groupValue =\n selectedResourceId === null ? ANY_VALUE : (selectedResourceId ?? \"\");\n\n return (\n <RadioGroup\n data-cimplify-resource-picker\n className={cn(\"flex flex-col gap-2\", className, classNames?.root)}\n value={groupValue}\n onValueChange={(value) => {\n onResourceSelect?.(value === ANY_VALUE ? null : value);\n }}\n >\n {showAnyOption && (\n <Radio.Root\n value={ANY_VALUE}\n data-cimplify-resource-option\n data-selected={selectedResourceId === null || undefined}\n data-any\n className={cn(\n \"flex items-center gap-3 rounded-lg border border-border p-3 transition-colors cursor-pointer\",\n \"hover:bg-muted data-[checked]:border-primary data-[checked]:bg-primary/5\",\n classNames?.option,\n )}\n >\n <span data-cimplify-resource-name className={cn(\"font-medium text-foreground\", classNames?.name)}>\n {anyLabel}\n </span>\n </Radio.Root>\n )}\n {resources.map((resource) => {\n const unavailable = resource.is_available === false;\n return (\n <Radio.Root\n key={resource.id}\n value={resource.id}\n disabled={unavailable}\n data-cimplify-resource-option\n data-selected={selectedResourceId === resource.id || undefined}\n data-unavailable={unavailable || undefined}\n className={cn(\n \"flex items-center gap-3 rounded-lg border border-border p-3 transition-colors cursor-pointer\",\n \"hover:bg-muted data-[checked]:border-primary data-[checked]:bg-primary/5\",\n unavailable && \"opacity-50 cursor-not-allowed\",\n classNames?.option,\n unavailable ? classNames?.unavailable : undefined,\n )}\n >\n {resource.image_url && (\n renderImage ? (\n renderImage({\n src: resource.image_url,\n alt: resource.name,\n className: classNames?.image,\n })\n ) : (\n <img\n src={resource.image_url}\n alt={resource.name}\n data-cimplify-resource-image\n className={cn(\"w-10 h-10 rounded-lg object-cover\", classNames?.image)}\n />\n )\n )}\n <div className=\"flex flex-col gap-0.5 flex-1 min-w-0\">\n <span data-cimplify-resource-name className={cn(\"font-medium text-foreground\", classNames?.name)}>\n {resource.name}\n </span>\n {resource.description && (\n <span data-cimplify-resource-description className={cn(\"text-sm text-muted-foreground truncate\", classNames?.description)}>\n {resource.description}\n </span>\n )}\n <div data-cimplify-resource-meta className={cn(\"flex items-center gap-2 text-xs text-muted-foreground\", classNames?.meta)}>\n {resource.capacity !== undefined && (\n <span data-cimplify-resource-capacity className={classNames?.capacity}>\n Up to {resource.capacity}\n </span>\n )}\n {resource.floor && (\n <span data-cimplify-resource-floor className={classNames?.floor}>\n {resource.floor}\n </span>\n )}\n </div>\n </div>\n </Radio.Root>\n );\n })}\n </RadioGroup>\n );\n}\n"
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "session-message-banner",
3
+ "title": "SessionMessageBanner",
4
+ "description": "Top-of-page banner for session-scoped messages (promo nudges, abandoned cart prompts, low-stock alerts) with dismiss tracking.",
5
+ "type": "component",
6
+ "registryDependencies": [
7
+ "cn"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "session-message-banner.tsx",
12
+ "content": "\"use client\";\n\nimport React from \"react\";\nimport type { SessionMessage } from \"../activity\";\nimport { useActivityState } from \"./hooks/use-activity-state\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface SessionMessageBannerClassNames {\n root?: string;\n message?: string;\n text?: string;\n dismissButton?: string;\n empty?: string;\n}\n\nexport interface SessionMessageBannerProps {\n /** Override messages (skips fetch). For SSR, pass pre-fetched messages. */\n messages?: SessionMessage[];\n /** Called when a message is dismissed. Receives the message code. */\n onDismiss?: (code: string) => void;\n /** Custom message renderer. */\n renderMessage?: (message: SessionMessage) => React.ReactNode;\n className?: string;\n classNames?: SessionMessageBannerClassNames;\n}\n\n/**\n * SessionMessageBanner — renders activity-based session messages.\n *\n * Displays dismissible message cards with level-based styling (info, promotion, urgency, suggestion).\n * Returns `null` when there are no messages.\n */\nexport function SessionMessageBanner({\n messages: messagesProp,\n onDismiss,\n renderMessage,\n className,\n classNames,\n}: SessionMessageBannerProps): React.ReactElement | null {\n const { messages: fetched, isLoading, dismissMessage } = useActivityState({\n enabled: messagesProp === undefined,\n });\n\n const messages = messagesProp ?? fetched;\n\n const handleDismiss = async (code: string): Promise<void> => {\n if (onDismiss) {\n onDismiss(code);\n } else {\n await dismissMessage(code);\n }\n };\n\n if (isLoading && messages.length === 0) {\n return (\n <div\n data-cimplify-session-message-banner\n aria-busy=\"true\"\n className={cn(className, classNames?.root)}\n />\n );\n }\n\n if (messages.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-session-message-banner className={cn(className, classNames?.root)}>\n {messages.map((msg) => (\n <div\n key={msg.code}\n data-cimplify-session-message\n data-level={msg.level}\n className={classNames?.message}\n >\n {renderMessage ? (\n renderMessage(msg)\n ) : (\n <>\n <span data-cimplify-session-message-text className={classNames?.text}>\n {msg.text}\n </span>\n {msg.dismissible && (\n <button\n type=\"button\"\n onClick={() => void handleDismiss(msg.code)}\n data-cimplify-session-message-dismiss\n className={classNames?.dismissButton}\n aria-label=\"Dismiss message\"\n >\n &times;\n </button>\n )}\n </>\n )}\n </div>\n ))}\n </div>\n );\n}\n"
13
+ }
14
+ ]
15
+ }
@@ -98,6 +98,18 @@ app/
98
98
  - `components/newsletter.tsx` (homepage strip) similarly fakes submit. Wire to a real list provider.
99
99
  - Hero / lookbook imagery is Unsplash placeholder — replace with merchant assets.
100
100
 
101
+ ## Customizing SDK components
102
+
103
+ For anything beyond `lib/brand.ts` + `app/globals.css`, lean on the SDK's prebuilt components rather than reinvent. **Especially for product customization** (variants, add-ons, bundles, composites, services with scheduling) — the SDK already gets price math, axis matching, and cart payload contracts right. Default to ejecting and restyling:
104
+
105
+ ```bash
106
+ bunx @cimplify/sdk add cart-drawer
107
+ bunx @cimplify/sdk add variant-selector
108
+ bunx @cimplify/sdk add product-page
109
+ ```
110
+
111
+ Then edit the local copy. **Don't change the cart payload shape** unless you're also touching the SDK mock + backend lens. Full ejection rules and the customizer contract are in the SDK-level [`AGENTS.md`](../../AGENTS.md) → "Don't reinvent product customization".
112
+
101
113
  ## Quick start
102
114
 
103
115
  ```bash
@@ -0,0 +1,4 @@
1
+ import { createBrandSuite } from "@cimplify/sdk/testing/suite";
2
+ import { brand } from "../lib/brand";
3
+
4
+ createBrandSuite({ brand });
@@ -0,0 +1,4 @@
1
+ import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";
2
+ import { brand } from "../lib/brand";
3
+
4
+ createCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });
@@ -0,0 +1,4 @@
1
+ import { createContractSuite } from "@cimplify/sdk/testing/suite";
2
+ import { brand } from "../lib/brand";
3
+
4
+ createContractSuite({ seed: brand.mock.seed });
@@ -8,10 +8,17 @@
8
8
  "dev:mock": "bunx @cimplify/sdk --seed default",
9
9
  "build": "next build",
10
10
  "start": "next start",
11
- "lint": "next lint"
11
+ "lint": "next lint",
12
+ "typecheck": "tsc --noEmit",
13
+ "test": "vitest",
14
+ "test:run": "vitest run",
15
+ "check:brand": "vitest run __tests__/brand.test.ts",
16
+ "check:cart": "vitest run __tests__/cart-flow.test.ts",
17
+ "check:contract": "vitest run __tests__/contract.test.ts",
18
+ "check": "bun run typecheck && bun run lint && bun run test:run"
12
19
  },
13
20
  "dependencies": {
14
- "@cimplify/sdk": "^0.44.4",
21
+ "@cimplify/sdk": "^0.44.14",
15
22
  "next": "^16.2.4",
16
23
  "react": "^19.0.0",
17
24
  "react-dom": "^19.0.0"
@@ -23,6 +30,7 @@
23
30
  "@types/react-dom": "^19.0.0",
24
31
  "concurrently": "^9.0.0",
25
32
  "tailwindcss": "^4.2.4",
26
- "typescript": "^5.9.3"
33
+ "typescript": "^5.9.3",
34
+ "vitest": "^4.1.5"
27
35
  }
28
36
  }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["__tests__/**/*.test.ts"],
7
+ globals: false,
8
+ },
9
+ });
@@ -94,7 +94,27 @@ app/
94
94
 
95
95
  ## Mock seed
96
96
 
97
- Wired to `--seed retail` (Currents Electronics catalogue — the closest stand-in until an apparel seed ships). Replace with apparel data in production.
97
+ Wired to `--seed fashion` (Studio FRX apparel catalogue — 14 products with size variants XS–2XL, Drop 04 + Best Sellers collections, full-bleed product imagery served from `static-tmp.cimplify.io`).
98
+
99
+ ## Customizing SDK components
100
+
101
+ For anything beyond `lib/brand.ts` + `app/globals.css`, you'll likely want to lean on the SDK's prebuilt components rather than reinvent. Particularly for **product customization** (variants, add-ons, bundles, composites, services with scheduling), the SDK already gets the price math, variant axis matching, and cart payload contracts right. Default to ejecting and restyling:
102
+
103
+ ```bash
104
+ bunx @cimplify/sdk add variant-selector # copies into ./components/
105
+ bunx @cimplify/sdk add cart-drawer
106
+ bunx @cimplify/sdk add product-page
107
+ ```
108
+
109
+ Then edit the local copy. **Don't change the cart payload shape** unless you're also touching the SDK's mock and the backend lens.
110
+
111
+ If you're considering a from-scratch rebuild of the customizer, read these SDK source files first to understand the contract you must reproduce:
112
+ - `src/react/product-customizer.tsx` — top-level state machine + final `onAddToCart` payload assembly
113
+ - `src/react/variant-selector.tsx` — multi-axis matching via `display_attributes`; default-variant init runs in a `useEffect` and is easy to break
114
+ - `src/react/{add-on,bundle,composite}-selector.tsx` — group constraints, exclusivity rules, per-component variants
115
+ - `src/mock/domain/cart/{index,pricing}.ts` — the contract: `computeUnitPrice`, `computeBundlePrice`, `computeCompositePrice` (4 modes), `computeLineKey`
116
+
117
+ Full ejection + customization rules are in the SDK-level [`AGENTS.md`](../../AGENTS.md) under "Don't reinvent product customization".
98
118
 
99
119
  ## Quick start
100
120
 
@@ -0,0 +1,4 @@
1
+ import { createBrandSuite } from "@cimplify/sdk/testing/suite";
2
+ import { brand } from "../lib/brand";
3
+
4
+ createBrandSuite({ brand });
@@ -0,0 +1,4 @@
1
+ import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";
2
+ import { brand } from "../lib/brand";
3
+
4
+ createCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });
@@ -0,0 +1,4 @@
1
+ import { createContractSuite } from "@cimplify/sdk/testing/suite";
2
+ import { brand } from "../lib/brand";
3
+
4
+ createContractSuite({ seed: brand.mock.seed });
@@ -8,10 +8,17 @@
8
8
  "dev:mock": "bunx @cimplify/sdk --seed fashion",
9
9
  "build": "next build",
10
10
  "start": "next start",
11
- "lint": "next lint"
11
+ "lint": "next lint",
12
+ "typecheck": "tsc --noEmit",
13
+ "test": "vitest",
14
+ "test:run": "vitest run",
15
+ "check:brand": "vitest run __tests__/brand.test.ts",
16
+ "check:cart": "vitest run __tests__/cart-flow.test.ts",
17
+ "check:contract": "vitest run __tests__/contract.test.ts",
18
+ "check": "bun run typecheck && bun run lint && bun run test:run"
12
19
  },
13
20
  "dependencies": {
14
- "@cimplify/sdk": "^0.44.4",
21
+ "@cimplify/sdk": "^0.44.14",
15
22
  "next": "^16.2.4",
16
23
  "react": "^19.0.0",
17
24
  "react-dom": "^19.0.0"
@@ -23,6 +30,7 @@
23
30
  "@types/react-dom": "^19.0.0",
24
31
  "concurrently": "^9.0.0",
25
32
  "tailwindcss": "^4.2.4",
26
- "typescript": "^5.9.3"
33
+ "typescript": "^5.9.3",
34
+ "vitest": "^4.1.5"
27
35
  }
28
36
  }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["__tests__/**/*.test.ts"],
7
+ globals: false,
8
+ },
9
+ });
@@ -74,6 +74,18 @@ app/
74
74
  - Contact + newsletter fake submits.
75
75
  - Hero / category / product imagery is Unsplash placeholder — replace with merchant photography.
76
76
 
77
+ ## Customizing SDK components
78
+
79
+ For anything beyond `lib/brand.ts` + `app/globals.css`, lean on the SDK's prebuilt components rather than reinvent. **Especially for product customization** (variants, add-ons, bundles, composites) — the SDK already gets price math, axis matching, and cart payload contracts right. Default to ejecting and restyling:
80
+
81
+ ```bash
82
+ bunx @cimplify/sdk add cart-drawer
83
+ bunx @cimplify/sdk add add-on-selector
84
+ bunx @cimplify/sdk add product-page
85
+ ```
86
+
87
+ Then edit the local copy. **Don't change the cart payload shape** unless you're also touching the SDK mock + backend lens. Full ejection rules and the customizer contract are in the SDK-level [`AGENTS.md`](../../AGENTS.md) → "Don't reinvent product customization".
88
+
77
89
  ## Quick start
78
90
 
79
91
  ```bash
@@ -0,0 +1,4 @@
1
+ import { createBrandSuite } from "@cimplify/sdk/testing/suite";
2
+ import { brand } from "../lib/brand";
3
+
4
+ createBrandSuite({ brand });
@@ -0,0 +1,4 @@
1
+ import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";
2
+ import { brand } from "../lib/brand";
3
+
4
+ createCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });
@@ -0,0 +1,4 @@
1
+ import { createContractSuite } from "@cimplify/sdk/testing/suite";
2
+ import { brand } from "../lib/brand";
3
+
4
+ createContractSuite({ seed: brand.mock.seed });
@@ -8,10 +8,17 @@
8
8
  "dev:mock": "bunx @cimplify/sdk --seed grocery",
9
9
  "build": "next build",
10
10
  "start": "next start",
11
- "lint": "next lint"
11
+ "lint": "next lint",
12
+ "typecheck": "tsc --noEmit",
13
+ "test": "vitest",
14
+ "test:run": "vitest run",
15
+ "check:brand": "vitest run __tests__/brand.test.ts",
16
+ "check:cart": "vitest run __tests__/cart-flow.test.ts",
17
+ "check:contract": "vitest run __tests__/contract.test.ts",
18
+ "check": "bun run typecheck && bun run lint && bun run test:run"
12
19
  },
13
20
  "dependencies": {
14
- "@cimplify/sdk": "^0.44.4",
21
+ "@cimplify/sdk": "^0.44.14",
15
22
  "next": "^16.2.4",
16
23
  "react": "^19.0.0",
17
24
  "react-dom": "^19.0.0"
@@ -23,6 +30,7 @@
23
30
  "@types/react-dom": "^19.0.0",
24
31
  "concurrently": "^9.0.0",
25
32
  "tailwindcss": "^4.2.4",
26
- "typescript": "^5.9.3"
33
+ "typescript": "^5.9.3",
34
+ "vitest": "^4.1.5"
27
35
  }
28
36
  }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["__tests__/**/*.test.ts"],
7
+ globals: false,
8
+ },
9
+ });
@@ -80,6 +80,18 @@ app/
80
80
  - Contact form + newsletter fake their submits.
81
81
  - Hero / dish photography is Unsplash — replace with merchant photos.
82
82
 
83
+ ## Customizing SDK components
84
+
85
+ For anything beyond `lib/brand.ts` + `app/globals.css`, lean on the SDK's prebuilt components rather than reinvent. **Especially for product customization** (variants, add-ons, bundles, composites, services with scheduling) — the SDK already gets price math, axis matching, and cart payload contracts right. Default to ejecting and restyling:
86
+
87
+ ```bash
88
+ bunx @cimplify/sdk add cart-drawer
89
+ bunx @cimplify/sdk add add-on-selector
90
+ bunx @cimplify/sdk add product-page
91
+ ```
92
+
93
+ Then edit the local copy. **Don't change the cart payload shape** unless you're also touching the SDK mock + backend lens. Full ejection rules and the customizer contract are in the SDK-level [`AGENTS.md`](../../AGENTS.md) → "Don't reinvent product customization".
94
+
83
95
  ## Quick start
84
96
 
85
97
  ```bash