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
@@ -11,6 +11,7 @@ import type {
11
11
  ModifierGroup,
12
12
  } from 'brainerce';
13
13
  import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
14
+ import { resolveDisplayPrice, type DisplayPrice } from '@/core/lib/display-price';
14
15
  import { useCart, useStoreInfo } from '@/core/providers/store-provider';
15
16
  import { trackAddToCart, trackProductView } from '@/core/lib/tracking';
16
17
  import {
@@ -42,8 +43,25 @@ export interface UseProductPageResult {
42
43
  /** Variant selection. */
43
44
  selectedVariant: ProductVariant | null;
44
45
  setSelectedVariant: (variant: ProductVariant | null) => void;
45
- /** Effective price info (variant price + discount-rule overlay). */
46
+ /**
47
+ * Effective price info in the STORE currency (variant price + discount-rule
48
+ * overlay).
49
+ *
50
+ * ⛔ This is the CHARGED amount, and it is what feeds `view_item` /
51
+ * `add_to_cart`. Analytics revenue has to be one currency across the store,
52
+ * so do NOT swap in a converted figure here. Render `displayPrice` instead.
53
+ */
46
54
  priceInfo: ProductPriceInfo;
55
+ /**
56
+ * The price to RENDER, region-aware: the FX-converted amounts when the
57
+ * product was read with a `regionId` whose currency differs from the store's,
58
+ * and `priceInfo` in the store currency otherwise.
59
+ *
60
+ * ⛔ Pass `displayPrice.currency` to `<PriceDisplay currency>` alongside the
61
+ * numbers. It defaults to the store currency, so a converted amount rendered
62
+ * without it is a euro figure wearing a dollar sign.
63
+ */
64
+ displayPrice: DisplayPrice;
47
65
  /** Variant inventory when a variant is selected, else product inventory. */
48
66
  inventory: InventoryInfo | null;
49
67
  canPurchase: boolean;
@@ -181,6 +199,19 @@ export function useProductPage(initialProduct: Product): UseProductPageResult {
181
199
  return getProductPriceInfo(product);
182
200
  }, [product, selectedVariant]);
183
201
 
202
+ // Region display pricing, variant-aware. The selected variant carries its own
203
+ // FX fields; fall back to the product's, then to the store-currency figures
204
+ // above. Same helper the product card uses, so a card and the page it opens
205
+ // can never quote different currencies for the same item.
206
+ const displayPrice = useMemo<DisplayPrice>(() => {
207
+ const fallback = {
208
+ price: priceInfo.originalPrice,
209
+ salePrice: priceInfo.isOnSale ? priceInfo.price : null,
210
+ };
211
+ const source = selectedVariant?.displayPrice != null ? selectedVariant : product;
212
+ return resolveDisplayPrice(source, fallback, currency);
213
+ }, [product, selectedVariant, priceInfo, currency]);
214
+
184
215
  // Inventory: use variant inventory if selected, else product inventory
185
216
  const inventory = selectedVariant?.inventory ?? product?.inventory ?? null;
186
217
  const canPurchase = inventory?.canPurchase !== false;
@@ -276,6 +307,7 @@ export function useProductPage(initialProduct: Product): UseProductPageResult {
276
307
  selectedVariant,
277
308
  setSelectedVariant,
278
309
  priceInfo,
310
+ displayPrice,
279
311
  inventory,
280
312
  canPurchase,
281
313
  description,
@@ -0,0 +1,65 @@
1
+ import type { StoreCapabilities } from 'brainerce';
2
+
3
+ /**
4
+ * Fallback used until `getStoreCapabilities()` resolves, and whenever the call
5
+ * fails. Matches the platform default (`SalesChannel.lowStockThreshold`), so a
6
+ * store that never reaches the endpoint behaves exactly as it did before
7
+ * capabilities were wired in — it never renders a broken or empty badge.
8
+ */
9
+ export const DEFAULT_LOW_STOCK_THRESHOLD = 5;
10
+
11
+ /**
12
+ * The subset of {@link StoreCapabilities} the storefront actually renders from.
13
+ * Operational channel state (granted API scopes, sandbox payment flags,
14
+ * reservation strategy) is deliberately dropped: it tells a reader how the
15
+ * merchant's channel is provisioned and has no role in what a shopper sees.
16
+ *
17
+ * Uses indexed access on `StoreCapabilities` for nested types so this stays in
18
+ * step with the SDK without restating its shapes.
19
+ */
20
+ export interface PublicStoreCapabilities {
21
+ /** Whether to call out low stock at all. False means the merchant switched the urgency messaging off. */
22
+ lowStockWarning: boolean;
23
+ /** Units at or below which stock counts as low. */
24
+ lowStockThreshold: number;
25
+ /** Whether "email me when this is back" should be offered on sold-out items. */
26
+ stockAlertsEnabled: boolean;
27
+ /** Minutes a checkout reservation is held before it expires. */
28
+ reservationTimeout: number;
29
+ /** Which optional features the merchant enabled — coupons, loyalty, shipping zones, and so on. */
30
+ features: StoreCapabilities['features'];
31
+ }
32
+
33
+ /**
34
+ * Project a raw capabilities response onto {@link PublicStoreCapabilities}.
35
+ * Add a field here only after confirming it is non-sensitive and that a
36
+ * storefront component actually needs it.
37
+ */
38
+ export function pickPublicCapabilities(raw: StoreCapabilities): PublicStoreCapabilities {
39
+ return {
40
+ lowStockWarning: raw.connection.lowStockWarning,
41
+ lowStockThreshold: raw.connection.lowStockThreshold,
42
+ stockAlertsEnabled: raw.connection.stockAlertsEnabled,
43
+ reservationTimeout: raw.connection.reservationTimeout,
44
+ features: raw.features,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Resolve the low-stock threshold a badge should use.
50
+ *
51
+ * Three cases, in order:
52
+ * 1. Capabilities not loaded yet, or the fetch failed (`null`) — fall back to
53
+ * {@link DEFAULT_LOW_STOCK_THRESHOLD} so the first paint is correct.
54
+ * 2. The merchant turned the low-stock warning off — return `0`. Stock counts
55
+ * as low only at or below the threshold, and an in-stock item always has at
56
+ * least one unit, so nothing is ever flagged low. The item still renders its
57
+ * normal in-stock label; no string is left blank.
58
+ * 3. Otherwise, the merchant's configured threshold.
59
+ */
60
+ export function resolveLowStockThreshold(
61
+ capabilities: PublicStoreCapabilities | null | undefined
62
+ ): number {
63
+ if (!capabilities) return DEFAULT_LOW_STOCK_THRESHOLD;
64
+ return capabilities.lowStockWarning ? capabilities.lowStockThreshold : 0;
65
+ }
@@ -0,0 +1,139 @@
1
+ import type { Product } from 'brainerce';
2
+ import { getProductPriceInfo, getVariantPrice } from 'brainerce';
3
+
4
+ /**
5
+ * Region display pricing (the FX overlay).
6
+ *
7
+ * When a product read was made with `regionId` AND the region's currency
8
+ * differs from the store currency, the backend attaches additive
9
+ * `displayPrice`, `displaySalePrice`, `displayPriceMin`, `displayPriceMax` and
10
+ * `displayCurrency` fields from the daily FX snapshot. They are DISPLAY ONLY:
11
+ * `basePrice` / `salePrice` stay in the store currency and the cart still
12
+ * charges in it.
13
+ *
14
+ * ⛔ NEVER read `displayPrice` on its own. It is absent whenever the region
15
+ * currency equals the store currency, which is every single-region store, so a
16
+ * bare read blanks the price on almost every storefront. Always fall back, and
17
+ * that is the entire reason these helpers exist.
18
+ *
19
+ * ⛔ EVERY PRODUCT PRICE ON SCREEN MUST GO THROUGH HERE. Convert one surface
20
+ * and not another and the shopper sees EUR on the card they clicked and USD on
21
+ * the page it opened, which is worse than never converting at all. This lives
22
+ * in `core/lib/` rather than beside one component precisely so a redesign
23
+ * cannot leave half the surfaces behind.
24
+ *
25
+ * ⛔ NOT FOR THE CART, CHECKOUT, ORDER TOTALS, OR THE TAX ESTIMATE. Those are
26
+ * charged amounts in the store currency. Using a display amount there would
27
+ * show a total that does not match what the card is billed.
28
+ *
29
+ * ⛔ NOT FOR ANALYTICS. `view_item` / `add_to_cart` must report one currency
30
+ * across the whole store or the revenue numbers are meaningless. Keep sending
31
+ * the store-currency figure.
32
+ */
33
+
34
+ /** The additive FX fields, as they appear on both Product and ProductVariant. */
35
+ interface DisplayPriceFields {
36
+ displayPrice?: string | null;
37
+ displaySalePrice?: string | null;
38
+ displayCurrency?: string | null;
39
+ }
40
+
41
+ export interface DisplayPrice {
42
+ /**
43
+ * Base (pre-sale) amount. Feeds `<PriceDisplay price>`.
44
+ *
45
+ * Always a number, never undefined: `getProductPriceInfo` returns 0 for a
46
+ * product with no usable price rather than nothing, so the render path has
47
+ * no undefined branch to guard.
48
+ */
49
+ price: number;
50
+ /** Sale amount, or null when not on sale. Feeds `<PriceDisplay salePrice>`. */
51
+ salePrice: number | null;
52
+ /**
53
+ * Currency `price` / `salePrice` are in.
54
+ *
55
+ * ⛔ ALWAYS pass this to `<PriceDisplay currency>` / `formatPrice`. It
56
+ * defaults to the STORE currency, so a converted number rendered without it
57
+ * comes out as a euro amount wearing a dollar sign, which is worse than an
58
+ * unconverted price because it looks right.
59
+ */
60
+ currency: string | undefined;
61
+ }
62
+
63
+ /**
64
+ * Prefer the region-converted amounts on `source`; otherwise return `fallback`
65
+ * in the store currency. The primitive the rest of this file is built from.
66
+ */
67
+ export function resolveDisplayPrice(
68
+ source: DisplayPriceFields | null | undefined,
69
+ fallback: { price: number; salePrice: number | null },
70
+ fallbackCurrency: string | undefined
71
+ ): DisplayPrice {
72
+ if (source?.displayPrice != null && source.displayCurrency) {
73
+ const sale = source.displaySalePrice != null ? parseFloat(source.displaySalePrice) : null;
74
+ return {
75
+ // `displayPrice` is the BASE price converted, so it belongs in the base
76
+ // slot, never the sale slot.
77
+ price: parseFloat(source.displayPrice),
78
+ salePrice: sale != null && !Number.isNaN(sale) ? sale : null,
79
+ currency: source.displayCurrency,
80
+ };
81
+ }
82
+ return { ...fallback, currency: fallbackCurrency };
83
+ }
84
+
85
+ /**
86
+ * A product's own price for a card, a hero, or a recommendation tile.
87
+ *
88
+ * The same-currency fallback maps the SDK's shape onto `<PriceDisplay>`'s:
89
+ * `getProductPriceInfo().price` is the EFFECTIVE charged amount (the sale
90
+ * price when on sale), and `originalPrice` is the base.
91
+ */
92
+ export function pickDisplayPrice(
93
+ product: Product,
94
+ fallbackCurrency: string | undefined
95
+ ): DisplayPrice {
96
+ const { price: effective, originalPrice, isOnSale } = getProductPriceInfo(product);
97
+ return resolveDisplayPrice(
98
+ product,
99
+ { price: originalPrice, salePrice: isOnSale ? effective : null },
100
+ fallbackCurrency
101
+ );
102
+ }
103
+
104
+ /**
105
+ * A variable product's "from X to Y" range.
106
+ *
107
+ * ⛔ `displayPriceMin` / `displayPriceMax` are their own fields. A range built
108
+ * from `priceMin` / `priceMax` (or by walking `variants`) stays in the store
109
+ * currency while the single-price card beside it converts, which is the same
110
+ * split-currency bug one level down. Returns null when there is no range to
111
+ * show, which is what a product with no variants and no bounds gives you.
112
+ */
113
+ export function pickDisplayPriceRange(
114
+ product: Product,
115
+ fallbackCurrency: string | undefined
116
+ ): { min: number; max: number; currency: string | undefined } | null {
117
+ if (product.displayPriceMin && product.displayPriceMax && product.displayCurrency) {
118
+ const min = parseFloat(product.displayPriceMin);
119
+ const max = parseFloat(product.displayPriceMax);
120
+ if (!Number.isNaN(min) && !Number.isNaN(max)) {
121
+ return { min, max, currency: product.displayCurrency };
122
+ }
123
+ }
124
+
125
+ let min: number;
126
+ let max: number;
127
+ if (product.priceMin && product.priceMax) {
128
+ min = parseFloat(product.priceMin);
129
+ max = parseFloat(product.priceMax);
130
+ } else {
131
+ const variants = product.variants ?? [];
132
+ if (variants.length === 0) return null;
133
+ const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
134
+ min = Math.min(...prices);
135
+ max = Math.max(...prices);
136
+ }
137
+ if (Number.isNaN(min) || Number.isNaN(max)) return null;
138
+ return { min, max, currency: fallbackCurrency };
139
+ }
@@ -0,0 +1,112 @@
1
+ import { cache } from 'react';
2
+ import { cookies, headers } from 'next/headers';
3
+ import type { PublicRegion } from 'brainerce';
4
+ import { getServerClient } from '@/core/lib/brainerce.server';
5
+ import { COUNTRY_HEADERS, REGION_COOKIE, normalizeCountry, pickRegion } from '@/core/lib/region';
6
+
7
+ /**
8
+ * SERVER-ONLY half of region resolution. Everything here reaches
9
+ * `next/headers`; importing it from a `'use client'` file fails the build.
10
+ * Import it in Server Components, `generateMetadata`, and route handlers.
11
+ *
12
+ * Resolution order, highest first:
13
+ * 1. the `brainerce_region` cookie (the shopper used the switcher)
14
+ * 2. the edge geo header for this host
15
+ * 3. the store's default region
16
+ *
17
+ * The cookie wins because an IP guess is wrong for travellers, expats, VPNs
18
+ * and anyone on a corporate egress, and it is the shopper who knows.
19
+ */
20
+
21
+ /**
22
+ * The buyer country as the edge saw it, or null.
23
+ *
24
+ * Read straight off the incoming request rather than forwarded by middleware:
25
+ * `headers()` in a Server Component already sees the original request headers,
26
+ * including through the locale rewrite, so a middleware hop would be a second
27
+ * copy of the same value that can drift out of step with this one.
28
+ *
29
+ * ⛔ Next 15 removed `request.geo`. The country lives in a header now, and
30
+ * which header depends on the host, so we try each in turn.
31
+ */
32
+ export const getBuyerCountry = cache(async (): Promise<string | null> => {
33
+ try {
34
+ const h = await headers();
35
+ for (const name of COUNTRY_HEADERS) {
36
+ const code = normalizeCountry(h.get(name));
37
+ if (code) return code;
38
+ }
39
+ } catch {
40
+ // headers() throws outside a request scope (a build-time prerender).
41
+ // No country is a valid answer: the default region takes over.
42
+ }
43
+ return null;
44
+ });
45
+
46
+ /**
47
+ * The store's active regions, default first. `[]` on any failure.
48
+ *
49
+ * ⛔ Never throws. A brand-new store is empty, the channel may not be live
50
+ * yet, and the backend can hiccup. An empty list makes every caller below a
51
+ * no-op and the storefront behaves exactly as it did before regions existed,
52
+ * which is the only acceptable failure mode for a call this widely used.
53
+ */
54
+ export const fetchRegions = cache(async (): Promise<PublicRegion[]> => {
55
+ try {
56
+ const client = await getServerClient();
57
+ const result = await client.getStoreRegions();
58
+ return Array.isArray(result?.data) ? result.data : [];
59
+ } catch {
60
+ return [];
61
+ }
62
+ });
63
+
64
+ export interface ResolvedRegion {
65
+ /** Every active region. Empty when the store has none or the call failed. */
66
+ regions: PublicRegion[];
67
+ /** The region to price this request in, or null when there are none. */
68
+ region: PublicRegion | null;
69
+ /** Buyer country from the edge, or null. Reused by the tax estimate. */
70
+ country: string | null;
71
+ }
72
+
73
+ /**
74
+ * Resolve the region for this request. `cache()`-wrapped, so the layout, a
75
+ * page and `generateMetadata` share one round trip.
76
+ *
77
+ * Returns `{ regions: [], region: null, country }` for a store with no
78
+ * regions. Callers spread `region?.id` into their SDK calls, so that case
79
+ * sends no `regionId` at all, which is the pre-regions behaviour.
80
+ */
81
+ export const resolveRegion = cache(async (): Promise<ResolvedRegion> => {
82
+ const [regions, country] = await Promise.all([fetchRegions(), getBuyerCountry()]);
83
+ if (regions.length === 0) return { regions, region: null, country };
84
+
85
+ // 1. The shopper's explicit choice, if it still names a live region. A
86
+ // stale id (the merchant deleted or deactivated that region) falls
87
+ // through to the geo guess rather than pricing in a region that is gone.
88
+ let chosen: PublicRegion | null = null;
89
+ try {
90
+ const cookieValue = (await cookies()).get(REGION_COOKIE)?.value;
91
+ if (cookieValue) chosen = regions.find((r) => r.id === cookieValue) ?? null;
92
+ } catch {
93
+ // Same as headers(): outside a request scope there is no cookie jar.
94
+ }
95
+
96
+ // 2/3. Geo header, then the default region.
97
+ return { regions, region: chosen ?? pickRegion(regions, country), country };
98
+ });
99
+
100
+ /**
101
+ * Just the id, for spreading into an SDK call:
102
+ *
103
+ * const regionId = await getRegionId();
104
+ * await client.getProducts({ limit: 24, ...(regionId ? { regionId } : {}) });
105
+ *
106
+ * ⛔ Pass it to the product reads AND to `createCheckout`. Product reads only
107
+ * and the shopper is shown one price and charged another, which is worse than
108
+ * not having built regions at all.
109
+ */
110
+ export async function getRegionId(): Promise<string | undefined> {
111
+ return (await resolveRegion()).region?.id;
112
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * CLIENT-SAFE half of region resolution. Nothing here may reach
3
+ * `next/headers`, because `<StoreProvider>` and the region switcher import it.
4
+ * The server half lives in `region.server.ts`.
5
+ *
6
+ * ## Why regions exist even when the merchant has never heard of them
7
+ *
8
+ * A store can split the world into regions, each with its own currency and
9
+ * payment providers. Every other optional feature in this project auto-hides
10
+ * when it is off, so forgetting one costs nothing until the merchant switches
11
+ * it on. Regions are the exception: a storefront that never sends `regionId`
12
+ * does not hide anything, it silently renders the DEFAULT region's prices to
13
+ * every shopper on earth. The page looks perfect and the prices are wrong.
14
+ *
15
+ * A single-region store is a complete no-op. `getStoreRegions()` returns one
16
+ * row, `regions.length <= 1`, the switcher renders nothing, and the `regionId`
17
+ * we attach to reads resolves to the default the backend would have used
18
+ * anyway. There is no empty dropdown and no extra chrome. The whole point is
19
+ * that the day a merchant adds a second region, this storefront is already
20
+ * correct instead of quietly overcharging half its customers.
21
+ */
22
+
23
+ import type { PublicRegion } from 'brainerce';
24
+
25
+ /**
26
+ * Manual region choice, set by the switcher. A cookie rather than React state
27
+ * on purpose: the server renders product pages and has to pick the same region
28
+ * the shopper chose, or SSR paints the geo-guessed prices and the client flips
29
+ * them a moment later.
30
+ *
31
+ * Not `httpOnly`, because the switcher is a client component and has to write
32
+ * it. It holds a region id, which is public data already present in the page.
33
+ */
34
+ export const REGION_COOKIE = 'brainerce_region';
35
+
36
+ /** One year. A region preference is not session state; it is where you live. */
37
+ const REGION_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
38
+
39
+ /**
40
+ * Request headers that carry a buyer country, in priority order.
41
+ *
42
+ * Brainerce cannot derive this itself: the storefront SERVER is what reaches
43
+ * the Brainerce API, so the backend sees this machine's IP, not the shopper's.
44
+ * The country has to be extracted at the edge and forwarded, which is exactly
45
+ * what these headers are.
46
+ *
47
+ * `x-country` is last and is the manual escape hatch: a host with no geo
48
+ * header of its own can be made to set it in a proxy rule.
49
+ */
50
+ export const COUNTRY_HEADERS = [
51
+ 'cf-ipcountry', // Cloudflare
52
+ 'x-vercel-ip-country', // Vercel
53
+ 'client-geo-country', // Fastly
54
+ 'x-country', // manual / self-hosted proxies
55
+ ] as const;
56
+
57
+ /**
58
+ * Accept only a plausible ISO-3166-1 alpha-2 code. Cloudflare sends `XX` for
59
+ * unknown clients and `T1` for Tor, and both would otherwise be forwarded to
60
+ * the API as if they were countries.
61
+ */
62
+ export function normalizeCountry(raw: string | null | undefined): string | null {
63
+ if (!raw) return null;
64
+ const code = raw.trim().toUpperCase();
65
+ if (!/^[A-Z]{2}$/.test(code)) return null;
66
+ if (code === 'XX' || code === 'T1') return null;
67
+ return code;
68
+ }
69
+
70
+ /**
71
+ * Pick the region for a country from an already-fetched list. Mirrors the
72
+ * SDK's `client.detectRegion(country, regions)` without needing a client
73
+ * instance, and keeps the return type narrowed to `PublicRegion`.
74
+ *
75
+ * Order: explicit country match → the default region → the first row. The last
76
+ * two fallbacks are what make a misconfigured store render prices instead of
77
+ * nothing.
78
+ */
79
+ export function pickRegion(
80
+ regions: PublicRegion[],
81
+ country: string | null | undefined
82
+ ): PublicRegion | null {
83
+ if (regions.length === 0) return null;
84
+ const code = normalizeCountry(country);
85
+ if (code) {
86
+ const matched = regions.find((r) => r.countries.includes(code));
87
+ if (matched) return matched;
88
+ }
89
+ return regions.find((r) => r.isDefault) ?? regions[0] ?? null;
90
+ }
91
+
92
+ /** Read the manual region choice in the browser. Returns null during SSR. */
93
+ export function readRegionCookie(): string | null {
94
+ if (typeof document === 'undefined') return null;
95
+ const match = document.cookie.match(
96
+ new RegExp(`(?:^|;\\s*)${REGION_COOKIE}=([^;]*)`)
97
+ );
98
+ return match ? decodeURIComponent(match[1]) : null;
99
+ }
100
+
101
+ /**
102
+ * Persist the manual region choice. `SameSite=Lax` so a link from an email or
103
+ * an ad still arrives with the shopper's chosen currency; `Secure` only off
104
+ * localhost, where there is no https to attach it to.
105
+ */
106
+ export function writeRegionCookie(regionId: string): void {
107
+ if (typeof document === 'undefined') return;
108
+ const secure = typeof location !== 'undefined' && location.protocol === 'https:';
109
+ document.cookie =
110
+ `${REGION_COOKIE}=${encodeURIComponent(regionId)}; path=/; max-age=${REGION_COOKIE_MAX_AGE}; SameSite=Lax` +
111
+ (secure ? '; Secure' : '');
112
+ }
@@ -49,6 +49,23 @@ export interface PublicStoreInfo {
49
49
  * Center). Both public by protocol design.
50
50
  */
51
51
  seo?: StoreInfo['seo'];
52
+ /**
53
+ * Whether this sales channel offers a back-in-stock alert on a sold-out
54
+ * product. Read by `/products/[slug]` and passed into
55
+ * `<ProductClientSection stockAlertsEnabled={...}>`.
56
+ *
57
+ * ⛔ It was READ before it was PICKED, so it arrived as `undefined` on every
58
+ * storefront and the page also failed `tsc --noEmit` out of the box. If you
59
+ * add a consumer of a StoreInfo field, add it to the projection below in the
60
+ * same change or it silently resolves to undefined.
61
+ */
62
+ stockAlertsEnabled?: boolean;
63
+ /**
64
+ * IANA timezone the store operates in (e.g. "Asia/Jerusalem"). The checkout
65
+ * date/time custom fields evaluate availability against THIS, never the
66
+ * shopper's browser timezone. Same story as above: read but not picked.
67
+ */
68
+ timezone?: string;
52
69
  }
53
70
 
54
71
  /**
@@ -74,5 +91,7 @@ export function pickPublicStoreInfo(raw: StoreInfo): PublicStoreInfo {
74
91
  shipping: raw.shipping,
75
92
  tracking: raw.tracking,
76
93
  seo: raw.seo,
94
+ stockAlertsEnabled: raw.stockAlertsEnabled,
95
+ timezone: raw.timezone,
77
96
  };
78
97
  }