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.
Files changed (77) hide show
  1. package/dist/index.js +35 -2
  2. package/messages/en.json +27 -1
  3. package/messages/he.json +27 -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/src/app/account/page.tsx +168 -122
  9. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
  10. package/templates/nextjs/base/src/app/checkout/page.tsx +145 -40
  11. package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
  12. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
  13. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
  14. package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
  15. package/templates/nextjs/base/src/components/account/order-history.tsx +58 -7
  16. package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
  17. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
  18. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
  19. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
  20. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +97 -6
  21. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
  22. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
  23. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
  24. package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
  25. package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
  26. package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
  27. package/templates/nextjs/base/src/core/lib/region.ts +112 -0
  28. package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
  29. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
  30. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +10 -0
  31. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
  32. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +45 -2
  33. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
  34. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
  35. package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
  36. package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
  37. package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
  38. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
  39. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
  40. package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
  41. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
  42. package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
  43. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
  44. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
  45. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
  46. package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
  47. package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
  48. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +10 -0
  49. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
  50. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +56 -11
  51. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
  52. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
  53. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
  54. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
  55. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
  56. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
  57. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
  58. package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
  59. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
  60. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
  61. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
  62. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
  63. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
  64. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
  65. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
  66. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
  67. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
  68. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
  69. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
  70. package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
  71. package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
  72. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
  73. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
  74. package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
  75. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
  76. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
  77. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
