@cartbase/storefront 0.9.0 → 0.11.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.
- package/package.json +11 -10
- package/src/api/regions.ts +64 -5
- package/src/cart-drawer/index.ts +0 -1
- package/src/checkout/address-error-copy.ts +117 -119
- package/src/checkout/address-form.tsx +6 -0
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -0
- package/src/checkout/gift-card-section.tsx +2 -2
- package/src/checkout/index.ts +4 -5
- package/src/checkout/labels.ts +218 -0
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -269
- package/src/checkout/promotion-error-copy.ts +91 -124
- package/src/checkout/use-checkout-orchestration.ts +55 -15
- package/src/common/country-flag.tsx +52 -0
- package/src/common/index.ts +1 -0
- package/src/locales/bg.ts +468 -0
- package/src/locales/en.ts +26 -0
- package/src/locales/es.ts +467 -0
- package/src/locales/index.ts +19 -0
- package/src/locales/provider.tsx +63 -0
- package/src/locales/types.ts +49 -0
- package/src/order/index.ts +0 -1
- package/src/primitives/select-field.tsx +19 -3
- package/src/products/index.ts +0 -1
- package/src/reviews-ui/index.ts +0 -1
- package/src/store/index.ts +0 -1
- package/src/tracking/consent-banner.tsx +5 -1
- package/src/cart-drawer/labels-bg.ts +0 -72
- package/src/checkout/labels-bg.ts +0 -128
- package/src/order/labels-bg.ts +0 -39
- package/src/products/labels-bg.ts +0 -35
- package/src/reviews-ui/labels-bg.ts +0 -91
- package/src/store/labels-bg.ts +0 -22
|
@@ -1,124 +1,91 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
opts?: { hasEmail?: boolean }
|
|
93
|
-
): string {
|
|
94
|
-
if (err && typeof err === "object") {
|
|
95
|
-
const code = (err as { code?: unknown }).code
|
|
96
|
-
if (typeof code === "string" && PROMOTION_ERROR_CODE_COPY[code]) {
|
|
97
|
-
return PROMOTION_ERROR_CODE_COPY[code]
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const raw = extractMessage(err)
|
|
102
|
-
|
|
103
|
-
for (const { match, copy } of PROMO_PATTERNS) {
|
|
104
|
-
if (match.test(raw)) return copy
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Unknown / opaque error. If the cart has no email yet, the
|
|
108
|
-
// campaign-budget case is by far the most common trigger — point the
|
|
109
|
-
// shopper at the fix rather than a dead end.
|
|
110
|
-
if (opts?.hasEmail === false) return NEEDS_EMAIL
|
|
111
|
-
|
|
112
|
-
return GENERIC
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function extractMessage(err: unknown): string {
|
|
116
|
-
if (!err) return ""
|
|
117
|
-
if (typeof err === "string") return err
|
|
118
|
-
if (err instanceof Error) return err.message
|
|
119
|
-
if (typeof err === "object" && err !== null) {
|
|
120
|
-
const e = err as { message?: string; code?: string; type?: string }
|
|
121
|
-
return [e.message, e.code, e.type].filter(Boolean).join(" ")
|
|
122
|
-
}
|
|
123
|
-
return String(err)
|
|
124
|
-
}
|
|
1
|
+
import { PROMOTION_CODE_KEYS } from "./error-copy-codes"
|
|
2
|
+
import type { PromotionErrorCopy } from "./labels"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Recognizes a discount-code failure and picks the copy key for it.
|
|
6
|
+
*
|
|
7
|
+
* THE COPY IS NOT HERE ANY MORE. Until 2026-09-13 this file held seven
|
|
8
|
+
* hardcoded BULGARIAN sentences that every merchant shipped and none could
|
|
9
|
+
* override; the words now live in the checkout label pack, English by
|
|
10
|
+
* default. The substring patterns stay in English because they match the
|
|
11
|
+
* API's own error text, which is wire format and never shown.
|
|
12
|
+
*
|
|
13
|
+
* Recognition is code-first (`StoreApiError.code`), then the substrings
|
|
14
|
+
* kept verbatim from the production port.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Known promo error substrings. Case-insensitive, first match wins, most
|
|
19
|
+
* specific first.
|
|
20
|
+
*/
|
|
21
|
+
const PROMO_PATTERNS: Array<{ match: RegExp; key: keyof PromotionErrorCopy }> = [
|
|
22
|
+
// Campaign budget needs an email on the cart.
|
|
23
|
+
{ match: /customer_email|campaign[\s_]?budget/i, key: "needsEmail" },
|
|
24
|
+
// Expired or no-longer-active promotion.
|
|
25
|
+
{ match: /expired|no[\s_]?longer[\s_]?(?:active|valid)|inactive/i, key: "promotionInactive" },
|
|
26
|
+
// Budget or usage limit reached.
|
|
27
|
+
{
|
|
28
|
+
match: /reached[\s_]?its[\s_]?limit|usage[\s_]?limit|budget[\s_]?(?:exceeded|reached)/i,
|
|
29
|
+
key: "exhausted",
|
|
30
|
+
},
|
|
31
|
+
// Invalid or unknown code. Last of the specific set, because it is the
|
|
32
|
+
// broadest pattern and would otherwise swallow the ones above.
|
|
33
|
+
{
|
|
34
|
+
match: /is[\s_]?invalid|invalid[\s_]?data|not[\s_]?found|does(?:n'?t| not)[\s_]?exist/i,
|
|
35
|
+
key: "promotionNotFound",
|
|
36
|
+
},
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Which promotion copy this failure asks for.
|
|
41
|
+
*
|
|
42
|
+
* @param err the error from a try/catch: a StoreApiError with a code, a
|
|
43
|
+
* network error, or an opaque render error
|
|
44
|
+
* @param opts.hasEmail whether the cart currently has an email. When the
|
|
45
|
+
* error is opaque and there is no email, the overwhelmingly
|
|
46
|
+
* likely cause is a campaign-budget promotion, so the shopper
|
|
47
|
+
* gets the actionable "enter your email" line rather than a
|
|
48
|
+
* vague fallback.
|
|
49
|
+
*/
|
|
50
|
+
export function promotionErrorKey(
|
|
51
|
+
err: unknown,
|
|
52
|
+
opts?: { hasEmail?: boolean }
|
|
53
|
+
): keyof PromotionErrorCopy {
|
|
54
|
+
if (err && typeof err === "object") {
|
|
55
|
+
const code = (err as { code?: unknown }).code
|
|
56
|
+
if (typeof code === "string" && PROMOTION_CODE_KEYS[code]) {
|
|
57
|
+
return PROMOTION_CODE_KEYS[code]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const raw = extractMessage(err)
|
|
62
|
+
for (const { match, key } of PROMO_PATTERNS) if (match.test(raw)) return key
|
|
63
|
+
|
|
64
|
+
// Unknown or opaque. With no email on the cart the campaign-budget case
|
|
65
|
+
// is by far the most common trigger, so point at the fix, not a dead end.
|
|
66
|
+
if (opts?.hasEmail === false) return "needsEmail"
|
|
67
|
+
return "generic"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Turn any thrown or returned promotion error into the sentence a shopper
|
|
72
|
+
* reads, in the store's own language.
|
|
73
|
+
*/
|
|
74
|
+
export function translatePromotionError(
|
|
75
|
+
err: unknown,
|
|
76
|
+
copy: PromotionErrorCopy,
|
|
77
|
+
opts?: { hasEmail?: boolean }
|
|
78
|
+
): string {
|
|
79
|
+
return copy[promotionErrorKey(err, opts)]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function extractMessage(err: unknown): string {
|
|
83
|
+
if (!err) return ""
|
|
84
|
+
if (typeof err === "string") return err
|
|
85
|
+
if (err instanceof Error) return err.message
|
|
86
|
+
if (typeof err === "object" && err !== null) {
|
|
87
|
+
const e = err as { message?: string; code?: string; type?: string }
|
|
88
|
+
return [e.message, e.code, e.type].filter(Boolean).join(" ")
|
|
89
|
+
}
|
|
90
|
+
return String(err)
|
|
91
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "react"
|
|
11
11
|
|
|
12
12
|
import type { StorefrontClient } from "../api/http"
|
|
13
|
+
import { listCountries, type StoreCountry } from "../api/regions"
|
|
13
14
|
import {
|
|
14
15
|
completeCart,
|
|
15
16
|
updateCart,
|
|
@@ -42,7 +43,7 @@ import { isStripeLike } from "../lib/payment-constants"
|
|
|
42
43
|
import compareAddresses from "./compare-addresses"
|
|
43
44
|
import { translateAddressError } from "./address-error-copy"
|
|
44
45
|
import { translatePaymentError } from "./payment-error-copy"
|
|
45
|
-
import { useOrderConfirmedPath } from "./context"
|
|
46
|
+
import { useCheckoutLabels, useOrderConfirmedPath } from "./context"
|
|
46
47
|
import type { EcontOffice } from "./econt-office-selector"
|
|
47
48
|
import type { BoxNowLocker } from "../api/integrations"
|
|
48
49
|
|
|
@@ -267,6 +268,9 @@ export function useCheckoutOrchestration({
|
|
|
267
268
|
logError: logErrorProp,
|
|
268
269
|
debug = false,
|
|
269
270
|
}: UseCheckoutOrchestrationOptions) {
|
|
271
|
+
// The store's language, for the sentences a shopper reads when a save
|
|
272
|
+
// fails. English unless a locale pack is mounted above.
|
|
273
|
+
const checkoutLabels = useCheckoutLabels()
|
|
270
274
|
// The default sink: the platform's own checkout error log. Browser money
|
|
271
275
|
// failures (Stripe confirm errors, failed 3DS returns, place-order
|
|
272
276
|
// rejections) land in checkout_error_logs step `browser` with no merchant
|
|
@@ -359,17 +363,54 @@ export function useCheckoutOrchestration({
|
|
|
359
363
|
: true
|
|
360
364
|
)
|
|
361
365
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
366
|
+
/**
|
|
367
|
+
* The store's own country list, fetched when the app did not pass one.
|
|
368
|
+
*
|
|
369
|
+
* Until 2026-09-13 an app that passed nothing got a ONE-ENTRY list built
|
|
370
|
+
* from `countryCode`, and the address form renders a read-only box for a
|
|
371
|
+
* single entry, so every scaffolded store shipped a checkout offering
|
|
372
|
+
* exactly one country that the shopper could not change. The list is the
|
|
373
|
+
* platform's to answer (`GET /api/store/countries`: the store's Markets
|
|
374
|
+
* when it has declared any, the whole catalogue when it has not), so the
|
|
375
|
+
* hook asks instead of guessing.
|
|
376
|
+
*
|
|
377
|
+
* A failure here must never block a checkout: the `countryCode` fallback
|
|
378
|
+
* below still stands, and the error is reported through `logError`.
|
|
379
|
+
*/
|
|
380
|
+
const [storeCountries, setStoreCountries] = useState<StoreCountry[] | null>(null)
|
|
381
|
+
const appSuppliedCountries = !!(countries && countries.length)
|
|
382
|
+
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
if (appSuppliedCountries) return
|
|
385
|
+
let alive = true
|
|
386
|
+
listCountries(client)
|
|
387
|
+
.then((res) => {
|
|
388
|
+
if (alive) setStoreCountries(res.countries)
|
|
389
|
+
})
|
|
390
|
+
.catch((e: unknown) => {
|
|
391
|
+
logError(
|
|
392
|
+
"country_list_failed",
|
|
393
|
+
e instanceof Error ? e.message : "could not load the country list"
|
|
394
|
+
)
|
|
395
|
+
})
|
|
396
|
+
return () => {
|
|
397
|
+
alive = false
|
|
398
|
+
}
|
|
399
|
+
}, [client, appSuppliedCountries, logError])
|
|
400
|
+
|
|
401
|
+
// The app's own list wins, then the store's, then the single-country
|
|
402
|
+
// fallback. This is the authority for both the select options and the
|
|
403
|
+
// saved-address region filter.
|
|
365
404
|
const regionCountries = useMemo(
|
|
366
405
|
() =>
|
|
367
406
|
countries && countries.length
|
|
368
407
|
? countries
|
|
369
|
-
:
|
|
370
|
-
?
|
|
371
|
-
:
|
|
372
|
-
|
|
408
|
+
: storeCountries && storeCountries.length
|
|
409
|
+
? storeCountries
|
|
410
|
+
: countryCode
|
|
411
|
+
? [{ iso_2: countryCode, display_name: "" }]
|
|
412
|
+
: [],
|
|
413
|
+
[countries, storeCountries, countryCode]
|
|
373
414
|
)
|
|
374
415
|
|
|
375
416
|
const countriesInRegion = useMemo(
|
|
@@ -578,12 +619,11 @@ export function useCheckoutOrchestration({
|
|
|
578
619
|
postal_code: formData["shipping_address.postal_code"] || null,
|
|
579
620
|
}
|
|
580
621
|
)
|
|
581
|
-
//
|
|
582
|
-
// English
|
|
583
|
-
// show a
|
|
584
|
-
//
|
|
585
|
-
|
|
586
|
-
setAddressError(translateAddressError(errObj ?? e))
|
|
622
|
+
// Into the store's own language. The raw error.message is technical
|
|
623
|
+
// English or a validation envelope, and neither is acceptable to
|
|
624
|
+
// show a shopper at the moment of failure. Unknown errors get the
|
|
625
|
+
// generic sentence rather than half-translated text.
|
|
626
|
+
setAddressError(translateAddressError(errObj ?? e, checkoutLabels.addressErrors))
|
|
587
627
|
} finally {
|
|
588
628
|
setAddressSaving(false)
|
|
589
629
|
addressSavingRef.current = false
|
|
@@ -1079,7 +1119,7 @@ export function useCheckoutOrchestration({
|
|
|
1079
1119
|
|
|
1080
1120
|
if (redirectStatus === "succeeded") {
|
|
1081
1121
|
placeOrder().catch((err: unknown) => {
|
|
1082
|
-
const translated = translatePaymentError(err, "card")
|
|
1122
|
+
const translated = translatePaymentError(err, "card", checkoutLabels.paymentErrors)
|
|
1083
1123
|
setPaymentError(translated)
|
|
1084
1124
|
logError?.(
|
|
1085
1125
|
"place_order_error",
|
|
@@ -0,0 +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
|
+
}
|
package/src/common/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export { LocalizedLink, type LocalizedLinkProps } from "./localized-link"
|
|
|
8
8
|
export { CartButton } from "./cart-button"
|
|
9
9
|
export { CartButtonClient } from "./cart-button-client"
|
|
10
10
|
export { DeleteButton, type DeleteButtonProps } from "./delete-button"
|
|
11
|
+
export { CountryFlag } from "./country-flag"
|
|
11
12
|
export { CountrySelect, type CountrySelectProps } from "./country-select"
|
|
12
13
|
export {
|
|
13
14
|
LanguageSelect,
|