create-brainerce-store 1.51.0 → 1.52.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.
Files changed (170) hide show
  1. package/README.md +72 -0
  2. package/dist/index.js +9 -68
  3. package/messages/en.json +32 -0
  4. package/messages/he.json +32 -0
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.claude/commands/design.md +9 -0
  7. package/templates/nextjs/base/AI-GUIDE.md +52 -3
  8. package/templates/nextjs/base/CLAUDE.md +10 -0
  9. package/templates/nextjs/base/package.json.ejs +52 -39
  10. package/templates/nextjs/base/src/app/globals.css +36 -30
  11. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -10
  12. package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
  13. package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
  14. package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
  15. package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
  16. package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
  17. package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
  18. package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
  19. package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
  20. package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
  21. package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
  22. package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
  23. package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
  24. package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
  25. package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
  26. package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
  27. package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
  28. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +17 -11
  29. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +2 -8
  30. package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -39
  31. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +3 -1
  32. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +8 -23
  33. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +18 -27
  34. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +10 -21
  35. package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +2 -3
  36. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -103
  37. package/templates/nextjs/base/src/ui/home/hero-section.tsx +4 -6
  38. package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -125
  39. package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -103
  40. package/templates/nextjs/base/src/ui/layout/header-account.tsx +2 -17
  41. package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -70
  42. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -164
  43. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -182
  44. package/templates/nextjs/base/src/ui/product/customization-fields.tsx +456 -444
  45. package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -22
  46. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +15 -27
  47. package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -175
  48. package/templates/nextjs/base/src/ui/product/price-display.tsx +3 -2
  49. package/templates/nextjs/base/src/ui/product/product-card.tsx +238 -271
  50. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -470
  51. package/templates/nextjs/base/src/ui/product/product-listing.tsx +96 -79
  52. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -107
  53. package/templates/nextjs/base/src/ui/product/review-form.tsx +44 -43
  54. package/templates/nextjs/{designs/passepartout → base/src}/ui/product/reviews-section.tsx.ejs +108 -96
  55. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -63
  56. package/templates/nextjs/base/tailwind.config.ts +69 -42
  57. package/templates/nextjs/designs/{passepartout → atelier}/app-overlay/layout.tsx.ejs +23 -25
  58. package/templates/nextjs/designs/atelier/globals.css +396 -0
  59. package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
  60. package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
  61. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-bundle-offer.tsx +110 -111
  62. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
  63. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
  64. package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
  65. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
  66. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-upgrade-banner.tsx +123 -141
  67. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
  68. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
  69. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/free-shipping-bar.tsx +77 -62
  70. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/reservation-countdown.tsx +89 -103
  71. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
  72. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
  73. package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
  74. package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
  75. package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
  76. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
  77. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
  78. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
  79. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
  80. package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
  81. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
  82. package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
  83. package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
  84. package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
  85. package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
  86. package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
  87. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
  88. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
  89. package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
  90. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/discount-badge.tsx +17 -22
  91. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/frequently-bought-together.tsx +196 -205
  92. package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
  93. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
  94. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
  95. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
  96. package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
  97. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
  98. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
  99. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/review-form.tsx +267 -294
  100. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
  101. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
  102. package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
  103. package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
  104. package/templates/nextjs/designs/{passepartout → atelier}/ui/shared/fly-to-cart.ts +86 -85
  105. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
  106. package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
  107. package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
  108. package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
  109. package/templates/nextjs/themes/luxury/globals.css +405 -399
  110. package/templates/nextjs/themes/minimal/globals.css +36 -30
  111. package/templates/nextjs/themes/playful/globals.css +406 -400
  112. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +2 -0
  113. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +2 -0
  114. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +2 -0
  115. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +2 -0
  116. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +2 -0
  117. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +2 -2
  118. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +2 -0
  119. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +2 -0
  120. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +2 -0
  121. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +1 -1
  122. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +1 -1
  123. package/templates/nextjs/ui-canvas/home/hero-section.tsx +1 -1
  124. package/templates/nextjs/ui-canvas/home/home-client.tsx +1 -1
  125. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +1 -1
  126. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +2 -2
  127. package/templates/nextjs/ui-canvas/layout/header-account.tsx +1 -1
  128. package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +2 -0
  129. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +2 -0
  130. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
  131. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +2 -2
  132. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +2 -0
  133. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +2 -0
  134. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +2 -0
  135. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +2 -0
  136. package/templates/nextjs/ui-canvas/product/price-display.tsx +2 -0
  137. package/templates/nextjs/ui-canvas/product/product-card.tsx +1 -1
  138. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +1 -1
  139. package/templates/nextjs/ui-canvas/product/product-grid.tsx +1 -1
  140. package/templates/nextjs/ui-canvas/product/product-listing.tsx +1 -1
  141. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +4 -0
  142. package/templates/nextjs/ui-canvas/product/review-form.tsx +29 -32
  143. package/templates/nextjs/ui-canvas/product/{reviews-section.tsx → reviews-section.tsx.ejs} +26 -8
  144. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +2 -0
  145. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +2 -0
  146. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +2 -0
  147. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +2 -0
  148. package/templates/nextjs/base/src/ui/product/reviews-section.tsx +0 -85
  149. package/templates/nextjs/designs/passepartout/globals.css +0 -412
  150. package/templates/nextjs/designs/passepartout/messages-patch/en.json +0 -87
  151. package/templates/nextjs/designs/passepartout/messages-patch/he.json +0 -87
  152. package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +0 -39
  153. package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +0 -149
  154. package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +0 -401
  155. package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +0 -139
  156. package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +0 -45
  157. package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +0 -103
  158. package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +0 -159
  159. package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +0 -155
  160. package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +0 -60
  161. package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +0 -34
  162. package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +0 -111
  163. package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +0 -265
  164. package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +0 -667
  165. package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +0 -35
  166. package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +0 -427
  167. package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +0 -113
  168. package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +0 -52
  169. package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +0 -307
  170. package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +0 -33
