@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartbase/storefront",
3
- "version": "0.11.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
- // Single-country region render a readonly field showing
109
- // the localized country name. (Library-default rendering
110
- // when `hideCountry` isn't passed.) The actual country_code
111
- // 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.
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
@@ -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,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
- ? [{ iso_2: countryCode, display_name: "" }]
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
+ }
@@ -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"
@@ -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}>