create-brainerce-store 1.50.5 → 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 +146 -100
- package/messages/en.json +36 -1
- package/messages/he.json +36 -1
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +60 -0
- package/templates/nextjs/base/.env.local.ejs +5 -0
- package/templates/nextjs/base/.eslintrc.json +57 -3
- package/templates/nextjs/base/AGENTS.md +41 -0
- package/templates/nextjs/base/AI-GUIDE.md +143 -0
- package/templates/nextjs/base/CLAUDE.md +52 -0
- package/templates/nextjs/base/TRANSLATIONS.md +1 -1
- package/templates/nextjs/base/next.config.ts +2 -2
- package/templates/nextjs/base/package.json.ejs +52 -39
- package/templates/nextjs/base/src/app/account/page.tsx +5 -5
- package/templates/nextjs/base/src/app/api/auth/logout/route.ts +1 -1
- package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +2 -2
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +1 -1
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +4 -4
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +4 -4
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +2 -2
- package/templates/nextjs/base/src/app/cart/page.tsx +2 -173
- package/templates/nextjs/base/src/app/checkout/page.tsx +12 -11
- package/templates/nextjs/base/src/app/contact/page.tsx +3 -3
- package/templates/nextjs/base/src/app/faq/page.tsx.ejs +2 -2
- package/templates/nextjs/base/src/app/forgot-password/page.tsx +4 -4
- package/templates/nextjs/base/src/app/globals.css +36 -30
- package/templates/nextjs/base/src/app/layout.tsx.ejs +12 -12
- package/templates/nextjs/base/src/app/login/page.tsx +4 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +19 -12
- package/templates/nextjs/base/src/app/page.tsx +4 -4
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +3 -3
- package/templates/nextjs/base/src/app/payment-complete/page.tsx +1 -1
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +6 -6
- package/templates/nextjs/base/src/app/products/page.tsx +4 -623
- package/templates/nextjs/base/src/app/register/page.tsx +4 -4
- package/templates/nextjs/base/src/app/reset-password/page.tsx +5 -5
- package/templates/nextjs/base/src/app/sitemap.ts +1 -1
- package/templates/nextjs/base/src/app/verify-email/page.tsx +5 -5
- package/templates/nextjs/base/src/components/account/address-book.tsx +3 -3
- package/templates/nextjs/base/src/components/account/order-customizations.tsx +3 -3
- package/templates/nextjs/base/src/components/account/order-history.tsx +13 -5
- package/templates/nextjs/base/src/components/account/order-payment-block.tsx +2 -2
- package/templates/nextjs/base/src/components/account/order-shipping-block.tsx +2 -2
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +2 -2
- package/templates/nextjs/base/src/components/account/profile-section.tsx +3 -3
- package/templates/nextjs/base/src/components/auth/login-form.tsx +4 -4
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +4 -4
- package/templates/nextjs/base/src/components/auth/register-form.tsx +4 -4
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +24 -3
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +4 -4
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +20 -16
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +3 -3
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +3 -3
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +3 -3
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +2 -2
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +3 -3
- 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/core/hooks/use-cart-page.ts +58 -0
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -0
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -0
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +278 -0
- package/templates/nextjs/base/src/{hooks → core/hooks}/use-search.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/brainerce.ts.ejs +1 -1
- package/templates/nextjs/base/src/core/lib/product-options.ts +121 -0
- package/templates/nextjs/base/src/{lib → core/lib}/resolve-currency.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/safe-redirect.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/sanitize.ts.ejs +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/use-currency.ts +1 -1
- package/templates/nextjs/base/src/{providers → core/providers}/store-provider.tsx.ejs +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/cart-bundle-offer.tsx +22 -16
- package/templates/nextjs/base/src/{components → ui}/cart/cart-item.tsx +8 -14
- package/templates/nextjs/base/src/{components → ui}/cart/cart-nudges.tsx +28 -39
- package/templates/nextjs/base/src/{components → ui}/cart/cart-summary.tsx +7 -5
- package/templates/nextjs/base/src/{components → ui}/cart/cart-upgrade-banner.tsx +13 -28
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -0
- package/templates/nextjs/base/src/{components → ui}/cart/coupon-input.tsx +14 -25
- package/templates/nextjs/base/src/{components → ui}/cart/free-shipping-bar.tsx +6 -7
- package/templates/nextjs/base/src/{components → ui}/cart/reservation-countdown.tsx +95 -103
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -0
- package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -0
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -0
- package/templates/nextjs/base/src/{components/content → ui/layout}/announcement-bar.tsx.ejs +116 -125
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -0
- package/templates/nextjs/base/src/{components/content → ui/layout}/header-account.tsx +5 -20
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -0
- package/templates/nextjs/base/src/{components/content → ui/layout}/rich-text-block.tsx.ejs +1 -1
- package/templates/nextjs/base/src/{components/content → ui/layout}/site-footer.tsx.ejs +144 -164
- package/templates/nextjs/base/src/{components/content → ui/layout}/site-header.tsx.ejs +152 -182
- package/templates/nextjs/base/src/{components/products → ui/product}/customization-fields.tsx +457 -478
- package/templates/nextjs/base/src/{components/products → ui/product}/discount-badge.tsx +18 -22
- package/templates/nextjs/base/src/{components/products → ui/product}/frequently-bought-together.tsx +21 -33
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -0
- package/templates/nextjs/base/src/{components/shared → ui/product}/price-display.tsx +5 -4
- package/templates/nextjs/base/src/{components/products → ui/product}/product-card.tsx +238 -271
- package/templates/nextjs/base/src/{app/products/[slug] → ui/product}/product-client-section.tsx +455 -631
- package/templates/nextjs/base/src/{components/products → ui/product}/product-grid.tsx +3 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -0
- package/templates/nextjs/base/src/{components/products → ui/product}/recommendation-section.tsx +102 -107
- package/templates/nextjs/base/src/{components/reviews → ui/product}/review-form.tsx +46 -45
- package/templates/nextjs/base/src/{components/reviews/reviews-section.tsx → ui/product/reviews-section.tsx.ejs} +38 -15
- package/templates/nextjs/base/src/{components/products → ui/product}/stock-badge.tsx +61 -63
- package/templates/nextjs/base/src/{components/products → ui/product}/variant-selector.tsx +3 -3
- package/templates/nextjs/base/src/{components → ui}/shared/cdn-image.tsx +1 -1
- package/templates/nextjs/base/src/{components → ui}/shared/loading-spinner.tsx +2 -2
- package/templates/nextjs/base/tailwind.config.ts +69 -30
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +251 -0
- 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/atelier/ui/cart/cart-bundle-offer.tsx +110 -0
- 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/atelier/ui/cart/cart-upgrade-banner.tsx +123 -0
- 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/atelier/ui/cart/free-shipping-bar.tsx +77 -0
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -0
- 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/atelier/ui/product/discount-badge.tsx +17 -0
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -0
- 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/atelier/ui/product/review-form.tsx +267 -0
- 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/atelier/ui/shared/fly-to-cart.ts +86 -0
- 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 +89 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -0
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +27 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -0
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -0
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -0
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -0
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -0
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -0
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -0
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -0
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -0
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -0
- package/templates/nextjs/{base/src/components → ui-canvas}/layout/language-switcher.tsx.ejs +76 -70
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -0
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -0
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -0
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +197 -0
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -0
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -0
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -0
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -0
- package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -0
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -0
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -0
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -0
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -0
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -0
- package/templates/nextjs/base/src/app/home-client.tsx +0 -98
- package/templates/nextjs/base/src/components/content/faq-section.tsx.ejs +0 -103
- package/templates/nextjs/base/src/components/products/modifier-group-selector.tsx +0 -238
- /package/templates/nextjs/base/src/{lib → core/lib}/auth.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/csrf.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/image-hosts.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/navigation.tsx.ejs +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/nonce.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/sanitize-html.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/seo.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/store-info.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/translations.ts.ejs +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/utils.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/validation.ts +0 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
|
+
import type { Product, ProductRecommendation } from 'brainerce';
|
|
6
|
+
import { formatPrice } from 'brainerce';
|
|
7
|
+
import { useCart, useStoreInfo } from '@/core/providers/store-provider';
|
|
8
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
9
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
10
|
+
import { cn } from '@/core/lib/utils';
|
|
11
|
+
|
|
12
|
+
interface FrequentlyBoughtTogetherProps {
|
|
13
|
+
items: ProductRecommendation[];
|
|
14
|
+
currentProduct: Product;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
|
|
19
|
+
const sale = item.salePrice ? parseFloat(item.salePrice) : null;
|
|
20
|
+
const base = parseFloat(item.basePrice);
|
|
21
|
+
return sale != null && sale < base ? sale : base;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ProductThumb({
|
|
25
|
+
name,
|
|
26
|
+
imageUrl,
|
|
27
|
+
price,
|
|
28
|
+
currency,
|
|
29
|
+
checked,
|
|
30
|
+
onToggle,
|
|
31
|
+
disabled,
|
|
32
|
+
}: {
|
|
33
|
+
name: string;
|
|
34
|
+
imageUrl: string | null;
|
|
35
|
+
price: number;
|
|
36
|
+
currency: string;
|
|
37
|
+
checked: boolean;
|
|
38
|
+
onToggle?: () => void;
|
|
39
|
+
disabled?: boolean;
|
|
40
|
+
}) {
|
|
41
|
+
return (
|
|
42
|
+
<label className="inline-flex items-center gap-2">
|
|
43
|
+
{onToggle && <input type="checkbox" checked={checked} onChange={onToggle} disabled={disabled} />}
|
|
44
|
+
{/* `relative` + fixed box are layout-critical for next/image fill */}
|
|
45
|
+
<span className="relative block h-20 w-20">
|
|
46
|
+
{imageUrl ? <Image src={imageUrl} alt={name} fill sizes="80px" /> : null}
|
|
47
|
+
</span>
|
|
48
|
+
<span>{name}</span>
|
|
49
|
+
<span>{formatPrice(price, { currency }) as string}</span>
|
|
50
|
+
</label>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* DESIGN ME — "frequently bought together" bundle on the product page:
|
|
56
|
+
* current product + selectable cross-sells + combined total + add-all button;
|
|
57
|
+
* items come from useProductPage().recommendations.crossSells, the feature
|
|
58
|
+
* gate from useStoreInfo().upsell.
|
|
59
|
+
*
|
|
60
|
+
* 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.
|
|
61
|
+
*/
|
|
62
|
+
export function FrequentlyBoughtTogether({
|
|
63
|
+
items,
|
|
64
|
+
currentProduct,
|
|
65
|
+
className,
|
|
66
|
+
}: FrequentlyBoughtTogetherProps) {
|
|
67
|
+
// Hooks must be called unconditionally and in the same order on every
|
|
68
|
+
// render — keep all of them above any early `return null` branch.
|
|
69
|
+
const { storeInfo } = useStoreInfo();
|
|
70
|
+
const { refreshCart } = useCart();
|
|
71
|
+
const t = useTranslations('productDetail');
|
|
72
|
+
const currency = useCurrency();
|
|
73
|
+
|
|
74
|
+
// Only show up to 3 cross-sells
|
|
75
|
+
const crossSells = items.slice(0, 3);
|
|
76
|
+
|
|
77
|
+
const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
|
|
78
|
+
const [adding, setAdding] = useState(false);
|
|
79
|
+
|
|
80
|
+
if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
|
|
81
|
+
if (crossSells.length === 0) return null;
|
|
82
|
+
|
|
83
|
+
const currentPrice = getEffectivePrice(currentProduct);
|
|
84
|
+
const currentImage = currentProduct.images?.[0];
|
|
85
|
+
const currentImageUrl = currentImage
|
|
86
|
+
? typeof currentImage === 'string'
|
|
87
|
+
? currentImage
|
|
88
|
+
: currentImage.url
|
|
89
|
+
: null;
|
|
90
|
+
|
|
91
|
+
const totalPrice = crossSells
|
|
92
|
+
.filter((item) => selected.has(item.id))
|
|
93
|
+
.reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
|
|
94
|
+
|
|
95
|
+
const toggleItem = (id: string) => {
|
|
96
|
+
setSelected((prev) => {
|
|
97
|
+
const next = new Set(prev);
|
|
98
|
+
if (next.has(id)) {
|
|
99
|
+
next.delete(id);
|
|
100
|
+
} else {
|
|
101
|
+
next.add(id);
|
|
102
|
+
}
|
|
103
|
+
return next;
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
async function handleAddAll() {
|
|
108
|
+
if (adding || selected.size === 0) return;
|
|
109
|
+
try {
|
|
110
|
+
setAdding(true);
|
|
111
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
112
|
+
const client = getClient();
|
|
113
|
+
const selectedItems = crossSells.filter((item) => selected.has(item.id));
|
|
114
|
+
for (const item of selectedItems) {
|
|
115
|
+
await client.smartAddToCart({ productId: item.id, quantity: 1 });
|
|
116
|
+
}
|
|
117
|
+
await refreshCart();
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error('Failed to add items to cart:', err);
|
|
120
|
+
} finally {
|
|
121
|
+
setAdding(false);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<section className={cn(className)}>
|
|
127
|
+
<h2>{t('frequentlyBoughtTogether')}</h2>
|
|
128
|
+
|
|
129
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
130
|
+
{/* Current product (always included, no checkbox) */}
|
|
131
|
+
<ProductThumb
|
|
132
|
+
name={currentProduct.name}
|
|
133
|
+
imageUrl={currentImageUrl}
|
|
134
|
+
price={currentPrice}
|
|
135
|
+
currency={currency}
|
|
136
|
+
checked={true}
|
|
137
|
+
disabled
|
|
138
|
+
/>
|
|
139
|
+
|
|
140
|
+
{crossSells.map((item) => {
|
|
141
|
+
const img = item.images?.[0];
|
|
142
|
+
const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
|
|
143
|
+
return (
|
|
144
|
+
<div key={item.id} className="flex items-center gap-3">
|
|
145
|
+
<span aria-hidden="true">+</span>
|
|
146
|
+
<ProductThumb
|
|
147
|
+
name={item.name}
|
|
148
|
+
imageUrl={imgUrl}
|
|
149
|
+
price={getEffectivePrice(item)}
|
|
150
|
+
currency={currency}
|
|
151
|
+
checked={selected.has(item.id)}
|
|
152
|
+
onToggle={() => toggleItem(item.id)}
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{/* Total + Add button */}
|
|
160
|
+
<div className="flex flex-wrap items-center gap-4">
|
|
161
|
+
<span>{t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}</span>
|
|
162
|
+
<button type="button" onClick={handleAddAll} disabled={adding || selected.size === 0}>
|
|
163
|
+
{adding ? t('addingAll') : t('addSelectedToCart')}
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
</section>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <ModifierGroupSelector> — bare-skeleton renderer for a single modifier
|
|
5
|
+
* group (toppings, sauce, bread type, …) per PRD §8.4.
|
|
6
|
+
*
|
|
7
|
+
* DESIGN ME — modifier group on the product page; the group + selections
|
|
8
|
+
* come from useProductPage() via props. The data contract is what matters:
|
|
9
|
+
*
|
|
10
|
+
* - SINGLE → renders as <input type="radio">, max picks = 1
|
|
11
|
+
* - MULTIPLE → renders as <input type="checkbox">, picks bounded by min..max
|
|
12
|
+
* - effectiveMax === 0 → group is hidden entirely (variant-disable convention)
|
|
13
|
+
* - available === false → modifier is disabled with "Sold out" badge
|
|
14
|
+
* - free counter shows "{used} of {freeQuantity} free" when freeQuantity > 0
|
|
15
|
+
* - defaultModifierIds + isDefault drive first-render checked state
|
|
16
|
+
* - client-side guards mirror min/max (UX); the SERVER is authoritative — wire
|
|
17
|
+
* `MODIFIER_VALIDATION_FAILED` errors back to the user when add-to-cart fails
|
|
18
|
+
*
|
|
19
|
+
* The price-delta side: this component does NOT compute totals. The server
|
|
20
|
+
* runs the free-allocation policy and returns the line's `unitPrice` /
|
|
21
|
+
* `modifiers[]` / `modifiersTotal` snapshot — render those after add-to-cart.
|
|
22
|
+
*
|
|
23
|
+
* 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.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Modifier, ModifierGroup } from 'brainerce';
|
|
27
|
+
|
|
28
|
+
// The pure selection logic (defaults, validation, wire-format adapter) lives
|
|
29
|
+
// in core (zero markup) so that `core/hooks/use-product-page` never imports
|
|
30
|
+
// from `ui/`. Re-exported here for backward compatibility with existing imports.
|
|
31
|
+
export {
|
|
32
|
+
buildInitialSelections,
|
|
33
|
+
toModifierSelections,
|
|
34
|
+
validateSelections,
|
|
35
|
+
} from '@/core/lib/product-options';
|
|
36
|
+
|
|
37
|
+
interface ModifierGroupSelectorProps {
|
|
38
|
+
/**
|
|
39
|
+
* Effective group as returned by `GET /products/:id` (overrides already
|
|
40
|
+
* applied — `min`, `max`, `freeQuantity`, etc. are the values the server
|
|
41
|
+
* will validate against for the active variant).
|
|
42
|
+
*/
|
|
43
|
+
group: ModifierGroup;
|
|
44
|
+
/** Currently-selected modifier IDs in click-order. */
|
|
45
|
+
value: string[];
|
|
46
|
+
onChange: (next: string[]) => void;
|
|
47
|
+
/** Disable all controls (e.g., while add-to-cart is in flight). */
|
|
48
|
+
disabled?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function ModifierGroupSelector({
|
|
52
|
+
group,
|
|
53
|
+
value,
|
|
54
|
+
onChange,
|
|
55
|
+
disabled = false,
|
|
56
|
+
}: ModifierGroupSelectorProps) {
|
|
57
|
+
// PRD §7.2.2: variant-disable convention — group with effectiveMax=0 is hidden.
|
|
58
|
+
if (group.max === 0) return null;
|
|
59
|
+
|
|
60
|
+
const isSingle = group.selectionType === 'SINGLE';
|
|
61
|
+
// Client-side bound — server is authoritative.
|
|
62
|
+
const effectiveMax = isSingle ? 1 : (group.max ?? Infinity);
|
|
63
|
+
|
|
64
|
+
const handleToggle = (modifierId: string, checked: boolean) => {
|
|
65
|
+
if (disabled) return;
|
|
66
|
+
|
|
67
|
+
if (isSingle) {
|
|
68
|
+
// Radio behavior: replace the selection.
|
|
69
|
+
onChange(checked ? [modifierId] : []);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (checked) {
|
|
74
|
+
if (value.length >= effectiveMax) return; // would exceed max — ignore the click
|
|
75
|
+
if (value.includes(modifierId)) return;
|
|
76
|
+
onChange([...value, modifierId]);
|
|
77
|
+
} else {
|
|
78
|
+
onChange(value.filter((id) => id !== modifierId));
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const sortedModifiers = [...group.modifiers].sort((a, b) => a.position - b.position);
|
|
83
|
+
|
|
84
|
+
// Counter: how many of the picked items are within the "free" window.
|
|
85
|
+
const usedFreeSlots = Math.min(value.length, group.freeQuantity);
|
|
86
|
+
const showFreeCounter = group.freeQuantity > 0;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<fieldset>
|
|
90
|
+
<legend>
|
|
91
|
+
<span>{group.name}</span>
|
|
92
|
+
{group.required && <span aria-hidden="true"> *</span>}
|
|
93
|
+
<span> {ruleSummary(group)}</span>
|
|
94
|
+
</legend>
|
|
95
|
+
|
|
96
|
+
{group.description && <p>{group.description}</p>}
|
|
97
|
+
|
|
98
|
+
{showFreeCounter && (
|
|
99
|
+
<p aria-live="polite">
|
|
100
|
+
{usedFreeSlots} of {group.freeQuantity} free
|
|
101
|
+
</p>
|
|
102
|
+
)}
|
|
103
|
+
|
|
104
|
+
<ul>
|
|
105
|
+
{sortedModifiers.map((modifier) => {
|
|
106
|
+
const isChecked = value.includes(modifier.id);
|
|
107
|
+
const isAtCap = !isSingle && !isChecked && value.length >= effectiveMax;
|
|
108
|
+
const isDisabled = disabled || !modifier.available || isAtCap;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<li key={modifier.id}>
|
|
112
|
+
<label className="inline-flex items-center gap-2">
|
|
113
|
+
<input
|
|
114
|
+
type={isSingle ? 'radio' : 'checkbox'}
|
|
115
|
+
name={`modifier-group-${group.id}`}
|
|
116
|
+
value={modifier.id}
|
|
117
|
+
checked={isChecked}
|
|
118
|
+
disabled={isDisabled}
|
|
119
|
+
onChange={(e) => handleToggle(modifier.id, e.target.checked)}
|
|
120
|
+
/>
|
|
121
|
+
<span>{modifier.name}</span>
|
|
122
|
+
<ModifierPriceLabel modifier={modifier} />
|
|
123
|
+
{!modifier.available && <span aria-label="Sold out">Sold out</span>}
|
|
124
|
+
</label>
|
|
125
|
+
{modifier.description && <p>{modifier.description}</p>}
|
|
126
|
+
</li>
|
|
127
|
+
);
|
|
128
|
+
})}
|
|
129
|
+
</ul>
|
|
130
|
+
</fieldset>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Renders the modifier's price contribution next to its name.
|
|
136
|
+
*
|
|
137
|
+
* Negative deltas display with a leading minus sign (downsell modifiers per
|
|
138
|
+
* PRD §6.3.4); zero-delta modifiers render nothing — silence is the right
|
|
139
|
+
* affordance for a free option.
|
|
140
|
+
*/
|
|
141
|
+
function ModifierPriceLabel({ modifier }: { modifier: Modifier }) {
|
|
142
|
+
const delta = parseFloat(modifier.priceDelta);
|
|
143
|
+
if (Number.isNaN(delta) || delta === 0) return null;
|
|
144
|
+
const sign = delta > 0 ? '+' : '';
|
|
145
|
+
return (
|
|
146
|
+
<span>
|
|
147
|
+
{sign}
|
|
148
|
+
{modifier.priceDelta}
|
|
149
|
+
</span>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function ruleSummary(group: ModifierGroup): string {
|
|
154
|
+
const parts: string[] = [];
|
|
155
|
+
if (group.selectionType === 'SINGLE') {
|
|
156
|
+
parts.push('Pick one');
|
|
157
|
+
} else if (group.max != null) {
|
|
158
|
+
if (group.min === 0) parts.push(`Up to ${group.max}`);
|
|
159
|
+
else if (group.min === group.max) parts.push(`Pick exactly ${group.min}`);
|
|
160
|
+
else parts.push(`${group.min}–${group.max}`);
|
|
161
|
+
} else if (group.min > 0) {
|
|
162
|
+
parts.push(`At least ${group.min}`);
|
|
163
|
+
}
|
|
164
|
+
return parts.length ? `(${parts.join(', ')})` : '';
|
|
165
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
/**
|
|
16
|
+
* DESIGN ME — price renderer used by cards, product page and recommendations;
|
|
17
|
+
* computes sale state + discount % from props (currency via useCurrency()).
|
|
18
|
+
* Never hardcode currency symbols — formatPrice handles them.
|
|
19
|
+
*
|
|
20
|
+
* 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.
|
|
21
|
+
*/
|
|
22
|
+
export function PriceDisplay({
|
|
23
|
+
price,
|
|
24
|
+
salePrice,
|
|
25
|
+
currency,
|
|
26
|
+
className,
|
|
27
|
+
size = 'md',
|
|
28
|
+
}: PriceDisplayProps) {
|
|
29
|
+
const currencyCode = useCurrency(currency);
|
|
30
|
+
|
|
31
|
+
const basePrice = typeof price === 'string' ? parseFloat(price) : price;
|
|
32
|
+
const sale =
|
|
33
|
+
salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
|
|
34
|
+
const isOnSale = sale !== null && sale < basePrice;
|
|
35
|
+
|
|
36
|
+
const discountPercent =
|
|
37
|
+
isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<span data-size={size} className={cn('inline-flex items-center gap-2', className)}>
|
|
41
|
+
{isOnSale ? (
|
|
42
|
+
<>
|
|
43
|
+
<span>{formatPrice(sale!, { currency: currencyCode }) as string}</span>
|
|
44
|
+
<s>{formatPrice(basePrice, { currency: currencyCode }) as string}</s>
|
|
45
|
+
{discountPercent > 0 && <span>-{discountPercent}%</span>}
|
|
46
|
+
</>
|
|
47
|
+
) : (
|
|
48
|
+
<span>{formatPrice(basePrice, { currency: currencyCode }) as string}</span>
|
|
49
|
+
)}
|
|
50
|
+
</span>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
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, 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 { useCurrency } from '@/core/lib/use-currency';
|
|
14
|
+
import { cn } from '@/core/lib/utils';
|
|
15
|
+
|
|
16
|
+
interface ProductCardProps {
|
|
17
|
+
product: Product;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pick the price + currency to render for a given product (PRD §23 FX overlay).
|
|
23
|
+
*
|
|
24
|
+
* When the storefront passed `regionId` to `getProducts({ regionId })` AND the
|
|
25
|
+
* region currency differs from the store currency, the backend attaches
|
|
26
|
+
* additive `displayPrice` / `displayCurrency` fields. Otherwise we fall back
|
|
27
|
+
* to the canonical store-currency `basePrice` / `salePrice`. Either way the
|
|
28
|
+
* cart still charges in the store currency — this only affects display.
|
|
29
|
+
*/
|
|
30
|
+
function pickDisplayPrice(
|
|
31
|
+
product: Product,
|
|
32
|
+
fallbackCurrency: string
|
|
33
|
+
): { price: number | undefined; salePrice: number | null; currency: string } {
|
|
34
|
+
if (product.displayPrice != null && product.displayCurrency) {
|
|
35
|
+
const sale =
|
|
36
|
+
product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
|
|
37
|
+
return {
|
|
38
|
+
// `displayPrice` is the base price converted to the region currency —
|
|
39
|
+
// it goes into the PriceDisplay `price` (base) slot, NOT the sale slot.
|
|
40
|
+
price: parseFloat(product.displayPrice),
|
|
41
|
+
salePrice: sale != null && !Number.isNaN(sale) ? sale : null,
|
|
42
|
+
currency: product.displayCurrency,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// Same-currency fallback. `getProductPriceInfo.price` is the EFFECTIVE
|
|
46
|
+
// charged amount (= salePrice when on sale, base otherwise); `originalPrice`
|
|
47
|
+
// is the base. Map them to PriceDisplay's (price = base, salePrice = sale).
|
|
48
|
+
const { price: effective, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
49
|
+
return {
|
|
50
|
+
price: originalPrice,
|
|
51
|
+
salePrice: isOnSale ? effective : null,
|
|
52
|
+
currency: fallbackCurrency,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function VariantPriceRange({ product }: { product: Product }) {
|
|
57
|
+
const currency = useCurrency();
|
|
58
|
+
|
|
59
|
+
let min: number;
|
|
60
|
+
let max: number;
|
|
61
|
+
if (product.priceMin && product.priceMax) {
|
|
62
|
+
min = parseFloat(product.priceMin);
|
|
63
|
+
max = parseFloat(product.priceMax);
|
|
64
|
+
} else {
|
|
65
|
+
const variants = product.variants ?? [];
|
|
66
|
+
if (variants.length === 0) return null;
|
|
67
|
+
const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
|
|
68
|
+
min = Math.min(...prices);
|
|
69
|
+
max = Math.max(...prices);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (isNaN(min) || isNaN(max)) return null;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<span>
|
|
76
|
+
{min === max
|
|
77
|
+
? (formatPrice(min, { currency }) as string)
|
|
78
|
+
: `${formatPrice(min, { currency })} – ${formatPrice(max, { currency })}`}
|
|
79
|
+
</span>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function ProductCard({ product, className }: ProductCardProps) {
|
|
84
|
+
const t = useTranslations('common');
|
|
85
|
+
const tp = useTranslations('productDetail');
|
|
86
|
+
const tProd = useTranslations('products');
|
|
87
|
+
const router = useRouter();
|
|
88
|
+
const { refreshCart } = useCart();
|
|
89
|
+
const fallbackCurrency = useCurrency();
|
|
90
|
+
// FX overlay (PRD §23): prefer the region-converted display values when the
|
|
91
|
+
// storefront passed regionId to getProducts. Otherwise fall back to the
|
|
92
|
+
// canonical store-currency basePrice / salePrice.
|
|
93
|
+
const display = pickDisplayPrice(product, fallbackCurrency);
|
|
94
|
+
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
95
|
+
const mainImage = product.images?.[0];
|
|
96
|
+
const imageUrl = mainImage?.url || null;
|
|
97
|
+
const slug = product.slug || product.id;
|
|
98
|
+
const isVariable = product.type === 'VARIABLE';
|
|
99
|
+
|
|
100
|
+
const [adding, setAdding] = useState(false);
|
|
101
|
+
const [added, setAdded] = useState(false);
|
|
102
|
+
|
|
103
|
+
const canPurchase = product.inventory?.canPurchase !== false;
|
|
104
|
+
|
|
105
|
+
async function handleAddToCart(e: React.MouseEvent) {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
e.stopPropagation();
|
|
108
|
+
|
|
109
|
+
if (isVariable) {
|
|
110
|
+
router.push(`/products/${slug}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (adding || !canPurchase) return;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
setAdding(true);
|
|
118
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
119
|
+
const client = getClient();
|
|
120
|
+
await client.smartAddToCart({ productId: product.id, quantity: 1 });
|
|
121
|
+
await refreshCart();
|
|
122
|
+
setAdded(true);
|
|
123
|
+
setTimeout(() => setAdded(false), 2000);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error('Failed to add to cart:', err);
|
|
126
|
+
} finally {
|
|
127
|
+
setAdding(false);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<article className={cn(className)}>
|
|
133
|
+
{/* 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. */}
|
|
134
|
+
<figure>
|
|
135
|
+
<Link href={`/products/${slug}`}>
|
|
136
|
+
{/* `relative` + `aspect-square` are layout-critical for next/image fill */}
|
|
137
|
+
<div className="relative aspect-square">
|
|
138
|
+
{imageUrl ? (
|
|
139
|
+
<Image
|
|
140
|
+
src={imageUrl}
|
|
141
|
+
alt={mainImage?.alt || product.name}
|
|
142
|
+
fill
|
|
143
|
+
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
|
144
|
+
/>
|
|
145
|
+
) : (
|
|
146
|
+
<span className="sr-only">{product.name}</span>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
</Link>
|
|
150
|
+
<figcaption className="sr-only">{product.name}</figcaption>
|
|
151
|
+
</figure>
|
|
152
|
+
|
|
153
|
+
{/* Badges */}
|
|
154
|
+
<div className="flex flex-wrap gap-1">
|
|
155
|
+
{isOnSale && <span>{t('sale')}</span>}
|
|
156
|
+
<DiscountBadge discount={product.discount} />
|
|
157
|
+
{product.isDownloadable && <span>{tp('digitalProduct')}</span>}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Categories */}
|
|
161
|
+
{product.categories && product.categories.length > 0 && (
|
|
162
|
+
<ul className="flex flex-wrap gap-1">
|
|
163
|
+
{product.categories.slice(0, 2).map((cat) => (
|
|
164
|
+
<li key={cat.id}>{cat.name}</li>
|
|
165
|
+
))}
|
|
166
|
+
</ul>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{/* Name — clickable */}
|
|
170
|
+
<h3>
|
|
171
|
+
<Link href={`/products/${slug}`}>{product.name}</Link>
|
|
172
|
+
</h3>
|
|
173
|
+
|
|
174
|
+
{/* Price */}
|
|
175
|
+
{isVariable ? (
|
|
176
|
+
<VariantPriceRange product={product} />
|
|
177
|
+
) : (
|
|
178
|
+
<PriceDisplay
|
|
179
|
+
price={display.price ?? originalPrice}
|
|
180
|
+
salePrice={display.salePrice ?? (isOnSale ? price : undefined)}
|
|
181
|
+
currency={display.currency}
|
|
182
|
+
size="sm"
|
|
183
|
+
/>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
{/* Stock */}
|
|
187
|
+
<StockBadge inventory={product.inventory} />
|
|
188
|
+
|
|
189
|
+
{/* Add to cart (variable products route to the product page for option selection) */}
|
|
190
|
+
{(isVariable || canPurchase) && (
|
|
191
|
+
<button type="button" onClick={handleAddToCart} disabled={adding}>
|
|
192
|
+
{added ? tp('addedToCart') : isVariable ? tProd('selectOptions') : tp('addToCart')}
|
|
193
|
+
</button>
|
|
194
|
+
)}
|
|
195
|
+
</article>
|
|
196
|
+
);
|
|
197
|
+
}
|