@cartbase/storefront 0.17.0 → 0.18.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.
Files changed (107) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -256
  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 +277 -164
  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 +71 -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 +1592 -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/country-name.ts +59 -59
  56. package/src/lib/get-product-price.ts +133 -133
  57. package/src/lib/media-image.tsx +39 -39
  58. package/src/lib/payment-constants.ts +53 -53
  59. package/src/lib/platform.ts +13 -13
  60. package/src/lib/price.tsx +39 -39
  61. package/src/lib/store-api-error.ts +36 -36
  62. package/src/lib/variant-caption.ts +32 -32
  63. package/src/locales/bg.ts +467 -467
  64. package/src/locales/context.ts +37 -37
  65. package/src/locales/en.ts +26 -26
  66. package/src/locales/es.ts +466 -466
  67. package/src/locales/index.ts +19 -19
  68. package/src/locales/provider.tsx +59 -59
  69. package/src/locales/types.ts +77 -77
  70. package/src/order/index.ts +62 -62
  71. package/src/order/labels.ts +79 -79
  72. package/src/order/order-totals.tsx +250 -250
  73. package/src/primitives/select-field.tsx +93 -93
  74. package/src/products/image-gallery.tsx +43 -43
  75. package/src/products/index.ts +68 -59
  76. package/src/products/option-select.tsx +67 -67
  77. package/src/products/product-actions-wrapper.tsx +63 -62
  78. package/src/products/product-actions.tsx +151 -150
  79. package/src/products/product-price.tsx +69 -69
  80. package/src/products/product-promises.tsx +61 -61
  81. package/src/products/product-specs.tsx +65 -65
  82. package/src/products/product-tabs.tsx +123 -123
  83. package/src/products/product-template.tsx +163 -162
  84. package/src/products/purchase-options.tsx +130 -130
  85. package/src/products/sets.ts +110 -0
  86. package/src/products/use-product-actions.ts +199 -164
  87. package/src/products/variant-matching.ts +71 -71
  88. package/src/products/variant-url.ts +74 -74
  89. package/src/reviews-ui/index.ts +73 -73
  90. package/src/store/category-template.tsx +136 -136
  91. package/src/store/index.ts +40 -40
  92. package/src/tracking/chatgpt-pixel.tsx +99 -99
  93. package/src/tracking/consent-init.tsx +62 -62
  94. package/src/tracking/events.ts +348 -348
  95. package/src/tracking/ga4.tsx +93 -93
  96. package/src/tracking/google-ads.ts +84 -84
  97. package/src/tracking/gtm.tsx +60 -60
  98. package/src/tracking/inline-script.ts +49 -49
  99. package/src/tracking/oaiq.ts +206 -206
  100. package/src/tracking/tiktok-pixel.tsx +91 -91
  101. package/src/tracking/track-init.tsx +56 -56
  102. package/src/tracking/track-order-purchase.tsx +122 -122
  103. package/src/tracking/ttq.ts +180 -180
  104. package/src/tracking/use-tracking-config.ts +54 -54
  105. package/theme/index.css +25 -25
  106. package/theme/theme.css +150 -150
  107. package/theme/tokens.css +106 -106
