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
|
@@ -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
|
-
/**
|
|
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
|
}
|