@cartbase/storefront 0.10.0 → 0.11.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.11.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": {
@@ -190,6 +190,7 @@
190
190
  "./common/cart-button": "./src/common/cart-button.tsx",
191
191
  "./common/cart-button-client": "./src/common/cart-button-client.tsx",
192
192
  "./common/delete-button": "./src/common/delete-button.tsx",
193
+ "./common/country-flag": "./src/common/country-flag.tsx",
193
194
  "./common/country-select": "./src/common/country-select.tsx",
194
195
  "./common/language-select": "./src/common/language-select.tsx",
195
196
  "./common/skeleton": "./src/common/skeleton.tsx",
@@ -200,19 +201,20 @@
200
201
  "./locales/bg": "./src/locales/bg.ts"
201
202
  },
202
203
  "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
204
  "@radix-ui/react-accordion": "^1.2.12",
207
205
  "@radix-ui/react-collapsible": "^1.1.12",
208
206
  "@radix-ui/react-dialog": "^1.1.15",
207
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
209
208
  "@radix-ui/react-label": "^2.1.8",
210
209
  "@radix-ui/react-popover": "^1.1.15",
211
210
  "@radix-ui/react-select": "^2.2.6",
212
211
  "@radix-ui/react-slot": "^1.2.4",
213
212
  "@radix-ui/react-tabs": "^1.1.13",
213
+ "@stripe/react-stripe-js": "^6.1.0",
214
+ "@stripe/stripe-js": "^9.1.0",
214
215
  "class-variance-authority": "^0.7.1",
215
216
  "clsx": "^2.1.1",
217
+ "flag-icons": "^7.5.0",
216
218
  "lucide-react": "^1.8.0",
217
219
  "tailwind-merge": "^3.5.0"
218
220
  },
@@ -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,7 @@ 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"
7
8
  import { Field } from "../primitives/field"
8
9
  import { SelectField } from "../primitives/select-field"
9
10
  import { AddressSelect } from "./address-select"
@@ -127,6 +128,11 @@ export function CheckoutAddressForm({
127
128
  value={formData["shipping_address.country_code"] ?? ""}
128
129
  onChange={onChange}
129
130
  required
131
+ // The chosen country's flag, inside the control. It stays a
132
+ // NATIVE select on purpose: a custom listbox would draw a flag
133
+ // per row but lose `autocomplete="country"` and the phone's own
134
+ // picker, and a checkout does not trade autofill for decoration.
135
+ leading={<CountryFlag code={formData["shipping_address.country_code"]} />}
130
136
  >
131
137
  <option value="" disabled />
132
138
  {countries.map((c) => (
@@ -10,6 +10,7 @@ import {
10
10
  } from "react"
11
11
 
12
12
  import type { StorefrontClient } from "../api/http"
13
+ import { listCountries, type StoreCountry } from "../api/regions"
13
14
  import {
14
15
  completeCart,
15
16
  updateCart,
@@ -362,17 +363,54 @@ export function useCheckoutOrchestration({
362
363
  : true
363
364
  )
364
365
 
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.
366
+ /**
367
+ * The store's own country list, fetched when the app did not pass one.
368
+ *
369
+ * Until 2026-09-13 an app that passed nothing got a ONE-ENTRY list built
370
+ * from `countryCode`, and the address form renders a read-only box for a
371
+ * single entry, so every scaffolded store shipped a checkout offering
372
+ * exactly one country that the shopper could not change. The list is the
373
+ * platform's to answer (`GET /api/store/countries`: the store's Markets
374
+ * when it has declared any, the whole catalogue when it has not), so the
375
+ * hook asks instead of guessing.
376
+ *
377
+ * A failure here must never block a checkout: the `countryCode` fallback
378
+ * below still stands, and the error is reported through `logError`.
379
+ */
380
+ const [storeCountries, setStoreCountries] = useState<StoreCountry[] | null>(null)
381
+ const appSuppliedCountries = !!(countries && countries.length)
382
+
383
+ useEffect(() => {
384
+ if (appSuppliedCountries) return
385
+ let alive = true
386
+ listCountries(client)
387
+ .then((res) => {
388
+ if (alive) setStoreCountries(res.countries)
389
+ })
390
+ .catch((e: unknown) => {
391
+ logError(
392
+ "country_list_failed",
393
+ e instanceof Error ? e.message : "could not load the country list"
394
+ )
395
+ })
396
+ return () => {
397
+ alive = false
398
+ }
399
+ }, [client, appSuppliedCountries, logError])
400
+
401
+ // The app's own list wins, then the store's, then the single-country
402
+ // fallback. This is the authority for both the select options and the
403
+ // saved-address region filter.
368
404
  const regionCountries = useMemo(
369
405
  () =>
370
406
  countries && countries.length
371
407
  ? countries
372
- : countryCode
373
- ? [{ iso_2: countryCode, display_name: "" }]
374
- : [],
375
- [countries, countryCode]
408
+ : storeCountries && storeCountries.length
409
+ ? storeCountries
410
+ : countryCode
411
+ ? [{ iso_2: countryCode, display_name: "" }]
412
+ : [],
413
+ [countries, storeCountries, countryCode]
376
414
  )
377
415
 
378
416
  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,
@@ -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"