@cartbase/storefront 0.16.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 (110) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -255
  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 -130
  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/free-gift.tsx +1 -1
  28. package/src/cart-drawer/gift-wrap.tsx +82 -82
  29. package/src/cart-drawer/index.ts +71 -68
  30. package/src/cart-drawer/item/index.tsx +162 -162
  31. package/src/cart-drawer/item/quantity.tsx +8 -14
  32. package/src/cart-drawer/item/upsell.tsx +110 -110
  33. package/src/cart-drawer/labels.ts +123 -123
  34. package/src/cart-drawer/mutation-queue.ts +78 -0
  35. package/src/cart-drawer/notes.tsx +131 -131
  36. package/src/cart-drawer/sticky-footer.tsx +73 -73
  37. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  38. package/src/checkout/address-error-copy.ts +117 -117
  39. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  40. package/src/checkout/compare-addresses.ts +40 -40
  41. package/src/checkout/discount-section.tsx +218 -218
  42. package/src/checkout/error-copy-codes.ts +63 -63
  43. package/src/checkout/geocode.ts +154 -154
  44. package/src/checkout/gift-card-section.tsx +224 -224
  45. package/src/checkout/index.ts +72 -72
  46. package/src/checkout/labels.ts +495 -495
  47. package/src/checkout/payment-button.tsx +372 -372
  48. package/src/checkout/payment-error-copy.ts +154 -154
  49. package/src/checkout/promotion-error-copy.ts +91 -91
  50. package/src/checkout/use-checkout-orchestration.ts +1592 -1592
  51. package/src/common/country-flag.tsx +52 -52
  52. package/src/common/country-select.tsx +11 -11
  53. package/src/common/index.ts +20 -20
  54. package/src/common/market-select.tsx +57 -57
  55. package/src/index.ts +12 -12
  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 -0
  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-item.tsx +1 -1
  74. package/src/order/order-totals.tsx +250 -250
  75. package/src/primitives/select-field.tsx +93 -93
  76. package/src/products/image-gallery.tsx +43 -43
  77. package/src/products/index.ts +68 -59
  78. package/src/products/option-select.tsx +67 -67
  79. package/src/products/product-actions-wrapper.tsx +63 -62
  80. package/src/products/product-actions.tsx +151 -150
  81. package/src/products/product-price.tsx +69 -69
  82. package/src/products/product-promises.tsx +61 -61
  83. package/src/products/product-specs.tsx +65 -65
  84. package/src/products/product-tabs.tsx +123 -123
  85. package/src/products/product-template.tsx +163 -162
  86. package/src/products/purchase-options.tsx +130 -130
  87. package/src/products/sets.ts +110 -0
  88. package/src/products/thumbnail.tsx +1 -1
  89. package/src/products/use-product-actions.ts +199 -164
  90. package/src/products/variant-matching.ts +71 -71
  91. package/src/products/variant-url.ts +74 -74
  92. package/src/reviews-ui/index.ts +73 -73
  93. package/src/store/category-template.tsx +136 -136
  94. package/src/store/index.ts +40 -40
  95. package/src/tracking/chatgpt-pixel.tsx +99 -99
  96. package/src/tracking/consent-init.tsx +62 -62
  97. package/src/tracking/events.ts +348 -348
  98. package/src/tracking/ga4.tsx +93 -93
  99. package/src/tracking/google-ads.ts +84 -84
  100. package/src/tracking/gtm.tsx +60 -60
  101. package/src/tracking/inline-script.ts +49 -49
  102. package/src/tracking/oaiq.ts +206 -206
  103. package/src/tracking/tiktok-pixel.tsx +91 -91
  104. package/src/tracking/track-init.tsx +56 -56
  105. package/src/tracking/track-order-purchase.tsx +122 -122
  106. package/src/tracking/ttq.ts +180 -180
  107. package/src/tracking/use-tracking-config.ts +54 -54
  108. package/theme/index.css +25 -25
  109. package/theme/theme.css +150 -150
  110. 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
+ }