@cartbase/storefront 0.14.0 → 0.16.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/README.md +9 -3
- package/package.json +4 -1
- package/src/api/customers.ts +9 -13
- package/src/api/products.ts +330 -310
- package/src/cart-drawer/context.tsx +11 -1
- package/src/cart-drawer/item/variant.tsx +8 -3
- package/src/checkout/checkout-client.tsx +10 -1
- package/src/checkout/line-item-card.tsx +5 -2
- package/src/checkout/order-summary.tsx +9 -5
- package/src/common/country-select.tsx +11 -65
- package/src/common/index.ts +2 -0
- package/src/common/market-select.tsx +57 -0
- package/src/lib/variant-caption.ts +32 -0
- package/src/locales/bg.ts +3 -0
- package/src/locales/context.ts +37 -0
- package/src/locales/es.ts +3 -0
- package/src/locales/provider.tsx +17 -21
- package/src/locales/types.ts +24 -21
- package/src/order/order-completed-template.tsx +10 -4
- package/src/order/order-item.tsx +4 -4
- package/src/products/index.ts +14 -0
- package/src/products/labels.ts +7 -0
- package/src/products/product-actions-wrapper.tsx +62 -58
- package/src/products/product-actions.tsx +104 -177
- package/src/products/product-info.tsx +10 -5
- package/src/products/product-template.tsx +162 -147
- package/src/products/related-products.tsx +9 -9
- package/src/products/use-product-actions.ts +164 -0
- package/src/products/variant-url.ts +74 -0
- package/src/reviews-ui/photo-upload.tsx +2 -1
- package/src/reviews-ui/review-list.tsx +4 -2
- package/src/reviews-ui/review-widget.tsx +4 -1
- package/src/reviews-ui/review-wizard.tsx +3 -2
- package/src/store/category-template.tsx +8 -1
- package/src/store/collection-template.tsx +8 -1
- package/src/store/paginated-products.tsx +3 -2
- package/src/store/search-template.tsx +10 -4
- package/src/store/store-template.tsx +10 -4
- package/src/tracking/chatgpt-pixel.tsx +17 -10
- package/src/tracking/consent-init.tsx +62 -44
- package/src/tracking/consent.ts +58 -7
- package/src/tracking/index.ts +2 -0
- package/src/tracking/meta-pixel.tsx +27 -15
- package/src/tracking/storefront-tags.tsx +14 -7
- package/src/tracking/tiktok-pixel.tsx +15 -7
- package/src/tracking/types.ts +5 -2
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import type { StorefrontClient } from "../api/http"
|
|
25
25
|
import { productItemCount } from "../lib/cart-helpers"
|
|
26
26
|
import { defaultCartDrawerLabels, type CartDrawerLabels } from "./labels"
|
|
27
|
+
import { useLocaleArea } from "../locales/context"
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Cart drawer context — open state + cart snapshot + labels + link hrefs.
|
|
@@ -313,6 +314,11 @@ export function CartDrawerProvider({
|
|
|
313
314
|
onCartChange?: (cart: Cart) => void
|
|
314
315
|
/** Ops funnel for every failed optimistic mutation (see module JSDoc). */
|
|
315
316
|
onOptimisticError?: (failure: OptimisticCartError) => void
|
|
317
|
+
/**
|
|
318
|
+
* Overrides. The default is the language mounted by
|
|
319
|
+
* `StorefrontLocaleProvider`, English when none is mounted, so a store
|
|
320
|
+
* that mounts its pack has nothing to hand over here (2026-09-14).
|
|
321
|
+
*/
|
|
316
322
|
labels?: Partial<CartDrawerLabels>
|
|
317
323
|
hrefs?: Partial<CartDrawerHrefs>
|
|
318
324
|
children: ReactNode
|
|
@@ -536,8 +542,12 @@ export function CartDrawerProvider({
|
|
|
536
542
|
}
|
|
537
543
|
}, [client, serverCart?.id, confirm])
|
|
538
544
|
|
|
545
|
+
// The mounted language is the default (2026-09-14): a store that mounts
|
|
546
|
+
// StorefrontLocaleProvider has nothing to hand over here, and a `labels`
|
|
547
|
+
// prop overrides only the keys it names.
|
|
548
|
+
const inherited = useLocaleArea("cartDrawer", defaultCartDrawerLabels)
|
|
539
549
|
const labels: CartDrawerLabels = {
|
|
540
|
-
...
|
|
550
|
+
...inherited,
|
|
541
551
|
...labelOverrides,
|
|
542
552
|
}
|
|
543
553
|
const hrefs: CartDrawerHrefs = {
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
import { variantCaption } from "../../lib/variant-caption"
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* CartItemVariant — renders the variant description (e.g. "Size M / Red")
|
|
5
|
-
* under the product title in a cart line.
|
|
7
|
+
* under the product title in a cart line. A single-variant placeholder
|
|
8
|
+
* ("Default", "Default Title", "Default variant") renders nothing
|
|
9
|
+
* (`lib/variant-caption`).
|
|
6
10
|
*
|
|
7
11
|
* Ported from `@1click/ui/src/cart-drawer/item/variant.tsx` (v2.3.1).
|
|
8
12
|
* Cartbase data seam: the source rendered `variant.options[].value` from an
|
|
@@ -36,11 +40,12 @@ export function CartItemVariant({ variantTitle, options }: CartItemVariantProps)
|
|
|
36
40
|
)
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
|
|
43
|
+
const caption = variantCaption(variantTitle)
|
|
44
|
+
if (!caption) return null
|
|
40
45
|
|
|
41
46
|
return (
|
|
42
47
|
<div className="flex items-center gap-2 flex-wrap">
|
|
43
|
-
<span className="text-xs text-muted-foreground">{
|
|
48
|
+
<span className="text-xs text-muted-foreground">{caption}</span>
|
|
44
49
|
</div>
|
|
45
50
|
)
|
|
46
51
|
}
|
|
@@ -53,7 +53,16 @@ type CheckoutClientProps = {
|
|
|
53
53
|
customer: StoreCustomer | null
|
|
54
54
|
availableShippingMethods: StoreShippingOption[] | null
|
|
55
55
|
availablePaymentMethods: PaymentProviderLike[] | null
|
|
56
|
-
|
|
56
|
+
/**
|
|
57
|
+
* The country to PRESELECT when the cart has no shipping address yet.
|
|
58
|
+
* Optional, like the hook's own: the offer comes from the store
|
|
59
|
+
* (`GET /api/store/countries`) and a store with no preselection lets the
|
|
60
|
+
* shopper pick. This stayed `string` when the hook went optional on
|
|
61
|
+
* 2026-09-13, so the reference storefront, which omits it on purpose, did
|
|
62
|
+
* not typecheck, and `next build` refuses a store with a type error
|
|
63
|
+
* (fixed 2026-09-14).
|
|
64
|
+
*/
|
|
65
|
+
countryCode?: string
|
|
57
66
|
/** Countries for the address form (Cartbase regions embed none). */
|
|
58
67
|
countries?: Array<{ iso_2: string; display_name: string }>
|
|
59
68
|
paymentMethodFilter?: (
|
|
@@ -6,6 +6,7 @@ import type { StorefrontClient } from "../api/http"
|
|
|
6
6
|
import { updateLineItem, type Cart, type CartLineItem } from "../api/carts"
|
|
7
7
|
import { Price } from "../lib/price"
|
|
8
8
|
import { cn } from "../lib/utils"
|
|
9
|
+
import { variantCaption } from "../lib/variant-caption"
|
|
9
10
|
import { useCheckoutLabels } from "./context"
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -106,8 +107,10 @@ export function LineItemCard({
|
|
|
106
107
|
<p className="text-sm font-semibold text-foreground leading-tight truncate">
|
|
107
108
|
{item.product_title}
|
|
108
109
|
</p>
|
|
109
|
-
{item.variant_title &&
|
|
110
|
-
<p className="text-xs text-muted-foreground mt-1">
|
|
110
|
+
{variantCaption(item.variant_title) && (
|
|
111
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
112
|
+
{variantCaption(item.variant_title)}
|
|
113
|
+
</p>
|
|
111
114
|
)}
|
|
112
115
|
|
|
113
116
|
<div className="flex items-center gap-2 mt-2.5">
|
|
@@ -7,6 +7,7 @@ import { updateLineItem, type Cart, type CartLineItem } from "../api/carts"
|
|
|
7
7
|
import { Price } from "../lib/price"
|
|
8
8
|
import { isProductLine } from "../lib/cart-helpers"
|
|
9
9
|
import { cn } from "../lib/utils"
|
|
10
|
+
import { variantCaption } from "../lib/variant-caption"
|
|
10
11
|
import { useCheckoutLabels } from "./context"
|
|
11
12
|
import { DiscountSection } from "./discount-section"
|
|
12
13
|
import { GiftCardSection } from "./gift-card-section"
|
|
@@ -87,8 +88,12 @@ export function CheckoutLineItem({
|
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
// `self-start`: in the desktop column the pill is a flex item, and a flex
|
|
92
|
+
// item stretches to the column's width unless told otherwise. Without it
|
|
93
|
+
// the border ran the full width of the row with the controls huddled at
|
|
94
|
+
// the left (evoo checkout, 2026-09-14).
|
|
90
95
|
const qtyPill = (
|
|
91
|
-
<div className="inline-flex items-center rounded-[2px] border border-border bg-card">
|
|
96
|
+
<div className="inline-flex self-start items-center rounded-[2px] border border-border bg-card">
|
|
92
97
|
<button
|
|
93
98
|
type="button"
|
|
94
99
|
onClick={() => handleQty(item.quantity - 1)}
|
|
@@ -133,15 +138,14 @@ export function CheckoutLineItem({
|
|
|
133
138
|
</div>
|
|
134
139
|
)
|
|
135
140
|
|
|
141
|
+
const caption = variantCaption(item.variant_title)
|
|
136
142
|
const titleBlock = (
|
|
137
143
|
<div className="min-w-0">
|
|
138
144
|
<p className="text-[15px] font-semibold text-foreground leading-snug break-words">
|
|
139
145
|
{item.product_title}
|
|
140
146
|
</p>
|
|
141
|
-
{
|
|
142
|
-
<p className="text-xs text-muted-foreground mt-1 truncate">
|
|
143
|
-
{item.variant_title}
|
|
144
|
-
</p>
|
|
147
|
+
{caption && (
|
|
148
|
+
<p className="text-xs text-muted-foreground mt-1 truncate">{caption}</p>
|
|
145
149
|
)}
|
|
146
150
|
</div>
|
|
147
151
|
)
|
|
@@ -1,65 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*
|
|
8
|
-
* `@
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
* countries; Cartbase regions carry NO countries on the store surface
|
|
13
|
-
* (divergence documented in `api/regions` — tax scoping is by
|
|
14
|
-
* `tax_regions` server-side). The select therefore lists the REGIONS
|
|
15
|
-
* themselves (`regions.listRegions`), valued by region id.
|
|
16
|
-
* - The source called the `updateRegion(countryCode, path)` server
|
|
17
|
-
* action bound to URL `[countryCode]` routing; Cartbase region
|
|
18
|
-
* persistence is app-owned (usually `carts.updateCart(client, cartId,
|
|
19
|
-
* {region_id})` + a cookie) — so the change surfaces via `onChange`.
|
|
20
|
-
*/
|
|
21
|
-
type CountrySelectProps = {
|
|
22
|
-
regions: StoreRegion[]
|
|
23
|
-
/** Currently active region id. */
|
|
24
|
-
value?: string | null
|
|
25
|
-
/** Fired with the selected region; persist it app-side. */
|
|
26
|
-
onChange: (region: StoreRegion) => void
|
|
27
|
-
className?: string
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function CountrySelect({
|
|
31
|
-
regions,
|
|
32
|
-
value,
|
|
33
|
-
onChange,
|
|
34
|
-
className,
|
|
35
|
-
}: CountrySelectProps) {
|
|
36
|
-
const options = useMemo(
|
|
37
|
-
() =>
|
|
38
|
-
[...regions].sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")),
|
|
39
|
-
[regions]
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
43
|
-
const selected = options.find((r) => r.id === e.target.value)
|
|
44
|
-
if (selected) onChange(selected)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return (
|
|
48
|
-
<select
|
|
49
|
-
value={value || ""}
|
|
50
|
-
onChange={handleChange}
|
|
51
|
-
className={
|
|
52
|
-
className ??
|
|
53
|
-
"text-sm text-foreground bg-card border border-border rounded-lg px-3 py-2"
|
|
54
|
-
}
|
|
55
|
-
>
|
|
56
|
-
{options.map((r) => (
|
|
57
|
-
<option key={r.id} value={r.id}>
|
|
58
|
-
{r.name}
|
|
59
|
-
</option>
|
|
60
|
-
))}
|
|
61
|
-
</select>
|
|
62
|
-
)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export { type CountrySelectProps }
|
|
1
|
+
/**
|
|
2
|
+
* `CountrySelect` is now `MarketSelect` (`common/market-select`), renamed on
|
|
3
|
+
* 2026-09-14: it lists the store's MARKETS, and a component is named for what
|
|
4
|
+
* it does (store-package card, finding 6; doctrine 14b). This path and these
|
|
5
|
+
* names are kept for ONE release so an existing import keeps compiling, and
|
|
6
|
+
* go with the release after it.
|
|
7
|
+
*
|
|
8
|
+
* @deprecated import `MarketSelect` from `@cartbase/storefront/common/market-select`.
|
|
9
|
+
*/
|
|
10
|
+
export { MarketSelect as CountrySelect } from "./market-select"
|
|
11
|
+
export type { MarketSelectProps as CountrySelectProps } from "./market-select"
|
package/src/common/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ export { CartButton } from "./cart-button"
|
|
|
9
9
|
export { CartButtonClient } from "./cart-button-client"
|
|
10
10
|
export { DeleteButton, type DeleteButtonProps } from "./delete-button"
|
|
11
11
|
export { CountryFlag } from "./country-flag"
|
|
12
|
+
export { MarketSelect, type MarketSelectProps } from "./market-select"
|
|
13
|
+
/** @deprecated the old name of `MarketSelect`, kept for one release (2026-09-14). */
|
|
12
14
|
export { CountrySelect, type CountrySelectProps } from "./country-select"
|
|
13
15
|
export {
|
|
14
16
|
LanguageSelect,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react"
|
|
4
|
+
import type { StoreRegion } from "../api/regions"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE MARKET PICKER for a header or footer: the store's regions, by name.
|
|
8
|
+
*
|
|
9
|
+
* Ported from `@1click/ui/src/common/country-select.tsx` (v2.3.1) and
|
|
10
|
+
* renamed on 2026-09-14 (store-package card, finding 6). The port carried the
|
|
11
|
+
* name of a component that listed a region's countries; Cartbase regions
|
|
12
|
+
* carry no countries on the store surface, so it listed the REGIONS
|
|
13
|
+
* themselves and was named for something it did not do. A market is what a
|
|
14
|
+
* shopper picks here; a country is picked in the checkout, from
|
|
15
|
+
* `listCountries`, with `CountryFlag` beside it.
|
|
16
|
+
*
|
|
17
|
+
* Persistence is the app's: `onChange` hands over the chosen region and the
|
|
18
|
+
* app writes it where it keeps it (usually `carts.updateCart(client, cartId,
|
|
19
|
+
* { region_id })` plus a cookie).
|
|
20
|
+
*/
|
|
21
|
+
export type MarketSelectProps = {
|
|
22
|
+
regions: StoreRegion[]
|
|
23
|
+
/** The active region's id. */
|
|
24
|
+
value?: string | null
|
|
25
|
+
/** Fired with the chosen region; persist it app-side. */
|
|
26
|
+
onChange: (region: StoreRegion) => void
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function MarketSelect({ regions, value, onChange, className }: MarketSelectProps) {
|
|
31
|
+
const options = useMemo(
|
|
32
|
+
() => [...regions].sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")),
|
|
33
|
+
[regions]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
37
|
+
const selected = options.find((r) => r.id === e.target.value)
|
|
38
|
+
if (selected) onChange(selected)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<select
|
|
43
|
+
value={value || ""}
|
|
44
|
+
onChange={handleChange}
|
|
45
|
+
className={
|
|
46
|
+
className ??
|
|
47
|
+
"text-sm text-foreground bg-card border border-border rounded-lg px-3 py-2"
|
|
48
|
+
}
|
|
49
|
+
>
|
|
50
|
+
{options.map((r) => (
|
|
51
|
+
<option key={r.id} value={r.id}>
|
|
52
|
+
{r.name}
|
|
53
|
+
</option>
|
|
54
|
+
))}
|
|
55
|
+
</select>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The variant caption a shopper sees under a product title.
|
|
3
|
+
*
|
|
4
|
+
* A product with one variant has no real variant, only a placeholder name
|
|
5
|
+
* for the row that holds its price: the platform names it "Default", a
|
|
6
|
+
* Shopify import carries "Default Title", Medusa "Default variant". None of
|
|
7
|
+
* them is a fact about the product, so none is shown. Every surface that
|
|
8
|
+
* prints a variant (cart drawer, checkout summary, order page) goes through
|
|
9
|
+
* this one function; a real variant title ("Size M / Red") passes through.
|
|
10
|
+
*
|
|
11
|
+
* Found 2026-09-14 on evoo's checkout summary, where every Shopify-imported
|
|
12
|
+
* product read "Default Title" under its name while three surfaces each
|
|
13
|
+
* tested a different spelling of the placeholder.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const PLACEHOLDER_TITLES = new Set(["default", "default title", "default variant"])
|
|
17
|
+
|
|
18
|
+
/** True for an empty title or any spelling of the single-variant placeholder. */
|
|
19
|
+
export function isPlaceholderVariantTitle(
|
|
20
|
+
title: string | null | undefined
|
|
21
|
+
): boolean {
|
|
22
|
+
if (!title) return true
|
|
23
|
+
return PLACEHOLDER_TITLES.has(title.trim().toLowerCase())
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The caption to render, or null when there is nothing worth showing. */
|
|
27
|
+
export function variantCaption(title: string | null | undefined): string | null {
|
|
28
|
+
if (!title) return null
|
|
29
|
+
const trimmed = title.trim()
|
|
30
|
+
if (!trimmed || isPlaceholderVariantTitle(trimmed)) return null
|
|
31
|
+
return trimmed
|
|
32
|
+
}
|
package/src/locales/bg.ts
CHANGED
|
@@ -28,6 +28,9 @@ export const bg: StorefrontLocale = {
|
|
|
28
28
|
selectVariant: "Изберете вариант",
|
|
29
29
|
outOfStock: "Изчерпан",
|
|
30
30
|
addToCart: "Добави в кошницата",
|
|
31
|
+
quantity: "Количество",
|
|
32
|
+
decreaseQuantity: "По-малко",
|
|
33
|
+
increaseQuantity: "Повече",
|
|
31
34
|
from: "От",
|
|
32
35
|
original: "Оригинална цена:",
|
|
33
36
|
productInformation: "Информация за продукта",
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react"
|
|
4
|
+
import type { StorefrontLocale } from "./types"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE MOUNTED LANGUAGE, as a context. Null outside `StorefrontLocaleProvider`.
|
|
8
|
+
*
|
|
9
|
+
* Its default is null and not `en` on purpose: `en` gathers every area's
|
|
10
|
+
* English, and a client component that needs one area (the cart drawer, a
|
|
11
|
+
* review card) must not bundle the other five to read it. Each reader hands
|
|
12
|
+
* over its own area's English instead, through `useLocaleArea`, so a
|
|
13
|
+
* component rendered with no language mounted behaves exactly as before.
|
|
14
|
+
*/
|
|
15
|
+
export const StorefrontLocaleContext = createContext<StorefrontLocale | null>(null)
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One area of the mounted language, for a CLIENT component that renders it.
|
|
19
|
+
*
|
|
20
|
+
* const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
|
|
21
|
+
*
|
|
22
|
+
* The pack the store mounted wins over the area's English; a `labels` prop
|
|
23
|
+
* still wins over both, so a store can override one word without a pack.
|
|
24
|
+
* Since 2026-09-14 this is how the cart drawer and the reviews family are
|
|
25
|
+
* localized: mount the provider once and there is nothing left to forget.
|
|
26
|
+
*
|
|
27
|
+
* A SERVER component cannot call this, because a server component has no
|
|
28
|
+
* context. That is why the store, product and order templates take their
|
|
29
|
+
* pack as a REQUIRED prop instead (`PropLabelAreas` in ./types).
|
|
30
|
+
*/
|
|
31
|
+
export function useLocaleArea<K extends keyof StorefrontLocale>(
|
|
32
|
+
area: K,
|
|
33
|
+
english: StorefrontLocale[K]
|
|
34
|
+
): StorefrontLocale[K] {
|
|
35
|
+
const locale = useContext(StorefrontLocaleContext)
|
|
36
|
+
return locale ? locale[area] : english
|
|
37
|
+
}
|
package/src/locales/es.ts
CHANGED
|
@@ -34,6 +34,9 @@ export const es: StorefrontLocale = {
|
|
|
34
34
|
selectVariant: "Elige variante",
|
|
35
35
|
outOfStock: "Agotado",
|
|
36
36
|
addToCart: "Añadir al carrito",
|
|
37
|
+
quantity: "Cantidad",
|
|
38
|
+
decreaseQuantity: "Menos",
|
|
39
|
+
increaseQuantity: "Más",
|
|
37
40
|
from: "Desde",
|
|
38
41
|
original: "Antes:",
|
|
39
42
|
productInformation: "Información del producto",
|
package/src/locales/provider.tsx
CHANGED
|
@@ -1,35 +1,31 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useContext, type ReactNode } from "react"
|
|
4
4
|
import { CheckoutProvider } from "../checkout/context"
|
|
5
5
|
import { OrderLabelsProvider } from "../order/context"
|
|
6
6
|
import { ProductLabelsProvider } from "../products/context"
|
|
7
|
+
import { StorefrontLocaleContext } from "./context"
|
|
7
8
|
import type { StorefrontLocale } from "./types"
|
|
8
9
|
import { en } from "./en"
|
|
9
10
|
|
|
10
|
-
const StorefrontLocaleContext = createContext<StorefrontLocale>(en)
|
|
11
|
-
|
|
12
11
|
/**
|
|
13
12
|
* ONE PROVIDER FOR THE STORE'S LANGUAGE.
|
|
14
13
|
*
|
|
15
14
|
* import { es } from "@cartbase/storefront/locales/es"
|
|
16
15
|
* <StorefrontLocaleProvider locale={es}>{children}</StorefrontLocaleProvider>
|
|
17
16
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* const locale = useStorefrontLocale()
|
|
27
|
-
* <CartDrawerProvider cart={cart} labels={locale.cartDrawer}>
|
|
28
|
-
*
|
|
29
|
-
* Same for the two areas that read labels from a prop:
|
|
17
|
+
* Every CLIENT component inside it speaks the pack, and nothing else has to
|
|
18
|
+
* be wired: it mounts the three label contexts (products, checkout, order),
|
|
19
|
+
* and the cart drawer and the reviews family read the language from it
|
|
20
|
+
* directly (`useLocaleArea`, since 2026-09-14). It does not MOUNT the cart
|
|
21
|
+
* drawer, because that provider carries the cart's state, its client and its
|
|
22
|
+
* persistence callbacks, which belong where the app owns that state; the
|
|
23
|
+
* drawer only reads the language from here.
|
|
30
24
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
25
|
+
* What it cannot reach is a SERVER component, because a server component has
|
|
26
|
+
* no context. The store, product and order templates render on the server,
|
|
27
|
+
* so each page hands them their area of the pack as a REQUIRED prop:
|
|
28
|
+
* `PropLabelAreas` in ./types names them, with the mounts written out.
|
|
33
29
|
*
|
|
34
30
|
* DEFAULT IS ENGLISH. A component rendered outside this provider reads the
|
|
35
31
|
* library's own English defaults, so nothing renders blank or crashes for
|
|
@@ -54,10 +50,10 @@ export function StorefrontLocaleProvider({
|
|
|
54
50
|
}
|
|
55
51
|
|
|
56
52
|
/**
|
|
57
|
-
* The whole locale,
|
|
58
|
-
*
|
|
59
|
-
*
|
|
53
|
+
* The whole mounted locale, English when none is mounted: for the code that
|
|
54
|
+
* needs the language itself, e.g. `<html lang={locale.code}>`, and for a
|
|
55
|
+
* client shell that hands a server template its pack.
|
|
60
56
|
*/
|
|
61
57
|
export function useStorefrontLocale(): StorefrontLocale {
|
|
62
|
-
return useContext(StorefrontLocaleContext)
|
|
58
|
+
return useContext(StorefrontLocaleContext) ?? en
|
|
63
59
|
}
|
package/src/locales/types.ts
CHANGED
|
@@ -40,35 +40,38 @@ export interface StorefrontLocale {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* THE AREAS WHOSE PACK THE APP MUST HAND OVER AS A PROP
|
|
43
|
+
* THE AREAS WHOSE PACK THE APP MUST HAND OVER AS A PROP, and the prop is
|
|
44
|
+
* REQUIRED.
|
|
44
45
|
*
|
|
45
46
|
* Not a style choice and not a gap in this layer: a React SERVER component
|
|
46
47
|
* cannot read a client context, and these areas are rendered on the server.
|
|
47
48
|
* `StorefrontLocaleProvider` therefore reaches the client components and
|
|
48
|
-
* nothing else, and a server-rendered heading
|
|
49
|
-
* providers
|
|
49
|
+
* nothing else, and a server-rendered heading would stay English however
|
|
50
|
+
* many providers were mounted above it.
|
|
50
51
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
52
|
+
* Since 2026-09-14 the page-level templates of these areas REQUIRE the pack
|
|
53
|
+
* (`labels: StoreLabels`, never `labels?`), so a page that forgets it fails
|
|
54
|
+
* to compile instead of shipping silent English. That was the defect: on
|
|
55
|
+
* 2026-09-13 evoo, a Bulgarian store, ended a product page in the English
|
|
56
|
+
* "Related products / You might also want to check out these products", and
|
|
57
|
+
* its search page and order confirmation stayed English, with no error, no
|
|
58
|
+
* warning and no failing test. English is not a special case: an English
|
|
59
|
+
* store passes `en.store`. The scaffold declares `STORE_LOCALE` once in its
|
|
60
|
+
* config and every page reads its area from it.
|
|
58
61
|
*
|
|
59
62
|
* Hand the pack to each page-level template once and everything inside it
|
|
60
63
|
* follows:
|
|
61
64
|
*
|
|
62
|
-
* <
|
|
63
|
-
* <
|
|
64
|
-
* <
|
|
65
|
-
* <
|
|
66
|
-
* <
|
|
65
|
+
* <StoreTemplate labels={STORE_LOCALE.store} />
|
|
66
|
+
* <SearchTemplate labels={STORE_LOCALE.store} />
|
|
67
|
+
* <CollectionTemplate labels={STORE_LOCALE.store} />
|
|
68
|
+
* <CategoryTemplate labels={STORE_LOCALE.store} />
|
|
69
|
+
* <ProductTemplate labels={STORE_LOCALE.products} /> // + the related strip
|
|
70
|
+
* <OrderCompletedTemplate labels={STORE_LOCALE.order} />
|
|
67
71
|
*
|
|
68
|
-
* The cart drawer
|
|
69
|
-
*
|
|
72
|
+
* The cart drawer and the reviews family were listed here until 2026-09-14.
|
|
73
|
+
* They are client components, so they now read the mounted language
|
|
74
|
+
* themselves (`useLocaleArea` in ./context); their `labels` prop is an
|
|
75
|
+
* override, never a requirement.
|
|
70
76
|
*/
|
|
71
|
-
export type PropLabelAreas = Pick<
|
|
72
|
-
StorefrontLocale,
|
|
73
|
-
"products" | "store" | "order" | "reviews" | "cartDrawer"
|
|
74
|
-
>
|
|
77
|
+
export type PropLabelAreas = Pick<StorefrontLocale, "products" | "store" | "order">
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Link from "next/link"
|
|
2
2
|
import { ChevronLeft } from "lucide-react"
|
|
3
3
|
import type { StoreOrderDetail, StoreOrderItem } from "../api/orders"
|
|
4
|
-
import {
|
|
4
|
+
import type { OrderLabels } from "./labels"
|
|
5
5
|
import {
|
|
6
6
|
OrderConfirmationHeader,
|
|
7
7
|
type OrderHeaderData,
|
|
@@ -67,7 +67,14 @@ type OrderCompletedTemplateProps = {
|
|
|
67
67
|
/** The merchant method's name — wins over any provider bucket. */
|
|
68
68
|
paymentMethodName?: string | null
|
|
69
69
|
cardLast4?: string | null
|
|
70
|
-
|
|
70
|
+
/**
|
|
71
|
+
* The store's order pack, REQUIRED: this template renders on the server,
|
|
72
|
+
* where `StorefrontLocaleProvider` cannot reach, so the page passes
|
|
73
|
+
* `STORE_LOCALE.order` (`en.order` for an English store). Required since
|
|
74
|
+
* 2026-09-14 so that forgetting it fails the build instead of shipping
|
|
75
|
+
* silent English, which is how evoo's confirmation page stayed English.
|
|
76
|
+
*/
|
|
77
|
+
labels: OrderLabels
|
|
71
78
|
locale?: string
|
|
72
79
|
contactHref?: string
|
|
73
80
|
returnsHref?: string
|
|
@@ -90,14 +97,13 @@ export function OrderCompletedTemplate({
|
|
|
90
97
|
paymentProviderId,
|
|
91
98
|
paymentMethodName,
|
|
92
99
|
cardLast4,
|
|
93
|
-
labels,
|
|
100
|
+
labels: l,
|
|
94
101
|
locale,
|
|
95
102
|
contactHref,
|
|
96
103
|
returnsHref,
|
|
97
104
|
storeHref = "/store",
|
|
98
105
|
methodFeeLabel,
|
|
99
106
|
}: OrderCompletedTemplateProps) {
|
|
100
|
-
const l = { ...defaultOrderLabels, ...labels }
|
|
101
107
|
const displayItems =
|
|
102
108
|
items ?? (order.items ?? []).map(displayItemFromOrderItem)
|
|
103
109
|
|
package/src/order/order-item.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Image from "next/image"
|
|
2
2
|
import { ImageOff } from "lucide-react"
|
|
3
3
|
import { Price } from "../lib/price"
|
|
4
|
+
import { variantCaption } from "../lib/variant-caption"
|
|
4
5
|
import type { StoreOrderItem } from "../api/orders"
|
|
5
6
|
import { defaultOrderLabels, type OrderLabels } from "./labels"
|
|
6
7
|
|
|
@@ -46,7 +47,8 @@ export interface OrderDisplayItem {
|
|
|
46
47
|
/**
|
|
47
48
|
* Variant caption resolution — port of the source's `variantLabel` IIFE:
|
|
48
49
|
* option values joined with " / " when present, else the variant title
|
|
49
|
-
* unless it is
|
|
50
|
+
* unless it is a single-variant placeholder (`lib/variant-caption`, one
|
|
51
|
+
* rule for every surface).
|
|
50
52
|
*/
|
|
51
53
|
export function variantLabelFrom(input: {
|
|
52
54
|
variant_option_values?: Record<string, unknown> | null
|
|
@@ -59,9 +61,7 @@ export function variantLabelFrom(input: {
|
|
|
59
61
|
)
|
|
60
62
|
if (parts.length) return parts.map(String).join(" / ")
|
|
61
63
|
}
|
|
62
|
-
|
|
63
|
-
if (title && title !== "Default variant") return title
|
|
64
|
-
return null
|
|
64
|
+
return variantCaption(input.variant_title)
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** Pure converter: order-detail pivot row → display line. */
|
package/src/products/index.ts
CHANGED
|
@@ -26,6 +26,20 @@ export {
|
|
|
26
26
|
type PurchaseOptionsLabels,
|
|
27
27
|
} from "./purchase-options"
|
|
28
28
|
export { ImageGallery, type ImageGalleryProps } from "./image-gallery"
|
|
29
|
+
// The product page contract: the address is the state, the default is the
|
|
30
|
+
// merchant's, a switch is a browser event (variant-url + use-product-actions).
|
|
31
|
+
export {
|
|
32
|
+
VARIANT_PARAM,
|
|
33
|
+
variantParamValue,
|
|
34
|
+
findVariantByParam,
|
|
35
|
+
defaultVariant,
|
|
36
|
+
variantHref,
|
|
37
|
+
} from "./variant-url"
|
|
38
|
+
export {
|
|
39
|
+
useProductActions,
|
|
40
|
+
type UseProductActionsInput,
|
|
41
|
+
type ProductActionsState,
|
|
42
|
+
} from "./use-product-actions"
|
|
29
43
|
export {
|
|
30
44
|
ProductActions,
|
|
31
45
|
type ProductActionsProps,
|
package/src/products/labels.ts
CHANGED
|
@@ -9,6 +9,10 @@ export type ProductLabels = {
|
|
|
9
9
|
selectVariant: string
|
|
10
10
|
outOfStock: string
|
|
11
11
|
addToCart: string
|
|
12
|
+
/** The quantity stepper of the buy panel and its two controls. */
|
|
13
|
+
quantity: string
|
|
14
|
+
decreaseQuantity: string
|
|
15
|
+
increaseQuantity: string
|
|
12
16
|
from: string
|
|
13
17
|
original: string
|
|
14
18
|
productInformation: string
|
|
@@ -39,6 +43,9 @@ export const defaultProductLabels: ProductLabels = {
|
|
|
39
43
|
selectVariant: "Select variant",
|
|
40
44
|
outOfStock: "Out of stock",
|
|
41
45
|
addToCart: "Add to cart",
|
|
46
|
+
quantity: "Quantity",
|
|
47
|
+
decreaseQuantity: "Fewer",
|
|
48
|
+
increaseQuantity: "More",
|
|
42
49
|
from: "From",
|
|
43
50
|
original: "Original:",
|
|
44
51
|
productInformation: "Product Information",
|