@cartbase/storefront 0.19.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/reviews.ts +22 -1
- 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/bg.ts +3 -2
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +3 -2
- 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/index.ts +12 -0
- package/src/reviews-ui/labels.ts +5 -2
- package/src/reviews-ui/lightbox-state.ts +46 -46
- package/src/reviews-ui/review-header.tsx +166 -0
- package/src/reviews-ui/review-lightbox.tsx +271 -271
- package/src/reviews-ui/review-list.tsx +193 -191
- package/src/reviews-ui/review-widget.tsx +63 -71
- package/src/reviews-ui/star-badge.tsx +6 -4
- package/src/reviews-ui/widget-options.ts +55 -0
- 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
|
@@ -1,93 +1,93 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import * as React from "react"
|
|
4
|
-
|
|
5
|
-
import { cn } from "../lib/utils"
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* SelectField — floating-label native select primitive.
|
|
9
|
-
*
|
|
10
|
-
* Matches `Field`'s visual treatment — the label floats into the top-left
|
|
11
|
-
* when a value is selected. Uses a plain native `<select>` for simplicity
|
|
12
|
-
* and native mobile keyboard support. If a fancier dropdown is needed
|
|
13
|
-
* (searchable, multi-select, rich options), use the shadcn `Select`
|
|
14
|
-
* primitives directly from `@cartbase/storefront/primitives/ui/select`.
|
|
15
|
-
*
|
|
16
|
-
* Extracted from mindpages-storefront
|
|
17
|
-
* src/modules/checkout/templates/checkout-client/index.tsx lines 207-270.
|
|
18
|
-
*
|
|
19
|
-
* @example
|
|
20
|
-
* <SelectField label="Country" name="country" value={country} onChange={...}>
|
|
21
|
-
* <option value="bg">Bulgaria</option>
|
|
22
|
-
* <option value="de">Germany</option>
|
|
23
|
-
* </SelectField>
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
export type SelectFieldProps = React.SelectHTMLAttributes<HTMLSelectElement> & {
|
|
27
|
-
label: string
|
|
28
|
-
children: React.ReactNode
|
|
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
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export const SelectField = React.forwardRef<HTMLSelectElement, SelectFieldProps>(
|
|
39
|
-
function SelectField(
|
|
40
|
-
{ label, required, disabled, children, className, value, leading, ...props },
|
|
41
|
-
ref
|
|
42
|
-
) {
|
|
43
|
-
const hasValue = value !== undefined && value !== null && value !== ""
|
|
44
|
-
|
|
45
|
-
return (
|
|
46
|
-
<div className={cn("relative", className)}>
|
|
47
|
-
<select
|
|
48
|
-
ref={ref}
|
|
49
|
-
required={required}
|
|
50
|
-
disabled={disabled}
|
|
51
|
-
value={value}
|
|
52
|
-
className={cn(
|
|
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",
|
|
55
|
-
"focus:border-primary focus:bg-primary/5",
|
|
56
|
-
disabled
|
|
57
|
-
? "bg-muted border-border text-muted-foreground cursor-not-allowed"
|
|
58
|
-
: "bg-card border-border text-foreground hover:border-muted-foreground"
|
|
59
|
-
)}
|
|
60
|
-
{...props}
|
|
61
|
-
>
|
|
62
|
-
{children}
|
|
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
|
-
)}
|
|
69
|
-
<span
|
|
70
|
-
className={cn(
|
|
71
|
-
"absolute pointer-events-none top-[14px] text-sm leading-4 origin-top-left",
|
|
72
|
-
leading ? "left-10" : "left-3",
|
|
73
|
-
"transition-transform transition-colors duration-150 ease-out",
|
|
74
|
-
hasValue ? "text-muted-foreground" : "text-muted-foreground",
|
|
75
|
-
hasValue ? "-translate-y-2 scale-[0.77]" : "translate-y-0 scale-100"
|
|
76
|
-
)}
|
|
77
|
-
>
|
|
78
|
-
{label}
|
|
79
|
-
</span>
|
|
80
|
-
<svg
|
|
81
|
-
className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none"
|
|
82
|
-
fill="none"
|
|
83
|
-
viewBox="0 0 24 24"
|
|
84
|
-
stroke="currentColor"
|
|
85
|
-
strokeWidth={1.5}
|
|
86
|
-
aria-hidden="true"
|
|
87
|
-
>
|
|
88
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
89
|
-
</svg>
|
|
90
|
-
</div>
|
|
91
|
-
)
|
|
92
|
-
}
|
|
93
|
-
)
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/utils"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SelectField — floating-label native select primitive.
|
|
9
|
+
*
|
|
10
|
+
* Matches `Field`'s visual treatment — the label floats into the top-left
|
|
11
|
+
* when a value is selected. Uses a plain native `<select>` for simplicity
|
|
12
|
+
* and native mobile keyboard support. If a fancier dropdown is needed
|
|
13
|
+
* (searchable, multi-select, rich options), use the shadcn `Select`
|
|
14
|
+
* primitives directly from `@cartbase/storefront/primitives/ui/select`.
|
|
15
|
+
*
|
|
16
|
+
* Extracted from mindpages-storefront
|
|
17
|
+
* src/modules/checkout/templates/checkout-client/index.tsx lines 207-270.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* <SelectField label="Country" name="country" value={country} onChange={...}>
|
|
21
|
+
* <option value="bg">Bulgaria</option>
|
|
22
|
+
* <option value="de">Germany</option>
|
|
23
|
+
* </SelectField>
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type SelectFieldProps = React.SelectHTMLAttributes<HTMLSelectElement> & {
|
|
27
|
+
label: string
|
|
28
|
+
children: React.ReactNode
|
|
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
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const SelectField = React.forwardRef<HTMLSelectElement, SelectFieldProps>(
|
|
39
|
+
function SelectField(
|
|
40
|
+
{ label, required, disabled, children, className, value, leading, ...props },
|
|
41
|
+
ref
|
|
42
|
+
) {
|
|
43
|
+
const hasValue = value !== undefined && value !== null && value !== ""
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className={cn("relative", className)}>
|
|
47
|
+
<select
|
|
48
|
+
ref={ref}
|
|
49
|
+
required={required}
|
|
50
|
+
disabled={disabled}
|
|
51
|
+
value={value}
|
|
52
|
+
className={cn(
|
|
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",
|
|
55
|
+
"focus:border-primary focus:bg-primary/5",
|
|
56
|
+
disabled
|
|
57
|
+
? "bg-muted border-border text-muted-foreground cursor-not-allowed"
|
|
58
|
+
: "bg-card border-border text-foreground hover:border-muted-foreground"
|
|
59
|
+
)}
|
|
60
|
+
{...props}
|
|
61
|
+
>
|
|
62
|
+
{children}
|
|
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
|
+
)}
|
|
69
|
+
<span
|
|
70
|
+
className={cn(
|
|
71
|
+
"absolute pointer-events-none top-[14px] text-sm leading-4 origin-top-left",
|
|
72
|
+
leading ? "left-10" : "left-3",
|
|
73
|
+
"transition-transform transition-colors duration-150 ease-out",
|
|
74
|
+
hasValue ? "text-muted-foreground" : "text-muted-foreground",
|
|
75
|
+
hasValue ? "-translate-y-2 scale-[0.77]" : "translate-y-0 scale-100"
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{label}
|
|
79
|
+
</span>
|
|
80
|
+
<svg
|
|
81
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none"
|
|
82
|
+
fill="none"
|
|
83
|
+
viewBox="0 0 24 24"
|
|
84
|
+
stroke="currentColor"
|
|
85
|
+
strokeWidth={1.5}
|
|
86
|
+
aria-hidden="true"
|
|
87
|
+
>
|
|
88
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
89
|
+
</svg>
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
)
|
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PDP image gallery — ported from
|
|
3
|
-
* `@1click/ui/src/products/image-gallery.tsx` (v2.3.1). Data seam:
|
|
4
|
-
* `HttpTypes.StoreProductImage[]` → Cartbase `StoreProductImage[]`
|
|
5
|
-
* (`@cartbase/storefront/api/products`; images arrive rank-ordered from the
|
|
6
|
-
* API). Server-safe.
|
|
7
|
-
*/
|
|
8
|
-
import { StoreImage as Image } from "../lib/media-image"
|
|
9
|
-
import type { StoreProductImage } from "../api/products"
|
|
10
|
-
|
|
11
|
-
type ImageGalleryProps = {
|
|
12
|
-
images: StoreProductImage[]
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const ImageGallery = ({ images }: ImageGalleryProps) => {
|
|
16
|
-
return (
|
|
17
|
-
<div className="flex items-start relative">
|
|
18
|
-
<div className="flex flex-col flex-1 sm:mx-16 gap-y-4">
|
|
19
|
-
{images.map((image, index) => (
|
|
20
|
-
<div
|
|
21
|
-
key={image.id}
|
|
22
|
-
className="relative aspect-[29/34] w-full overflow-hidden bg-muted rounded-lg"
|
|
23
|
-
id={image.id}
|
|
24
|
-
>
|
|
25
|
-
{!!image.url && (
|
|
26
|
-
<Image
|
|
27
|
-
src={image.url}
|
|
28
|
-
priority={index <= 2}
|
|
29
|
-
className="absolute inset-0 rounded-lg"
|
|
30
|
-
alt={`Product image ${index + 1}`}
|
|
31
|
-
fill
|
|
32
|
-
sizes="(max-width: 576px) 280px, (max-width: 768px) 360px, (max-width: 992px) 480px, 800px"
|
|
33
|
-
style={{ objectFit: "cover" }}
|
|
34
|
-
/>
|
|
35
|
-
)}
|
|
36
|
-
</div>
|
|
37
|
-
))}
|
|
38
|
-
</div>
|
|
39
|
-
</div>
|
|
40
|
-
)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export { ImageGallery, type ImageGalleryProps }
|
|
1
|
+
/**
|
|
2
|
+
* PDP image gallery — ported from
|
|
3
|
+
* `@1click/ui/src/products/image-gallery.tsx` (v2.3.1). Data seam:
|
|
4
|
+
* `HttpTypes.StoreProductImage[]` → Cartbase `StoreProductImage[]`
|
|
5
|
+
* (`@cartbase/storefront/api/products`; images arrive rank-ordered from the
|
|
6
|
+
* API). Server-safe.
|
|
7
|
+
*/
|
|
8
|
+
import { StoreImage as Image } from "../lib/media-image"
|
|
9
|
+
import type { StoreProductImage } from "../api/products"
|
|
10
|
+
|
|
11
|
+
type ImageGalleryProps = {
|
|
12
|
+
images: StoreProductImage[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ImageGallery = ({ images }: ImageGalleryProps) => {
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex items-start relative">
|
|
18
|
+
<div className="flex flex-col flex-1 sm:mx-16 gap-y-4">
|
|
19
|
+
{images.map((image, index) => (
|
|
20
|
+
<div
|
|
21
|
+
key={image.id}
|
|
22
|
+
className="relative aspect-[29/34] w-full overflow-hidden bg-muted rounded-lg"
|
|
23
|
+
id={image.id}
|
|
24
|
+
>
|
|
25
|
+
{!!image.url && (
|
|
26
|
+
<Image
|
|
27
|
+
src={image.url}
|
|
28
|
+
priority={index <= 2}
|
|
29
|
+
className="absolute inset-0 rounded-lg"
|
|
30
|
+
alt={`Product image ${index + 1}`}
|
|
31
|
+
fill
|
|
32
|
+
sizes="(max-width: 576px) 280px, (max-width: 768px) 360px, (max-width: 992px) 480px, 800px"
|
|
33
|
+
style={{ objectFit: "cover" }}
|
|
34
|
+
/>
|
|
35
|
+
)}
|
|
36
|
+
</div>
|
|
37
|
+
))}
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { ImageGallery, type ImageGalleryProps }
|
|
@@ -1,67 +1,67 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Option selector — ported from
|
|
5
|
-
* `@1click/ui/src/products/option-select.tsx` (v2.3.1). Data seam:
|
|
6
|
-
* `HttpTypes.StoreProductOption` → Cartbase `StoreProductOption`
|
|
7
|
-
* (`@cartbase/storefront/api/products`; `values` is present on
|
|
8
|
-
* `product.options[]`, which is what this component receives).
|
|
9
|
-
*/
|
|
10
|
-
import React from "react"
|
|
11
|
-
import type { StoreProductOption } from "../api/products"
|
|
12
|
-
import { cn } from "../lib/utils"
|
|
13
|
-
import { useProductLabels } from "./context"
|
|
14
|
-
|
|
15
|
-
type OptionSelectProps = {
|
|
16
|
-
option: StoreProductOption
|
|
17
|
-
current: string | undefined
|
|
18
|
-
updateOption: (optionId: string, value: string) => void
|
|
19
|
-
title: string
|
|
20
|
-
disabled: boolean
|
|
21
|
-
"data-testid"?: string
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const OptionSelect: React.FC<OptionSelectProps> = ({
|
|
25
|
-
option,
|
|
26
|
-
current,
|
|
27
|
-
updateOption,
|
|
28
|
-
title,
|
|
29
|
-
"data-testid": dataTestId,
|
|
30
|
-
disabled,
|
|
31
|
-
}) => {
|
|
32
|
-
const labels = useProductLabels()
|
|
33
|
-
const filteredOptions = (option.values ?? []).map((v) => v.value)
|
|
34
|
-
|
|
35
|
-
return (
|
|
36
|
-
<div className="flex flex-col gap-y-3">
|
|
37
|
-
<span className="text-sm text-foreground">
|
|
38
|
-
{labels.selectOption} {title}
|
|
39
|
-
</span>
|
|
40
|
-
<div
|
|
41
|
-
className="flex flex-wrap justify-between gap-2"
|
|
42
|
-
data-testid={dataTestId}
|
|
43
|
-
>
|
|
44
|
-
{filteredOptions.map((v) => (
|
|
45
|
-
<button
|
|
46
|
-
onClick={() => updateOption(option.id, v!)}
|
|
47
|
-
key={v}
|
|
48
|
-
className={cn(
|
|
49
|
-
"border border-border bg-muted text-sm h-10 rounded-lg p-2 flex-1",
|
|
50
|
-
{
|
|
51
|
-
"border-primary": v === current,
|
|
52
|
-
"hover:shadow-sm transition-shadow ease-in-out duration-150":
|
|
53
|
-
v !== current,
|
|
54
|
-
}
|
|
55
|
-
)}
|
|
56
|
-
disabled={disabled}
|
|
57
|
-
data-testid="option-button"
|
|
58
|
-
>
|
|
59
|
-
{v}
|
|
60
|
-
</button>
|
|
61
|
-
))}
|
|
62
|
-
</div>
|
|
63
|
-
</div>
|
|
64
|
-
)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export { OptionSelect, type OptionSelectProps }
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Option selector — ported from
|
|
5
|
+
* `@1click/ui/src/products/option-select.tsx` (v2.3.1). Data seam:
|
|
6
|
+
* `HttpTypes.StoreProductOption` → Cartbase `StoreProductOption`
|
|
7
|
+
* (`@cartbase/storefront/api/products`; `values` is present on
|
|
8
|
+
* `product.options[]`, which is what this component receives).
|
|
9
|
+
*/
|
|
10
|
+
import React from "react"
|
|
11
|
+
import type { StoreProductOption } from "../api/products"
|
|
12
|
+
import { cn } from "../lib/utils"
|
|
13
|
+
import { useProductLabels } from "./context"
|
|
14
|
+
|
|
15
|
+
type OptionSelectProps = {
|
|
16
|
+
option: StoreProductOption
|
|
17
|
+
current: string | undefined
|
|
18
|
+
updateOption: (optionId: string, value: string) => void
|
|
19
|
+
title: string
|
|
20
|
+
disabled: boolean
|
|
21
|
+
"data-testid"?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const OptionSelect: React.FC<OptionSelectProps> = ({
|
|
25
|
+
option,
|
|
26
|
+
current,
|
|
27
|
+
updateOption,
|
|
28
|
+
title,
|
|
29
|
+
"data-testid": dataTestId,
|
|
30
|
+
disabled,
|
|
31
|
+
}) => {
|
|
32
|
+
const labels = useProductLabels()
|
|
33
|
+
const filteredOptions = (option.values ?? []).map((v) => v.value)
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex flex-col gap-y-3">
|
|
37
|
+
<span className="text-sm text-foreground">
|
|
38
|
+
{labels.selectOption} {title}
|
|
39
|
+
</span>
|
|
40
|
+
<div
|
|
41
|
+
className="flex flex-wrap justify-between gap-2"
|
|
42
|
+
data-testid={dataTestId}
|
|
43
|
+
>
|
|
44
|
+
{filteredOptions.map((v) => (
|
|
45
|
+
<button
|
|
46
|
+
onClick={() => updateOption(option.id, v!)}
|
|
47
|
+
key={v}
|
|
48
|
+
className={cn(
|
|
49
|
+
"border border-border bg-muted text-sm h-10 rounded-lg p-2 flex-1",
|
|
50
|
+
{
|
|
51
|
+
"border-primary": v === current,
|
|
52
|
+
"hover:shadow-sm transition-shadow ease-in-out duration-150":
|
|
53
|
+
v !== current,
|
|
54
|
+
}
|
|
55
|
+
)}
|
|
56
|
+
disabled={disabled}
|
|
57
|
+
data-testid="option-button"
|
|
58
|
+
>
|
|
59
|
+
{v}
|
|
60
|
+
</button>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { OptionSelect, type OptionSelectProps }
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PDP title/collection/description block — ported from
|
|
3
|
-
* `@1click/ui/src/products/product-info.tsx` (v2.3.1). Data seam:
|
|
4
|
-
* `
|
|
5
|
-
* EVERY collection it belongs to since 2026-09-15 (the membership
|
|
6
|
-
* the one store); the line above the title names the first of them,
|
|
7
|
-
* what the single `collection` object used to be. Server-safe.
|
|
3
|
+
* `@1click/ui/src/products/product-info.tsx` (v2.3.1). Data seam: Cartbase's
|
|
4
|
+
* own `StoreProduct` stands in for `HttpTypes.StoreProduct`. The product
|
|
5
|
+
* carries EVERY collection it belongs to since 2026-09-15 (the membership
|
|
6
|
+
* join is the one store); the line above the title names the first of them,
|
|
7
|
+
* which is what the single `collection` object used to be. Server-safe.
|
|
8
8
|
*/
|
|
9
9
|
import Link from "next/link"
|
|
10
10
|
import type { StoreProduct } from "../api/products"
|
|
@@ -1,69 +1,69 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* PDP price display — ported from
|
|
5
|
-
* `@1click/ui/src/products/product-price.tsx` (v2.3.1). Data seam:
|
|
6
|
-
* `@medusajs/types` `HttpTypes.StoreProduct/StoreProductVariant` are
|
|
7
|
-
* replaced by the Cartbase SDK DTOs (`@cartbase/storefront/api/products`).
|
|
8
|
-
* Pricing stays SERVER truth — `lib/get-product-price` only selects and
|
|
9
|
-
* formats `variant.calculated_price` (Cartbase's flat `price_list_type`).
|
|
10
|
-
*/
|
|
11
|
-
import type { StoreProduct, StoreProductVariant } from "../api/products"
|
|
12
|
-
import { cn } from "../lib/utils"
|
|
13
|
-
import { getProductPrice } from "../lib/get-product-price"
|
|
14
|
-
import { useProductLabels } from "./context"
|
|
15
|
-
|
|
16
|
-
export function ProductPrice({
|
|
17
|
-
product,
|
|
18
|
-
variant,
|
|
19
|
-
}: {
|
|
20
|
-
product: StoreProduct
|
|
21
|
-
variant?: StoreProductVariant
|
|
22
|
-
}) {
|
|
23
|
-
const labels = useProductLabels()
|
|
24
|
-
const { cheapestPrice, variantPrice } = getProductPrice({
|
|
25
|
-
product,
|
|
26
|
-
variantId: variant?.id,
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
const selectedPrice = variant ? variantPrice : cheapestPrice
|
|
30
|
-
|
|
31
|
-
if (!selectedPrice) {
|
|
32
|
-
return <div className="block w-32 h-9 bg-muted animate-pulse rounded" />
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return (
|
|
36
|
-
<div className="flex flex-col text-foreground">
|
|
37
|
-
<span
|
|
38
|
-
className={cn("text-xl font-semibold", {
|
|
39
|
-
"text-primary": selectedPrice.price_type === "sale",
|
|
40
|
-
})}
|
|
41
|
-
>
|
|
42
|
-
{!variant && `${labels.from} `}
|
|
43
|
-
<span
|
|
44
|
-
data-testid="product-price"
|
|
45
|
-
data-value={selectedPrice.calculated_price_number}
|
|
46
|
-
>
|
|
47
|
-
{selectedPrice.calculated_price}
|
|
48
|
-
</span>
|
|
49
|
-
</span>
|
|
50
|
-
{selectedPrice.price_type === "sale" && (
|
|
51
|
-
<>
|
|
52
|
-
<p>
|
|
53
|
-
<span className="text-muted-foreground">{labels.original} </span>
|
|
54
|
-
<span
|
|
55
|
-
className="line-through"
|
|
56
|
-
data-testid="original-product-price"
|
|
57
|
-
data-value={selectedPrice.original_price_number}
|
|
58
|
-
>
|
|
59
|
-
{selectedPrice.original_price}
|
|
60
|
-
</span>
|
|
61
|
-
</p>
|
|
62
|
-
<span className="text-primary">
|
|
63
|
-
-{selectedPrice.percentage_diff}%
|
|
64
|
-
</span>
|
|
65
|
-
</>
|
|
66
|
-
)}
|
|
67
|
-
</div>
|
|
68
|
-
)
|
|
69
|
-
}
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PDP price display — ported from
|
|
5
|
+
* `@1click/ui/src/products/product-price.tsx` (v2.3.1). Data seam:
|
|
6
|
+
* `@medusajs/types` `HttpTypes.StoreProduct/StoreProductVariant` are
|
|
7
|
+
* replaced by the Cartbase SDK DTOs (`@cartbase/storefront/api/products`).
|
|
8
|
+
* Pricing stays SERVER truth — `lib/get-product-price` only selects and
|
|
9
|
+
* formats `variant.calculated_price` (Cartbase's flat `price_list_type`).
|
|
10
|
+
*/
|
|
11
|
+
import type { StoreProduct, StoreProductVariant } from "../api/products"
|
|
12
|
+
import { cn } from "../lib/utils"
|
|
13
|
+
import { getProductPrice } from "../lib/get-product-price"
|
|
14
|
+
import { useProductLabels } from "./context"
|
|
15
|
+
|
|
16
|
+
export function ProductPrice({
|
|
17
|
+
product,
|
|
18
|
+
variant,
|
|
19
|
+
}: {
|
|
20
|
+
product: StoreProduct
|
|
21
|
+
variant?: StoreProductVariant
|
|
22
|
+
}) {
|
|
23
|
+
const labels = useProductLabels()
|
|
24
|
+
const { cheapestPrice, variantPrice } = getProductPrice({
|
|
25
|
+
product,
|
|
26
|
+
variantId: variant?.id,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const selectedPrice = variant ? variantPrice : cheapestPrice
|
|
30
|
+
|
|
31
|
+
if (!selectedPrice) {
|
|
32
|
+
return <div className="block w-32 h-9 bg-muted animate-pulse rounded" />
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex flex-col text-foreground">
|
|
37
|
+
<span
|
|
38
|
+
className={cn("text-xl font-semibold", {
|
|
39
|
+
"text-primary": selectedPrice.price_type === "sale",
|
|
40
|
+
})}
|
|
41
|
+
>
|
|
42
|
+
{!variant && `${labels.from} `}
|
|
43
|
+
<span
|
|
44
|
+
data-testid="product-price"
|
|
45
|
+
data-value={selectedPrice.calculated_price_number}
|
|
46
|
+
>
|
|
47
|
+
{selectedPrice.calculated_price}
|
|
48
|
+
</span>
|
|
49
|
+
</span>
|
|
50
|
+
{selectedPrice.price_type === "sale" && (
|
|
51
|
+
<>
|
|
52
|
+
<p>
|
|
53
|
+
<span className="text-muted-foreground">{labels.original} </span>
|
|
54
|
+
<span
|
|
55
|
+
className="line-through"
|
|
56
|
+
data-testid="original-product-price"
|
|
57
|
+
data-value={selectedPrice.original_price_number}
|
|
58
|
+
>
|
|
59
|
+
{selectedPrice.original_price}
|
|
60
|
+
</span>
|
|
61
|
+
</p>
|
|
62
|
+
<span className="text-primary">
|
|
63
|
+
-{selectedPrice.percentage_diff}%
|
|
64
|
+
</span>
|
|
65
|
+
</>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|