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.
- package/dist/index.js +27 -2
- package/messages/en.json +12 -3
- package/messages/he.json +12 -3
- package/package.json +10 -1
- package/templates/nextjs/base/.eslintrc.json +2 -0
- package/templates/nextjs/base/AGENTS.md.ejs +23 -5
- package/templates/nextjs/base/CLAUDE.md.ejs +23 -5
- package/templates/nextjs/base/src/app/checkout/page.tsx +19 -2
- package/templates/nextjs/base/src/app/order-status/page.tsx +18 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +220 -214
- package/templates/nextjs/base/src/components/account/order-history.tsx +11 -4
- package/templates/nextjs/base/src/components/account/profile-section.tsx +7 -1
- package/templates/nextjs/base/src/components/auth/register-form.tsx +18 -1
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -2
- package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +1 -1
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +343 -328
- package/templates/nextjs/base/src/core/lib/kit.ts +88 -0
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +87 -12
- package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +59 -12
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +205 -197
- package/templates/nextjs/base/src/ui/product/product-card.tsx +230 -221
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +524 -493
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +117 -108
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +23 -3
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +210 -202
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +251 -242
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +540 -509
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +110 -101
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +22 -2
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +41 -11
- package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +50 -8
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +182 -174
- package/templates/nextjs/ui-canvas/product/product-card.tsx +174 -165
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +31 -0
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +114 -105
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +22 -2
|
@@ -1,221 +1,230 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import { Check, Image as ImageIcon, Plus, ShoppingCart, Star } from 'lucide-react';
|
|
5
|
-
import { Link, useRouter } from '@/core/lib/navigation';
|
|
6
|
-
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
7
|
-
import type { Product } from 'brainerce';
|
|
8
|
-
import { getProductPriceInfo, formatPrice } from 'brainerce';
|
|
9
|
-
import { pickDisplayPrice, pickDisplayPriceRange } from '@/core/lib/display-price';
|
|
10
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
11
|
-
import { PriceDisplay } from '@/ui/product/price-display';
|
|
12
|
-
import { StockBadge } from '@/ui/product/stock-badge';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const [
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// carries NO `inventory` object
|
|
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
|
-
|
|
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
|
-
{cat.
|
|
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
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Check, Image as ImageIcon, Plus, ShoppingCart, Star } from 'lucide-react';
|
|
5
|
+
import { Link, useRouter } from '@/core/lib/navigation';
|
|
6
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
7
|
+
import type { Product } from 'brainerce';
|
|
8
|
+
import { getProductPriceInfo, formatPrice } from 'brainerce';
|
|
9
|
+
import { pickDisplayPrice, pickDisplayPriceRange } from '@/core/lib/display-price';
|
|
10
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
11
|
+
import { PriceDisplay } from '@/ui/product/price-display';
|
|
12
|
+
import { StockBadge } from '@/ui/product/stock-badge';
|
|
13
|
+
import { canPurchaseProduct } from '@/core/lib/kit';
|
|
14
|
+
import { DiscountBadge } from '@/ui/product/discount-badge';
|
|
15
|
+
import { Badge } from '@/components/ui/badge';
|
|
16
|
+
import { Button } from '@/components/ui/button';
|
|
17
|
+
import { Card } from '@/components/ui/card';
|
|
18
|
+
import { useCart } from '@/core/providers/store-provider';
|
|
19
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
20
|
+
import { trackAddToCart } from '@/core/lib/tracking';
|
|
21
|
+
import { cn } from '@/core/lib/utils';
|
|
22
|
+
|
|
23
|
+
interface ProductCardProps {
|
|
24
|
+
product: Product;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function VariantPriceRange({ product }: { product: Product }) {
|
|
29
|
+
const fallbackCurrency = useCurrency();
|
|
30
|
+
// ⛔ Region-aware. A range built from priceMin/priceMax alone stays in the
|
|
31
|
+
// store currency while the single-price card beside it converts, so a
|
|
32
|
+
// variable product would quote a different currency from its neighbours.
|
|
33
|
+
const range = pickDisplayPriceRange(product, fallbackCurrency);
|
|
34
|
+
if (!range) return null;
|
|
35
|
+
const { min, max, currency } = range;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<span className="text-foreground text-sm font-medium">
|
|
39
|
+
{min === max
|
|
40
|
+
? (formatPrice(min, { currency }) as string)
|
|
41
|
+
: `${formatPrice(min, { currency })} – ${formatPrice(max, { currency })}`}
|
|
42
|
+
</span>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function ProductCard({ product, className }: ProductCardProps) {
|
|
47
|
+
const t = useTranslations('common');
|
|
48
|
+
const tp = useTranslations('productDetail');
|
|
49
|
+
const tProd = useTranslations('products');
|
|
50
|
+
const tr = useTranslations('reviews');
|
|
51
|
+
const router = useRouter();
|
|
52
|
+
const { refreshCart } = useCart();
|
|
53
|
+
const fallbackCurrency = useCurrency();
|
|
54
|
+
// FX overlay (PRD §23): prefer the region-converted display values when the
|
|
55
|
+
// storefront passed regionId to getProducts. Otherwise fall back to the
|
|
56
|
+
// canonical store-currency basePrice / salePrice.
|
|
57
|
+
const display = pickDisplayPrice(product, fallbackCurrency);
|
|
58
|
+
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
59
|
+
const mainImage = product.images?.[0];
|
|
60
|
+
const imageUrl = mainImage?.url || null;
|
|
61
|
+
const slug = product.slug || product.id;
|
|
62
|
+
const isVariable = product.type === 'VARIABLE';
|
|
63
|
+
|
|
64
|
+
const [adding, setAdding] = useState(false);
|
|
65
|
+
const [added, setAdded] = useState(false);
|
|
66
|
+
|
|
67
|
+
// ⛔ A KIT carries NO `inventory` object — its stock is on `kitAvailable`
|
|
68
|
+
// (`null` = unlimited, `0` = not sellable). Reading `inventory` here left a
|
|
69
|
+
// SOLD-OUT kit with an enabled add-to-cart button, because
|
|
70
|
+
// `undefined?.canPurchase !== false` is `true`. `canPurchaseProduct` reads
|
|
71
|
+
// the right field per product type, and keeps the `!== false` rule so a store
|
|
72
|
+
// that tracks no inventory at all can still sell.
|
|
73
|
+
// A kit also falls through `isVariable` above and adds by `productId` alone,
|
|
74
|
+
// which is correct — kits take no variantId and no selections.
|
|
75
|
+
const canPurchase = canPurchaseProduct(product);
|
|
76
|
+
|
|
77
|
+
async function handleAddToCart(e: React.MouseEvent) {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
e.stopPropagation();
|
|
80
|
+
|
|
81
|
+
if (isVariable) {
|
|
82
|
+
router.push(`/products/${slug}`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (adding || !canPurchase) return;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
setAdding(true);
|
|
90
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
91
|
+
const client = getClient();
|
|
92
|
+
await client.smartAddToCart({ productId: product.id, quantity: 1 });
|
|
93
|
+
await refreshCart();
|
|
94
|
+
// Quick-add from a grid card is a real add_to_cart — without this the
|
|
95
|
+
// ad platforms only ever see adds made from a product page. Reported in
|
|
96
|
+
// the DISPLAYED currency (region-converted when the storefront passed a
|
|
97
|
+
// regionId), because that is what the shopper is actually being charged.
|
|
98
|
+
trackAddToCart(product, null, 1, display.salePrice ?? display.price, display.currency);
|
|
99
|
+
setAdded(true);
|
|
100
|
+
setTimeout(() => setAdded(false), 2000);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error('Failed to add to cart:', err);
|
|
103
|
+
} finally {
|
|
104
|
+
setAdding(false);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<Card
|
|
110
|
+
className={cn(
|
|
111
|
+
'group block overflow-hidden shadow-none transition-shadow hover:shadow-md',
|
|
112
|
+
className
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
{/* Image — clickable */}
|
|
116
|
+
<Link href={`/products/${slug}`} className="block">
|
|
117
|
+
<div className="bg-muted relative aspect-square overflow-hidden">
|
|
118
|
+
{imageUrl ? (
|
|
119
|
+
<Image
|
|
120
|
+
src={imageUrl}
|
|
121
|
+
alt={mainImage?.alt || product.name}
|
|
122
|
+
fill
|
|
123
|
+
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
|
124
|
+
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
|
125
|
+
/>
|
|
126
|
+
) : (
|
|
127
|
+
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
128
|
+
<ImageIcon className="h-12 w-12" strokeWidth={1.5} aria-hidden="true" />
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{/* Badges */}
|
|
133
|
+
<div className="absolute start-2 top-2 flex flex-col items-start gap-1">
|
|
134
|
+
{isOnSale && (
|
|
135
|
+
<Badge variant="destructive" className="rounded px-2 py-1 font-bold">
|
|
136
|
+
{t('sale')}
|
|
137
|
+
</Badge>
|
|
138
|
+
)}
|
|
139
|
+
<DiscountBadge discount={product.discount} />
|
|
140
|
+
{product.isDownloadable && (
|
|
141
|
+
<Badge className="rounded px-2 py-1 font-bold">{tp('digitalProduct')}</Badge>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
{/* Add to cart overlay button */}
|
|
146
|
+
{(isVariable || canPurchase) && (
|
|
147
|
+
<Button
|
|
148
|
+
size="icon"
|
|
149
|
+
onClick={handleAddToCart}
|
|
150
|
+
disabled={adding}
|
|
151
|
+
aria-label={isVariable ? tProd('selectOptions') : tp('addToCart')}
|
|
152
|
+
className={cn(
|
|
153
|
+
'absolute bottom-2 end-2 h-8 w-8 rounded-full shadow-md transition-all',
|
|
154
|
+
'translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100',
|
|
155
|
+
added && 'bg-green-500 text-white hover:bg-green-500'
|
|
156
|
+
)}
|
|
157
|
+
>
|
|
158
|
+
{added ? (
|
|
159
|
+
<Check strokeWidth={2.5} aria-hidden="true" />
|
|
160
|
+
) : isVariable ? (
|
|
161
|
+
<Plus aria-hidden="true" />
|
|
162
|
+
) : (
|
|
163
|
+
<ShoppingCart aria-hidden="true" />
|
|
164
|
+
)}
|
|
165
|
+
</Button>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
</Link>
|
|
169
|
+
|
|
170
|
+
{/* Content */}
|
|
171
|
+
<div className="space-y-2 p-3">
|
|
172
|
+
{/* Categories */}
|
|
173
|
+
{product.categories && product.categories.length > 0 && (
|
|
174
|
+
<div className="flex flex-wrap gap-1">
|
|
175
|
+
{product.categories.slice(0, 2).map((cat) => (
|
|
176
|
+
<Badge
|
|
177
|
+
key={cat.id}
|
|
178
|
+
variant="secondary"
|
|
179
|
+
className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px] font-normal"
|
|
180
|
+
>
|
|
181
|
+
{cat.name}
|
|
182
|
+
</Badge>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{/* Name — clickable */}
|
|
188
|
+
<Link href={`/products/${slug}`}>
|
|
189
|
+
<h3 className="text-foreground hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
|
|
190
|
+
{product.name}
|
|
191
|
+
</h3>
|
|
192
|
+
</Link>
|
|
193
|
+
|
|
194
|
+
{/* Star rating. `avgRating` / `reviewCount` are denormalized rollups
|
|
195
|
+
the API returns on every product fetch. Auto-hides on a store with
|
|
196
|
+
no reviews yet: `reviewCount` is 0 (or absent) and the whole block
|
|
197
|
+
is skipped, so nothing renders and the card keeps its layout. */}
|
|
198
|
+
{product.reviewCount ? (
|
|
199
|
+
<div className="flex items-center gap-1" aria-label={tr('starRating')}>
|
|
200
|
+
<Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" aria-hidden="true" />
|
|
201
|
+
<span className="text-foreground text-xs font-medium">
|
|
202
|
+
{(product.avgRating ?? 0).toFixed(1)}
|
|
203
|
+
</span>
|
|
204
|
+
<span className="text-muted-foreground text-xs">({product.reviewCount})</span>
|
|
205
|
+
</div>
|
|
206
|
+
) : null}
|
|
207
|
+
|
|
208
|
+
{/* Price */}
|
|
209
|
+
{isVariable ? (
|
|
210
|
+
<VariantPriceRange product={product} />
|
|
211
|
+
) : (
|
|
212
|
+
<PriceDisplay
|
|
213
|
+
price={display.price ?? originalPrice}
|
|
214
|
+
salePrice={display.salePrice ?? (isOnSale ? price : undefined)}
|
|
215
|
+
currency={display.currency}
|
|
216
|
+
size="sm"
|
|
217
|
+
/>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
{/* Stock */}
|
|
221
|
+
<StockBadge
|
|
222
|
+
inventory={product.inventory}
|
|
223
|
+
// Only a KIT has kit stock. Gating on `type` here keeps the badge and
|
|
224
|
+
// `resolveStockInfo()` using ONE definition of 'this is a kit'.
|
|
225
|
+
kitAvailable={product.type === 'KIT' ? product.kitAvailable : undefined}
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
</Card>
|
|
229
|
+
);
|
|
230
|
+
}
|