@cartbase/storefront 0.17.0 → 0.18.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/LICENSE +21 -21
- package/package.json +258 -256
- 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/products.ts +43 -1
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/reviews.ts +259 -259
- 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 +277 -164
- 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/index.ts +71 -68
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/quantity.tsx +8 -14
- 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 -0
- 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/checkout/use-checkout-orchestration.ts +1592 -1592
- 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 +467 -467
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +466 -466
- 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/index.ts +68 -59
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-actions-wrapper.tsx +63 -62
- package/src/products/product-actions.tsx +151 -150
- 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/product-template.tsx +163 -162
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -0
- package/src/products/use-product-actions.ts +199 -164
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/index.ts +73 -73
- 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/theme/theme.css +150 -150
- package/theme/tokens.css +106 -106
|
@@ -1,162 +1,163 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Full PDP layout — ported from
|
|
3
|
-
* `@1click/ui/src/products/product-template.tsx` (v2.3.1). Data seam:
|
|
4
|
-
* `region`/`countryCode` (Medusa region routing) are replaced by
|
|
5
|
-
* `client` + `pricingContext`; the cart seam (`addToCart`/`openCart`)
|
|
6
|
-
* threads through to `ProductActions` (see product-actions.tsx for the
|
|
7
|
-
* seam rationale). Layout — sticky info column, scrolling gallery, sticky
|
|
8
|
-
* actions column, related strip — unchanged.
|
|
9
|
-
*
|
|
10
|
-
* Server component: fetch the product in the page (`retrieveProduct` by
|
|
11
|
-
* handle with the pricing context) and pass it in; the wrapper re-fetches
|
|
12
|
-
* live prices inside Suspense.
|
|
13
|
-
*/
|
|
14
|
-
import { Suspense, type ComponentType } from "react"
|
|
15
|
-
import { notFound } from "next/navigation"
|
|
16
|
-
|
|
17
|
-
import type { StorefrontClient } from "../api/http"
|
|
18
|
-
import type { PricingContextQuery } from "../api/types"
|
|
19
|
-
import type { StoreProduct, StoreProductVariant } from "../api/products"
|
|
20
|
-
import { ImageGallery } from "./image-gallery"
|
|
21
|
-
import { ProductActions, type AddToCartInput } from "./product-actions"
|
|
22
|
-
import { ProductTabs, type ProductSection } from "./product-tabs"
|
|
23
|
-
import type { ProductPromise } from "./product-promises"
|
|
24
|
-
import type { ProductLabels } from "./labels"
|
|
25
|
-
import { RelatedProducts } from "./related-products"
|
|
26
|
-
import { ProductInfo } from "./product-info"
|
|
27
|
-
import { ProductActionsWrapper } from "./product-actions-wrapper"
|
|
28
|
-
|
|
29
|
-
type ProductTemplateProps = {
|
|
30
|
-
client: StorefrontClient
|
|
31
|
-
product: StoreProduct
|
|
32
|
-
/** Pricing context (currency_code/region_id) for actions + related. */
|
|
33
|
-
pricingContext?: PricingContextQuery
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Full PDP layout — ported from
|
|
3
|
+
* `@1click/ui/src/products/product-template.tsx` (v2.3.1). Data seam:
|
|
4
|
+
* `region`/`countryCode` (Medusa region routing) are replaced by
|
|
5
|
+
* `client` + `pricingContext`; the cart seam (`addToCart`/`openCart`)
|
|
6
|
+
* threads through to `ProductActions` (see product-actions.tsx for the
|
|
7
|
+
* seam rationale). Layout — sticky info column, scrolling gallery, sticky
|
|
8
|
+
* actions column, related strip — unchanged.
|
|
9
|
+
*
|
|
10
|
+
* Server component: fetch the product in the page (`retrieveProduct` by
|
|
11
|
+
* handle with the pricing context) and pass it in; the wrapper re-fetches
|
|
12
|
+
* live prices inside Suspense.
|
|
13
|
+
*/
|
|
14
|
+
import { Suspense, type ComponentType } from "react"
|
|
15
|
+
import { notFound } from "next/navigation"
|
|
16
|
+
|
|
17
|
+
import type { StorefrontClient } from "../api/http"
|
|
18
|
+
import type { PricingContextQuery } from "../api/types"
|
|
19
|
+
import type { StoreProduct, StoreProductVariant } from "../api/products"
|
|
20
|
+
import { ImageGallery } from "./image-gallery"
|
|
21
|
+
import { ProductActions, type AddToCartInput } from "./product-actions"
|
|
22
|
+
import { ProductTabs, type ProductSection } from "./product-tabs"
|
|
23
|
+
import type { ProductPromise } from "./product-promises"
|
|
24
|
+
import type { ProductLabels } from "./labels"
|
|
25
|
+
import { RelatedProducts } from "./related-products"
|
|
26
|
+
import { ProductInfo } from "./product-info"
|
|
27
|
+
import { ProductActionsWrapper } from "./product-actions-wrapper"
|
|
28
|
+
|
|
29
|
+
type ProductTemplateProps = {
|
|
30
|
+
client: StorefrontClient
|
|
31
|
+
product: StoreProduct
|
|
32
|
+
/** Pricing context (currency_code/region_id) for actions + related. */
|
|
33
|
+
pricingContext?: PricingContextQuery
|
|
34
|
+
/** A server action; leave it out for the instant add through the mounted cart drawer. */
|
|
35
|
+
addToCart?: (input: AddToCartInput) => Promise<void>
|
|
36
|
+
onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
|
|
37
|
+
openCart?: () => void
|
|
38
|
+
/**
|
|
39
|
+
* The store's delivery, exchange and return promises, shown in the
|
|
40
|
+
* accordion. No default: with none, that section does not exist, because
|
|
41
|
+
* the library cannot promise anything on a merchant's behalf.
|
|
42
|
+
*/
|
|
43
|
+
promises?: ProductPromise[]
|
|
44
|
+
/**
|
|
45
|
+
* The store's product pack, REQUIRED even when a `StorefrontLocaleProvider`
|
|
46
|
+
* is mounted: the related-products strip is a SERVER component and a
|
|
47
|
+
* server component cannot read a client context, so its heading stays
|
|
48
|
+
* English unless the pack arrives as a prop. Until 2026-09-13 this
|
|
49
|
+
* template did not accept one at all and did not pass one down, which made
|
|
50
|
+
* that strip untranslatable without ejecting the file (Alexander found it
|
|
51
|
+
* on evoo: a Bulgarian page ending in "Related products / You might also
|
|
52
|
+
* want to check out these products"). Optional until 2026-09-14, which is
|
|
53
|
+
* how a page could still forget it; now the build refuses the page.
|
|
54
|
+
* Pass `STORE_LOCALE.products` (`en.products` for an English store).
|
|
55
|
+
*/
|
|
56
|
+
labels: ProductLabels
|
|
57
|
+
/** Drop the physical-facts section even for a product that has facts. */
|
|
58
|
+
hideSpecs?: boolean
|
|
59
|
+
/** Anything else the store wants in the accordion, appended in order. */
|
|
60
|
+
sections?: ProductSection[]
|
|
61
|
+
/**
|
|
62
|
+
* The variant the address names: pass `searchParams.variant` from the
|
|
63
|
+
* page, so the server renders that variant's price, code and stock and
|
|
64
|
+
* nothing flashes (the product page contract, `use-product-actions.ts`).
|
|
65
|
+
*/
|
|
66
|
+
initialVariantId?: string | null
|
|
67
|
+
/** The store's own card for the related strip; the library's preview without it. */
|
|
68
|
+
renderProduct?: ComponentType<{ product: StoreProduct }>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ProductTemplate({
|
|
72
|
+
client,
|
|
73
|
+
product,
|
|
74
|
+
pricingContext,
|
|
75
|
+
addToCart,
|
|
76
|
+
onAddToCart,
|
|
77
|
+
openCart,
|
|
78
|
+
promises,
|
|
79
|
+
labels,
|
|
80
|
+
hideSpecs,
|
|
81
|
+
sections,
|
|
82
|
+
initialVariantId,
|
|
83
|
+
renderProduct,
|
|
84
|
+
}: ProductTemplateProps) {
|
|
85
|
+
if (!product || !product.id) {
|
|
86
|
+
return notFound()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const images = product.images ?? []
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
<div
|
|
94
|
+
className="max-w-7xl mx-auto px-4 flex flex-col sm:flex-row sm:items-start py-6 relative"
|
|
95
|
+
data-testid="product-container"
|
|
96
|
+
>
|
|
97
|
+
<div className="flex flex-col sm:sticky sm:top-48 sm:py-0 sm:max-w-[300px] w-full py-8 gap-y-6">
|
|
98
|
+
<ProductInfo product={product} />
|
|
99
|
+
<ProductTabs
|
|
100
|
+
product={product}
|
|
101
|
+
promises={promises}
|
|
102
|
+
hideSpecs={hideSpecs}
|
|
103
|
+
sections={sections}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
<div className="block w-full relative">
|
|
107
|
+
<ImageGallery images={images} />
|
|
108
|
+
</div>
|
|
109
|
+
<div className="flex flex-col sm:sticky sm:top-48 sm:py-0 sm:max-w-[300px] w-full py-8 gap-y-12">
|
|
110
|
+
<Suspense
|
|
111
|
+
fallback={
|
|
112
|
+
<ProductActions
|
|
113
|
+
disabled={true}
|
|
114
|
+
product={product}
|
|
115
|
+
initialVariantId={initialVariantId}
|
|
116
|
+
addToCart={addToCart}
|
|
117
|
+
onAddToCart={onAddToCart}
|
|
118
|
+
openCart={openCart}
|
|
119
|
+
/>
|
|
120
|
+
}
|
|
121
|
+
>
|
|
122
|
+
<ProductActionsWrapper
|
|
123
|
+
client={client}
|
|
124
|
+
id={product.id}
|
|
125
|
+
pricingContext={pricingContext}
|
|
126
|
+
initialVariantId={initialVariantId}
|
|
127
|
+
addToCart={addToCart}
|
|
128
|
+
onAddToCart={onAddToCart}
|
|
129
|
+
openCart={openCart}
|
|
130
|
+
/>
|
|
131
|
+
</Suspense>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
<div
|
|
135
|
+
className="max-w-7xl mx-auto px-4 my-16 sm:my-32"
|
|
136
|
+
data-testid="related-products-container"
|
|
137
|
+
>
|
|
138
|
+
<Suspense
|
|
139
|
+
fallback={
|
|
140
|
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
141
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
142
|
+
<div
|
|
143
|
+
key={i}
|
|
144
|
+
className="aspect-[9/16] bg-muted animate-pulse rounded-lg"
|
|
145
|
+
/>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
}
|
|
149
|
+
>
|
|
150
|
+
<RelatedProducts
|
|
151
|
+
client={client}
|
|
152
|
+
product={product}
|
|
153
|
+
pricingContext={pricingContext}
|
|
154
|
+
labels={labels}
|
|
155
|
+
renderProduct={renderProduct}
|
|
156
|
+
/>
|
|
157
|
+
</Suspense>
|
|
158
|
+
</div>
|
|
159
|
+
</>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export { type ProductTemplateProps }
|
|
@@ -1,130 +1,130 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* PDP purchase options — one-time vs subscription plans (subscriptions-core
|
|
5
|
-
* card, step 4; Cartbase-native, no @1click/ui ancestor). Presentational and
|
|
6
|
-
* controlled: the host owns the selection and passes the chosen plan id
|
|
7
|
-
* into its add-to-cart seam (`carts.addLineItem` `selling_plan_id`). The
|
|
8
|
-
* SERVER prices the line — the preview here is display-only and computed
|
|
9
|
-
* from the same plan fields the server applies.
|
|
10
|
-
*
|
|
11
|
-
* Consent note (docs/storefront/checkout.md): a plan selection later
|
|
12
|
-
* requires a logged-in customer at payment (`save_payment_method` →
|
|
13
|
-
* 400 `customer_required` for guests) — surface login before checkout when
|
|
14
|
-
* a plan line is in the cart.
|
|
15
|
-
*/
|
|
16
|
-
import React from "react"
|
|
17
|
-
import type { StoreSellingPlan } from "../api/products"
|
|
18
|
-
import { cn } from "../lib/utils"
|
|
19
|
-
|
|
20
|
-
export type PurchaseOptionsLabels = {
|
|
21
|
-
title: string
|
|
22
|
-
oneTime: string
|
|
23
|
-
/** Interval nouns, singular/plural: {day: ["day","days"], …} */
|
|
24
|
-
every: string
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const DEFAULT_LABELS: PurchaseOptionsLabels = {
|
|
28
|
-
title: "Purchase options",
|
|
29
|
-
oneTime: "One-time purchase",
|
|
30
|
-
every: "Every",
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Display-only mirror of the server's plan pricing (the server is truth). */
|
|
34
|
-
export function previewPlanPrice(plan: StoreSellingPlan, basePrice: number): number {
|
|
35
|
-
if (plan.pricing_type === "percent" && plan.pricing_value != null) {
|
|
36
|
-
return Math.max(0, Math.round(basePrice * (1 - plan.pricing_value / 100) * 100) / 100)
|
|
37
|
-
}
|
|
38
|
-
if (plan.pricing_type === "fixed" && plan.pricing_value != null) {
|
|
39
|
-
return plan.pricing_value
|
|
40
|
-
}
|
|
41
|
-
return basePrice
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function cadenceLabel(plan: StoreSellingPlan, everyWord = "Every"): string {
|
|
45
|
-
const unit =
|
|
46
|
-
plan.interval_count === 1 ? plan.interval : `${plan.interval_count} ${plan.interval}s`
|
|
47
|
-
return `${everyWord} ${unit}`
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
type PurchaseOptionsProps = {
|
|
51
|
-
plans: StoreSellingPlan[]
|
|
52
|
-
/** Selected plan id; null = one-time. */
|
|
53
|
-
value: string | null
|
|
54
|
-
onChange: (sellingPlanId: string | null) => void
|
|
55
|
-
/** The one-time unit price (from the product's calculated price). */
|
|
56
|
-
basePrice: number
|
|
57
|
-
/** Format an amount for display (host's currency formatting). */
|
|
58
|
-
formatPrice: (amount: number) => string
|
|
59
|
-
disabled?: boolean
|
|
60
|
-
labels?: Partial<PurchaseOptionsLabels>
|
|
61
|
-
"data-testid"?: string
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function PurchaseOptions({
|
|
65
|
-
plans,
|
|
66
|
-
value,
|
|
67
|
-
onChange,
|
|
68
|
-
basePrice,
|
|
69
|
-
formatPrice,
|
|
70
|
-
disabled,
|
|
71
|
-
labels: labelOverrides,
|
|
72
|
-
"data-testid": dataTestId,
|
|
73
|
-
}: PurchaseOptionsProps) {
|
|
74
|
-
const labels = { ...DEFAULT_LABELS, ...labelOverrides }
|
|
75
|
-
if (plans.length === 0) return null
|
|
76
|
-
|
|
77
|
-
const options: Array<{
|
|
78
|
-
id: string | null
|
|
79
|
-
title: string
|
|
80
|
-
subtitle: string | null
|
|
81
|
-
price: number
|
|
82
|
-
}> = [
|
|
83
|
-
{ id: null, title: labels.oneTime, subtitle: null, price: basePrice },
|
|
84
|
-
...plans.map((plan) => ({
|
|
85
|
-
id: plan.id,
|
|
86
|
-
title: plan.name,
|
|
87
|
-
subtitle: cadenceLabel(plan, labels.every),
|
|
88
|
-
price: previewPlanPrice(plan, basePrice),
|
|
89
|
-
})),
|
|
90
|
-
]
|
|
91
|
-
|
|
92
|
-
return (
|
|
93
|
-
<div className="flex flex-col gap-y-3" data-testid={dataTestId}>
|
|
94
|
-
<span className="text-sm text-foreground">{labels.title}</span>
|
|
95
|
-
<div role="radiogroup" aria-label={labels.title} className="flex flex-col gap-2">
|
|
96
|
-
{options.map((opt) => {
|
|
97
|
-
const selected = value === opt.id
|
|
98
|
-
return (
|
|
99
|
-
<button
|
|
100
|
-
key={opt.id ?? "one-time"}
|
|
101
|
-
type="button"
|
|
102
|
-
role="radio"
|
|
103
|
-
aria-checked={selected}
|
|
104
|
-
onClick={() => onChange(opt.id)}
|
|
105
|
-
disabled={disabled}
|
|
106
|
-
className={cn(
|
|
107
|
-
"border border-border bg-muted rounded-lg p-3 flex items-center justify-between gap-3 text-left",
|
|
108
|
-
{
|
|
109
|
-
"border-primary": selected,
|
|
110
|
-
"hover:shadow-sm transition-shadow ease-in-out duration-150": !selected,
|
|
111
|
-
}
|
|
112
|
-
)}
|
|
113
|
-
data-testid="purchase-option"
|
|
114
|
-
>
|
|
115
|
-
<span className="flex flex-col">
|
|
116
|
-
<span className="text-sm text-foreground">{opt.title}</span>
|
|
117
|
-
{opt.subtitle && (
|
|
118
|
-
<span className="text-xs text-muted-foreground">{opt.subtitle}</span>
|
|
119
|
-
)}
|
|
120
|
-
</span>
|
|
121
|
-
<span className="text-sm text-foreground whitespace-nowrap">
|
|
122
|
-
{formatPrice(opt.price)}
|
|
123
|
-
</span>
|
|
124
|
-
</button>
|
|
125
|
-
)
|
|
126
|
-
})}
|
|
127
|
-
</div>
|
|
128
|
-
</div>
|
|
129
|
-
)
|
|
130
|
-
}
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PDP purchase options — one-time vs subscription plans (subscriptions-core
|
|
5
|
+
* card, step 4; Cartbase-native, no @1click/ui ancestor). Presentational and
|
|
6
|
+
* controlled: the host owns the selection and passes the chosen plan id
|
|
7
|
+
* into its add-to-cart seam (`carts.addLineItem` `selling_plan_id`). The
|
|
8
|
+
* SERVER prices the line — the preview here is display-only and computed
|
|
9
|
+
* from the same plan fields the server applies.
|
|
10
|
+
*
|
|
11
|
+
* Consent note (docs/storefront/checkout.md): a plan selection later
|
|
12
|
+
* requires a logged-in customer at payment (`save_payment_method` →
|
|
13
|
+
* 400 `customer_required` for guests) — surface login before checkout when
|
|
14
|
+
* a plan line is in the cart.
|
|
15
|
+
*/
|
|
16
|
+
import React from "react"
|
|
17
|
+
import type { StoreSellingPlan } from "../api/products"
|
|
18
|
+
import { cn } from "../lib/utils"
|
|
19
|
+
|
|
20
|
+
export type PurchaseOptionsLabels = {
|
|
21
|
+
title: string
|
|
22
|
+
oneTime: string
|
|
23
|
+
/** Interval nouns, singular/plural: {day: ["day","days"], …} */
|
|
24
|
+
every: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_LABELS: PurchaseOptionsLabels = {
|
|
28
|
+
title: "Purchase options",
|
|
29
|
+
oneTime: "One-time purchase",
|
|
30
|
+
every: "Every",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Display-only mirror of the server's plan pricing (the server is truth). */
|
|
34
|
+
export function previewPlanPrice(plan: StoreSellingPlan, basePrice: number): number {
|
|
35
|
+
if (plan.pricing_type === "percent" && plan.pricing_value != null) {
|
|
36
|
+
return Math.max(0, Math.round(basePrice * (1 - plan.pricing_value / 100) * 100) / 100)
|
|
37
|
+
}
|
|
38
|
+
if (plan.pricing_type === "fixed" && plan.pricing_value != null) {
|
|
39
|
+
return plan.pricing_value
|
|
40
|
+
}
|
|
41
|
+
return basePrice
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function cadenceLabel(plan: StoreSellingPlan, everyWord = "Every"): string {
|
|
45
|
+
const unit =
|
|
46
|
+
plan.interval_count === 1 ? plan.interval : `${plan.interval_count} ${plan.interval}s`
|
|
47
|
+
return `${everyWord} ${unit}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type PurchaseOptionsProps = {
|
|
51
|
+
plans: StoreSellingPlan[]
|
|
52
|
+
/** Selected plan id; null = one-time. */
|
|
53
|
+
value: string | null
|
|
54
|
+
onChange: (sellingPlanId: string | null) => void
|
|
55
|
+
/** The one-time unit price (from the product's calculated price). */
|
|
56
|
+
basePrice: number
|
|
57
|
+
/** Format an amount for display (host's currency formatting). */
|
|
58
|
+
formatPrice: (amount: number) => string
|
|
59
|
+
disabled?: boolean
|
|
60
|
+
labels?: Partial<PurchaseOptionsLabels>
|
|
61
|
+
"data-testid"?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function PurchaseOptions({
|
|
65
|
+
plans,
|
|
66
|
+
value,
|
|
67
|
+
onChange,
|
|
68
|
+
basePrice,
|
|
69
|
+
formatPrice,
|
|
70
|
+
disabled,
|
|
71
|
+
labels: labelOverrides,
|
|
72
|
+
"data-testid": dataTestId,
|
|
73
|
+
}: PurchaseOptionsProps) {
|
|
74
|
+
const labels = { ...DEFAULT_LABELS, ...labelOverrides }
|
|
75
|
+
if (plans.length === 0) return null
|
|
76
|
+
|
|
77
|
+
const options: Array<{
|
|
78
|
+
id: string | null
|
|
79
|
+
title: string
|
|
80
|
+
subtitle: string | null
|
|
81
|
+
price: number
|
|
82
|
+
}> = [
|
|
83
|
+
{ id: null, title: labels.oneTime, subtitle: null, price: basePrice },
|
|
84
|
+
...plans.map((plan) => ({
|
|
85
|
+
id: plan.id,
|
|
86
|
+
title: plan.name,
|
|
87
|
+
subtitle: cadenceLabel(plan, labels.every),
|
|
88
|
+
price: previewPlanPrice(plan, basePrice),
|
|
89
|
+
})),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className="flex flex-col gap-y-3" data-testid={dataTestId}>
|
|
94
|
+
<span className="text-sm text-foreground">{labels.title}</span>
|
|
95
|
+
<div role="radiogroup" aria-label={labels.title} className="flex flex-col gap-2">
|
|
96
|
+
{options.map((opt) => {
|
|
97
|
+
const selected = value === opt.id
|
|
98
|
+
return (
|
|
99
|
+
<button
|
|
100
|
+
key={opt.id ?? "one-time"}
|
|
101
|
+
type="button"
|
|
102
|
+
role="radio"
|
|
103
|
+
aria-checked={selected}
|
|
104
|
+
onClick={() => onChange(opt.id)}
|
|
105
|
+
disabled={disabled}
|
|
106
|
+
className={cn(
|
|
107
|
+
"border border-border bg-muted rounded-lg p-3 flex items-center justify-between gap-3 text-left",
|
|
108
|
+
{
|
|
109
|
+
"border-primary": selected,
|
|
110
|
+
"hover:shadow-sm transition-shadow ease-in-out duration-150": !selected,
|
|
111
|
+
}
|
|
112
|
+
)}
|
|
113
|
+
data-testid="purchase-option"
|
|
114
|
+
>
|
|
115
|
+
<span className="flex flex-col">
|
|
116
|
+
<span className="text-sm text-foreground">{opt.title}</span>
|
|
117
|
+
{opt.subtitle && (
|
|
118
|
+
<span className="text-xs text-muted-foreground">{opt.subtitle}</span>
|
|
119
|
+
)}
|
|
120
|
+
</span>
|
|
121
|
+
<span className="text-sm text-foreground whitespace-nowrap">
|
|
122
|
+
{formatPrice(opt.price)}
|
|
123
|
+
</span>
|
|
124
|
+
</button>
|
|
125
|
+
)
|
|
126
|
+
})}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|