@@ -1,74 +1,141 @@
1
- 'use client';
2
-
3
- import type { TaxBreakdown } from 'brainerce';
4
- import { formatPrice } from 'brainerce';
5
- import { useTranslations } from '@/core/lib/translations';
6
- import { useCurrency } from '@/core/lib/use-currency';
7
- import { cn } from '@/core/lib/utils';
8
-
9
- interface TaxDisplayProps {
10
- /** Whether shipping address has been set */
11
- addressSet: boolean;
12
- /** Tax amount string from checkout (only available after address is set) */
13
- taxAmount?: string;
14
- /** Detailed tax breakdown (optional) */
15
- taxBreakdown?: TaxBreakdown | null;
16
- className?: string;
17
- }
18
-
19
- export function TaxDisplay({ addressSet, taxAmount, taxBreakdown, className }: TaxDisplayProps) {
20
- const t = useTranslations('checkout');
21
- const tc = useTranslations('common');
22
- const currency = useCurrency();
23
-
24
- // Before address is set
25
- if (!addressSet) {
26
- return (
27
- <div className={cn('flex items-center justify-between text-sm', className)}>
28
- <span className="text-muted-foreground">{tc('tax')}</span>
29
- <span className="text-muted-foreground text-xs">{t('calculatedAfterAddress')}</span>
30
- </div>
31
- );
32
- }
33
-
34
- // Inclusive-pricing stores back the tax out of the displayed price, so
35
- // `checkout.taxAmount` is stored as 0 and the real VAT lives on the
36
- // aggregated breakdown. Surface whichever is present.
37
- const explicitTax = taxAmount ? parseFloat(taxAmount) : 0;
38
- const tax =
39
- explicitTax > 0
40
- ? explicitTax
41
- : typeof taxBreakdown?.totalTax === 'number'
42
- ? taxBreakdown.totalTax
43
- : 0;
44
-
45
- // When there's a per-rate breakdown, prefer per-row display ("VAT 18% ₪3.05").
46
- // Otherwise show a single aggregated tax line.
47
- const hasBreakdown = !!taxBreakdown?.breakdown && taxBreakdown.breakdown.length > 0;
48
-
49
- if (hasBreakdown) {
50
- return (
51
- <div className={cn('space-y-1', className)}>
52
- {taxBreakdown!.breakdown.map((item, index) => (
53
- <div key={index} className="flex items-center justify-between text-sm">
54
- <span className="text-muted-foreground">
55
- {item.name} ({(item.rate * 100).toFixed(1)}%)
56
- </span>
57
- <span className="text-foreground font-medium">
58
- {formatPrice(item.amount, { currency }) as string}
59
- </span>
60
- </div>
61
- ))}
62
- </div>
63
- );
64
- }
65
-
66
- return (
67
- <div className={cn('flex items-center justify-between text-sm', className)}>
68
- <span className="text-muted-foreground">{tc('tax')}</span>
69
- <span className="text-foreground font-medium">
70
- {tax > 0 ? (formatPrice(tax, { currency }) as string) : t('noTax')}
71
- </span>
72
- </div>
73
- );
74
- }
1
+ 'use client';
2
+
3
+ import type { TaxBreakdown } from 'brainerce';
4
+ import { formatPrice } from 'brainerce';
5
+ import { useTranslations } from '@/core/lib/translations';
6
+ import { useCurrency } from '@/core/lib/use-currency';
7
+ import { cn } from '@/core/lib/utils';
8
+
9
+ interface TaxDisplayProps {
10
+ /** Whether shipping address has been set */
11
+ addressSet: boolean;
12
+ /** Tax amount string from checkout (only available after address is set) */
13
+ taxAmount?: string;
14
+ /** Detailed tax breakdown (optional) */
15
+ taxBreakdown?: TaxBreakdown | null;
16
+ className?: string;
17
+ }
18
+
19
+ export function TaxDisplay({ addressSet, taxAmount, taxBreakdown, className }: TaxDisplayProps) {
20
+ const t = useTranslations('checkout');
21
+ const tc = useTranslations('common');
22
+ const currency = useCurrency();
23
+
24
+ // Before address is set
25
+ if (!addressSet) {
26
+ return (
27
+ <div className={cn('flex items-center justify-between text-sm', className)}>
28
+ <span className="text-muted-foreground">{tc('tax')}</span>
29
+ <span className="text-muted-foreground text-xs">{t('calculatedAfterAddress')}</span>
30
+ </div>
31
+ );
32
+ }
33
+
34
+ // Inclusive-pricing stores back the tax out of the displayed price, so
35
+ // `checkout.taxAmount` is stored as 0 and the real VAT lives on the
36
+ // aggregated breakdown. Surface whichever is present.
37
+ const explicitTax = taxAmount ? parseFloat(taxAmount) : 0;
38
+ const tax =
39
+ explicitTax > 0
40
+ ? explicitTax
41
+ : typeof taxBreakdown?.totalTax === 'number'
42
+ ? taxBreakdown.totalTax
43
+ : 0;
44
+
45
+ // Inclusive (VAT) stores already have this tax INSIDE the line prices, so it
46
+ // has to read as informational and never as an amount added on top. Without
47
+ // it the summary prints Subtotal + Shipping + GST + QST above a Total that is
48
+ // not their sum, and the shopper reads that as a mistake. Same treatment, and
49
+ // deliberately the same wording, as the order history summary.
50
+ const taxIncluded = !!taxBreakdown?.pricesIncludeTax;
51
+
52
+ // When there's a per-rate breakdown, prefer per-row display ("VAT 18% ₪3.05").
53
+ // Otherwise show a single aggregated tax line.
54
+ //
55
+ // `breakdown` holds ONE ROW PER TAX and is routinely more than one: a Quebec
56
+ // order carries GST 5% and QST 9.975%, a British Columbia one GST 5% and
57
+ // PST 7%, and both have to be shown separately. Rendering every row is not an
58
+ // edge case to handle later.
59
+ //
60
+ // Read every field defensively. The breakdown is a FROZEN SNAPSHOT: a row
61
+ // written by older code can reach us with no rate, no name, or a non-numeric
62
+ // amount, and `item.rate * 100` on such a row prints "NaN%" on something the
63
+ // buyer treats as a receipt. This mirrors `resolveDisplayTax()` in
64
+ // @brainerce/types, the platform's canonical filter — keep a row only when it
65
+ // has a printable name AND a finite amount, and turn a missing rate into null
66
+ // instead of dropping the row, because the name and the amount are the parts
67
+ // the shopper actually needs.
68
+ const taxRows: Array<{ name: string; rate: number | null; amount: number }> = (
69
+ taxBreakdown?.breakdown ?? []
70
+ )
71
+ .filter(
72
+ (row) =>
73
+ !!row &&
74
+ typeof row.name === 'string' &&
75
+ row.name.length > 0 &&
76
+ typeof row.amount === 'number' &&
77
+ Number.isFinite(row.amount)
78
+ )
79
+ .map((row) => ({
80
+ name: row.name,
81
+ rate: typeof row.rate === 'number' && Number.isFinite(row.rate) ? row.rate : null,
82
+ amount: row.amount,
83
+ }));
84
+
85
+ // Driven by the rows that SURVIVED the filter, not by the raw array: if every
86
+ // row was unprintable, fall through to the single aggregated line below
87
+ // rather than render an empty box where the tax used to be.
88
+ const hasBreakdown = taxRows.length > 0;
89
+
90
+ if (hasBreakdown) {
91
+ return (
92
+ <div className={cn('space-y-1', className)}>
93
+ {taxRows.map((item, index) => {
94
+ // Parenthetical parts, in order: the rate when the snapshot carried
95
+ // one, then the inclusive marker when prices already contain the tax.
96
+ // Neither present leaves the bare name.
97
+ //
98
+ // Joined with a SPACE, not a comma. In Hebrew this renders
99
+ // `מע"מ (18% כלול)`, and a comma sitting between a Latin numeral and
100
+ // an RTL word is a bidi neutral that can reorder on screen. The cart's
101
+ // tax estimate line already ships the same `<name> <rate>%`
102
+ // space-joined construction, so this follows a proven pattern.
103
+ //
104
+ // Rate is printed to 3 decimals with trailing zeros trimmed.
105
+ // `toFixed(1)` prints Quebec's QST 9.975% as "10.0%", which is the
106
+ // wrong number on something a buyer treats as a receipt.
107
+ const parts: string[] = [];
108
+ if (item.rate !== null) parts.push(`${Number((item.rate * 100).toFixed(3))}%`);
109
+ if (taxIncluded) parts.push(tc('inclSuffix'));
110
+
111
+ return (
112
+ <div
113
+ key={`${item.name}-${index}`}
114
+ className="flex items-center justify-between text-sm"
115
+ >
116
+ <span className="text-muted-foreground">
117
+ {item.name}
118
+ {parts.length > 0 ? ` (${parts.join(' ')})` : ''}
119
+ </span>
120
+ <span className="text-foreground font-medium">
121
+ {formatPrice(item.amount, { currency }) as string}
122
+ </span>
123
+ </div>
124
+ );
125
+ })}
126
+ </div>
127
+ );
128
+ }
129
+
130
+ return (
131
+ <div className={cn('flex items-center justify-between text-sm', className)}>
132
+ {/* An inclusive store with no per-rate breakdown lands here — it persists
133
+ taxAmount=0 and keeps the VAT on `totalTax` — so the label has to say
134
+ "included" on this line too, or the total still fails to add up. */}
135
+ <span className="text-muted-foreground">{taxIncluded ? tc('taxIncl') : tc('tax')}</span>
136
+ <span className="text-foreground font-medium">
137
+ {tax > 0 ? (formatPrice(tax, { currency }) as string) : t('noTax')}
138
+ </span>
139
+ </div>
140
+ );
141
+ }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import type {
5
5
  Cart,
6
6
  CartItem,
@@ -31,9 +31,36 @@ export interface UseCartPageResult {
31
31
  /** Lines the server says cannot be bought right now (`isAvailable === false`). */
32
32
  unavailableItems: CartItem[];
33
33
  /**
34
- * False while the reservation is expired or any line is unavailable. The
35
- * proceed-to-checkout action must be genuinely disabled on false, not just
36
- * styled as disabled.
34
+ * Cart line ids the shopper has ticked for checkout. Starts as every line
35
+ * and follows the cart: a newly added line arrives selected, a removed one
36
+ * drops out. This is a PARTIAL CHECKOUT selection, not a UI nicety —
37
+ * `createCheckout({ selectedItemIds })` is what the platform honours, and
38
+ * after payment the unselected lines stay in the still-ACTIVE cart.
39
+ */
40
+ selectedItemIds: string[];
41
+ /** Tick / untick one line. */
42
+ toggleItemSelected: (itemId: string) => void;
43
+ /** True when every line is ticked (the default, and the full-cart case). */
44
+ allSelected: boolean;
45
+ /** Tick or untick every line at once. */
46
+ setAllSelected: (selected: boolean) => void;
47
+ /**
48
+ * Where the proceed-to-checkout action should point. Carries the selection
49
+ * as `?items=` ONLY when it is a strict subset — a full cart links to a
50
+ * bare `/checkout`, so the default path is byte-for-byte what it was.
51
+ */
52
+ checkoutHref: string;
53
+ /**
54
+ * Lines the shopper actually ticked. Scope the summary and the checkout
55
+ * gate to these, not to the whole cart.
56
+ */
57
+ selectedItems: CartItem[];
58
+ /**
59
+ * False while the reservation is expired or any SELECTED line is
60
+ * unavailable. The proceed-to-checkout action must be genuinely disabled on
61
+ * false, not just styled as disabled. Scoped to the selection on purpose:
62
+ * an unavailable line the shopper has unticked is not going to the server,
63
+ * so it must not block a checkout of the rest.
37
64
  */
38
65
  canProceedToCheckout: boolean;
39
66
  /**
@@ -106,10 +133,68 @@ export function useCartPage(): UseCartPageResult {
106
133
  expiredWindow === reservationExpiresAt &&
107
134
  cart?.reservation?.hasReservation === true;
108
135
 
109
- const unavailableItems = cart?.items.filter((item) => item.isAvailable === false) ?? [];
136
+ // ---- Partial-checkout selection ----
137
+ //
138
+ // Held as a Set of cart-line ids rather than a per-item boolean so it can be
139
+ // reconciled against the cart in one pass. `null` means "nothing has been
140
+ // untick'd yet", which is the everything-selected default — distinct from an
141
+ // empty Set, which is a shopper who deliberately cleared the list.
142
+ const [deselectedIds, setDeselectedIds] = useState<Set<string>>(() => new Set());
143
+
144
+ const items = useMemo(() => cart?.items ?? [], [cart?.items]);
145
+
146
+ // Drop ids for lines that no longer exist, so a removed-and-readded product
147
+ // does not come back invisibly unticked.
148
+ const liveItemIds = items.map((item) => item.id).join(',');
149
+ useEffect(() => {
150
+ setDeselectedIds((prev) => {
151
+ if (prev.size === 0) return prev;
152
+ const live = new Set(liveItemIds ? liveItemIds.split(',') : []);
153
+ const next = new Set([...prev].filter((id) => live.has(id)));
154
+ return next.size === prev.size ? prev : next;
155
+ });
156
+ }, [liveItemIds]);
157
+
158
+ const selectedItems = items.filter((item) => !deselectedIds.has(item.id));
159
+ const selectedItemIds = selectedItems.map((item) => item.id);
160
+ const allSelected = items.length > 0 && selectedItems.length === items.length;
161
+
162
+ const toggleItemSelected = useCallback((itemId: string) => {
163
+ setDeselectedIds((prev) => {
164
+ const next = new Set(prev);
165
+ if (next.has(itemId)) {
166
+ next.delete(itemId);
167
+ } else {
168
+ next.add(itemId);
169
+ }
170
+ return next;
171
+ });
172
+ }, []);
173
+
174
+ const setAllSelected = useCallback(
175
+ (selected: boolean) => {
176
+ setDeselectedIds(selected ? new Set() : new Set(items.map((item) => item.id)));
177
+ },
178
+ [items]
179
+ );
180
+
181
+ // A full cart keeps the plain `/checkout` URL so the common path is
182
+ // unchanged; only a strict subset carries the ids.
183
+ const checkoutHref =
184
+ allSelected || selectedItemIds.length === 0
185
+ ? '/checkout'
186
+ : `/checkout?items=${encodeURIComponent(selectedItemIds.join(','))}`;
187
+
188
+ // Reported over the whole cart (the banner names every blocked line) but
189
+ // gated over the selection below.
190
+ const unavailableItems = items.filter((item) => item.isAvailable === false);
191
+ const unavailableSelected = selectedItems.filter((item) => item.isAvailable === false);
110
192
 
111
193
  const canProceedToCheckout =
112
- !!cart && cart.items.length > 0 && !reservationExpired && unavailableItems.length === 0;
194
+ !!cart &&
195
+ selectedItems.length > 0 &&
196
+ !reservationExpired &&
197
+ unavailableSelected.length === 0;
113
198
 
114
199
  return {
115
200
  cart,
@@ -121,6 +206,12 @@ export function useCartPage(): UseCartPageResult {
121
206
  bundles,
122
207
  reservationExpired,
123
208
  unavailableItems,
209
+ selectedItemIds,
210
+ selectedItems,
211
+ toggleItemSelected,
212
+ allSelected,
213
+ setAllSelected,
214
+ checkoutHref,
124
215
  canProceedToCheckout,
125
216
  onReservationExpired,
126
217
  };
@@ -1,51 +1,63 @@
1
- 'use client';
2
-
3
- import { useEffect, useState } from 'react';
4
- import type { Product, DiscountBanner } from 'brainerce';
5
- import { getClient } from '@/core/lib/brainerce';
6
-
7
- export interface UseHomeDataResult {
8
- /** Newest products for the featured grid (max 8). */
9
- products: Product[];
10
- /** Active store-wide discount banners. */
11
- banners: DiscountBanner[];
12
- /** True while the initial fetch is in flight. */
13
- loading: boolean;
14
- }
15
-
16
- /**
17
- * Homepage data: featured products + discount banners.
18
- * Pure data/behavior rendering lives in `ui/home/`.
19
- */
20
- export function useHomeData(): UseHomeDataResult {
21
- const [products, setProducts] = useState<Product[]>([]);
22
- const [banners, setBanners] = useState<DiscountBanner[]>([]);
23
- const [loading, setLoading] = useState(true);
24
-
25
- useEffect(() => {
26
- async function load() {
27
- try {
28
- const client = getClient();
29
- const [productsRes, bannersRes] = await Promise.allSettled([
30
- client.getProducts({ limit: 8, sortBy: 'createdAt', sortOrder: 'desc' }),
31
- client.getDiscountBanners(),
32
- ]);
33
-
34
- if (productsRes.status === 'fulfilled') {
35
- setProducts(productsRes.value.data);
36
- }
37
- if (bannersRes.status === 'fulfilled') {
38
- setBanners(bannersRes.value);
39
- }
40
- } catch (err) {
41
- console.error('Failed to load home page data:', err);
42
- } finally {
43
- setLoading(false);
44
- }
45
- }
46
-
47
- load();
48
- }, []);
49
-
50
- return { products, banners, loading };
51
- }
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import type { Product } from 'brainerce';
5
+ import { getClient } from '@/core/lib/brainerce';
6
+ import { useRegion } from '@/core/providers/store-provider';
7
+
8
+ export interface UseHomeDataResult {
9
+ /** Newest products for the featured grid (max 8). */
10
+ products: Product[];
11
+ /** True while the initial fetch is in flight. */
12
+ loading: boolean;
13
+ }
14
+
15
+ /**
16
+ * Homepage data: the featured product grid.
17
+ *
18
+ * Discount banners are deliberately NOT fetched here. They render below the
19
+ * header on every page, so the root layout fetches them server-side and hands
20
+ * them to `<DiscountBannerStrip>`. Fetching them again on the home page would
21
+ * be a duplicate request and a second, later-arriving copy of the same strip.
22
+ *
23
+ * Pure data/behavior — rendering lives in `ui/home/`.
24
+ */
25
+ export function useHomeData(): UseHomeDataResult {
26
+ const [products, setProducts] = useState<Product[]>([]);
27
+ const [loading, setLoading] = useState(true);
28
+ // Every product read carries the region, or the featured grid quotes the
29
+ // default region's currency to a shopper the product page then prices
30
+ // differently. `undefined` on a store with no regions, and the SDK omits
31
+ // the param, so a single-region store behaves exactly as before.
32
+ const { regionId } = useRegion();
33
+
34
+ useEffect(() => {
35
+ async function load() {
36
+ try {
37
+ const client = getClient();
38
+ const [productsRes] = await Promise.allSettled([
39
+ client.getProducts({
40
+ limit: 8,
41
+ sortBy: 'createdAt',
42
+ sortOrder: 'desc',
43
+ ...(regionId ? { regionId } : {}),
44
+ }),
45
+ ]);
46
+
47
+ if (productsRes.status === 'fulfilled') {
48
+ setProducts(productsRes.value.data);
49
+ }
50
+ } catch (err) {
51
+ console.error('Failed to load home page data:', err);
52
+ } finally {
53
+ setLoading(false);
54
+ }
55
+ }
56
+
57
+ load();
58
+ // Re-runs when the shopper switches region, so the grid re-prices instead
59
+ // of keeping the currency it was first rendered with.
60
+ }, [regionId]);
61
+
62
+ return { products, loading };
63
+ }