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.
Files changed (88) hide show
  1. package/dist/index.js +37 -4
  2. package/messages/en.json +30 -1
  3. package/messages/he.json +30 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/AGENTS.md.ejs +63 -16
  6. package/templates/nextjs/base/AI-GUIDE.md +17 -4
  7. package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
  8. package/templates/nextjs/base/TRANSLATIONS.md +207 -207
  9. package/templates/nextjs/base/src/app/account/page.tsx +168 -122
  10. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
  11. package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
  12. package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
  13. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
  14. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
  15. package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
  16. package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
  17. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
  18. package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
  19. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
  20. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
  21. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
  22. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
  23. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
  24. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
  25. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
  26. package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
  27. package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
  28. package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
  29. package/templates/nextjs/base/src/core/lib/region.ts +112 -0
  30. package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
  31. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
  32. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
  33. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
  34. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
  35. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
  36. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
  37. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
  38. package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
  39. package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
  40. package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
  41. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
  42. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
  43. package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
  44. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
  45. package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
  46. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
  47. package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
  48. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
  49. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
  50. package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
  51. package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
  52. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
  53. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
  54. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
  55. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
  56. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
  57. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
  58. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
  59. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
  60. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
  61. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
  62. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
  63. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
  64. package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
  65. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
  66. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
  67. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
  68. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
  69. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
  70. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
  71. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
  72. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
  73. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
  74. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
  75. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
  76. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
  77. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
  78. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
  79. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
  80. package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
  81. package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
  82. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
  83. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
  84. package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
  85. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
  86. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
  87. package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
  88. 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 { DiscountBannerStrip } from './discount-banner-strip';
7
- import { HeroSection } from './hero-section';
8
- import { FeaturedProductsSection } from './featured-products-section';
9
-
10
- export function HomeClient() {
11
- const { storeInfo, loading: storeLoading } = useStoreInfo();
12
- const { products, banners, loading } = useHomeData();
13
-
14
- if (storeLoading || loading) {
15
- return <LoadingSpinner size="lg" />;
16
- }
17
-
18
- return (
19
- <div>
20
- {/* DESIGN ME — homepage composition: discount banners + 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. */}
21
- <DiscountBannerStrip banners={banners} />
22
- <HeroSection storeName={storeInfo?.name} />
23
- <FeaturedProductsSection products={products} />
24
- </div>
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