create-brainerce-store 1.84.0 → 1.86.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 (52) hide show
  1. package/README.md +130 -92
  2. package/dist/index.js +39 -17
  3. package/messages/en.json +9 -2
  4. package/messages/he.json +9 -2
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.env.local.ejs +10 -1
  7. package/templates/nextjs/base/AGENTS.md.ejs +229 -207
  8. package/templates/nextjs/base/AI-GUIDE.md +4 -0
  9. package/templates/nextjs/base/CLAUDE.md.ejs +240 -218
  10. package/templates/nextjs/base/scripts/connect.mjs +115 -18
  11. package/templates/nextjs/base/scripts/env-file.mjs +93 -0
  12. package/templates/nextjs/base/scripts/fetch-store-info.mjs +109 -83
  13. package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +2 -1
  14. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +2 -0
  15. package/templates/nextjs/base/src/app/checkout/page.tsx +1325 -1302
  16. package/templates/nextjs/base/src/app/forgot-password/page.tsx +3 -2
  17. package/templates/nextjs/base/src/app/icon.svg +11 -0
  18. package/templates/nextjs/base/src/app/layout.tsx.ejs +50 -21
  19. package/templates/nextjs/base/src/app/opengraph-image.tsx +5 -1
  20. package/templates/nextjs/base/src/app/reset-password/page.tsx +25 -138
  21. package/templates/nextjs/base/src/app/reset-password/reset-password-form.tsx +138 -0
  22. package/templates/nextjs/base/src/components/account/order-payment-block.tsx +17 -2
  23. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +174 -137
  24. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +130 -12
  25. package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
  26. package/templates/nextjs/base/src/core/lib/tracking.ts +25 -14
  27. package/templates/nextjs/base/src/core/lib/use-store-name.ts +18 -0
  28. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
  29. package/templates/nextjs/base/src/ui/layout/header-search.tsx +2 -2
  30. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +160 -160
  31. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +2 -2
  32. package/templates/nextjs/base/src/ui/product/price-display.tsx +85 -62
  33. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +539 -537
  34. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +374 -345
  35. package/templates/nextjs/designs/atelier/globals.css +408 -402
  36. package/templates/nextjs/designs/atelier/messages-patch/en.json +91 -96
  37. package/templates/nextjs/designs/atelier/messages-patch/he.json +91 -96
  38. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +131 -128
  39. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
  40. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +222 -219
  41. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +178 -151
  42. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +61 -31
  43. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +2 -2
  44. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +161 -155
  45. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +232 -227
  46. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +90 -69
  47. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +271 -268
  48. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +560 -553
  49. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +113 -110
  50. package/templates/nextjs/designs/atelier/ui/shared/no-photo-tile.tsx +73 -0
  51. package/templates/nextjs/ui-canvas/product/price-display.tsx +73 -52
  52. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +428 -426
