@cartbase/storefront 0.16.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.
Files changed (110) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -255
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +180 -130
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/products.ts +43 -1
  18. package/src/api/redirects.ts +37 -37
  19. package/src/api/regions.ts +200 -200
  20. package/src/api/reviews.ts +259 -259
  21. package/src/api/search.ts +163 -163
  22. package/src/api/store.ts +35 -35
  23. package/src/api/types.ts +91 -91
  24. package/src/cart-drawer/context.tsx +277 -164
  25. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  26. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  27. package/src/cart-drawer/free-gift.tsx +1 -1
  28. package/src/cart-drawer/gift-wrap.tsx +82 -82
  29. package/src/cart-drawer/index.ts +71 -68
  30. package/src/cart-drawer/item/index.tsx +162 -162
  31. package/src/cart-drawer/item/quantity.tsx +8 -14
  32. package/src/cart-drawer/item/upsell.tsx +110 -110
  33. package/src/cart-drawer/labels.ts +123 -123
  34. package/src/cart-drawer/mutation-queue.ts +78 -0
  35. package/src/cart-drawer/notes.tsx +131 -131
  36. package/src/cart-drawer/sticky-footer.tsx +73 -73
  37. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  38. package/src/checkout/address-error-copy.ts +117 -117
  39. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  40. package/src/checkout/compare-addresses.ts +40 -40
  41. package/src/checkout/discount-section.tsx +218 -218
  42. package/src/checkout/error-copy-codes.ts +63 -63
  43. package/src/checkout/geocode.ts +154 -154
  44. package/src/checkout/gift-card-section.tsx +224 -224
  45. package/src/checkout/index.ts +72 -72
  46. package/src/checkout/labels.ts +495 -495
  47. package/src/checkout/payment-button.tsx +372 -372
  48. package/src/checkout/payment-error-copy.ts +154 -154
  49. package/src/checkout/promotion-error-copy.ts +91 -91
  50. package/src/checkout/use-checkout-orchestration.ts +1592 -1592
  51. package/src/common/country-flag.tsx +52 -52
  52. package/src/common/country-select.tsx +11 -11
  53. package/src/common/index.ts +20 -20
  54. package/src/common/market-select.tsx +57 -57
  55. package/src/index.ts +12 -12
  56. package/src/lib/country-name.ts +59 -59
  57. package/src/lib/get-product-price.ts +133 -133
  58. package/src/lib/media-image.tsx +39 -0
  59. package/src/lib/payment-constants.ts +53 -53
  60. package/src/lib/platform.ts +13 -13
  61. package/src/lib/price.tsx +39 -39
  62. package/src/lib/store-api-error.ts +36 -36
  63. package/src/lib/variant-caption.ts +32 -32
  64. package/src/locales/bg.ts +467 -467
  65. package/src/locales/context.ts +37 -37
  66. package/src/locales/en.ts +26 -26
  67. package/src/locales/es.ts +466 -466
  68. package/src/locales/index.ts +19 -19
  69. package/src/locales/provider.tsx +59 -59
  70. package/src/locales/types.ts +77 -77
  71. package/src/order/index.ts +62 -62
  72. package/src/order/labels.ts +79 -79
  73. package/src/order/order-item.tsx +1 -1
  74. package/src/order/order-totals.tsx +250 -250
  75. package/src/primitives/select-field.tsx +93 -93
  76. package/src/products/image-gallery.tsx +43 -43
  77. package/src/products/index.ts +68 -59
  78. package/src/products/option-select.tsx +67 -67
  79. package/src/products/product-actions-wrapper.tsx +63 -62
  80. package/src/products/product-actions.tsx +151 -150
  81. package/src/products/product-price.tsx +69 -69
  82. package/src/products/product-promises.tsx +61 -61
  83. package/src/products/product-specs.tsx +65 -65
  84. package/src/products/product-tabs.tsx +123 -123
  85. package/src/products/product-template.tsx +163 -162
  86. package/src/products/purchase-options.tsx +130 -130
  87. package/src/products/sets.ts +110 -0
  88. package/src/products/thumbnail.tsx +1 -1
  89. package/src/products/use-product-actions.ts +199 -164
  90. package/src/products/variant-matching.ts +71 -71
  91. package/src/products/variant-url.ts +74 -74
  92. package/src/reviews-ui/index.ts +73 -73
  93. package/src/store/category-template.tsx +136 -136
  94. package/src/store/index.ts +40 -40
  95. package/src/tracking/chatgpt-pixel.tsx +99 -99
  96. package/src/tracking/consent-init.tsx +62 -62
  97. package/src/tracking/events.ts +348 -348
  98. package/src/tracking/ga4.tsx +93 -93
  99. package/src/tracking/google-ads.ts +84 -84
  100. package/src/tracking/gtm.tsx +60 -60
  101. package/src/tracking/inline-script.ts +49 -49
  102. package/src/tracking/oaiq.ts +206 -206
  103. package/src/tracking/tiktok-pixel.tsx +91 -91
  104. package/src/tracking/track-init.tsx +56 -56
  105. package/src/tracking/track-order-purchase.tsx +122 -122
  106. package/src/tracking/ttq.ts +180 -180
  107. package/src/tracking/use-tracking-config.ts +54 -54
  108. package/theme/index.css +25 -25
  109. package/theme/theme.css +150 -150
  110. 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
