create-brainerce-store 1.84.0 → 1.85.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 +130 -92
- package/dist/index.js +39 -17
- package/messages/en.json +3 -0
- package/messages/he.json +3 -0
- package/package.json +1 -1
- package/templates/nextjs/base/.env.local.ejs +10 -1
- package/templates/nextjs/base/AGENTS.md.ejs +229 -207
- package/templates/nextjs/base/AI-GUIDE.md +4 -0
- package/templates/nextjs/base/CLAUDE.md.ejs +240 -218
- package/templates/nextjs/base/scripts/connect.mjs +115 -18
- package/templates/nextjs/base/scripts/env-file.mjs +93 -0
- package/templates/nextjs/base/scripts/fetch-store-info.mjs +109 -83
- package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +2 -1
- package/templates/nextjs/base/src/app/forgot-password/page.tsx +3 -2
- package/templates/nextjs/base/src/app/layout.tsx.ejs +50 -21
- package/templates/nextjs/base/src/app/opengraph-image.tsx +5 -1
- package/templates/nextjs/base/src/app/reset-password/page.tsx +25 -138
- package/templates/nextjs/base/src/app/reset-password/reset-password-form.tsx +138 -0
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +174 -137
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +82 -11
- package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
- package/templates/nextjs/base/src/core/lib/use-store-name.ts +18 -0
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
- package/templates/nextjs/base/src/ui/layout/header-search.tsx +2 -2
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +160 -160
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +2 -2
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +374 -345
- package/templates/nextjs/designs/atelier/globals.css +408 -402
- package/templates/nextjs/designs/atelier/messages-patch/en.json +91 -96
- package/templates/nextjs/designs/atelier/messages-patch/he.json +91 -96
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +131 -128
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +222 -219
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +178 -151
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +61 -31
- package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +2 -2
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +161 -155
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +232 -227
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +271 -268
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +558 -553
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +113 -110
- package/templates/nextjs/designs/atelier/ui/shared/no-photo-tile.tsx +73 -0
|
@@ -1,227 +1,232 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
const
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
{
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
|
+
import { NoPhotoTile } from '@/ui/shared/no-photo-tile';
|
|
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 { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
|
|
12
|
+
import { cn } from '@/core/lib/utils';
|
|
13
|
+
import { IconPlus, IconBag } from '@/ui/shared/icons';
|
|
14
|
+
|
|
15
|
+
interface FrequentlyBoughtTogetherProps {
|
|
16
|
+
items: ProductRecommendation[];
|
|
17
|
+
currentProduct: Product;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ⛔ STORE CURRENCY THROUGHOUT, deliberately. The cross-sells are
|
|
22
|
+
// `ProductRecommendation`, a trimmed shape with no displayPrice/displayCurrency,
|
|
23
|
+
// so there is nothing to convert them to. Converting only `currentProduct`
|
|
24
|
+
// (which is a full Product and does carry FX fields) would add a euro amount to
|
|
25
|
+
// two dollar amounts and print the result as one total. One currency wins, and
|
|
26
|
+
// it has to be the one the cart actually charges.
|
|
27
|
+
function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
|
|
28
|
+
const sale = item.salePrice ? parseFloat(item.salePrice) : null;
|
|
29
|
+
const base = parseFloat(item.basePrice);
|
|
30
|
+
return sale != null && sale < base ? sale : base;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ProductThumb({
|
|
34
|
+
name,
|
|
35
|
+
imageUrl,
|
|
36
|
+
price,
|
|
37
|
+
currency,
|
|
38
|
+
checked,
|
|
39
|
+
onToggle,
|
|
40
|
+
disabled,
|
|
41
|
+
}: {
|
|
42
|
+
name: string;
|
|
43
|
+
imageUrl: string | null;
|
|
44
|
+
price: number;
|
|
45
|
+
currency: string;
|
|
46
|
+
checked: boolean;
|
|
47
|
+
onToggle?: () => void;
|
|
48
|
+
disabled?: boolean;
|
|
49
|
+
}) {
|
|
50
|
+
return (
|
|
51
|
+
<label
|
|
52
|
+
className={cn(
|
|
53
|
+
'flex w-40 cursor-pointer flex-col gap-2 rounded-xl border bg-background p-3 transition-colors',
|
|
54
|
+
checked ? 'border-primary' : 'border-border',
|
|
55
|
+
disabled && 'cursor-default'
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
{/* `relative` + fixed box are layout-critical for next/image fill */}
|
|
59
|
+
<span className="relative block aspect-square overflow-hidden rounded-lg bg-secondary">
|
|
60
|
+
{imageUrl ? (
|
|
61
|
+
<Image src={imageUrl} alt={name} fill sizes="160px" className="object-cover" />
|
|
62
|
+
) : (
|
|
63
|
+
<NoPhotoTile name={name} />
|
|
64
|
+
)}
|
|
65
|
+
{onToggle && (
|
|
66
|
+
<input
|
|
67
|
+
type="checkbox"
|
|
68
|
+
checked={checked}
|
|
69
|
+
onChange={onToggle}
|
|
70
|
+
disabled={disabled}
|
|
71
|
+
className="absolute end-2 top-2 h-[18px] w-[18px] accent-primary"
|
|
72
|
+
aria-label={name}
|
|
73
|
+
/>
|
|
74
|
+
)}
|
|
75
|
+
</span>
|
|
76
|
+
<span className="line-clamp-2 text-xs font-semibold leading-snug text-foreground">
|
|
77
|
+
{name}
|
|
78
|
+
</span>
|
|
79
|
+
<span className="text-sm font-semibold text-foreground">
|
|
80
|
+
{formatPrice(price, { currency }) as string}
|
|
81
|
+
</span>
|
|
82
|
+
</label>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* "Frequently bought together" bundle on the product page: current product +
|
|
88
|
+
* selectable cross-sells + combined total + add-all button; items come from
|
|
89
|
+
* useProductPage().recommendations.crossSells, the feature gate from
|
|
90
|
+
* useStoreInfo().upsell.
|
|
91
|
+
*/
|
|
92
|
+
export function FrequentlyBoughtTogether({
|
|
93
|
+
items,
|
|
94
|
+
currentProduct,
|
|
95
|
+
className,
|
|
96
|
+
}: FrequentlyBoughtTogetherProps) {
|
|
97
|
+
// Hooks must be called unconditionally and in the same order on every
|
|
98
|
+
// render — keep all of them above any early `return null` branch.
|
|
99
|
+
const { storeInfo } = useStoreInfo();
|
|
100
|
+
const { refreshCart } = useCart();
|
|
101
|
+
const t = useTranslations('productDetail');
|
|
102
|
+
const currency = useCurrency();
|
|
103
|
+
|
|
104
|
+
// ⛔ KITs are dropped, not priced. A recommendation is a `ProductRecommendation`
|
|
105
|
+
// — an UNRESOLVED list shape, and the recommendations endpoint does not resolve
|
|
106
|
+
// kits, so `item.basePrice` on a kit is the stored PLACEHOLDER, wrong for every
|
|
107
|
+
// kit priced SUM / SUM_MINUS_PERCENT. This block sums those numbers into one
|
|
108
|
+
// "Total" beside a button that adds them all, so a wrong figure here is a
|
|
109
|
+
// number the shopper commits to. Adding a kit by its own `productId` would in
|
|
110
|
+
// fact be correct — it is the displayed price that cannot be trusted — so a
|
|
111
|
+
// kit still sells fine from its own product page, where the API resolves it.
|
|
112
|
+
// Only show up to 3 cross-sells.
|
|
113
|
+
const crossSells = items.filter((item) => item.type !== 'KIT').slice(0, 3);
|
|
114
|
+
|
|
115
|
+
const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
|
|
116
|
+
const [adding, setAdding] = useState(false);
|
|
117
|
+
const [addError, setAddError] = useState<AddToCartError | null>(null);
|
|
118
|
+
|
|
119
|
+
if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
|
|
120
|
+
if (crossSells.length === 0) return null;
|
|
121
|
+
|
|
122
|
+
const currentPrice = getEffectivePrice(currentProduct);
|
|
123
|
+
const currentImage = currentProduct.images?.[0];
|
|
124
|
+
const currentImageUrl = currentImage
|
|
125
|
+
? typeof currentImage === 'string'
|
|
126
|
+
? currentImage
|
|
127
|
+
: currentImage.url
|
|
128
|
+
: null;
|
|
129
|
+
|
|
130
|
+
const totalPrice = crossSells
|
|
131
|
+
.filter((item) => selected.has(item.id))
|
|
132
|
+
.reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
|
|
133
|
+
|
|
134
|
+
const toggleItem = (id: string) => {
|
|
135
|
+
setSelected((prev) => {
|
|
136
|
+
const next = new Set(prev);
|
|
137
|
+
if (next.has(id)) {
|
|
138
|
+
next.delete(id);
|
|
139
|
+
} else {
|
|
140
|
+
next.add(id);
|
|
141
|
+
}
|
|
142
|
+
return next;
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
async function handleAddAll() {
|
|
147
|
+
if (adding || selected.size === 0) return;
|
|
148
|
+
setAddError(null);
|
|
149
|
+
try {
|
|
150
|
+
setAdding(true);
|
|
151
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
152
|
+
const client = getClient();
|
|
153
|
+
const selectedItems = crossSells.filter((item) => selected.has(item.id));
|
|
154
|
+
for (const item of selectedItems) {
|
|
155
|
+
await client.smartAddToCart({ productId: item.id, quantity: 1 });
|
|
156
|
+
}
|
|
157
|
+
await refreshCart();
|
|
158
|
+
} catch (err) {
|
|
159
|
+
// The loop is not transactional: if the cart hits its 50-line cap
|
|
160
|
+
// part-way through, the earlier items ARE in the cart and the rest are
|
|
161
|
+
// not. Saying so beats a silent stop the shopper reads as a dead button.
|
|
162
|
+
setAddError(toAddToCartError(err));
|
|
163
|
+
console.error('Failed to add items to cart:', err);
|
|
164
|
+
} finally {
|
|
165
|
+
setAdding(false);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<section className={cn('rounded-2xl border bg-secondary/50 p-6 sm:p-8', className)}>
|
|
171
|
+
<h2 className="mb-6 text-2xl">{t('frequentlyBoughtTogether')}</h2>
|
|
172
|
+
|
|
173
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
174
|
+
{/* Current product (always included, no checkbox) */}
|
|
175
|
+
<ProductThumb
|
|
176
|
+
name={currentProduct.name}
|
|
177
|
+
imageUrl={currentImageUrl}
|
|
178
|
+
price={currentPrice}
|
|
179
|
+
currency={currency}
|
|
180
|
+
checked={true}
|
|
181
|
+
disabled
|
|
182
|
+
/>
|
|
183
|
+
|
|
184
|
+
{crossSells.map((item) => {
|
|
185
|
+
const img = item.images?.[0];
|
|
186
|
+
const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
|
|
187
|
+
return (
|
|
188
|
+
<div key={item.id} className="flex items-center gap-3">
|
|
189
|
+
<span aria-hidden="true" className="text-muted-foreground">
|
|
190
|
+
<IconPlus size={20} />
|
|
191
|
+
</span>
|
|
192
|
+
<ProductThumb
|
|
193
|
+
name={item.name}
|
|
194
|
+
imageUrl={imgUrl}
|
|
195
|
+
price={getEffectivePrice(item)}
|
|
196
|
+
currency={currency}
|
|
197
|
+
checked={selected.has(item.id)}
|
|
198
|
+
onToggle={() => toggleItem(item.id)}
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
})}
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{/* Total + Add button */}
|
|
206
|
+
<div className="mt-6 flex flex-wrap items-center gap-4 border-t pt-5">
|
|
207
|
+
<span className="text-lg font-semibold text-foreground">
|
|
208
|
+
{t('totalPrice').replace('{price}', formatPrice(totalPrice, { currency }) as string)}
|
|
209
|
+
</span>
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
onClick={handleAddAll}
|
|
213
|
+
disabled={adding || selected.size === 0}
|
|
214
|
+
className="btn-primary btn-md"
|
|
215
|
+
>
|
|
216
|
+
<IconBag size={18} />
|
|
217
|
+
{adding ? t('addingAll') : t('addSelectedToCart')}
|
|
218
|
+
</button>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
{/*
|
|
222
|
+
Why the add was refused. Some items may already be in the cart:
|
|
223
|
+
the loop above adds one at a time and stops at the first refusal.
|
|
224
|
+
*/}
|
|
225
|
+
{addError && (
|
|
226
|
+
<p role="alert" className="text-destructive mt-3 text-sm font-medium">
|
|
227
|
+
{addError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
|
|
228
|
+
</p>
|
|
229
|
+
)}
|
|
230
|
+
</section>
|
|
231
|
+
);
|
|
232
|
+
}
|