@@ -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 { IconBag, IconCheck, IconDownload, IconStar } from '@/ui/shared/icons';
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-base font-semibold">
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 hoverImage = product.images?.[1];
61
- const imageUrl = mainImage?.url || null;
62
- const slug = product.slug || product.id;
63
- const isVariable = product.type === 'VARIABLE';
64
-
65
- const [adding, setAdding] = useState(false);
66
- const [added, setAdded] = useState(false);
67
- const [addError, setAddError] = useState<AddToCartError | null>(null);
68
-
69
- // ⛔ A KIT carries NO `inventory` object — its stock is on `kitAvailable`
70
- // (`null` = unlimited, `0` = not sellable). Reading `inventory` here left a
71
- // SOLD-OUT kit with an enabled add-to-cart button, because
72
- // `undefined?.canPurchase !== false` is `true`. `canPurchaseProduct` reads
73
- // the right field per product type, and keeps the `!== false` rule so a store
74
- // that tracks no inventory at all can still sell.
75
- // A kit also falls through `isVariable` above and adds by `productId` alone,
76
- // which is correct — kits take no variantId and no selections.
77
- const canPurchase = canPurchaseProduct(product);
78
-
79
- const imageWellRef = useRef<HTMLDivElement>(null);
80
-
81
- async function handleAddToCart(e: React.MouseEvent) {
82
- e.preventDefault();
83
- e.stopPropagation();
84
-
85
- if (isVariable) {
86
- router.push(`/products/${slug}`);
87
- return;
88
- }
89
-
90
- if (adding || !canPurchase) return;
91
- setAddError(null);
92
-
93
- try {
94
- flyToCart(imageWellRef.current);
95
- setAdding(true);
96
- const { getClient } = await import('@/core/lib/brainerce');
97
- const client = getClient();
98
- await client.smartAddToCart({ productId: product.id, quantity: 1 });
99
- await refreshCart();
100
- openCartDrawer();
101
- // Quick-add from a grid card is a real add_to_cart — without this the
102
- // ad platforms only ever see adds made from a product page. Reported in
103
- // the DISPLAYED currency (region-converted when the storefront passed a
104
- // regionId), because that is what the shopper is actually being charged.
105
- trackAddToCart(product, null, 1, display.salePrice ?? display.price, display.currency);
106
- setAdded(true);
107
- setTimeout(() => setAdded(false), 2000);
108
- } catch (err) {
109
- // Quick-add used to fail in silence: the button spun, reset, and the
110
- // item never appeared. A cart at the 50-line cap is the commonest
111
- // cause and the shopper can fix it, so say so on the card.
112
- setAddError(toAddToCartError(err));
113
- console.error('Failed to add to cart:', err);
114
- } finally {
115
- setAdding(false);
116
- }
117
- }
118
-
119
- return (
120
- <article
121
- className={cn('card card-hover group relative flex flex-col overflow-hidden', className)}
122
- >
123
- <figure className="relative">
124
- <Link href={`/products/${slug}`} className="block" tabIndex={-1} aria-hidden="true">
125
- {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
126
- <div ref={imageWellRef} className="bg-secondary relative aspect-square overflow-hidden">
127
- {imageUrl ? (
128
- <>
129
- <Image
130
- src={imageUrl}
131
- alt={mainImage?.alt || product.name}
132
- fill
133
- sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
134
- className={cn(
135
- 'img-zoom object-cover',
136
- hoverImage && 'transition-opacity duration-500 group-hover:opacity-0'
137
- )}
138
- />
139
- {hoverImage && (
140
- <Image
141
- src={hoverImage.url}
142
- alt={hoverImage.alt || product.name}
143
- fill
144
- sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
145
- className="object-cover opacity-0 transition-opacity duration-500 group-hover:opacity-100"
146
- />
147
- )}
148
- </>
149
- ) : (
150
- <span className="sr-only">{product.name}</span>
151
- )}
152
- </div>
153
- </Link>
154
- <figcaption className="sr-only">{product.name}</figcaption>
155
-
156
- {/* Badges overlay */}
157
- <div className="absolute start-3 top-3 flex flex-wrap gap-1.5">
158
- {isOnSale && <span className="badge-primary">{t('sale')}</span>}
159
- <DiscountBadge discount={product.discount} />
160
- {product.isDownloadable && (
161
- <span className="badge-soft">
162
- <IconDownload size={16} />
163
- {tp('digitalProduct')}
164
- </span>
165
- )}
166
- </div>
167
- </figure>
168
-
169
- <div className="flex flex-1 flex-col gap-1.5 p-4">
170
- {/* Categories */}
171
- {product.categories && product.categories.length > 0 && (
172
- <ul className="text-muted-foreground flex flex-wrap gap-1 text-xs">
173
- {product.categories.slice(0, 2).map((cat, idx) => (
174
- <li key={cat.id}>
175
- {idx > 0 && <span aria-hidden="true">· </span>}
176
- {cat.name}
177
- </li>
178
- ))}
179
- </ul>
180
- )}
181
-
182
- {/* Name — the card-covering link */}
183
- <h3 className="text-foreground font-sans text-[15px] font-semibold leading-snug">
184
- <Link
185
- href={`/products/${slug}`}
186
- className="hover:text-primary after:absolute after:inset-0 after:content-['']"
187
- >
188
- {product.name}
189
- </Link>
190
- </h3>
191
-
192
- {/* Star rating. `avgRating` / `reviewCount` are denormalized rollups
193
- the API returns on every product fetch. Auto-hides on a store with
194
- no reviews yet: `reviewCount` is 0 (or absent) and the whole block
195
- is skipped, so nothing renders and the card keeps its layout. */}
196
- {product.reviewCount ? (
197
- <div className="flex items-center gap-1" aria-label={tr('starRating')}>
198
- <span aria-hidden="true" className="text-primary">
199
- <IconStar size={16} />
200
- </span>
201
- <span className="text-foreground text-xs font-semibold">
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
- <div>
210
- {isVariable ? (
211
- <VariantPriceRange product={product} />
212
- ) : (
213
- <PriceDisplay
214
- price={display.price ?? originalPrice}
215
- salePrice={display.salePrice ?? (isOnSale ? price : undefined)}
216
- currency={display.currency}
217
- size="md"
218
- />
219
- )}
220
- </div>
221
-
222
- <StockBadge
223
- inventory={product.inventory}
224
- // Only a KIT has kit stock. Gating on `type` here keeps the badge and
225
- // `resolveStockInfo()` using ONE definition of 'this is a kit'.
226
- kitAvailable={product.type === 'KIT' ? product.kitAvailable : undefined}
227
- />
228
-
229
- {/* Add to cart (variable products route to the product page for option selection) */}
230
- {(isVariable || canPurchase) && (
231
- <button
232
- type="button"
233
- onClick={handleAddToCart}
234
- disabled={adding}
235
- className={cn(
236
- 'btn-outline btn-sm relative z-10 mt-2 w-full',
237
- added && 'border-primary bg-primary text-primary-foreground hover:bg-primary'
238
- )}
239
- >
240
- {added ? (
241
- <>
242
- <IconCheck size={16} />
243
- {tp('addedToCart')}
244
- </>
245
- ) : isVariable ? (
246
- tProd('selectOptions')
247
- ) : (
248
- <>
249
- <IconBag size={16} />
250
- {adding ? tp('addingToCart') : tp('addToCart')}
251
- </>
252
- )}
253
- </button>
254
- )}
255
-
256
- {/*
257
- Quick-add refusal. The card has no other error surface, and without
258
- this the shopper taps, the button resets, and nothing appears.
259
- */}
260
- {addError && (
261
- <p role="alert" className="text-destructive mt-2 text-xs">
262
- {addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}
263
- </p>
264
- )}
265
- </div>
266
- </article>
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
+ }