@cartbase/storefront 0.17.0 → 0.18.1

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.
Files changed (108) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +3 -1
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +180 -180
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/products.ts +43 -1
  18. package/src/api/redirects.ts +37 -37
  19. package/src/api/regions.ts +200 -200
  20. package/src/api/reviews.ts +259 -259
  21. package/src/api/search.ts +163 -163
  22. package/src/api/store.ts +35 -35
  23. package/src/api/types.ts +91 -91
  24. package/src/cart-drawer/context.tsx +367 -168
  25. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  26. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  27. package/src/cart-drawer/gift-wrap.tsx +82 -82
  28. package/src/cart-drawer/index.ts +73 -68
  29. package/src/cart-drawer/item/index.tsx +162 -162
  30. package/src/cart-drawer/item/quantity.tsx +8 -14
  31. package/src/cart-drawer/item/upsell.tsx +110 -110
  32. package/src/cart-drawer/labels.ts +123 -123
  33. package/src/cart-drawer/mutation-queue.ts +78 -0
  34. package/src/cart-drawer/notes.tsx +131 -131
  35. package/src/cart-drawer/sticky-footer.tsx +73 -73
  36. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  37. package/src/checkout/address-error-copy.ts +117 -117
  38. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  39. package/src/checkout/compare-addresses.ts +40 -40
  40. package/src/checkout/discount-section.tsx +218 -218
  41. package/src/checkout/error-copy-codes.ts +63 -63
  42. package/src/checkout/geocode.ts +154 -154
  43. package/src/checkout/gift-card-section.tsx +224 -224
  44. package/src/checkout/index.ts +72 -72
  45. package/src/checkout/labels.ts +495 -495
  46. package/src/checkout/payment-button.tsx +372 -372
  47. package/src/checkout/payment-error-copy.ts +154 -154
  48. package/src/checkout/promotion-error-copy.ts +91 -91
  49. package/src/checkout/use-checkout-orchestration.ts +1604 -1592
  50. package/src/common/country-flag.tsx +52 -52
  51. package/src/common/country-select.tsx +11 -11
  52. package/src/common/index.ts +20 -20
  53. package/src/common/market-select.tsx +57 -57
  54. package/src/index.ts +12 -12
  55. package/src/lib/cookie-names.ts +14 -0
  56. package/src/lib/country-name.ts +59 -59
  57. package/src/lib/get-product-price.ts +133 -133
  58. package/src/lib/media-image.tsx +39 -39
  59. package/src/lib/payment-constants.ts +53 -53
  60. package/src/lib/platform.ts +13 -13
  61. package/src/lib/price.tsx +39 -39
  62. package/src/lib/store-api-error.ts +36 -36
  63. package/src/lib/variant-caption.ts +32 -32
  64. package/src/locales/bg.ts +467 -467
  65. package/src/locales/context.ts +37 -37
  66. package/src/locales/en.ts +26 -26
  67. package/src/locales/es.ts +466 -466
  68. package/src/locales/index.ts +19 -19
  69. package/src/locales/provider.tsx +59 -59
  70. package/src/locales/types.ts +77 -77
  71. package/src/order/index.ts +62 -62
  72. package/src/order/labels.ts +79 -79
  73. package/src/order/order-totals.tsx +250 -250
  74. package/src/primitives/select-field.tsx +93 -93
  75. package/src/products/image-gallery.tsx +43 -43
  76. package/src/products/index.ts +68 -59
  77. package/src/products/option-select.tsx +67 -67
  78. package/src/products/product-actions-wrapper.tsx +63 -62
  79. package/src/products/product-actions.tsx +151 -150
  80. package/src/products/product-price.tsx +69 -69
  81. package/src/products/product-promises.tsx +61 -61
  82. package/src/products/product-specs.tsx +65 -65
  83. package/src/products/product-tabs.tsx +123 -123
  84. package/src/products/product-template.tsx +163 -162
  85. package/src/products/purchase-options.tsx +130 -130
  86. package/src/products/sets.ts +110 -0
  87. package/src/products/use-product-actions.ts +199 -164
  88. package/src/products/variant-matching.ts +71 -71
  89. package/src/products/variant-url.ts +74 -74
  90. package/src/reviews-ui/index.ts +73 -73
  91. package/src/store/category-template.tsx +136 -136
  92. package/src/store/index.ts +40 -40
  93. package/src/tracking/chatgpt-pixel.tsx +99 -99
  94. package/src/tracking/consent-init.tsx +62 -62
  95. package/src/tracking/events.ts +348 -348
  96. package/src/tracking/ga4.tsx +93 -93
  97. package/src/tracking/google-ads.ts +84 -84
  98. package/src/tracking/gtm.tsx +60 -60
  99. package/src/tracking/inline-script.ts +49 -49
  100. package/src/tracking/oaiq.ts +206 -206
  101. package/src/tracking/tiktok-pixel.tsx +91 -91
  102. package/src/tracking/track-init.tsx +56 -56
  103. package/src/tracking/track-order-purchase.tsx +122 -122
  104. package/src/tracking/ttq.ts +180 -180
  105. package/src/tracking/use-tracking-config.ts +54 -54
  106. package/theme/index.css +25 -25
  107. package/theme/theme.css +150 -150
  108. package/theme/tokens.css +106 -106
