create-brainerce-store 1.62.0 → 1.63.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/LICENSE +21 -21
- package/README.md +71 -71
- package/dist/index.js +5 -2
- package/messages/en.json +502 -502
- package/messages/he.json +502 -502
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +60 -60
- package/templates/nextjs/base/.env.local.ejs +24 -24
- package/templates/nextjs/base/.eslintrc.json +57 -57
- package/templates/nextjs/base/.mcp.json +8 -8
- package/templates/nextjs/base/AI-GUIDE.md +174 -174
- package/templates/nextjs/base/TRANSLATIONS.md +200 -200
- package/templates/nextjs/base/package.json.ejs +52 -52
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +262 -262
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +276 -276
- package/templates/nextjs/base/src/app/cart/page.tsx +7 -7
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +106 -106
- package/templates/nextjs/base/src/app/checkout/page.tsx +12 -0
- package/templates/nextjs/base/src/app/globals.css +36 -36
- package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +26 -26
- package/templates/nextjs/base/src/app/layout.tsx.ejs +10 -0
- package/templates/nextjs/base/src/app/llms.txt/route.ts +44 -44
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +295 -278
- package/templates/nextjs/base/src/app/order-status/page.tsx +121 -121
- package/templates/nextjs/base/src/app/page.tsx +64 -64
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +175 -175
- package/templates/nextjs/base/src/app/products/page.tsx +19 -19
- package/templates/nextjs/base/src/components/account/order-history.tsx +385 -385
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +70 -70
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +612 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +292 -292
- package/templates/nextjs/base/src/components/checkout/date-picker.tsx +309 -309
- package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +144 -144
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +125 -125
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -74
- package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +59 -59
- package/templates/nextjs/base/src/components/seo/breadcrumbs.tsx +37 -37
- package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +61 -61
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +97 -97
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +179 -179
- package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +62 -0
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -53
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -32
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -49
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -28
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -107
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -22
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -38
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -153
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -26
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -125
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -7
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -32
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -58
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +20 -2
- package/templates/nextjs/base/src/core/lib/image-hosts.ts +7 -7
- package/templates/nextjs/base/src/core/lib/product-options.ts +121 -121
- package/templates/nextjs/base/src/core/lib/resolve-currency.ts +20 -20
- package/templates/nextjs/base/src/core/lib/safe-redirect.ts +38 -38
- package/templates/nextjs/base/src/core/lib/seo.ts +46 -46
- package/templates/nextjs/base/src/core/lib/store-info.ts +9 -0
- package/templates/nextjs/base/src/core/lib/tracking.ts +171 -0
- package/templates/nextjs/base/src/core/lib/use-currency.ts +19 -19
- package/templates/nextjs/base/src/core/lib/utils.ts +21 -21
- package/templates/nextjs/base/src/middleware.ts.ejs +54 -2
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +117 -117
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +146 -146
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -28
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +110 -110
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +126 -126
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -140
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +134 -134
- package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +61 -61
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -95
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -20
- package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -24
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
- package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -29
- package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -116
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -90
- package/templates/nextjs/base/src/ui/layout/header-account.tsx +40 -40
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -80
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -144
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -152
- package/templates/nextjs/base/src/ui/product/customization-fields.tsx +457 -457
- package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -18
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +191 -191
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -206
- package/templates/nextjs/base/src/ui/product/price-display.tsx +62 -62
- package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -455
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -61
- package/templates/nextjs/base/src/ui/product/variant-selector.tsx +291 -291
- package/templates/nextjs/base/src/ui/shared/cdn-image.tsx +26 -26
- package/templates/nextjs/base/tailwind.config.ts +69 -69
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +10 -0
- package/templates/nextjs/designs/atelier/globals.css +402 -402
- package/templates/nextjs/designs/atelier/messages-patch/en.json +100 -100
- package/templates/nextjs/designs/atelier/messages-patch/he.json +100 -100
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -110
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -163
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -140
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -123
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -147
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -142
- package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -77
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -89
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +151 -151
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -27
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +75 -75
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +174 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -54
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -46
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -118
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -96
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -137
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -14
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -141
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -138
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -466
- package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -17
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -196
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -193
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -69
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +13 -11
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +477 -477
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +42 -42
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -267
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -66
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -206
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -29
- package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -86
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +276 -276
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +63 -63
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -71
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -125
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -89
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -123
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +27 -27
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -99
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -105
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -106
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -119
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -54
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -81
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -19
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -23
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -21
- package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -26
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -101
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -71
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -31
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +76 -76
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -36
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -82
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -119
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -445
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -19
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -168
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -165
- package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -52
- package/templates/nextjs/ui-canvas/product/product-card.tsx +7 -2
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -352
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -27
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -224
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -266
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -96
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -45
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -212
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -31
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -27
- package/templates/nextjs/base/tsconfig.tsbuildinfo +0 -1
|
@@ -1,206 +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 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 convention — group 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
|
+
'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 convention — group 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,62 +1,62 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { formatPrice } from 'brainerce';
|
|
4
|
-
import { Badge } from '@/components/ui/badge';
|
|
5
|
-
import { useCurrency } from '@/core/lib/use-currency';
|
|
6
|
-
import { cn } from '@/core/lib/utils';
|
|
7
|
-
|
|
8
|
-
interface PriceDisplayProps {
|
|
9
|
-
price: string | number;
|
|
10
|
-
salePrice?: string | number | null;
|
|
11
|
-
currency?: string;
|
|
12
|
-
className?: string;
|
|
13
|
-
size?: 'sm' | 'md' | 'lg';
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const sizeClasses = {
|
|
17
|
-
sm: 'text-sm',
|
|
18
|
-
md: 'text-base',
|
|
19
|
-
lg: 'text-xl font-semibold',
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export function PriceDisplay({
|
|
23
|
-
price,
|
|
24
|
-
salePrice,
|
|
25
|
-
currency,
|
|
26
|
-
className,
|
|
27
|
-
size = 'md',
|
|
28
|
-
}: PriceDisplayProps) {
|
|
29
|
-
const currencyCode = useCurrency(currency);
|
|
30
|
-
|
|
31
|
-
const basePrice = typeof price === 'string' ? parseFloat(price) : price;
|
|
32
|
-
const sale =
|
|
33
|
-
salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
|
|
34
|
-
const isOnSale = sale !== null && sale < basePrice;
|
|
35
|
-
|
|
36
|
-
const discountPercent =
|
|
37
|
-
isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
|
|
38
|
-
|
|
39
|
-
return (
|
|
40
|
-
<span className={cn('inline-flex items-center gap-2', sizeClasses[size], className)}>
|
|
41
|
-
{isOnSale ? (
|
|
42
|
-
<>
|
|
43
|
-
<span className="text-destructive font-medium">
|
|
44
|
-
{formatPrice(sale!, { currency: currencyCode }) as string}
|
|
45
|
-
</span>
|
|
46
|
-
<span className="text-muted-foreground text-[0.85em] line-through">
|
|
47
|
-
{formatPrice(basePrice, { currency: currencyCode }) as string}
|
|
48
|
-
</span>
|
|
49
|
-
{discountPercent > 0 && (
|
|
50
|
-
<Badge variant="destructive" className="rounded px-1.5 py-0.5 font-medium">
|
|
51
|
-
-{discountPercent}%
|
|
52
|
-
</Badge>
|
|
53
|
-
)}
|
|
54
|
-
</>
|
|
55
|
-
) : (
|
|
56
|
-
<span className="text-foreground font-medium">
|
|
57
|
-
{formatPrice(basePrice, { currency: currencyCode }) as string}
|
|
58
|
-
</span>
|
|
59
|
-
)}
|
|
60
|
-
</span>
|
|
61
|
-
);
|
|
62
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { formatPrice } from 'brainerce';
|
|
4
|
+
import { Badge } from '@/components/ui/badge';
|
|
5
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
6
|
+
import { cn } from '@/core/lib/utils';
|
|
7
|
+
|
|
8
|
+
interface PriceDisplayProps {
|
|
9
|
+
price: string | number;
|
|
10
|
+
salePrice?: string | number | null;
|
|
11
|
+
currency?: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
size?: 'sm' | 'md' | 'lg';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const sizeClasses = {
|
|
17
|
+
sm: 'text-sm',
|
|
18
|
+
md: 'text-base',
|
|
19
|
+
lg: 'text-xl font-semibold',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function PriceDisplay({
|
|
23
|
+
price,
|
|
24
|
+
salePrice,
|
|
25
|
+
currency,
|
|
26
|
+
className,
|
|
27
|
+
size = 'md',
|
|
28
|
+
}: PriceDisplayProps) {
|
|
29
|
+
const currencyCode = useCurrency(currency);
|
|
30
|
+
|
|
31
|
+
const basePrice = typeof price === 'string' ? parseFloat(price) : price;
|
|
32
|
+
const sale =
|
|
33
|
+
salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
|
|
34
|
+
const isOnSale = sale !== null && sale < basePrice;
|
|
35
|
+
|
|
36
|
+
const discountPercent =
|
|
37
|
+
isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<span className={cn('inline-flex items-center gap-2', sizeClasses[size], className)}>
|
|
41
|
+
{isOnSale ? (
|
|
42
|
+
<>
|
|
43
|
+
<span className="text-destructive font-medium">
|
|
44
|
+
{formatPrice(sale!, { currency: currencyCode }) as string}
|
|
45
|
+
</span>
|
|
46
|
+
<span className="text-muted-foreground text-[0.85em] line-through">
|
|
47
|
+
{formatPrice(basePrice, { currency: currencyCode }) as string}
|
|
48
|
+
</span>
|
|
49
|
+
{discountPercent > 0 && (
|
|
50
|
+
<Badge variant="destructive" className="rounded px-1.5 py-0.5 font-medium">
|
|
51
|
+
-{discountPercent}%
|
|
52
|
+
</Badge>
|
|
53
|
+
)}
|
|
54
|
+
</>
|
|
55
|
+
) : (
|
|
56
|
+
<span className="text-foreground font-medium">
|
|
57
|
+
{formatPrice(basePrice, { currency: currencyCode }) as string}
|
|
58
|
+
</span>
|
|
59
|
+
)}
|
|
60
|
+
</span>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
|
|
|
15
15
|
import { Card } from '@/components/ui/card';
|
|
16
16
|
import { useCart } from '@/core/providers/store-provider';
|
|
17
17
|
import { useCurrency } from '@/core/lib/use-currency';
|
|
18
|
+
import { trackAddToCart } from '@/core/lib/tracking';
|
|
18
19
|
import { cn } from '@/core/lib/utils';
|
|
19
20
|
|
|
20
21
|
interface ProductCardProps {
|
|
@@ -36,8 +37,7 @@ function pickDisplayPrice(
|
|
|
36
37
|
fallbackCurrency: string
|
|
37
38
|
): { price: number | undefined; salePrice: number | null; currency: string } {
|
|
38
39
|
if (product.displayPrice != null && product.displayCurrency) {
|
|
39
|
-
const sale =
|
|
40
|
-
product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
|
|
40
|
+
const sale = product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
|
|
41
41
|
return {
|
|
42
42
|
// `displayPrice` is the base price converted to the region currency —
|
|
43
43
|
// it goes into the PriceDisplay `price` (base) slot, NOT the sale slot.
|
|
@@ -123,6 +123,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
123
123
|
const client = getClient();
|
|
124
124
|
await client.smartAddToCart({ productId: product.id, quantity: 1 });
|
|
125
125
|
await refreshCart();
|
|
126
|
+
// Quick-add from a grid card is a real add_to_cart — without this the
|
|
127
|
+
// ad platforms only ever see adds made from a product page. Reported in
|
|
128
|
+
// the DISPLAYED currency (region-converted when the storefront passed a
|
|
129
|
+
// regionId), because that is what the shopper is actually being charged.
|
|
130
|
+
trackAddToCart(product, null, 1, display.salePrice ?? display.price, display.currency);
|
|
126
131
|
setAdded(true);
|
|
127
132
|
setTimeout(() => setAdded(false), 2000);
|
|
128
133
|
} catch (err) {
|