create-brainerce-store 1.73.0 → 1.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +35 -2
- package/messages/en.json +27 -1
- package/messages/he.json +27 -1
- package/package.json +1 -1
- package/templates/nextjs/base/AGENTS.md.ejs +63 -16
- package/templates/nextjs/base/AI-GUIDE.md +17 -4
- package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
- package/templates/nextjs/base/src/app/account/page.tsx +168 -122
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +145 -40
- package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
- package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +58 -7
- package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +97 -6
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
- package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
- package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
- package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
- package/templates/nextjs/base/src/core/lib/region.ts +112 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +10 -0
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +45 -2
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
- package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
- package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
- package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
- package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
- package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
- package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +10 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +56 -11
- package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
- package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
- package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
- package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
- package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
- package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Non-binding tax preview for the cart.
|
|
5
|
+
*
|
|
6
|
+
* Shoppers in VAT markets expect a tax figure before they reach the address
|
|
7
|
+
* form, and "calculated at checkout" reads as an unpleasant surprise waiting
|
|
8
|
+
* to happen. `estimateTax` gives them a number early without pretending it is
|
|
9
|
+
* final.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ NEVER PRESENT THIS AS THE REAL TAX. The authoritative calculation runs at
|
|
12
|
+
* checkout against the full shipping address. A preview has no province, so a
|
|
13
|
+
* Canadian estimate shows federal GST alone and the provincial PST/QST joins
|
|
14
|
+
* it at checkout. That is what `note` is for, and why the "calculated at
|
|
15
|
+
* checkout" caveat stays on the row even when a figure is showing.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ NO ESTIMATE IS RENDERED when `appliesTax` is false, which is what an
|
|
18
|
+
* empty or freshly created store returns: tax is off, no country reached us,
|
|
19
|
+
* or no active rate covers it. In that case (and when the call fails, and
|
|
20
|
+
* while it is in flight, and on an empty cart) this falls back to EXACTLY the
|
|
21
|
+
* row the cart shipped with before the estimate existed: "Tax / calculated at
|
|
22
|
+
* checkout". There is no spinner, no error box, and no empty amount.
|
|
23
|
+
*
|
|
24
|
+
* `getStoreTaxClasses()` would let you add a separate "9% VAT" transparency
|
|
25
|
+
* badge, but `rateName` + `rate` already come back on this response and say
|
|
26
|
+
* the same thing, so a second request would buy nothing.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { useEffect, useState } from 'react';
|
|
30
|
+
import type { TaxEstimateResponse } from 'brainerce';
|
|
31
|
+
import { formatPrice } from 'brainerce';
|
|
32
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
33
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
34
|
+
import { useCart, useRegion } from '@/core/providers/store-provider';
|
|
35
|
+
|
|
36
|
+
export function useTaxEstimate(): TaxEstimateResponse | null {
|
|
37
|
+
const { totals } = useCart();
|
|
38
|
+
// The same country the region lookup already extracted from the edge. Asking
|
|
39
|
+
// for it twice would be two sources that can disagree.
|
|
40
|
+
const { buyerCountry } = useRegion();
|
|
41
|
+
const [estimate, setEstimate] = useState<TaxEstimateResponse | null>(null);
|
|
42
|
+
const subtotal = totals.subtotal;
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
// An empty cart has nothing to tax. Skipping the call also keeps a
|
|
46
|
+
// brand-new store from firing a request on every page load forever.
|
|
47
|
+
if (!(subtotal > 0)) {
|
|
48
|
+
setEstimate(null);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
let cancelled = false;
|
|
52
|
+
getClient()
|
|
53
|
+
.estimateTax({ ...(buyerCountry ? { country: buyerCountry } : {}), subtotal })
|
|
54
|
+
.then((result) => {
|
|
55
|
+
if (!cancelled) setEstimate(result);
|
|
56
|
+
})
|
|
57
|
+
.catch(() => {
|
|
58
|
+
// Swallowed on purpose. A tax preview is a courtesy; it must never
|
|
59
|
+
// take the cart down with it. The caller falls back to the plain
|
|
60
|
+
// "calculated at checkout" row.
|
|
61
|
+
if (!cancelled) setEstimate(null);
|
|
62
|
+
});
|
|
63
|
+
return () => {
|
|
64
|
+
cancelled = true;
|
|
65
|
+
};
|
|
66
|
+
}, [subtotal, buyerCountry]);
|
|
67
|
+
|
|
68
|
+
return estimate;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* DESIGN ME — unstyled on purpose (canvas mode). The rules that are NOT yours
|
|
72
|
+
to change: no estimate is rendered when `appliesTax` is false or the call
|
|
73
|
+
failed, `note` is rendered verbatim, and the "calculated at checkout" caveat
|
|
74
|
+
stays beside the figure. */
|
|
75
|
+
export function TaxEstimateLine() {
|
|
76
|
+
const t = useTranslations('cart');
|
|
77
|
+
const tc = useTranslations('common');
|
|
78
|
+
const estimate = useTaxEstimate();
|
|
79
|
+
|
|
80
|
+
if (!estimate || !estimate.appliesTax) {
|
|
81
|
+
return (
|
|
82
|
+
<div className="flex items-center justify-between gap-4">
|
|
83
|
+
<dt>{tc('tax')}</dt>
|
|
84
|
+
<dd>{t('taxAtCheckout')}</dd>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const label =
|
|
90
|
+
estimate.rateName && estimate.rate != null
|
|
91
|
+
? `${estimate.rateName} ${estimate.rate}%`
|
|
92
|
+
: (estimate.rateName ?? tc('tax'));
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div>
|
|
96
|
+
<div className="flex items-center justify-between gap-4">
|
|
97
|
+
<dt>{label}</dt>
|
|
98
|
+
<dd>{formatPrice(estimate.estimatedTax, { currency: estimate.currency }) as string}</dd>
|
|
99
|
+
</div>
|
|
100
|
+
<p>{t('taxAtCheckout')}</p>
|
|
101
|
+
{estimate.note ? <p>{estimate.note}</p> : null}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -1,26 +1,24 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useStoreInfo } from '@/core/providers/store-provider';
|
|
4
|
-
import { useHomeData } from '@/core/hooks/use-home-data';
|
|
5
|
-
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
{
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
);
|
|
26
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useStoreInfo } from '@/core/providers/store-provider';
|
|
4
|
+
import { useHomeData } from '@/core/hooks/use-home-data';
|
|
5
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
6
|
+
import { HeroSection } from './hero-section';
|
|
7
|
+
import { FeaturedProductsSection } from './featured-products-section';
|
|
8
|
+
|
|
9
|
+
export function HomeClient() {
|
|
10
|
+
const { storeInfo, loading: storeLoading } = useStoreInfo();
|
|
11
|
+
const { products, loading } = useHomeData();
|
|
12
|
+
|
|
13
|
+
if (storeLoading || loading) {
|
|
14
|
+
return <LoadingSpinner size="lg" />;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div>
|
|
19
|
+
{/* DESIGN ME — homepage composition: hero + featured products; data via useHomeData() and useStoreInfo(). Add/remove/reorder sections freely. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. Discount banners are NOT composed here: the root layout renders <DiscountBannerStrip> below the header on every page, so adding one here is a second, later-arriving copy of the same strip. */}
|
|
20
|
+
<HeroSection storeName={storeInfo?.name} />
|
|
21
|
+
<FeaturedProductsSection products={products} />
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Region / currency switcher.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ RENDERS NOTHING on a single-region store, which is almost every store.
|
|
7
|
+
* `regions.length <= 1` returns null before any markup: an empty or
|
|
8
|
+
* one-option dropdown in the header is worse than no switcher at all, and the
|
|
9
|
+
* whole regions feature is meant to be invisible until a merchant actually
|
|
10
|
+
* adds a second region.
|
|
11
|
+
*
|
|
12
|
+
* ⛔ It also renders nothing when the regions call FAILED. A brand-new store
|
|
13
|
+
* is empty and the call can fail (channel not live yet, backend hiccup); the
|
|
14
|
+
* provider swallows that and leaves `regions` at `[]`, which lands in the same
|
|
15
|
+
* early return. There is no error box and no skeleton, because a shopper has
|
|
16
|
+
* no idea what a region is and nothing here is worth interrupting them for.
|
|
17
|
+
*
|
|
18
|
+
* Why a manual switcher exists at all when the edge already guesses: the guess
|
|
19
|
+
* is an IP, and an IP is wrong for travellers, expats, VPNs and anyone behind
|
|
20
|
+
* a corporate egress. The shopper is the authority on where they are, and
|
|
21
|
+
* their choice is stored in a cookie so the SERVER renders the same prices on
|
|
22
|
+
* the next navigation instead of the client re-pricing after hydration.
|
|
23
|
+
*
|
|
24
|
+
* The labels are store data (`region.name`, `region.currency`), not UI copy,
|
|
25
|
+
* so this component needs no translation keys in any locale.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { useRegion } from '@/core/providers/store-provider';
|
|
29
|
+
|
|
30
|
+
/* DESIGN ME — unstyled on purpose (canvas mode). The two rules that are NOT
|
|
31
|
+
yours to change: the early return on `regions.length <= 1`, and the fact
|
|
32
|
+
that the labels come from store data rather than a translation key. */
|
|
33
|
+
export function RegionSwitcher() {
|
|
34
|
+
const { regions, region, setRegion } = useRegion();
|
|
35
|
+
|
|
36
|
+
if (regions.length <= 1 || !region) return null;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<select
|
|
40
|
+
value={region.id}
|
|
41
|
+
onChange={(e) => setRegion(e.target.value)}
|
|
42
|
+
aria-label="Select region and currency"
|
|
43
|
+
>
|
|
44
|
+
{regions.map((r) => (
|
|
45
|
+
<option key={r.id} value={r.id}>
|
|
46
|
+
{r.name} ({r.currency})
|
|
47
|
+
</option>
|
|
48
|
+
))}
|
|
49
|
+
</select>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -21,6 +21,7 @@ import { HeaderAccount } from './header-account';
|
|
|
21
21
|
<% if (i18nEnabled) { %>
|
|
22
22
|
import { LanguageSwitcher } from '@/ui/layout/language-switcher';
|
|
23
23
|
<% } %>
|
|
24
|
+
import { RegionSwitcher } from '@/ui/layout/region-switcher';
|
|
24
25
|
|
|
25
26
|
interface SiteHeaderProps {
|
|
26
27
|
/** Pre-fetched header payload (server-side). `null` triggers static fallback. */
|
|
@@ -51,6 +52,7 @@ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
|
|
|
51
52
|
<% if (i18nEnabled) { %>
|
|
52
53
|
<LanguageSwitcher />
|
|
53
54
|
<% } %>
|
|
55
|
+
<RegionSwitcher />
|
|
54
56
|
<HeaderAccount />
|
|
55
57
|
<Link href="/cart" aria-label="Cart">
|
|
56
58
|
Cart
|
|
@@ -92,6 +94,7 @@ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
|
|
|
92
94
|
<% if (i18nEnabled) { %>
|
|
93
95
|
<LanguageSwitcher />
|
|
94
96
|
<% } %>
|
|
97
|
+
<RegionSwitcher />
|
|
95
98
|
|
|
96
99
|
<HeaderAccount />
|
|
97
100
|
|
|
@@ -1,168 +1,174 @@
|
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
await
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
+
// ⛔ STORE CURRENCY THROUGHOUT, deliberately. The cross-sells are
|
|
19
|
+
// `ProductRecommendation`, a trimmed shape with no displayPrice/displayCurrency,
|
|
20
|
+
// so there is nothing to convert them to. Converting only `currentProduct`
|
|
21
|
+
// (which is a full Product and does carry FX fields) would add a euro amount to
|
|
22
|
+
// two dollar amounts and print the result as one total. One currency wins, and
|
|
23
|
+
// it has to be the one the cart actually charges.
|
|
24
|
+
function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
|
|
25
|
+
const sale = item.salePrice ? parseFloat(item.salePrice) : null;
|
|
26
|
+
const base = parseFloat(item.basePrice);
|
|
27
|
+
return sale != null && sale < base ? sale : base;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ProductThumb({
|
|
31
|
+
name,
|
|
32
|
+
imageUrl,
|
|
33
|
+
price,
|
|
34
|
+
currency,
|
|
35
|
+
checked,
|
|
36
|
+
onToggle,
|
|
37
|
+
disabled,
|
|
38
|
+
}: {
|
|
39
|
+
name: string;
|
|
40
|
+
imageUrl: string | null;
|
|
41
|
+
price: number;
|
|
42
|
+
currency: string;
|
|
43
|
+
checked: boolean;
|
|
44
|
+
onToggle?: () => void;
|
|
45
|
+
disabled?: boolean;
|
|
46
|
+
}) {
|
|
47
|
+
return (
|
|
48
|
+
<label className="inline-flex items-center gap-2">
|
|
49
|
+
{onToggle && <input type="checkbox" checked={checked} onChange={onToggle} disabled={disabled} />}
|
|
50
|
+
{/* `relative` + fixed box are layout-critical for next/image fill */}
|
|
51
|
+
<span className="relative block h-20 w-20">
|
|
52
|
+
{imageUrl ? <Image src={imageUrl} alt={name} fill sizes="80px" /> : null}
|
|
53
|
+
</span>
|
|
54
|
+
<span>{name}</span>
|
|
55
|
+
<span>{formatPrice(price, { currency }) as string}</span>
|
|
56
|
+
</label>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* DESIGN ME — "frequently bought together" bundle on the product page:
|
|
62
|
+
* current product + selectable cross-sells + combined total + add-all button;
|
|
63
|
+
* items come from useProductPage().recommendations.crossSells, the feature
|
|
64
|
+
* gate from useStoreInfo().upsell.
|
|
65
|
+
*
|
|
66
|
+
* 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.
|
|
67
|
+
*/
|
|
68
|
+
export function FrequentlyBoughtTogether({
|
|
69
|
+
items,
|
|
70
|
+
currentProduct,
|
|
71
|
+
className,
|
|
72
|
+
}: FrequentlyBoughtTogetherProps) {
|
|
73
|
+
// Hooks must be called unconditionally and in the same order on every
|
|
74
|
+
// render — keep all of them above any early `return null` branch.
|
|
75
|
+
const { storeInfo } = useStoreInfo();
|
|
76
|
+
const { refreshCart } = useCart();
|
|
77
|
+
const t = useTranslations('productDetail');
|
|
78
|
+
const currency = useCurrency();
|
|
79
|
+
|
|
80
|
+
// Only show up to 3 cross-sells
|
|
81
|
+
const crossSells = items.slice(0, 3);
|
|
82
|
+
|
|
83
|
+
const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
|
|
84
|
+
const [adding, setAdding] = useState(false);
|
|
85
|
+
|
|
86
|
+
if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
|
|
87
|
+
if (crossSells.length === 0) return null;
|
|
88
|
+
|
|
89
|
+
const currentPrice = getEffectivePrice(currentProduct);
|
|
90
|
+
const currentImage = currentProduct.images?.[0];
|
|
91
|
+
const currentImageUrl = currentImage
|
|
92
|
+
? typeof currentImage === 'string'
|
|
93
|
+
? currentImage
|
|
94
|
+
: currentImage.url
|
|
95
|
+
: null;
|
|
96
|
+
|
|
97
|
+
const totalPrice = crossSells
|
|
98
|
+
.filter((item) => selected.has(item.id))
|
|
99
|
+
.reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
|
|
100
|
+
|
|
101
|
+
const toggleItem = (id: string) => {
|
|
102
|
+
setSelected((prev) => {
|
|
103
|
+
const next = new Set(prev);
|
|
104
|
+
if (next.has(id)) {
|
|
105
|
+
next.delete(id);
|
|
106
|
+
} else {
|
|
107
|
+
next.add(id);
|
|
108
|
+
}
|
|
109
|
+
return next;
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
async function handleAddAll() {
|
|
114
|
+
if (adding || selected.size === 0) return;
|
|
115
|
+
try {
|
|
116
|
+
setAdding(true);
|
|
117
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
118
|
+
const client = getClient();
|
|
119
|
+
const selectedItems = crossSells.filter((item) => selected.has(item.id));
|
|
120
|
+
for (const item of selectedItems) {
|
|
121
|
+
await client.smartAddToCart({ productId: item.id, quantity: 1 });
|
|
122
|
+
}
|
|
123
|
+
await refreshCart();
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error('Failed to add items to cart:', err);
|
|
126
|
+
} finally {
|
|
127
|
+
setAdding(false);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<section className={cn(className)}>
|
|
133
|
+
<h2>{t('frequentlyBoughtTogether')}</h2>
|
|
134
|
+
|
|
135
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
136
|
+
{/* Current product (always included, no checkbox) */}
|
|
137
|
+
<ProductThumb
|
|
138
|
+
name={currentProduct.name}
|
|
139
|
+
imageUrl={currentImageUrl}
|
|
140
|
+
price={currentPrice}
|
|
141
|
+
currency={currency}
|
|
142
|
+
checked={true}
|
|
143
|
+
disabled
|
|
144
|
+
/>
|
|
145
|
+
|
|
146
|
+
{crossSells.map((item) => {
|
|
147
|
+
const img = item.images?.[0];
|
|
148
|
+
const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
|
|
149
|
+
return (
|
|
150
|
+
<div key={item.id} className="flex items-center gap-3">
|
|
151
|
+
<span aria-hidden="true">+</span>
|
|
152
|
+
<ProductThumb
|
|
153
|
+
name={item.name}
|
|
154
|
+
imageUrl={imgUrl}
|
|
155
|
+
price={getEffectivePrice(item)}
|
|
156
|
+
currency={currency}
|
|
157
|
+
checked={selected.has(item.id)}
|
|
158
|
+
onToggle={() => toggleItem(item.id)}
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
})}
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
{/* Total + Add button */}
|
|
166
|
+
<div className="flex flex-wrap items-center gap-4">
|
|
167
|
+
<span>{t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}</span>
|
|
168
|
+
<button type="button" onClick={handleAddAll} disabled={adding || selected.size === 0}>
|
|
169
|
+
{adding ? t('addingAll') : t('addSelectedToCart')}
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
</section>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -4,7 +4,8 @@ import { useState } from 'react';
|
|
|
4
4
|
import { Link, useRouter } from '@/core/lib/navigation';
|
|
5
5
|
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
6
6
|
import type { Product } from 'brainerce';
|
|
7
|
-
import { getProductPriceInfo,
|
|
7
|
+
import { getProductPriceInfo, formatPrice } from 'brainerce';
|
|
8
|
+
import { pickDisplayPrice, pickDisplayPriceRange } from '@/core/lib/display-price';
|
|
8
9
|
import { useTranslations } from '@/core/lib/translations';
|
|
9
10
|
import { PriceDisplay } from '@/ui/product/price-display';
|
|
10
11
|
import { StockBadge } from '@/ui/product/stock-badge';
|
|
@@ -19,57 +20,14 @@ interface ProductCardProps {
|
|
|
19
20
|
className?: string;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
/**
|
|
23
|
-
* Pick the price + currency to render for a given product (PRD §23 FX overlay).
|
|
24
|
-
*
|
|
25
|
-
* When the storefront passed `regionId` to `getProducts({ regionId })` AND the
|
|
26
|
-
* region currency differs from the store currency, the backend attaches
|
|
27
|
-
* additive `displayPrice` / `displayCurrency` fields. Otherwise we fall back
|
|
28
|
-
* to the canonical store-currency `basePrice` / `salePrice`. Either way the
|
|
29
|
-
* cart still charges in the store currency — this only affects display.
|
|
30
|
-
*/
|
|
31
|
-
function pickDisplayPrice(
|
|
32
|
-
product: Product,
|
|
33
|
-
fallbackCurrency: string
|
|
34
|
-
): { price: number | undefined; salePrice: number | null; currency: string } {
|
|
35
|
-
if (product.displayPrice != null && product.displayCurrency) {
|
|
36
|
-
const sale = 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
23
|
function VariantPriceRange({ product }: { product: Product }) {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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;
|
|
24
|
+
const fallbackCurrency = useCurrency();
|
|
25
|
+
// ⛔ Region-aware. A range built from priceMin/priceMax alone stays in the
|
|
26
|
+
// store currency while the single-price card beside it converts, so a
|
|
27
|
+
// variable product would quote a different currency from its neighbours.
|
|
28
|
+
const range = pickDisplayPriceRange(product, fallbackCurrency);
|
|
29
|
+
if (!range) return null;
|
|
30
|
+
const { min, max, currency } = range;
|
|
73
31
|
|
|
74
32
|
return (
|
|
75
33
|
<span>
|