create-brainerce-store 1.78.0 → 1.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/index.js +27 -2
  2. package/messages/en.json +12 -3
  3. package/messages/he.json +12 -3
  4. package/package.json +10 -1
  5. package/templates/nextjs/base/.eslintrc.json +2 -0
  6. package/templates/nextjs/base/AGENTS.md.ejs +23 -5
  7. package/templates/nextjs/base/CLAUDE.md.ejs +23 -5
  8. package/templates/nextjs/base/src/app/checkout/page.tsx +19 -2
  9. package/templates/nextjs/base/src/app/order-status/page.tsx +18 -3
  10. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +220 -214
  11. package/templates/nextjs/base/src/components/account/order-history.tsx +11 -4
  12. package/templates/nextjs/base/src/components/account/profile-section.tsx +7 -1
  13. package/templates/nextjs/base/src/components/auth/register-form.tsx +18 -1
  14. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -2
  15. package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +1 -1
  16. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +343 -328
  17. package/templates/nextjs/base/src/core/lib/kit.ts +88 -0
  18. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +87 -12
  19. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +59 -12
  20. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +205 -197
  21. package/templates/nextjs/base/src/ui/product/product-card.tsx +230 -221
  22. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +524 -493
  23. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +117 -108
  24. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +23 -3
  25. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +210 -202
  26. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +251 -242
  27. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +540 -509
  28. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +110 -101
  29. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +22 -2
  30. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +41 -11
  31. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +50 -8
  32. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +182 -174
  33. package/templates/nextjs/ui-canvas/product/product-card.tsx +174 -165
  34. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +31 -0
  35. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +114 -105
  36. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +22 -2