@@ -1,22 +1,18 @@
1
- import type { ProductDiscount } from 'brainerce';
2
- import { cn } from '@/core/lib/utils';
3
-
4
- interface DiscountBadgeProps {
5
- discount?: ProductDiscount | null;
6
- className?: string;
7
- }
8
-
9
- export function DiscountBadge({ discount, className }: DiscountBadgeProps) {
10
- if (!discount) return null;
11
-
12
- return (
13
- <span
14
- className={cn(
15
- 'bg-destructive text-destructive-foreground inline-flex items-center rounded px-2 py-1 text-xs font-bold',
16
- className
17
- )}
18
- >
19
- {discount.badgeText}
20
- </span>
21
- );
22
- }
1
+ import type { ProductDiscount } from 'brainerce';
2
+ import { Badge } from '@/components/ui/badge';
3
+ import { cn } from '@/core/lib/utils';
4
+
5
+ interface DiscountBadgeProps {
6
+ discount?: ProductDiscount | null;
7
+ className?: string;
8
+ }
9
+
10
+ export function DiscountBadge({ discount, className }: DiscountBadgeProps) {
11
+ if (!discount) return null;
12
+
13
+ return (
14
+ <Badge variant="destructive" className={cn('rounded px-2 py-1 font-bold', className)}>
15
+ {discount.badgeText}
16
+ </Badge>
17
+ );
18
+ }
@@ -1,12 +1,16 @@
1
1
  'use client';
2
2
 
3
3
  import { useState } from 'react';
4
+ import { Image as ImageIcon } from 'lucide-react';
4
5
  import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
6
  import type { Product, ProductRecommendation } from 'brainerce';
6
7
  import { formatPrice } from 'brainerce';
7
8
  import { useCart, useStoreInfo } from '@/core/providers/store-provider';
8
9
  import { useCurrency } from '@/core/lib/use-currency';
9
10
  import { useTranslations } from '@/core/lib/translations';
