@cartbase/storefront 0.9.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.
@@ -1,124 +1,91 @@
1
- /**
2
- * Translates promotion errors into user-friendly Bulgarian copy for the
3
- * checkout discount-code surface.
4
- *
5
- * Ported from `@1click/ui/src/checkout/promotion-error-copy.ts` (v2.3.1)
6
- * and adapted to the Cartbase error envelope: the cart-promotions route
7
- * (POST /api/store/carts/:id/promotions) throws structured codes
8
- * `promotion_not_found`, `promotion_inactive`, `promotion_misconfigured`,
9
- * `promotion_unsupported_type`, plus the cart-level `cart_not_found` /
10
- * `cart_completed` / `validation_failed` so translation is code-first;
11
- * the Medusa-era substrings stay as fallback.
12
- *
13
- * Why this exists: applying a promo code is the one checkout action a
14
- * shopper triggers speculatively they type a half-remembered code and
15
- * hope. So it FAILS often, by design. Raw technical English at the
16
- * highest-intent moment reads as "the site is broken".
17
- */
18
-
19
- /** Generic fallback — used when we can't recognise the error at all. */
20
- const GENERIC =
21
- "Кодът за отстъпка не може да бъде приложен в момента. Моля, проверете кода и опитайте отново."
22
-
23
- /** Campaign-budget promotions are tracked per customer, so the engine can
24
- * require an email on the cart before applying them. A shopper who jumps
25
- * straight to the promo field without filling their details hits this. */
26
- const NEEDS_EMAIL =
27
- "Въведете имейл адрес по-горе, за да приложите този код за отстъпка."
28
-
29
- const INVALID = "Този код за отстъпка не е валиден."
30
-
31
- const EXPIRED = "Този код за отстъпка вече не е активен."
32
-
33
- const EXHAUSTED =
34
- "Промоцията е изчерпана и вече не може да бъде използвана."
35
-
36
- /**
37
- * Cartbase promotion error codes → BG copy (POST/DELETE
38
- * /api/store/carts/:id/promotions + src/lib/promotions/apply.ts).
39
- */
40
- export const PROMOTION_ERROR_CODE_COPY: Record<string, string> = {
41
- promotion_not_found: INVALID,
42
- promotion_inactive: EXPIRED,
43
- promotion_misconfigured: GENERIC,
44
- promotion_unsupported_type: GENERIC,
45
- cart_completed:
46
- "Поръчката вече е финализирана. Моля, проверете имейла си за потвърждение.",
47
- cart_not_found: "Сесията на количката изтече. Моля, презаредете страницата.",
48
- validation_failed: INVALID,
49
- }
50
-
51
- /**
52
- * Known promo error substrings → BG copy. Case-insensitive, first match
53
- * wins, most-specific first. Kept verbatim from the production port as the
54
- * fallback layer.
55
- */
56
- const PROMO_PATTERNS: Array<{ match: RegExp; copy: string }> = [
57
- // Campaign budget needs an email on the cart.
58
- {
59
- match: /customer_email|campaign[\s_]?budget/i,
60
- copy: NEEDS_EMAIL,
61
- },
62
- // Expired / no-longer-active promotion.
63
- {
64
- match: /expired|no[\s_]?longer[\s_]?(?:active|valid)|inactive/i,
65
- copy: EXPIRED,
66
- },
67
- // Budget / usage limit reached.
68
- {
69
- match: /reached[\s_]?its[\s_]?limit|usage[\s_]?limit|budget[\s_]?(?:exceeded|reached)/i,
70
- copy: EXHAUSTED,
71
- },
72
- // Invalid / unknown code (keep last of the specific set — broadest).
73
- {
74
- match: /is[\s_]?invalid|invalid[\s_]?data|not[\s_]?found|does(?:n'?t| not)[\s_]?exist/i,
75
- copy: INVALID,
76
- },
77
- ]
78
-
79
- /**
80
- * Translate any thrown / returned promo error into customer-facing Bulgarian.
81
- *
82
- * @param err the error from a try/catch (a StoreApiError with a code, a
83
- * Stripe-less network error, or an opaque render error)
84
- * @param opts.hasEmail whether the cart currently has an email. When the
85
- * error is opaque and there's no email, the overwhelmingly
86
- * likely cause is a campaign-budget promo, so we give the
87
- * actionable "enter your email" message instead of a vague
88
- * fallback.
89
- */
90
- export function translatePromotionError(
91
- err: unknown,
92
- opts?: { hasEmail?: boolean }
93
- ): string {
94
- if (err && typeof err === "object") {
95
- const code = (err as { code?: unknown }).code
96
- if (typeof code === "string" && PROMOTION_ERROR_CODE_COPY[code]) {
97
- return PROMOTION_ERROR_CODE_COPY[code]
98
- }
99
- }
100
-
101
- const raw = extractMessage(err)
102
-
103
- for (const { match, copy } of PROMO_PATTERNS) {
104
- if (match.test(raw)) return copy
105
- }
106
-
107
- // Unknown / opaque error. If the cart has no email yet, the
108
- // campaign-budget case is by far the most common trigger — point the
109
- // shopper at the fix rather than a dead end.
110
- if (opts?.hasEmail === false) return NEEDS_EMAIL
111
-
112
- return GENERIC
113
- }
114
-
115
- function extractMessage(err: unknown): string {
116
- if (!err) return ""
117
- if (typeof err === "string") return err
118
- if (err instanceof Error) return err.message
119
- if (typeof err === "object" && err !== null) {
120
- const e = err as { message?: string; code?: string; type?: string }
121
- return [e.message, e.code, e.type].filter(Boolean).join(" ")
122
- }
123
- return String(err)
124
- }
1
+ import { PROMOTION_CODE_KEYS } from "./error-copy-codes"
2
+ import type { PromotionErrorCopy } from "./labels"
3
+
4
+ /**
5
+ * Recognizes a discount-code failure and picks the copy key for it.
6
+ *
7
+ * THE COPY IS NOT HERE ANY MORE. Until 2026-09-13 this file held seven
8
+ * hardcoded BULGARIAN sentences that every merchant shipped and none could
9
+ * override; the words now live in the checkout label pack, English by
10
+ * default. The substring patterns stay in English because they match the
11
+ * API's own error text, which is wire format and never shown.
12
+ *
13
+ * Recognition is code-first (`StoreApiError.code`), then the substrings
14
+ * kept verbatim from the production port.
15
+ */
16
+
17
+ /**
18
+ * Known promo error substrings. Case-insensitive, first match wins, most
19
+ * specific first.
20
+ */
21
+ const PROMO_PATTERNS: Array<{ match: RegExp; key: keyof PromotionErrorCopy }> = [
22
+ // Campaign budget needs an email on the cart.
23
+ { match: /customer_email|campaign[\s_]?budget/i, key: "needsEmail" },
24
+ // Expired or no-longer-active promotion.
25
+ { match: /expired|no[\s_]?longer[\s_]?(?:active|valid)|inactive/i, key: "promotionInactive" },
26
+ // Budget or usage limit reached.
27
+ {
28
+ match: /reached[\s_]?its[\s_]?limit|usage[\s_]?limit|budget[\s_]?(?:exceeded|reached)/i,
29
+ key: "exhausted",
30
+ },
31
+ // Invalid or unknown code. Last of the specific set, because it is the
32
+ // broadest pattern and would otherwise swallow the ones above.
33
+ {
34
+ match: /is[\s_]?invalid|invalid[\s_]?data|not[\s_]?found|does(?:n'?t| not)[\s_]?exist/i,
35
+ key: "promotionNotFound",
36
+ },
37
+ ]
38
+
39
+ /**
40
+ * Which promotion copy this failure asks for.
41
+ *
42
+ * @param err the error from a try/catch: a StoreApiError with a code, a
43
+ * network error, or an opaque render error
44
+ * @param opts.hasEmail whether the cart currently has an email. When the
45
+ * error is opaque and there is no email, the overwhelmingly
46
+ * likely cause is a campaign-budget promotion, so the shopper
47
+ * gets the actionable "enter your email" line rather than a
48
+ * vague fallback.
49
+ */
50
+ export function promotionErrorKey(
51
+ err: unknown,
52
+ opts?: { hasEmail?: boolean }
53
+ ): keyof PromotionErrorCopy {
54
+ if (err && typeof err === "object") {
55
+ const code = (err as { code?: unknown }).code
56
+ if (typeof code === "string" && PROMOTION_CODE_KEYS[code]) {
57
+ return PROMOTION_CODE_KEYS[code]
58
+ }
59
+ }
60
+
61
+ const raw = extractMessage(err)
62
+ for (const { match, key } of PROMO_PATTERNS) if (match.test(raw)) return key
63
+
64
+ // Unknown or opaque. With no email on the cart the campaign-budget case
65
+ // is by far the most common trigger, so point at the fix, not a dead end.
66
+ if (opts?.hasEmail === false) return "needsEmail"
67
+ return "generic"
68
+ }
69
+
70
+ /**
71
+ * Turn any thrown or returned promotion error into the sentence a shopper
72
+ * reads, in the store's own language.
73
+ */
74
+ export function translatePromotionError(
75
+ err: unknown,
76
+ copy: PromotionErrorCopy,
77
+ opts?: { hasEmail?: boolean }
78
+ ): string {
79
+ return copy[promotionErrorKey(err, opts)]
80
+ }
81
+
82
+ function extractMessage(err: unknown): string {
83
+ if (!err) return ""
84
+ if (typeof err === "string") return err
85
+ if (err instanceof Error) return err.message
86
+ if (typeof err === "object" && err !== null) {
87
+ const e = err as { message?: string; code?: string; type?: string }
88
+ return [e.message, e.code, e.type].filter(Boolean).join(" ")
89
+ }
90
+ return String(err)
91
+ }
@@ -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,
@@ -42,7 +43,7 @@ import { isStripeLike } from "../lib/payment-constants"
42
43
  import compareAddresses from "./compare-addresses"
43
44
  import { translateAddressError } from "./address-error-copy"
44
45
  import { translatePaymentError } from "./payment-error-copy"
45
- import { useOrderConfirmedPath } from "./context"
46
+ import { useCheckoutLabels, useOrderConfirmedPath } from "./context"
46
47
  import type { EcontOffice } from "./econt-office-selector"
47
48
  import type { BoxNowLocker } from "../api/integrations"
48
49
 
@@ -267,6 +268,9 @@ export function useCheckoutOrchestration({
267
268
  logError: logErrorProp,
268
269
  debug = false,
269
270
  }: UseCheckoutOrchestrationOptions) {
271
+ // The store's language, for the sentences a shopper reads when a save
272
+ // fails. English unless a locale pack is mounted above.
273
+ const checkoutLabels = useCheckoutLabels()
270
274
  // The default sink: the platform's own checkout error log. Browser money
271
275
  // failures (Stripe confirm errors, failed 3DS returns, place-order
272
276
  // rejections) land in checkout_error_logs step `browser` with no merchant
@@ -359,17 +363,54 @@ export function useCheckoutOrchestration({
359
363
  : true
360
364
  )
361
365
 
362
- // Cartbase regions have no embedded countries — the caller-supplied list
363
- // (or the countryCode fallback) is the authority for both the select
364
- // 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.
365
404
  const regionCountries = useMemo(
366
405
  () =>
367
406
  countries && countries.length
368
407
  ? countries
369
- : countryCode
370
- ? [{ iso_2: countryCode, display_name: "" }]
371
- : [],
372
- [countries, countryCode]
408
+ : storeCountries && storeCountries.length
409
+ ? storeCountries
410
+ : countryCode
411
+ ? [{ iso_2: countryCode, display_name: "" }]
412
+ : [],
413
+ [countries, storeCountries, countryCode]
373
414
  )
374
415
 
375
416
  const countriesInRegion = useMemo(
@@ -578,12 +619,11 @@ export function useCheckoutOrchestration({
578
619
  postal_code: formData["shipping_address.postal_code"] || null,
579
620
  }
580
621
  )
581
- // Translate to Bulgarian the raw error.message is technical
582
- // English (or a validation envelope). Neither is acceptable to
583
- // show a Bulgarian shopper at the moment of failure. Unknown
584
- // errors get the generic fallback rather than half-translated
585
- // text.
586
- setAddressError(translateAddressError(errObj ?? e))
622
+ // Into the store's own language. The raw error.message is technical
623
+ // English or a validation envelope, and neither is acceptable to
624
+ // show a shopper at the moment of failure. Unknown errors get the
625
+ // generic sentence rather than half-translated text.
626
+ setAddressError(translateAddressError(errObj ?? e, checkoutLabels.addressErrors))
587
627
  } finally {
588
628
  setAddressSaving(false)
589
629
  addressSavingRef.current = false
@@ -1079,7 +1119,7 @@ export function useCheckoutOrchestration({
1079
1119
 
1080
1120
  if (redirectStatus === "succeeded") {
1081
1121
  placeOrder().catch((err: unknown) => {
1082
- const translated = translatePaymentError(err, "card")
1122
+ const translated = translatePaymentError(err, "card", checkoutLabels.paymentErrors)
1083
1123
  setPaymentError(translated)
1084
1124
  logError?.(
1085
1125
  "place_order_error",
@@ -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,