@@ -1,154 +1,154 @@
1
- import { GIFT_CARD_CODE_KEYS, PAYMENT_CODE_KEYS } from "./error-copy-codes"
2
- import type { GiftCardErrorCopy, PaymentErrorCopy } from "./labels"
3
-
4
- /**
5
- * Recognizes a payment or order-completion failure and picks the copy key
6
- * for it.
7
- *
8
- * THE COPY IS NOT HERE ANY MORE. Until 2026-09-13 this file held 47
9
- * hardcoded BULGARIAN sentences, so every merchant who installed the
10
- * package showed Bulgarian at the exact moment a payment failed, with no
11
- * override. The words now live in the checkout label pack: English by
12
- * default, translated by whichever locale the store mounts.
13
- *
14
- * WHY THIS LAYER EXISTS AT ALL: Stripe and API errors are technical
15
- * English ("Your card's security code is incorrect.", "fetch failed").
16
- * Showing that to a shopper makes the site look broken rather than making
17
- * it look like they mistyped a digit. Unknown errors get the per-context
18
- * generic, because a half-translated technical string is worse than a
19
- * clean one.
20
- *
21
- * Recognition is CODE-FIRST — the store API throws `StoreApiError` with a
22
- * structured `code` (docs/storefront/checkout.md lists every code per
23
- * endpoint) — then the Stripe substring patterns, which still do the heavy
24
- * lifting on the card path because Stripe.js browser errors carry no
25
- * Cartbase code, then the legacy Medusa-era substrings.
26
- *
27
- * The patterns stay in English on purpose: they match the wire, not the
28
- * shopper.
29
- */
30
-
31
- export type ErrorContext = "card" | "cod"
32
-
33
- /**
34
- * Stripe error codes, decline codes and common substrings. Matched
35
- * case-insensitively, first match wins, so the order runs most specific to
36
- * most generic. Kept verbatim from the production port.
37
- *
38
- * Stripe error code reference: https://docs.stripe.com/error-codes
39
- */
40
- const STRIPE_PATTERNS: Array<{ match: RegExp; key: keyof PaymentErrorCopy }> = [
41
- { match: /card[\s_-]?declined|card_declined/i, key: "cardDeclined" },
42
- { match: /insufficient[\s_]?funds/i, key: "insufficientFunds" },
43
- { match: /expired[\s_]?card|card[\s_]?has[\s_]?expired/i, key: "expiredCard" },
44
- { match: /incorrect[\s_]?cvc|invalid[\s_]?cvc|cvc[\s_]?check/i, key: "incorrectCvc" },
45
- { match: /incorrect[\s_]?number|invalid[\s_]?number/i, key: "incorrectCardNumber" },
46
- { match: /invalid[\s_]?expir|incorrect[\s_]?expir/i, key: "invalidExpiry" },
47
- { match: /authentication[\s_]?required|3d[\s_]?secure/i, key: "authenticationRequired" },
48
- { match: /processing[\s_]?error/i, key: "processingError" },
49
- { match: /rate[\s_]?limit/i, key: "rateLimited" },
50
- // Terminal-state payment intent: the bug class refresh-payment-if-terminal heals.
51
- {
52
- match: /terminal[\s_]?state|payment[\s_]?intent[\s_]?(?:is|in)[\s_]?(?:a[\s_]?)?terminal/i,
53
- key: "paymentExpired",
54
- },
55
- // Amount mismatch: the cart total drifted from the payment intent's amount.
56
- { match: /amount[\s_]?mismatch|amount[\s_]?does[\s_]?not[\s_]?match/i, key: "amountMismatch" },
57
- { match: /failed[\s_]?to[\s_]?fetch|network[\s_]?error|networkerror/i, key: "networkError" },
58
- ]
59
-
60
- /** Legacy Medusa-era API substrings, the last fallback layer. */
61
- const API_PATTERNS: Array<{ match: RegExp; key: keyof PaymentErrorCopy }> = [
62
- {
63
- match: /cart[\s_]?(?:is[\s_]?)?already[\s_]?completed|already[\s_]?an[\s_]?order/i,
64
- key: "cartAlreadyCompleted",
65
- },
66
- {
67
- match:
68
- /not[\s_]?enough[\s_]?stock|insufficient[\s_]?stock|out[\s_]?of[\s_]?stock|insufficient[\s_]?inventory/i,
69
- key: "outOfStock",
70
- },
71
- { match: /no[\s_]?region|invalid[\s_]?region/i, key: "invalidRegion" },
72
- {
73
- match: /no[\s_]?shipping[\s_]?method|shipping[\s_]?method[\s_]?not[\s_]?found/i,
74
- key: "shippingMethodMissing",
75
- },
76
- ]
77
-
78
- /** Pull the structured Cartbase error code out of any thrown value. */
79
- function extractCode(err: unknown): string | null {
80
- if (err && typeof err === "object") {
81
- const code = (err as { code?: unknown }).code
82
- if (typeof code === "string" && code.length > 0) return code
83
- }
84
- return null
85
- }
86
-
87
- /** Which copy this failure asks for, independent of language. */
88
- export function paymentErrorKey(
89
- err: unknown,
90
- context: ErrorContext
91
- ): keyof PaymentErrorCopy {
92
- const generic = context === "card" ? "genericCard" : "genericCod"
93
-
94
- const code = extractCode(err)
95
- if (code && PAYMENT_CODE_KEYS[code]) return PAYMENT_CODE_KEYS[code]
96
-
97
- const raw = extractMessage(err)
98
- if (!raw) return generic
99
-
100
- for (const { match, key } of STRIPE_PATTERNS) if (match.test(raw)) return key
101
- for (const { match, key } of API_PATTERNS) if (match.test(raw)) return key
102
- return generic
103
- }
104
-
105
- /**
106
- * Turn any thrown or returned payment error into the sentence a shopper
107
- * reads, in the store's own language.
108
- *
109
- * @param err - the error from a try/catch or a Stripe error response
110
- * @param context - "card" for the Stripe path, "cod" for the manual path
111
- * @param copy - the store's payment copy (`labels.paymentErrors`)
112
- */
113
- export function translatePaymentError(
114
- err: unknown,
115
- context: ErrorContext,
116
- copy: PaymentErrorCopy
117
- ): string {
118
- return copy[paymentErrorKey(err, context)]
119
- }
120
-
121
- /** Which gift-card copy this failure asks for. */
122
- export function giftCardErrorKey(err: unknown): keyof GiftCardErrorCopy {
123
- const code = extractCode(err)
124
- if (code && GIFT_CARD_CODE_KEYS[code]) return GIFT_CARD_CODE_KEYS[code]
125
- return "generic"
126
- }
127
-
128
- /**
129
- * Turn a gift-card apply or remove failure into the shopper's sentence.
130
- * Code-first, falling back to one clean generic: the API's anti-oracle
131
- * design means there is deliberately nothing more specific to say.
132
- */
133
- export function translateGiftCardError(
134
- err: unknown,
135
- copy: GiftCardErrorCopy
136
- ): string {
137
- return copy[giftCardErrorKey(err)]
138
- }
139
-
140
- function extractMessage(err: unknown): string {
141
- if (!err) return ""
142
- if (typeof err === "string") return err
143
- if (err instanceof Error) return err.message
144
- if (typeof err === "object" && err !== null) {
145
- const e = err as {
146
- message?: string
147
- code?: string
148
- decline_code?: string
149
- type?: string
150
- }
151
- return [e.message, e.code, e.decline_code, e.type].filter(Boolean).join(" ")
152
- }
153
- return String(err)
154
- }
1
+ import { GIFT_CARD_CODE_KEYS, PAYMENT_CODE_KEYS } from "./error-copy-codes"
2
+ import type { GiftCardErrorCopy, PaymentErrorCopy } from "./labels"
3
+
4
+ /**
5
+ * Recognizes a payment or order-completion failure and picks the copy key
6
+ * for it.
7
+ *
8
+ * THE COPY IS NOT HERE ANY MORE. Until 2026-09-13 this file held 47
9
+ * hardcoded BULGARIAN sentences, so every merchant who installed the
10
+ * package showed Bulgarian at the exact moment a payment failed, with no
11
+ * override. The words now live in the checkout label pack: English by
12
+ * default, translated by whichever locale the store mounts.
13
+ *
14
+ * WHY THIS LAYER EXISTS AT ALL: Stripe and API errors are technical
15
+ * English ("Your card's security code is incorrect.", "fetch failed").
16
+ * Showing that to a shopper makes the site look broken rather than making
17
+ * it look like they mistyped a digit. Unknown errors get the per-context
18
+ * generic, because a half-translated technical string is worse than a
19
+ * clean one.
20
+ *
21
+ * Recognition is CODE-FIRST — the store API throws `StoreApiError` with a
22
+ * structured `code` (docs/storefront/checkout.md lists every code per
23
+ * endpoint) — then the Stripe substring patterns, which still do the heavy
24
+ * lifting on the card path because Stripe.js browser errors carry no
25
+ * Cartbase code, then the legacy Medusa-era substrings.
26
+ *
27
+ * The patterns stay in English on purpose: they match the wire, not the
28
+ * shopper.
29
+ */
30
+
31
+ export type ErrorContext = "card" | "cod"
32
+
33
+ /**
34
+ * Stripe error codes, decline codes and common substrings. Matched
35
+ * case-insensitively, first match wins, so the order runs most specific to
36
+ * most generic. Kept verbatim from the production port.
37
+ *
38
+ * Stripe error code reference: https://docs.stripe.com/error-codes
39
+ */
40
+ const STRIPE_PATTERNS: Array<{ match: RegExp; key: keyof PaymentErrorCopy }> = [
41
+ { match: /card[\s_-]?declined|card_declined/i, key: "cardDeclined" },
42
+ { match: /insufficient[\s_]?funds/i, key: "insufficientFunds" },
43
+ { match: /expired[\s_]?card|card[\s_]?has[\s_]?expired/i, key: "expiredCard" },
44
+ { match: /incorrect[\s_]?cvc|invalid[\s_]?cvc|cvc[\s_]?check/i, key: "incorrectCvc" },
45
+ { match: /incorrect[\s_]?number|invalid[\s_]?number/i, key: "incorrectCardNumber" },
46
+ { match: /invalid[\s_]?expir|incorrect[\s_]?expir/i, key: "invalidExpiry" },
47
+ { match: /authentication[\s_]?required|3d[\s_]?secure/i, key: "authenticationRequired" },
48
+ { match: /processing[\s_]?error/i, key: "processingError" },
49
+ { match: /rate[\s_]?limit/i, key: "rateLimited" },
50
+ // Terminal-state payment intent: the bug class refresh-payment-if-terminal heals.
51
+ {
52
+ match: /terminal[\s_]?state|payment[\s_]?intent[\s_]?(?:is|in)[\s_]?(?:a[\s_]?)?terminal/i,
53
+ key: "paymentExpired",
54
+ },
55
+ // Amount mismatch: the cart total drifted from the payment intent's amount.
56
+ { match: /amount[\s_]?mismatch|amount[\s_]?does[\s_]?not[\s_]?match/i, key: "amountMismatch" },
57
+ { match: /failed[\s_]?to[\s_]?fetch|network[\s_]?error|networkerror/i, key: "networkError" },
58
+ ]
59
+
60
+ /** Legacy Medusa-era API substrings, the last fallback layer. */
61
+ const API_PATTERNS: Array<{ match: RegExp; key: keyof PaymentErrorCopy }> = [
62
+ {
63
+ match: /cart[\s_]?(?:is[\s_]?)?already[\s_]?completed|already[\s_]?an[\s_]?order/i,
64
+ key: "cartAlreadyCompleted",
65
+ },
66
+ {
67
+ match:
68
+ /not[\s_]?enough[\s_]?stock|insufficient[\s_]?stock|out[\s_]?of[\s_]?stock|insufficient[\s_]?inventory/i,
69
+ key: "outOfStock",
70
+ },
71
+ { match: /no[\s_]?region|invalid[\s_]?region/i, key: "invalidRegion" },
72
+ {
73
+ match: /no[\s_]?shipping[\s_]?method|shipping[\s_]?method[\s_]?not[\s_]?found/i,
74
+ key: "shippingMethodMissing",
75
+ },
76
+ ]
77
+
78
+ /** Pull the structured Cartbase error code out of any thrown value. */
79
+ function extractCode(err: unknown): string | null {
80
+ if (err && typeof err === "object") {
81
+ const code = (err as { code?: unknown }).code
82
+ if (typeof code === "string" && code.length > 0) return code
83
+ }
84
+ return null
85
+ }
86
+
87
+ /** Which copy this failure asks for, independent of language. */
88
+ export function paymentErrorKey(
89
+ err: unknown,
90
+ context: ErrorContext
91
+ ): keyof PaymentErrorCopy {
92
+ const generic = context === "card" ? "genericCard" : "genericCod"
93
+
94
+ const code = extractCode(err)
95
+ if (code && PAYMENT_CODE_KEYS[code]) return PAYMENT_CODE_KEYS[code]
96
+
97
+ const raw = extractMessage(err)
98
+ if (!raw) return generic
99
+
100
+ for (const { match, key } of STRIPE_PATTERNS) if (match.test(raw)) return key
101
+ for (const { match, key } of API_PATTERNS) if (match.test(raw)) return key
102
+ return generic
103
+ }
104
+
105
+ /**
106
+ * Turn any thrown or returned payment error into the sentence a shopper
107
+ * reads, in the store's own language.
108
+ *
109
+ * @param err - the error from a try/catch or a Stripe error response
110
+ * @param context - "card" for the Stripe path, "cod" for the manual path
111
+ * @param copy - the store's payment copy (`labels.paymentErrors`)
112
+ */
113
+ export function translatePaymentError(
114
+ err: unknown,
115
+ context: ErrorContext,
116
+ copy: PaymentErrorCopy
117
+ ): string {
118
+ return copy[paymentErrorKey(err, context)]
119
+ }
120
+
121
+ /** Which gift-card copy this failure asks for. */
122
+ export function giftCardErrorKey(err: unknown): keyof GiftCardErrorCopy {
123
+ const code = extractCode(err)
124
+ if (code && GIFT_CARD_CODE_KEYS[code]) return GIFT_CARD_CODE_KEYS[code]
125
+ return "generic"
126
+ }
127
+
128
+ /**
129
+ * Turn a gift-card apply or remove failure into the shopper's sentence.
130
+ * Code-first, falling back to one clean generic: the API's anti-oracle
131
+ * design means there is deliberately nothing more specific to say.
132
+ */
133
+ export function translateGiftCardError(
134
+ err: unknown,
135
+ copy: GiftCardErrorCopy
136
+ ): string {
137
+ return copy[giftCardErrorKey(err)]
138
+ }
139
+
140
+ function extractMessage(err: unknown): string {
141
+ if (!err) return ""
142
+ if (typeof err === "string") return err
143
+ if (err instanceof Error) return err.message
144
+ if (typeof err === "object" && err !== null) {
145
+ const e = err as {
146
+ message?: string
147
+ code?: string
148
+ decline_code?: string
149
+ type?: string
150
+ }
151
+ return [e.message, e.code, e.decline_code, e.type].filter(Boolean).join(" ")
152
+ }
153
+ return String(err)
154
+ }
@@ -1,91 +1,91 @@
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
- }
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
+ }