@cartbase/storefront 0.10.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 +7 -4
- package/src/api/regions.ts +64 -5
- package/src/checkout/address-form.tsx +20 -7
- package/src/checkout/context.tsx +19 -2
- package/src/checkout/use-checkout-orchestration.ts +49 -7
- package/src/common/country-flag.tsx +52 -0
- package/src/common/index.ts +1 -0
- package/src/lib/country-name.ts +59 -0
- package/src/order/context.tsx +9 -3
- package/src/primitives/field.tsx +17 -2
- package/src/primitives/select-field.tsx +19 -3
- 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",
|
|
@@ -190,6 +191,7 @@
|
|
|
190
191
|
"./common/cart-button": "./src/common/cart-button.tsx",
|
|
191
192
|
"./common/cart-button-client": "./src/common/cart-button-client.tsx",
|
|
192
193
|
"./common/delete-button": "./src/common/delete-button.tsx",
|
|
194
|
+
"./common/country-flag": "./src/common/country-flag.tsx",
|
|
193
195
|
"./common/country-select": "./src/common/country-select.tsx",
|
|
194
196
|
"./common/language-select": "./src/common/language-select.tsx",
|
|
195
197
|
"./common/skeleton": "./src/common/skeleton.tsx",
|
|
@@ -200,19 +202,20 @@
|
|
|
200
202
|
"./locales/bg": "./src/locales/bg.ts"
|
|
201
203
|
},
|
|
202
204
|
"dependencies": {
|
|
203
|
-
"@stripe/react-stripe-js": "^6.1.0",
|
|
204
|
-
"@stripe/stripe-js": "^9.1.0",
|
|
205
|
-
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
206
205
|
"@radix-ui/react-accordion": "^1.2.12",
|
|
207
206
|
"@radix-ui/react-collapsible": "^1.1.12",
|
|
208
207
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
208
|
+
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
209
209
|
"@radix-ui/react-label": "^2.1.8",
|
|
210
210
|
"@radix-ui/react-popover": "^1.1.15",
|
|
211
211
|
"@radix-ui/react-select": "^2.2.6",
|
|
212
212
|
"@radix-ui/react-slot": "^1.2.4",
|
|
213
213
|
"@radix-ui/react-tabs": "^1.1.13",
|
|
214
|
+
"@stripe/react-stripe-js": "^6.1.0",
|
|
215
|
+
"@stripe/stripe-js": "^9.1.0",
|
|
214
216
|
"class-variance-authority": "^0.7.1",
|
|
215
217
|
"clsx": "^2.1.1",
|
|
218
|
+
"flag-icons": "^7.5.0",
|
|
216
219
|
"lucide-react": "^1.8.0",
|
|
217
220
|
"tailwind-merge": "^3.5.0"
|
|
218
221
|
},
|
package/src/api/regions.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @cartbase/storefront/api/regions — regions, currencies, locales.
|
|
2
|
+
* @cartbase/storefront/api/regions — regions, countries, currencies, locales.
|
|
3
3
|
*
|
|
4
4
|
* Catalog-context primitives every storefront resolves at boot: which
|
|
5
|
-
* regions exist (→ `region_id` for the pricing context), which
|
|
6
|
-
*
|
|
7
|
-
* reads (`x-client-id` scoping via RLS). Docs:
|
|
5
|
+
* regions exist (→ `region_id` for the pricing context), which countries the
|
|
6
|
+
* store sells to, which currencies it has enabled, which locales it serves.
|
|
7
|
+
* All endpoints are anon reads (`x-client-id` scoping via RLS). Docs:
|
|
8
|
+
* docs/storefront/regions.md.
|
|
8
9
|
*/
|
|
9
10
|
import type { StorefrontClient } from "./http"
|
|
10
11
|
import type { IsoDateString, ListEnvelope, PaginationQuery } from "./types"
|
|
@@ -12,7 +13,9 @@ import type { IsoDateString, ListEnvelope, PaginationQuery } from "./types"
|
|
|
12
13
|
/**
|
|
13
14
|
* A store region (`barter_commerce.regions` row). NOTE: Cartbase regions carry
|
|
14
15
|
* NO embedded `countries` array on the store surface (divergence from
|
|
15
|
-
* Medusa's Store API — tax scoping is by `tax_regions` server-side).
|
|
16
|
+
* Medusa's Store API — tax scoping is by `tax_regions` server-side). The
|
|
17
|
+
* countries are their own call, `listCountries`, and each row carries the
|
|
18
|
+
* `region_id` it belongs to.
|
|
16
19
|
*/
|
|
17
20
|
export interface StoreRegion {
|
|
18
21
|
id: string
|
|
@@ -38,6 +41,35 @@ export interface StoreCurrency {
|
|
|
38
41
|
updated_at: IsoDateString
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/**
|
|
45
|
+
* A country a shopper may choose (`barter_commerce.countries` row, the store's
|
|
46
|
+
* own tenant catalogue).
|
|
47
|
+
*/
|
|
48
|
+
export interface StoreCountry {
|
|
49
|
+
/** Lowercase ISO-3166 alpha-2, e.g. "bg" — the value a cart address takes. */
|
|
50
|
+
iso_2: string
|
|
51
|
+
/** What a shopper reads, e.g. "Bulgaria". The code is never shown as copy. */
|
|
52
|
+
display_name: string
|
|
53
|
+
/** The catalogue's uppercase form, e.g. "BULGARIA". */
|
|
54
|
+
name: string
|
|
55
|
+
/** The region this country belongs to, null when the store has not placed it. */
|
|
56
|
+
region_id: string | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ListCountriesQuery extends PaginationQuery {
|
|
60
|
+
/** Case-insensitive substring match on the displayed name. */
|
|
61
|
+
q?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CountryListResponse extends ListEnvelope {
|
|
65
|
+
countries: StoreCountry[]
|
|
66
|
+
/**
|
|
67
|
+
* True when this list IS the store's Markets declaration; false when the
|
|
68
|
+
* store has declared nothing and this is the whole ISO catalogue.
|
|
69
|
+
*/
|
|
70
|
+
restricted: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
41
73
|
export interface ListRegionsQuery extends PaginationQuery {
|
|
42
74
|
/** Case-insensitive substring match on region name. */
|
|
43
75
|
q?: string
|
|
@@ -98,6 +130,33 @@ export async function retrieveRegion(
|
|
|
98
130
|
return client.get(`/api/store/regions/${encodeURIComponent(regionId)}`)
|
|
99
131
|
}
|
|
100
132
|
|
|
133
|
+
/**
|
|
134
|
+
* List the countries a shopper may choose at checkout, ordered by the name
|
|
135
|
+
* they read.
|
|
136
|
+
*
|
|
137
|
+
* THE RULE IS THE PLATFORM'S, NOT THE STOREFRONT'S: the store's own Markets
|
|
138
|
+
* decide where it sells, and a store that has decided nothing yet sells to
|
|
139
|
+
* the whole world. So this answers with the countries the store's regions
|
|
140
|
+
* declare, and with the whole 250-entry catalogue when it declares none;
|
|
141
|
+
* `restricted` says which you got. A storefront therefore never hardcodes a
|
|
142
|
+
* country list, and never has to invent a fallback of its own.
|
|
143
|
+
*
|
|
144
|
+
* `useCheckoutOrchestration` calls this for you when you do not pass it a
|
|
145
|
+
* `countries` list, so a new store offers every country with no wiring at
|
|
146
|
+
* all. Pass your own list only to override the store's answer.
|
|
147
|
+
*
|
|
148
|
+
* Auth: anon (`x-client-id`). Errors: 400 `missing_client_id`,
|
|
149
|
+
* 400 `validation_failed`. Settings: Admin → Settings → Markets → Regions,
|
|
150
|
+
* where a region owns its countries; `limit` 1–300 (default 300, so one
|
|
151
|
+
* call carries the entire catalogue).
|
|
152
|
+
*/
|
|
153
|
+
export async function listCountries(
|
|
154
|
+
client: StorefrontClient,
|
|
155
|
+
query?: ListCountriesQuery
|
|
156
|
+
): Promise<CountryListResponse> {
|
|
157
|
+
return client.get("/api/store/countries", { query: { ...query } })
|
|
158
|
+
}
|
|
159
|
+
|
|
101
160
|
/**
|
|
102
161
|
* List the currencies ENABLED on this store (the `store_currencies` link
|
|
103
162
|
* filters the shared currency catalog). A store with none enabled returns an
|
|
@@ -4,6 +4,8 @@ import type { ChangeEvent } from "react"
|
|
|
4
4
|
|
|
5
5
|
import type { CartAddress } from "../api/carts"
|
|
6
6
|
import type { CustomerAddress } from "../api/customers"
|
|
7
|
+
import { CountryFlag } from "../common/country-flag"
|
|
8
|
+
import { countryName } from "../lib/country-name"
|
|
7
9
|
import { Field } from "../primitives/field"
|
|
8
10
|
import { SelectField } from "../primitives/select-field"
|
|
9
11
|
import { AddressSelect } from "./address-select"
|
|
@@ -104,20 +106,26 @@ export function CheckoutAddressForm({
|
|
|
104
106
|
|
|
105
107
|
{!hideCountry && (
|
|
106
108
|
countries.length === 1 ? (
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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.
|
|
111
118
|
<Field
|
|
112
119
|
label={labels.country}
|
|
113
120
|
name="shipping_address.country_code_display"
|
|
114
121
|
value={
|
|
115
|
-
labels.singleCountryName
|
|
116
|
-
countries[0]?.display_name
|
|
117
|
-
|
|
122
|
+
labels.singleCountryName ||
|
|
123
|
+
countries[0]?.display_name ||
|
|
124
|
+
countryName(countries[0]?.iso_2)
|
|
118
125
|
}
|
|
119
126
|
onChange={() => {}}
|
|
120
127
|
readOnly
|
|
128
|
+
leading={<CountryFlag code={countries[0]?.iso_2} />}
|
|
121
129
|
/>
|
|
122
130
|
) : (
|
|
123
131
|
<SelectField
|
|
@@ -127,6 +135,11 @@ export function CheckoutAddressForm({
|
|
|
127
135
|
value={formData["shipping_address.country_code"] ?? ""}
|
|
128
136
|
onChange={onChange}
|
|
129
137
|
required
|
|
138
|
+
// The chosen country's flag, inside the control. It stays a
|
|
139
|
+
// NATIVE select on purpose: a custom listbox would draw a flag
|
|
140
|
+
// per row but lose `autocomplete="country"` and the phone's own
|
|
141
|
+
// picker, and a checkout does not trade autofill for decoration.
|
|
142
|
+
leading={<CountryFlag code={formData["shipping_address.country_code"]} />}
|
|
130
143
|
>
|
|
131
144
|
<option value="" disabled />
|
|
132
145
|
{countries.map((c) => (
|
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,8 @@ import {
|
|
|
10
10
|
} from "react"
|
|
11
11
|
|
|
12
12
|
import type { StorefrontClient } from "../api/http"
|
|
13
|
+
import { countryName } from "../lib/country-name"
|
|
14
|
+
import { listCountries, type StoreCountry } from "../api/regions"
|
|
13
15
|
import {
|
|
14
16
|
completeCart,
|
|
15
17
|
updateCart,
|
|
@@ -362,17 +364,57 @@ export function useCheckoutOrchestration({
|
|
|
362
364
|
: true
|
|
363
365
|
)
|
|
364
366
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
367
|
+
/**
|
|
368
|
+
* The store's own country list, fetched when the app did not pass one.
|
|
369
|
+
*
|
|
370
|
+
* Until 2026-09-13 an app that passed nothing got a ONE-ENTRY list built
|
|
371
|
+
* from `countryCode`, and the address form renders a read-only box for a
|
|
372
|
+
* single entry, so every scaffolded store shipped a checkout offering
|
|
373
|
+
* exactly one country that the shopper could not change. The list is the
|
|
374
|
+
* platform's to answer (`GET /api/store/countries`: the store's Markets
|
|
375
|
+
* when it has declared any, the whole catalogue when it has not), so the
|
|
376
|
+
* hook asks instead of guessing.
|
|
377
|
+
*
|
|
378
|
+
* A failure here must never block a checkout: the `countryCode` fallback
|
|
379
|
+
* below still stands, and the error is reported through `logError`.
|
|
380
|
+
*/
|
|
381
|
+
const [storeCountries, setStoreCountries] = useState<StoreCountry[] | null>(null)
|
|
382
|
+
const appSuppliedCountries = !!(countries && countries.length)
|
|
383
|
+
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
if (appSuppliedCountries) return
|
|
386
|
+
let alive = true
|
|
387
|
+
listCountries(client)
|
|
388
|
+
.then((res) => {
|
|
389
|
+
if (alive) setStoreCountries(res.countries)
|
|
390
|
+
})
|
|
391
|
+
.catch((e: unknown) => {
|
|
392
|
+
logError(
|
|
393
|
+
"country_list_failed",
|
|
394
|
+
e instanceof Error ? e.message : "could not load the country list"
|
|
395
|
+
)
|
|
396
|
+
})
|
|
397
|
+
return () => {
|
|
398
|
+
alive = false
|
|
399
|
+
}
|
|
400
|
+
}, [client, appSuppliedCountries, logError])
|
|
401
|
+
|
|
402
|
+
// The app's own list wins, then the store's, then the single-country
|
|
403
|
+
// fallback. This is the authority for both the select options and the
|
|
404
|
+
// saved-address region filter.
|
|
368
405
|
const regionCountries = useMemo(
|
|
369
406
|
() =>
|
|
370
407
|
countries && countries.length
|
|
371
408
|
? countries
|
|
372
|
-
:
|
|
373
|
-
?
|
|
374
|
-
:
|
|
375
|
-
|
|
409
|
+
: storeCountries && storeCountries.length
|
|
410
|
+
? storeCountries
|
|
411
|
+
: countryCode
|
|
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) }]
|
|
416
|
+
: [],
|
|
417
|
+
[countries, storeCountries, countryCode]
|
|
376
418
|
)
|
|
377
419
|
|
|
378
420
|
const countriesInRegion = useMemo(
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import "flag-icons/css/flag-icons.min.css"
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A country's flag, from its ISO-3166 alpha-2 code.
|
|
7
|
+
*
|
|
8
|
+
* WHY A SPRITE AND NOT AN EMOJI: the emoji flag (🇧🇬) costs nothing and is
|
|
9
|
+
* the obvious choice, but Windows ships no flag glyphs in Segoe UI Emoji, so
|
|
10
|
+
* a Windows shopper reads the two letters "BG" instead of a flag. `flag-icons`
|
|
11
|
+
* is SVG under a CSS class, so it renders identically on every platform. It is
|
|
12
|
+
* also the same sprite the Cartbase admin draws its flags with, so a merchant
|
|
13
|
+
* sees one country treatment across the admin and their storefront.
|
|
14
|
+
*
|
|
15
|
+
* The aspect ratio is the sprite's own 4:3, sized in `em` so a flag matches
|
|
16
|
+
* the text it sits beside at any font size.
|
|
17
|
+
*
|
|
18
|
+
* Decorative by default: a flag never carries meaning a shopper cannot get
|
|
19
|
+
* from the name next to it, so it is `aria-hidden` and screen readers read
|
|
20
|
+
* the name. Pass `title` for a tooltip when the flag stands alone.
|
|
21
|
+
*/
|
|
22
|
+
export function CountryFlag({
|
|
23
|
+
code,
|
|
24
|
+
title,
|
|
25
|
+
className,
|
|
26
|
+
}: {
|
|
27
|
+
/** ISO-3166 alpha-2, any case. Unknown or empty renders a neutral placeholder. */
|
|
28
|
+
code: string | null | undefined
|
|
29
|
+
title?: string
|
|
30
|
+
className?: string
|
|
31
|
+
}) {
|
|
32
|
+
const iso2 = code?.trim().toLowerCase()
|
|
33
|
+
|
|
34
|
+
// A placeholder rather than nothing: the box holds its space, so a field
|
|
35
|
+
// does not jump when a shopper picks their country.
|
|
36
|
+
if (!iso2 || !/^[a-z]{2}$/.test(iso2)) {
|
|
37
|
+
return (
|
|
38
|
+
<span
|
|
39
|
+
className={cn("inline-block w-[1.33em] h-[1em] rounded-[2px] bg-muted", className)}
|
|
40
|
+
aria-hidden
|
|
41
|
+
/>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<span
|
|
47
|
+
className={cn(`fi fi-${iso2}`, "inline-block w-[1.33em] h-[1em] rounded-[2px] shrink-0", className)}
|
|
48
|
+
title={title}
|
|
49
|
+
aria-hidden
|
|
50
|
+
/>
|
|
51
|
+
)
|
|
52
|
+
}
|
package/src/common/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export { LocalizedLink, type LocalizedLinkProps } from "./localized-link"
|
|
|
8
8
|
export { CartButton } from "./cart-button"
|
|
9
9
|
export { CartButtonClient } from "./cart-button-client"
|
|
10
10
|
export { DeleteButton, type DeleteButtonProps } from "./delete-button"
|
|
11
|
+
export { CountryFlag } from "./country-flag"
|
|
11
12
|
export { CountrySelect, type CountrySelectProps } from "./country-select"
|
|
12
13
|
export {
|
|
13
14
|
LanguageSelect,
|
|
@@ -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"
|
|
@@ -27,10 +27,19 @@ export type SelectFieldProps = React.SelectHTMLAttributes<HTMLSelectElement> & {
|
|
|
27
27
|
label: string
|
|
28
28
|
children: React.ReactNode
|
|
29
29
|
className?: string
|
|
30
|
+
/**
|
|
31
|
+
* A mark shown inside the control, before the value: a flag, a currency
|
|
32
|
+
* symbol. Decorative and click-through, so the whole field still opens the
|
|
33
|
+
* select. The value and the floating label make room for it.
|
|
34
|
+
*/
|
|
35
|
+
leading?: React.ReactNode
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export const SelectField = React.forwardRef<HTMLSelectElement, SelectFieldProps>(
|
|
33
|
-
function SelectField(
|
|
39
|
+
function SelectField(
|
|
40
|
+
{ label, required, disabled, children, className, value, leading, ...props },
|
|
41
|
+
ref
|
|
42
|
+
) {
|
|
34
43
|
const hasValue = value !== undefined && value !== null && value !== ""
|
|
35
44
|
|
|
36
45
|
return (
|
|
@@ -41,7 +50,8 @@ export const SelectField = React.forwardRef<HTMLSelectElement, SelectFieldProps>
|
|
|
41
50
|
disabled={disabled}
|
|
42
51
|
value={value}
|
|
43
52
|
className={cn(
|
|
44
|
-
"w-full h-[44px]
|
|
53
|
+
"w-full h-[44px] pt-[14px] pb-[2px] pr-9 text-sm rounded-lg border transition-colors duration-150 appearance-none outline-none",
|
|
54
|
+
leading ? "pl-10" : "px-3",
|
|
45
55
|
"focus:border-primary focus:bg-primary/5",
|
|
46
56
|
disabled
|
|
47
57
|
? "bg-muted border-border text-muted-foreground cursor-not-allowed"
|
|
@@ -51,9 +61,15 @@ export const SelectField = React.forwardRef<HTMLSelectElement, SelectFieldProps>
|
|
|
51
61
|
>
|
|
52
62
|
{children}
|
|
53
63
|
</select>
|
|
64
|
+
{leading && (
|
|
65
|
+
<span className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none flex items-center">
|
|
66
|
+
{leading}
|
|
67
|
+
</span>
|
|
68
|
+
)}
|
|
54
69
|
<span
|
|
55
70
|
className={cn(
|
|
56
|
-
"absolute pointer-events-none
|
|
71
|
+
"absolute pointer-events-none top-[14px] text-sm leading-4 origin-top-left",
|
|
72
|
+
leading ? "left-10" : "left-3",
|
|
57
73
|
"transition-transform transition-colors duration-150 ease-out",
|
|
58
74
|
hasValue ? "text-muted-foreground" : "text-muted-foreground",
|
|
59
75
|
hasValue ? "-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}>
|