@@ -1,52 +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
- }
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
+ }
@@ -1,11 +1,11 @@
1
- /**
2
- * `CountrySelect` is now `MarketSelect` (`common/market-select`), renamed on
3
- * 2026-09-14: it lists the store's MARKETS, and a component is named for what
4
- * it does (store-package card, finding 6; doctrine 14b). This path and these
5
- * names are kept for ONE release so an existing import keeps compiling, and
6
- * go with the release after it.
7
- *
8
- * @deprecated import `MarketSelect` from `@cartbase/storefront/common/market-select`.
9
- */
10
- export { MarketSelect as CountrySelect } from "./market-select"
11
- export type { MarketSelectProps as CountrySelectProps } from "./market-select"
1
+ /**
2
+ * `CountrySelect` is now `MarketSelect` (`common/market-select`), renamed on
3
+ * 2026-09-14: it lists the store's MARKETS, and a component is named for what
4
+ * it does (store-package card, finding 6; doctrine 14b). This path and these
5
+ * names are kept for ONE release so an existing import keeps compiling, and
6
+ * go with the release after it.
7
+ *
8
+ * @deprecated import `MarketSelect` from `@cartbase/storefront/common/market-select`.
9
+ */
10
+ export { MarketSelect as CountrySelect } from "./market-select"
11
+ export type { MarketSelectProps as CountrySelectProps } from "./market-select"
@@ -1,20 +1,20 @@
1
- /**
2
- * @cartbase/storefront/common — shared storefront chrome. Ported from
3
- * `@1click/ui/src/common` (v2.3.1) + `LocalizedLink` (from the
4
- * mindpages-storefront production component); Cartbase data seams per
5
- * module JSDoc.
6
- */
7
- export { LocalizedLink, type LocalizedLinkProps } from "./localized-link"
8
- export { CartButton } from "./cart-button"
9
- export { CartButtonClient } from "./cart-button-client"
10
- export { DeleteButton, type DeleteButtonProps } from "./delete-button"
11
- export { CountryFlag } from "./country-flag"
12
- export { MarketSelect, type MarketSelectProps } from "./market-select"
13
- /** @deprecated the old name of `MarketSelect`, kept for one release (2026-09-14). */
14
- export { CountrySelect, type CountrySelectProps } from "./country-select"
15
- export {
16
- LanguageSelect,
17
- localeDisplayName,
18
- type LanguageSelectProps,
19
- } from "./language-select"
20
- export { Skeleton, SkeletonProductPreview } from "./skeleton"
1
+ /**
2
+ * @cartbase/storefront/common — shared storefront chrome. Ported from
3
+ * `@1click/ui/src/common` (v2.3.1) + `LocalizedLink` (from the
4
+ * mindpages-storefront production component); Cartbase data seams per
5
+ * module JSDoc.
6
+ */
7
+ export { LocalizedLink, type LocalizedLinkProps } from "./localized-link"
8
+ export { CartButton } from "./cart-button"
9
+ export { CartButtonClient } from "./cart-button-client"
10
+ export { DeleteButton, type DeleteButtonProps } from "./delete-button"
11
+ export { CountryFlag } from "./country-flag"
12
+ export { MarketSelect, type MarketSelectProps } from "./market-select"
13
+ /** @deprecated the old name of `MarketSelect`, kept for one release (2026-09-14). */
14
+ export { CountrySelect, type CountrySelectProps } from "./country-select"
15
+ export {
16
+ LanguageSelect,
17
+ localeDisplayName,
18
+ type LanguageSelectProps,
19
+ } from "./language-select"
20
+ export { Skeleton, SkeletonProductPreview } from "./skeleton"
@@ -1,57 +1,57 @@
1
- "use client"
2
-
3
- import { useMemo } from "react"
4
- import type { StoreRegion } from "../api/regions"
5
-
6
- /**
7
- * THE MARKET PICKER for a header or footer: the store's regions, by name.
8
- *
9
- * Ported from `@1click/ui/src/common/country-select.tsx` (v2.3.1) and
10
- * renamed on 2026-09-14 (store-package card, finding 6). The port carried the
11
- * name of a component that listed a region's countries; Cartbase regions
12
- * carry no countries on the store surface, so it listed the REGIONS
13
- * themselves and was named for something it did not do. A market is what a
14
- * shopper picks here; a country is picked in the checkout, from
15
- * `listCountries`, with `CountryFlag` beside it.
16
- *
17
- * Persistence is the app's: `onChange` hands over the chosen region and the
18
- * app writes it where it keeps it (usually `carts.updateCart(client, cartId,
19
- * { region_id })` plus a cookie).
20
- */
21
- export type MarketSelectProps = {
22
- regions: StoreRegion[]
23
- /** The active region's id. */
24
- value?: string | null
25
- /** Fired with the chosen region; persist it app-side. */
26
- onChange: (region: StoreRegion) => void
27
- className?: string
28
- }
29
-
30
- export function MarketSelect({ regions, value, onChange, className }: MarketSelectProps) {
31
- const options = useMemo(
32
- () => [...regions].sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")),
33
- [regions]
34
- )
35
-
36
- const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
37
- const selected = options.find((r) => r.id === e.target.value)
38
- if (selected) onChange(selected)
39
- }
40
-
41
- return (
42
- <select
43
- value={value || ""}
44
- onChange={handleChange}
45
- className={
46
- className ??
47
- "text-sm text-foreground bg-card border border-border rounded-lg px-3 py-2"
48
- }
49
- >
50
- {options.map((r) => (
51
- <option key={r.id} value={r.id}>
52
- {r.name}
53
- </option>
54
- ))}
55
- </select>
56
- )
57
- }
1
+ "use client"
2
+
3
+ import { useMemo } from "react"
4
+ import type { StoreRegion } from "../api/regions"
5
+
6
+ /**
7
+ * THE MARKET PICKER for a header or footer: the store's regions, by name.
8
+ *
9
+ * Ported from `@1click/ui/src/common/country-select.tsx` (v2.3.1) and
10
+ * renamed on 2026-09-14 (store-package card, finding 6). The port carried the
11
+ * name of a component that listed a region's countries; Cartbase regions
12
+ * carry no countries on the store surface, so it listed the REGIONS
13
+ * themselves and was named for something it did not do. A market is what a
14
+ * shopper picks here; a country is picked in the checkout, from
15
+ * `listCountries`, with `CountryFlag` beside it.
16
+ *
17
+ * Persistence is the app's: `onChange` hands over the chosen region and the
18
+ * app writes it where it keeps it (usually `carts.updateCart(client, cartId,
19
+ * { region_id })` plus a cookie).
20
+ */
21
+ export type MarketSelectProps = {
22
+ regions: StoreRegion[]
23
+ /** The active region's id. */
24
+ value?: string | null
25
+ /** Fired with the chosen region; persist it app-side. */
26
+ onChange: (region: StoreRegion) => void
27
+ className?: string
28
+ }
29
+
30
+ export function MarketSelect({ regions, value, onChange, className }: MarketSelectProps) {
31
+ const options = useMemo(
32
+ () => [...regions].sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")),
33
+ [regions]
34
+ )
35
+
36
+ const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
37
+ const selected = options.find((r) => r.id === e.target.value)
38
+ if (selected) onChange(selected)
39
+ }
40
+
41
+ return (
42
+ <select
43
+ value={value || ""}
44
+ onChange={handleChange}
45
+ className={
46
+ className ??
47
+ "text-sm text-foreground bg-card border border-border rounded-lg px-3 py-2"
48
+ }
49
+ >
50
+ {options.map((r) => (
51
+ <option key={r.id} value={r.id}>
52
+ {r.name}
53
+ </option>
54
+ ))}
55
+ </select>
56
+ )
57
+ }
package/src/index.ts CHANGED
@@ -1,12 +1,12 @@
1
- /**
2
- * @cartbase/storefront — root barrel.
3
- *
4
- * `@cartbase/storefront/api` is the SDK layer (typed store-API client).
5
- * Component families ship under their own subpath exports (stage 3) and are
6
- * NOT re-exported here — UI imports stay per-module so tree-shaking and
7
- * client/server boundaries stay explicit, same discipline as @1click/ui.
8
- */
9
-
10
- export * from "./api/types"
11
- export { StorefrontClient } from "./api/http"
12
- export type { StorefrontClientConfig } from "./api/http"
1
+ /**
2
+ * @cartbase/storefront — root barrel.
3
+ *
4
+ * `@cartbase/storefront/api` is the SDK layer (typed store-API client).
5
+ * Component families ship under their own subpath exports (stage 3) and are
6
+ * NOT re-exported here — UI imports stay per-module so tree-shaking and
7
+ * client/server boundaries stay explicit, same discipline as @1click/ui.
8
+ */
9
+
10
+ export * from "./api/types"
11
+ export { StorefrontClient } from "./api/http"
12
+ export type { StorefrontClientConfig } from "./api/http"
@@ -40,6 +40,20 @@ export function readCartCookie(
40
40
  return get(CART_COOKIE) ?? get(LEGACY_CART_COOKIE)
41
41
  }
42
42
 
43
+ /**
44
+ * Forget the stored cart in the browser, under both names: Medusa's
45
+ * `removeCartId()`. The package's checkout calls it when the order is
46
+ * placed, and a store hands it to `CartDrawerProvider`'s `onCartEnd` so a
47
+ * cart that turned out to be completed or gone is forgotten too. A no-op
48
+ * on the server.
49
+ */
50
+ export function clearCartCookie(): void {
51
+ if (typeof document === "undefined") return
52
+ for (const name of [CART_COOKIE, LEGACY_CART_COOKIE]) {
53
+ document.cookie = `${name}=;path=/;max-age=0;samesite=lax`
54
+ }
55
+ }
56
+
43
57
  /**
44
58
  * THE VISITOR ID — our own per-browser id and the join key between browsing
45
59
  * and money (ecommerce-analytics §3.1). It is OURS deliberately: the
@@ -1,59 +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
- }
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
+ }