- addToCart: (input: AddToCartInput) => Promise<void>
35
- onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
36
- openCart?: () => void
37
- /**
38
- * The store's delivery, exchange and return promises, shown in the
39
- * accordion. No default: with none, that section does not exist, because
40
- * the library cannot promise anything on a merchant's behalf.
41
- */
42
- promises?: ProductPromise[]
43
- /**
44
- * The store's product pack, REQUIRED even when a `StorefrontLocaleProvider`
45
- * is mounted: the related-products strip is a SERVER component and a
46
- * server component cannot read a client context, so its heading stays
47
- * English unless the pack arrives as a prop. Until 2026-09-13 this
48
- * template did not accept one at all and did not pass one down, which made
49
- * that strip untranslatable without ejecting the file (Alexander found it
50
- * on evoo: a Bulgarian page ending in "Related products / You might also
51
- * want to check out these products"). Optional until 2026-09-14, which is
52
- * how a page could still forget it; now the build refuses the page.
53
- * Pass `STORE_LOCALE.products` (`en.products` for an English store).
54
- */
55
- labels: ProductLabels
56
- /** Drop the physical-facts section even for a product that has facts. */
57
- hideSpecs?: boolean
58
- /** Anything else the store wants in the accordion, appended in order. */
59
- sections?: ProductSection[]
60
- /**
61
- * The variant the address names: pass `searchParams.variant` from the
62
- * page, so the server renders that variant's price, code and stock and
63
- * nothing flashes (the product page contract, `use-product-actions.ts`).
64
- */
65
- initialVariantId?: string | null
66
- /** The store's own card for the related strip; the library's preview without it. */
67
- renderProduct?: ComponentType<{ product: StoreProduct }>
68
- }
69
-
70
- export function ProductTemplate({
71
- client,
72
- product,
73
- pricingContext,
74
- addToCart,
75
- onAddToCart,
76
- openCart,
77
- promises,
78
- labels,
79
- hideSpecs,
80
- sections,
81
- initialVariantId,
82
- renderProduct,
83
- }: ProductTemplateProps) {
84
- if (!product || !product.id) {
85
- return notFound()
86
- }
87
-
88
- const images = product.images ?? []
89
-
90
- return (
91
- <>
92
- <div
93
- className="max-w-7xl mx-auto px-4 flex flex-col sm:flex-row sm:items-start py-6 relative"
94
- data-testid="product-container"
95
- >
96
- <div className="flex flex-col sm:sticky sm:top-48 sm:py-0 sm:max-w-[300px] w-full py-8 gap-y-6">
97
- <ProductInfo product={product} />
98
- <ProductTabs
99
- product={product}
100
- promises={promises}
101
- hideSpecs={hideSpecs}
102
- sections={sections}
103
- />
104
- </div>
105
- <div className="block w-full relative">
106
- <ImageGallery images={images} />
107
- </div>
108
- <div className="flex flex-col sm:sticky sm:top-48 sm:py-0 sm:max-w-[300px] w-full py-8 gap-y-12">
109
- <Suspense
110
- fallback={
111
- <ProductActions
112
- disabled={true}
113
- product={product}
114
- initialVariantId={initialVariantId}
115
- addToCart={addToCart}
116
- onAddToCart={onAddToCart}
117
- openCart={openCart}
118
- />
119
- }
120
- >
121
- <ProductActionsWrapper
122
- client={client}
123
- id={product.id}
124
- pricingContext={pricingContext}
125
- initialVariantId={initialVariantId}
126
- addToCart={addToCart}
127
- onAddToCart={onAddToCart}
128
- openCart={openCart}
129
- />
130
- </Suspense>
131
- </div>
132
- </div>
133
- <div
134
- className="max-w-7xl mx-auto px-4 my-16 sm:my-32"
135
- data-testid="related-products-container"
136
- >
137
- <Suspense
138
- fallback={
139
- <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
140
- {Array.from({ length: 4 }).map((_, i) => (
141
- <div
142
- key={i}
143
- className="aspect-[9/16] bg-muted animate-pulse rounded-lg"
144
- />
145
- ))}
146
- </div>
147
- }
148
- >
149
- <RelatedProducts
150
- client={client}
151
- product={product}
152
- pricingContext={pricingContext}
153
- labels={labels}
154
- renderProduct={renderProduct}
155
- />
156
- </Suspense>
157
- </div>
158
- </>
159
- )
160
- }
161
-
162
- export { type ProductTemplateProps }
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
+ }