create-brainerce-store 1.72.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 +37 -4
- package/messages/en.json +30 -1
- package/messages/he.json +30 -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/TRANSLATIONS.md +207 -207
- 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 +1179 -1074
- 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 +422 -371
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
- 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 +218 -127
- 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 +174 -164
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
- 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/review-form.tsx +33 -11
- 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-drawer.tsx +177 -181
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
- 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/review-form.tsx +30 -10
- 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-item.tsx +137 -137
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
- 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/review-form.tsx +9 -1
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
|
@@ -1,131 +1,131 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
-
import type { ReservationInfo } from 'brainerce';
|
|
5
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
-
import { cn } from '@/core/lib/utils';
|
|
7
|
-
import { IconClock } from '@/ui/shared/icons';
|
|
8
|
-
|
|
9
|
-
interface ReservationCountdownProps {
|
|
10
|
-
reservation: ReservationInfo;
|
|
11
|
-
/**
|
|
12
|
-
* Fired once per reservation window when the timer reaches zero, including
|
|
13
|
-
* when the page opens on an already-expired reservation. Wired to
|
|
14
|
-
* `useCartPage().onReservationExpired`, which refreshes the cart and blocks
|
|
15
|
-
* checkout. This component only displays; it never decides what is still
|
|
16
|
-
* purchasable.
|
|
17
|
-
*/
|
|
18
|
-
onExpire?: () => void;
|
|
19
|
-
className?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Stock-reservation countdown on the cart/checkout pages; the `reservation`
|
|
24
|
-
* prop comes from useCartPage().cart.reservation. Keeps the ticking logic
|
|
25
|
-
* and the expired/urgent states (exposed via data-state).
|
|
26
|
-
*/
|
|
27
|
-
export function ReservationCountdown({
|
|
28
|
-
reservation,
|
|
29
|
-
onExpire,
|
|
30
|
-
className,
|
|
31
|
-
}: ReservationCountdownProps) {
|
|
32
|
-
const t = useTranslations('reservation');
|
|
33
|
-
// `null` means "not measured yet". Server-rendered HTML must not compute a
|
|
34
|
-
// time, or it disagrees with the client on hydration; starting at 0 instead
|
|
35
|
-
// would render the expired state for one frame on a perfectly live
|
|
36
|
-
// reservation, and would fire onExpire on every mount.
|
|
37
|
-
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
|
|
38
|
-
|
|
39
|
-
const calculateRemaining = useCallback(() => {
|
|
40
|
-
if (!reservation.expiresAt) return 0;
|
|
41
|
-
const expiresAtMs = new Date(reservation.expiresAt).getTime();
|
|
42
|
-
const nowMs = Date.now();
|
|
43
|
-
return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
|
|
44
|
-
}, [reservation.expiresAt]);
|
|
45
|
-
|
|
46
|
-
// Report each expiry window at most once. Keyed on `expiresAt` rather than a
|
|
47
|
-
// boolean, so the cart refresh that expiry triggers (which remounts this
|
|
48
|
-
// component) cannot bounce straight back into a second report.
|
|
49
|
-
const expiresAt = reservation.expiresAt;
|
|
50
|
-
const reportedExpiryRef = useRef<string | null>(null);
|
|
51
|
-
const onExpireRef = useRef(onExpire);
|
|
52
|
-
// Kept in a ref, and synced in an effect rather than during render, so a
|
|
53
|
-
// caller passing an inline arrow does not restart the timer every render.
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
onExpireRef.current = onExpire;
|
|
56
|
-
}, [onExpire]);
|
|
57
|
-
|
|
58
|
-
const reportExpired = useCallback(() => {
|
|
59
|
-
if (!expiresAt) return;
|
|
60
|
-
if (reportedExpiryRef.current === expiresAt) return;
|
|
61
|
-
reportedExpiryRef.current = expiresAt;
|
|
62
|
-
onExpireRef.current?.();
|
|
63
|
-
}, [expiresAt]);
|
|
64
|
-
|
|
65
|
-
useEffect(() => {
|
|
66
|
-
const initial = calculateRemaining();
|
|
67
|
-
setRemainingSeconds(initial);
|
|
68
|
-
if (initial <= 0) reportExpired();
|
|
69
|
-
|
|
70
|
-
const interval = setInterval(() => {
|
|
71
|
-
const remaining = calculateRemaining();
|
|
72
|
-
setRemainingSeconds(remaining);
|
|
73
|
-
|
|
74
|
-
if (remaining <= 0) {
|
|
75
|
-
clearInterval(interval);
|
|
76
|
-
reportExpired();
|
|
77
|
-
}
|
|
78
|
-
}, 1000);
|
|
79
|
-
|
|
80
|
-
return () => clearInterval(interval);
|
|
81
|
-
}, [calculateRemaining, reportExpired]);
|
|
82
|
-
|
|
83
|
-
if (!reservation.hasReservation) return null;
|
|
84
|
-
|
|
85
|
-
const displaySeconds = remainingSeconds ?? 0;
|
|
86
|
-
const minutes = Math.floor(displaySeconds / 60);
|
|
87
|
-
const seconds = displaySeconds % 60;
|
|
88
|
-
const isExpired = remainingSeconds !== null && remainingSeconds <= 0;
|
|
89
|
-
const isUrgent = displaySeconds > 0 && displaySeconds < 120;
|
|
90
|
-
|
|
91
|
-
const displayMessage = reservation.countdownMessage
|
|
92
|
-
? reservation.countdownMessage.replace(
|
|
93
|
-
'{time}',
|
|
94
|
-
`${minutes}:${seconds.toString().padStart(2, '0')}`
|
|
95
|
-
)
|
|
96
|
-
: null;
|
|
97
|
-
|
|
98
|
-
return (
|
|
99
|
-
<div
|
|
100
|
-
role="timer"
|
|
101
|
-
data-state={isExpired ? 'expired' : isUrgent ? 'urgent' : 'active'}
|
|
102
|
-
className={cn(
|
|
103
|
-
'flex items-center gap-2.5 rounded-lg px-4 py-3 text-sm font-medium',
|
|
104
|
-
isExpired || isUrgent
|
|
105
|
-
? 'bg-destructive/10 text-destructive'
|
|
106
|
-
: 'bg-secondary text-foreground',
|
|
107
|
-
className
|
|
108
|
-
)}
|
|
109
|
-
>
|
|
110
|
-
<span aria-hidden="true" className="shrink-0">
|
|
111
|
-
<IconClock size={18} />
|
|
112
|
-
</span>
|
|
113
|
-
{isExpired ? (
|
|
114
|
-
<div>
|
|
115
|
-
<p>{t('expired')}</p>
|
|
116
|
-
<p className="text-xs font-normal opacity-90">{t('expiredHint')}</p>
|
|
117
|
-
</div>
|
|
118
|
-
) : displayMessage ? (
|
|
119
|
-
<p>{displayMessage}</p>
|
|
120
|
-
) : (
|
|
121
|
-
<p>
|
|
122
|
-
{isUrgent ? `${t('hurry')} ` : ''}
|
|
123
|
-
{t('reservedFor')}{' '}
|
|
124
|
-
<span className="font-bold tabular-nums">
|
|
125
|
-
{minutes}:{seconds.toString().padStart(2, '0')}
|
|
126
|
-
</span>
|
|
127
|
-
</p>
|
|
128
|
-
)}
|
|
129
|
-
</div>
|
|
130
|
-
);
|
|
131
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
+
import type { ReservationInfo } from 'brainerce';
|
|
5
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
+
import { cn } from '@/core/lib/utils';
|
|
7
|
+
import { IconClock } from '@/ui/shared/icons';
|
|
8
|
+
|
|
9
|
+
interface ReservationCountdownProps {
|
|
10
|
+
reservation: ReservationInfo;
|
|
11
|
+
/**
|
|
12
|
+
* Fired once per reservation window when the timer reaches zero, including
|
|
13
|
+
* when the page opens on an already-expired reservation. Wired to
|
|
14
|
+
* `useCartPage().onReservationExpired`, which refreshes the cart and blocks
|
|
15
|
+
* checkout. This component only displays; it never decides what is still
|
|
16
|
+
* purchasable.
|
|
17
|
+
*/
|
|
18
|
+
onExpire?: () => void;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Stock-reservation countdown on the cart/checkout pages; the `reservation`
|
|
24
|
+
* prop comes from useCartPage().cart.reservation. Keeps the ticking logic
|
|
25
|
+
* and the expired/urgent states (exposed via data-state).
|
|
26
|
+
*/
|
|
27
|
+
export function ReservationCountdown({
|
|
28
|
+
reservation,
|
|
29
|
+
onExpire,
|
|
30
|
+
className,
|
|
31
|
+
}: ReservationCountdownProps) {
|
|
32
|
+
const t = useTranslations('reservation');
|
|
33
|
+
// `null` means "not measured yet". Server-rendered HTML must not compute a
|
|
34
|
+
// time, or it disagrees with the client on hydration; starting at 0 instead
|
|
35
|
+
// would render the expired state for one frame on a perfectly live
|
|
36
|
+
// reservation, and would fire onExpire on every mount.
|
|
37
|
+
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
|
|
38
|
+
|
|
39
|
+
const calculateRemaining = useCallback(() => {
|
|
40
|
+
if (!reservation.expiresAt) return 0;
|
|
41
|
+
const expiresAtMs = new Date(reservation.expiresAt).getTime();
|
|
42
|
+
const nowMs = Date.now();
|
|
43
|
+
return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
|
|
44
|
+
}, [reservation.expiresAt]);
|
|
45
|
+
|
|
46
|
+
// Report each expiry window at most once. Keyed on `expiresAt` rather than a
|
|
47
|
+
// boolean, so the cart refresh that expiry triggers (which remounts this
|
|
48
|
+
// component) cannot bounce straight back into a second report.
|
|
49
|
+
const expiresAt = reservation.expiresAt;
|
|
50
|
+
const reportedExpiryRef = useRef<string | null>(null);
|
|
51
|
+
const onExpireRef = useRef(onExpire);
|
|
52
|
+
// Kept in a ref, and synced in an effect rather than during render, so a
|
|
53
|
+
// caller passing an inline arrow does not restart the timer every render.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
onExpireRef.current = onExpire;
|
|
56
|
+
}, [onExpire]);
|
|
57
|
+
|
|
58
|
+
const reportExpired = useCallback(() => {
|
|
59
|
+
if (!expiresAt) return;
|
|
60
|
+
if (reportedExpiryRef.current === expiresAt) return;
|
|
61
|
+
reportedExpiryRef.current = expiresAt;
|
|
62
|
+
onExpireRef.current?.();
|
|
63
|
+
}, [expiresAt]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const initial = calculateRemaining();
|
|
67
|
+
setRemainingSeconds(initial);
|
|
68
|
+
if (initial <= 0) reportExpired();
|
|
69
|
+
|
|
70
|
+
const interval = setInterval(() => {
|
|
71
|
+
const remaining = calculateRemaining();
|
|
72
|
+
setRemainingSeconds(remaining);
|
|
73
|
+
|
|
74
|
+
if (remaining <= 0) {
|
|
75
|
+
clearInterval(interval);
|
|
76
|
+
reportExpired();
|
|
77
|
+
}
|
|
78
|
+
}, 1000);
|
|
79
|
+
|
|
80
|
+
return () => clearInterval(interval);
|
|
81
|
+
}, [calculateRemaining, reportExpired]);
|
|
82
|
+
|
|
83
|
+
if (!reservation.hasReservation) return null;
|
|
84
|
+
|
|
85
|
+
const displaySeconds = remainingSeconds ?? 0;
|
|
86
|
+
const minutes = Math.floor(displaySeconds / 60);
|
|
87
|
+
const seconds = displaySeconds % 60;
|
|
88
|
+
const isExpired = remainingSeconds !== null && remainingSeconds <= 0;
|
|
89
|
+
const isUrgent = displaySeconds > 0 && displaySeconds < 120;
|
|
90
|
+
|
|
91
|
+
const displayMessage = reservation.countdownMessage
|
|
92
|
+
? reservation.countdownMessage.replace(
|
|
93
|
+
'{time}',
|
|
94
|
+
`${minutes}:${seconds.toString().padStart(2, '0')}`
|
|
95
|
+
)
|
|
96
|
+
: null;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
role="timer"
|
|
101
|
+
data-state={isExpired ? 'expired' : isUrgent ? 'urgent' : 'active'}
|
|
102
|
+
className={cn(
|
|
103
|
+
'flex items-center gap-2.5 rounded-lg px-4 py-3 text-sm font-medium',
|
|
104
|
+
isExpired || isUrgent
|
|
105
|
+
? 'bg-destructive/10 text-destructive'
|
|
106
|
+
: 'bg-secondary text-foreground',
|
|
107
|
+
className
|
|
108
|
+
)}
|
|
109
|
+
>
|
|
110
|
+
<span aria-hidden="true" className="shrink-0">
|
|
111
|
+
<IconClock size={18} />
|
|
112
|
+
</span>
|
|
113
|
+
{isExpired ? (
|
|
114
|
+
<div>
|
|
115
|
+
<p>{t('expired')}</p>
|
|
116
|
+
<p className="text-xs font-normal opacity-90">{t('expiredHint')}</p>
|
|
117
|
+
</div>
|
|
118
|
+
) : displayMessage ? (
|
|
119
|
+
<p>{displayMessage}</p>
|
|
120
|
+
) : (
|
|
121
|
+
<p>
|
|
122
|
+
{isUrgent ? `${t('hurry')} ` : ''}
|
|
123
|
+
{t('reservedFor')}{' '}
|
|
124
|
+
<span className="font-bold tabular-nums">
|
|
125
|
+
{minutes}:{seconds.toString().padStart(2, '0')}
|
|
126
|
+
</span>
|
|
127
|
+
</p>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
export function TaxEstimateLine() {
|
|
72
|
+
const t = useTranslations('cart');
|
|
73
|
+
const tc = useTranslations('common');
|
|
74
|
+
const estimate = useTaxEstimate();
|
|
75
|
+
|
|
76
|
+
// No estimate: the row the cart has always shown.
|
|
77
|
+
if (!estimate || !estimate.appliesTax) {
|
|
78
|
+
return (
|
|
79
|
+
<div className="flex items-center justify-between gap-4">
|
|
80
|
+
<dt className="text-muted-foreground">{tc('tax')}</dt>
|
|
81
|
+
<dd className="text-muted-foreground">{t('taxAtCheckout')}</dd>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// `rateName` is one rate or several joined ("GST + QST"); `rate` is the
|
|
87
|
+
// summed effective percent and is null when no rule matched.
|
|
88
|
+
const label =
|
|
89
|
+
estimate.rateName && estimate.rate != null
|
|
90
|
+
? `${estimate.rateName} ${estimate.rate}%`
|
|
91
|
+
: (estimate.rateName ?? tc('tax'));
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="space-y-1">
|
|
95
|
+
<div className="flex items-center justify-between gap-4">
|
|
96
|
+
<dt className="text-muted-foreground">{label}</dt>
|
|
97
|
+
<dd className="text-foreground font-medium">
|
|
98
|
+
{formatPrice(estimate.estimatedTax, { currency: estimate.currency }) as string}
|
|
99
|
+
</dd>
|
|
100
|
+
</div>
|
|
101
|
+
{/* The caveat travels WITH the number, not in a footnote nobody reads. */}
|
|
102
|
+
<p className="text-muted-foreground text-xs">{t('taxAtCheckout')}</p>
|
|
103
|
+
{/* Server-written and already localized — never replace it with copy of
|
|
104
|
+
your own, it is what explains a partial estimate. */}
|
|
105
|
+
{estimate.note ? <p className="text-muted-foreground text-xs">{estimate.note}</p> : null}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -10,7 +10,16 @@ const BENEFITS = [
|
|
|
10
10
|
{ icon: IconReturn, titleKey: 'benefit4Title', descKey: 'benefit4Desc' },
|
|
11
11
|
] as const;
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Trust/benefits row under the hero.
|
|
15
|
+
*
|
|
16
|
+
* The copy here deliberately states no policy the store has not configured.
|
|
17
|
+
* Do not restore claims like "free nationwide delivery", "30-day returns" or
|
|
18
|
+
* "full warranty": a new store has no such policy, and shipping a storefront
|
|
19
|
+
* that asserts one publishes a false promise under the merchant's name. Replace
|
|
20
|
+
* these strings with the merchant's real terms once they exist, or point at the
|
|
21
|
+
* policy pages.
|
|
22
|
+
*/
|
|
14
23
|
export function BenefitsBand() {
|
|
15
24
|
const t = useTranslations('home');
|
|
16
25
|
|