@cartbase/storefront 0.15.0 → 0.17.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 +4 -1
- package/src/api/customers.ts +6 -7
- package/src/api/http.ts +51 -1
- package/src/api/products.ts +330 -314
- package/src/cart-drawer/cross-sell-carousel.tsx +1 -1
- package/src/cart-drawer/cross-sell-sidebar.tsx +1 -1
- package/src/cart-drawer/free-gift.tsx +1 -1
- package/src/cart-drawer/item/index.tsx +1 -1
- package/src/cart-drawer/item/upsell.tsx +1 -1
- package/src/lib/media-image.tsx +39 -0
- package/src/locales/bg.ts +3 -0
- package/src/locales/es.ts +3 -0
- package/src/order/order-item.tsx +1 -1
- package/src/products/image-gallery.tsx +1 -1
- package/src/products/index.ts +14 -0
- package/src/products/labels.ts +7 -0
- package/src/products/product-actions-wrapper.tsx +62 -58
- package/src/products/product-actions.tsx +104 -177
- package/src/products/product-info.tsx +10 -5
- package/src/products/product-template.tsx +162 -149
- package/src/products/thumbnail.tsx +1 -1
- package/src/products/use-product-actions.ts +164 -0
- package/src/products/variant-url.ts +74 -0
- package/src/store/paginated-products.tsx +3 -2
|
@@ -0,0 +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
|
+
}
|
package/src/locales/bg.ts
CHANGED
|
@@ -28,6 +28,9 @@ export const bg: StorefrontLocale = {
|
|
|
28
28
|
selectVariant: "Изберете вариант",
|
|
29
29
|
outOfStock: "Изчерпан",
|
|
30
30
|
addToCart: "Добави в кошницата",
|
|
31
|
+
quantity: "Количество",
|
|
32
|
+
decreaseQuantity: "По-малко",
|
|
33
|
+
increaseQuantity: "Повече",
|
|
31
34
|
from: "От",
|
|
32
35
|
original: "Оригинална цена:",
|
|
33
36
|
productInformation: "Информация за продукта",
|
package/src/locales/es.ts
CHANGED
|
@@ -34,6 +34,9 @@ export const es: StorefrontLocale = {
|
|
|
34
34
|
selectVariant: "Elige variante",
|
|
35
35
|
outOfStock: "Agotado",
|
|
36
36
|
addToCart: "Añadir al carrito",
|
|
37
|
+
quantity: "Cantidad",
|
|
38
|
+
decreaseQuantity: "Menos",
|
|
39
|
+
increaseQuantity: "Más",
|
|
37
40
|
from: "Desde",
|
|
38
41
|
original: "Antes:",
|
|
39
42
|
productInformation: "Información del producto",
|
package/src/order/order-item.tsx
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* (`@cartbase/storefront/api/products`; images arrive rank-ordered from the
|
|
6
6
|
* API). Server-safe.
|
|
7
7
|
*/
|
|
8
|
-
import Image from "
|
|
8
|
+
import { StoreImage as Image } from "../lib/media-image"
|
|
9
9
|
import type { StoreProductImage } from "../api/products"
|
|
10
10
|
|
|
11
11
|
type ImageGalleryProps = {
|
package/src/products/index.ts
CHANGED
|
@@ -26,6 +26,20 @@ export {
|
|
|
26
26
|
type PurchaseOptionsLabels,
|
|
27
27
|
} from "./purchase-options"
|
|
28
28
|
export { ImageGallery, type ImageGalleryProps } from "./image-gallery"
|
|
29
|
+
// The product page contract: the address is the state, the default is the
|
|
30
|
+
// merchant's, a switch is a browser event (variant-url + use-product-actions).
|
|
31
|
+
export {
|
|
32
|
+
VARIANT_PARAM,
|
|
33
|
+
variantParamValue,
|
|
34
|
+
findVariantByParam,
|
|
35
|
+
defaultVariant,
|
|
36
|
+
variantHref,
|
|
37
|
+
} from "./variant-url"
|
|
38
|
+
export {
|
|
39
|
+
useProductActions,
|
|
40
|
+
type UseProductActionsInput,
|
|
41
|
+
type ProductActionsState,
|
|
42
|
+
} from "./use-product-actions"
|
|
29
43
|
export {
|
|
30
44
|
ProductActions,
|
|
31
45
|
type ProductActionsProps,
|
package/src/products/labels.ts
CHANGED
|
@@ -9,6 +9,10 @@ export type ProductLabels = {
|
|
|
9
9
|
selectVariant: string
|
|
10
10
|
outOfStock: string
|
|
11
11
|
addToCart: string
|
|
12
|
+
/** The quantity stepper of the buy panel and its two controls. */
|
|
13
|
+
quantity: string
|
|
14
|
+
decreaseQuantity: string
|
|
15
|
+
increaseQuantity: string
|
|
12
16
|
from: string
|
|
13
17
|
original: string
|
|
14
18
|
productInformation: string
|
|
@@ -39,6 +43,9 @@ export const defaultProductLabels: ProductLabels = {
|
|
|
39
43
|
selectVariant: "Select variant",
|
|
40
44
|
outOfStock: "Out of stock",
|
|
41
45
|
addToCart: "Add to cart",
|
|
46
|
+
quantity: "Quantity",
|
|
47
|
+
decreaseQuantity: "Fewer",
|
|
48
|
+
increaseQuantity: "More",
|
|
42
49
|
from: "From",
|
|
43
50
|
original: "Original:",
|
|
44
51
|
productInformation: "Product Information",
|
|
@@ -1,58 +1,62 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fresh-price fetch wrapper for ProductActions — ported from
|
|
3
|
-
* `@1click/ui/src/products/product-actions-wrapper.tsx` (v2.3.1). Data
|
|
4
|
-
* seam: the Medusa `listProducts({id})` + region-priced fetch becomes
|
|
5
|
-
* `api/products` `retrieveProduct(idOrHandle, pricingContext)` — the PDP
|
|
6
|
-
* shell renders with the (possibly cached) product while this wrapper
|
|
7
|
-
* re-fetches `calculated_price` with the live pricing context (and the
|
|
8
|
-
* customer's Bearer JWT via the client, for group-aware B2B prices).
|
|
9
|
-
*
|
|
10
|
-
* Async server component — render inside `<Suspense>` with a disabled
|
|
11
|
-
* `<ProductActions>` fallback (see ProductTemplate).
|
|
12
|
-
*/
|
|
13
|
-
import type { StorefrontClient } from "../api/http"
|
|
14
|
-
import type { PricingContextQuery } from "../api/types"
|
|
15
|
-
import type { StoreProduct, StoreProductVariant } from "../api/products"
|
|
16
|
-
import { retrieveProduct } from "../api/products"
|
|
17
|
-
import { StoreApiError } from "../api/types"
|
|
18
|
-
import { ProductActions, type AddToCartInput } from "./product-actions"
|
|
19
|
-
|
|
20
|
-
type ProductActionsWrapperProps = {
|
|
21
|
-
client: StorefrontClient
|
|
22
|
-
/** Product id (`prod_…`) or handle. */
|
|
23
|
-
id: string
|
|
24
|
-
/** Pricing context so `calculated_price` is present for the price panel. */
|
|
25
|
-
pricingContext?: PricingContextQuery
|
|
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
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Fresh-price fetch wrapper for ProductActions — ported from
|
|
3
|
+
* `@1click/ui/src/products/product-actions-wrapper.tsx` (v2.3.1). Data
|
|
4
|
+
* seam: the Medusa `listProducts({id})` + region-priced fetch becomes
|
|
5
|
+
* `api/products` `retrieveProduct(idOrHandle, pricingContext)` — the PDP
|
|
6
|
+
* shell renders with the (possibly cached) product while this wrapper
|
|
7
|
+
* re-fetches `calculated_price` with the live pricing context (and the
|
|
8
|
+
* customer's Bearer JWT via the client, for group-aware B2B prices).
|
|
9
|
+
*
|
|
10
|
+
* Async server component — render inside `<Suspense>` with a disabled
|
|
11
|
+
* `<ProductActions>` fallback (see ProductTemplate).
|
|
12
|
+
*/
|
|
13
|
+
import type { StorefrontClient } from "../api/http"
|
|
14
|
+
import type { PricingContextQuery } from "../api/types"
|
|
15
|
+
import type { StoreProduct, StoreProductVariant } from "../api/products"
|
|
16
|
+
import { retrieveProduct } from "../api/products"
|
|
17
|
+
import { StoreApiError } from "../api/types"
|
|
18
|
+
import { ProductActions, type AddToCartInput } from "./product-actions"
|
|
19
|
+
|
|
20
|
+
type ProductActionsWrapperProps = {
|
|
21
|
+
client: StorefrontClient
|
|
22
|
+
/** Product id (`prod_…`) or handle. */
|
|
23
|
+
id: string
|
|
24
|
+
/** Pricing context so `calculated_price` is present for the price panel. */
|
|
25
|
+
pricingContext?: PricingContextQuery
|
|
26
|
+
/** The variant the address names; see the product page contract. */
|
|
27
|
+
initialVariantId?: string | null
|
|
28
|
+
addToCart: (input: AddToCartInput) => Promise<void>
|
|
29
|
+
onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
|
|
30
|
+
openCart?: () => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function ProductActionsWrapper({
|
|
34
|
+
client,
|
|
35
|
+
id,
|
|
36
|
+
pricingContext,
|
|
37
|
+
initialVariantId,
|
|
38
|
+
addToCart,
|
|
39
|
+
onAddToCart,
|
|
40
|
+
openCart,
|
|
41
|
+
}: ProductActionsWrapperProps) {
|
|
42
|
+
let product: StoreProduct
|
|
43
|
+
try {
|
|
44
|
+
const res = await retrieveProduct(client, id, pricingContext)
|
|
45
|
+
product = res.product
|
|
46
|
+
} catch (e) {
|
|
47
|
+
if (e instanceof StoreApiError && e.status === 404) return null
|
|
48
|
+
throw e
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<ProductActions
|
|
53
|
+
product={product}
|
|
54
|
+
initialVariantId={initialVariantId}
|
|
55
|
+
addToCart={addToCart}
|
|
56
|
+
onAddToCart={onAddToCart}
|
|
57
|
+
openCart={openCart}
|
|
58
|
+
/>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { type ProductActionsWrapperProps }
|
|
@@ -1,31 +1,22 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The package's default buy panel: options, price, quantity, the button,
|
|
5
|
+
* and the sticky bar on phones. Every rule of the product page contract
|
|
6
|
+
* lives in `useProductActions`; this file is only the markup, and a store
|
|
7
|
+
* that wants its own look draws its own panel on the same hook and
|
|
8
|
+
* inherits the same behaviour (evoo's `features/product/buy-box.tsx` is
|
|
9
|
+
* the reference).
|
|
6
10
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* the products family has no hard dependency on the cart-drawer family.
|
|
14
|
-
* - `countryCode` (Medusa region routing) is dropped — Cartbase pricing
|
|
15
|
-
* context travels with the product fetch, and the host's `addToCart`
|
|
16
|
-
* closes over whatever routing it needs.
|
|
17
|
-
* - Stock: the variant DTO carries `in_stock`, computed server-side by THE
|
|
18
|
-
* availability predicate — this component only reads it. The server still
|
|
19
|
-
* enforces stock at add (400 `insufficient_inventory` flips the button to
|
|
20
|
-
* the out-of-stock state). On a legacy wire without the field, managed
|
|
21
|
-
* variants fall back to the old optimistic behavior; richer DTOs carrying
|
|
22
|
-
* `inventory_quantity` keep the original quantity check.
|
|
23
|
-
*
|
|
24
|
-
* Variant matching is the extracted pure module `./variant-matching`
|
|
25
|
-
* (handles Cartbase's option-value link shape).
|
|
11
|
+
* Until 2026-09-15 this panel carried the behaviour itself, and carried it
|
|
12
|
+
* wrong: it wrote the variant into the address through the router as
|
|
13
|
+
* `v_id=<whole key>`, so every click was a server render and the address
|
|
14
|
+
* lagged; it chose nothing on a product with several variants until the
|
|
15
|
+
* shopper clicked; and it added one unit, always. The hook fixed all three
|
|
16
|
+
* for every consumer at once.
|
|
26
17
|
*/
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
18
|
+
import { useRef } from "react"
|
|
19
|
+
import { Minus, Plus } from "lucide-react"
|
|
29
20
|
|
|
30
21
|
import type { StoreProduct, StoreProductVariant } from "../api/products"
|
|
31
22
|
import { useIntersection } from "../lib/hooks/use-intersection"
|
|
@@ -34,189 +25,125 @@ import { useProductLabels } from "./context"
|
|
|
34
25
|
import { OptionSelect } from "./option-select"
|
|
35
26
|
import { ProductPrice } from "./product-price"
|
|
36
27
|
import { MobileActions } from "./mobile-actions"
|
|
37
|
-
import {
|
|
28
|
+
import { useProductActions, type AddToCartInput } from "./use-product-actions"
|
|
38
29
|
|
|
39
|
-
|
|
40
|
-
export type AddToCartInput = {
|
|
41
|
-
variantId: string
|
|
42
|
-
quantity: number
|
|
43
|
-
}
|
|
30
|
+
export type { AddToCartInput }
|
|
44
31
|
|
|
45
32
|
type ProductActionsProps = {
|
|
46
33
|
product: StoreProduct
|
|
47
34
|
/**
|
|
48
|
-
* The cart seam: called with the selected variant. Wire to
|
|
49
|
-
* cart orchestration (`api/carts` `addLineItem` behind the
|
|
50
|
-
* storage). May throw `StoreApiError
|
|
51
|
-
* handled here (button flips to out
|
|
35
|
+
* The cart seam: called with the selected variant and quantity. Wire to
|
|
36
|
+
* the host's cart orchestration (`api/carts` `addLineItem` behind the
|
|
37
|
+
* host's cart-id storage). May throw `StoreApiError`;
|
|
38
|
+
* `insufficient_inventory` is handled here (button flips to out of stock).
|
|
52
39
|
*/
|
|
53
40
|
addToCart: (input: AddToCartInput) => Promise<void>
|
|
41
|
+
/**
|
|
42
|
+
* The variant the address names (`searchParams.variant`), passed from the
|
|
43
|
+
* page so the first paint is the right variant. See the product page
|
|
44
|
+
* contract in `use-product-actions.ts`.
|
|
45
|
+
*/
|
|
46
|
+
initialVariantId?: string | null
|
|
54
47
|
disabled?: boolean
|
|
55
48
|
/** Fired after a successful add (analytics: trackAddToCart trio). */
|
|
56
49
|
onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
|
|
57
50
|
/** Open the host's cart drawer after a successful add. */
|
|
58
51
|
openCart?: () => void
|
|
52
|
+
/** The quantity stepper; on by default. */
|
|
53
|
+
showQuantity?: boolean
|
|
59
54
|
}
|
|
60
55
|
|
|
61
56
|
export function ProductActions({
|
|
62
57
|
product,
|
|
63
58
|
addToCart,
|
|
59
|
+
initialVariantId,
|
|
64
60
|
disabled,
|
|
65
61
|
onAddToCart,
|
|
66
62
|
openCart,
|
|
63
|
+
showQuantity = true,
|
|
67
64
|
}: ProductActionsProps) {
|
|
68
65
|
const labels = useProductLabels()
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const searchParams = useSearchParams()
|
|
72
|
-
|
|
73
|
-
const [options, setOptions] = useState<Record<string, string | undefined>>({})
|
|
74
|
-
const [isAdding, setIsAdding] = useState(false)
|
|
75
|
-
// Set when the server rejected an add with `insufficient_inventory`
|
|
76
|
-
// (Cartbase exposes no inventory_quantity on the store surface — the server
|
|
77
|
-
// is the stock truth). Cleared when the selection changes.
|
|
78
|
-
const [stockExhausted, setStockExhausted] = useState(false)
|
|
79
|
-
|
|
80
|
-
useEffect(() => {
|
|
81
|
-
if (product.variants?.length === 1) {
|
|
82
|
-
const variantOptions = optionsAsKeymap(product.variants[0]?.options ?? null)
|
|
83
|
-
setOptions(variantOptions ?? {})
|
|
84
|
-
}
|
|
85
|
-
}, [product.variants])
|
|
86
|
-
|
|
87
|
-
const selectedVariant = useMemo(
|
|
88
|
-
() => findMatchingVariant(product.variants, options),
|
|
89
|
-
[product.variants, options]
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
const setOptionValue = (optionId: string, value: string) => {
|
|
93
|
-
setOptions((prev) => ({ ...prev, [optionId]: value }))
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const isValidVariant = useMemo(() => {
|
|
97
|
-
return product.variants?.some((v) =>
|
|
98
|
-
optionsMatch(options, optionsAsKeymap(v.options))
|
|
99
|
-
)
|
|
100
|
-
}, [product.variants, options])
|
|
101
|
-
|
|
102
|
-
useEffect(() => {
|
|
103
|
-
setStockExhausted(false)
|
|
104
|
-
}, [selectedVariant?.id])
|
|
105
|
-
|
|
106
|
-
useEffect(() => {
|
|
107
|
-
const params = new URLSearchParams(searchParams.toString())
|
|
108
|
-
const value = isValidVariant ? selectedVariant?.id : null
|
|
109
|
-
|
|
110
|
-
if (params.get("v_id") === value) return
|
|
111
|
-
|
|
112
|
-
if (value) {
|
|
113
|
-
params.set("v_id", value)
|
|
114
|
-
} else {
|
|
115
|
-
params.delete("v_id")
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
router.replace(pathname + "?" + params.toString())
|
|
119
|
-
}, [selectedVariant, isValidVariant, searchParams, pathname, router])
|
|
120
|
-
|
|
121
|
-
const inStock = useMemo(() => {
|
|
122
|
-
if (!selectedVariant) return false
|
|
123
|
-
// The server computes THE availability predicate and emits it as
|
|
124
|
-
// `in_stock` on the variant DTO — read it, never re-derive it here
|
|
125
|
-
// (the hand-copied clauses this replaced could not see stock levels and
|
|
126
|
-
// showed an enabled Add to cart on sold-out variants until a 400).
|
|
127
|
-
if (typeof selectedVariant.in_stock === "boolean") {
|
|
128
|
-
return selectedVariant.in_stock && !stockExhausted
|
|
129
|
-
}
|
|
130
|
-
// Legacy wire (platform predates the field): optimistic fallback — the
|
|
131
|
-
// server still enforces stock on add.
|
|
132
|
-
if (!selectedVariant.manage_inventory) return true
|
|
133
|
-
if (selectedVariant.allow_backorder) return true
|
|
134
|
-
const qty = (selectedVariant as { inventory_quantity?: number })
|
|
135
|
-
.inventory_quantity
|
|
136
|
-
if (qty === undefined) return !stockExhausted
|
|
137
|
-
return qty > 0
|
|
138
|
-
}, [selectedVariant, stockExhausted])
|
|
66
|
+
const state = useProductActions({ product, addToCart, initialVariantId, onAddToCart, openCart })
|
|
67
|
+
const { variant, chosen, choose, quantity, setQuantity, inStock, isAdding, isValidVariant, add } = state
|
|
139
68
|
|
|
140
69
|
const actionsRef = useRef<HTMLDivElement>(null)
|
|
141
70
|
const inView = useIntersection(actionsRef, "0px")
|
|
142
|
-
|
|
143
|
-
const handleAddToCart = async () => {
|
|
144
|
-
if (!selectedVariant?.id) return
|
|
145
|
-
|
|
146
|
-
setIsAdding(true)
|
|
147
|
-
try {
|
|
148
|
-
await addToCart({ variantId: selectedVariant.id, quantity: 1 })
|
|
149
|
-
} catch (e) {
|
|
150
|
-
if ((e as { code?: string } | null)?.code === "insufficient_inventory") {
|
|
151
|
-
setStockExhausted(true)
|
|
152
|
-
return
|
|
153
|
-
}
|
|
154
|
-
throw e
|
|
155
|
-
} finally {
|
|
156
|
-
setIsAdding(false)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
onAddToCart?.(product, selectedVariant)
|
|
160
|
-
openCart?.()
|
|
161
|
-
router.refresh()
|
|
162
|
-
}
|
|
71
|
+
const busy = !!disabled || isAdding
|
|
163
72
|
|
|
164
73
|
return (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
<div>
|
|
168
|
-
{(product.
|
|
169
|
-
<div
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
disabled={!!disabled || isAdding}
|
|
179
|
-
/>
|
|
180
|
-
</div>
|
|
181
|
-
))}
|
|
182
|
-
<div className="h-px bg-border" />
|
|
74
|
+
<div className="flex flex-col gap-y-2" ref={actionsRef}>
|
|
75
|
+
{(product.variants?.length ?? 0) > 1 && (
|
|
76
|
+
<div className="flex flex-col gap-y-4">
|
|
77
|
+
{(product.options || []).map((option) => (
|
|
78
|
+
<div key={option.id}>
|
|
79
|
+
<OptionSelect
|
|
80
|
+
option={option}
|
|
81
|
+
current={chosen[option.id]}
|
|
82
|
+
updateOption={choose}
|
|
83
|
+
title={option.title ?? ""}
|
|
84
|
+
data-testid="product-options"
|
|
85
|
+
disabled={busy}
|
|
86
|
+
/>
|
|
183
87
|
</div>
|
|
184
|
-
)}
|
|
88
|
+
))}
|
|
89
|
+
<div className="h-px bg-border" />
|
|
185
90
|
</div>
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
<ProductPrice product={product} variant={variant} />
|
|
94
|
+
|
|
95
|
+
{showQuantity && (
|
|
96
|
+
<div className="flex flex-col gap-y-2">
|
|
97
|
+
<span className="text-sm text-foreground">{labels.quantity}</span>
|
|
98
|
+
<div className="inline-flex w-fit items-stretch rounded-lg border border-border bg-card">
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={() => setQuantity(quantity - 1)}
|
|
102
|
+
aria-label={labels.decreaseQuantity}
|
|
103
|
+
disabled={quantity <= 1 || busy}
|
|
104
|
+
className="px-3 text-foreground transition-colors hover:bg-muted disabled:opacity-40"
|
|
105
|
+
data-testid="quantity-decrease"
|
|
106
|
+
>
|
|
107
|
+
<Minus className="size-4" aria-hidden />
|
|
108
|
+
</button>
|
|
109
|
+
<output className="min-w-10 py-2 text-center text-sm text-foreground" aria-live="polite" data-testid="quantity">
|
|
110
|
+
{quantity}
|
|
111
|
+
</output>
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
onClick={() => setQuantity(quantity + 1)}
|
|
115
|
+
aria-label={labels.increaseQuantity}
|
|
116
|
+
disabled={busy}
|
|
117
|
+
className="px-3 text-foreground transition-colors hover:bg-muted disabled:opacity-40"
|
|
118
|
+
data-testid="quantity-increase"
|
|
119
|
+
>
|
|
120
|
+
<Plus className="size-4" aria-hidden />
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
<Button
|
|
127
|
+
onClick={add}
|
|
128
|
+
disabled={!inStock || !variant || busy || !isValidVariant}
|
|
129
|
+
className="w-full h-10"
|
|
130
|
+
data-testid="add-product-button"
|
|
131
|
+
>
|
|
132
|
+
{!variant ? labels.selectVariant : !inStock ? labels.outOfStock : labels.addToCart}
|
|
133
|
+
</Button>
|
|
134
|
+
|
|
135
|
+
<MobileActions
|
|
136
|
+
product={product}
|
|
137
|
+
variant={variant}
|
|
138
|
+
options={chosen}
|
|
139
|
+
updateOptions={choose}
|
|
140
|
+
inStock={inStock}
|
|
141
|
+
handleAddToCart={add}
|
|
142
|
+
isAdding={isAdding}
|
|
143
|
+
show={!inView}
|
|
144
|
+
optionsDisabled={busy}
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
220
147
|
)
|
|
221
148
|
}
|
|
222
149
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PDP title/collection/description block — ported from
|
|
3
3
|
* `@1click/ui/src/products/product-info.tsx` (v2.3.1). Data seam:
|
|
4
|
-
* `HttpTypes.StoreProduct` → Cartbase `StoreProduct
|
|
5
|
-
*
|
|
4
|
+
* `HttpTypes.StoreProduct` → Cartbase `StoreProduct`. The product carries
|
|
5
|
+
* EVERY collection it belongs to since 2026-09-15 (the membership join is
|
|
6
|
+
* the one store); the line above the title names the first of them, which is
|
|
7
|
+
* what the single `collection` object used to be. Server-safe.
|
|
6
8
|
*/
|
|
7
9
|
import Link from "next/link"
|
|
8
10
|
import type { StoreProduct } from "../api/products"
|
|
@@ -12,15 +14,18 @@ type ProductInfoProps = {
|
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export function ProductInfo({ product }: ProductInfoProps) {
|
|
17
|
+
const collection = (product.collections ?? [])
|
|
18
|
+
.map((m) => m.collection)
|
|
19
|
+
.find((c): c is NonNullable<typeof c> => Boolean(c))
|
|
15
20
|
return (
|
|
16
21
|
<div id="product-info">
|
|
17
22
|
<div className="flex flex-col gap-y-4 lg:max-w-[500px] mx-auto">
|
|
18
|
-
{
|
|
23
|
+
{collection && (
|
|
19
24
|
<Link
|
|
20
|
-
href={`/collections/${
|
|
25
|
+
href={`/collections/${collection.handle}`}
|
|
21
26
|
className="text-sm text-muted-foreground hover:text-muted-foreground"
|
|
22
27
|
>
|
|
23
|
-
{
|
|
28
|
+
{collection.title}
|
|
24
29
|
</Link>
|
|
25
30
|
)}
|
|
26
31
|
<h2
|