@cimplify/sdk 0.16.3 → 0.18.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/advanced.d.mts +2 -2
- package/dist/advanced.d.ts +2 -2
- package/dist/advanced.js +90 -2913
- package/dist/advanced.mjs +3 -2893
- package/dist/chunk-2JI47EYJ.mjs +2786 -0
- package/dist/chunk-DYKFSV77.js +2837 -0
- package/dist/chunk-FLPI2VVJ.js +72 -0
- package/dist/chunk-FTL2SXCG.mjs +674 -0
- package/dist/chunk-KTTQC5HG.mjs +687 -0
- package/dist/chunk-LM3CJ4L7.mjs +69 -0
- package/dist/chunk-MJECWMOY.js +692 -0
- package/dist/chunk-RYQ5IPWX.js +714 -0
- package/dist/chunk-S6TU55KP.js +170 -0
- package/dist/chunk-WDIMISRC.mjs +166 -0
- package/dist/{client-BaolRdB4.d.mts → client-B1UtuyOg.d.ts} +21 -12
- package/dist/{client-C59R79gx.d.ts → client-BFFfuzJr.d.mts} +21 -12
- package/dist/index.d.mts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +401 -4420
- package/dist/index.mjs +5 -4325
- package/dist/{payment-C6ULw3K-.d.mts → payment-D9FTaOse.d.mts} +66 -1
- package/dist/{payment-C6ULw3K-.d.ts → payment-D9FTaOse.d.ts} +66 -1
- package/dist/{price-CbR6DCt0.d.mts → price-9JsPuknm.d.mts} +1 -1
- package/dist/{price-Bd6JBV8W.d.ts → price-BD1iioLh.d.ts} +1 -1
- package/dist/react.d.mts +43 -11
- package/dist/react.d.ts +43 -11
- package/dist/react.js +540 -3981
- package/dist/react.mjs +489 -3933
- package/dist/styles.css +1 -1
- package/dist/utils.d.mts +5 -5
- package/dist/utils.d.ts +5 -5
- package/dist/utils.js +122 -721
- package/dist/utils.mjs +2 -695
- package/package.json +1 -1
- package/registry/add-on-selector.json +1 -1
- package/registry/catalogue-page.json +1 -1
- package/registry/checkout-page.json +1 -1
- package/registry/composite-selector.json +1 -1
- package/registry/product-grid.json +1 -1
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "add-on-selector.tsx",
|
|
12
|
-
"content": "\"use client\";\n\nimport React, { useCallback } from \"react\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport type { AddOnWithOptions } from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface AddOnSelectorClassNames {\n root?: string;\n group?: string;\n header?: string;\n name?: string;\n required?: string;\n constraint?: string;\n validation?: string;\n options?: string;\n option?: string;\n optionSelected?: string;\n optionName?: string;\n optionDescription?: string;\n}\n\nexport interface AddOnSelectorProps {\n addOns: AddOnWithOptions[];\n selectedOptions: string[];\n onOptionsChange: (optionIds: string[]) => void;\n className?: string;\n classNames?: AddOnSelectorClassNames;\n}\n\nexport function AddOnSelector({\n addOns,\n selectedOptions,\n onOptionsChange,\n className,\n classNames,\n}: AddOnSelectorProps): React.ReactElement | null {\n const isOptionSelected = useCallback(\n (optionId: string) =>
|
|
12
|
+
"content": "\"use client\";\n\nimport React, { useCallback, useMemo } from \"react\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport type { AddOnWithOptions } from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface AddOnSelectorClassNames {\n root?: string;\n group?: string;\n header?: string;\n name?: string;\n required?: string;\n constraint?: string;\n validation?: string;\n options?: string;\n option?: string;\n optionSelected?: string;\n optionName?: string;\n optionDescription?: string;\n}\n\nexport interface AddOnSelectorProps {\n addOns: AddOnWithOptions[];\n selectedOptions: string[];\n onOptionsChange: (optionIds: string[]) => void;\n className?: string;\n classNames?: AddOnSelectorClassNames;\n}\n\nexport function AddOnSelector({\n addOns,\n selectedOptions,\n onOptionsChange,\n className,\n classNames,\n}: AddOnSelectorProps): React.ReactElement | null {\n const selectedSet = useMemo(() => new Set(selectedOptions), [selectedOptions]);\n\n const isOptionSelected = useCallback(\n (optionId: string) => selectedSet.has(optionId),\n [selectedSet],\n );\n\n const handleCheckedChange = useCallback(\n (addOn: AddOnWithOptions, optionId: string, checked: boolean) => {\n const isSelected = selectedSet.has(optionId);\n\n if (addOn.is_mutually_exclusive || !addOn.is_multiple_allowed) {\n const groupOptionIds = new Set(addOn.options.map((o) => o.id));\n const withoutGroup = selectedOptions.filter((id) => !groupOptionIds.has(id));\n\n if (isSelected) {\n if (!addOn.is_required) {\n onOptionsChange(withoutGroup);\n }\n } else {\n onOptionsChange([...withoutGroup, optionId]);\n }\n } else {\n if (isSelected) {\n onOptionsChange(selectedOptions.filter((id) => id !== optionId));\n } else {\n let currentCount = 0;\n for (const option of addOn.options) {\n if (selectedSet.has(option.id)) {\n currentCount += 1;\n }\n }\n\n if (addOn.max_selections && currentCount >= addOn.max_selections) {\n return;\n }\n\n onOptionsChange([...selectedOptions, optionId]);\n }\n }\n },\n [selectedOptions, selectedSet, onOptionsChange],\n );\n\n if (!addOns || addOns.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-addon-selector className={cn(\"space-y-6\", className, classNames?.root)}>\n {addOns.map((addOn) => {\n let currentSelections = 0;\n for (const option of addOn.options) {\n if (selectedSet.has(option.id)) {\n currentSelections += 1;\n }\n }\n const minMet = !addOn.min_selections || currentSelections >= addOn.min_selections;\n const isSingleSelect = addOn.is_mutually_exclusive || !addOn.is_multiple_allowed;\n\n return (\n <div\n key={addOn.id}\n data-cimplify-addon-group\n className={cn(classNames?.group)}\n >\n <div\n data-cimplify-addon-header\n className={cn(\"flex items-center justify-between py-3\", classNames?.header)}\n >\n <div>\n <span\n data-cimplify-addon-name\n className={cn(\"text-base font-bold\", classNames?.name)}\n >\n {addOn.name}\n </span>\n {(addOn.min_selections || addOn.max_selections) && (\n <span\n data-cimplify-addon-constraint\n className={cn(\"block text-xs text-muted-foreground mt-0.5\", classNames?.constraint)}\n >\n {addOn.min_selections && addOn.max_selections\n ? `Choose ${addOn.min_selections}\\u2013${addOn.max_selections}`\n : addOn.min_selections\n ? `Choose at least ${addOn.min_selections}`\n : `Choose up to ${addOn.max_selections}`}\n </span>\n )}\n </div>\n {(addOn.is_required || !minMet) && (\n <span\n data-cimplify-addon-required\n className={cn(\n \"text-xs font-semibold px-2.5 py-1 rounded\",\n !minMet\n ? \"text-destructive bg-destructive/10\"\n : \"text-destructive bg-destructive/10\",\n classNames?.required,\n )}\n >\n Required\n </span>\n )}\n </div>\n\n <div\n data-cimplify-addon-options\n className={cn(\"divide-y divide-border\", classNames?.options)}\n >\n {addOn.options.map((option) => {\n const isSelected = isOptionSelected(option.id);\n\n return (\n <Checkbox.Root\n key={option.id}\n checked={isSelected}\n onCheckedChange={(checked) =>\n handleCheckedChange(addOn, option.id, checked)\n }\n value={option.id}\n data-cimplify-addon-option\n data-selected={isSelected || undefined}\n className={cn(\n \"w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer\",\n isSelected ? classNames?.optionSelected : classNames?.option,\n )}\n >\n <Checkbox.Indicator\n className=\"hidden\"\n keepMounted={false}\n />\n\n {/* Visual indicator: radio circle for single-select, checkbox square for multi-select */}\n {isSingleSelect ? (\n <span\n data-cimplify-addon-radio\n className={cn(\n \"w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors\",\n isSelected ? \"border-primary\" : \"border-muted-foreground/30\",\n )}\n >\n {isSelected && <span className=\"w-2.5 h-2.5 rounded-full bg-primary\" />}\n </span>\n ) : (\n <span\n data-cimplify-addon-checkbox\n className={cn(\n \"w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors\",\n isSelected ? \"border-primary bg-primary\" : \"border-muted-foreground/30\",\n )}\n >\n {isSelected && (\n <svg viewBox=\"0 0 12 12\" className=\"w-3 h-3 text-primary-foreground\" fill=\"none\">\n <path d=\"M2 6l3 3 5-5\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"/>\n </svg>\n )}\n </span>\n )}\n\n <span\n data-cimplify-addon-option-name\n className={cn(\n \"flex-1 min-w-0 text-sm\",\n classNames?.optionName,\n )}\n >\n {option.name}\n </span>\n\n {option.default_price != null && (\n <span className=\"text-sm text-muted-foreground\">\n +<Price amount={option.default_price} />\n </span>\n )}\n </Checkbox.Root>\n );\n })}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n"
|
|
13
13
|
}
|
|
14
14
|
]
|
|
15
15
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
{
|
|
14
14
|
"path": "catalogue-page.tsx",
|
|
15
|
-
"content": "\"use client\";\n\nimport React, { useState, useMemo, useCallback } from \"react\";\nimport type { Product, Category } from \"@cimplify/sdk\";\nimport { useProducts, useCategories } from \"@cimplify/sdk/react\";\nimport { ProductGrid } from \"./product-grid\";\nimport { CategoryFilter } from \"@cimplify/sdk/react\";\nimport { SearchInput } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface CataloguePageClassNames {\n root?: string;\n header?: string;\n title?: string;\n searchContainer?: string;\n filterContainer?: string;\n gridContainer?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface CataloguePageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched products for SSR. When provided, skips client-side fetch. */\n products?: Product[];\n /** Pre-fetched categories for SSR. */\n categories?: Category[];\n /** Show category filter. Default: true. */\n showFilter?: boolean;\n /** Show search input. Default: true. */\n showSearch?: boolean;\n /** Called when a product is selected (e.g. navigate to product page). */\n onProductClick?: (product: Product) => void;\n /** Called when a category is selected. */\n onCategoryClick?: (category: Category) => void;\n /** Custom card renderer passed to ProductGrid. */\n renderCard?: (product: Product) => React.ReactNode;\n /** Custom image renderer passed to ProductGrid. */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Grid column config. */\n columns?: { sm?: number; md?: number; lg?: number; xl?: number };\n className?: string;\n classNames?: CataloguePageClassNames;\n}\n\n/**\n * CataloguePage — browse all products with category filtering and search.\n *\n * SSR-friendly: pass `products` and `categories` props for server rendering.\n * Omit them for fully client-side data fetching.\n */\nexport function CataloguePage({\n title = \"Products\",\n products: productsProp,\n categories: categoriesProp,\n showFilter = true,\n showSearch = true,\n onProductClick,\n onCategoryClick,\n renderCard,\n renderImage,\n columns,\n className,\n classNames,\n}: CataloguePageProps): React.ReactElement {\n const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);\n const [searchQuery, setSearchQuery] = useState(\"\");\n\n const { products: fetchedProducts, isLoading: productsLoading } = useProducts({\n enabled: productsProp === undefined,\n });\n const { categories: fetchedCategories } = useCategories({\n enabled: showFilter && categoriesProp === undefined,\n });\n\n const allProducts = productsProp ?? fetchedProducts;\n const categories = categoriesProp ?? fetchedCategories;\n\n const filteredProducts = useMemo(() => {\n let result = allProducts;\n if (selectedCategoryId) {\n result = result.filter((p) => p.category_id === selectedCategoryId);\n }\n if (searchQuery.trim().length >= 2) {\n const q = searchQuery.trim().toLowerCase();\n result = result.filter(\n (p) =>\n p.name.toLowerCase().includes(q) ||\n p.description?.toLowerCase().includes(q),\n );\n }\n return result;\n }, [allProducts, selectedCategoryId, searchQuery]);\n\n const handleCategorySelect = useCallback(\n (id: string | null) => {\n setSelectedCategoryId(id);\n if (id) {\n const cat = categories.find((c: Category) => c.id === id);\n if (cat) onCategoryClick?.(cat);\n }\n },\n [categories, onCategoryClick],\n );\n\n return (\n <div data-cimplify-catalogue-page className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-catalogue-header className={classNames?.header}>\n <h1 data-cimplify-catalogue-title className={classNames?.title}>\n {title}\n </h1>\n\n {showSearch && (\n <div data-cimplify-catalogue-search className={classNames?.searchContainer}>\n <SearchInput\n onResultClick={onProductClick}\n searchOptions={{ category: selectedCategoryId ?? undefined }}\n />\n </div>\n )}\n </div>\n\n {/* Category filter */}\n {showFilter && categories.length > 0 && (\n <div data-cimplify-catalogue-filter className={classNames?.filterContainer}>\n <CategoryFilter\n selectedId={selectedCategoryId}\n onSelect={handleCategorySelect}\n />\n </div>\n )}\n\n {/* Product grid */}\n <div data-cimplify-catalogue-grid className={classNames?.gridContainer}>\n {productsLoading && !productsProp ? (\n <div\n data-cimplify-catalogue-loading\n aria-busy=\"true\"\n className={classNames?.loading}\n />\n ) : (\n <ProductGrid\n products={filteredProducts}\n renderCard={renderCard}\n renderImage={renderImage}\n columns={columns}\n emptyMessage=\"No products found\"\n />\n )}\n </div>\n </div>\n );\n}\n"
|
|
15
|
+
"content": "\"use client\";\n\nimport React, { useState, useMemo, useCallback } from \"react\";\nimport type { Product, Category } from \"@cimplify/sdk\";\nimport { useProducts, useCategories } from \"@cimplify/sdk/react\";\nimport { ProductGrid } from \"./product-grid\";\nimport { CategoryFilter } from \"@cimplify/sdk/react\";\nimport { SearchInput } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface CataloguePageClassNames {\n root?: string;\n header?: string;\n title?: string;\n searchContainer?: string;\n filterContainer?: string;\n toolbar?: string;\n gridContainer?: string;\n empty?: string;\n loading?: string;\n pagination?: string;\n}\n\nexport interface CataloguePageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched products for SSR. When provided, skips client-side fetch. */\n products?: Product[];\n /** Pre-fetched categories for SSR. */\n categories?: Category[];\n /** Show category filter. Default: true. */\n showFilter?: boolean;\n /** Show search input. Default: true. */\n showSearch?: boolean;\n /** Show sort controls. Default: false. */\n showSort?: boolean;\n /** Show in-stock toggle. Default: false. */\n showInStockFilter?: boolean;\n /** Show price range filter. Default: false. */\n showPriceRange?: boolean;\n /** Show tags filter. Default: false. */\n showTagsFilter?: boolean;\n /** Available tags for filtering. When provided, renders tag chips. */\n availableTags?: string[];\n /** Default sort. */\n defaultSort?: { by: \"name\" | \"price\" | \"created_at\" | \"updated_at\"; order: \"asc\" | \"desc\" };\n /** Products per page. */\n pageSize?: number;\n /** Called when a product is selected (e.g. navigate to product page). */\n onProductClick?: (product: Product) => void;\n /** Called when a category is selected. */\n onCategoryClick?: (category: Category) => void;\n /** Custom card renderer passed to ProductGrid. */\n renderCard?: (product: Product) => React.ReactNode;\n /** Custom image renderer passed to ProductGrid. */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Grid column config. */\n columns?: { sm?: number; md?: number; lg?: number; xl?: number };\n className?: string;\n classNames?: CataloguePageClassNames;\n}\n\n/**\n * CataloguePage — browse all products with server-side filtering, sorting, and pagination.\n *\n * SSR-friendly: pass `products` and `categories` props for server rendering.\n * Omit them for fully client-side data fetching.\n */\nexport function CataloguePage({\n title = \"Products\",\n products: productsProp,\n categories: categoriesProp,\n showFilter = true,\n showSearch = true,\n showSort = false,\n showInStockFilter = false,\n showPriceRange = false,\n showTagsFilter = false,\n availableTags,\n defaultSort,\n pageSize,\n onProductClick,\n onCategoryClick,\n renderCard,\n renderImage,\n columns,\n className,\n classNames,\n}: CataloguePageProps): React.ReactElement {\n const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);\n const [searchQuery, setSearchQuery] = useState(\"\");\n const [sortBy, setSortBy] = useState<\"name\" | \"price\" | \"created_at\" | \"updated_at\">(defaultSort?.by ?? \"name\");\n const [sortOrder, setSortOrder] = useState<\"asc\" | \"desc\">(defaultSort?.order ?? \"asc\");\n const [inStockOnly, setInStockOnly] = useState(false);\n const [selectedTags, setSelectedTags] = useState<string[]>([]);\n const [minPrice, setMinPrice] = useState<string>(\"\");\n const [maxPrice, setMaxPrice] = useState<string>(\"\");\n const [page, setPage] = useState(1);\n\n const debouncedSearch = useDebounce(searchQuery, 300);\n\n const { products: fetchedProducts, isLoading: productsLoading, pagination } = useProducts({\n enabled: productsProp === undefined,\n category: selectedCategoryId ?? undefined,\n search: debouncedSearch.length >= 2 ? debouncedSearch : undefined,\n sort_by: sortBy,\n sort_order: sortOrder,\n in_stock: inStockOnly || undefined,\n tags: selectedTags.length > 0 ? selectedTags : undefined,\n min_price: minPrice ? parseFloat(minPrice) : undefined,\n max_price: maxPrice ? parseFloat(maxPrice) : undefined,\n page,\n limit: pageSize,\n });\n const { categories: fetchedCategories } = useCategories({\n enabled: showFilter && categoriesProp === undefined,\n });\n\n const allProducts = productsProp ?? fetchedProducts;\n const categories = categoriesProp ?? fetchedCategories;\n\n const handleCategorySelect = useCallback(\n (id: string | null) => {\n setSelectedCategoryId(id);\n setPage(1);\n if (id) {\n const cat = categories.find((c: Category) => c.id === id);\n if (cat) onCategoryClick?.(cat);\n }\n },\n [categories, onCategoryClick],\n );\n\n const handleTagToggle = useCallback((tag: string) => {\n setSelectedTags((prev) =>\n prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag],\n );\n setPage(1);\n }, []);\n\n const totalPages = pagination?.total_pages ?? 1;\n\n return (\n <div data-cimplify-catalogue-page className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-catalogue-header className={classNames?.header}>\n <h1 data-cimplify-catalogue-title className={classNames?.title}>\n {title}\n </h1>\n\n {showSearch && (\n <div data-cimplify-catalogue-search className={classNames?.searchContainer}>\n <SearchInput\n onResultClick={onProductClick}\n searchOptions={{ category: selectedCategoryId ?? undefined }}\n />\n </div>\n )}\n </div>\n\n {/* Category filter */}\n {showFilter && categories.length > 0 && (\n <div data-cimplify-catalogue-filter className={classNames?.filterContainer}>\n <CategoryFilter\n selectedId={selectedCategoryId}\n onSelect={handleCategorySelect}\n />\n </div>\n )}\n\n {/* Toolbar: sort, in-stock, price range, tags */}\n {(showSort || showInStockFilter || showPriceRange || showTagsFilter) && (\n <div data-cimplify-catalogue-toolbar className={cn(\"flex flex-wrap items-center gap-3\", classNames?.toolbar)}>\n {showSort && (\n <select\n data-cimplify-sort\n value={`${sortBy}:${sortOrder}`}\n onChange={(e) => {\n const [by, order] = e.target.value.split(\":\") as [typeof sortBy, typeof sortOrder];\n setSortBy(by);\n setSortOrder(order);\n setPage(1);\n }}\n className=\"rounded-md border border-input bg-background px-3 py-1.5 text-sm\"\n >\n <option value=\"name:asc\">Name A–Z</option>\n <option value=\"name:desc\">Name Z–A</option>\n <option value=\"price:asc\">Price: Low to High</option>\n <option value=\"price:desc\">Price: High to Low</option>\n <option value=\"created_at:desc\">Newest</option>\n <option value=\"created_at:asc\">Oldest</option>\n <option value=\"updated_at:desc\">Recently Updated</option>\n </select>\n )}\n\n {showInStockFilter && (\n <label data-cimplify-stock-filter className=\"flex items-center gap-1.5 text-sm cursor-pointer\">\n <input\n type=\"checkbox\"\n checked={inStockOnly}\n onChange={(e) => { setInStockOnly(e.target.checked); setPage(1); }}\n className=\"accent-primary\"\n />\n In stock only\n </label>\n )}\n\n {showPriceRange && (\n <div data-cimplify-price-range className=\"flex items-center gap-1.5\">\n <input\n type=\"number\"\n placeholder=\"Min\"\n value={minPrice}\n onChange={(e) => { setMinPrice(e.target.value); setPage(1); }}\n className=\"w-20 rounded-md border border-input bg-background px-2 py-1.5 text-sm\"\n min={0}\n step=\"0.01\"\n />\n <span className=\"text-muted-foreground text-sm\">–</span>\n <input\n type=\"number\"\n placeholder=\"Max\"\n value={maxPrice}\n onChange={(e) => { setMaxPrice(e.target.value); setPage(1); }}\n className=\"w-20 rounded-md border border-input bg-background px-2 py-1.5 text-sm\"\n min={0}\n step=\"0.01\"\n />\n </div>\n )}\n\n {showTagsFilter && availableTags && availableTags.length > 0 && (\n <div data-cimplify-tags-filter className=\"flex flex-wrap gap-1.5\">\n {availableTags.map((tag) => (\n <button\n key={tag}\n type=\"button\"\n onClick={() => handleTagToggle(tag)}\n className={cn(\n \"rounded-full px-3 py-1 text-xs font-medium border transition-colors\",\n selectedTags.includes(tag)\n ? \"bg-primary text-primary-foreground border-primary\"\n : \"bg-background text-foreground border-input hover:bg-muted\",\n )}\n >\n {tag}\n </button>\n ))}\n </div>\n )}\n </div>\n )}\n\n {/* Product grid */}\n <div data-cimplify-catalogue-grid className={classNames?.gridContainer}>\n {productsLoading && !productsProp ? (\n <div\n data-cimplify-catalogue-loading\n aria-busy=\"true\"\n className={classNames?.loading}\n />\n ) : (\n <ProductGrid\n products={allProducts}\n renderCard={renderCard}\n renderImage={renderImage}\n columns={columns}\n emptyMessage=\"No products found\"\n />\n )}\n </div>\n\n {/* Pagination */}\n {totalPages > 1 && (\n <div data-cimplify-catalogue-pagination className={cn(\"flex items-center justify-center gap-2 pt-6\", classNames?.pagination)}>\n <button\n type=\"button\"\n disabled={page <= 1}\n onClick={() => setPage((p) => Math.max(1, p - 1))}\n className=\"rounded-md border border-input bg-background px-3 py-1.5 text-sm disabled:opacity-40\"\n >\n Previous\n </button>\n <span className=\"text-sm text-muted-foreground tabular-nums\">\n {page} / {totalPages}\n </span>\n <button\n type=\"button\"\n disabled={page >= totalPages}\n onClick={() => setPage((p) => p + 1)}\n className=\"rounded-md border border-input bg-background px-3 py-1.5 text-sm disabled:opacity-40\"\n >\n Next\n </button>\n </div>\n )}\n </div>\n );\n}\n\nfunction useDebounce(value: string, delay: number): string {\n const [debounced, setDebounced] = React.useState(value);\n React.useEffect(() => {\n const timer = setTimeout(() => setDebounced(value), delay);\n return () => clearTimeout(timer);\n }, [value, delay]);\n return debounced;\n}\n"
|
|
16
16
|
}
|
|
17
17
|
]
|
|
18
18
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "checkout-page.tsx",
|
|
12
|
-
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { ProcessCheckoutResult } from \"@cimplify/sdk\";\nimport {
|
|
12
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { ProcessCheckoutResult } from \"@cimplify/sdk\";\nimport { useCimplifyClient } from \"@cimplify/sdk/react\";\nimport { CimplifyCheckout } from \"@cimplify/sdk/react\";\nimport type { CimplifyCheckoutProps } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface CheckoutPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n checkout?: string;\n}\n\nexport interface CheckoutPageProps {\n /** Page title. */\n title?: string;\n /** Called after successful checkout. */\n onComplete: (result: ProcessCheckoutResult) => void;\n /** Called on checkout failure. */\n onError?: (error: { code: string; message: string }) => void;\n /** Props forwarded to CimplifyCheckout. */\n checkoutProps?: Partial<\n Omit<CimplifyCheckoutProps, \"client\" | \"onComplete\" | \"onError\">\n >;\n className?: string;\n classNames?: CheckoutPageClassNames;\n}\n\n/**\n * CheckoutPage — thin page shell around CimplifyCheckout.\n *\n * Reads the CimplifyClient from CimplifyProvider context,\n * renders a page header, and delegates all checkout logic\n * to CimplifyCheckout.\n */\nexport function CheckoutPage({\n title = \"Checkout\",\n onComplete,\n onError,\n checkoutProps,\n className,\n classNames,\n}: CheckoutPageProps): React.ReactElement {\n const { client } = useCimplifyClient();\n\n return (\n <div data-cimplify-checkout-page className={cn(className, classNames?.root)}>\n <div data-cimplify-checkout-header className={classNames?.header}>\n <h1 data-cimplify-checkout-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n <CimplifyCheckout\n client={client}\n onComplete={onComplete}\n onError={onError}\n className={classNames?.checkout}\n {...checkoutProps}\n />\n </div>\n );\n}\n"
|
|
13
13
|
}
|
|
14
14
|
]
|
|
15
15
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "composite-selector.tsx",
|
|
12
|
-
"content": "\"use client\";\n\nimport React, { useState, useCallback, useMemo, useEffect } from \"react\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport { NumberField } from \"@base-ui/react/number-field\";\nimport type {\n CompositeGroupView,\n CompositeComponentView,\n ComponentSelectionInput,\n CompositePriceResult,\n} from \"@cimplify/sdk\";\nimport { useCimplify } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface CompositeSelectorClassNames {\n root?: string;\n group?: string;\n groupHeader?: string;\n groupName?: string;\n required?: string;\n groupDescription?: string;\n groupConstraint?: string;\n validation?: string;\n components?: string;\n component?: string;\n componentSelected?: string;\n componentInfo?: string;\n componentName?: string;\n badgePopular?: string;\n badgePremium?: string;\n componentDescription?: string;\n componentCalories?: string;\n qty?: string;\n qtyButton?: string;\n qtyValue?: string;\n summary?: string;\n summaryLine?: string;\n summaryTotal?: string;\n calculating?: string;\n priceError?: string;\n}\n\nexport interface CompositeSelectorProps {\n compositeId: string;\n groups: CompositeGroupView[];\n onSelectionsChange: (selections: ComponentSelectionInput[]) => void;\n onPriceChange?: (price: CompositePriceResult | null) => void;\n onReady?: (ready: boolean) => void;\n skipPriceFetch?: boolean;\n className?: string;\n classNames?: CompositeSelectorClassNames;\n}\n\nexport function CompositeSelector({\n compositeId,\n groups,\n onSelectionsChange,\n onPriceChange,\n onReady,\n skipPriceFetch,\n className,\n classNames,\n}: CompositeSelectorProps): React.ReactElement | null {\n const { client } = useCimplify();\n\n const [groupSelections, setGroupSelections] = useState<\n Record<string, Record<string, number>>\n >({});\n const [priceResult, setPriceResult] = useState<CompositePriceResult | null>(null);\n const [isPriceLoading, setIsPriceLoading] = useState(false);\n const [priceError, setPriceError] = useState(false);\n\n const selections = useMemo((): ComponentSelectionInput[] => {\n const result: ComponentSelectionInput[] = [];\n for (const groupSels of Object.values(groupSelections)) {\n for (const [componentId, qty] of Object.entries(groupSels)) {\n if (qty > 0) {\n result.push({ component_id: componentId, quantity: qty });\n }\n }\n }\n return result;\n }, [groupSelections]);\n\n useEffect(() => {\n onSelectionsChange(selections);\n }, [selections, onSelectionsChange]);\n\n useEffect(() => {\n onPriceChange?.(priceResult);\n }, [priceResult, onPriceChange]);\n\n const allGroupsSatisfied = useMemo(() => {\n for (const group of groups) {\n const groupSels = groupSelections[group.id] || {};\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n if (totalSelected < group.min_selections) return false;\n }\n return true;\n }, [groups, groupSelections]);\n\n useEffect(() => {\n onReady?.(allGroupsSatisfied);\n }, [allGroupsSatisfied, onReady]);\n\n useEffect(() => {\n if (skipPriceFetch || !allGroupsSatisfied || selections.length === 0) return;\n\n let cancelled = false;\n const timer = setTimeout(() => {\n void (async () => {\n setIsPriceLoading(true);\n setPriceError(false);\n try {\n const result = await client.catalogue.calculateCompositePrice(compositeId, selections);\n if (cancelled) return;\n if (result.ok) {\n setPriceResult(result.value);\n } else {\n setPriceError(true);\n }\n } catch {\n if (!cancelled) setPriceError(true);\n } finally {\n if (!cancelled) setIsPriceLoading(false);\n }\n })();\n }, 300);\n\n return () => {\n cancelled = true;\n clearTimeout(timer);\n };\n }, [selections, allGroupsSatisfied, compositeId, client, skipPriceFetch]);\n\n const toggleComponent = useCallback(\n (group: CompositeGroupView, component: CompositeComponentView) => {\n setGroupSelections((prev) => {\n const groupSels = { ...(prev[group.id] || {}) };\n const currentQty = groupSels[component.id] || 0;\n\n if (currentQty > 0) {\n if (group.min_selections > 0) {\n const totalOthers = Object.entries(groupSels)\n .filter(([id]) => id !== component.id)\n .reduce((sum, [, q]) => sum + q, 0);\n if (totalOthers < group.min_selections) {\n return prev;\n }\n }\n delete groupSels[component.id];\n } else {\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n if (group.max_selections && totalSelected >= group.max_selections) {\n if (group.max_selections === 1) {\n return { ...prev, [group.id]: { [component.id]: 1 } };\n }\n return prev;\n }\n groupSels[component.id] = 1;\n }\n\n return { ...prev, [group.id]: groupSels };\n });\n },\n [],\n );\n\n const updateQuantity = useCallback(\n (group: CompositeGroupView, componentId: string, newValue: number) => {\n setGroupSelections((prev) => {\n const groupSels = { ...(prev[group.id] || {}) };\n const current = groupSels[componentId] || 0;\n const next = Math.max(0, newValue);\n\n if (next === current) return prev;\n\n const delta = next - current;\n\n if (group.max_quantity_per_component && next > group.max_quantity_per_component) {\n return prev;\n }\n\n const totalAfter = Object.entries(groupSels)\n .reduce((sum, [id, q]) => sum + (id === componentId ? next : q), 0);\n\n if (delta > 0 && group.max_selections && totalAfter > group.max_selections) {\n return prev;\n }\n\n if (delta < 0 && totalAfter < group.min_selections) {\n return prev;\n }\n\n if (next === 0) {\n delete groupSels[componentId];\n } else {\n groupSels[componentId] = next;\n }\n\n return { ...prev, [group.id]: groupSels };\n });\n },\n [],\n );\n\n if (groups.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-composite-selector className={cn(\"space-y-6\", className, classNames?.root)}>\n {[...groups]\n .sort((a, b) => a.display_order - b.display_order)\n .map((group) => {\n const groupSels = groupSelections[group.id] || {};\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n const minMet = totalSelected >= group.min_selections;\n const isSingleSelect = group.max_selections === 1;\n\n return (\n <div\n key={group.id}\n data-cimplify-composite-group\n className={cn(classNames?.group)}\n >\n <div\n data-cimplify-composite-group-header\n className={cn(\"flex items-center justify-between py-3\", classNames?.groupHeader)}\n >\n <div>\n <span\n data-cimplify-composite-group-name\n className={cn(\"text-base font-bold\", classNames?.groupName)}\n >\n {group.name}\n </span>\n {group.description && (\n <span\n data-cimplify-composite-group-description\n className={cn(\"block text-xs text-muted-foreground mt-0.5\", classNames?.groupDescription)}\n >\n {group.description}\n </span>\n )}\n <span\n data-cimplify-composite-group-constraint\n className={cn(\"block text-xs text-muted-foreground mt-0.5\", classNames?.groupConstraint)}\n >\n {group.min_selections > 0 && group.max_selections\n ? `Choose ${group.min_selections}\\u2013${group.max_selections}`\n : group.min_selections > 0\n ? `Choose at least ${group.min_selections}`\n : group.max_selections\n ? `Choose up to ${group.max_selections}`\n : \"Choose as many as you like\"}\n </span>\n </div>\n {group.min_selections > 0 && (\n <span\n data-cimplify-composite-required\n className={cn(\n \"text-xs font-semibold px-2.5 py-1 rounded shrink-0\",\n !minMet\n ? \"text-destructive bg-destructive/10\"\n : \"text-destructive bg-destructive/10\",\n classNames?.required,\n )}\n >\n Required\n </span>\n )}\n </div>\n\n <div\n data-cimplify-composite-components\n role={isSingleSelect ? \"radiogroup\" : \"group\"}\n aria-label={group.name}\n className={cn(\"divide-y divide-border\", classNames?.components)}\n >\n {group.components\n .filter((c) => c.is_available && !c.is_archived)\n .sort((a, b) => a.display_order - b.display_order)\n .map((component) => {\n const qty = groupSels[component.id] || 0;\n const isSelected = qty > 0;\n const displayName = component.display_name || component.id;\n\n return (\n <Checkbox.Root\n key={component.id}\n checked={isSelected}\n onCheckedChange={() => toggleComponent(group, component)}\n value={component.id}\n data-cimplify-composite-component\n data-selected={isSelected || undefined}\n className={cn(\n \"w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer\",\n isSelected ? classNames?.componentSelected : classNames?.component,\n )}\n >\n <Checkbox.Indicator\n className=\"hidden\"\n keepMounted={false}\n />\n\n {/* Visual indicator: radio circle for single-select, checkbox square for multi-select */}\n {isSingleSelect ? (\n <span\n data-cimplify-composite-radio\n className={cn(\n \"w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors\",\n isSelected ? \"border-primary\" : \"border-muted-foreground/30\",\n )}\n >\n {isSelected && <span className=\"w-2.5 h-2.5 rounded-full bg-primary\" />}\n </span>\n ) : (\n <span\n data-cimplify-composite-checkbox\n className={cn(\n \"w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors\",\n isSelected ? \"border-primary bg-primary\" : \"border-muted-foreground/30\",\n )}\n >\n {isSelected && (\n <svg viewBox=\"0 0 12 12\" className=\"w-3 h-3 text-primary-foreground\" fill=\"none\">\n <path d=\"M2 6l3 3 5-5\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"/>\n </svg>\n )}\n </span>\n )}\n\n <div\n data-cimplify-composite-component-info\n className={cn(\"flex-1 min-w-0\", classNames?.componentInfo)}\n >\n <span\n data-cimplify-composite-component-name\n className={cn(\"text-sm\", classNames?.componentName)}\n >\n {displayName}\n </span>\n {component.is_popular && (\n <span\n data-cimplify-composite-badge=\"popular\"\n className={cn(\"text-[10px] uppercase tracking-wider text-primary font-medium\", classNames?.badgePopular)}\n >\n Popular\n </span>\n )}\n {component.is_premium && (\n <span\n data-cimplify-composite-badge=\"premium\"\n className={cn(\"text-[10px] uppercase tracking-wider text-amber-600 font-medium\", classNames?.badgePremium)}\n >\n Premium\n </span>\n )}\n {component.display_description && (\n <span\n data-cimplify-composite-component-description\n className={cn(\"block text-xs text-muted-foreground truncate\", classNames?.componentDescription)}\n >\n {component.display_description}\n </span>\n )}\n {component.calories != null && (\n <span\n data-cimplify-composite-component-calories\n className={cn(\"block text-xs text-muted-foreground/60\", classNames?.componentCalories)}\n >\n {component.calories} cal\n </span>\n )}\n </div>\n\n {group.allow_quantity && isSelected && (\n <NumberField.Root\n value={qty}\n onValueChange={(val) => {\n if (val != null) {\n updateQuantity(group, component.id, val);\n }\n }}\n min={0}\n max={group.max_quantity_per_component || undefined}\n step={1}\n >\n <NumberField.Group\n data-cimplify-composite-qty\n onClick={(e: React.MouseEvent) => e.stopPropagation()}\n className={cn(\"flex items-center gap-2\", classNames?.qty)}\n >\n <NumberField.Decrement\n aria-label={`Decrease ${displayName} quantity`}\n className={cn(\"w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30\", classNames?.qtyButton)}\n >\n −\n </NumberField.Decrement>\n <NumberField.Input\n readOnly\n className={cn(\"w-4 text-center text-sm font-medium bg-transparent border-none outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\", classNames?.qtyValue)}\n />\n <NumberField.Increment\n aria-label={`Increase ${displayName} quantity`}\n className={cn(\"w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30\", classNames?.qtyButton)}\n >\n +\n </NumberField.Increment>\n </NumberField.Group>\n </NumberField.Root>\n )}\n\n {component.price != null && (\n <span className=\"text-sm text-muted-foreground shrink-0\">\n +<Price amount={component.price} />\n </span>\n )}\n </Checkbox.Root>\n );\n })}\n </div>\n </div>\n );\n })}\n\n {priceResult && (\n <div\n data-cimplify-composite-summary\n className={cn(\"border-t border-border pt-4 space-y-1 text-sm\", classNames?.summary)}\n >\n {parsePrice(priceResult.base_price) !== 0 && (\n <div\n data-cimplify-composite-summary-line\n className={cn(\"flex justify-between text-muted-foreground\", classNames?.summaryLine)}\n >\n <span>Base</span>\n <Price amount={priceResult.base_price} />\n </div>\n )}\n {parsePrice(priceResult.components_total) !== 0 && (\n <div\n data-cimplify-composite-summary-line\n className={cn(\"flex justify-between text-muted-foreground\", classNames?.summaryLine)}\n >\n <span>Selections</span>\n <Price amount={priceResult.components_total} />\n </div>\n )}\n <div\n data-cimplify-composite-summary-total\n className={cn(\"flex justify-between font-medium pt-1 border-t border-border\", classNames?.summaryTotal)}\n >\n <span>Total</span>\n <Price amount={priceResult.final_price} className=\"text-primary\" />\n </div>\n </div>\n )}\n\n {isPriceLoading && (\n <div\n data-cimplify-composite-calculating\n className={cn(\"flex items-center gap-2 text-sm text-muted-foreground\", classNames?.calculating)}\n >\n Calculating price...\n </div>\n )}\n\n {priceError && !isPriceLoading && (\n <div\n data-cimplify-composite-price-error\n className={cn(\"text-sm text-destructive\", classNames?.priceError)}\n >\n Unable to calculate price\n </div>\n )}\n </div>\n );\n}\n"
|
|
12
|
+
"content": "\"use client\";\n\nimport React, { useState, useCallback, useMemo, useEffect } from \"react\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport { NumberField } from \"@base-ui/react/number-field\";\nimport type {\n CompositeGroupView,\n CompositeComponentView,\n ComponentSelectionInput,\n CompositePriceResult,\n} from \"@cimplify/sdk\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { parsePrice } from \"@cimplify/sdk\";\nimport { cn } from \"@cimplify/sdk/react\";\nimport { useCimplifyClient } from \"@cimplify/sdk/react\";\n\nexport interface CompositeSelectorClassNames {\n root?: string;\n group?: string;\n groupHeader?: string;\n groupName?: string;\n required?: string;\n groupDescription?: string;\n groupConstraint?: string;\n validation?: string;\n components?: string;\n component?: string;\n componentSelected?: string;\n componentInfo?: string;\n componentName?: string;\n badgePopular?: string;\n badgePremium?: string;\n componentDescription?: string;\n componentCalories?: string;\n qty?: string;\n qtyButton?: string;\n qtyValue?: string;\n summary?: string;\n summaryLine?: string;\n summaryTotal?: string;\n calculating?: string;\n priceError?: string;\n}\n\nexport interface CompositeSelectorProps {\n compositeId: string;\n groups: CompositeGroupView[];\n onSelectionsChange: (selections: ComponentSelectionInput[]) => void;\n onPriceChange?: (price: CompositePriceResult | null) => void;\n onReady?: (ready: boolean) => void;\n skipPriceFetch?: boolean;\n className?: string;\n classNames?: CompositeSelectorClassNames;\n}\n\nexport function CompositeSelector({\n compositeId,\n groups,\n onSelectionsChange,\n onPriceChange,\n onReady,\n skipPriceFetch,\n className,\n classNames,\n}: CompositeSelectorProps): React.ReactElement | null {\n const { client } = useCimplifyClient();\n\n const [groupSelections, setGroupSelections] = useState<\n Record<string, Record<string, number>>\n >({});\n const [priceResult, setPriceResult] = useState<CompositePriceResult | null>(null);\n const [isPriceLoading, setIsPriceLoading] = useState(false);\n const [priceError, setPriceError] = useState(false);\n\n const selections = useMemo((): ComponentSelectionInput[] => {\n const result: ComponentSelectionInput[] = [];\n for (const groupSels of Object.values(groupSelections)) {\n for (const [componentId, qty] of Object.entries(groupSels)) {\n if (qty > 0) {\n result.push({ component_id: componentId, quantity: qty });\n }\n }\n }\n return result;\n }, [groupSelections]);\n\n useEffect(() => {\n onSelectionsChange(selections);\n }, [selections, onSelectionsChange]);\n\n useEffect(() => {\n onPriceChange?.(priceResult);\n }, [priceResult, onPriceChange]);\n\n const allGroupsSatisfied = useMemo(() => {\n for (const group of groups) {\n const groupSels = groupSelections[group.id] || {};\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n if (totalSelected < group.min_selections) return false;\n }\n return true;\n }, [groups, groupSelections]);\n\n const sortedGroups = useMemo(\n () =>\n [...groups]\n .sort((a, b) => a.display_order - b.display_order)\n .map((group) => ({\n ...group,\n _sortedComponents: group.components\n .filter((component) => component.is_available && !component.is_archived)\n .sort((a, b) => a.display_order - b.display_order),\n })),\n [groups],\n );\n\n useEffect(() => {\n onReady?.(allGroupsSatisfied);\n }, [allGroupsSatisfied, onReady]);\n\n useEffect(() => {\n if (skipPriceFetch || !allGroupsSatisfied || selections.length === 0) return;\n\n let cancelled = false;\n const timer = setTimeout(() => {\n void (async () => {\n setIsPriceLoading(true);\n setPriceError(false);\n try {\n const result = await client.catalogue.calculateCompositePrice(compositeId, selections);\n if (cancelled) return;\n if (result.ok) {\n setPriceResult(result.value);\n } else {\n setPriceError(true);\n }\n } catch {\n if (!cancelled) setPriceError(true);\n } finally {\n if (!cancelled) setIsPriceLoading(false);\n }\n })();\n }, 300);\n\n return () => {\n cancelled = true;\n clearTimeout(timer);\n };\n }, [selections, allGroupsSatisfied, compositeId, client, skipPriceFetch]);\n\n const toggleComponent = useCallback(\n (group: CompositeGroupView, component: CompositeComponentView) => {\n setGroupSelections((prev) => {\n const groupSels = { ...(prev[group.id] || {}) };\n const currentQty = groupSels[component.id] || 0;\n\n if (currentQty > 0) {\n if (group.min_selections > 0) {\n const totalOthers = Object.entries(groupSels)\n .filter(([id]) => id !== component.id)\n .reduce((sum, [, q]) => sum + q, 0);\n if (totalOthers < group.min_selections) {\n return prev;\n }\n }\n delete groupSels[component.id];\n } else {\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n if (group.max_selections && totalSelected >= group.max_selections) {\n if (group.max_selections === 1) {\n return { ...prev, [group.id]: { [component.id]: 1 } };\n }\n return prev;\n }\n groupSels[component.id] = 1;\n }\n\n return { ...prev, [group.id]: groupSels };\n });\n },\n [],\n );\n\n const updateQuantity = useCallback(\n (group: CompositeGroupView, componentId: string, newValue: number) => {\n setGroupSelections((prev) => {\n const groupSels = { ...(prev[group.id] || {}) };\n const current = groupSels[componentId] || 0;\n const next = Math.max(0, newValue);\n\n if (next === current) return prev;\n\n const delta = next - current;\n\n if (group.max_quantity_per_component && next > group.max_quantity_per_component) {\n return prev;\n }\n\n const totalAfter = Object.entries(groupSels)\n .reduce((sum, [id, q]) => sum + (id === componentId ? next : q), 0);\n\n if (delta > 0 && group.max_selections && totalAfter > group.max_selections) {\n return prev;\n }\n\n if (delta < 0 && totalAfter < group.min_selections) {\n return prev;\n }\n\n if (next === 0) {\n delete groupSels[componentId];\n } else {\n groupSels[componentId] = next;\n }\n\n return { ...prev, [group.id]: groupSels };\n });\n },\n [],\n );\n\n if (groups.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-composite-selector className={cn(\"space-y-6\", className, classNames?.root)}>\n {sortedGroups.map((group) => {\n const groupSels = groupSelections[group.id] || {};\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n const minMet = totalSelected >= group.min_selections;\n const isSingleSelect = group.max_selections === 1;\n\n return (\n <div\n key={group.id}\n data-cimplify-composite-group\n className={cn(classNames?.group)}\n >\n <div\n data-cimplify-composite-group-header\n className={cn(\"flex items-center justify-between py-3\", classNames?.groupHeader)}\n >\n <div>\n <span\n data-cimplify-composite-group-name\n className={cn(\"text-base font-bold\", classNames?.groupName)}\n >\n {group.name}\n </span>\n {group.description && (\n <span\n data-cimplify-composite-group-description\n className={cn(\"block text-xs text-muted-foreground mt-0.5\", classNames?.groupDescription)}\n >\n {group.description}\n </span>\n )}\n <span\n data-cimplify-composite-group-constraint\n className={cn(\"block text-xs text-muted-foreground mt-0.5\", classNames?.groupConstraint)}\n >\n {group.min_selections > 0 && group.max_selections\n ? `Choose ${group.min_selections}\\u2013${group.max_selections}`\n : group.min_selections > 0\n ? `Choose at least ${group.min_selections}`\n : group.max_selections\n ? `Choose up to ${group.max_selections}`\n : \"Choose as many as you like\"}\n </span>\n </div>\n {group.min_selections > 0 && (\n <span\n data-cimplify-composite-required\n className={cn(\n \"text-xs font-semibold px-2.5 py-1 rounded shrink-0\",\n !minMet\n ? \"text-destructive bg-destructive/10\"\n : \"text-destructive bg-destructive/10\",\n classNames?.required,\n )}\n >\n Required\n </span>\n )}\n </div>\n\n <div\n data-cimplify-composite-components\n role={isSingleSelect ? \"radiogroup\" : \"group\"}\n aria-label={group.name}\n className={cn(\"divide-y divide-border\", classNames?.components)}\n >\n {group._sortedComponents.map((component) => {\n const qty = groupSels[component.id] || 0;\n const isSelected = qty > 0;\n const displayName = component.display_name || component.id;\n\n return (\n <Checkbox.Root\n key={component.id}\n checked={isSelected}\n onCheckedChange={() => toggleComponent(group, component)}\n value={component.id}\n data-cimplify-composite-component\n data-selected={isSelected || undefined}\n className={cn(\n \"w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer\",\n isSelected ? classNames?.componentSelected : classNames?.component,\n )}\n >\n <Checkbox.Indicator\n className=\"hidden\"\n keepMounted={false}\n />\n\n {/* Visual indicator: radio circle for single-select, checkbox square for multi-select */}\n {isSingleSelect ? (\n <span\n data-cimplify-composite-radio\n className={cn(\n \"w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors\",\n isSelected ? \"border-primary\" : \"border-muted-foreground/30\",\n )}\n >\n {isSelected && <span className=\"w-2.5 h-2.5 rounded-full bg-primary\" />}\n </span>\n ) : (\n <span\n data-cimplify-composite-checkbox\n className={cn(\n \"w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors\",\n isSelected ? \"border-primary bg-primary\" : \"border-muted-foreground/30\",\n )}\n >\n {isSelected && (\n <svg viewBox=\"0 0 12 12\" className=\"w-3 h-3 text-primary-foreground\" fill=\"none\">\n <path d=\"M2 6l3 3 5-5\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"/>\n </svg>\n )}\n </span>\n )}\n\n <div\n data-cimplify-composite-component-info\n className={cn(\"flex-1 min-w-0\", classNames?.componentInfo)}\n >\n <span\n data-cimplify-composite-component-name\n className={cn(\"text-sm\", classNames?.componentName)}\n >\n {displayName}\n </span>\n {component.is_popular && (\n <span\n data-cimplify-composite-badge=\"popular\"\n className={cn(\"text-[10px] uppercase tracking-wider text-primary font-medium\", classNames?.badgePopular)}\n >\n Popular\n </span>\n )}\n {component.is_premium && (\n <span\n data-cimplify-composite-badge=\"premium\"\n className={cn(\"text-[10px] uppercase tracking-wider text-amber-600 font-medium\", classNames?.badgePremium)}\n >\n Premium\n </span>\n )}\n {component.display_description && (\n <span\n data-cimplify-composite-component-description\n className={cn(\"block text-xs text-muted-foreground truncate\", classNames?.componentDescription)}\n >\n {component.display_description}\n </span>\n )}\n {component.calories != null && (\n <span\n data-cimplify-composite-component-calories\n className={cn(\"block text-xs text-muted-foreground/60\", classNames?.componentCalories)}\n >\n {component.calories} cal\n </span>\n )}\n </div>\n\n {group.allow_quantity && isSelected && (\n <NumberField.Root\n value={qty}\n onValueChange={(val) => {\n if (val != null) {\n updateQuantity(group, component.id, val);\n }\n }}\n min={0}\n max={group.max_quantity_per_component || undefined}\n step={1}\n >\n <NumberField.Group\n data-cimplify-composite-qty\n onClick={(e: React.MouseEvent) => e.stopPropagation()}\n className={cn(\"flex items-center gap-2\", classNames?.qty)}\n >\n <NumberField.Decrement\n aria-label={`Decrease ${displayName} quantity`}\n className={cn(\"w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30\", classNames?.qtyButton)}\n >\n −\n </NumberField.Decrement>\n <NumberField.Input\n readOnly\n className={cn(\"w-4 text-center text-sm font-medium bg-transparent border-none outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\", classNames?.qtyValue)}\n />\n <NumberField.Increment\n aria-label={`Increase ${displayName} quantity`}\n className={cn(\"w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30\", classNames?.qtyButton)}\n >\n +\n </NumberField.Increment>\n </NumberField.Group>\n </NumberField.Root>\n )}\n\n {component.price != null && (\n <span className=\"text-sm text-muted-foreground shrink-0\">\n +<Price amount={component.price} />\n </span>\n )}\n </Checkbox.Root>\n );\n })}\n </div>\n </div>\n );\n })}\n\n {priceResult && (\n <div\n data-cimplify-composite-summary\n className={cn(\"border-t border-border pt-4 space-y-1 text-sm\", classNames?.summary)}\n >\n {parsePrice(priceResult.base_price) !== 0 && (\n <div\n data-cimplify-composite-summary-line\n className={cn(\"flex justify-between text-muted-foreground\", classNames?.summaryLine)}\n >\n <span>Base</span>\n <Price amount={priceResult.base_price} />\n </div>\n )}\n {parsePrice(priceResult.components_total) !== 0 && (\n <div\n data-cimplify-composite-summary-line\n className={cn(\"flex justify-between text-muted-foreground\", classNames?.summaryLine)}\n >\n <span>Selections</span>\n <Price amount={priceResult.components_total} />\n </div>\n )}\n <div\n data-cimplify-composite-summary-total\n className={cn(\"flex justify-between font-medium pt-1 border-t border-border\", classNames?.summaryTotal)}\n >\n <span>Total</span>\n <Price amount={priceResult.final_price} className=\"text-primary\" />\n </div>\n </div>\n )}\n\n {isPriceLoading && (\n <div\n data-cimplify-composite-calculating\n className={cn(\"flex items-center gap-2 text-sm text-muted-foreground\", classNames?.calculating)}\n >\n Calculating price...\n </div>\n )}\n\n {priceError && !isPriceLoading && (\n <div\n data-cimplify-composite-price-error\n className={cn(\"text-sm text-destructive\", classNames?.priceError)}\n >\n Unable to calculate price\n </div>\n )}\n </div>\n );\n}\n"
|
|
13
13
|
}
|
|
14
14
|
]
|
|
15
15
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
{
|
|
12
12
|
"path": "product-grid.tsx",
|
|
13
|
-
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { Product } from \"@cimplify/sdk\";\nimport { ProductCard } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface ProductGridClassNames {\n root?: string;\n item?: string;\n empty?: string;\n}\n\nexport interface ProductGridProps {\n /** Products to display in the grid. */\n products: Product[];\n /** Responsive column counts at each breakpoint. */\n columns?: { sm?: number; md?: number; lg?: number; xl?: number };\n /** Custom card renderer per product. */\n renderCard?: (product: Product) => React.ReactNode;\n /** Custom image renderer passed to default ProductCards. */\n renderImage?: (props: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n /** Text shown when `products` is empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: ProductGridClassNames;\n}\n\n/**\n * ProductGrid — responsive CSS grid that renders ProductCards.\n *\n * Injects an inline `<style>` tag with media queries for responsive columns.\n * Uses `React.useId()` for a hydration-safe, collision-free CSS selector.\n */\nexport function ProductGrid({\n products,\n columns,\n renderCard,\n renderImage,\n emptyMessage,\n className,\n classNames,\n}: ProductGridProps): React.ReactElement {\n const rawId = React.useId();\n // CSS selectors can't contain colons, so strip them from the React-generated ID\n const gridId = `cimplify-grid-${rawId.replace(/:/g, \"\")}`;\n\n const sm = columns?.sm ?? 1;\n const md = columns?.md ?? 2;\n const lg = columns?.lg ?? 3;\n const xl = columns?.xl ?? 4;\n
|
|
13
|
+
"content": "\"use client\";\n\nimport React from \"react\";\nimport type { Product } from \"@cimplify/sdk\";\nimport { ProductCard } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface ProductGridClassNames {\n root?: string;\n item?: string;\n empty?: string;\n}\n\nexport interface ProductGridProps {\n /** Products to display in the grid. */\n products: Product[];\n /** Responsive column counts at each breakpoint. */\n columns?: { sm?: number; md?: number; lg?: number; xl?: number };\n /** Custom card renderer per product. */\n renderCard?: (product: Product) => React.ReactNode;\n /** Custom image renderer passed to default ProductCards. */\n renderImage?: (props: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n /** Text shown when `products` is empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: ProductGridClassNames;\n}\n\n/**\n * ProductGrid — responsive CSS grid that renders ProductCards.\n *\n * Injects an inline `<style>` tag with media queries for responsive columns.\n * Uses `React.useId()` for a hydration-safe, collision-free CSS selector.\n */\nexport function ProductGrid({\n products,\n columns,\n renderCard,\n renderImage,\n emptyMessage,\n className,\n classNames,\n}: ProductGridProps): React.ReactElement {\n const rawId = React.useId();\n // CSS selectors can't contain colons, so strip them from the React-generated ID\n const gridId = `cimplify-grid-${rawId.replace(/:/g, \"\")}`;\n\n const sm = columns?.sm ?? 1;\n const md = columns?.md ?? 2;\n const lg = columns?.lg ?? 3;\n const xl = columns?.xl ?? 4;\n const css = React.useMemo(\n () =>\n [\n `#${gridId}{display:grid;grid-template-columns:repeat(${sm},1fr);gap:1rem}`,\n `@media(min-width:768px){#${gridId}{grid-template-columns:repeat(${md},1fr)}}`,\n `@media(min-width:1024px){#${gridId}{grid-template-columns:repeat(${lg},1fr)}}`,\n `@media(min-width:1280px){#${gridId}{grid-template-columns:repeat(${xl},1fr)}}`,\n ].join(\"\"),\n [gridId, sm, md, lg, xl],\n );\n\n if (products.length === 0) {\n return (\n <div\n data-cimplify-product-grid\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage ?? \"No products found\"}</p>\n </div>\n );\n }\n\n return (\n <>\n <style dangerouslySetInnerHTML={{ __html: css }} />\n <div\n id={gridId}\n data-cimplify-product-grid\n className={cn(className, classNames?.root)}\n >\n {products.map((product) => (\n <div\n key={product.id}\n data-cimplify-product-grid-item\n className={classNames?.item}\n >\n {renderCard\n ? renderCard(product)\n : (\n <ProductCard\n product={product}\n renderImage={renderImage}\n />\n )}\n </div>\n ))}\n </div>\n </>\n );\n}\n"
|
|
14
14
|
}
|
|
15
15
|
]
|
|
16
16
|
}
|