@cartbase/storefront 0.20.0 → 0.21.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 (109) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +4 -1
  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 +181 -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 +56 -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/payment-badges.tsx +36 -96
  32. package/src/cart-drawer/sticky-footer.tsx +73 -73
  33. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  34. package/src/checkout/address-error-copy.ts +117 -117
  35. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  36. package/src/checkout/compare-addresses.ts +40 -40
  37. package/src/checkout/discount-section.tsx +218 -218
  38. package/src/checkout/error-copy-codes.ts +63 -63
  39. package/src/checkout/geocode.ts +154 -154
  40. package/src/checkout/gift-card-section.tsx +224 -224
  41. package/src/checkout/index.ts +72 -72
  42. package/src/checkout/labels.ts +495 -495
  43. package/src/checkout/payment-button.tsx +372 -372
  44. package/src/checkout/payment-error-copy.ts +154 -154
  45. package/src/checkout/promotion-error-copy.ts +91 -91
  46. package/src/common/country-flag.tsx +52 -52
  47. package/src/common/country-select.tsx +11 -11
  48. package/src/common/icons/cartbase-mark.ts +9 -0
  49. package/src/common/icons/payment-marks.ts +73 -0
  50. package/src/common/icons/social-marks.ts +61 -0
  51. package/src/common/index.ts +35 -20
  52. package/src/common/market-select.tsx +57 -57
  53. package/src/common/payment-icons.tsx +54 -0
  54. package/src/common/powered-by-cartbase.tsx +47 -0
  55. package/src/common/social-links.tsx +65 -0
  56. package/src/index.ts +12 -12
  57. package/src/lib/country-name.ts +59 -59
  58. package/src/lib/get-product-price.ts +133 -133
  59. package/src/lib/media-image.tsx +39 -39
  60. package/src/lib/payment-constants.ts +53 -53
  61. package/src/lib/platform.ts +13 -13
  62. package/src/lib/price.tsx +39 -39
  63. package/src/lib/store-api-error.ts +36 -36
  64. package/src/lib/variant-caption.ts +32 -32
  65. package/src/locales/bg.ts +3 -0
  66. package/src/locales/context.ts +37 -37
  67. package/src/locales/en.ts +26 -26
  68. package/src/locales/es.ts +3 -0
  69. package/src/locales/index.ts +19 -19
  70. package/src/locales/provider.tsx +59 -59
  71. package/src/locales/types.ts +77 -77
  72. package/src/order/index.ts +62 -62
  73. package/src/order/labels.ts +79 -79
  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/option-select.tsx +67 -67
  78. package/src/products/product-info.tsx +5 -5
  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/purchase-options.tsx +130 -130
  84. package/src/products/sets.ts +110 -110
  85. package/src/products/variant-matching.ts +71 -71
  86. package/src/products/variant-url.ts +74 -74
  87. package/src/reviews-ui/lightbox-state.ts +46 -46
  88. package/src/reviews-ui/review-header.tsx +166 -166
  89. package/src/reviews-ui/review-lightbox.tsx +271 -271
  90. package/src/reviews-ui/review-list.tsx +193 -193
  91. package/src/reviews-ui/review-widget.tsx +219 -218
  92. package/src/reviews-ui/widget-options.ts +55 -55
  93. package/src/store/category-template.tsx +136 -136
  94. package/src/store/index.ts +40 -40
  95. package/src/store/labels.ts +10 -0
  96. package/src/tracking/chatgpt-pixel.tsx +99 -99
  97. package/src/tracking/consent-init.tsx +62 -62
  98. package/src/tracking/events.ts +348 -348
  99. package/src/tracking/ga4.tsx +93 -93
  100. package/src/tracking/google-ads.ts +84 -84
  101. package/src/tracking/gtm.tsx +60 -60
  102. package/src/tracking/inline-script.ts +49 -49
  103. package/src/tracking/oaiq.ts +206 -206
  104. package/src/tracking/tiktok-pixel.tsx +91 -91
  105. package/src/tracking/track-init.tsx +56 -56
  106. package/src/tracking/track-order-purchase.tsx +122 -122
  107. package/src/tracking/ttq.ts +180 -180
  108. package/src/tracking/use-tracking-config.ts +54 -54
  109. package/theme/index.css +25 -25
