@cartbase/storefront 0.20.0 → 0.20.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 (99) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -258
  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/redirects.ts +37 -37
  18. package/src/api/regions.ts +200 -200
  19. package/src/api/search.ts +163 -163
  20. package/src/api/store.ts +35 -35
  21. package/src/api/types.ts +91 -91
  22. package/src/cart-drawer/context.tsx +778 -778
  23. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  24. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  25. package/src/cart-drawer/gift-wrap.tsx +82 -82
  26. package/src/cart-drawer/item/index.tsx +162 -162
  27. package/src/cart-drawer/item/upsell.tsx +110 -110
  28. package/src/cart-drawer/labels.ts +123 -123
  29. package/src/cart-drawer/mutation-queue.ts +78 -78
  30. package/src/cart-drawer/notes.tsx +131 -131
  31. package/src/cart-drawer/sticky-footer.tsx +73 -73
  32. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  33. package/src/checkout/address-error-copy.ts +117 -117
  34. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  35. package/src/checkout/compare-addresses.ts +40 -40
  36. package/src/checkout/discount-section.tsx +218 -218
  37. package/src/checkout/error-copy-codes.ts +63 -63
  38. package/src/checkout/geocode.ts +154 -154
  39. package/src/checkout/gift-card-section.tsx +224 -224
  40. package/src/checkout/index.ts +72 -72
  41. package/src/checkout/labels.ts +495 -495
  42. package/src/checkout/payment-button.tsx +372 -372
  43. package/src/checkout/payment-error-copy.ts +154 -154
  44. package/src/checkout/promotion-error-copy.ts +91 -91
  45. package/src/common/country-flag.tsx +52 -52
  46. package/src/common/country-select.tsx +11 -11
  47. package/src/common/index.ts +20 -20
  48. package/src/common/market-select.tsx +57 -57
  49. package/src/index.ts +12 -12
  50. package/src/lib/country-name.ts +59 -59
  51. package/src/lib/get-product-price.ts +133 -133
  52. package/src/lib/media-image.tsx +39 -39
  53. package/src/lib/payment-constants.ts +53 -53
  54. package/src/lib/platform.ts +13 -13
  55. package/src/lib/price.tsx +39 -39
  56. package/src/lib/store-api-error.ts +36 -36
  57. package/src/lib/variant-caption.ts +32 -32
  58. package/src/locales/context.ts +37 -37
  59. package/src/locales/en.ts +26 -26
  60. package/src/locales/index.ts +19 -19
  61. package/src/locales/provider.tsx +59 -59
  62. package/src/locales/types.ts +77 -77
  63. package/src/order/index.ts +62 -62
  64. package/src/order/labels.ts +79 -79
  65. package/src/order/order-totals.tsx +250 -250
  66. package/src/primitives/select-field.tsx +93 -93
  67. package/src/products/image-gallery.tsx +43 -43
  68. package/src/products/option-select.tsx +67 -67
  69. package/src/products/product-info.tsx +5 -5
  70. package/src/products/product-price.tsx +69 -69
  71. package/src/products/product-promises.tsx +61 -61
  72. package/src/products/product-specs.tsx +65 -65
  73. package/src/products/product-tabs.tsx +123 -123
  74. package/src/products/purchase-options.tsx +130 -130
  75. package/src/products/sets.ts +110 -110
  76. package/src/products/variant-matching.ts +71 -71
  77. package/src/products/variant-url.ts +74 -74
  78. package/src/reviews-ui/lightbox-state.ts +46 -46
  79. package/src/reviews-ui/review-header.tsx +166 -166
  80. package/src/reviews-ui/review-lightbox.tsx +271 -271
  81. package/src/reviews-ui/review-list.tsx +193 -193
  82. package/src/reviews-ui/review-widget.tsx +219 -218
  83. package/src/reviews-ui/widget-options.ts +55 -55
  84. package/src/store/category-template.tsx +136 -136
  85. package/src/store/index.ts +40 -40
  86. package/src/tracking/chatgpt-pixel.tsx +99 -99
  87. package/src/tracking/consent-init.tsx +62 -62
  88. package/src/tracking/events.ts +348 -348
  89. package/src/tracking/ga4.tsx +93 -93
  90. package/src/tracking/google-ads.ts +84 -84
  91. package/src/tracking/gtm.tsx +60 -60
  92. package/src/tracking/inline-script.ts +49 -49
  93. package/src/tracking/oaiq.ts +206 -206
  94. package/src/tracking/tiktok-pixel.tsx +91 -91
  95. package/src/tracking/track-init.tsx +56 -56
  96. package/src/tracking/track-order-purchase.tsx +122 -122
  97. package/src/tracking/ttq.ts +180 -180
  98. package/src/tracking/use-tracking-config.ts +54 -54
  99. package/theme/index.css +25 -25
@@ -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
+ }
@@ -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"