@cartbase/storefront 0.11.0 → 0.12.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/package.json +2 -1
- package/src/checkout/address-form.tsx +14 -7
- package/src/checkout/context.tsx +19 -2
- package/src/checkout/use-checkout-orchestration.ts +5 -1
- package/src/lib/country-name.ts +59 -0
- package/src/order/context.tsx +9 -3
- package/src/primitives/field.tsx +17 -2
- package/src/products/context.tsx +9 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cartbase/storefront",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Storefront SDK + UI component library for Cartbase stores: typed API client, checkout orchestration, cart drawer, product/catalog components, tracking. Source-shipped TypeScript — add it to transpilePackages.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"./lib/sort-products": "./src/lib/sort-products.ts",
|
|
52
52
|
"./lib/price": "./src/lib/price.tsx",
|
|
53
53
|
"./lib/payment-constants": "./src/lib/payment-constants.ts",
|
|
54
|
+
"./lib/country-name": "./src/lib/country-name.ts",
|
|
54
55
|
"./lib/store-api-error": "./src/lib/store-api-error.ts",
|
|
55
56
|
"./lib/hooks/use-intersection": "./src/lib/hooks/use-intersection.ts",
|
|
56
57
|
"./lib/hooks/use-toggle-state": "./src/lib/hooks/use-toggle-state.ts",
|
|
@@ -5,6 +5,7 @@ import type { ChangeEvent } from "react"
|
|
|
5
5
|
import type { CartAddress } from "../api/carts"
|
|
6
6
|
import type { CustomerAddress } from "../api/customers"
|
|
7
7
|
import { CountryFlag } from "../common/country-flag"
|
|
8
|
+
import { countryName } from "../lib/country-name"
|
|
8
9
|
import { Field } from "../primitives/field"
|
|
9
10
|
import { SelectField } from "../primitives/select-field"
|
|
10
11
|
import { AddressSelect } from "./address-select"
|
|
@@ -105,20 +106,26 @@ export function CheckoutAddressForm({
|
|
|
105
106
|
|
|
106
107
|
{!hideCountry && (
|
|
107
108
|
countries.length === 1 ? (
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
109
|
+
// One country — a read-only field showing its name, with its
|
|
110
|
+
// flag, so a fixed country reads the same as a chosen one. The
|
|
111
|
+
// actual country_code stays in formData, so submission still
|
|
112
|
+
// carries it.
|
|
113
|
+
//
|
|
114
|
+
// The name has THREE sources and never falls through to blank
|
|
115
|
+
// (it did until 2026-09-13, which is how evoo showed an empty
|
|
116
|
+
// Country box): the store's own word for it, the catalogue's
|
|
117
|
+
// name, then the code resolved through the runtime.
|
|
112
118
|
<Field
|
|
113
119
|
label={labels.country}
|
|
114
120
|
name="shipping_address.country_code_display"
|
|
115
121
|
value={
|
|
116
|
-
labels.singleCountryName
|
|
117
|
-
countries[0]?.display_name
|
|
118
|
-
|
|
122
|
+
labels.singleCountryName ||
|
|
123
|
+
countries[0]?.display_name ||
|
|
124
|
+
countryName(countries[0]?.iso_2)
|
|
119
125
|
}
|
|
120
126
|
onChange={() => {}}
|
|
121
127
|
readOnly
|
|
128
|
+
leading={<CountryFlag code={countries[0]?.iso_2} />}
|
|
122
129
|
/>
|
|
123
130
|
) : (
|
|
124
131
|
<SelectField
|
package/src/checkout/context.tsx
CHANGED
|
@@ -50,6 +50,22 @@ export function useOrderConfirmedPath(): string {
|
|
|
50
50
|
return useContext(CheckoutContext).orderConfirmedPath
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* A NESTED PROVIDER INHERITS, it does not reset (fixed 2026-09-13).
|
|
55
|
+
*
|
|
56
|
+
* This used to merge over `defaultCheckoutLabels`, which are English, so a
|
|
57
|
+
* store that mounted a second provider purely to set `orderConfirmedPath`
|
|
58
|
+
* silently threw away its whole language. That is not a hypothetical: the
|
|
59
|
+
* reference storefront does exactly that on its checkout page, so every
|
|
60
|
+
* scaffolded store ran an English checkout no matter which locale it
|
|
61
|
+
* mounted, and the Bulgarian `singleCountryName` vanished with the rest,
|
|
62
|
+
* leaving an EMPTY country field. Alexander found it on evoo.
|
|
63
|
+
*
|
|
64
|
+
* Reading the enclosing value first makes both cases right: with no
|
|
65
|
+
* provider above, the context default IS the English baseline, so a single
|
|
66
|
+
* provider behaves exactly as before; with one above, only the keys this
|
|
67
|
+
* one names change.
|
|
68
|
+
*/
|
|
53
69
|
export function CheckoutProvider({
|
|
54
70
|
labels: labelOverrides,
|
|
55
71
|
orderConfirmedPath,
|
|
@@ -59,15 +75,16 @@ export function CheckoutProvider({
|
|
|
59
75
|
orderConfirmedPath?: string
|
|
60
76
|
children: ReactNode
|
|
61
77
|
}) {
|
|
78
|
+
const inherited = useContext(CheckoutContext)
|
|
62
79
|
const labels: CheckoutLabels = {
|
|
63
|
-
...
|
|
80
|
+
...inherited.labels,
|
|
64
81
|
...labelOverrides,
|
|
65
82
|
}
|
|
66
83
|
return (
|
|
67
84
|
<CheckoutContext.Provider
|
|
68
85
|
value={{
|
|
69
86
|
labels,
|
|
70
|
-
orderConfirmedPath: orderConfirmedPath ??
|
|
87
|
+
orderConfirmedPath: orderConfirmedPath ?? inherited.orderConfirmedPath,
|
|
71
88
|
}}
|
|
72
89
|
>
|
|
73
90
|
{children}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "react"
|
|
11
11
|
|
|
12
12
|
import type { StorefrontClient } from "../api/http"
|
|
13
|
+
import { countryName } from "../lib/country-name"
|
|
13
14
|
import { listCountries, type StoreCountry } from "../api/regions"
|
|
14
15
|
import {
|
|
15
16
|
completeCart,
|
|
@@ -408,7 +409,10 @@ export function useCheckoutOrchestration({
|
|
|
408
409
|
: storeCountries && storeCountries.length
|
|
409
410
|
? storeCountries
|
|
410
411
|
: countryCode
|
|
411
|
-
?
|
|
412
|
+
? // A fallback country still needs a NAME: this used to be an
|
|
413
|
+
// empty string, and the address form rendered a blank country
|
|
414
|
+
// field for it (evoo, 2026-09-13).
|
|
415
|
+
[{ iso_2: countryCode, display_name: countryName(countryCode) }]
|
|
412
416
|
: [],
|
|
413
417
|
[countries, storeCountries, countryCode]
|
|
414
418
|
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The name of a country, from its ISO-3166 alpha-2 code.
|
|
3
|
+
*
|
|
4
|
+
* A code is a machine string: "bg" is how a cart address stores a country,
|
|
5
|
+
* never how a shopper reads it. The catalogue from
|
|
6
|
+
* `GET /api/store/countries` carries real names, so this is for the one case
|
|
7
|
+
* where a code arrives WITHOUT one: the checkout hook's fallback when the
|
|
8
|
+
* country list cannot be loaded. It used to hand the address form
|
|
9
|
+
* `{iso_2: "bg", display_name: ""}`, and the form rendered an EMPTY country
|
|
10
|
+
* field (found on evoo, 2026-09-13).
|
|
11
|
+
*
|
|
12
|
+
* `Intl.DisplayNames` is in the runtime, so this needs no bundled table and
|
|
13
|
+
* knows every country in every language the browser does.
|
|
14
|
+
*
|
|
15
|
+
* THE LOCALE IS EXPLICIT AND DEFAULTS TO ENGLISH ON PURPOSE. Letting Intl
|
|
16
|
+
* pick the runtime default would name the country in the SERVER's language
|
|
17
|
+
* during a server render and in the BROWSER's language after hydration,
|
|
18
|
+
* which is a hydration mismatch on any store whose visitors are not in the
|
|
19
|
+
* server's locale. Pass the store's own language to translate it:
|
|
20
|
+
*
|
|
21
|
+
* const locale = useStorefrontLocale()
|
|
22
|
+
* countryName("bg", locale.code) // "България"
|
|
23
|
+
*
|
|
24
|
+
* Three behaviours verified against the runtime rather than assumed
|
|
25
|
+
* (2026-09-13): a LOWERCASE code returns itself unchanged, so the code is
|
|
26
|
+
* uppercased here; a malformed code throws `RangeError`, so it is shape
|
|
27
|
+
* checked and wrapped; and an unknown-but-well-formed code resolves to
|
|
28
|
+
* "Unknown Region" unless `fallback: "none"` is set, which is why it is set
|
|
29
|
+
* and why the uppercase code is the last resort.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const cache = new Map<string, Intl.DisplayNames>()
|
|
33
|
+
|
|
34
|
+
function namer(locale: string): Intl.DisplayNames | null {
|
|
35
|
+
const hit = cache.get(locale)
|
|
36
|
+
if (hit) return hit
|
|
37
|
+
try {
|
|
38
|
+
const made = new Intl.DisplayNames([locale], { type: "region", fallback: "none" })
|
|
39
|
+
cache.set(locale, made)
|
|
40
|
+
return made
|
|
41
|
+
} catch {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function countryName(
|
|
47
|
+
code: string | null | undefined,
|
|
48
|
+
locale: string = "en"
|
|
49
|
+
): string {
|
|
50
|
+
const iso2 = code?.trim()
|
|
51
|
+
if (!iso2 || !/^[a-z]{2}$/i.test(iso2)) return ""
|
|
52
|
+
|
|
53
|
+
const upper = iso2.toUpperCase()
|
|
54
|
+
try {
|
|
55
|
+
return namer(locale)?.of(upper) || upper
|
|
56
|
+
} catch {
|
|
57
|
+
return upper
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/order/context.tsx
CHANGED
|
@@ -9,6 +9,13 @@ import { defaultOrderLabels, type OrderLabels } from "./labels"
|
|
|
9
9
|
|
|
10
10
|
const OrderLabelsContext = createContext<OrderLabels>(defaultOrderLabels)
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* A nested provider INHERITS the one above it rather than resetting to the
|
|
14
|
+
* English defaults (fixed 2026-09-13, the same defect that made evoo's
|
|
15
|
+
* checkout English: see `checkout/context.tsx`). With no provider above,
|
|
16
|
+
* the context default IS the English baseline, so a single provider behaves
|
|
17
|
+
* exactly as before.
|
|
18
|
+
*/
|
|
12
19
|
export function OrderLabelsProvider({
|
|
13
20
|
labels,
|
|
14
21
|
children,
|
|
@@ -16,9 +23,8 @@ export function OrderLabelsProvider({
|
|
|
16
23
|
labels?: Partial<OrderLabels>
|
|
17
24
|
children: ReactNode
|
|
18
25
|
}) {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
: defaultOrderLabels
|
|
26
|
+
const inherited = useContext(OrderLabelsContext)
|
|
27
|
+
const merged = labels ? { ...inherited, ...labels } : inherited
|
|
22
28
|
|
|
23
29
|
return (
|
|
24
30
|
<OrderLabelsContext.Provider value={merged}>
|
package/src/primitives/field.tsx
CHANGED
|
@@ -39,6 +39,13 @@ export type FieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
|
|
39
39
|
* confusing it for an error.
|
|
40
40
|
*/
|
|
41
41
|
pulse?: boolean
|
|
42
|
+
/**
|
|
43
|
+
* A mark shown inside the control, before the value: a flag, a currency
|
|
44
|
+
* symbol. Decorative and click-through. The value and the floating label
|
|
45
|
+
* make room for it. Same slot name and behaviour as `SelectField`, so a
|
|
46
|
+
* country reads the same whether it is chosen or fixed.
|
|
47
|
+
*/
|
|
48
|
+
leading?: React.ReactNode
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
export const Field = React.forwardRef<HTMLInputElement, FieldProps>(
|
|
@@ -52,6 +59,7 @@ export const Field = React.forwardRef<HTMLInputElement, FieldProps>(
|
|
|
52
59
|
onFocus,
|
|
53
60
|
onBlur,
|
|
54
61
|
pulse,
|
|
62
|
+
leading,
|
|
55
63
|
...props
|
|
56
64
|
},
|
|
57
65
|
ref
|
|
@@ -94,7 +102,8 @@ export const Field = React.forwardRef<HTMLInputElement, FieldProps>(
|
|
|
94
102
|
onBlur?.(e)
|
|
95
103
|
}}
|
|
96
104
|
className={cn(
|
|
97
|
-
"w-full h-[44px]
|
|
105
|
+
"w-full h-[44px] pt-[14px] pb-[2px] text-sm rounded-lg border",
|
|
106
|
+
leading ? "pl-10 pr-3" : "px-3",
|
|
98
107
|
"transition-colors duration-300 outline-none",
|
|
99
108
|
"focus:border-primary focus:bg-primary/5",
|
|
100
109
|
disabled
|
|
@@ -109,9 +118,15 @@ export const Field = React.forwardRef<HTMLInputElement, FieldProps>(
|
|
|
109
118
|
)}
|
|
110
119
|
{...props}
|
|
111
120
|
/>
|
|
121
|
+
{leading && (
|
|
122
|
+
<span className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none flex items-center">
|
|
123
|
+
{leading}
|
|
124
|
+
</span>
|
|
125
|
+
)}
|
|
112
126
|
<span
|
|
113
127
|
className={cn(
|
|
114
|
-
"absolute pointer-events-none
|
|
128
|
+
"absolute pointer-events-none top-[14px] text-sm leading-4 origin-top-left",
|
|
129
|
+
leading ? "left-10" : "left-3",
|
|
115
130
|
"transition-transform transition-colors duration-300 ease-out",
|
|
116
131
|
showPulse ? "text-sky-600" : "text-muted-foreground",
|
|
117
132
|
isActive ? "-translate-y-2 scale-[0.77]" : "translate-y-0 scale-100"
|
package/src/products/context.tsx
CHANGED
|
@@ -11,6 +11,13 @@ import { defaultProductLabels, type ProductLabels } from "./labels"
|
|
|
11
11
|
|
|
12
12
|
const ProductLabelsContext = createContext<ProductLabels>(defaultProductLabels)
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* A nested provider INHERITS the one above it rather than resetting to the
|
|
16
|
+
* English defaults (fixed 2026-09-13, the same defect that made evoo's
|
|
17
|
+
* checkout English: see `checkout/context.tsx`). With no provider above,
|
|
18
|
+
* the context default IS the English baseline, so a single provider behaves
|
|
19
|
+
* exactly as before.
|
|
20
|
+
*/
|
|
14
21
|
export function ProductLabelsProvider({
|
|
15
22
|
labels,
|
|
16
23
|
children,
|
|
@@ -18,9 +25,8 @@ export function ProductLabelsProvider({
|
|
|
18
25
|
labels?: Partial<ProductLabels>
|
|
19
26
|
children: ReactNode
|
|
20
27
|
}) {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
: defaultProductLabels
|
|
28
|
+
const inherited = useContext(ProductLabelsContext)
|
|
29
|
+
const merged = labels ? { ...inherited, ...labels } : inherited
|
|
24
30
|
|
|
25
31
|
return (
|
|
26
32
|
<ProductLabelsContext.Provider value={merged}>
|