@@ -1,197 +1,197 @@
1
- "use client"
2
-
3
- import type { AppliedGiftCard } from "../api/carts"
4
- import { convertToLocale } from "../lib/money"
5
- import { useCartDrawer } from "./context"
6
-
7
- /**
8
- * CartSummaryBreakdown — full totals breakdown for the drawer (or a cart
9
- * page): subtotal, discount, shipping, tax, COD fee, total, applied
10
- * gift-card tender and the remainder. Alternative to the compact
11
- * `CartStickyFooter` (which deliberately shows a product-only subtotal in
12
- * pre-checkout context).
13
- *
14
- * Ported from `@1click/ui/src/cart-drawer/summary-breakdown.tsx` (v2.3.1)
15
- * and EXPANDED for Cartbase: the source rendered a client-summed product
16
- * subtotal only; Cartbase's decorated cart carries server-computed totals on
17
- * every read (`subtotal`, `discount_total`, `shipping_total`, `tax_total`,
18
- * `payment_method_fee_total`/`payment_method_fee_label`, `gift_cards[]`, `gift_card_total`,
19
- * `gift_card_remainder`, `total` — all EUR major units), so this component
20
- * renders EXACTLY what the server returns and computes no money. The only
21
- * client-side transformation is the display sign on deductions.
22
- *
23
- * Row rules (see `selectSummaryRows`):
24
- * - discount / tax / method-fee rows appear only when their total is > 0.
25
- * - the method fee renders the server's `payment_method_fee_label`
26
- * (labels.paymentMethodFee fallback).
27
- * - shipping renders the amount once a shipping method is set (0 → FREE);
28
- * before that it shows "calculated at checkout".
29
- * - gift-card tender renders one row per applied card (masked `last4`,
30
- * negative display) plus the `gift_card_remainder` row — the amount the
31
- * remainder provider actually charges. A depleted card stays listed at 0
32
- * (server keeps it in `gift_cards` at amount 0).
33
- */
34
-
35
- /** The decorated-cart fields the breakdown consumes (structural subset). */
36
- export type SummaryCartTotals = {
37
- subtotal: number
38
- discount_total: number
39
- shipping_total: number
40
- tax_total: number
41
- total: number
42
- payment_method_fee_total?: number
43
- payment_method_fee_label?: string | null
44
- gift_cards?: AppliedGiftCard[]
45
- gift_card_total?: number
46
- gift_card_remainder?: number
47
- shipping_methods?: ReadonlyArray<unknown>
48
- }
49
-
50
- export type SummaryRow =
51
- | { kind: "subtotal"; amount: number }
52
- | { kind: "discount"; amount: number }
53
- | { kind: "shipping"; amount: number }
54
- | { kind: "shipping_pending" }
55
- | { kind: "tax"; amount: number }
56
- | { kind: "payment_method_fee"; amount: number; label: string | null }
57
- | { kind: "total"; amount: number }
58
- | { kind: "gift_card"; last4: string; amount: number }
59
- | { kind: "remainder"; amount: number }
60
-
61
- /**
62
- * Pure row selection over the decorated-cart totals — exported for unit
63
- * tests. Amounts pass through verbatim (server truth); deduction rows
64
- * (discount, gift cards) carry a negated amount for display only.
65
- */
66
- export function selectSummaryRows(cart: SummaryCartTotals): SummaryRow[] {
67
- const rows: SummaryRow[] = []
68
- rows.push({ kind: "subtotal", amount: cart.subtotal })
69
-
70
- if ((cart.discount_total ?? 0) > 0) {
71
- rows.push({ kind: "discount", amount: -cart.discount_total })
72
- }
73
-
74
- if (cart.shipping_methods && cart.shipping_methods.length > 0) {
75
- rows.push({ kind: "shipping", amount: cart.shipping_total })
76
- } else {
77
- rows.push({ kind: "shipping_pending" })
78
- }
79
-
80
- if ((cart.tax_total ?? 0) > 0) {
81
- rows.push({ kind: "tax", amount: cart.tax_total })
82
- }
83
-
84
- if ((cart.payment_method_fee_total ?? 0) > 0) {
85
- rows.push({
86
- kind: "payment_method_fee",
87
- amount: cart.payment_method_fee_total!,
88
- label: cart.payment_method_fee_label ?? null,
89
- })
90
- }
91
-
92
- rows.push({ kind: "total", amount: cart.total })
93
-
94
- if (cart.gift_cards && cart.gift_cards.length > 0) {
95
- for (const card of cart.gift_cards) {
96
- // `|| 0` normalizes -0 (a depleted card at amount 0) so Intl never
97
- // renders a stray "-€0.00".
98
- rows.push({ kind: "gift_card", last4: card.last4, amount: -card.amount || 0 })
99
- }
100
- rows.push({ kind: "remainder", amount: cart.gift_card_remainder ?? 0 })
101
- }
102
-
103
- return rows
104
- }
105
-
106
- export function CartSummaryBreakdown() {
107
- const { cart, labels } = useCartDrawer()
108
- if (!cart) return null
109
-
110
- const currencyCode = cart.currency_code
111
- const money = (amount: number) =>
112
- convertToLocale({ amount, currency_code: currencyCode })
113
- const rows = selectSummaryRows(cart)
114
-
115
- return (
116
- <div className="px-6 py-5 space-y-2">
117
- {rows.map((row, i) => {
118
- switch (row.kind) {
119
- case "subtotal":
120
- return (
121
- <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
122
- <span>{labels.subtotal}</span>
123
- <span className="tabular-nums">{money(row.amount)}</span>
124
- </div>
125
- )
126
- case "discount":
127
- return (
128
- <div key={i} className="flex justify-between items-center text-[13px] text-success">
129
- <span>{labels.discount}</span>
130
- <span className="tabular-nums">{money(row.amount)}</span>
131
- </div>
132
- )
133
- case "shipping":
134
- return (
135
- <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
136
- <span>{labels.shipping}</span>
137
- <span className="tabular-nums">
138
- {row.amount === 0 ? labels.free : money(row.amount)}
139
- </span>
140
- </div>
141
- )
142
- case "shipping_pending":
143
- return (
144
- <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
145
- <span>{labels.shipping}</span>
146
- <span>{labels.calculatedAtCheckout}</span>
147
- </div>
148
- )
149
- case "tax":
150
- return (
151
- <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
152
- <span>{labels.tax}</span>
153
- <span className="tabular-nums">{money(row.amount)}</span>
154
- </div>
155
- )
156
- case "payment_method_fee":
157
- return (
158
- <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
159
- <span>{row.label ?? labels.paymentMethodFee}</span>
160
- <span className="tabular-nums">{money(row.amount)}</span>
161
- </div>
162
- )
163
- case "total":
164
- return (
165
- <div
166
- key={i}
167
- className="flex justify-between items-center pt-2 mt-1 border-t border-border"
168
- >
169
- <span className="text-[15px] font-bold text-foreground">
170
- {labels.total}
171
- </span>
172
- <span className="text-[17px] font-bold text-foreground tracking-tight tabular-nums">
173
- {money(row.amount)}
174
- </span>
175
- </div>
176
- )
177
- case "gift_card":
178
- return (
179
- <div key={i} className="flex justify-between items-center text-[13px] text-success">
180
- <span>
181
- {labels.giftCard} ••{row.last4}
182
- </span>
183
- <span className="tabular-nums">{money(row.amount)}</span>
184
- </div>
185
- )
186
- case "remainder":
187
- return (
188
- <div key={i} className="flex justify-between items-center text-[13px] font-semibold text-foreground">
189
- <span>{labels.remainingToPay}</span>
190
- <span className="tabular-nums">{money(row.amount)}</span>
191
- </div>
192
- )
193
- }
194
- })}
195
- </div>
196
- )
197
- }
1
+ "use client"
2
+
3
+ import type { AppliedGiftCard } from "../api/carts"
4
+ import { convertToLocale } from "../lib/money"
5
+ import { useCartDrawer } from "./context"
6
+
7
+ /**
8
+ * CartSummaryBreakdown — full totals breakdown for the drawer (or a cart
9
+ * page): subtotal, discount, shipping, tax, COD fee, total, applied
10
+ * gift-card tender and the remainder. Alternative to the compact
11
+ * `CartStickyFooter` (which deliberately shows a product-only subtotal in
12
+ * pre-checkout context).
13
+ *
14
+ * Ported from `@1click/ui/src/cart-drawer/summary-breakdown.tsx` (v2.3.1)
15
+ * and EXPANDED for Cartbase: the source rendered a client-summed product
16
+ * subtotal only; Cartbase's decorated cart carries server-computed totals on
17
+ * every read (`subtotal`, `discount_total`, `shipping_total`, `tax_total`,
18
+ * `payment_method_fee_total`/`payment_method_fee_label`, `gift_cards[]`, `gift_card_total`,
19
+ * `gift_card_remainder`, `total` — all EUR major units), so this component
20
+ * renders EXACTLY what the server returns and computes no money. The only
21
+ * client-side transformation is the display sign on deductions.
22
+ *
23
+ * Row rules (see `selectSummaryRows`):
24
+ * - discount / tax / method-fee rows appear only when their total is > 0.
25
+ * - the method fee renders the server's `payment_method_fee_label`
26
+ * (labels.paymentMethodFee fallback).
27
+ * - shipping renders the amount once a shipping method is set (0 → FREE);
28
+ * before that it shows "calculated at checkout".
29
+ * - gift-card tender renders one row per applied card (masked `last4`,
30
+ * negative display) plus the `gift_card_remainder` row — the amount the
31
+ * remainder provider actually charges. A depleted card stays listed at 0
32
+ * (server keeps it in `gift_cards` at amount 0).
33
+ */
34
+
35
+ /** The decorated-cart fields the breakdown consumes (structural subset). */
36
+ export type SummaryCartTotals = {
37
+ subtotal: number
38
+ discount_total: number
39
+ shipping_total: number
40
+ tax_total: number
41
+ total: number
42
+ payment_method_fee_total?: number
43
+ payment_method_fee_label?: string | null
44
+ gift_cards?: AppliedGiftCard[]
45
+ gift_card_total?: number
46
+ gift_card_remainder?: number
47
+ shipping_methods?: ReadonlyArray<unknown>
48
+ }
49
+
50
+ export type SummaryRow =
51
+ | { kind: "subtotal"; amount: number }
52
+ | { kind: "discount"; amount: number }
53
+ | { kind: "shipping"; amount: number }
54
+ | { kind: "shipping_pending" }
55
+ | { kind: "tax"; amount: number }
56
+ | { kind: "payment_method_fee"; amount: number; label: string | null }
57
+ | { kind: "total"; amount: number }
58
+ | { kind: "gift_card"; last4: string; amount: number }
59
+ | { kind: "remainder"; amount: number }
60
+
61
+ /**
62
+ * Pure row selection over the decorated-cart totals — exported for unit
63
+ * tests. Amounts pass through verbatim (server truth); deduction rows
64
+ * (discount, gift cards) carry a negated amount for display only.
65
+ */
66
+ export function selectSummaryRows(cart: SummaryCartTotals): SummaryRow[] {
67
+ const rows: SummaryRow[] = []
68
+ rows.push({ kind: "subtotal", amount: cart.subtotal })
69
+
70
+ if ((cart.discount_total ?? 0) > 0) {
71
+ rows.push({ kind: "discount", amount: -cart.discount_total })
72
+ }
73
+
74
+ if (cart.shipping_methods && cart.shipping_methods.length > 0) {
75
+ rows.push({ kind: "shipping", amount: cart.shipping_total })
76
+ } else {
77
+ rows.push({ kind: "shipping_pending" })
78
+ }
79
+
80
+ if ((cart.tax_total ?? 0) > 0) {
81
+ rows.push({ kind: "tax", amount: cart.tax_total })
82
+ }
83
+
84
+ if ((cart.payment_method_fee_total ?? 0) > 0) {
85
+ rows.push({
86
+ kind: "payment_method_fee",
87
+ amount: cart.payment_method_fee_total!,
88
+ label: cart.payment_method_fee_label ?? null,
89
+ })
90
+ }
91
+
92
+ rows.push({ kind: "total", amount: cart.total })
93
+
94
+ if (cart.gift_cards && cart.gift_cards.length > 0) {
95
+ for (const card of cart.gift_cards) {
96
+ // `|| 0` normalizes -0 (a depleted card at amount 0) so Intl never
97
+ // renders a stray "-€0.00".
98
+ rows.push({ kind: "gift_card", last4: card.last4, amount: -card.amount || 0 })
99
+ }
100
+ rows.push({ kind: "remainder", amount: cart.gift_card_remainder ?? 0 })
101
+ }
102
+
103
+ return rows
104
+ }
105
+
106
+ export function CartSummaryBreakdown() {
107
+ const { cart, labels } = useCartDrawer()
108
+ if (!cart) return null
109
+
110
+ const currencyCode = cart.currency_code
111
+ const money = (amount: number) =>
112
+ convertToLocale({ amount, currency_code: currencyCode })
113
+ const rows = selectSummaryRows(cart)
114
+
115
+ return (
116
+ <div className="px-6 py-5 space-y-2">
117
+ {rows.map((row, i) => {
118
+ switch (row.kind) {
119
+ case "subtotal":
120
+ return (
121
+ <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
122
+ <span>{labels.subtotal}</span>
123
+ <span className="tabular-nums">{money(row.amount)}</span>
124
+ </div>
125
+ )
126
+ case "discount":
127
+ return (
128
+ <div key={i} className="flex justify-between items-center text-[13px] text-success">
129
+ <span>{labels.discount}</span>
130
+ <span className="tabular-nums">{money(row.amount)}</span>
131
+ </div>
132
+ )
133
+ case "shipping":
134
+ return (
135
+ <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
136
+ <span>{labels.shipping}</span>
137
+ <span className="tabular-nums">
138
+ {row.amount === 0 ? labels.free : money(row.amount)}
139
+ </span>
140
+ </div>
141
+ )
142
+ case "shipping_pending":
143
+ return (
144
+ <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
145
+ <span>{labels.shipping}</span>
146
+ <span>{labels.calculatedAtCheckout}</span>
147
+ </div>
148
+ )
149
+ case "tax":
150
+ return (
151
+ <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
152
+ <span>{labels.tax}</span>
153
+ <span className="tabular-nums">{money(row.amount)}</span>
154
+ </div>
155
+ )
156
+ case "payment_method_fee":
157
+ return (
158
+ <div key={i} className="flex justify-between items-center text-[13px] text-muted-foreground">
159
+ <span>{row.label ?? labels.paymentMethodFee}</span>
160
+ <span className="tabular-nums">{money(row.amount)}</span>
161
+ </div>
162
+ )
163
+ case "total":
164
+ return (
165
+ <div
166
+ key={i}
167
+ className="flex justify-between items-center pt-2 mt-1 border-t border-border"
168
+ >
169
+ <span className="text-[15px] font-bold text-foreground">
170
+ {labels.total}
171
+ </span>
172
+ <span className="text-[17px] font-bold text-foreground tracking-tight tabular-nums">
173
+ {money(row.amount)}
174
+ </span>
175
+ </div>
176
+ )
177
+ case "gift_card":
178
+ return (
179
+ <div key={i} className="flex justify-between items-center text-[13px] text-success">
180
+ <span>
181
+ {labels.giftCard} ••{row.last4}
182
+ </span>
183
+ <span className="tabular-nums">{money(row.amount)}</span>
184
+ </div>
185
+ )
186
+ case "remainder":
187
+ return (
188
+ <div key={i} className="flex justify-between items-center text-[13px] font-semibold text-foreground">
189
+ <span>{labels.remainingToPay}</span>
190
+ <span className="tabular-nums">{money(row.amount)}</span>
191
+ </div>
192
+ )
193
+ }
194
+ })}
195
+ </div>
196
+ )
197
+ }
@@ -1,117 +1,117 @@
1
- import type { AddressErrorCopy } from "./labels"
2
-
3
- /**
4
- * Recognizes an address-save failure and picks the copy for it.
5
- *
6
- * THE COPY IS NOT HERE. Until 2026-09-13 this file held thirteen hardcoded
7
- * BULGARIAN sentences, so every merchant who installed the package showed
8
- * Bulgarian on the one screen where a shopper is about to pay, with no way
9
- * to override it. The copy now lives in the checkout label pack like all
10
- * other copy, which means it is English by default and translated by
11
- * whichever locale the store mounts.
12
- *
13
- * WHAT STAYS HERE IS THE RECOGNITION, and it stays on purpose: the codes
14
- * and the patterns match the API's own error text, which is English wire
15
- * format, not user-facing copy. Translating the matcher would break it.
16
- *
17
- * Ported from `@1click/ui/src/checkout/address-error-copy.ts` (v2.3.1) and
18
- * adapted to the Cartbase error envelope: `StoreApiError` carries a
19
- * structured `code` (POST /api/store/carts/:id, docs/storefront/carts.md),
20
- * so recognition is code-first and the substring patterns are the fallback
21
- * for network failures and any legacy wire.
22
- *
23
- * The mapping is deliberately narrow: only failures we recognize get their
24
- * own sentence, and everything else gets the generic one rather than
25
- * half-translated technical text.
26
- */
27
-
28
- /** Cartbase cart-update error codes, mapped to a copy key. Code-first. */
29
- export const ADDRESS_CODE_KEYS: Record<string, keyof AddressErrorCopy> = {
30
- validation_failed: "validationFailed",
31
- invalid_region: "invalidRegion",
32
- region_required: "regionRequired",
33
- cart_completed: "cartCompleted",
34
- cart_not_found: "cartNotFound",
35
- }
36
-
37
- /**
38
- * Patterns matched in order, first match wins: most specific first,
39
- * generic last. Case-insensitive. Kept verbatim from the production port.
40
- */
41
- const PATTERNS: Array<{ match: RegExp; key: keyof AddressErrorCopy }> = [
42
- // Email validation — confirmed (in @1click production) as the most common
43
- // address-save failure shape.
44
- {
45
- match: /invalid[\s_]?(?:request:?\s*)?invalid[\s_]?email|invalid[\s_]?email/i,
46
- key: "invalidEmail",
47
- },
48
- {
49
- match:
50
- /invalid[\s_]?postal|invalid[\s_]?post(?:code|al[\s_]?code)|postal[\s_]?code[\s_]?(?:is[\s_]?)?invalid/i,
51
- key: "invalidPostalCode",
52
- },
53
- {
54
- match: /invalid[\s_]?phone|phone[\s_]?(?:number[\s_]?)?(?:is[\s_]?)?invalid/i,
55
- key: "invalidPhone",
56
- },
57
- {
58
- match:
59
- /invalid[\s_]?country|country[\s_]?(?:code[\s_]?)?(?:is[\s_]?)?invalid|no[\s_]?region|invalid[\s_]?region/i,
60
- key: "invalidCountry",
61
- },
62
- // Cart already completed — race with order placement.
63
- {
64
- match:
65
- /cart[\s_]?(?:is[\s_]?)?already[\s_]?completed|already[\s_]?an[\s_]?order|completed[\s_]?cart/i,
66
- key: "cartCompleted",
67
- },
68
- {
69
- match: /cart[\s_]?(?:not[\s_]?found|does[\s_]?not[\s_]?exist|expired)/i,
70
- key: "cartNotFound",
71
- },
72
- {
73
- match: /failed[\s_]?to[\s_]?fetch|network[\s_]?error|networkerror|econnreset|etimedout/i,
74
- key: "network",
75
- },
76
- // The Next.js canned Server Components production message — surface a
77
- // clean fallback instead of the English placeholder. (The Cartbase SDK
78
- // throws from the browser, so this only fires for stores that proxy the
79
- // save through a server action.)
80
- {
81
- match: /server[\s_]?components[\s_]?render|digest property is included/i,
82
- key: "generic",
83
- },
84
- ]
85
-
86
- /** Which copy an error asks for, independent of language. */
87
- export function addressErrorKey(err: unknown): keyof AddressErrorCopy {
88
- if (err && typeof err === "object") {
89
- const code = (err as { code?: unknown }).code
90
- if (typeof code === "string" && ADDRESS_CODE_KEYS[code]) return ADDRESS_CODE_KEYS[code]
91
- }
92
- const raw = extractMessage(err)
93
- if (!raw) return "generic"
94
- for (const { match, key } of PATTERNS) {
95
- if (match.test(raw)) return key
96
- }
97
- return "generic"
98
- }
99
-
100
- /**
101
- * Turn any thrown or returned address-save error into the sentence a
102
- * shopper reads, in the store's own language.
103
- */
104
- export function translateAddressError(err: unknown, copy: AddressErrorCopy): string {
105
- return copy[addressErrorKey(err)]
106
- }
107
-
108
- function extractMessage(err: unknown): string {
109
- if (!err) return ""
110
- if (typeof err === "string") return err
111
- if (err instanceof Error) return err.message
112
- if (typeof err === "object" && err !== null) {
113
- const e = err as { message?: string; code?: string; type?: string }
114
- return [e.message, e.code, e.type].filter(Boolean).join(" ")
115
- }
116
- return String(err)
117
- }
1
+ import type { AddressErrorCopy } from "./labels"
2
+
3
+ /**
4
+ * Recognizes an address-save failure and picks the copy for it.
5
+ *
6
+ * THE COPY IS NOT HERE. Until 2026-09-13 this file held thirteen hardcoded
7
+ * BULGARIAN sentences, so every merchant who installed the package showed
8
+ * Bulgarian on the one screen where a shopper is about to pay, with no way
9
+ * to override it. The copy now lives in the checkout label pack like all
10
+ * other copy, which means it is English by default and translated by
11
+ * whichever locale the store mounts.
12
+ *
13
+ * WHAT STAYS HERE IS THE RECOGNITION, and it stays on purpose: the codes
14
+ * and the patterns match the API's own error text, which is English wire
15
+ * format, not user-facing copy. Translating the matcher would break it.
16
+ *
17
+ * Ported from `@1click/ui/src/checkout/address-error-copy.ts` (v2.3.1) and
18
+ * adapted to the Cartbase error envelope: `StoreApiError` carries a
19
+ * structured `code` (POST /api/store/carts/:id, docs/storefront/carts.md),
20
+ * so recognition is code-first and the substring patterns are the fallback
21
+ * for network failures and any legacy wire.
22
+ *
23
+ * The mapping is deliberately narrow: only failures we recognize get their
24
+ * own sentence, and everything else gets the generic one rather than
25
+ * half-translated technical text.
26
+ */
27
+
28
+ /** Cartbase cart-update error codes, mapped to a copy key. Code-first. */
29
+ export const ADDRESS_CODE_KEYS: Record<string, keyof AddressErrorCopy> = {
30
+ validation_failed: "validationFailed",
31
+ invalid_region: "invalidRegion",
32
+ region_required: "regionRequired",
33
+ cart_completed: "cartCompleted",
34
+ cart_not_found: "cartNotFound",
35
+ }
36
+
37
+ /**
38
+ * Patterns matched in order, first match wins: most specific first,
39
+ * generic last. Case-insensitive. Kept verbatim from the production port.
40
+ */
41
+ const PATTERNS: Array<{ match: RegExp; key: keyof AddressErrorCopy }> = [
42
+ // Email validation — confirmed (in @1click production) as the most common
43
+ // address-save failure shape.
44
+ {
45
+ match: /invalid[\s_]?(?:request:?\s*)?invalid[\s_]?email|invalid[\s_]?email/i,
46
+ key: "invalidEmail",
47
+ },
48
+ {
49
+ match:
50
+ /invalid[\s_]?postal|invalid[\s_]?post(?:code|al[\s_]?code)|postal[\s_]?code[\s_]?(?:is[\s_]?)?invalid/i,
51
+ key: "invalidPostalCode",
52
+ },
53
+ {
54
+ match: /invalid[\s_]?phone|phone[\s_]?(?:number[\s_]?)?(?:is[\s_]?)?invalid/i,
55
+ key: "invalidPhone",
56
+ },
57
+ {
58
+ match:
59
+ /invalid[\s_]?country|country[\s_]?(?:code[\s_]?)?(?:is[\s_]?)?invalid|no[\s_]?region|invalid[\s_]?region/i,
60
+ key: "invalidCountry",
61
+ },
62
+ // Cart already completed — race with order placement.
63
+ {
64
+ match:
65
+ /cart[\s_]?(?:is[\s_]?)?already[\s_]?completed|already[\s_]?an[\s_]?order|completed[\s_]?cart/i,
66
+ key: "cartCompleted",
67
+ },
68
+ {
69
+ match: /cart[\s_]?(?:not[\s_]?found|does[\s_]?not[\s_]?exist|expired)/i,
70
+ key: "cartNotFound",
71
+ },
72
+ {
73
+ match: /failed[\s_]?to[\s_]?fetch|network[\s_]?error|networkerror|econnreset|etimedout/i,
74
+ key: "network",
75
+ },
76
+ // The Next.js canned Server Components production message — surface a
77
+ // clean fallback instead of the English placeholder. (The Cartbase SDK
78
+ // throws from the browser, so this only fires for stores that proxy the
79
+ // save through a server action.)
80
+ {
81
+ match: /server[\s_]?components[\s_]?render|digest property is included/i,
82
+ key: "generic",
83
+ },
84
+ ]
85
+
86
+ /** Which copy an error asks for, independent of language. */
87
+ export function addressErrorKey(err: unknown): keyof AddressErrorCopy {
88
+ if (err && typeof err === "object") {
89
+ const code = (err as { code?: unknown }).code
90
+ if (typeof code === "string" && ADDRESS_CODE_KEYS[code]) return ADDRESS_CODE_KEYS[code]
91
+ }
92
+ const raw = extractMessage(err)
93
+ if (!raw) return "generic"
94
+ for (const { match, key } of PATTERNS) {
95
+ if (match.test(raw)) return key
96
+ }
97
+ return "generic"
98
+ }
99
+
100
+ /**
101
+ * Turn any thrown or returned address-save error into the sentence a
102
+ * shopper reads, in the store's own language.
103
+ */
104
+ export function translateAddressError(err: unknown, copy: AddressErrorCopy): string {
105
+ return copy[addressErrorKey(err)]
106
+ }
107
+
108
+ function extractMessage(err: unknown): string {
109
+ if (!err) return ""
110
+ if (typeof err === "string") return err
111
+ if (err instanceof Error) return err.message
112
+ if (typeof err === "object" && err !== null) {
113
+ const e = err as { message?: string; code?: string; type?: string }
114
+ return [e.message, e.code, e.type].filter(Boolean).join(" ")
115
+ }
116
+ return String(err)
117
+ }