create-brainerce-store 1.51.0 → 1.52.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 +72 -0
- package/dist/index.js +9 -68
- package/messages/en.json +32 -0
- package/messages/he.json +32 -0
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +9 -0
- package/templates/nextjs/base/AI-GUIDE.md +52 -3
- package/templates/nextjs/base/CLAUDE.md +10 -0
- package/templates/nextjs/base/package.json.ejs +52 -39
- package/templates/nextjs/base/src/app/globals.css +36 -30
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -10
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +17 -11
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +2 -8
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -39
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +3 -1
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +8 -23
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +18 -27
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +10 -21
- package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +2 -3
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -103
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +4 -6
- package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -125
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -103
- package/templates/nextjs/base/src/ui/layout/header-account.tsx +2 -17
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -70
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -164
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -182
- package/templates/nextjs/base/src/ui/product/customization-fields.tsx +456 -444
- package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -22
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +15 -27
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -175
- package/templates/nextjs/base/src/ui/product/price-display.tsx +3 -2
- package/templates/nextjs/base/src/ui/product/product-card.tsx +238 -271
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -470
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +96 -79
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -107
- package/templates/nextjs/base/src/ui/product/review-form.tsx +44 -43
- package/templates/nextjs/{designs/passepartout → base/src}/ui/product/reviews-section.tsx.ejs +108 -96
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -63
- package/templates/nextjs/base/tailwind.config.ts +69 -42
- package/templates/nextjs/designs/{passepartout → atelier}/app-overlay/layout.tsx.ejs +23 -25
- package/templates/nextjs/designs/atelier/globals.css +396 -0
- package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
- package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-bundle-offer.tsx +110 -111
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-upgrade-banner.tsx +123 -141
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/free-shipping-bar.tsx +77 -62
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/reservation-countdown.tsx +89 -103
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/discount-badge.tsx +17 -22
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/frequently-bought-together.tsx +196 -205
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/review-form.tsx +267 -294
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/shared/fly-to-cart.ts +86 -85
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
- package/templates/nextjs/themes/luxury/globals.css +405 -399
- package/templates/nextjs/themes/minimal/globals.css +36 -30
- package/templates/nextjs/themes/playful/globals.css +406 -400
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +2 -2
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +2 -0
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/home-client.tsx +1 -1
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +1 -1
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +1 -1
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +2 -0
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +2 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +4 -0
- package/templates/nextjs/ui-canvas/product/review-form.tsx +29 -32
- package/templates/nextjs/ui-canvas/product/{reviews-section.tsx → reviews-section.tsx.ejs} +26 -8
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +2 -0
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +2 -0
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +2 -0
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx +0 -85
- package/templates/nextjs/designs/passepartout/globals.css +0 -412
- package/templates/nextjs/designs/passepartout/messages-patch/en.json +0 -87
- package/templates/nextjs/designs/passepartout/messages-patch/he.json +0 -87
- package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +0 -39
- package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +0 -149
- package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +0 -401
- package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +0 -139
- package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +0 -45
- package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +0 -103
- package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +0 -159
- package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +0 -155
- package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +0 -60
- package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +0 -34
- package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +0 -111
- package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +0 -265
- package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +0 -667
- package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +0 -35
- package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +0 -427
- package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +0 -113
- package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +0 -52
- package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +0 -307
- package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +0 -33
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { formatPrice } from 'brainerce';
|
|
4
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
5
|
+
import { cn } from '@/core/lib/utils';
|
|
6
|
+
|
|
7
|
+
interface PriceDisplayProps {
|
|
8
|
+
price: string | number;
|
|
9
|
+
salePrice?: string | number | null;
|
|
10
|
+
currency?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
size?: 'sm' | 'md' | 'lg';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CURRENT: Record<NonNullable<PriceDisplayProps['size']>, string> = {
|
|
16
|
+
sm: 'text-sm font-semibold',
|
|
17
|
+
md: 'text-base font-semibold',
|
|
18
|
+
lg: 'text-2xl font-semibold',
|
|
19
|
+
};
|
|
20
|
+
const STRUCK: Record<NonNullable<PriceDisplayProps['size']>, string> = {
|
|
21
|
+
sm: 'text-xs',
|
|
22
|
+
md: 'text-sm',
|
|
23
|
+
lg: 'text-base',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Price renderer used by cards, product page and recommendations; computes
|
|
28
|
+
* sale state + discount % from props (currency via useCurrency()).
|
|
29
|
+
* Never hardcode currency symbols — formatPrice handles them.
|
|
30
|
+
*/
|
|
31
|
+
export function PriceDisplay({
|
|
32
|
+
price,
|
|
33
|
+
salePrice,
|
|
34
|
+
currency,
|
|
35
|
+
className,
|
|
36
|
+
size = 'md',
|
|
37
|
+
}: PriceDisplayProps) {
|
|
38
|
+
const currencyCode = useCurrency(currency);
|
|
39
|
+
|
|
40
|
+
const basePrice = typeof price === 'string' ? parseFloat(price) : price;
|
|
41
|
+
const sale =
|
|
42
|
+
salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
|
|
43
|
+
const isOnSale = sale !== null && sale < basePrice;
|
|
44
|
+
|
|
45
|
+
const discountPercent =
|
|
46
|
+
isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<span data-size={size} className={cn('inline-flex flex-wrap items-baseline gap-2', className)}>
|
|
50
|
+
{isOnSale ? (
|
|
51
|
+
<>
|
|
52
|
+
<span className={cn(CURRENT[size], 'text-foreground')}>
|
|
53
|
+
{formatPrice(sale!, { currency: currencyCode }) as string}
|
|
54
|
+
</span>
|
|
55
|
+
<s className={cn(STRUCK[size], 'text-muted-foreground')}>
|
|
56
|
+
{formatPrice(basePrice, { currency: currencyCode }) as string}
|
|
57
|
+
</s>
|
|
58
|
+
{discountPercent > 0 && (
|
|
59
|
+
<span className="badge-destructive self-center">-{discountPercent}%</span>
|
|
60
|
+
)}
|
|
61
|
+
</>
|
|
62
|
+
) : (
|
|
63
|
+
<span className={cn(CURRENT[size], 'text-foreground')}>
|
|
64
|
+
{formatPrice(basePrice, { currency: currencyCode }) as string}
|
|
65
|
+
</span>
|
|
66
|
+
)}
|
|
67
|
+
</span>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
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, getVariantPrice, formatPrice } from 'brainerce';
|
|
8
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
9
|
+
import { PriceDisplay } from '@/ui/product/price-display';
|
|
10
|
+
import { StockBadge } from '@/ui/product/stock-badge';
|
|
11
|
+
import { DiscountBadge } from '@/ui/product/discount-badge';
|
|
12
|
+
import { useCart } from '@/core/providers/store-provider';
|
|
13
|
+
import { flyToCart } from '@/ui/shared/fly-to-cart';
|
|
14
|
+
import { openCartDrawer } from '@/ui/cart/cart-drawer';
|
|
15
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
16
|
+
import { cn } from '@/core/lib/utils';
|
|
17
|
+
import { IconBag, IconCheck, IconDownload } from '@/ui/shared/icons';
|
|
18
|
+
|
|
19
|
+
interface ProductCardProps {
|
|
20
|
+
product: Product;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pick the price + currency to render for a given product (PRD §23 FX overlay).
|
|
26
|
+
*
|
|
27
|
+
* When the storefront passed `regionId` to `getProducts({ regionId })` AND the
|
|
28
|
+
* region currency differs from the store currency, the backend attaches
|
|
29
|
+
* additive `displayPrice` / `displayCurrency` fields. Otherwise we fall back
|
|
30
|
+
* to the canonical store-currency `basePrice` / `salePrice`. Either way the
|
|
31
|
+
* cart still charges in the store currency — this only affects display.
|
|
32
|
+
*/
|
|
33
|
+
function pickDisplayPrice(
|
|
34
|
+
product: Product,
|
|
35
|
+
fallbackCurrency: string
|
|
36
|
+
): { price: number | undefined; salePrice: number | null; currency: string } {
|
|
37
|
+
if (product.displayPrice != null && product.displayCurrency) {
|
|
38
|
+
const sale =
|
|
39
|
+
product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
|
|
40
|
+
return {
|
|
41
|
+
// `displayPrice` is the base price converted to the region currency —
|
|
42
|
+
// it goes into the PriceDisplay `price` (base) slot, NOT the sale slot.
|
|
43
|
+
price: parseFloat(product.displayPrice),
|
|
44
|
+
salePrice: sale != null && !Number.isNaN(sale) ? sale : null,
|
|
45
|
+
currency: product.displayCurrency,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// Same-currency fallback. `getProductPriceInfo.price` is the EFFECTIVE
|
|
49
|
+
// charged amount (= salePrice when on sale, base otherwise); `originalPrice`
|
|
50
|
+
// is the base. Map them to PriceDisplay's (price = base, salePrice = sale).
|
|
51
|
+
const { price: effective, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
52
|
+
return {
|
|
53
|
+
price: originalPrice,
|
|
54
|
+
salePrice: isOnSale ? effective : null,
|
|
55
|
+
currency: fallbackCurrency,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function VariantPriceRange({ product }: { product: Product }) {
|
|
60
|
+
const currency = useCurrency();
|
|
61
|
+
|
|
62
|
+
let min: number;
|
|
63
|
+
let max: number;
|
|
64
|
+
if (product.priceMin && product.priceMax) {
|
|
65
|
+
min = parseFloat(product.priceMin);
|
|
66
|
+
max = parseFloat(product.priceMax);
|
|
67
|
+
} else {
|
|
68
|
+
const variants = product.variants ?? [];
|
|
69
|
+
if (variants.length === 0) return null;
|
|
70
|
+
const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
|
|
71
|
+
min = Math.min(...prices);
|
|
72
|
+
max = Math.max(...prices);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (isNaN(min) || isNaN(max)) return null;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<span className="text-base font-semibold text-foreground">
|
|
79
|
+
{min === max
|
|
80
|
+
? (formatPrice(min, { currency }) as string)
|
|
81
|
+
: `${formatPrice(min, { currency })} – ${formatPrice(max, { currency })}`}
|
|
82
|
+
</span>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function ProductCard({ product, className }: ProductCardProps) {
|
|
87
|
+
const t = useTranslations('common');
|
|
88
|
+
const tp = useTranslations('productDetail');
|
|
89
|
+
const tProd = useTranslations('products');
|
|
90
|
+
const router = useRouter();
|
|
91
|
+
const { refreshCart } = useCart();
|
|
92
|
+
const fallbackCurrency = useCurrency();
|
|
93
|
+
// FX overlay (PRD §23): prefer the region-converted display values when the
|
|
94
|
+
// storefront passed regionId to getProducts. Otherwise fall back to the
|
|
95
|
+
// canonical store-currency basePrice / salePrice.
|
|
96
|
+
const display = pickDisplayPrice(product, fallbackCurrency);
|
|
97
|
+
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
98
|
+
const mainImage = product.images?.[0];
|
|
99
|
+
const hoverImage = product.images?.[1];
|
|
100
|
+
const imageUrl = mainImage?.url || null;
|
|
101
|
+
const slug = product.slug || product.id;
|
|
102
|
+
const isVariable = product.type === 'VARIABLE';
|
|
103
|
+
|
|
104
|
+
const [adding, setAdding] = useState(false);
|
|
105
|
+
const [added, setAdded] = useState(false);
|
|
106
|
+
|
|
107
|
+
const canPurchase = product.inventory?.canPurchase !== false;
|
|
108
|
+
|
|
109
|
+
const imageWellRef = useRef<HTMLDivElement>(null);
|
|
110
|
+
|
|
111
|
+
async function handleAddToCart(e: React.MouseEvent) {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
e.stopPropagation();
|
|
114
|
+
|
|
115
|
+
if (isVariable) {
|
|
116
|
+
router.push(`/products/${slug}`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (adding || !canPurchase) return;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
flyToCart(imageWellRef.current);
|
|
124
|
+
setAdding(true);
|
|
125
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
126
|
+
const client = getClient();
|
|
127
|
+
await client.smartAddToCart({ productId: product.id, quantity: 1 });
|
|
128
|
+
await refreshCart();
|
|
129
|
+
openCartDrawer();
|
|
130
|
+
setAdded(true);
|
|
131
|
+
setTimeout(() => setAdded(false), 2000);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error('Failed to add to cart:', err);
|
|
134
|
+
} finally {
|
|
135
|
+
setAdding(false);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<article
|
|
141
|
+
className={cn(
|
|
142
|
+
'group card card-hover relative flex flex-col overflow-hidden',
|
|
143
|
+
className
|
|
144
|
+
)}
|
|
145
|
+
>
|
|
146
|
+
<figure className="relative">
|
|
147
|
+
<Link href={`/products/${slug}`} className="block" tabIndex={-1} aria-hidden="true">
|
|
148
|
+
{/* `relative` + `aspect-square` are layout-critical for next/image fill */}
|
|
149
|
+
<div ref={imageWellRef} className="relative aspect-square overflow-hidden bg-secondary">
|
|
150
|
+
{imageUrl ? (
|
|
151
|
+
<>
|
|
152
|
+
<Image
|
|
153
|
+
src={imageUrl}
|
|
154
|
+
alt={mainImage?.alt || product.name}
|
|
155
|
+
fill
|
|
156
|
+
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
|
157
|
+
className={cn(
|
|
158
|
+
'img-zoom object-cover',
|
|
159
|
+
hoverImage && 'transition-opacity duration-500 group-hover:opacity-0'
|
|
160
|
+
)}
|
|
161
|
+
/>
|
|
162
|
+
{hoverImage && (
|
|
163
|
+
<Image
|
|
164
|
+
src={hoverImage.url}
|
|
165
|
+
alt={hoverImage.alt || product.name}
|
|
166
|
+
fill
|
|
167
|
+
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
|
168
|
+
className="object-cover opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
171
|
+
</>
|
|
172
|
+
) : (
|
|
173
|
+
<span className="sr-only">{product.name}</span>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
</Link>
|
|
177
|
+
<figcaption className="sr-only">{product.name}</figcaption>
|
|
178
|
+
|
|
179
|
+
{/* Badges overlay */}
|
|
180
|
+
<div className="absolute start-3 top-3 flex flex-wrap gap-1.5">
|
|
181
|
+
{isOnSale && <span className="badge-primary">{t('sale')}</span>}
|
|
182
|
+
<DiscountBadge discount={product.discount} />
|
|
183
|
+
{product.isDownloadable && (
|
|
184
|
+
<span className="badge-soft">
|
|
185
|
+
<IconDownload size={16} />
|
|
186
|
+
{tp('digitalProduct')}
|
|
187
|
+
</span>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
</figure>
|
|
191
|
+
|
|
192
|
+
<div className="flex flex-1 flex-col gap-1.5 p-4">
|
|
193
|
+
{/* Categories */}
|
|
194
|
+
{product.categories && product.categories.length > 0 && (
|
|
195
|
+
<ul className="flex flex-wrap gap-1 text-xs text-muted-foreground">
|
|
196
|
+
{product.categories.slice(0, 2).map((cat, idx) => (
|
|
197
|
+
<li key={cat.id}>
|
|
198
|
+
{idx > 0 && <span aria-hidden="true">· </span>}
|
|
199
|
+
{cat.name}
|
|
200
|
+
</li>
|
|
201
|
+
))}
|
|
202
|
+
</ul>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{/* Name — the card-covering link */}
|
|
206
|
+
<h3 className="font-sans text-[15px] font-semibold leading-snug text-foreground">
|
|
207
|
+
<Link
|
|
208
|
+
href={`/products/${slug}`}
|
|
209
|
+
className="after:absolute after:inset-0 after:content-[''] hover:text-primary"
|
|
210
|
+
>
|
|
211
|
+
{product.name}
|
|
212
|
+
</Link>
|
|
213
|
+
</h3>
|
|
214
|
+
|
|
215
|
+
{/* Price */}
|
|
216
|
+
<div>
|
|
217
|
+
{isVariable ? (
|
|
218
|
+
<VariantPriceRange product={product} />
|
|
219
|
+
) : (
|
|
220
|
+
<PriceDisplay
|
|
221
|
+
price={display.price ?? originalPrice}
|
|
222
|
+
salePrice={display.salePrice ?? (isOnSale ? price : undefined)}
|
|
223
|
+
currency={display.currency}
|
|
224
|
+
size="md"
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<StockBadge inventory={product.inventory} />
|
|
230
|
+
|
|
231
|
+
{/* Add to cart (variable products route to the product page for option selection) */}
|
|
232
|
+
{(isVariable || canPurchase) && (
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
onClick={handleAddToCart}
|
|
236
|
+
disabled={adding}
|
|
237
|
+
className={cn(
|
|
238
|
+
'btn-outline btn-sm relative z-10 mt-2 w-full',
|
|
239
|
+
added && 'border-primary bg-primary text-primary-foreground hover:bg-primary'
|
|
240
|
+
)}
|
|
241
|
+
>
|
|
242
|
+
{added ? (
|
|
243
|
+
<>
|
|
244
|
+
<IconCheck size={16} />
|
|
245
|
+
{tp('addedToCart')}
|
|
246
|
+
</>
|
|
247
|
+
) : isVariable ? (
|
|
248
|
+
tProd('selectOptions')
|
|
249
|
+
) : (
|
|
250
|
+
<>
|
|
251
|
+
<IconBag size={16} />
|
|
252
|
+
{adding ? tp('addingToCart') : tp('addToCart')}
|
|
253
|
+
</>
|
|
254
|
+
)}
|
|
255
|
+
</button>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
</article>
|
|
259
|
+
);
|
|
260
|
+
}
|