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,124 +1,124 @@
|
|
|
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
|
-
|
|
8
|
-
interface ReservationCountdownProps {
|
|
9
|
-
reservation: ReservationInfo;
|
|
10
|
-
/**
|
|
11
|
-
* Fired once per reservation window when the timer reaches zero, including
|
|
12
|
-
* when the page opens on an already-expired reservation. Wired to
|
|
13
|
-
* `useCartPage().onReservationExpired`, which refreshes the cart and blocks
|
|
14
|
-
* checkout. Keep the prop when you redesign this: the gate lives in the
|
|
15
|
-
* hook, but nothing tells it about the expiry unless this still calls it.
|
|
16
|
-
*/
|
|
17
|
-
onExpire?: () => void;
|
|
18
|
-
className?: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* DESIGN ME — stock-reservation countdown on the cart/checkout pages; the
|
|
23
|
-
* `reservation` prop comes from useCartPage().cart.reservation. Keep the
|
|
24
|
-
* ticking logic, the `onExpire` call and the expired/urgent states (exposed
|
|
25
|
-
* via data-state).
|
|
26
|
-
*
|
|
27
|
-
* 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.
|
|
28
|
-
*/
|
|
29
|
-
export function ReservationCountdown({
|
|
30
|
-
reservation,
|
|
31
|
-
onExpire,
|
|
32
|
-
className,
|
|
33
|
-
}: ReservationCountdownProps) {
|
|
34
|
-
const t = useTranslations('reservation');
|
|
35
|
-
// `null` means "not measured yet". Server-rendered HTML must not compute a
|
|
36
|
-
// time, or it disagrees with the client on hydration; starting at 0 instead
|
|
37
|
-
// would render the expired state for one frame on a perfectly live
|
|
38
|
-
// reservation, and would fire onExpire on every mount.
|
|
39
|
-
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
|
|
40
|
-
|
|
41
|
-
const calculateRemaining = useCallback(() => {
|
|
42
|
-
if (!reservation.expiresAt) return 0;
|
|
43
|
-
const expiresAtMs = new Date(reservation.expiresAt).getTime();
|
|
44
|
-
const nowMs = Date.now();
|
|
45
|
-
return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
|
|
46
|
-
}, [reservation.expiresAt]);
|
|
47
|
-
|
|
48
|
-
// Report each expiry window at most once. Keyed on `expiresAt` rather than a
|
|
49
|
-
// boolean, so the cart refresh that expiry triggers (which remounts this
|
|
50
|
-
// component) cannot bounce straight back into a second report.
|
|
51
|
-
const expiresAt = reservation.expiresAt;
|
|
52
|
-
const reportedExpiryRef = useRef<string | null>(null);
|
|
53
|
-
const onExpireRef = useRef(onExpire);
|
|
54
|
-
// Kept in a ref, and synced in an effect rather than during render, so a
|
|
55
|
-
// caller passing an inline arrow does not restart the timer every render.
|
|
56
|
-
useEffect(() => {
|
|
57
|
-
onExpireRef.current = onExpire;
|
|
58
|
-
}, [onExpire]);
|
|
59
|
-
|
|
60
|
-
const reportExpired = useCallback(() => {
|
|
61
|
-
if (!expiresAt) return;
|
|
62
|
-
if (reportedExpiryRef.current === expiresAt) return;
|
|
63
|
-
reportedExpiryRef.current = expiresAt;
|
|
64
|
-
onExpireRef.current?.();
|
|
65
|
-
}, [expiresAt]);
|
|
66
|
-
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
const initial = calculateRemaining();
|
|
69
|
-
setRemainingSeconds(initial);
|
|
70
|
-
if (initial <= 0) reportExpired();
|
|
71
|
-
|
|
72
|
-
const interval = setInterval(() => {
|
|
73
|
-
const remaining = calculateRemaining();
|
|
74
|
-
setRemainingSeconds(remaining);
|
|
75
|
-
|
|
76
|
-
if (remaining <= 0) {
|
|
77
|
-
clearInterval(interval);
|
|
78
|
-
reportExpired();
|
|
79
|
-
}
|
|
80
|
-
}, 1000);
|
|
81
|
-
|
|
82
|
-
return () => clearInterval(interval);
|
|
83
|
-
}, [calculateRemaining, reportExpired]);
|
|
84
|
-
|
|
85
|
-
if (!reservation.hasReservation) return null;
|
|
86
|
-
|
|
87
|
-
const displaySeconds = remainingSeconds ?? 0;
|
|
88
|
-
const minutes = Math.floor(displaySeconds / 60);
|
|
89
|
-
const seconds = displaySeconds % 60;
|
|
90
|
-
const isExpired = remainingSeconds !== null && remainingSeconds <= 0;
|
|
91
|
-
const isUrgent = displaySeconds > 0 && displaySeconds < 120;
|
|
92
|
-
|
|
93
|
-
const displayMessage = reservation.countdownMessage
|
|
94
|
-
? reservation.countdownMessage.replace(
|
|
95
|
-
'{time}',
|
|
96
|
-
`${minutes}:${seconds.toString().padStart(2, '0')}`
|
|
97
|
-
)
|
|
98
|
-
: null;
|
|
99
|
-
|
|
100
|
-
return (
|
|
101
|
-
<div
|
|
102
|
-
role="timer"
|
|
103
|
-
data-state={isExpired ? 'expired' : isUrgent ? 'urgent' : 'active'}
|
|
104
|
-
className={cn(className)}
|
|
105
|
-
>
|
|
106
|
-
{isExpired ? (
|
|
107
|
-
<>
|
|
108
|
-
<p>{t('expired')}</p>
|
|
109
|
-
<p>{t('expiredHint')}</p>
|
|
110
|
-
</>
|
|
111
|
-
) : displayMessage ? (
|
|
112
|
-
<p>{displayMessage}</p>
|
|
113
|
-
) : (
|
|
114
|
-
<p>
|
|
115
|
-
{isUrgent ? `${t('hurry')} ` : ''}
|
|
116
|
-
{t('reservedFor')}{' '}
|
|
117
|
-
<span>
|
|
118
|
-
{minutes}:{seconds.toString().padStart(2, '0')}
|
|
119
|
-
</span>
|
|
120
|
-
</p>
|
|
121
|
-
)}
|
|
122
|
-
</div>
|
|
123
|
-
);
|
|
124
|
-
}
|
|
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
|
+
|
|
8
|
+
interface ReservationCountdownProps {
|
|
9
|
+
reservation: ReservationInfo;
|
|
10
|
+
/**
|
|
11
|
+
* Fired once per reservation window when the timer reaches zero, including
|
|
12
|
+
* when the page opens on an already-expired reservation. Wired to
|
|
13
|
+
* `useCartPage().onReservationExpired`, which refreshes the cart and blocks
|
|
14
|
+
* checkout. Keep the prop when you redesign this: the gate lives in the
|
|
15
|
+
* hook, but nothing tells it about the expiry unless this still calls it.
|
|
16
|
+
*/
|
|
17
|
+
onExpire?: () => void;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* DESIGN ME — stock-reservation countdown on the cart/checkout pages; the
|
|
23
|
+
* `reservation` prop comes from useCartPage().cart.reservation. Keep the
|
|
24
|
+
* ticking logic, the `onExpire` call and the expired/urgent states (exposed
|
|
25
|
+
* via data-state).
|
|
26
|
+
*
|
|
27
|
+
* 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.
|
|
28
|
+
*/
|
|
29
|
+
export function ReservationCountdown({
|
|
30
|
+
reservation,
|
|
31
|
+
onExpire,
|
|
32
|
+
className,
|
|
33
|
+
}: ReservationCountdownProps) {
|
|
34
|
+
const t = useTranslations('reservation');
|
|
35
|
+
// `null` means "not measured yet". Server-rendered HTML must not compute a
|
|
36
|
+
// time, or it disagrees with the client on hydration; starting at 0 instead
|
|
37
|
+
// would render the expired state for one frame on a perfectly live
|
|
38
|
+
// reservation, and would fire onExpire on every mount.
|
|
39
|
+
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
|
|
40
|
+
|
|
41
|
+
const calculateRemaining = useCallback(() => {
|
|
42
|
+
if (!reservation.expiresAt) return 0;
|
|
43
|
+
const expiresAtMs = new Date(reservation.expiresAt).getTime();
|
|
44
|
+
const nowMs = Date.now();
|
|
45
|
+
return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
|
|
46
|
+
}, [reservation.expiresAt]);
|
|
47
|
+
|
|
48
|
+
// Report each expiry window at most once. Keyed on `expiresAt` rather than a
|
|
49
|
+
// boolean, so the cart refresh that expiry triggers (which remounts this
|
|
50
|
+
// component) cannot bounce straight back into a second report.
|
|
51
|
+
const expiresAt = reservation.expiresAt;
|
|
52
|
+
const reportedExpiryRef = useRef<string | null>(null);
|
|
53
|
+
const onExpireRef = useRef(onExpire);
|
|
54
|
+
// Kept in a ref, and synced in an effect rather than during render, so a
|
|
55
|
+
// caller passing an inline arrow does not restart the timer every render.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
onExpireRef.current = onExpire;
|
|
58
|
+
}, [onExpire]);
|
|
59
|
+
|
|
60
|
+
const reportExpired = useCallback(() => {
|
|
61
|
+
if (!expiresAt) return;
|
|
62
|
+
if (reportedExpiryRef.current === expiresAt) return;
|
|
63
|
+
reportedExpiryRef.current = expiresAt;
|
|
64
|
+
onExpireRef.current?.();
|
|
65
|
+
}, [expiresAt]);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const initial = calculateRemaining();
|
|
69
|
+
setRemainingSeconds(initial);
|
|
70
|
+
if (initial <= 0) reportExpired();
|
|
71
|
+
|
|
72
|
+
const interval = setInterval(() => {
|
|
73
|
+
const remaining = calculateRemaining();
|
|
74
|
+
setRemainingSeconds(remaining);
|
|
75
|
+
|
|
76
|
+
if (remaining <= 0) {
|
|
77
|
+
clearInterval(interval);
|
|
78
|
+
reportExpired();
|
|
79
|
+
}
|
|
80
|
+
}, 1000);
|
|
81
|
+
|
|
82
|
+
return () => clearInterval(interval);
|
|
83
|
+
}, [calculateRemaining, reportExpired]);
|
|
84
|
+
|
|
85
|
+
if (!reservation.hasReservation) return null;
|
|
86
|
+
|
|
87
|
+
const displaySeconds = remainingSeconds ?? 0;
|
|
88
|
+
const minutes = Math.floor(displaySeconds / 60);
|
|
89
|
+
const seconds = displaySeconds % 60;
|
|
90
|
+
const isExpired = remainingSeconds !== null && remainingSeconds <= 0;
|
|
91
|
+
const isUrgent = displaySeconds > 0 && displaySeconds < 120;
|
|
92
|
+
|
|
93
|
+
const displayMessage = reservation.countdownMessage
|
|
94
|
+
? reservation.countdownMessage.replace(
|
|
95
|
+
'{time}',
|
|
96
|
+
`${minutes}:${seconds.toString().padStart(2, '0')}`
|
|
97
|
+
)
|
|
98
|
+
: null;
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div
|
|
102
|
+
role="timer"
|
|
103
|
+
data-state={isExpired ? 'expired' : isUrgent ? 'urgent' : 'active'}
|
|
104
|
+
className={cn(className)}
|
|
105
|
+
>
|
|
106
|
+
{isExpired ? (
|
|
107
|
+
<>
|
|
108
|
+
<p>{t('expired')}</p>
|
|
109
|
+
<p>{t('expiredHint')}</p>
|
|
110
|
+
</>
|
|
111
|
+
) : displayMessage ? (
|
|
112
|
+
<p>{displayMessage}</p>
|
|
113
|
+
) : (
|
|
114
|
+
<p>
|
|
115
|
+
{isUrgent ? `${t('hurry')} ` : ''}
|
|
116
|
+
{t('reservedFor')}{' '}
|
|
117
|
+
<span>
|
|
118
|
+
{minutes}:{seconds.toString().padStart(2, '0')}
|
|
119
|
+
</span>
|
|
120
|
+
</p>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -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
|
|