@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartbase/storefront",
3
- "version": "0.10.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
  },
@@ -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 currencies
6
- * the store has enabled, which locales it serves. All endpoints are anon
7
- * reads (`x-client-id` scoping via RLS). Docs: docs/storefront/regions.md.
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
- // Single-country region render a readonly field showing
108
- // the localized country name. (Library-default rendering
109
- // when `hideCountry` isn't passed.) The actual country_code
110
- // remains in formData so submission still carries it.
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) => (
@@ -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
- ...defaultCheckoutLabels,
80
+ ...inherited.labels,
64
81
  ...labelOverrides,
65
82
  }
66
83
  return (
67
84
  <CheckoutContext.Provider
68
85
  value={{
69
86
  labels,
70
- orderConfirmedPath: orderConfirmedPath ?? DEFAULT_CONFIRMED_PATH,
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
- // Cartbase regions have no embedded countries — the caller-supplied list
366
- // (or the countryCode fallback) is the authority for both the select
367
- // options and the saved-address region filter.
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
- : countryCode
373
- ? [{ iso_2: countryCode, display_name: "" }]
374
- : [],
375
- [countries, countryCode]
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
+ }
@@ -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
+ }
@@ -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 merged = labels
20
- ? { ...defaultOrderLabels, ...labels }
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}>
@@ -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] px-3 pt-[14px] pb-[2px] text-sm rounded-lg border",
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 left-3 top-[14px] text-sm leading-4 origin-top-left",
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({ label, required, disabled, children, className, value, ...props }, ref) {
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] px-3 pt-[14px] pb-[2px] pr-9 text-sm rounded-lg border transition-colors duration-150 appearance-none outline-none",
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 left-3 top-[14px] text-sm leading-4 origin-top-left",
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"
@@ -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 merged = labels
22
- ? { ...defaultProductLabels, ...labels }
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}>