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.
- package/README.md +72 -0
- package/dist/index.js +9 -68
- package/messages/en.json +32 -0
- package/messages/he.json +32 -0
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +9 -0
- package/templates/nextjs/base/AI-GUIDE.md +52 -3
- package/templates/nextjs/base/CLAUDE.md +10 -0
- package/templates/nextjs/base/package.json.ejs +52 -39
- package/templates/nextjs/base/src/app/globals.css +36 -30
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -10
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +17 -11
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +2 -8
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -39
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +3 -1
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +8 -23
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +18 -27
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +10 -21
- package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +2 -3
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -103
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +4 -6
- package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -125
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -103
- package/templates/nextjs/base/src/ui/layout/header-account.tsx +2 -17
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -70
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -164
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -182
- package/templates/nextjs/base/src/ui/product/customization-fields.tsx +456 -444
- package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -22
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +15 -27
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -175
- package/templates/nextjs/base/src/ui/product/price-display.tsx +3 -2
- package/templates/nextjs/base/src/ui/product/product-card.tsx +238 -271
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -470
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +96 -79
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -107
- package/templates/nextjs/base/src/ui/product/review-form.tsx +44 -43
- package/templates/nextjs/{designs/passepartout → base/src}/ui/product/reviews-section.tsx.ejs +108 -96
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -63
- package/templates/nextjs/base/tailwind.config.ts +69 -42
- package/templates/nextjs/designs/{passepartout → atelier}/app-overlay/layout.tsx.ejs +23 -25
- package/templates/nextjs/designs/atelier/globals.css +396 -0
- package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
- package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-bundle-offer.tsx +110 -111
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-upgrade-banner.tsx +123 -141
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/free-shipping-bar.tsx +77 -62
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/reservation-countdown.tsx +89 -103
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/discount-badge.tsx +17 -22
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/frequently-bought-together.tsx +196 -205
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/review-form.tsx +267 -294
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/shared/fly-to-cart.ts +86 -85
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
- package/templates/nextjs/themes/luxury/globals.css +405 -399
- package/templates/nextjs/themes/minimal/globals.css +36 -30
- package/templates/nextjs/themes/playful/globals.css +406 -400
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +2 -2
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +2 -0
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/home-client.tsx +1 -1
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +1 -1
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +1 -1
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +2 -0
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +2 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +4 -0
- package/templates/nextjs/ui-canvas/product/review-form.tsx +29 -32
- package/templates/nextjs/ui-canvas/product/{reviews-section.tsx → reviews-section.tsx.ejs} +26 -8
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +2 -0
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +2 -0
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +2 -0
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx +0 -85
- package/templates/nextjs/designs/passepartout/globals.css +0 -412
- package/templates/nextjs/designs/passepartout/messages-patch/en.json +0 -87
- package/templates/nextjs/designs/passepartout/messages-patch/he.json +0 -87
- package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +0 -39
- package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +0 -149
- package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +0 -401
- package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +0 -139
- package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +0 -45
- package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +0 -103
- package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +0 -159
- package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +0 -155
- package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +0 -60
- package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +0 -34
- package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +0 -111
- package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +0 -265
- package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +0 -667
- package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +0 -35
- package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +0 -427
- package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +0 -113
- package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +0 -52
- package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +0 -307
- package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +0 -33
|
@@ -1,22 +1,18 @@
|
|
|
1
|
-
import type { ProductDiscount } from 'brainerce';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
<
|
|
51
|
-
type="checkbox"
|
|
54
|
+
<Checkbox
|
|
52
55
|
checked={checked}
|
|
53
|
-
|
|
54
|
-
className="absolute start-2 top-2
|
|
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
|
-
<
|
|
65
|
+
<ImageIcon
|
|
63
66
|
className="text-muted-foreground h-8 w-8"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
</
|
|
187
|
+
</Button>
|
|
200
188
|
</div>
|
|
201
|
-
</
|
|
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
|
|
13
|
-
* - MULTIPLE → renders as
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
{
|
|
99
|
-
<
|
|
100
|
-
{
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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 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,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
|
-
<
|
|
50
|
+
<Badge variant="destructive" className="rounded px-1.5 py-0.5 font-medium">
|
|
50
51
|
-{discountPercent}%
|
|
51
|
-
</
|
|
52
|
+
</Badge>
|
|
52
53
|
)}
|
|
53
54
|
</>
|
|
54
55
|
) : (
|