@@ -1,197 +1,205 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import { Image as ImageIcon } from 'lucide-react';
5
- import { CdnImage as Image } from '@/ui/shared/cdn-image';
6
- import type { Product, ProductRecommendation } from 'brainerce';
7
- import { formatPrice } from 'brainerce';
8
- import { useCart, useStoreInfo } from '@/core/providers/store-provider';
9
- import { useCurrency } from '@/core/lib/use-currency';
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';
14
- import { cn } from '@/core/lib/utils';
15
-
16
- interface FrequentlyBoughtTogetherProps {
17
- items: ProductRecommendation[];
18
- currentProduct: Product;
19
- className?: string;
20
- }
21
-
22
- // ⛔ STORE CURRENCY THROUGHOUT, deliberately. The cross-sells are
23
- // `ProductRecommendation`, a trimmed shape with no displayPrice/displayCurrency,
24
- // so there is nothing to convert them to. Converting only `currentProduct`
25
- // (which is a full Product and does carry FX fields) would add a euro amount to
26
- // two dollar amounts and print the result as one total. One currency wins, and
27
- // it has to be the one the cart actually charges.
28
- function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
29
- const sale = item.salePrice ? parseFloat(item.salePrice) : null;
30
- const base = parseFloat(item.basePrice);
31
- return sale != null && sale < base ? sale : base;
32
- }
33
-
34
- function ProductThumb({
35
- name,
36
- imageUrl,
37
- price,
38
- currency,
39
- checked,
40
- onToggle,
41
- disabled,
42
- }: {
43
- name: string;
44
- imageUrl: string | null;
45
- price: number;
46
- currency: string;
47
- checked: boolean;
48
- onToggle?: () => void;
49
- disabled?: boolean;
50
- }) {
51
- return (
52
- <label
53
- className={cn(
54
- 'border-border bg-background relative flex cursor-pointer flex-col items-center rounded-lg border p-3 transition-all',
55
- checked ? 'ring-primary ring-2' : 'opacity-60',
56
- disabled && 'pointer-events-none'
57
- )}
58
- >
59
- {onToggle && (
60
- <Checkbox
61
- checked={checked}
62
- onCheckedChange={onToggle}
63
- className="absolute start-2 top-2"
64
- />
65
- )}
66
- <div className="bg-muted relative mb-2 h-20 w-20 overflow-hidden rounded">
67
- {imageUrl ? (
68
- <Image src={imageUrl} alt={name} fill sizes="80px" className="object-cover" />
69
- ) : (
70
- <div className="flex h-full w-full items-center justify-center">
71
- <ImageIcon
72
- className="text-muted-foreground h-8 w-8"
73
- strokeWidth={1.5}
74
- aria-hidden="true"
75
- />
76
- </div>
77
- )}
78
- </div>
79
- <span className="text-foreground line-clamp-2 text-center text-xs font-medium">{name}</span>
80
- <span className="text-muted-foreground mt-1 text-xs">
81
- {formatPrice(price, { currency }) as string}
82
- </span>
83
- </label>
84
- );
85
- }
86
-
87
- export function FrequentlyBoughtTogether({
88
- items,
89
- currentProduct,
90
- className,
91
- }: FrequentlyBoughtTogetherProps) {
92
- // Hooks must be called unconditionally and in the same order on every
93
- // render — keep all of them above any early `return null` branch.
94
- const { storeInfo } = useStoreInfo();
95
- const { refreshCart } = useCart();
96
- const t = useTranslations('productDetail');
97
- const currency = useCurrency();
98
-
99
- // Only show up to 3 cross-sells
100
- const crossSells = items.slice(0, 3);
101
-
102
- const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
103
- const [adding, setAdding] = useState(false);
104
-
105
- if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
106
- if (crossSells.length === 0) return null;
107
-
108
- const currentPrice = getEffectivePrice(currentProduct);
109
- const currentImage = currentProduct.images?.[0];
110
- const currentImageUrl = currentImage
111
- ? typeof currentImage === 'string'
112
- ? currentImage
113
- : currentImage.url
114
- : null;
115
-
116
- const totalPrice = crossSells
117
- .filter((item) => selected.has(item.id))
118
- .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
119
-
120
- const toggleItem = (id: string) => {
121
- setSelected((prev) => {
122
- const next = new Set(prev);
123
- if (next.has(id)) {
124
- next.delete(id);
125
- } else {
126
- next.add(id);
127
- }
128
- return next;
129
- });
130
- };
131
-
132
- async function handleAddAll() {
133
- if (adding || selected.size === 0) return;
134
- try {
135
- setAdding(true);
136
- const { getClient } = await import('@/core/lib/brainerce');
137
- const client = getClient();
138
- const selectedItems = crossSells.filter((item) => selected.has(item.id));
139
- for (const item of selectedItems) {
140
- await client.smartAddToCart({ productId: item.id, quantity: 1 });
141
- }
142
- await refreshCart();
143
- } catch (err) {
144
- console.error('Failed to add items to cart:', err);
145
- } finally {
146
- setAdding(false);
147
- }
148
- }
149
-
150
- return (
151
- <Card className={cn('shadow-none p-6', className)}>
152
- <h2 className="text-foreground mb-4 text-xl font-semibold">
153
- {t('frequentlyBoughtTogether')}
154
- </h2>
155
-
156
- <div className="flex flex-wrap items-center gap-3">
157
- {/* Current product (always included, no checkbox) */}
158
- <ProductThumb
159
- name={currentProduct.name}
160
- imageUrl={currentImageUrl}
161
- price={currentPrice}
162
- currency={currency}
163
- checked={true}
164
- disabled
165
- />
166
-
167
- {crossSells.map((item) => {
168
- const img = item.images?.[0];
169
- const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
170
- return (
171
- <div key={item.id} className="flex items-center gap-3">
172
- <span className="text-muted-foreground text-lg font-light">+</span>
173
- <ProductThumb
174
- name={item.name}
175
- imageUrl={imgUrl}
176
- price={getEffectivePrice(item)}
177
- currency={currency}
178
- checked={selected.has(item.id)}
179
- onToggle={() => toggleItem(item.id)}
180
- />
181
- </div>
182
- );
183
- })}
184
- </div>
185
-
186
- {/* Total + Add button */}
187
- <div className="mt-4 flex flex-wrap items-center gap-4">
188
- <span className="text-foreground text-lg font-semibold">
189
- {t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
190
- </span>
191
- <Button onClick={handleAddAll} disabled={adding || selected.size === 0} className="rounded px-5">
192
- {adding ? t('addingAll') : t('addSelectedToCart')}
193
- </Button>
194
- </div>
195
- </Card>
196
- );
197
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Image as ImageIcon } from 'lucide-react';
5
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
6
+ import type { Product, ProductRecommendation } from 'brainerce';
7
+ import { formatPrice } from 'brainerce';
8
+ import { useCart, useStoreInfo } from '@/core/providers/store-provider';
9
+ import { useCurrency } from '@/core/lib/use-currency';
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';
14
+ import { cn } from '@/core/lib/utils';
15
+
16
+ interface FrequentlyBoughtTogetherProps {
17
+ items: ProductRecommendation[];
18
+ currentProduct: Product;
19
+ className?: string;
20
+ }
21
+
22
+ // ⛔ STORE CURRENCY THROUGHOUT, deliberately. The cross-sells are
23
+ // `ProductRecommendation`, a trimmed shape with no displayPrice/displayCurrency,
24
+ // so there is nothing to convert them to. Converting only `currentProduct`
25
+ // (which is a full Product and does carry FX fields) would add a euro amount to
26
+ // two dollar amounts and print the result as one total. One currency wins, and
27
+ // it has to be the one the cart actually charges.
28
+ function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
29
+ const sale = item.salePrice ? parseFloat(item.salePrice) : null;
30
+ const base = parseFloat(item.basePrice);
31
+ return sale != null && sale < base ? sale : base;
32
+ }
33
+
34
+ function ProductThumb({
35
+ name,
36
+ imageUrl,
37
+ price,
38
+ currency,
39
+ checked,
40
+ onToggle,
41
+ disabled,
42
+ }: {
43
+ name: string;
44
+ imageUrl: string | null;
45
+ price: number;
46
+ currency: string;
47
+ checked: boolean;
48
+ onToggle?: () => void;
49
+ disabled?: boolean;
50
+ }) {
51
+ return (
52
+ <label
53
+ className={cn(
54
+ 'border-border bg-background relative flex cursor-pointer flex-col items-center rounded-lg border p-3 transition-all',
55
+ checked ? 'ring-primary ring-2' : 'opacity-60',
56
+ disabled && 'pointer-events-none'
57
+ )}
58
+ >
59
+ {onToggle && (
60
+ <Checkbox
61
+ checked={checked}
62
+ onCheckedChange={onToggle}
63
+ className="absolute start-2 top-2"
64
+ />
65
+ )}
66
+ <div className="bg-muted relative mb-2 h-20 w-20 overflow-hidden rounded">
67
+ {imageUrl ? (
68
+ <Image src={imageUrl} alt={name} fill sizes="80px" className="object-cover" />
69
+ ) : (
70
+ <div className="flex h-full w-full items-center justify-center">
71
+ <ImageIcon
72
+ className="text-muted-foreground h-8 w-8"
73
+ strokeWidth={1.5}
74
+ aria-hidden="true"
75
+ />
76
+ </div>
77
+ )}
78
+ </div>
79
+ <span className="text-foreground line-clamp-2 text-center text-xs font-medium">{name}</span>
80
+ <span className="text-muted-foreground mt-1 text-xs">
81
+ {formatPrice(price, { currency }) as string}
82
+ </span>
83
+ </label>
84
+ );
85
+ }
86
+
87
+ export function FrequentlyBoughtTogether({
88
+ items,
89
+ currentProduct,
90
+ className,
91
+ }: FrequentlyBoughtTogetherProps) {
92
+ // Hooks must be called unconditionally and in the same order on every
93
+ // render — keep all of them above any early `return null` branch.
94
+ const { storeInfo } = useStoreInfo();
95
+ const { refreshCart } = useCart();
96
+ const t = useTranslations('productDetail');
97
+ const currency = useCurrency();
98
+
99
+ // KITs are dropped, not priced. A recommendation is a `ProductRecommendation`
100
+ // an UNRESOLVED list shape, and the recommendations endpoint does not resolve
101
+ // kits, so `item.basePrice` on a kit is the stored PLACEHOLDER, wrong for every
102
+ // kit priced SUM / SUM_MINUS_PERCENT. This block sums those numbers into one
103
+ // "Total" beside a button that adds them all, so a wrong figure here is a
104
+ // number the shopper commits to. Adding a kit by its own `productId` would in
105
+ // fact be correct — it is the displayed price that cannot be trusted — so a
106
+ // kit still sells fine from its own product page, where the API resolves it.
107
+ // Only show up to 3 cross-sells.
108
+ const crossSells = items.filter((item) => item.type !== 'KIT').slice(0, 3);
109
+
110
+ const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
111
+ const [adding, setAdding] = useState(false);
112
+
113
+ if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
114
+ if (crossSells.length === 0) return null;
115
+
116
+ const currentPrice = getEffectivePrice(currentProduct);
117
+ const currentImage = currentProduct.images?.[0];
118
+ const currentImageUrl = currentImage
119
+ ? typeof currentImage === 'string'
120
+ ? currentImage
121
+ : currentImage.url
122
+ : null;
123
+
124
+ const totalPrice = crossSells
125
+ .filter((item) => selected.has(item.id))
126
+ .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
127
+
128
+ const toggleItem = (id: string) => {
129
+ setSelected((prev) => {
130
+ const next = new Set(prev);
131
+ if (next.has(id)) {
132
+ next.delete(id);
133
+ } else {
134
+ next.add(id);
135
+ }
136
+ return next;
137
+ });
138
+ };
139
+
140
+ async function handleAddAll() {
141
+ if (adding || selected.size === 0) return;
142
+ try {
143
+ setAdding(true);
144
+ const { getClient } = await import('@/core/lib/brainerce');
145
+ const client = getClient();
146
+ const selectedItems = crossSells.filter((item) => selected.has(item.id));
147
+ for (const item of selectedItems) {
148
+ await client.smartAddToCart({ productId: item.id, quantity: 1 });
149
+ }
150
+ await refreshCart();
151
+ } catch (err) {
152
+ console.error('Failed to add items to cart:', err);
153
+ } finally {
154
+ setAdding(false);
155
+ }
156
+ }
157
+
158
+ return (
159
+ <Card className={cn('shadow-none p-6', className)}>
160
+ <h2 className="text-foreground mb-4 text-xl font-semibold">
161
+ {t('frequentlyBoughtTogether')}
162
+ </h2>
163
+
164
+ <div className="flex flex-wrap items-center gap-3">
165
+ {/* Current product (always included, no checkbox) */}
166
+ <ProductThumb
167
+ name={currentProduct.name}
168
+ imageUrl={currentImageUrl}
169
+ price={currentPrice}
170
+ currency={currency}
171
+ checked={true}
172
+ disabled
173
+ />
174
+
175
+ {crossSells.map((item) => {
176
+ const img = item.images?.[0];
177
+ const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
178
+ return (
179
+ <div key={item.id} className="flex items-center gap-3">
180
+ <span className="text-muted-foreground text-lg font-light">+</span>
181
+ <ProductThumb
182
+ name={item.name}
183
+ imageUrl={imgUrl}
184
+ price={getEffectivePrice(item)}
185
+ currency={currency}
186
+ checked={selected.has(item.id)}
187
+ onToggle={() => toggleItem(item.id)}
188
+ />
189
+ </div>
190
+ );
191
+ })}
192
+ </div>
193
+
194
+ {/* Total + Add button */}
195
+ <div className="mt-4 flex flex-wrap items-center gap-4">
196
+ <span className="text-foreground text-lg font-semibold">
197
+ {t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
198
+ </span>
199
+ <Button onClick={handleAddAll} disabled={adding || selected.size === 0} className="rounded px-5">
200
+ {adding ? t('addingAll') : t('addSelectedToCart')}
201
+ </Button>
202
+ </div>
203
+ </Card>
204
+ );
205
+ }