@cartbase/storefront 0.10.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cartbase/storefront",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Storefront SDK + UI component library for Cartbase stores: typed API client, checkout orchestration, cart drawer, product/catalog components, tracking. Source-shipped TypeScript — add it to transpilePackages.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -190,6 +190,7 @@
|
|
|
190
190
|
"./common/cart-button": "./src/common/cart-button.tsx",
|
|
191
191
|
"./common/cart-button-client": "./src/common/cart-button-client.tsx",
|
|
192
192
|
"./common/delete-button": "./src/common/delete-button.tsx",
|
|
193
|
+
"./common/country-flag": "./src/common/country-flag.tsx",
|
|
193
194
|
"./common/country-select": "./src/common/country-select.tsx",
|
|
194
195
|
"./common/language-select": "./src/common/language-select.tsx",
|
|
195
196
|
"./common/skeleton": "./src/common/skeleton.tsx",
|
|
@@ -200,19 +201,20 @@
|
|
|
200
201
|
"./locales/bg": "./src/locales/bg.ts"
|
|
201
202
|
},
|
|
202
203
|
"dependencies": {
|
|
203
|
-
"@stripe/react-stripe-js": "^6.1.0",
|
|
204
|
-
"@stripe/stripe-js": "^9.1.0",
|
|
205
|
-
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
206
204
|
"@radix-ui/react-accordion": "^1.2.12",
|
|
207
205
|
"@radix-ui/react-collapsible": "^1.1.12",
|
|
208
206
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
207
|
+
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
209
208
|
"@radix-ui/react-label": "^2.1.8",
|
|
210
209
|
"@radix-ui/react-popover": "^1.1.15",
|
|
211
210
|
"@radix-ui/react-select": "^2.2.6",
|
|
212
211
|
"@radix-ui/react-slot": "^1.2.4",
|
|
213
212
|
"@radix-ui/react-tabs": "^1.1.13",
|
|
213
|
+
"@stripe/react-stripe-js": "^6.1.0",
|
|
214
|
+
"@stripe/stripe-js": "^9.1.0",
|
|
214
215
|
"class-variance-authority": "^0.7.1",
|
|
215
216
|
"clsx": "^2.1.1",
|
|
217
|
+
"flag-icons": "^7.5.0",
|
|
216
218
|
"lucide-react": "^1.8.0",
|
|
217
219
|
"tailwind-merge": "^3.5.0"
|
|
218
220
|
},
|
package/src/api/regions.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @cartbase/storefront/api/regions — regions, currencies, locales.
|
|
2
|
+
* @cartbase/storefront/api/regions — regions, countries, currencies, locales.
|
|
3
3
|
*
|
|
4
4
|
* Catalog-context primitives every storefront resolves at boot: which
|
|
5
|
-
* regions exist (→ `region_id` for the pricing context), which
|
|
6
|
-
*
|
|
7
|
-
* reads (`x-client-id` scoping via RLS). Docs:
|
|
5
|
+
* regions exist (→ `region_id` for the pricing context), which countries the
|
|
6
|
+
* store sells to, which currencies it has enabled, which locales it serves.
|
|
7
|
+
* All endpoints are anon reads (`x-client-id` scoping via RLS). Docs:
|
|
8
|
+
* docs/storefront/regions.md.
|
|
8
9
|
*/
|
|
9
10
|
import type { StorefrontClient } from "./http"
|
|
10
11
|
import type { IsoDateString, ListEnvelope, PaginationQuery } from "./types"
|
|
@@ -12,7 +13,9 @@ import type { IsoDateString, ListEnvelope, PaginationQuery } from "./types"
|
|
|
12
13
|
/**
|
|
13
14
|
* A store region (`barter_commerce.regions` row). NOTE: Cartbase regions carry
|
|
14
15
|
* NO embedded `countries` array on the store surface (divergence from
|
|
15
|
-
* Medusa's Store API — tax scoping is by `tax_regions` server-side).
|
|
16
|
+
* Medusa's Store API — tax scoping is by `tax_regions` server-side). The
|
|
17
|
+
* countries are their own call, `listCountries`, and each row carries the
|
|
18
|
+
* `region_id` it belongs to.
|
|
16
19
|
*/
|
|
17
20
|
export interface StoreRegion {
|
|
18
21
|
id: string
|
|
@@ -38,6 +41,35 @@ export interface StoreCurrency {
|
|
|
38
41
|
updated_at: IsoDateString
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/**
|
|
45
|
+
* A country a shopper may choose (`barter_commerce.countries` row, the store's
|
|
46
|
+
* own tenant catalogue).
|
|
47
|
+
*/
|
|
48
|
+
export interface StoreCountry {
|
|
49
|
+
/** Lowercase ISO-3166 alpha-2, e.g. "bg" — the value a cart address takes. */
|
|
50
|
+
iso_2: string
|
|
51
|
+
/** What a shopper reads, e.g. "Bulgaria". The code is never shown as copy. */
|
|
52
|
+
display_name: string
|
|
53
|
+
/** The catalogue's uppercase form, e.g. "BULGARIA". */
|
|
54
|
+
name: string
|
|
55
|
+
/** The region this country belongs to, null when the store has not placed it. */
|
|
56
|
+
region_id: string | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ListCountriesQuery extends PaginationQuery {
|
|
60
|
+
/** Case-insensitive substring match on the displayed name. */
|
|
61
|
+
q?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CountryListResponse extends ListEnvelope {
|
|
65
|
+
countries: StoreCountry[]
|
|
66
|
+
/**
|
|
67
|
+
* True when this list IS the store's Markets declaration; false when the
|
|
68
|
+
* store has declared nothing and this is the whole ISO catalogue.
|
|
69
|
+
*/
|
|
70
|
+
restricted: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
41
73
|
export interface ListRegionsQuery extends PaginationQuery {
|
|
42
74
|
/** Case-insensitive substring match on region name. */
|
|
43
75
|
q?: string
|
|
@@ -98,6 +130,33 @@ export async function retrieveRegion(
|
|
|
98
130
|
return client.get(`/api/store/regions/${encodeURIComponent(regionId)}`)
|
|
99
131
|
}
|
|
100
132
|
|
|
133
|
+
/**
|
|
134
|
+
* List the countries a shopper may choose at checkout, ordered by the name
|
|
135
|
+
* they read.
|
|
136
|
+
*
|
|
137
|
+
* THE RULE IS THE PLATFORM'S, NOT THE STOREFRONT'S: the store's own Markets
|
|
138
|
+
* decide where it sells, and a store that has decided nothing yet sells to
|
|
139
|
+
* the whole world. So this answers with the countries the store's regions
|
|
140
|
+
* declare, and with the whole 250-entry catalogue when it declares none;
|
|
141
|
+
* `restricted` says which you got. A storefront therefore never hardcodes a
|
|
142
|
+
* country list, and never has to invent a fallback of its own.
|
|
143
|
+
*
|
|
144
|
+
* `useCheckoutOrchestration` calls this for you when you do not pass it a
|
|
145
|
+
* `countries` list, so a new store offers every country with no wiring at
|
|
146
|
+
* all. Pass your own list only to override the store's answer.
|
|
147
|
+
*
|
|
148
|
+
* Auth: anon (`x-client-id`). Errors: 400 `missing_client_id`,
|
|
149
|
+
* 400 `validation_failed`. Settings: Admin → Settings → Markets → Regions,
|
|
150
|
+
* where a region owns its countries; `limit` 1–300 (default 300, so one
|
|
151
|
+
* call carries the entire catalogue).
|
|
152
|
+
*/
|
|
153
|
+
export async function listCountries(
|
|
154
|
+
client: StorefrontClient,
|
|
155
|
+
query?: ListCountriesQuery
|
|
156
|
+
): Promise<CountryListResponse> {
|
|
157
|
+
return client.get("/api/store/countries", { query: { ...query } })
|
|
158
|
+
}
|
|
159
|
+
|
|
101
160
|
/**
|
|
102
161
|
* List the currencies ENABLED on this store (the `store_currencies` link
|
|
103
162
|
* filters the shared currency catalog). A store with none enabled returns an
|
|
@@ -4,6 +4,7 @@ import type { ChangeEvent } from "react"
|
|
|
4
4
|
|
|
5
5
|
import type { CartAddress } from "../api/carts"
|
|
6
6
|
import type { CustomerAddress } from "../api/customers"
|
|
7
|
+
import { CountryFlag } from "../common/country-flag"
|
|
7
8
|
import { Field } from "../primitives/field"
|
|
8
9
|
import { SelectField } from "../primitives/select-field"
|
|
9
10
|
import { AddressSelect } from "./address-select"
|
|
@@ -127,6 +128,11 @@ export function CheckoutAddressForm({
|
|
|
127
128
|
value={formData["shipping_address.country_code"] ?? ""}
|
|
128
129
|
onChange={onChange}
|
|
129
130
|
required
|
|
131
|
+
// The chosen country's flag, inside the control. It stays a
|
|
132
|
+
// NATIVE select on purpose: a custom listbox would draw a flag
|
|
133
|
+
// per row but lose `autocomplete="country"` and the phone's own
|
|
134
|
+
// picker, and a checkout does not trade autofill for decoration.
|
|
135
|
+
leading={<CountryFlag code={formData["shipping_address.country_code"]} />}
|
|
130
136
|
>
|
|
131
137
|
<option value="" disabled />
|
|
132
138
|
{countries.map((c) => (
|
|
@@ -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,
|
|
@@ -362,17 +363,54 @@ export function useCheckoutOrchestration({
|
|
|
362
363
|
: true
|
|
363
364
|
)
|
|
364
365
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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.
|
|
368
404
|
const regionCountries = useMemo(
|
|
369
405
|
() =>
|
|
370
406
|
countries && countries.length
|
|
371
407
|
? countries
|
|
372
|
-
:
|
|
373
|
-
?
|
|
374
|
-
:
|
|
375
|
-
|
|
408
|
+
: storeCountries && storeCountries.length
|
|
409
|
+
? storeCountries
|
|
410
|
+
: countryCode
|
|
411
|
+
? [{ iso_2: countryCode, display_name: "" }]
|
|
412
|
+
: [],
|
|
413
|
+
[countries, storeCountries, countryCode]
|
|
376
414
|
)
|
|
377
415
|
|
|
378
416
|
const countriesInRegion = useMemo(
|
|
@@ -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,
|
|
@@ -27,10 +27,19 @@ export type SelectFieldProps = React.SelectHTMLAttributes<HTMLSelectElement> & {
|
|
|
27
27
|
label: string
|
|
28
28
|
children: React.ReactNode
|
|
29
29
|
className?: string
|
|
30
|
+
/**
|
|
31
|
+
* A mark shown inside the control, before the value: a flag, a currency
|
|
32
|
+
* symbol. Decorative and click-through, so the whole field still opens the
|
|
33
|
+
* select. The value and the floating label make room for it.
|
|
34
|
+
*/
|
|
35
|
+
leading?: React.ReactNode
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export const SelectField = React.forwardRef<HTMLSelectElement, SelectFieldProps>(
|
|
33
|
-
function SelectField(
|
|
39
|
+
function SelectField(
|
|
40
|
+
{ label, required, disabled, children, className, value, leading, ...props },
|
|
41
|
+
ref
|
|
42
|
+
) {
|
|
34
43
|
const hasValue = value !== undefined && value !== null && value !== ""
|
|
35
44
|
|
|
36
45
|
return (
|
|
@@ -41,7 +50,8 @@ export const SelectField = React.forwardRef<HTMLSelectElement, SelectFieldProps>
|
|
|
41
50
|
disabled={disabled}
|
|
42
51
|
value={value}
|
|
43
52
|
className={cn(
|
|
44
|
-
"w-full h-[44px]
|
|
53
|
+
"w-full h-[44px] pt-[14px] pb-[2px] pr-9 text-sm rounded-lg border transition-colors duration-150 appearance-none outline-none",
|
|
54
|
+
leading ? "pl-10" : "px-3",
|
|
45
55
|
"focus:border-primary focus:bg-primary/5",
|
|
46
56
|
disabled
|
|
47
57
|
? "bg-muted border-border text-muted-foreground cursor-not-allowed"
|
|
@@ -51,9 +61,15 @@ export const SelectField = React.forwardRef<HTMLSelectElement, SelectFieldProps>
|
|
|
51
61
|
>
|
|
52
62
|
{children}
|
|
53
63
|
</select>
|
|
64
|
+
{leading && (
|
|
65
|
+
<span className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none flex items-center">
|
|
66
|
+
{leading}
|
|
67
|
+
</span>
|
|
68
|
+
)}
|
|
54
69
|
<span
|
|
55
70
|
className={cn(
|
|
56
|
-
"absolute pointer-events-none
|
|
71
|
+
"absolute pointer-events-none top-[14px] text-sm leading-4 origin-top-left",
|
|
72
|
+
leading ? "left-10" : "left-3",
|
|
57
73
|
"transition-transform transition-colors duration-150 ease-out",
|
|
58
74
|
hasValue ? "text-muted-foreground" : "text-muted-foreground",
|
|
59
75
|
hasValue ? "-translate-y-2 scale-[0.77]" : "translate-y-0 scale-100"
|