create-brainerce-store 1.62.0 → 1.64.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/LICENSE +21 -21
- package/README.md +71 -71
- package/dist/index.js +26 -3
- package/messages/en.json +502 -502
- package/messages/he.json +502 -502
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +60 -60
- package/templates/nextjs/base/.env.local.ejs +24 -24
- package/templates/nextjs/base/.eslintrc.json +57 -57
- package/templates/nextjs/base/.mcp.json +8 -8
- package/templates/nextjs/base/AI-GUIDE.md +174 -174
- package/templates/nextjs/base/TRANSLATIONS.md +200 -200
- package/templates/nextjs/base/package.json.ejs +52 -52
- package/templates/nextjs/base/src/app/agents.md/route.ts +87 -0
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +308 -262
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +276 -276
- package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +70 -0
- package/templates/nextjs/base/src/app/cart/page.tsx +7 -7
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +29 -3
- package/templates/nextjs/base/src/app/checkout/page.tsx +12 -0
- package/templates/nextjs/base/src/app/contact/layout.tsx.ejs +14 -0
- package/templates/nextjs/base/src/app/globals.css +36 -36
- package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +26 -26
- package/templates/nextjs/base/src/app/layout.tsx.ejs +30 -0
- package/templates/nextjs/base/src/app/llms.txt/route.ts +28 -5
- package/templates/nextjs/base/src/app/not-found.tsx.ejs +35 -0
- package/templates/nextjs/base/src/app/opengraph-image.tsx +109 -0
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +295 -278
- package/templates/nextjs/base/src/app/order-status/page.tsx +121 -121
- package/templates/nextjs/base/src/app/page.tsx +64 -64
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
- package/templates/nextjs/base/src/app/products/page.tsx +19 -19
- package/templates/nextjs/base/src/app/robots.ts +73 -14
- package/templates/nextjs/base/src/app/sitemap.ts +89 -66
- package/templates/nextjs/base/src/components/account/order-history.tsx +385 -385
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +70 -70
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +612 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +292 -292
- package/templates/nextjs/base/src/components/checkout/date-picker.tsx +309 -309
- package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +144 -144
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +125 -125
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -74
- package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +59 -59
- package/templates/nextjs/base/src/components/seo/breadcrumbs.tsx +37 -37
- package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +61 -61
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +97 -97
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +23 -131
- package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +62 -0
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -53
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -32
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -49
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -28
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -107
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -22
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -38
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -153
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -26
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -125
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -7
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -32
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -58
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +20 -2
- package/templates/nextjs/base/src/core/lib/image-hosts.ts +7 -7
- package/templates/nextjs/base/src/core/lib/product-options.ts +121 -121
- package/templates/nextjs/base/src/core/lib/resolve-currency.ts +20 -20
- package/templates/nextjs/base/src/core/lib/safe-redirect.ts +38 -38
- package/templates/nextjs/base/src/core/lib/seo.ts +46 -46
- package/templates/nextjs/base/src/core/lib/store-info.ts +17 -0
- package/templates/nextjs/base/src/core/lib/tracking.ts +171 -0
- package/templates/nextjs/base/src/core/lib/use-currency.ts +19 -19
- package/templates/nextjs/base/src/core/lib/utils.ts +21 -21
- package/templates/nextjs/base/src/middleware.ts.ejs +54 -2
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +117 -117
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +146 -146
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -28
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +110 -110
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +126 -126
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -140
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +134 -134
- package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +61 -61
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -95
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +22 -20
- package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -24
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
- package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -29
- package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -116
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -90
- package/templates/nextjs/base/src/ui/layout/header-account.tsx +40 -40
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -80
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -144
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -152
- package/templates/nextjs/base/src/ui/product/customization-fields.tsx +457 -457
- package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -18
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +191 -191
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -206
- package/templates/nextjs/base/src/ui/product/price-display.tsx +62 -62
- package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -455
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -61
- package/templates/nextjs/base/src/ui/product/variant-selector.tsx +291 -291
- package/templates/nextjs/base/src/ui/shared/cdn-image.tsx +26 -26
- package/templates/nextjs/base/tailwind.config.ts +69 -69
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +30 -0
- package/templates/nextjs/designs/atelier/globals.css +402 -402
- package/templates/nextjs/designs/atelier/messages-patch/en.json +100 -100
- package/templates/nextjs/designs/atelier/messages-patch/he.json +100 -100
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -110
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -163
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -140
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -123
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -147
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -142
- package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -77
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -89
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +151 -151
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -27
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +75 -75
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +174 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -54
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -46
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -118
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -96
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -137
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -14
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -141
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -138
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -466
- package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -17
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -196
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -193
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -69
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +13 -11
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +477 -477
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +42 -42
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -267
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -66
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -206
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -29
- package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -86
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +276 -276
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +63 -63
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -71
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -125
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -89
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -123
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +29 -27
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -99
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -105
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -106
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -119
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -54
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -81
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +21 -19
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -23
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -21
- package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -26
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -101
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -71
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -31
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +76 -76
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -36
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -82
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -119
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -445
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -19
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -168
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -165
- package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -52
- package/templates/nextjs/ui-canvas/product/product-card.tsx +7 -2
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -352
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -27
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -224
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -266
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -96
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -45
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -212
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -31
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -27
- package/templates/nextjs/base/tsconfig.tsbuildinfo +0 -1
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import type { ProductDiscount } from 'brainerce';
|
|
2
|
-
import { cn } from '@/core/lib/utils';
|
|
3
|
-
|
|
4
|
-
interface DiscountBadgeProps {
|
|
5
|
-
discount?: ProductDiscount | null;
|
|
6
|
-
className?: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* DESIGN ME — discount-rule badge shown on product cards; text comes from
|
|
11
|
-
* the product's `discount.badgeText` (merchant-configured rule).
|
|
12
|
-
*
|
|
13
|
-
* 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.
|
|
14
|
-
*/
|
|
15
|
-
export function DiscountBadge({ discount, className }: DiscountBadgeProps) {
|
|
16
|
-
if (!discount) return null;
|
|
17
|
-
|
|
18
|
-
return <span className={cn(className)}>{discount.badgeText}</span>;
|
|
19
|
-
}
|
|
1
|
+
import type { ProductDiscount } from 'brainerce';
|
|
2
|
+
import { cn } from '@/core/lib/utils';
|
|
3
|
+
|
|
4
|
+
interface DiscountBadgeProps {
|
|
5
|
+
discount?: ProductDiscount | null;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* DESIGN ME — discount-rule badge shown on product cards; text comes from
|
|
11
|
+
* the product's `discount.badgeText` (merchant-configured rule).
|
|
12
|
+
*
|
|
13
|
+
* 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.
|
|
14
|
+
*/
|
|
15
|
+
export function DiscountBadge({ discount, className }: DiscountBadgeProps) {
|
|
16
|
+
if (!discount) return null;
|
|
17
|
+
|
|
18
|
+
return <span className={cn(className)}>{discount.badgeText}</span>;
|
|
19
|
+
}
|
|
@@ -1,168 +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
|
-
}
|
|
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
|
+
}
|