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,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 { DiscountBadge } from '@/ui/product/discount-badge';
14
- import { Badge } from '@/components/ui/badge';
15
- import { Button } from '@/components/ui/button';
16
- import { Card } from '@/components/ui/card';
17
- import { useCart } from '@/core/providers/store-provider';
18
- import { useCurrency } from '@/core/lib/use-currency';
19
- import { trackAddToCart } from '@/core/lib/tracking';
20
- import { cn } from '@/core/lib/utils';
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-sm font-medium">
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 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
- async function handleAddToCart(e: React.MouseEvent) {
74
- e.preventDefault();
75
- e.stopPropagation();
76
-
77
- if (isVariable) {
78
- router.push(`/products/${slug}`);
79
- return;
80
- }
81
-
82
- if (adding || !canPurchase) return;
83
-
84
- try {
85
- setAdding(true);
86
- const { getClient } = await import('@/core/lib/brainerce');
87
- const client = getClient();
88
- await client.smartAddToCart({ productId: product.id, quantity: 1 });
89
- await refreshCart();
90
- // Quick-add from a grid card is a real add_to_cart — without this the
91
- // ad platforms only ever see adds made from a product page. Reported in
92
- // the DISPLAYED currency (region-converted when the storefront passed a
93
- // regionId), because that is what the shopper is actually being charged.
94
- trackAddToCart(product, null, 1, display.salePrice ?? display.price, display.currency);
95
- setAdded(true);
96
- setTimeout(() => setAdded(false), 2000);
97
- } catch (err) {
98
- console.error('Failed to add to cart:', err);
99
- } finally {
100
- setAdding(false);
101
- }
102
- }
103
-
104
- return (
105
- <Card
106
- className={cn(
107
- 'group block overflow-hidden shadow-none transition-shadow hover:shadow-md',
108
- className
109
- )}
110
- >
111
- {/* Image clickable */}
112
- <Link href={`/products/${slug}`} className="block">
113
- <div className="bg-muted relative aspect-square overflow-hidden">
114
- {imageUrl ? (
115
- <Image
116
- src={imageUrl}
117
- alt={mainImage?.alt || product.name}
118
- fill
119
- sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
120
- className="object-cover transition-transform duration-300 group-hover:scale-105"
121
- />
122
- ) : (
123
- <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
124
- <ImageIcon className="h-12 w-12" strokeWidth={1.5} aria-hidden="true" />
125
- </div>
126
- )}
127
-
128
- {/* Badges */}
129
- <div className="absolute start-2 top-2 flex flex-col items-start gap-1">
130
- {isOnSale && (
131
- <Badge variant="destructive" className="rounded px-2 py-1 font-bold">
132
- {t('sale')}
133
- </Badge>
134
- )}
135
- <DiscountBadge discount={product.discount} />
136
- {product.isDownloadable && (
137
- <Badge className="rounded px-2 py-1 font-bold">{tp('digitalProduct')}</Badge>
138
- )}
139
- </div>
140
-
141
- {/* Add to cart overlay button */}
142
- {(isVariable || canPurchase) && (
143
- <Button
144
- size="icon"
145
- onClick={handleAddToCart}
146
- disabled={adding}
147
- aria-label={isVariable ? tProd('selectOptions') : tp('addToCart')}
148
- className={cn(
149
- 'absolute bottom-2 end-2 h-8 w-8 rounded-full shadow-md transition-all',
150
- 'translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100',
151
- added && 'bg-green-500 text-white hover:bg-green-500'
152
- )}
153
- >
154
- {added ? (
155
- <Check strokeWidth={2.5} aria-hidden="true" />
156
- ) : isVariable ? (
157
- <Plus aria-hidden="true" />
158
- ) : (
159
- <ShoppingCart aria-hidden="true" />
160
- )}
161
- </Button>
162
- )}
163
- </div>
164
- </Link>
165
-
166
- {/* Content */}
167
- <div className="space-y-2 p-3">
168
- {/* Categories */}
169
- {product.categories && product.categories.length > 0 && (
170
- <div className="flex flex-wrap gap-1">
171
- {product.categories.slice(0, 2).map((cat) => (
172
- <Badge
173
- key={cat.id}
174
- variant="secondary"
175
- className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px] font-normal"
176
- >
177
- {cat.name}
178
- </Badge>
179
- ))}
180
- </div>
181
- )}
182
-
183
- {/* Name — clickable */}
184
- <Link href={`/products/${slug}`}>
185
- <h3 className="text-foreground hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
186
- {product.name}
187
- </h3>
188
- </Link>
189
-
190
- {/* Star rating. `avgRating` / `reviewCount` are denormalized rollups
191
- the API returns on every product fetch. Auto-hides on a store with
192
- no reviews yet: `reviewCount` is 0 (or absent) and the whole block
193
- is skipped, so nothing renders and the card keeps its layout. */}
194
- {product.reviewCount ? (
195
- <div className="flex items-center gap-1" aria-label={tr('starRating')}>
196
- <Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" aria-hidden="true" />
197
- <span className="text-foreground text-xs font-medium">
198
- {(product.avgRating ?? 0).toFixed(1)}
199
- </span>
200
- <span className="text-muted-foreground text-xs">({product.reviewCount})</span>
201
- </div>
202
- ) : null}
203
-
204
- {/* Price */}
205
- {isVariable ? (
206
- <VariantPriceRange product={product} />
207
- ) : (
208
- <PriceDisplay
209
- price={display.price ?? originalPrice}
210
- salePrice={display.salePrice ?? (isOnSale ? price : undefined)}
211
- currency={display.currency}
212
- size="sm"
213
- />
214
- )}
215
-
216
- {/* Stock */}
217
- <StockBadge inventory={product.inventory} />
218
- </div>
219
- </Card>
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
+ }