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,165 +1,174 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState } 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 {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const [
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
// carries NO `inventory` object
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } 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 { useCurrency } from '@/core/lib/use-currency';
|
|
16
|
+
import { trackAddToCart } from '@/core/lib/tracking';
|
|
17
|
+
import { cn } from '@/core/lib/utils';
|
|
18
|
+
|
|
19
|
+
interface ProductCardProps {
|
|
20
|
+
product: Product;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function VariantPriceRange({ product }: { product: Product }) {
|
|
25
|
+
const fallbackCurrency = useCurrency();
|
|
26
|
+
// ⛔ Region-aware. A range built from priceMin/priceMax alone stays in the
|
|
27
|
+
// store currency while the single-price card beside it converts, so a
|
|
28
|
+
// variable product would quote a different currency from its neighbours.
|
|
29
|
+
const range = pickDisplayPriceRange(product, fallbackCurrency);
|
|
30
|
+
if (!range) return null;
|
|
31
|
+
const { min, max, currency } = range;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<span>
|
|
35
|
+
{min === max
|
|
36
|
+
? (formatPrice(min, { currency }) as string)
|
|
37
|
+
: `${formatPrice(min, { currency })} – ${formatPrice(max, { currency })}`}
|
|
38
|
+
</span>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function ProductCard({ product, className }: ProductCardProps) {
|
|
43
|
+
const t = useTranslations('common');
|
|
44
|
+
const tp = useTranslations('productDetail');
|
|
45
|
+
const tProd = useTranslations('products');
|
|
46
|
+
const router = useRouter();
|
|
47
|
+
const { refreshCart } = useCart();
|
|
48
|
+
const fallbackCurrency = useCurrency();
|
|
49
|
+
// FX overlay (PRD §23): prefer the region-converted display values when the
|
|
50
|
+
// storefront passed regionId to getProducts. Otherwise fall back to the
|
|
51
|
+
// canonical store-currency basePrice / salePrice.
|
|
52
|
+
const display = pickDisplayPrice(product, fallbackCurrency);
|
|
53
|
+
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
54
|
+
const mainImage = product.images?.[0];
|
|
55
|
+
const imageUrl = mainImage?.url || null;
|
|
56
|
+
const slug = product.slug || product.id;
|
|
57
|
+
const isVariable = product.type === 'VARIABLE';
|
|
58
|
+
|
|
59
|
+
const [adding, setAdding] = useState(false);
|
|
60
|
+
const [added, setAdded] = useState(false);
|
|
61
|
+
|
|
62
|
+
// ⛔ A KIT carries NO `inventory` object — its stock is on `kitAvailable`
|
|
63
|
+
// (`null` = unlimited, `0` = not sellable). Reading `inventory` here left a
|
|
64
|
+
// SOLD-OUT kit with an enabled add-to-cart button, because
|
|
65
|
+
// `undefined?.canPurchase !== false` is `true`. `canPurchaseProduct` reads
|
|
66
|
+
// the right field per product type, and keeps the `!== false` rule so a store
|
|
67
|
+
// that tracks no inventory at all can still sell.
|
|
68
|
+
// A kit also falls through `isVariable` above and adds by `productId` alone,
|
|
69
|
+
// which is correct — kits take no variantId and no selections.
|
|
70
|
+
const canPurchase = canPurchaseProduct(product);
|
|
71
|
+
|
|
72
|
+
async function handleAddToCart(e: React.MouseEvent) {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
e.stopPropagation();
|
|
75
|
+
|
|
76
|
+
if (isVariable) {
|
|
77
|
+
router.push(`/products/${slug}`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (adding || !canPurchase) return;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
setAdding(true);
|
|
85
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
86
|
+
const client = getClient();
|
|
87
|
+
await client.smartAddToCart({ productId: product.id, quantity: 1 });
|
|
88
|
+
await refreshCart();
|
|
89
|
+
// Quick-add from a grid card is a real add_to_cart — without this the
|
|
90
|
+
// ad platforms only ever see adds made from a product page. Reported in
|
|
91
|
+
// the DISPLAYED currency (region-converted when the storefront passed a
|
|
92
|
+
// regionId), because that is what the shopper is actually being charged.
|
|
93
|
+
trackAddToCart(product, null, 1, display.salePrice ?? display.price, display.currency);
|
|
94
|
+
setAdded(true);
|
|
95
|
+
setTimeout(() => setAdded(false), 2000);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error('Failed to add to cart:', err);
|
|
98
|
+
} finally {
|
|
99
|
+
setAdding(false);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<article className={cn(className)}>
|
|
105
|
+
{/* DESIGN ME — product card (grid/listing unit): image, badges, name, price, stock, add-to-cart; product data arrives as a prop from useHomeData()/useProductListing(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
106
|
+
<figure>
|
|
107
|
+
<Link href={`/products/${slug}`}>
|
|
108
|
+
{/* `relative` + `aspect-square` are layout-critical for next/image fill */}
|
|
109
|
+
<div className="relative aspect-square">
|
|
110
|
+
{imageUrl ? (
|
|
111
|
+
<Image
|
|
112
|
+
src={imageUrl}
|
|
113
|
+
alt={mainImage?.alt || product.name}
|
|
114
|
+
fill
|
|
115
|
+
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
|
116
|
+
/>
|
|
117
|
+
) : (
|
|
118
|
+
<span className="sr-only">{product.name}</span>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
</Link>
|
|
122
|
+
<figcaption className="sr-only">{product.name}</figcaption>
|
|
123
|
+
</figure>
|
|
124
|
+
|
|
125
|
+
{/* Badges */}
|
|
126
|
+
<div className="flex flex-wrap gap-1">
|
|
127
|
+
{isOnSale && <span>{t('sale')}</span>}
|
|
128
|
+
<DiscountBadge discount={product.discount} />
|
|
129
|
+
{product.isDownloadable && <span>{tp('digitalProduct')}</span>}
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{/* Categories */}
|
|
133
|
+
{product.categories && product.categories.length > 0 && (
|
|
134
|
+
<ul className="flex flex-wrap gap-1">
|
|
135
|
+
{product.categories.slice(0, 2).map((cat) => (
|
|
136
|
+
<li key={cat.id}>{cat.name}</li>
|
|
137
|
+
))}
|
|
138
|
+
</ul>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{/* Name — clickable */}
|
|
142
|
+
<h3>
|
|
143
|
+
<Link href={`/products/${slug}`}>{product.name}</Link>
|
|
144
|
+
</h3>
|
|
145
|
+
|
|
146
|
+
{/* Price */}
|
|
147
|
+
{isVariable ? (
|
|
148
|
+
<VariantPriceRange product={product} />
|
|
149
|
+
) : (
|
|
150
|
+
<PriceDisplay
|
|
151
|
+
price={display.price ?? originalPrice}
|
|
152
|
+
salePrice={display.salePrice ?? (isOnSale ? price : undefined)}
|
|
153
|
+
currency={display.currency}
|
|
154
|
+
size="sm"
|
|
155
|
+
/>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{/* Stock */}
|
|
159
|
+
<StockBadge
|
|
160
|
+
inventory={product.inventory}
|
|
161
|
+
// Only a KIT has kit stock. Gating on `type` here keeps the badge and
|
|
162
|
+
// `resolveStockInfo()` using ONE definition of 'this is a kit'.
|
|
163
|
+
kitAvailable={product.type === 'KIT' ? product.kitAvailable : undefined}
|
|
164
|
+
/>
|
|
165
|
+
|
|
166
|
+
{/* Add to cart (variable products route to the product page for option selection) */}
|
|
167
|
+
{(isVariable || canPurchase) && (
|
|
168
|
+
<button type="button" onClick={handleAddToCart} disabled={adding}>
|
|
169
|
+
{added ? tp('addedToCart') : isVariable ? tProd('selectOptions') : tp('addToCart')}
|
|
170
|
+
</button>
|
|
171
|
+
)}
|
|
172
|
+
</article>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -251,6 +251,37 @@ export function ProductClientSection({
|
|
|
251
251
|
)}
|
|
252
252
|
|
|
253
253
|
{/* Variant Selector */}
|
|
254
|
+
{/* A KIT is bought as ONE line, but the shopper needs to see what is
|
|
255
|
+
in the box before deciding. These rows are display only — never add
|
|
256
|
+
them to the cart individually; the kit reserves its components on
|
|
257
|
+
its own. */}
|
|
258
|
+
{product.type === 'KIT' && product.kitComponents?.length ? (
|
|
259
|
+
<div className="mb-6">
|
|
260
|
+
<h2 className="mb-3 text-sm font-medium">{t('whatsInTheBox')}</h2>
|
|
261
|
+
<ul className="divide-y rounded-lg border">
|
|
262
|
+
{product.kitComponents.map((c) => (
|
|
263
|
+
<li
|
|
264
|
+
key={`${c.productId}-${c.variantId ?? ''}`}
|
|
265
|
+
className="flex items-center gap-3 p-3"
|
|
266
|
+
>
|
|
267
|
+
{c.image ? (
|
|
268
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
269
|
+
<img
|
|
270
|
+
src={c.image}
|
|
271
|
+
alt={c.name}
|
|
272
|
+
className="h-10 w-10 shrink-0 rounded border object-cover"
|
|
273
|
+
/>
|
|
274
|
+
) : (
|
|
275
|
+
<div className="bg-muted h-10 w-10 shrink-0 rounded border" />
|
|
276
|
+
)}
|
|
277
|
+
<span className="flex-1 text-sm">{c.name}</span>
|
|
278
|
+
<span className="text-muted-foreground text-sm">x{c.quantity}</span>
|
|
279
|
+
</li>
|
|
280
|
+
))}
|
|
281
|
+
</ul>
|
|
282
|
+
</div>
|
|
283
|
+
) : null}
|
|
284
|
+
|
|
254
285
|
{product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
|
|
255
286
|
<VariantSelector
|
|
256
287
|
product={product}
|
|
@@ -1,105 +1,114 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Link } from '@/core/lib/navigation';
|
|
4
|
-
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
|
-
import type { ProductRecommendation } from 'brainerce';
|
|
6
|
-
import { PriceDisplay } from '@/ui/product/price-display';
|
|
7
|
-
import { cn } from '@/core/lib/utils';
|
|
8
|
-
|
|
9
|
-
interface RecommendationCardProps {
|
|
10
|
-
item: ProductRecommendation;
|
|
11
|
-
className?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function RecommendationCard({ item, className }: RecommendationCardProps) {
|
|
15
|
-
const firstImage = item.images?.[0];
|
|
16
|
-
const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
|
|
17
|
-
const slug = item.slug || item.id;
|
|
18
|
-
// ⛔ NOT region-converted, and it cannot be. `ProductRecommendation` is a
|
|
19
|
-
// trimmed shape that carries `basePrice` / `salePrice` only: the backend
|
|
20
|
-
// attaches no displayPrice/displayCurrency to it, so there is no converted
|
|
21
|
-
// amount to render and converting here would mean inventing an FX rate.
|
|
22
|
-
// These tiles stay in the store currency on a multi-region store. Do not
|
|
23
|
-
// "fix" it with a client-side conversion.
|
|
24
|
-
const basePrice = parseFloat(item.basePrice);
|
|
25
|
-
const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
|
|
26
|
-
const isOnSale = salePrice != null && salePrice < basePrice;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Link } from '@/core/lib/navigation';
|
|
4
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
|
+
import type { ProductRecommendation } from 'brainerce';
|
|
6
|
+
import { PriceDisplay } from '@/ui/product/price-display';
|
|
7
|
+
import { cn } from '@/core/lib/utils';
|
|
8
|
+
|
|
9
|
+
interface RecommendationCardProps {
|
|
10
|
+
item: ProductRecommendation;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function RecommendationCard({ item, className }: RecommendationCardProps) {
|
|
15
|
+
const firstImage = item.images?.[0];
|
|
16
|
+
const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
|
|
17
|
+
const slug = item.slug || item.id;
|
|
18
|
+
// ⛔ NOT region-converted, and it cannot be. `ProductRecommendation` is a
|
|
19
|
+
// trimmed shape that carries `basePrice` / `salePrice` only: the backend
|
|
20
|
+
// attaches no displayPrice/displayCurrency to it, so there is no converted
|
|
21
|
+
// amount to render and converting here would mean inventing an FX rate.
|
|
22
|
+
// These tiles stay in the store currency on a multi-region store. Do not
|
|
23
|
+
// "fix" it with a client-side conversion.
|
|
24
|
+
const basePrice = parseFloat(item.basePrice);
|
|
25
|
+
const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
|
|
26
|
+
const isOnSale = salePrice != null && salePrice < basePrice;
|
|
27
|
+
// ⛔ No price on a KIT tile. The recommendations endpoint does not resolve
|
|
28
|
+
// kits, so `item.basePrice` here is the kit's stored PLACEHOLDER — right only
|
|
29
|
+
// for FIXED pricing and wrong for every SUM / SUM_MINUS_PERCENT kit. Showing
|
|
30
|
+
// it would quote a price the product page then contradicts. The tile still
|
|
31
|
+
// links through, and the product page reads the resolved price from the API.
|
|
32
|
+
// Do NOT recompute a kit price on the client.
|
|
33
|
+
const priceIsUnresolved = item.type === 'KIT';
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Link href={`/products/${slug}`} className={cn(className)}>
|
|
37
|
+
{/* `relative` + `aspect-square` are layout-critical for next/image fill */}
|
|
38
|
+
<span className="relative block aspect-square">
|
|
39
|
+
{imageUrl ? (
|
|
40
|
+
<Image
|
|
41
|
+
src={imageUrl}
|
|
42
|
+
alt={item.name}
|
|
43
|
+
fill
|
|
44
|
+
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
|
45
|
+
/>
|
|
46
|
+
) : null}
|
|
47
|
+
</span>
|
|
48
|
+
<span className="block">
|
|
49
|
+
<span className="block">{item.name}</span>
|
|
50
|
+
{priceIsUnresolved ? null : (
|
|
51
|
+
<PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
|
|
52
|
+
)}
|
|
53
|
+
</span>
|
|
54
|
+
</Link>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface RecommendationSectionProps {
|
|
59
|
+
title: string;
|
|
60
|
+
items: ProductRecommendation[];
|
|
61
|
+
className?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* DESIGN ME — recommendation strip on the product page (upsells / related);
|
|
66
|
+
* items come from useProductPage().recommendations, title is passed in.
|
|
67
|
+
*
|
|
68
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
69
|
+
*/
|
|
70
|
+
export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
|
|
71
|
+
if (items.length === 0) return null;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<section className={cn(className)}>
|
|
75
|
+
<h2>{title}</h2>
|
|
76
|
+
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
77
|
+
{items.map((item) => (
|
|
78
|
+
<RecommendationCard key={item.id} item={item} />
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
</section>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface CartRecommendationSectionProps {
|
|
86
|
+
title: string;
|
|
87
|
+
items: ProductRecommendation[];
|
|
88
|
+
className?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* DESIGN ME — cross-sell strip on the cart page ("you might also need");
|
|
93
|
+
* items come from useCartPage().cartRecs.
|
|
94
|
+
*
|
|
95
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
96
|
+
*/
|
|
97
|
+
export function CartRecommendationSection({
|
|
98
|
+
title,
|
|
99
|
+
items,
|
|
100
|
+
className,
|
|
101
|
+
}: CartRecommendationSectionProps) {
|
|
102
|
+
if (items.length === 0) return null;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<section className={cn(className)}>
|
|
106
|
+
<h2>{title}</h2>
|
|
107
|
+
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
108
|
+
{items.map((item) => (
|
|
109
|
+
<RecommendationCard key={item.id} item={item} />
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
</section>
|
|
113
|
+
);
|
|
114
|
+
}
|