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