@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.
- package/LICENSE +21 -21
- package/package.json +258 -258
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +180 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +35 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +778 -778
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -78
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/index.ts +20 -20
- package/src/common/market-select.tsx +57 -57
- package/src/index.ts +12 -12
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-info.tsx +5 -5
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -110
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/lightbox-state.ts +46 -46
- package/src/reviews-ui/review-header.tsx +166 -166
- package/src/reviews-ui/review-lightbox.tsx +271 -271
- package/src/reviews-ui/review-list.tsx +193 -193
- package/src/reviews-ui/review-widget.tsx +219 -218
- package/src/reviews-ui/widget-options.ts +55 -55
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- package/theme/index.css +25 -25
package/src/common/index.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @cartbase/storefront/common — shared storefront chrome. Ported from
|
|
3
|
-
* `@1click/ui/src/common` (v2.3.1) + `LocalizedLink` (from the
|
|
4
|
-
* mindpages-storefront production component); Cartbase data seams per
|
|
5
|
-
* module JSDoc.
|
|
6
|
-
*/
|
|
7
|
-
export { LocalizedLink, type LocalizedLinkProps } from "./localized-link"
|
|
8
|
-
export { CartButton } from "./cart-button"
|
|
9
|
-
export { CartButtonClient } from "./cart-button-client"
|
|
10
|
-
export { DeleteButton, type DeleteButtonProps } from "./delete-button"
|
|
11
|
-
export { CountryFlag } from "./country-flag"
|
|
12
|
-
export { MarketSelect, type MarketSelectProps } from "./market-select"
|
|
13
|
-
/** @deprecated the old name of `MarketSelect`, kept for one release (2026-09-14). */
|
|
14
|
-
export { CountrySelect, type CountrySelectProps } from "./country-select"
|
|
15
|
-
export {
|
|
16
|
-
LanguageSelect,
|
|
17
|
-
localeDisplayName,
|
|
18
|
-
type LanguageSelectProps,
|
|
19
|
-
} from "./language-select"
|
|
20
|
-
export { Skeleton, SkeletonProductPreview } from "./skeleton"
|
|
1
|
+
/**
|
|
2
|
+
* @cartbase/storefront/common — shared storefront chrome. Ported from
|
|
3
|
+
* `@1click/ui/src/common` (v2.3.1) + `LocalizedLink` (from the
|
|
4
|
+
* mindpages-storefront production component); Cartbase data seams per
|
|
5
|
+
* module JSDoc.
|
|
6
|
+
*/
|
|
7
|
+
export { LocalizedLink, type LocalizedLinkProps } from "./localized-link"
|
|
8
|
+
export { CartButton } from "./cart-button"
|
|
9
|
+
export { CartButtonClient } from "./cart-button-client"
|
|
10
|
+
export { DeleteButton, type DeleteButtonProps } from "./delete-button"
|
|
11
|
+
export { CountryFlag } from "./country-flag"
|
|
12
|
+
export { MarketSelect, type MarketSelectProps } from "./market-select"
|
|
13
|
+
/** @deprecated the old name of `MarketSelect`, kept for one release (2026-09-14). */
|
|
14
|
+
export { CountrySelect, type CountrySelectProps } from "./country-select"
|
|
15
|
+
export {
|
|
16
|
+
LanguageSelect,
|
|
17
|
+
localeDisplayName,
|
|
18
|
+
type LanguageSelectProps,
|
|
19
|
+
} from "./language-select"
|
|
20
|
+
export { Skeleton, SkeletonProductPreview } from "./skeleton"
|
|
@@ -1,57 +1,57 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { useMemo } from "react"
|
|
4
|
-
import type { StoreRegion } from "../api/regions"
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* THE MARKET PICKER for a header or footer: the store's regions, by name.
|
|
8
|
-
*
|
|
9
|
-
* Ported from `@1click/ui/src/common/country-select.tsx` (v2.3.1) and
|
|
10
|
-
* renamed on 2026-09-14 (store-package card, finding 6). The port carried the
|
|
11
|
-
* name of a component that listed a region's countries; Cartbase regions
|
|
12
|
-
* carry no countries on the store surface, so it listed the REGIONS
|
|
13
|
-
* themselves and was named for something it did not do. A market is what a
|
|
14
|
-
* shopper picks here; a country is picked in the checkout, from
|
|
15
|
-
* `listCountries`, with `CountryFlag` beside it.
|
|
16
|
-
*
|
|
17
|
-
* Persistence is the app's: `onChange` hands over the chosen region and the
|
|
18
|
-
* app writes it where it keeps it (usually `carts.updateCart(client, cartId,
|
|
19
|
-
* { region_id })` plus a cookie).
|
|
20
|
-
*/
|
|
21
|
-
export type MarketSelectProps = {
|
|
22
|
-
regions: StoreRegion[]
|
|
23
|
-
/** The active region's id. */
|
|
24
|
-
value?: string | null
|
|
25
|
-
/** Fired with the chosen region; persist it app-side. */
|
|
26
|
-
onChange: (region: StoreRegion) => void
|
|
27
|
-
className?: string
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function MarketSelect({ regions, value, onChange, className }: MarketSelectProps) {
|
|
31
|
-
const options = useMemo(
|
|
32
|
-
() => [...regions].sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")),
|
|
33
|
-
[regions]
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
37
|
-
const selected = options.find((r) => r.id === e.target.value)
|
|
38
|
-
if (selected) onChange(selected)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return (
|
|
42
|
-
<select
|
|
43
|
-
value={value || ""}
|
|
44
|
-
onChange={handleChange}
|
|
45
|
-
className={
|
|
46
|
-
className ??
|
|
47
|
-
"text-sm text-foreground bg-card border border-border rounded-lg px-3 py-2"
|
|
48
|
-
}
|
|
49
|
-
>
|
|
50
|
-
{options.map((r) => (
|
|
51
|
-
<option key={r.id} value={r.id}>
|
|
52
|
-
{r.name}
|
|
53
|
-
</option>
|
|
54
|
-
))}
|
|
55
|
-
</select>
|
|
56
|
-
)
|
|
57
|
-
}
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react"
|
|
4
|
+
import type { StoreRegion } from "../api/regions"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE MARKET PICKER for a header or footer: the store's regions, by name.
|
|
8
|
+
*
|
|
9
|
+
* Ported from `@1click/ui/src/common/country-select.tsx` (v2.3.1) and
|
|
10
|
+
* renamed on 2026-09-14 (store-package card, finding 6). The port carried the
|
|
11
|
+
* name of a component that listed a region's countries; Cartbase regions
|
|
12
|
+
* carry no countries on the store surface, so it listed the REGIONS
|
|
13
|
+
* themselves and was named for something it did not do. A market is what a
|
|
14
|
+
* shopper picks here; a country is picked in the checkout, from
|
|
15
|
+
* `listCountries`, with `CountryFlag` beside it.
|
|
16
|
+
*
|
|
17
|
+
* Persistence is the app's: `onChange` hands over the chosen region and the
|
|
18
|
+
* app writes it where it keeps it (usually `carts.updateCart(client, cartId,
|
|
19
|
+
* { region_id })` plus a cookie).
|
|
20
|
+
*/
|
|
21
|
+
export type MarketSelectProps = {
|
|
22
|
+
regions: StoreRegion[]
|
|
23
|
+
/** The active region's id. */
|
|
24
|
+
value?: string | null
|
|
25
|
+
/** Fired with the chosen region; persist it app-side. */
|
|
26
|
+
onChange: (region: StoreRegion) => void
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function MarketSelect({ regions, value, onChange, className }: MarketSelectProps) {
|
|
31
|
+
const options = useMemo(
|
|
32
|
+
() => [...regions].sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")),
|
|
33
|
+
[regions]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
37
|
+
const selected = options.find((r) => r.id === e.target.value)
|
|
38
|
+
if (selected) onChange(selected)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<select
|
|
43
|
+
value={value || ""}
|
|
44
|
+
onChange={handleChange}
|
|
45
|
+
className={
|
|
46
|
+
className ??
|
|
47
|
+
"text-sm text-foreground bg-card border border-border rounded-lg px-3 py-2"
|
|
48
|
+
}
|
|
49
|
+
>
|
|
50
|
+
{options.map((r) => (
|
|
51
|
+
<option key={r.id} value={r.id}>
|
|
52
|
+
{r.name}
|
|
53
|
+
</option>
|
|
54
|
+
))}
|
|
55
|
+
</select>
|
|
56
|
+
)
|
|
57
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @cartbase/storefront — root barrel.
|
|
3
|
-
*
|
|
4
|
-
* `@cartbase/storefront/api` is the SDK layer (typed store-API client).
|
|
5
|
-
* Component families ship under their own subpath exports (stage 3) and are
|
|
6
|
-
* NOT re-exported here — UI imports stay per-module so tree-shaking and
|
|
7
|
-
* client/server boundaries stay explicit, same discipline as @1click/ui.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export * from "./api/types"
|
|
11
|
-
export { StorefrontClient } from "./api/http"
|
|
12
|
-
export type { StorefrontClientConfig } from "./api/http"
|
|
1
|
+
/**
|
|
2
|
+
* @cartbase/storefront — root barrel.
|
|
3
|
+
*
|
|
4
|
+
* `@cartbase/storefront/api` is the SDK layer (typed store-API client).
|
|
5
|
+
* Component families ship under their own subpath exports (stage 3) and are
|
|
6
|
+
* NOT re-exported here — UI imports stay per-module so tree-shaking and
|
|
7
|
+
* client/server boundaries stay explicit, same discipline as @1click/ui.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export * from "./api/types"
|
|
11
|
+
export { StorefrontClient } from "./api/http"
|
|
12
|
+
export type { StorefrontClientConfig } from "./api/http"
|
package/src/lib/country-name.ts
CHANGED
|
@@ -1,59 +1,59 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The name of a country, from its ISO-3166 alpha-2 code.
|
|
3
|
-
*
|
|
4
|
-
* A code is a machine string: "bg" is how a cart address stores a country,
|
|
5
|
-
* never how a shopper reads it. The catalogue from
|
|
6
|
-
* `GET /api/store/countries` carries real names, so this is for the one case
|
|
7
|
-
* where a code arrives WITHOUT one: the checkout hook's fallback when the
|
|
8
|
-
* country list cannot be loaded. It used to hand the address form
|
|
9
|
-
* `{iso_2: "bg", display_name: ""}`, and the form rendered an EMPTY country
|
|
10
|
-
* field (found on evoo, 2026-09-13).
|
|
11
|
-
*
|
|
12
|
-
* `Intl.DisplayNames` is in the runtime, so this needs no bundled table and
|
|
13
|
-
* knows every country in every language the browser does.
|
|
14
|
-
*
|
|
15
|
-
* THE LOCALE IS EXPLICIT AND DEFAULTS TO ENGLISH ON PURPOSE. Letting Intl
|
|
16
|
-
* pick the runtime default would name the country in the SERVER's language
|
|
17
|
-
* during a server render and in the BROWSER's language after hydration,
|
|
18
|
-
* which is a hydration mismatch on any store whose visitors are not in the
|
|
19
|
-
* server's locale. Pass the store's own language to translate it:
|
|
20
|
-
*
|
|
21
|
-
* const locale = useStorefrontLocale()
|
|
22
|
-
* countryName("bg", locale.code) // "България"
|
|
23
|
-
*
|
|
24
|
-
* Three behaviours verified against the runtime rather than assumed
|
|
25
|
-
* (2026-09-13): a LOWERCASE code returns itself unchanged, so the code is
|
|
26
|
-
* uppercased here; a malformed code throws `RangeError`, so it is shape
|
|
27
|
-
* checked and wrapped; and an unknown-but-well-formed code resolves to
|
|
28
|
-
* "Unknown Region" unless `fallback: "none"` is set, which is why it is set
|
|
29
|
-
* and why the uppercase code is the last resort.
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
const cache = new Map<string, Intl.DisplayNames>()
|
|
33
|
-
|
|
34
|
-
function namer(locale: string): Intl.DisplayNames | null {
|
|
35
|
-
const hit = cache.get(locale)
|
|
36
|
-
if (hit) return hit
|
|
37
|
-
try {
|
|
38
|
-
const made = new Intl.DisplayNames([locale], { type: "region", fallback: "none" })
|
|
39
|
-
cache.set(locale, made)
|
|
40
|
-
return made
|
|
41
|
-
} catch {
|
|
42
|
-
return null
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function countryName(
|
|
47
|
-
code: string | null | undefined,
|
|
48
|
-
locale: string = "en"
|
|
49
|
-
): string {
|
|
50
|
-
const iso2 = code?.trim()
|
|
51
|
-
if (!iso2 || !/^[a-z]{2}$/i.test(iso2)) return ""
|
|
52
|
-
|
|
53
|
-
const upper = iso2.toUpperCase()
|
|
54
|
-
try {
|
|
55
|
-
return namer(locale)?.of(upper) || upper
|
|
56
|
-
} catch {
|
|
57
|
-
return upper
|
|
58
|
-
}
|
|
59
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The name of a country, from its ISO-3166 alpha-2 code.
|
|
3
|
+
*
|
|
4
|
+
* A code is a machine string: "bg" is how a cart address stores a country,
|
|
5
|
+
* never how a shopper reads it. The catalogue from
|
|
6
|
+
* `GET /api/store/countries` carries real names, so this is for the one case
|
|
7
|
+
* where a code arrives WITHOUT one: the checkout hook's fallback when the
|
|
8
|
+
* country list cannot be loaded. It used to hand the address form
|
|
9
|
+
* `{iso_2: "bg", display_name: ""}`, and the form rendered an EMPTY country
|
|
10
|
+
* field (found on evoo, 2026-09-13).
|
|
11
|
+
*
|
|
12
|
+
* `Intl.DisplayNames` is in the runtime, so this needs no bundled table and
|
|
13
|
+
* knows every country in every language the browser does.
|
|
14
|
+
*
|
|
15
|
+
* THE LOCALE IS EXPLICIT AND DEFAULTS TO ENGLISH ON PURPOSE. Letting Intl
|
|
16
|
+
* pick the runtime default would name the country in the SERVER's language
|
|
17
|
+
* during a server render and in the BROWSER's language after hydration,
|
|
18
|
+
* which is a hydration mismatch on any store whose visitors are not in the
|
|
19
|
+
* server's locale. Pass the store's own language to translate it:
|
|
20
|
+
*
|
|
21
|
+
* const locale = useStorefrontLocale()
|
|
22
|
+
* countryName("bg", locale.code) // "България"
|
|
23
|
+
*
|
|
24
|
+
* Three behaviours verified against the runtime rather than assumed
|
|
25
|
+
* (2026-09-13): a LOWERCASE code returns itself unchanged, so the code is
|
|
26
|
+
* uppercased here; a malformed code throws `RangeError`, so it is shape
|
|
27
|
+
* checked and wrapped; and an unknown-but-well-formed code resolves to
|
|
28
|
+
* "Unknown Region" unless `fallback: "none"` is set, which is why it is set
|
|
29
|
+
* and why the uppercase code is the last resort.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const cache = new Map<string, Intl.DisplayNames>()
|
|
33
|
+
|
|
34
|
+
function namer(locale: string): Intl.DisplayNames | null {
|
|
35
|
+
const hit = cache.get(locale)
|
|
36
|
+
if (hit) return hit
|
|
37
|
+
try {
|
|
38
|
+
const made = new Intl.DisplayNames([locale], { type: "region", fallback: "none" })
|
|
39
|
+
cache.set(locale, made)
|
|
40
|
+
return made
|
|
41
|
+
} catch {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function countryName(
|
|
47
|
+
code: string | null | undefined,
|
|
48
|
+
locale: string = "en"
|
|
49
|
+
): string {
|
|
50
|
+
const iso2 = code?.trim()
|
|
51
|
+
if (!iso2 || !/^[a-z]{2}$/i.test(iso2)) return ""
|
|
52
|
+
|
|
53
|
+
const upper = iso2.toUpperCase()
|
|
54
|
+
try {
|
|
55
|
+
return namer(locale)?.of(upper) || upper
|
|
56
|
+
} catch {
|
|
57
|
+
return upper
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -1,133 +1,133 @@
|
|
|
1
|
-
import { getPercentageDiff } from "./get-percentage-diff"
|
|
2
|
-
import { convertToLocale } from "./money"
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Product price extraction — ported from
|
|
6
|
-
* `@1click/ui/src/lib/get-product-price.ts` (v2.3.1). The
|
|
7
|
-
* `@medusajs/types` `HttpTypes.StoreProduct` parameter is replaced by the
|
|
8
|
-
* structural shapes below so this module stays decoupled from the SDK
|
|
9
|
-
* domain modules (Cartbase's `variant.calculated_price` is the b2b-v1
|
|
10
|
-
* pricing context documented in `@cartbase/storefront/api/types`
|
|
11
|
-
* `CalculatedPrice`, decimal EUR major units).
|
|
12
|
-
*
|
|
13
|
-
* Wire-shape note (code truth, `src/lib/products/pricing.ts`): Cartbase serves
|
|
14
|
-
* `calculated_price.price_list_type` FLAT on the calculated_price object.
|
|
15
|
-
* The Medusa wire shape ALSO nests a `calculated_price.calculated_price`
|
|
16
|
-
* detail object carrying `price_list_type` — the original library read the
|
|
17
|
-
* nested path. `getPricesForVariant` reads the flat key first and falls
|
|
18
|
-
* back to the nested path so both shapes produce the same result.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/** Structural `variant.calculated_price` shape (see api/types CalculatedPrice). */
|
|
22
|
-
export type CalculatedPriceLike = {
|
|
23
|
-
calculated_amount: number
|
|
24
|
-
original_amount: number
|
|
25
|
-
currency_code: string
|
|
26
|
-
price_list_type?: string | null
|
|
27
|
-
/** Medusa-wire nested detail object (fallback path). */
|
|
28
|
-
calculated_price?: { price_list_type?: string | null } | null
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Structural variant shape — any richer product-variant DTO satisfies it. */
|
|
32
|
-
export type VariantWithPrice = {
|
|
33
|
-
id?: string
|
|
34
|
-
sku?: string | null
|
|
35
|
-
calculated_price?: CalculatedPriceLike | null
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Structural product shape — any richer product DTO satisfies it. */
|
|
39
|
-
export type ProductWithVariants = {
|
|
40
|
-
id?: string
|
|
41
|
-
variants?: VariantWithPrice[] | null
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export type VariantPrice = {
|
|
45
|
-
calculated_price_number: number
|
|
46
|
-
calculated_price: string
|
|
47
|
-
original_price_number: number
|
|
48
|
-
original_price: string
|
|
49
|
-
currency_code: string
|
|
50
|
-
price_type: string | null | undefined
|
|
51
|
-
percentage_diff: string
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export const getPricesForVariant = (
|
|
55
|
-
variant: VariantWithPrice | null | undefined
|
|
56
|
-
): VariantPrice | null => {
|
|
57
|
-
if (!variant?.calculated_price?.calculated_amount) {
|
|
58
|
-
return null
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const cp = variant.calculated_price
|
|
62
|
-
return {
|
|
63
|
-
calculated_price_number: cp.calculated_amount,
|
|
64
|
-
calculated_price: convertToLocale({
|
|
65
|
-
amount: cp.calculated_amount,
|
|
66
|
-
currency_code: cp.currency_code,
|
|
67
|
-
}),
|
|
68
|
-
original_price_number: cp.original_amount,
|
|
69
|
-
original_price: convertToLocale({
|
|
70
|
-
amount: cp.original_amount,
|
|
71
|
-
currency_code: cp.currency_code,
|
|
72
|
-
}),
|
|
73
|
-
currency_code: cp.currency_code,
|
|
74
|
-
// Flat key (Cartbase wire shape) first — an explicit null stays null;
|
|
75
|
-
// the nested Medusa detail is the fallback only when the flat key is
|
|
76
|
-
// absent entirely (the original @1click read path).
|
|
77
|
-
price_type:
|
|
78
|
-
cp.price_list_type !== undefined
|
|
79
|
-
? cp.price_list_type
|
|
80
|
-
: cp.calculated_price?.price_list_type,
|
|
81
|
-
percentage_diff: getPercentageDiff(cp.original_amount, cp.calculated_amount),
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function getProductPrice({
|
|
86
|
-
product,
|
|
87
|
-
variantId,
|
|
88
|
-
}: {
|
|
89
|
-
product: ProductWithVariants
|
|
90
|
-
variantId?: string
|
|
91
|
-
}) {
|
|
92
|
-
if (!product || !product.id) {
|
|
93
|
-
throw new Error("No product provided")
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const cheapestPrice = (): VariantPrice | null => {
|
|
97
|
-
if (!product || !product.variants?.length) {
|
|
98
|
-
return null
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const cheapestVariant = product.variants
|
|
102
|
-
.filter((v) => !!v.calculated_price)
|
|
103
|
-
.sort(
|
|
104
|
-
(a, b) =>
|
|
105
|
-
(a.calculated_price?.calculated_amount ?? 0) -
|
|
106
|
-
(b.calculated_price?.calculated_amount ?? 0)
|
|
107
|
-
)[0]
|
|
108
|
-
|
|
109
|
-
return getPricesForVariant(cheapestVariant)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const variantPrice = (): VariantPrice | null => {
|
|
113
|
-
if (!product || !variantId) {
|
|
114
|
-
return null
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const variant = product.variants?.find(
|
|
118
|
-
(v) => v.id === variantId || v.sku === variantId
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
if (!variant) {
|
|
122
|
-
return null
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return getPricesForVariant(variant)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return {
|
|
129
|
-
product,
|
|
130
|
-
cheapestPrice: cheapestPrice(),
|
|
131
|
-
variantPrice: variantPrice(),
|
|
132
|
-
}
|
|
133
|
-
}
|
|
1
|
+
import { getPercentageDiff } from "./get-percentage-diff"
|
|
2
|
+
import { convertToLocale } from "./money"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Product price extraction — ported from
|
|
6
|
+
* `@1click/ui/src/lib/get-product-price.ts` (v2.3.1). The
|
|
7
|
+
* `@medusajs/types` `HttpTypes.StoreProduct` parameter is replaced by the
|
|
8
|
+
* structural shapes below so this module stays decoupled from the SDK
|
|
9
|
+
* domain modules (Cartbase's `variant.calculated_price` is the b2b-v1
|
|
10
|
+
* pricing context documented in `@cartbase/storefront/api/types`
|
|
11
|
+
* `CalculatedPrice`, decimal EUR major units).
|
|
12
|
+
*
|
|
13
|
+
* Wire-shape note (code truth, `src/lib/products/pricing.ts`): Cartbase serves
|
|
14
|
+
* `calculated_price.price_list_type` FLAT on the calculated_price object.
|
|
15
|
+
* The Medusa wire shape ALSO nests a `calculated_price.calculated_price`
|
|
16
|
+
* detail object carrying `price_list_type` — the original library read the
|
|
17
|
+
* nested path. `getPricesForVariant` reads the flat key first and falls
|
|
18
|
+
* back to the nested path so both shapes produce the same result.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Structural `variant.calculated_price` shape (see api/types CalculatedPrice). */
|
|
22
|
+
export type CalculatedPriceLike = {
|
|
23
|
+
calculated_amount: number
|
|
24
|
+
original_amount: number
|
|
25
|
+
currency_code: string
|
|
26
|
+
price_list_type?: string | null
|
|
27
|
+
/** Medusa-wire nested detail object (fallback path). */
|
|
28
|
+
calculated_price?: { price_list_type?: string | null } | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Structural variant shape — any richer product-variant DTO satisfies it. */
|
|
32
|
+
export type VariantWithPrice = {
|
|
33
|
+
id?: string
|
|
34
|
+
sku?: string | null
|
|
35
|
+
calculated_price?: CalculatedPriceLike | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Structural product shape — any richer product DTO satisfies it. */
|
|
39
|
+
export type ProductWithVariants = {
|
|
40
|
+
id?: string
|
|
41
|
+
variants?: VariantWithPrice[] | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type VariantPrice = {
|
|
45
|
+
calculated_price_number: number
|
|
46
|
+
calculated_price: string
|
|
47
|
+
original_price_number: number
|
|
48
|
+
original_price: string
|
|
49
|
+
currency_code: string
|
|
50
|
+
price_type: string | null | undefined
|
|
51
|
+
percentage_diff: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const getPricesForVariant = (
|
|
55
|
+
variant: VariantWithPrice | null | undefined
|
|
56
|
+
): VariantPrice | null => {
|
|
57
|
+
if (!variant?.calculated_price?.calculated_amount) {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const cp = variant.calculated_price
|
|
62
|
+
return {
|
|
63
|
+
calculated_price_number: cp.calculated_amount,
|
|
64
|
+
calculated_price: convertToLocale({
|
|
65
|
+
amount: cp.calculated_amount,
|
|
66
|
+
currency_code: cp.currency_code,
|
|
67
|
+
}),
|
|
68
|
+
original_price_number: cp.original_amount,
|
|
69
|
+
original_price: convertToLocale({
|
|
70
|
+
amount: cp.original_amount,
|
|
71
|
+
currency_code: cp.currency_code,
|
|
72
|
+
}),
|
|
73
|
+
currency_code: cp.currency_code,
|
|
74
|
+
// Flat key (Cartbase wire shape) first — an explicit null stays null;
|
|
75
|
+
// the nested Medusa detail is the fallback only when the flat key is
|
|
76
|
+
// absent entirely (the original @1click read path).
|
|
77
|
+
price_type:
|
|
78
|
+
cp.price_list_type !== undefined
|
|
79
|
+
? cp.price_list_type
|
|
80
|
+
: cp.calculated_price?.price_list_type,
|
|
81
|
+
percentage_diff: getPercentageDiff(cp.original_amount, cp.calculated_amount),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getProductPrice({
|
|
86
|
+
product,
|
|
87
|
+
variantId,
|
|
88
|
+
}: {
|
|
89
|
+
product: ProductWithVariants
|
|
90
|
+
variantId?: string
|
|
91
|
+
}) {
|
|
92
|
+
if (!product || !product.id) {
|
|
93
|
+
throw new Error("No product provided")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const cheapestPrice = (): VariantPrice | null => {
|
|
97
|
+
if (!product || !product.variants?.length) {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const cheapestVariant = product.variants
|
|
102
|
+
.filter((v) => !!v.calculated_price)
|
|
103
|
+
.sort(
|
|
104
|
+
(a, b) =>
|
|
105
|
+
(a.calculated_price?.calculated_amount ?? 0) -
|
|
106
|
+
(b.calculated_price?.calculated_amount ?? 0)
|
|
107
|
+
)[0]
|
|
108
|
+
|
|
109
|
+
return getPricesForVariant(cheapestVariant)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const variantPrice = (): VariantPrice | null => {
|
|
113
|
+
if (!product || !variantId) {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const variant = product.variants?.find(
|
|
118
|
+
(v) => v.id === variantId || v.sku === variantId
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if (!variant) {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return getPricesForVariant(variant)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
product,
|
|
130
|
+
cheapestPrice: cheapestPrice(),
|
|
131
|
+
variantPrice: variantPrice(),
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/lib/media-image.tsx
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
import NextImage, { type ImageProps } from "next/image"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The store's picture element (store-speed-law card, step 3, 2026-09-15).
|
|
5
|
-
*
|
|
6
|
-
* Product pictures live on the Cartbase media CDN, and Next resizes them on
|
|
7
|
-
* the store's own project, per device and in the modern formats, which the
|
|
8
|
-
* scaffold allows through `images.remotePatterns` (next.config.ts). The
|
|
9
|
-
* scaffold used to ship `images.unoptimized: true` instead, so no picture
|
|
10
|
-
* was ever resized: a phone downloaded the merchant's full upload.
|
|
11
|
-
*
|
|
12
|
-
* A picture from any other host (a catalogue not yet rehosted, a demo
|
|
13
|
-
* placeholder, a merchant's own asset host) renders as it is instead of
|
|
14
|
-
* failing the page: Next refuses to resize a host the config does not
|
|
15
|
-
* list, so this element switches resizing off for those. A merchant who
|
|
16
|
-
* adds their host to `remotePatterns` gets resizing there too by passing
|
|
17
|
-
* `unoptimized={false}` where they render it, or by using `next/image`
|
|
18
|
-
* directly.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/** The public base of the media CDN (REGISTRY.md, CDN domain). */
|
|
22
|
-
export const MEDIA_HOST = "cartbase.cloud"
|
|
23
|
-
|
|
24
|
-
/** Is this picture served by the media CDN (or by the app itself)? */
|
|
25
|
-
export function isResizableSrc(src: ImageProps["src"]): boolean {
|
|
26
|
-
if (typeof src !== "string") return true
|
|
27
|
-
if (src.startsWith("/")) return true
|
|
28
|
-
try {
|
|
29
|
-
const host = new URL(src).hostname
|
|
30
|
-
return host === MEDIA_HOST || host.endsWith(`.${MEDIA_HOST}`)
|
|
31
|
-
} catch {
|
|
32
|
-
return false
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function StoreImage(props: ImageProps) {
|
|
37
|
-
const unoptimized = props.unoptimized ?? !isResizableSrc(props.src)
|
|
38
|
-
return <NextImage {...props} unoptimized={unoptimized} />
|
|
39
|
-
}
|
|
1
|
+
import NextImage, { type ImageProps } from "next/image"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The store's picture element (store-speed-law card, step 3, 2026-09-15).
|
|
5
|
+
*
|
|
6
|
+
* Product pictures live on the Cartbase media CDN, and Next resizes them on
|
|
7
|
+
* the store's own project, per device and in the modern formats, which the
|
|
8
|
+
* scaffold allows through `images.remotePatterns` (next.config.ts). The
|
|
9
|
+
* scaffold used to ship `images.unoptimized: true` instead, so no picture
|
|
10
|
+
* was ever resized: a phone downloaded the merchant's full upload.
|
|
11
|
+
*
|
|
12
|
+
* A picture from any other host (a catalogue not yet rehosted, a demo
|
|
13
|
+
* placeholder, a merchant's own asset host) renders as it is instead of
|
|
14
|
+
* failing the page: Next refuses to resize a host the config does not
|
|
15
|
+
* list, so this element switches resizing off for those. A merchant who
|
|
16
|
+
* adds their host to `remotePatterns` gets resizing there too by passing
|
|
17
|
+
* `unoptimized={false}` where they render it, or by using `next/image`
|
|
18
|
+
* directly.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The public base of the media CDN (REGISTRY.md, CDN domain). */
|
|
22
|
+
export const MEDIA_HOST = "cartbase.cloud"
|
|
23
|
+
|
|
24
|
+
/** Is this picture served by the media CDN (or by the app itself)? */
|
|
25
|
+
export function isResizableSrc(src: ImageProps["src"]): boolean {
|
|
26
|
+
if (typeof src !== "string") return true
|
|
27
|
+
if (src.startsWith("/")) return true
|
|
28
|
+
try {
|
|
29
|
+
const host = new URL(src).hostname
|
|
30
|
+
return host === MEDIA_HOST || host.endsWith(`.${MEDIA_HOST}`)
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function StoreImage(props: ImageProps) {
|
|
37
|
+
const unoptimized = props.unoptimized ?? !isResizableSrc(props.src)
|
|
38
|
+
return <NextImage {...props} unoptimized={unoptimized} />
|
|
39
|
+
}
|