@cartbase/storefront 0.9.0 → 0.10.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
+ }
@@ -42,7 +42,7 @@ import { isStripeLike } from "../lib/payment-constants"
42
42
  import compareAddresses from "./compare-addresses"
43
43
  import { translateAddressError } from "./address-error-copy"
44
44
  import { translatePaymentError } from "./payment-error-copy"
45
- import { useOrderConfirmedPath } from "./context"
45
+ import { useCheckoutLabels, useOrderConfirmedPath } from "./context"
46
46
  import type { EcontOffice } from "./econt-office-selector"
47
47
  import type { BoxNowLocker } from "../api/integrations"
48
48
 
@@ -267,6 +267,9 @@ export function useCheckoutOrchestration({
267
267
  logError: logErrorProp,
268
268
  debug = false,
269
269
  }: UseCheckoutOrchestrationOptions) {
270
+ // The store's language, for the sentences a shopper reads when a save
271
+ // fails. English unless a locale pack is mounted above.
272
+ const checkoutLabels = useCheckoutLabels()
270
273
  // The default sink: the platform's own checkout error log. Browser money
271
274
  // failures (Stripe confirm errors, failed 3DS returns, place-order
272
275
  // rejections) land in checkout_error_logs step `browser` with no merchant
@@ -578,12 +581,11 @@ export function useCheckoutOrchestration({
578
581
  postal_code: formData["shipping_address.postal_code"] || null,
579
582
  }
580
583
  )
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))
584
+ // Into the store's own language. The raw error.message is technical
585
+ // English or a validation envelope, and neither is acceptable to
586
+ // show a shopper at the moment of failure. Unknown errors get the
587
+ // generic sentence rather than half-translated text.
588
+ setAddressError(translateAddressError(errObj ?? e, checkoutLabels.addressErrors))
587
589
  } finally {
588
590
  setAddressSaving(false)
589
591
  addressSavingRef.current = false
@@ -1079,7 +1081,7 @@ export function useCheckoutOrchestration({
1079
1081
 
1080
1082
  if (redirectStatus === "succeeded") {
1081
1083
  placeOrder().catch((err: unknown) => {
1082
- const translated = translatePaymentError(err, "card")
1084
+ const translated = translatePaymentError(err, "card", checkoutLabels.paymentErrors)
1083
1085
  setPaymentError(translated)
1084
1086
  logError?.(
1085
1087
  "place_order_error",