11
+ import { Button } from '@/components/ui/button';
12
+ import { Card } from '@/components/ui/card';
13
+ import { Checkbox } from '@/components/ui/checkbox';
10
14
  import { cn } from '@/core/lib/utils';
11
15
 
12
16
  interface FrequentlyBoughtTogetherProps {
@@ -47,11 +51,10 @@ function ProductThumb({
47
51
  )}
48
52
  >
49
53
  {onToggle && (
50
- <input
51
- type="checkbox"
54
+ <Checkbox
52
55
  checked={checked}
53
- onChange={onToggle}
54
- className="absolute start-2 top-2 h-4 w-4 rounded"
56
+ onCheckedChange={onToggle}
57
+ className="absolute start-2 top-2"
55
58
  />
56
59
  )}
57
60
  <div className="bg-muted relative mb-2 h-20 w-20 overflow-hidden rounded">
@@ -59,19 +62,11 @@ function ProductThumb({
59
62
  <Image src={imageUrl} alt={name} fill sizes="80px" className="object-cover" />
60
63
  ) : (
61
64
  <div className="flex h-full w-full items-center justify-center">
62
- <svg
65
+ <ImageIcon
63
66
  className="text-muted-foreground h-8 w-8"
64
- fill="none"
65
- viewBox="0 0 24 24"
66
- stroke="currentColor"
67
- >
68
- <path
69
- strokeLinecap="round"
70
- strokeLinejoin="round"
71
- strokeWidth={1.5}
72
- d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
73
- />
74
- </svg>
67
+ strokeWidth={1.5}
68
+ aria-hidden="true"
69
+ />
75
70
  </div>
76
71
  )}
77
72
  </div>
@@ -147,7 +142,7 @@ export function FrequentlyBoughtTogether({
147
142
  }
148
143
 
149
144
  return (
150
- <div className={cn('border-border rounded-lg border p-6', className)}>
145
+ <Card className={cn('shadow-none p-6', className)}>
151
146
  <h2 className="text-foreground mb-4 text-xl font-semibold">
152
147
  {t('frequentlyBoughtTogether')}
153
148
  </h2>
@@ -187,17 +182,10 @@ export function FrequentlyBoughtTogether({
187
182
  <span className="text-foreground text-lg font-semibold">
188
183
  {t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
189
184
  </span>
190
- <button
191
- onClick={handleAddAll}
192
- disabled={adding || selected.size === 0}
193
- className={cn(
194
- 'bg-primary text-primary-foreground rounded px-5 py-2.5 text-sm font-medium transition-opacity hover:opacity-90',
195
- 'disabled:cursor-not-allowed disabled:opacity-50'
196
- )}
197
- >
185
+ <Button onClick={handleAddAll} disabled={adding || selected.size === 0} className="rounded px-5">
198
186
  {adding ? t('addingAll') : t('addSelectedToCart')}
199
- </button>
187
+ </Button>
200
188
  </div>
201
- </div>
189
+ </Card>
202
190
  );
203
191
  }
@@ -1,175 +1,206 @@
1
- 'use client';
2
-
3
- /**
4
- * <ModifierGroupSelector> — reference React component for storefront authors
5
- * (PRD §8.4).
6
- *
7
- * Drop-in renderer for a single modifier group (toppings, sauce, bread type, …).
8
- * Storefront authors are encouraged to copy this file and tailor the styling /
9
- * markup to their site — the data contract is what matters, not the markup.
10
- *
11
- * Behavior pinned by the contract:
12
- * - SINGLE → renders as <input type="radio">, max picks = 1
13
- * - MULTIPLE → renders as <input type="checkbox">, picks bounded by min..max
14
- * - effectiveMax === 0 → group is hidden entirely (variant-disable convention)
15
- * - available === false → modifier is disabled with "Sold out" badge
16
- * - free counter shows "{used} of {freeQuantity} free" when freeQuantity > 0
17
- * - defaultModifierIds + isDefault drive first-render checked state
18
- * - client-side guards mirror min/max (UX); the SERVER is authoritative — wire
19
- * `MODIFIER_VALIDATION_FAILED` errors back to the user when add-to-cart fails
20
- *
21
- * The price-delta side: this component does NOT compute totals. The server
22
- * runs the free-allocation policy and returns the line's `unitPrice` /
23
- * `modifiers[]` / `modifiersTotal` snapshot — render those after add-to-cart.
24
- */
25
-
26
- import type { Modifier, ModifierGroup } from 'brainerce';
27
-
28
- // The pure selection logic (defaults, validation, wire-format adapter) lives
29
- // in core (zero markup) so that `core/hooks/use-product-page` never imports
30
- // from `ui/`. Re-exported here for backward compatibility with existing imports.
31
- export {
32
- buildInitialSelections,
33
- toModifierSelections,
34
- validateSelections,
35
- } from '@/core/lib/product-options';
36
-
37
- interface ModifierGroupSelectorProps {
38
- /**
39
- * Effective group as returned by `GET /products/:id` (overrides already
40
- * applied — `min`, `max`, `freeQuantity`, etc. are the values the server
41
- * will validate against for the active variant).
42
- */
43
- group: ModifierGroup;
44
- /** Currently-selected modifier IDs in click-order. */
45
- value: string[];
46
- onChange: (next: string[]) => void;
47
- /** Disable all controls (e.g., while add-to-cart is in flight). */
48
- disabled?: boolean;
49
- }
50
-
51
- export function ModifierGroupSelector({
52
- group,
53
- value,
54
- onChange,
55
- disabled = false,
56
- }: ModifierGroupSelectorProps) {
57
- // PRD §7.2.2: variant-disable convention — group with effectiveMax=0 is hidden.
58
- if (group.max === 0) return null;
59
-
60
- const isSingle = group.selectionType === 'SINGLE';
61
- // Client-side boundserver is authoritative.
62
- const effectiveMax = isSingle ? 1 : (group.max ?? Infinity);
63
-
64
- const handleToggle = (modifierId: string, checked: boolean) => {
65
- if (disabled) return;
66
-
67
- if (isSingle) {
68
- // Radio behavior: replace the selection.
69
- onChange(checked ? [modifierId] : []);
70
- return;
71
- }
72
-
73
- if (checked) {
74
- if (value.length >= effectiveMax) return; // would exceed max — ignore the click
75
- if (value.includes(modifierId)) return;
76
- onChange([...value, modifierId]);
77
- } else {
78
- onChange(value.filter((id) => id !== modifierId));
79
- }
80
- };
81
-
82
- const sortedModifiers = [...group.modifiers].sort((a, b) => a.position - b.position);
83
-
84
- // Counter: how many of the picked items are within the "free" window.
85
- const usedFreeSlots = Math.min(value.length, group.freeQuantity);
86
- const showFreeCounter = group.freeQuantity > 0;
87
-
88
- return (
89
- <fieldset className="modifier-group">
90
- <legend className="modifier-group__legend">
91
- <span className="modifier-group__name">{group.name}</span>
92
- {group.required && <span className="modifier-group__required"> *</span>}
93
- <span className="modifier-group__rules">{ruleSummary(group)}</span>
94
- </legend>
95
-
96
- {group.description && <p className="modifier-group__description">{group.description}</p>}
97
-
98
- {showFreeCounter && (
99
- <p className="modifier-group__counter" aria-live="polite">
100
- {usedFreeSlots} of {group.freeQuantity} free
101
- </p>
102
- )}
103
-
104
- <ul className="modifier-group__options">
105
- {sortedModifiers.map((modifier) => {
106
- const isChecked = value.includes(modifier.id);
107
- const isAtCap = !isSingle && !isChecked && value.length >= effectiveMax;
108
- const isDisabled = disabled || !modifier.available || isAtCap;
109
-
110
- return (
111
- <li key={modifier.id} className="modifier-option">
112
- <label className="modifier-option__label">
113
- <input
114
- type={isSingle ? 'radio' : 'checkbox'}
115
- name={`modifier-group-${group.id}`}
116
- value={modifier.id}
117
- checked={isChecked}
118
- disabled={isDisabled}
119
- onChange={(e) => handleToggle(modifier.id, e.target.checked)}
120
- className="modifier-option__input"
121
- />
122
- <span className="modifier-option__name">{modifier.name}</span>
123
- <ModifierPriceLabel modifier={modifier} />
124
- {!modifier.available && (
125
- <span
126
- className="modifier-option__badge modifier-option__badge--soldout"
127
- aria-label="Sold out"
128
- >
129
- Sold out
130
- </span>
131
- )}
132
- </label>
133
- {modifier.description && (
134
- <p className="modifier-option__description">{modifier.description}</p>
135
- )}
136
- </li>
137
- );
138
- })}
139
- </ul>
140
- </fieldset>
141
- );
142
- }
143
-
144
- /**
145
- * Renders the modifier's price contribution next to its name.
146
- *
147
- * Negative deltas display with a leading minus sign (downsell modifiers per
148
- * PRD §6.3.4); zero-delta modifiers render nothing — silence is the right
149
- * affordance for a free option.
150
- */
151
- function ModifierPriceLabel({ modifier }: { modifier: Modifier }) {
152
- const delta = parseFloat(modifier.priceDelta);
153
- if (Number.isNaN(delta) || delta === 0) return null;
154
- const sign = delta > 0 ? '+' : '';
155
- return (
156
- <span className="modifier-option__price">
157
- {sign}
158
- {modifier.priceDelta}
159
- </span>
160
- );
161
- }
162
-
163
- function ruleSummary(group: ModifierGroup): string {
164
- const parts: string[] = [];
165
- if (group.selectionType === 'SINGLE') {
166
- parts.push('Pick one');
167
- } else if (group.max != null) {
168
- if (group.min === 0) parts.push(`Up to ${group.max}`);
169
- else if (group.min === group.max) parts.push(`Pick exactly ${group.min}`);
170
- else parts.push(`${group.min}–${group.max}`);
171
- } else if (group.min > 0) {
172
- parts.push(`At least ${group.min}`);
173
- }
174
- return parts.length ? `(${parts.join(', ')})` : '';
175
- }
1
+ 'use client';
2
+
3
+ /**
4
+ * <ModifierGroupSelector> — reference React component for storefront authors
5
+ * (PRD §8.4).
6
+ *
7
+ * Drop-in renderer for a single modifier group (toppings, sauce, bread type, …).
8
+ * Storefront authors are encouraged to copy this file and tailor the styling /
9
+ * markup to their site — the data contract is what matters, not the markup.
10
+ *
11
+ * Behavior pinned by the contract:
12
+ * - SINGLE → renders as a radio group, max picks = 1
13
+ * - MULTIPLE → renders as checkboxes, picks bounded by min..max
14
+ * - effectiveMax === 0 → group is hidden entirely (variant-disable convention)
15
+ * - available === false → modifier is disabled with "Sold out" badge
16
+ * - free counter shows "{used} of {freeQuantity} free" when freeQuantity > 0
17
+ * - defaultModifierIds + isDefault drive first-render checked state
18
+ * - client-side guards mirror min/max (UX); the SERVER is authoritative — wire
19
+ * `MODIFIER_VALIDATION_FAILED` errors back to the user when add-to-cart fails
20
+ *
21
+ * The price-delta side: this component does NOT compute totals. The server
22
+ * runs the free-allocation policy and returns the line's `unitPrice` /
23
+ * `modifiers[]` / `modifiersTotal` snapshot — render those after add-to-cart.
24
+ */
25
+
26
+ import type { Modifier, ModifierGroup } from 'brainerce';
27
+ import { Badge } from '@/components/ui/badge';
28
+ import { Checkbox } from '@/components/ui/checkbox';
29
+ import { Label } from '@/components/ui/label';
30
+ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
31
+
32
+ // The pure selection logic (defaults, validation, wire-format adapter) lives
33
+ // in core (zero markup) so that `core/hooks/use-product-page` never imports
34
+ // from `ui/`. Re-exported here for backward compatibility with existing imports.
35
+ export {
36
+ buildInitialSelections,
37
+ toModifierSelections,
38
+ validateSelections,
39
+ } from '@/core/lib/product-options';
40
+
41
+ interface ModifierGroupSelectorProps {
42
+ /**
43
+ * Effective group as returned by `GET /products/:id` (overrides already
44
+ * applied `min`, `max`, `freeQuantity`, etc. are the values the server
45
+ * will validate against for the active variant).
46
+ */
47
+ group: ModifierGroup;
48
+ /** Currently-selected modifier IDs in click-order. */
49
+ value: string[];
50
+ onChange: (next: string[]) => void;
51
+ /** Disable all controls (e.g., while add-to-cart is in flight). */
52
+ disabled?: boolean;
53
+ }
54
+
55
+ export function ModifierGroupSelector({
56
+ group,
57
+ value,
58
+ onChange,
59
+ disabled = false,
60
+ }: ModifierGroupSelectorProps) {
61
+ // PRD §7.2.2: variant-disable conventiongroup with effectiveMax=0 is hidden.
62
+ if (group.max === 0) return null;
63
+
64
+ const isSingle = group.selectionType === 'SINGLE';
65
+ // Client-side bound — server is authoritative.
66
+ const effectiveMax = isSingle ? 1 : (group.max ?? Infinity);
67
+
68
+ const handleToggle = (modifierId: string, checked: boolean) => {
69
+ if (disabled) return;
70
+
71
+ if (isSingle) {
72
+ // Radio behavior: replace the selection.
73
+ onChange(checked ? [modifierId] : []);
74
+ return;
75
+ }
76
+
77
+ if (checked) {
78
+ if (value.length >= effectiveMax) return; // would exceed max — ignore the click
79
+ if (value.includes(modifierId)) return;
80
+ onChange([...value, modifierId]);
81
+ } else {
82
+ onChange(value.filter((id) => id !== modifierId));
83
+ }
84
+ };
85
+
86
+ const sortedModifiers = [...group.modifiers].sort((a, b) => a.position - b.position);
87
+
88
+ // Counter: how many of the picked items are within the "free" window.
89
+ const usedFreeSlots = Math.min(value.length, group.freeQuantity);
90
+ const showFreeCounter = group.freeQuantity > 0;
91
+
92
+ const renderOption = (modifier: Modifier) => {
93
+ const isChecked = value.includes(modifier.id);
94
+ const isAtCap = !isSingle && !isChecked && value.length >= effectiveMax;
95
+ const isDisabled = disabled || !modifier.available || isAtCap;
96
+
97
+ return (
98
+ <li key={modifier.id} className="modifier-option">
99
+ <Label className="modifier-option__label flex cursor-pointer items-center gap-2 font-normal">
100
+ {isSingle ? (
101
+ <RadioGroupItem
102
+ value={modifier.id}
103
+ disabled={isDisabled}
104
+ className="modifier-option__input"
105
+ />
106
+ ) : (
107
+ <Checkbox
108
+ checked={isChecked}
109
+ disabled={isDisabled}
110
+ onCheckedChange={(checked) => handleToggle(modifier.id, checked === true)}
111
+ className="modifier-option__input"
112
+ />
113
+ )}
114
+ <span className="modifier-option__name text-sm">{modifier.name}</span>
115
+ <ModifierPriceLabel modifier={modifier} />
116
+ {!modifier.available && (
117
+ <Badge
118
+ variant="secondary"
119
+ className="modifier-option__badge modifier-option__badge--soldout font-normal"
120
+ aria-label="Sold out"
121
+ >
122
+ Sold out
123
+ </Badge>
124
+ )}
125
+ </Label>
126
+ {modifier.description && (
127
+ <p className="modifier-option__description text-muted-foreground ms-6 text-xs">
128
+ {modifier.description}
129
+ </p>
130
+ )}
131
+ </li>
132
+ );
133
+ };
134
+
135
+ return (
136
+ <fieldset className="modifier-group">
137
+ <legend className="modifier-group__legend text-sm">
138
+ <span className="modifier-group__name font-medium">{group.name}</span>
139
+ {group.required && <span className="modifier-group__required text-destructive"> *</span>}
140
+ <span className="modifier-group__rules text-muted-foreground"> {ruleSummary(group)}</span>
141
+ </legend>
142
+
143
+ {group.description && (
144
+ <p className="modifier-group__description text-muted-foreground text-xs">
145
+ {group.description}
146
+ </p>
147
+ )}
148
+
149
+ {showFreeCounter && (
150
+ <p className="modifier-group__counter text-muted-foreground text-xs" aria-live="polite">
151
+ {usedFreeSlots} of {group.freeQuantity} free
152
+ </p>
153
+ )}
154
+
155
+ {isSingle ? (
156
+ <RadioGroup
157
+ value={value[0] ?? ''}
158
+ onValueChange={(v) => onChange([v])}
159
+ disabled={disabled}
160
+ asChild
161
+ >
162
+ <ul className="modifier-group__options mt-2 gap-2">
163
+ {sortedModifiers.map(renderOption)}
164
+ </ul>
165
+ </RadioGroup>
166
+ ) : (
167
+ <ul className="modifier-group__options mt-2 grid gap-2">
168
+ {sortedModifiers.map(renderOption)}
169
+ </ul>
170
+ )}
171
+ </fieldset>
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Renders the modifier's price contribution next to its name.
177
+ *
178
+ * Negative deltas display with a leading minus sign (downsell modifiers per
179
+ * PRD §6.3.4); zero-delta modifiers render nothing — silence is the right
180
+ * affordance for a free option.
181
+ */
182
+ function ModifierPriceLabel({ modifier }: { modifier: Modifier }) {
183
+ const delta = parseFloat(modifier.priceDelta);
184
+ if (Number.isNaN(delta) || delta === 0) return null;
185
+ const sign = delta > 0 ? '+' : '';
186
+ return (
187
+ <span className="modifier-option__price text-muted-foreground text-xs">
188
+ {sign}
189
+ {modifier.priceDelta}
190
+ </span>
191
+ );
192
+ }
193
+
194
+ function ruleSummary(group: ModifierGroup): string {
195
+ const parts: string[] = [];
196
+ if (group.selectionType === 'SINGLE') {
197
+ parts.push('Pick one');
198
+ } else if (group.max != null) {
199
+ if (group.min === 0) parts.push(`Up to ${group.max}`);
200
+ else if (group.min === group.max) parts.push(`Pick exactly ${group.min}`);
201
+ else parts.push(`${group.min}–${group.max}`);
202
+ } else if (group.min > 0) {
203
+ parts.push(`At least ${group.min}`);
204
+ }
205
+ return parts.length ? `(${parts.join(', ')})` : '';
206
+ }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { formatPrice } from 'brainerce';
4
+ import { Badge } from '@/components/ui/badge';
4
5
  import { useCurrency } from '@/core/lib/use-currency';
5
6
  import { cn } from '@/core/lib/utils';
6
7
 
@@ -46,9 +47,9 @@ export function PriceDisplay({
46
47
  {formatPrice(basePrice, { currency: currencyCode }) as string}
47
48
  </span>
48
49
  {discountPercent > 0 && (
49
- <span className="bg-destructive text-destructive-foreground rounded px-1.5 py-0.5 text-xs font-medium">
50
+ <Badge variant="destructive" className="rounded px-1.5 py-0.5 font-medium">
50
51
  -{discountPercent}%
51
- </span>
52
+ </Badge>
52
53
  )}
53
54
  </>
54
55
  ) : (