@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.
Files changed (107) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -256
  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 -180
  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/gift-wrap.tsx +82 -82
  28. package/src/cart-drawer/index.ts +71 -68
  29. package/src/cart-drawer/item/index.tsx +162 -162
  30. package/src/cart-drawer/item/quantity.tsx +8 -14
  31. package/src/cart-drawer/item/upsell.tsx +110 -110
  32. package/src/cart-drawer/labels.ts +123 -123
  33. package/src/cart-drawer/mutation-queue.ts +78 -0
  34. package/src/cart-drawer/notes.tsx +131 -131
  35. package/src/cart-drawer/sticky-footer.tsx +73 -73
  36. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  37. package/src/checkout/address-error-copy.ts +117 -117
  38. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  39. package/src/checkout/compare-addresses.ts +40 -40
  40. package/src/checkout/discount-section.tsx +218 -218
  41. package/src/checkout/error-copy-codes.ts +63 -63
  42. package/src/checkout/geocode.ts +154 -154
  43. package/src/checkout/gift-card-section.tsx +224 -224
  44. package/src/checkout/index.ts +72 -72
  45. package/src/checkout/labels.ts +495 -495
  46. package/src/checkout/payment-button.tsx +372 -372
  47. package/src/checkout/payment-error-copy.ts +154 -154
  48. package/src/checkout/promotion-error-copy.ts +91 -91
  49. package/src/checkout/use-checkout-orchestration.ts +1592 -1592
  50. package/src/common/country-flag.tsx +52 -52
  51. package/src/common/country-select.tsx +11 -11
  52. package/src/common/index.ts +20 -20
  53. package/src/common/market-select.tsx +57 -57
  54. package/src/index.ts +12 -12
  55. package/src/lib/country-name.ts +59 -59
  56. package/src/lib/get-product-price.ts +133 -133
  57. package/src/lib/media-image.tsx +39 -39
  58. package/src/lib/payment-constants.ts +53 -53
  59. package/src/lib/platform.ts +13 -13
  60. package/src/lib/price.tsx +39 -39
  61. package/src/lib/store-api-error.ts +36 -36
  62. package/src/lib/variant-caption.ts +32 -32
  63. package/src/locales/bg.ts +467 -467
  64. package/src/locales/context.ts +37 -37
  65. package/src/locales/en.ts +26 -26
  66. package/src/locales/es.ts +466 -466
  67. package/src/locales/index.ts +19 -19
  68. package/src/locales/provider.tsx +59 -59
  69. package/src/locales/types.ts +77 -77
  70. package/src/order/index.ts +62 -62
  71. package/src/order/labels.ts +79 -79
  72. package/src/order/order-totals.tsx +250 -250
  73. package/src/primitives/select-field.tsx +93 -93
  74. package/src/products/image-gallery.tsx +43 -43
  75. package/src/products/index.ts +68 -59
  76. package/src/products/option-select.tsx +67 -67
  77. package/src/products/product-actions-wrapper.tsx +63 -62
  78. package/src/products/product-actions.tsx +151 -150
  79. package/src/products/product-price.tsx +69 -69
  80. package/src/products/product-promises.tsx +61 -61
  81. package/src/products/product-specs.tsx +65 -65
  82. package/src/products/product-tabs.tsx +123 -123
  83. package/src/products/product-template.tsx +163 -162
  84. package/src/products/purchase-options.tsx +130 -130
  85. package/src/products/sets.ts +110 -0
  86. package/src/products/use-product-actions.ts +199 -164
  87. package/src/products/variant-matching.ts +71 -71
  88. package/src/products/variant-url.ts +74 -74
  89. package/src/reviews-ui/index.ts +73 -73
  90. package/src/store/category-template.tsx +136 -136
  91. package/src/store/index.ts +40 -40
  92. package/src/tracking/chatgpt-pixel.tsx +99 -99
  93. package/src/tracking/consent-init.tsx +62 -62
  94. package/src/tracking/events.ts +348 -348
  95. package/src/tracking/ga4.tsx +93 -93
  96. package/src/tracking/google-ads.ts +84 -84
  97. package/src/tracking/gtm.tsx +60 -60
  98. package/src/tracking/inline-script.ts +49 -49
  99. package/src/tracking/oaiq.ts +206 -206
  100. package/src/tracking/tiktok-pixel.tsx +91 -91
  101. package/src/tracking/track-init.tsx +56 -56
  102. package/src/tracking/track-order-purchase.tsx +122 -122
  103. package/src/tracking/ttq.ts +180 -180
  104. package/src/tracking/use-tracking-config.ts +54 -54
  105. package/theme/index.css +25 -25
  106. package/theme/theme.css +150 -150
  107. 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
+ }