create-brainerce-store 1.61.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 +10 -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 -564
- 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,165 +1,165 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* <ModifierGroupSelector> — bare-skeleton renderer for a single modifier
|
|
5
|
-
* group (toppings, sauce, bread type, …) per PRD §8.4.
|
|
6
|
-
*
|
|
7
|
-
* DESIGN ME — modifier group on the product page; the group + selections
|
|
8
|
-
* come from useProductPage() via props. The data contract is what matters:
|
|
9
|
-
*
|
|
10
|
-
* - SINGLE → renders as <input type="radio">, max picks = 1
|
|
11
|
-
* - MULTIPLE → renders as <input type="checkbox">, picks bounded by min..max
|
|
12
|
-
* - effectiveMax === 0 → group is hidden entirely (variant-disable convention)
|
|
13
|
-
* - available === false → modifier is disabled with "Sold out" badge
|
|
14
|
-
* - free counter shows "{used} of {freeQuantity} free" when freeQuantity > 0
|
|
15
|
-
* - defaultModifierIds + isDefault drive first-render checked state
|
|
16
|
-
* - client-side guards mirror min/max (UX); the SERVER is authoritative — wire
|
|
17
|
-
* `MODIFIER_VALIDATION_FAILED` errors back to the user when add-to-cart fails
|
|
18
|
-
*
|
|
19
|
-
* The price-delta side: this component does NOT compute totals. The server
|
|
20
|
-
* runs the free-allocation policy and returns the line's `unitPrice` /
|
|
21
|
-
* `modifiers[]` / `modifiersTotal` snapshot — render those after add-to-cart.
|
|
22
|
-
*
|
|
23
|
-
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
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 bound — server 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>
|
|
90
|
-
<legend>
|
|
91
|
-
<span>{group.name}</span>
|
|
92
|
-
{group.required && <span aria-hidden="true"> *</span>}
|
|
93
|
-
<span> {ruleSummary(group)}</span>
|
|
94
|
-
</legend>
|
|
95
|
-
|
|
96
|
-
{group.description && <p>{group.description}</p>}
|
|
97
|
-
|
|
98
|
-
{showFreeCounter && (
|
|
99
|
-
<p aria-live="polite">
|
|
100
|
-
{usedFreeSlots} of {group.freeQuantity} free
|
|
101
|
-
</p>
|
|
102
|
-
)}
|
|
103
|
-
|
|
104
|
-
<ul>
|
|
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}>
|
|
112
|
-
<label className="inline-flex items-center gap-2">
|
|
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
|
-
/>
|
|
121
|
-
<span>{modifier.name}</span>
|
|
122
|
-
<ModifierPriceLabel modifier={modifier} />
|
|
123
|
-
{!modifier.available && <span aria-label="Sold out">Sold out</span>}
|
|
124
|
-
</label>
|
|
125
|
-
{modifier.description && <p>{modifier.description}</p>}
|
|
126
|
-
</li>
|
|
127
|
-
);
|
|
128
|
-
})}
|
|
129
|
-
</ul>
|
|
130
|
-
</fieldset>
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Renders the modifier's price contribution next to its name.
|
|
136
|
-
*
|
|
137
|
-
* Negative deltas display with a leading minus sign (downsell modifiers per
|
|
138
|
-
* PRD §6.3.4); zero-delta modifiers render nothing — silence is the right
|
|
139
|
-
* affordance for a free option.
|
|
140
|
-
*/
|
|
141
|
-
function ModifierPriceLabel({ modifier }: { modifier: Modifier }) {
|
|
142
|
-
const delta = parseFloat(modifier.priceDelta);
|
|
143
|
-
if (Number.isNaN(delta) || delta === 0) return null;
|
|
144
|
-
const sign = delta > 0 ? '+' : '';
|
|
145
|
-
return (
|
|
146
|
-
<span>
|
|
147
|
-
{sign}
|
|
148
|
-
{modifier.priceDelta}
|
|
149
|
-
</span>
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function ruleSummary(group: ModifierGroup): string {
|
|
154
|
-
const parts: string[] = [];
|
|
155
|
-
if (group.selectionType === 'SINGLE') {
|
|
156
|
-
parts.push('Pick one');
|
|
157
|
-
} else if (group.max != null) {
|
|
158
|
-
if (group.min === 0) parts.push(`Up to ${group.max}`);
|
|
159
|
-
else if (group.min === group.max) parts.push(`Pick exactly ${group.min}`);
|
|
160
|
-
else parts.push(`${group.min}–${group.max}`);
|
|
161
|
-
} else if (group.min > 0) {
|
|
162
|
-
parts.push(`At least ${group.min}`);
|
|
163
|
-
}
|
|
164
|
-
return parts.length ? `(${parts.join(', ')})` : '';
|
|
165
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <ModifierGroupSelector> — bare-skeleton renderer for a single modifier
|
|
5
|
+
* group (toppings, sauce, bread type, …) per PRD §8.4.
|
|
6
|
+
*
|
|
7
|
+
* DESIGN ME — modifier group on the product page; the group + selections
|
|
8
|
+
* come from useProductPage() via props. The data contract is what matters:
|
|
9
|
+
*
|
|
10
|
+
* - SINGLE → renders as <input type="radio">, max picks = 1
|
|
11
|
+
* - MULTIPLE → renders as <input type="checkbox">, picks bounded by min..max
|
|
12
|
+
* - effectiveMax === 0 → group is hidden entirely (variant-disable convention)
|
|
13
|
+
* - available === false → modifier is disabled with "Sold out" badge
|
|
14
|
+
* - free counter shows "{used} of {freeQuantity} free" when freeQuantity > 0
|
|
15
|
+
* - defaultModifierIds + isDefault drive first-render checked state
|
|
16
|
+
* - client-side guards mirror min/max (UX); the SERVER is authoritative — wire
|
|
17
|
+
* `MODIFIER_VALIDATION_FAILED` errors back to the user when add-to-cart fails
|
|
18
|
+
*
|
|
19
|
+
* The price-delta side: this component does NOT compute totals. The server
|
|
20
|
+
* runs the free-allocation policy and returns the line's `unitPrice` /
|
|
21
|
+
* `modifiers[]` / `modifiersTotal` snapshot — render those after add-to-cart.
|
|
22
|
+
*
|
|
23
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
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 bound — server 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>
|
|
90
|
+
<legend>
|
|
91
|
+
<span>{group.name}</span>
|
|
92
|
+
{group.required && <span aria-hidden="true"> *</span>}
|
|
93
|
+
<span> {ruleSummary(group)}</span>
|
|
94
|
+
</legend>
|
|
95
|
+
|
|
96
|
+
{group.description && <p>{group.description}</p>}
|
|
97
|
+
|
|
98
|
+
{showFreeCounter && (
|
|
99
|
+
<p aria-live="polite">
|
|
100
|
+
{usedFreeSlots} of {group.freeQuantity} free
|
|
101
|
+
</p>
|
|
102
|
+
)}
|
|
103
|
+
|
|
104
|
+
<ul>
|
|
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}>
|
|
112
|
+
<label className="inline-flex items-center gap-2">
|
|
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
|
+
/>
|
|
121
|
+
<span>{modifier.name}</span>
|
|
122
|
+
<ModifierPriceLabel modifier={modifier} />
|
|
123
|
+
{!modifier.available && <span aria-label="Sold out">Sold out</span>}
|
|
124
|
+
</label>
|
|
125
|
+
{modifier.description && <p>{modifier.description}</p>}
|
|
126
|
+
</li>
|
|
127
|
+
);
|
|
128
|
+
})}
|
|
129
|
+
</ul>
|
|
130
|
+
</fieldset>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Renders the modifier's price contribution next to its name.
|
|
136
|
+
*
|
|
137
|
+
* Negative deltas display with a leading minus sign (downsell modifiers per
|
|
138
|
+
* PRD §6.3.4); zero-delta modifiers render nothing — silence is the right
|
|
139
|
+
* affordance for a free option.
|
|
140
|
+
*/
|
|
141
|
+
function ModifierPriceLabel({ modifier }: { modifier: Modifier }) {
|
|
142
|
+
const delta = parseFloat(modifier.priceDelta);
|
|
143
|
+
if (Number.isNaN(delta) || delta === 0) return null;
|
|
144
|
+
const sign = delta > 0 ? '+' : '';
|
|
145
|
+
return (
|
|
146
|
+
<span>
|
|
147
|
+
{sign}
|
|
148
|
+
{modifier.priceDelta}
|
|
149
|
+
</span>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function ruleSummary(group: ModifierGroup): string {
|
|
154
|
+
const parts: string[] = [];
|
|
155
|
+
if (group.selectionType === 'SINGLE') {
|
|
156
|
+
parts.push('Pick one');
|
|
157
|
+
} else if (group.max != null) {
|
|
158
|
+
if (group.min === 0) parts.push(`Up to ${group.max}`);
|
|
159
|
+
else if (group.min === group.max) parts.push(`Pick exactly ${group.min}`);
|
|
160
|
+
else parts.push(`${group.min}–${group.max}`);
|
|
161
|
+
} else if (group.min > 0) {
|
|
162
|
+
parts.push(`At least ${group.min}`);
|
|
163
|
+
}
|
|
164
|
+
return parts.length ? `(${parts.join(', ')})` : '';
|
|
165
|
+
}
|
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { formatPrice } from 'brainerce';
|
|
4
|
-
import { useCurrency } from '@/core/lib/use-currency';
|
|
5
|
-
import { cn } from '@/core/lib/utils';
|
|
6
|
-
|
|
7
|
-
interface PriceDisplayProps {
|
|
8
|
-
price: string | number;
|
|
9
|
-
salePrice?: string | number | null;
|
|
10
|
-
currency?: string;
|
|
11
|
-
className?: string;
|
|
12
|
-
size?: 'sm' | 'md' | 'lg';
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* DESIGN ME — price renderer used by cards, product page and recommendations;
|
|
17
|
-
* computes sale state + discount % from props (currency via useCurrency()).
|
|
18
|
-
* Never hardcode currency symbols — formatPrice handles them.
|
|
19
|
-
*
|
|
20
|
-
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
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 data-size={size} className={cn('inline-flex items-center gap-2', className)}>
|
|
41
|
-
{isOnSale ? (
|
|
42
|
-
<>
|
|
43
|
-
<span>{formatPrice(sale!, { currency: currencyCode }) as string}</span>
|
|
44
|
-
<s>{formatPrice(basePrice, { currency: currencyCode }) as string}</s>
|
|
45
|
-
{discountPercent > 0 && <span>-{discountPercent}%</span>}
|
|
46
|
-
</>
|
|
47
|
-
) : (
|
|
48
|
-
<span>{formatPrice(basePrice, { currency: currencyCode }) as string}</span>
|
|
49
|
-
)}
|
|
50
|
-
</span>
|
|
51
|
-
);
|
|
52
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { formatPrice } from 'brainerce';
|
|
4
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
5
|
+
import { cn } from '@/core/lib/utils';
|
|
6
|
+
|
|
7
|
+
interface PriceDisplayProps {
|
|
8
|
+
price: string | number;
|
|
9
|
+
salePrice?: string | number | null;
|
|
10
|
+
currency?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
size?: 'sm' | 'md' | 'lg';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* DESIGN ME — price renderer used by cards, product page and recommendations;
|
|
17
|
+
* computes sale state + discount % from props (currency via useCurrency()).
|
|
18
|
+
* Never hardcode currency symbols — formatPrice handles them.
|
|
19
|
+
*
|
|
20
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
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 data-size={size} className={cn('inline-flex items-center gap-2', className)}>
|
|
41
|
+
{isOnSale ? (
|
|
42
|
+
<>
|
|
43
|
+
<span>{formatPrice(sale!, { currency: currencyCode }) as string}</span>
|
|
44
|
+
<s>{formatPrice(basePrice, { currency: currencyCode }) as string}</s>
|
|
45
|
+
{discountPercent > 0 && <span>-{discountPercent}%</span>}
|
|
46
|
+
</>
|
|
47
|
+
) : (
|
|
48
|
+
<span>{formatPrice(basePrice, { currency: currencyCode }) as string}</span>
|
|
49
|
+
)}
|
|
50
|
+
</span>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -11,6 +11,7 @@ import { StockBadge } from '@/ui/product/stock-badge';
|
|
|
11
11
|
import { DiscountBadge } from '@/ui/product/discount-badge';
|
|
12
12
|
import { useCart } from '@/core/providers/store-provider';
|
|
13
13
|
import { useCurrency } from '@/core/lib/use-currency';
|
|
14
|
+
import { trackAddToCart } from '@/core/lib/tracking';
|
|
14
15
|
import { cn } from '@/core/lib/utils';
|
|
15
16
|
|
|
16
17
|
interface ProductCardProps {
|
|
@@ -32,8 +33,7 @@ function pickDisplayPrice(
|
|
|
32
33
|
fallbackCurrency: string
|
|
33
34
|
): { price: number | undefined; salePrice: number | null; currency: string } {
|
|
34
35
|
if (product.displayPrice != null && product.displayCurrency) {
|
|
35
|
-
const sale =
|
|
36
|
-
product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
|
|
36
|
+
const sale = product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
|
|
37
37
|
return {
|
|
38
38
|
// `displayPrice` is the base price converted to the region currency —
|
|
39
39
|
// it goes into the PriceDisplay `price` (base) slot, NOT the sale slot.
|
|
@@ -119,6 +119,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
119
119
|
const client = getClient();
|
|
120
120
|
await client.smartAddToCart({ productId: product.id, quantity: 1 });
|
|
121
121
|
await refreshCart();
|
|
122
|
+
// Quick-add from a grid card is a real add_to_cart — without this the
|
|
123
|
+
// ad platforms only ever see adds made from a product page. Reported in
|
|
124
|
+
// the DISPLAYED currency (region-converted when the storefront passed a
|
|
125
|
+
// regionId), because that is what the shopper is actually being charged.
|
|
126
|
+
trackAddToCart(product, null, 1, display.salePrice ?? display.price, display.currency);
|
|
122
127
|
setAdded(true);
|
|
123
128
|
setTimeout(() => setAdded(false), 2000);
|
|
124
129
|
} catch (err) {
|