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,268 +1,271 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState, useRef } from 'react';
|
|
4
|
-
import { Link, useRouter } from '@/core/lib/navigation';
|
|
5
|
-
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
6
|
-
import type { Product } from 'brainerce';
|
|
7
|
-
import { getProductPriceInfo, formatPrice } from 'brainerce';
|
|
8
|
-
import { pickDisplayPrice, pickDisplayPriceRange } from '@/core/lib/display-price';
|
|
9
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
10
|
-
import { PriceDisplay } from '@/ui/product/price-display';
|
|
11
|
-
import { StockBadge } from '@/ui/product/stock-badge';
|
|
12
|
-
import { canPurchaseProduct } from '@/core/lib/kit';
|
|
13
|
-
import { DiscountBadge } from '@/ui/product/discount-badge';
|
|
14
|
-
import { useCart } from '@/core/providers/store-provider';
|
|
15
|
-
import { flyToCart } from '@/ui/shared/fly-to-cart';
|
|
16
|
-
import { openCartDrawer } from '@/ui/cart/cart-drawer';
|
|
17
|
-
import { useCurrency } from '@/core/lib/use-currency';
|
|
18
|
-
import { trackAddToCart } from '@/core/lib/tracking';
|
|
19
|
-
import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
|
|
20
|
-
import { cn } from '@/core/lib/utils';
|
|
21
|
-
import {
|
|
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
|
-
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const [
|
|
67
|
-
const [
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
e.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
await
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
{
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
<span className="text-
|
|
202
|
-
{
|
|
203
|
-
</span>
|
|
204
|
-
<span className="text-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef } from 'react';
|
|
4
|
+
import { Link, useRouter } from '@/core/lib/navigation';
|
|
5
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
6
|
+
import type { Product } from 'brainerce';
|
|
7
|
+
import { getProductPriceInfo, formatPrice } from 'brainerce';
|
|
8
|
+
import { pickDisplayPrice, pickDisplayPriceRange } from '@/core/lib/display-price';
|
|
9
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
10
|
+
import { PriceDisplay } from '@/ui/product/price-display';
|
|
11
|
+
import { StockBadge } from '@/ui/product/stock-badge';
|
|
12
|
+
import { canPurchaseProduct } from '@/core/lib/kit';
|
|
13
|
+
import { DiscountBadge } from '@/ui/product/discount-badge';
|
|
14
|
+
import { useCart } from '@/core/providers/store-provider';
|
|
15
|
+
import { flyToCart } from '@/ui/shared/fly-to-cart';
|
|
16
|
+
import { openCartDrawer } from '@/ui/cart/cart-drawer';
|
|
17
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
18
|
+
import { trackAddToCart } from '@/core/lib/tracking';
|
|
19
|
+
import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
|
|
20
|
+
import { cn } from '@/core/lib/utils';
|
|
21
|
+
import { NoPhotoTile } from '@/ui/shared/no-photo-tile';
|
|
22
|
+
import { IconBag, IconCheck, IconDownload, IconStar } from '@/ui/shared/icons';
|
|
23
|
+
|
|
24
|
+
interface ProductCardProps {
|
|
25
|
+
product: Product;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function VariantPriceRange({ product }: { product: Product }) {
|
|
30
|
+
const fallbackCurrency = useCurrency();
|
|
31
|
+
// ⛔ Region-aware. A range built from priceMin/priceMax alone stays in the
|
|
32
|
+
// store currency while the single-price card beside it converts, so a
|
|
33
|
+
// variable product would quote a different currency from its neighbours.
|
|
34
|
+
const range = pickDisplayPriceRange(product, fallbackCurrency);
|
|
35
|
+
if (!range) return null;
|
|
36
|
+
const { min, max, currency } = range;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<span className="text-foreground text-base font-semibold">
|
|
40
|
+
{min === max
|
|
41
|
+
? (formatPrice(min, { currency }) as string)
|
|
42
|
+
: `${formatPrice(min, { currency })} – ${formatPrice(max, { currency })}`}
|
|
43
|
+
</span>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ProductCard({ product, className }: ProductCardProps) {
|
|
48
|
+
const t = useTranslations('common');
|
|
49
|
+
const tp = useTranslations('productDetail');
|
|
50
|
+
const tProd = useTranslations('products');
|
|
51
|
+
const tr = useTranslations('reviews');
|
|
52
|
+
const router = useRouter();
|
|
53
|
+
const { refreshCart } = useCart();
|
|
54
|
+
const fallbackCurrency = useCurrency();
|
|
55
|
+
// FX overlay (PRD §23): prefer the region-converted display values when the
|
|
56
|
+
// storefront passed regionId to getProducts. Otherwise fall back to the
|
|
57
|
+
// canonical store-currency basePrice / salePrice.
|
|
58
|
+
const display = pickDisplayPrice(product, fallbackCurrency);
|
|
59
|
+
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
60
|
+
const mainImage = product.images?.[0];
|
|
61
|
+
const hoverImage = product.images?.[1];
|
|
62
|
+
const imageUrl = mainImage?.url || null;
|
|
63
|
+
const slug = product.slug || product.id;
|
|
64
|
+
const isVariable = product.type === 'VARIABLE';
|
|
65
|
+
|
|
66
|
+
const [adding, setAdding] = useState(false);
|
|
67
|
+
const [added, setAdded] = useState(false);
|
|
68
|
+
const [addError, setAddError] = useState<AddToCartError | null>(null);
|
|
69
|
+
|
|
70
|
+
// ⛔ A KIT carries NO `inventory` object — its stock is on `kitAvailable`
|
|
71
|
+
// (`null` = unlimited, `0` = not sellable). Reading `inventory` here left a
|
|
72
|
+
// SOLD-OUT kit with an enabled add-to-cart button, because
|
|
73
|
+
// `undefined?.canPurchase !== false` is `true`. `canPurchaseProduct` reads
|
|
74
|
+
// the right field per product type, and keeps the `!== false` rule so a store
|
|
75
|
+
// that tracks no inventory at all can still sell.
|
|
76
|
+
// A kit also falls through `isVariable` above and adds by `productId` alone,
|
|
77
|
+
// which is correct — kits take no variantId and no selections.
|
|
78
|
+
const canPurchase = canPurchaseProduct(product);
|
|
79
|
+
|
|
80
|
+
const imageWellRef = useRef<HTMLDivElement>(null);
|
|
81
|
+
|
|
82
|
+
async function handleAddToCart(e: React.MouseEvent) {
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
e.stopPropagation();
|
|
85
|
+
|
|
86
|
+
if (isVariable) {
|
|
87
|
+
router.push(`/products/${slug}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (adding || !canPurchase) return;
|
|
92
|
+
setAddError(null);
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
flyToCart(imageWellRef.current);
|
|
96
|
+
setAdding(true);
|
|
97
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
98
|
+
const client = getClient();
|
|
99
|
+
await client.smartAddToCart({ productId: product.id, quantity: 1 });
|
|
100
|
+
await refreshCart();
|
|
101
|
+
openCartDrawer();
|
|
102
|
+
// Quick-add from a grid card is a real add_to_cart — without this the
|
|
103
|
+
// ad platforms only ever see adds made from a product page. Reported in
|
|
104
|
+
// the DISPLAYED currency (region-converted when the storefront passed a
|
|
105
|
+
// regionId), because that is what the shopper is actually being charged.
|
|
106
|
+
trackAddToCart(product, null, 1, display.salePrice ?? display.price, display.currency);
|
|
107
|
+
setAdded(true);
|
|
108
|
+
setTimeout(() => setAdded(false), 2000);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
// Quick-add used to fail in silence: the button spun, reset, and the
|
|
111
|
+
// item never appeared. A cart at the 50-line cap is the commonest
|
|
112
|
+
// cause and the shopper can fix it, so say so on the card.
|
|
113
|
+
setAddError(toAddToCartError(err));
|
|
114
|
+
console.error('Failed to add to cart:', err);
|
|
115
|
+
} finally {
|
|
116
|
+
setAdding(false);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<article
|
|
122
|
+
className={cn('card card-hover group relative flex flex-col overflow-hidden', className)}
|
|
123
|
+
>
|
|
124
|
+
<figure className="relative">
|
|
125
|
+
<Link href={`/products/${slug}`} className="block" tabIndex={-1} aria-hidden="true">
|
|
126
|
+
{/* `relative` + `aspect-square` are layout-critical for next/image fill */}
|
|
127
|
+
<div ref={imageWellRef} className="bg-secondary relative aspect-square overflow-hidden">
|
|
128
|
+
{imageUrl ? (
|
|
129
|
+
<>
|
|
130
|
+
<Image
|
|
131
|
+
src={imageUrl}
|
|
132
|
+
alt={mainImage?.alt || product.name}
|
|
133
|
+
fill
|
|
134
|
+
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
|
135
|
+
className={cn(
|
|
136
|
+
'img-zoom object-cover',
|
|
137
|
+
hoverImage && 'transition-opacity duration-500 group-hover:opacity-0'
|
|
138
|
+
)}
|
|
139
|
+
/>
|
|
140
|
+
{hoverImage && (
|
|
141
|
+
<Image
|
|
142
|
+
src={hoverImage.url}
|
|
143
|
+
alt={hoverImage.alt || product.name}
|
|
144
|
+
fill
|
|
145
|
+
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
|
146
|
+
className="object-cover opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
|
147
|
+
/>
|
|
148
|
+
)}
|
|
149
|
+
</>
|
|
150
|
+
) : (
|
|
151
|
+
// No photo yet: a designed typographic tile, not a blank square.
|
|
152
|
+
// Decorative — the h3 link below carries the accessible name.
|
|
153
|
+
<NoPhotoTile name={product.name} />
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
</Link>
|
|
157
|
+
<figcaption className="sr-only">{product.name}</figcaption>
|
|
158
|
+
|
|
159
|
+
{/* Badges overlay */}
|
|
160
|
+
<div className="absolute start-3 top-3 flex flex-wrap gap-1.5">
|
|
161
|
+
{isOnSale && <span className="badge-primary">{t('sale')}</span>}
|
|
162
|
+
<DiscountBadge discount={product.discount} />
|
|
163
|
+
{product.isDownloadable && (
|
|
164
|
+
<span className="badge-soft">
|
|
165
|
+
<IconDownload size={16} />
|
|
166
|
+
{tp('digitalProduct')}
|
|
167
|
+
</span>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
</figure>
|
|
171
|
+
|
|
172
|
+
<div className="flex flex-1 flex-col gap-1.5 p-4">
|
|
173
|
+
{/* Categories */}
|
|
174
|
+
{product.categories && product.categories.length > 0 && (
|
|
175
|
+
<ul className="text-muted-foreground flex flex-wrap gap-1 text-xs">
|
|
176
|
+
{product.categories.slice(0, 2).map((cat, idx) => (
|
|
177
|
+
<li key={cat.id}>
|
|
178
|
+
{idx > 0 && <span aria-hidden="true">· </span>}
|
|
179
|
+
{cat.name}
|
|
180
|
+
</li>
|
|
181
|
+
))}
|
|
182
|
+
</ul>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
{/* Name — the card-covering link */}
|
|
186
|
+
<h3 className="text-foreground font-sans text-[15px] font-semibold leading-snug">
|
|
187
|
+
<Link
|
|
188
|
+
href={`/products/${slug}`}
|
|
189
|
+
className="hover:text-primary after:absolute after:inset-0 after:content-['']"
|
|
190
|
+
>
|
|
191
|
+
{product.name}
|
|
192
|
+
</Link>
|
|
193
|
+
</h3>
|
|
194
|
+
|
|
195
|
+
{/* Star rating. `avgRating` / `reviewCount` are denormalized rollups
|
|
196
|
+
the API returns on every product fetch. Auto-hides on a store with
|
|
197
|
+
no reviews yet: `reviewCount` is 0 (or absent) and the whole block
|
|
198
|
+
is skipped, so nothing renders and the card keeps its layout. */}
|
|
199
|
+
{product.reviewCount ? (
|
|
200
|
+
<div className="flex items-center gap-1" aria-label={tr('starRating')}>
|
|
201
|
+
<span aria-hidden="true" className="text-primary">
|
|
202
|
+
<IconStar size={16} />
|
|
203
|
+
</span>
|
|
204
|
+
<span className="text-foreground text-xs font-semibold">
|
|
205
|
+
{(product.avgRating ?? 0).toFixed(1)}
|
|
206
|
+
</span>
|
|
207
|
+
<span className="text-muted-foreground text-xs">({product.reviewCount})</span>
|
|
208
|
+
</div>
|
|
209
|
+
) : null}
|
|
210
|
+
|
|
211
|
+
{/* Price */}
|
|
212
|
+
<div>
|
|
213
|
+
{isVariable ? (
|
|
214
|
+
<VariantPriceRange product={product} />
|
|
215
|
+
) : (
|
|
216
|
+
<PriceDisplay
|
|
217
|
+
price={display.price ?? originalPrice}
|
|
218
|
+
salePrice={display.salePrice ?? (isOnSale ? price : undefined)}
|
|
219
|
+
currency={display.currency}
|
|
220
|
+
size="md"
|
|
221
|
+
/>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<StockBadge
|
|
226
|
+
inventory={product.inventory}
|
|
227
|
+
// Only a KIT has kit stock. Gating on `type` here keeps the badge and
|
|
228
|
+
// `resolveStockInfo()` using ONE definition of 'this is a kit'.
|
|
229
|
+
kitAvailable={product.type === 'KIT' ? product.kitAvailable : undefined}
|
|
230
|
+
/>
|
|
231
|
+
|
|
232
|
+
{/* Add to cart (variable products route to the product page for option selection) */}
|
|
233
|
+
{(isVariable || canPurchase) && (
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
onClick={handleAddToCart}
|
|
237
|
+
disabled={adding}
|
|
238
|
+
className={cn(
|
|
239
|
+
'btn-outline btn-sm relative z-10 mt-2 w-full',
|
|
240
|
+
added && 'border-primary bg-primary text-primary-foreground hover:bg-primary'
|
|
241
|
+
)}
|
|
242
|
+
>
|
|
243
|
+
{added ? (
|
|
244
|
+
<>
|
|
245
|
+
<IconCheck size={16} />
|
|
246
|
+
{tp('addedToCart')}
|
|
247
|
+
</>
|
|
248
|
+
) : isVariable ? (
|
|
249
|
+
tProd('selectOptions')
|
|
250
|
+
) : (
|
|
251
|
+
<>
|
|
252
|
+
<IconBag size={16} />
|
|
253
|
+
{adding ? tp('addingToCart') : tp('addToCart')}
|
|
254
|
+
</>
|
|
255
|
+
)}
|
|
256
|
+
</button>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
{/*
|
|
260
|
+
Quick-add refusal. The card has no other error surface, and without
|
|
261
|
+
this the shopper taps, the button resets, and nothing appears.
|
|
262
|
+
*/}
|
|
263
|
+
{addError && (
|
|
264
|
+
<p role="alert" className="text-destructive mt-2 text-xs">
|
|
265
|
+
{addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}
|
|
266
|
+
</p>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
</article>
|
|
270
|
+
);
|
|
271
|
+
}
|