@umituz/web-payment 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -2
  3. package/dist/billing-adapter.interface-CDkeeowZ.d.mts +94 -0
  4. package/dist/billing-adapter.interface-m7yE50I0.d.ts +94 -0
  5. package/dist/billing-cycle.enum--hJRYlgP.d.mts +10 -0
  6. package/dist/billing-cycle.enum--hJRYlgP.d.ts +10 -0
  7. package/dist/billing-env-D4YjvA3M.d.mts +8 -0
  8. package/dist/billing-env-D4YjvA3M.d.ts +8 -0
  9. package/dist/checkout-metadata-D7Wp_DAD.d.mts +27 -0
  10. package/dist/checkout-metadata-D7Wp_DAD.d.ts +27 -0
  11. package/dist/chunk-2KV7JFYF.mjs +61 -0
  12. package/dist/chunk-2NH5DOLT.mjs +101 -0
  13. package/dist/chunk-3AXPZOCV.mjs +106 -0
  14. package/dist/chunk-4N3BASZ6.mjs +51 -0
  15. package/dist/chunk-NHKK3ENU.mjs +1128 -0
  16. package/dist/chunk-PIWNUAKW.mjs +30 -0
  17. package/dist/chunk-QFB7AOSQ.mjs +33 -0
  18. package/dist/chunk-RQTMBR74.mjs +30 -0
  19. package/dist/chunk-U45FS4FF.mjs +110 -0
  20. package/dist/chunk-X6R76FZT.mjs +0 -0
  21. package/dist/chunk-XQ2HJTTA.mjs +252 -0
  22. package/dist/core.d.mts +17 -0
  23. package/dist/core.d.ts +17 -0
  24. package/dist/core.js +425 -0
  25. package/dist/core.mjs +66 -0
  26. package/dist/http.d.mts +12 -0
  27. package/dist/http.d.ts +12 -0
  28. package/dist/http.js +115 -0
  29. package/dist/http.mjs +88 -0
  30. package/dist/index.d.mts +17 -0
  31. package/dist/index.d.ts +17 -0
  32. package/dist/index.js +1929 -0
  33. package/dist/index.mjs +153 -0
  34. package/dist/memory.d.mts +14 -0
  35. package/dist/memory.d.ts +14 -0
  36. package/dist/memory.js +130 -0
  37. package/dist/memory.mjs +6 -0
  38. package/dist/portal.d.mts +445 -0
  39. package/dist/portal.d.ts +445 -0
  40. package/dist/portal.js +2927 -0
  41. package/dist/portal.mjs +2737 -0
  42. package/dist/product.mapper-CnY1ISOp.d.ts +97 -0
  43. package/dist/product.mapper-noVl-sel.d.mts +97 -0
  44. package/dist/products.d.mts +64 -0
  45. package/dist/products.d.ts +64 -0
  46. package/dist/products.js +370 -0
  47. package/dist/products.mjs +21 -0
  48. package/dist/react.d.mts +182 -0
  49. package/dist/react.d.ts +182 -0
  50. package/dist/react.js +1874 -0
  51. package/dist/react.mjs +105 -0
  52. package/dist/server.d.mts +177 -0
  53. package/dist/server.d.ts +177 -0
  54. package/dist/server.js +622 -0
  55. package/dist/server.mjs +417 -0
  56. package/dist/visual-tokens-BCLVR85G.d.mts +94 -0
  57. package/dist/visual-tokens-BCLVR85G.d.ts +94 -0
  58. package/package.json +122 -4
  59. package/src/core.ts +81 -0
  60. package/src/domain/entities/cancellation.entity.ts +28 -0
  61. package/src/domain/entities/checkout.entity.ts +21 -0
  62. package/src/domain/entities/index.ts +10 -0
  63. package/src/domain/entities/order.entity.ts +15 -0
  64. package/src/domain/entities/product.entity.ts +16 -0
  65. package/src/domain/entities/subscription.entity.ts +24 -0
  66. package/src/domain/entities/sync.entity.ts +4 -0
  67. package/src/domain/enums/amount-type.enum.ts +10 -0
  68. package/src/domain/enums/billing-cycle.enum.ts +30 -0
  69. package/src/domain/enums/index.ts +5 -0
  70. package/src/domain/enums/payment-provider.enum.ts +14 -0
  71. package/src/domain/enums/subscription-status.enum.ts +32 -0
  72. package/src/domain/index.ts +5 -0
  73. package/src/domain/interfaces/billing-adapter.interface.ts +18 -0
  74. package/src/domain/interfaces/index.ts +1 -0
  75. package/src/domain/mappers/product.mapper.ts +176 -0
  76. package/src/domain/utils/minor-units.ts +20 -0
  77. package/src/domain/utils/percent-discount.ts +17 -0
  78. package/src/http.ts +3 -0
  79. package/src/index.ts +12 -0
  80. package/src/infrastructure/browser/hosted-checkout.ts +26 -0
  81. package/src/infrastructure/config/billing-env.ts +31 -0
  82. package/src/infrastructure/constants/billing.constants.ts +10 -0
  83. package/src/infrastructure/http/http-client.ts +83 -0
  84. package/src/infrastructure/pagination/page-iterator.ts +43 -0
  85. package/src/infrastructure/query/query-builder.ts +26 -0
  86. package/src/infrastructure/services/billing.client.ts +300 -0
  87. package/src/infrastructure/services/create-billing-client.ts +23 -0
  88. package/src/infrastructure/services/http/create-http-adapter.ts +123 -0
  89. package/src/infrastructure/services/memory/create-memory-adapter.ts +126 -0
  90. package/src/infrastructure/services/product.repository.ts +198 -0
  91. package/src/infrastructure/utils/checkout-metadata.ts +55 -0
  92. package/src/infrastructure/utils/date-renderer.ts +53 -0
  93. package/src/infrastructure/utils/money-formatter.ts +39 -0
  94. package/src/infrastructure/utils/normalization.util.ts +22 -0
  95. package/src/infrastructure/utils/pagination-clamp.ts +11 -0
  96. package/src/infrastructure/utils/receipt.util.ts +6 -0
  97. package/src/infrastructure/utils/security.util.ts +23 -0
  98. package/src/infrastructure/utils/type-guards.util.ts +23 -0
  99. package/src/infrastructure/utils/validation.util.ts +10 -0
  100. package/src/infrastructure/webhook/parse-event.ts +135 -0
  101. package/src/infrastructure/webhook/verify-webhook.ts +124 -0
  102. package/src/memory.ts +3 -0
  103. package/src/portal.ts +2 -0
  104. package/src/presentation/components/BillingHistoryList.tsx +172 -0
  105. package/src/presentation/components/BillingProvider.tsx +179 -0
  106. package/src/presentation/components/CancelSubscriptionButton.tsx +155 -0
  107. package/src/presentation/components/CheckoutButton.tsx +76 -0
  108. package/src/presentation/components/CustomerPortalLink.tsx +71 -0
  109. package/src/presentation/components/PricingTable.tsx +225 -0
  110. package/src/presentation/components/SubscriptionStatusBadge.tsx +102 -0
  111. package/src/presentation/context/billing-context.ts +30 -0
  112. package/src/presentation/hooks/useBilling.ts +3 -0
  113. package/src/presentation/hooks/useCancelSubscription.ts +47 -0
  114. package/src/presentation/hooks/useCheckoutButton.ts +49 -0
  115. package/src/presentation/hooks/useProducts.ts +76 -0
  116. package/src/presentation/navigation/navigation.strategy.ts +16 -0
  117. package/src/presentation/portal/TurnkeyBillingPage.tsx +144 -0
  118. package/src/presentation/portal/components/BillingLayout.tsx +163 -0
  119. package/src/presentation/portal/components/BillingPage.tsx +22 -0
  120. package/src/presentation/portal/components/BillingPortal.tsx +248 -0
  121. package/src/presentation/portal/components/InvoiceCard.tsx +275 -0
  122. package/src/presentation/portal/components/OverviewTab.tsx +137 -0
  123. package/src/presentation/portal/components/PaymentMethodsList.tsx +268 -0
  124. package/src/presentation/portal/components/PlanComparison.tsx +405 -0
  125. package/src/presentation/portal/components/PlanTab.tsx +197 -0
  126. package/src/presentation/portal/components/PortalButton.tsx +89 -0
  127. package/src/presentation/portal/components/PortalSpinner.tsx +30 -0
  128. package/src/presentation/portal/components/UsageCard.tsx +158 -0
  129. package/src/presentation/portal/components/index.ts +11 -0
  130. package/src/presentation/portal/constants.ts +44 -0
  131. package/src/presentation/portal/create-portal-client.ts +202 -0
  132. package/src/presentation/portal/hooks/index.ts +3 -0
  133. package/src/presentation/portal/hooks/useBilling.ts +240 -0
  134. package/src/presentation/portal/index.ts +97 -0
  135. package/src/presentation/portal/labels.tsx +218 -0
  136. package/src/presentation/portal/styles.ts +70 -0
  137. package/src/presentation/portal/types.ts +213 -0
  138. package/src/presentation/portal/utils/billing.ts +193 -0
  139. package/src/presentation/portal/utils/index.ts +27 -0
  140. package/src/presentation/portal/utils/plans.ts +71 -0
  141. package/src/presentation/theme/visual-tokens.ts +103 -0
  142. package/src/presentation/utils/cancellation-reason-catalog.ts +18 -0
  143. package/src/presentation/utils/checkout-button-visual.ts +50 -0
  144. package/src/presentation/utils/inline-error-visual.ts +21 -0
  145. package/src/presentation/utils/product-pricing.ts +33 -0
  146. package/src/presentation/utils/status-label.ts +23 -0
  147. package/src/presentation/utils/status-tone.ts +25 -0
  148. package/src/products.ts +20 -0
  149. package/src/react.tsx +63 -0
  150. package/src/server.ts +79 -0
@@ -0,0 +1,268 @@
1
+
2
+ import { useState } from 'react';
3
+ import type { CSSProperties } from 'react';
4
+ import {
5
+ CreditCard,
6
+ Trash2,
7
+ Check,
8
+ Plus,
9
+ AlertTriangle,
10
+ } from 'lucide-react';
11
+ import { VisualTokens } from '../../theme/visual-tokens';
12
+ import { useBillingLabels } from '../labels';
13
+ import type { PaymentMethod, PaymentMethodsListProps } from '../types';
14
+ import { formatCardNumber, formatExpiry } from '../utils/billing';
15
+ import { PortalButton } from './PortalButton';
16
+ import { PortalSpinner } from './PortalSpinner';
17
+ import { alertDanger, mutedText, row, stack, visuallyHidden } from '../styles';
18
+
19
+ const BRAND_GRADIENT: Record<string, string> = {
20
+ visa: 'linear-gradient(135deg, #1d4ed8, #3b82f6)',
21
+ mastercard: 'linear-gradient(135deg, #f97316, #ef4444)',
22
+ amex: 'linear-gradient(135deg, #0e7490, #06b6d4)',
23
+ discover: 'linear-gradient(135deg, #f59e0b, #f97316)',
24
+ default: 'linear-gradient(135deg, #334155, #64748b)',
25
+ };
26
+
27
+ const cardGradient = (brand: string | undefined): string => {
28
+ if (!brand) return BRAND_GRADIENT.default;
29
+ const key = brand.toLowerCase();
30
+ return BRAND_GRADIENT[key] ?? BRAND_GRADIENT.default;
31
+ };
32
+
33
+ export const PaymentMethodsList = ({
34
+ paymentMethods,
35
+ loading = false,
36
+ busyId,
37
+ onSetDefault,
38
+ onRemove,
39
+ onAddNew,
40
+ }: PaymentMethodsListProps) => {
41
+ const labels = useBillingLabels();
42
+ const [pendingRemove, setPendingRemove] = useState<string | null>(null);
43
+
44
+ if (loading) {
45
+ return (
46
+ <div style={{ ...row, justifyContent: 'center', padding: `${VisualTokens.spacing.section}px 0` }} role="status" aria-live="polite">
47
+ <PortalSpinner size={32} />
48
+ <span style={visuallyHidden}>{labels.common.loading}</span>
49
+ </div>
50
+ );
51
+ }
52
+
53
+ if (paymentMethods.length === 0) {
54
+ return (
55
+ <div style={{ textAlign: 'center', padding: `${VisualTokens.spacing.section}px 0` }}>
56
+ <CreditCard
57
+ size={48}
58
+ aria-hidden="true"
59
+ style={{ color: VisualTokens.color.textMuted, marginBottom: VisualTokens.spacing.lg }}
60
+ />
61
+ <p style={{ ...mutedText, margin: 0, marginBottom: VisualTokens.spacing.section }}>
62
+ {labels.paymentMethods.none}
63
+ </p>
64
+ {onAddNew && (
65
+ <PortalButton type="button" onClick={onAddNew} variant="outline" icon={<Plus size={16} />}>
66
+ {labels.paymentMethods.add}
67
+ </PortalButton>
68
+ )}
69
+ </div>
70
+ );
71
+ }
72
+
73
+ const requestRemove = (id: string) => setPendingRemove(id);
74
+ const cancelRemove = () => setPendingRemove(null);
75
+ const confirmRemove = (id: string) => {
76
+ onRemove?.(id);
77
+ setPendingRemove(null);
78
+ };
79
+
80
+ return (
81
+ <div style={stack}>
82
+ {paymentMethods.map((method) => (
83
+ <PaymentMethodItem
84
+ key={method.id}
85
+ method={method}
86
+ allMethods={paymentMethods}
87
+ onRemove={onRemove}
88
+ isBusy={busyId === method.id}
89
+ pendingRemove={pendingRemove === method.id}
90
+ onRequestRemove={requestRemove}
91
+ onCancelRemove={cancelRemove}
92
+ onConfirmRemove={confirmRemove}
93
+ onSetDefault={onSetDefault}
94
+ />
95
+ ))}
96
+
97
+ {onAddNew && (
98
+ <PortalButton
99
+ type="button"
100
+ onClick={onAddNew}
101
+ variant="outline"
102
+ fullWidth
103
+ icon={<Plus size={16} />}
104
+ >
105
+ {labels.paymentMethods.add}
106
+ </PortalButton>
107
+ )}
108
+ </div>
109
+ );
110
+ };
111
+
112
+ interface PaymentMethodItemProps {
113
+ method: PaymentMethod;
114
+ allMethods: PaymentMethod[];
115
+ onRemove?: (id: string) => void;
116
+ isBusy: boolean;
117
+ pendingRemove: boolean;
118
+ onRequestRemove: (id: string) => void;
119
+ onCancelRemove: () => void;
120
+ onConfirmRemove: (id: string) => void;
121
+ onSetDefault?: (id: string) => void;
122
+ }
123
+
124
+ const PaymentMethodItem = ({
125
+ method,
126
+ allMethods,
127
+ onRemove,
128
+ isBusy,
129
+ pendingRemove,
130
+ onRequestRemove,
131
+ onCancelRemove,
132
+ onConfirmRemove,
133
+ onSetDefault,
134
+ }: PaymentMethodItemProps) => {
135
+ const labels = useBillingLabels();
136
+
137
+ const itemStyle: CSSProperties = {
138
+ borderRadius: VisualTokens.radius.lg,
139
+ border: `1px solid ${method.isDefault ? VisualTokens.color.brandPrimary : VisualTokens.color.borderSubtle}`,
140
+ background: method.isDefault ? VisualTokens.color.surfaceMuted : VisualTokens.color.surfacePrimary,
141
+ padding: VisualTokens.spacing.xl,
142
+ };
143
+
144
+ return (
145
+ <div style={itemStyle}>
146
+ <div style={{ ...row, justifyContent: 'space-between', gap: VisualTokens.spacing.xl }}>
147
+ <div style={{ ...row, gap: VisualTokens.spacing.xl, minWidth: 0 }}>
148
+ <div
149
+ style={{
150
+ width: 48,
151
+ height: 32,
152
+ borderRadius: VisualTokens.radius.sm,
153
+ background: cardGradient(method.card?.brand),
154
+ display: 'flex',
155
+ alignItems: 'center',
156
+ justifyContent: 'center',
157
+ flexShrink: 0,
158
+ }}
159
+ >
160
+ {method.card && (
161
+ <CreditCard size={20} color={VisualTokens.color.textInverse} aria-hidden="true" />
162
+ )}
163
+ </div>
164
+ <div style={{ minWidth: 0 }}>
165
+ <p
166
+ style={{
167
+ fontWeight: VisualTokens.fontWeight.medium,
168
+ color: VisualTokens.color.textPrimary,
169
+ margin: 0,
170
+ overflow: 'hidden',
171
+ textOverflow: 'ellipsis',
172
+ whiteSpace: 'nowrap',
173
+ }}
174
+ >
175
+ {method.card
176
+ ? formatCardNumber(method.card.last4, method.card.brand)
177
+ : labels.paymentMethods.bankAccount}
178
+ </p>
179
+ <p style={{ ...mutedText, margin: 0 }}>
180
+ {method.card
181
+ ? `${labels.paymentMethods.expires} ${formatExpiry(
182
+ method.card.expiryMonth,
183
+ method.card.expiryYear,
184
+ )}`
185
+ : method.bankAccount
186
+ ? `${method.bankAccount.bankName} •••• ${method.bankAccount.last4}`
187
+ : null}
188
+ </p>
189
+ </div>
190
+ {method.isDefault && (
191
+ <span
192
+ style={{
193
+ fontSize: VisualTokens.fontSize.caption,
194
+ background: VisualTokens.color.brandPrimary,
195
+ color: VisualTokens.color.textOnPrimary,
196
+ padding: `${VisualTokens.spacing.xs}px ${VisualTokens.spacing.md}px`,
197
+ borderRadius: VisualTokens.radius.pill,
198
+ fontWeight: VisualTokens.fontWeight.medium,
199
+ flexShrink: 0,
200
+ }}
201
+ aria-label={labels.paymentMethods.default}
202
+ >
203
+ {labels.paymentMethods.default}
204
+ </span>
205
+ )}
206
+ </div>
207
+
208
+ <div style={{ ...row, gap: VisualTokens.spacing.sm, flexShrink: 0 }}>
209
+ {!method.isDefault && onSetDefault && (
210
+ <PortalButton
211
+ type="button"
212
+ variant="ghost"
213
+ size="sm"
214
+ onClick={() => onSetDefault(method.id)}
215
+ disabled={isBusy}
216
+ aria-label={labels.paymentMethods.setDefault}
217
+ >
218
+ <Check size={16} />
219
+ </PortalButton>
220
+ )}
221
+ {onRemove && paymentMethodsAllowsRemove(method, allMethods) && (
222
+ <PortalButton
223
+ type="button"
224
+ variant="ghost"
225
+ size="sm"
226
+ onClick={() => onRequestRemove(method.id)}
227
+ disabled={isBusy}
228
+ aria-label={labels.paymentMethods.remove}
229
+ style={{ color: isBusy ? undefined : VisualTokens.color.brandDanger }}
230
+ >
231
+ {isBusy ? <PortalSpinner size={16} /> : <Trash2 size={16} />}
232
+ </PortalButton>
233
+ )}
234
+ </div>
235
+ </div>
236
+
237
+ {pendingRemove && (
238
+ <div
239
+ role="alert"
240
+ style={{ ...alertDanger, marginTop: VisualTokens.spacing.lg, justifyContent: 'space-between' }}
241
+ >
242
+ <span style={{ ...row, gap: VisualTokens.spacing.md }}>
243
+ <AlertTriangle size={16} style={{ flexShrink: 0 }} />
244
+ {labels.paymentMethods.removeConfirm}
245
+ </span>
246
+ <div style={{ ...row, gap: VisualTokens.spacing.sm }}>
247
+ <PortalButton type="button" variant="ghost" size="sm" onClick={onCancelRemove}>
248
+ {labels.paymentMethods.cancelRemove}
249
+ </PortalButton>
250
+ <PortalButton
251
+ type="button"
252
+ variant="destructive"
253
+ size="sm"
254
+ onClick={() => onConfirmRemove(method.id)}
255
+ >
256
+ {labels.paymentMethods.confirmRemove}
257
+ </PortalButton>
258
+ </div>
259
+ </div>
260
+ )}
261
+ </div>
262
+ );
263
+ };
264
+
265
+ const paymentMethodsAllowsRemove = (
266
+ method: PaymentMethod,
267
+ all: PaymentMethod[],
268
+ ): boolean => all.length > 1 || !method.isDefault;
@@ -0,0 +1,405 @@
1
+
2
+ import { Check, X } from 'lucide-react';
3
+ import type { CSSProperties } from 'react';
4
+ import { VisualTokens } from '../../theme/visual-tokens';
5
+ import { useBillingLabels, BillingLabelsProvider, formatLabel } from '../labels';
6
+ import type { BillingCycle, PlanComparisonProps, PlanTier } from '../types';
7
+ import {
8
+ formatPrice,
9
+ getPlanPrice,
10
+ calculateDiscount,
11
+ isPopularPlan,
12
+ formatFeature,
13
+ } from '../utils/billing';
14
+ import { PortalButton } from './PortalButton';
15
+ import { PortalSpinner } from './PortalSpinner';
16
+
17
+ const RADIO_GROUP_NAME = 'plan-comparison';
18
+
19
+ export const PlanComparison = ({
20
+ plans,
21
+ selectedPlan,
22
+ cycle = 'monthly',
23
+ showCycleToggle = true,
24
+ showFeatures = true,
25
+ onPlanSelect,
26
+ onCycleChange,
27
+ loading = false,
28
+ labels: labelsOverride,
29
+ }: PlanComparisonProps) => {
30
+ const labels = useBillingLabels();
31
+ const discount = pickLargestDiscount(plans);
32
+
33
+ return (
34
+ <BillingLabelsProvider labels={labelsOverride}>
35
+ <div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: VisualTokens.spacing.page }}>
36
+ {showCycleToggle && (
37
+ <div
38
+ style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
39
+ role="group"
40
+ >
41
+ <CycleToggle cycle={cycle} onChange={onCycleChange} discountPercent={discount} />
42
+ </div>
43
+ )}
44
+
45
+ <div
46
+ style={{
47
+ display: 'grid',
48
+ gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
49
+ gap: VisualTokens.spacing.section,
50
+ }}
51
+ role="radiogroup"
52
+ aria-label={labels.planComparison.radiogroupLabel}
53
+ >
54
+ {plans.map((plan, index) => (
55
+ <PlanCard
56
+ key={plan.id}
57
+ plan={plan}
58
+ isSelected={selectedPlan === plan.id}
59
+ isPopular={isPopularPlan(plan, plans)}
60
+ cycle={cycle}
61
+ showFeatures={showFeatures}
62
+ loading={loading}
63
+ onSelect={onPlanSelect}
64
+ tabbable={selectedPlan === plan.id || (!selectedPlan && index === 0)}
65
+ />
66
+ ))}
67
+ </div>
68
+ </div>
69
+ </BillingLabelsProvider>
70
+ );
71
+ };
72
+
73
+ interface CycleToggleProps {
74
+ cycle: BillingCycle;
75
+ onChange?: (cycle: BillingCycle) => void;
76
+ discountPercent: number;
77
+ }
78
+
79
+ const pillBase: CSSProperties = {
80
+ padding: `${VisualTokens.spacing.sm}px ${VisualTokens.spacing.section}px`,
81
+ borderRadius: VisualTokens.radius.pill,
82
+ fontSize: VisualTokens.fontSize.body,
83
+ fontWeight: VisualTokens.fontWeight.medium,
84
+ cursor: 'pointer',
85
+ border: '1px solid transparent',
86
+ };
87
+
88
+ const CycleToggle = ({ cycle, onChange, discountPercent }: CycleToggleProps) => {
89
+ const labels = useBillingLabels();
90
+ const active: CSSProperties = {
91
+ background: VisualTokens.color.surfacePrimary,
92
+ color: VisualTokens.color.textPrimary,
93
+ };
94
+ const inactive: CSSProperties = {
95
+ background: 'transparent',
96
+ color: VisualTokens.color.textMuted,
97
+ };
98
+
99
+ return (
100
+ <div
101
+ style={{
102
+ display: 'inline-flex',
103
+ alignItems: 'center',
104
+ background: VisualTokens.color.surfaceSubtle,
105
+ borderRadius: VisualTokens.radius.pill,
106
+ padding: VisualTokens.spacing.xs,
107
+ gap: VisualTokens.spacing.xs,
108
+ }}
109
+ >
110
+ <button
111
+ type="button"
112
+ aria-pressed={cycle === 'monthly'}
113
+ onClick={() => onChange?.('monthly')}
114
+ style={{ ...pillBase, ...(cycle === 'monthly' ? active : inactive) }}
115
+ >
116
+ {labels.planComparison.monthly}
117
+ </button>
118
+ <button
119
+ type="button"
120
+ aria-pressed={cycle === 'yearly'}
121
+ onClick={() => onChange?.('yearly')}
122
+ style={{ ...pillBase, ...(cycle === 'yearly' ? active : inactive) }}
123
+ >
124
+ {labels.planComparison.yearly}
125
+ {discountPercent > 0 && (
126
+ <span
127
+ style={{
128
+ marginLeft: VisualTokens.spacing.xs,
129
+ fontSize: VisualTokens.fontSize.caption,
130
+ background: VisualTokens.color.brandPrimary,
131
+ color: VisualTokens.color.textOnPrimary,
132
+ padding: `${2}px ${VisualTokens.spacing.md}px`,
133
+ borderRadius: VisualTokens.radius.pill,
134
+ }}
135
+ >
136
+ {formatLabel(labels.planComparison.save, { percent: discountPercent })}
137
+ </span>
138
+ )}
139
+ </button>
140
+ </div>
141
+ );
142
+ };
143
+
144
+ interface PlanCardProps {
145
+ plan: PlanTier;
146
+ isSelected: boolean;
147
+ isPopular: boolean;
148
+ cycle: BillingCycle;
149
+ showFeatures: boolean;
150
+ loading: boolean;
151
+ onSelect?: (planId: string) => void;
152
+ tabbable: boolean;
153
+ }
154
+
155
+ const PlanCard = ({
156
+ plan,
157
+ isSelected,
158
+ isPopular,
159
+ cycle,
160
+ showFeatures,
161
+ loading,
162
+ onSelect,
163
+ tabbable,
164
+ }: PlanCardProps) => {
165
+ const labels = useBillingLabels();
166
+ const price = getPlanPrice(plan, cycle);
167
+ const discount = calculateDiscount(plan.monthlyPrice, plan.yearlyPrice);
168
+
169
+ const cardStyle: CSSProperties = {
170
+ position: 'relative',
171
+ background: VisualTokens.color.surfacePrimary,
172
+ border: `2px solid ${
173
+ isSelected
174
+ ? VisualTokens.color.brandPrimary
175
+ : isPopular
176
+ ? VisualTokens.color.brandPrimary
177
+ : VisualTokens.color.borderSubtle
178
+ }`,
179
+ opacity: isPopular && !isSelected ? 0.85 : 1,
180
+ borderRadius: VisualTokens.radius.lg,
181
+ padding: VisualTokens.spacing.section,
182
+ cursor: loading ? 'wait' : 'pointer',
183
+ boxSizing: 'border-box',
184
+ display: 'flex',
185
+ flexDirection: 'column',
186
+ };
187
+
188
+ return (
189
+ <div
190
+ role="radio"
191
+ aria-checked={isSelected}
192
+ tabIndex={tabbable ? 0 : -1}
193
+ onClick={() => !loading && onSelect?.(plan.id)}
194
+ onKeyDown={(e) => {
195
+ if (e.key === 'Enter' || e.key === ' ') {
196
+ e.preventDefault();
197
+ if (!loading) onSelect?.(plan.id);
198
+ }
199
+ }}
200
+ style={cardStyle}
201
+ >
202
+ {isPopular && (
203
+ <div
204
+ style={{
205
+ position: 'absolute',
206
+ top: -12,
207
+ left: '50%',
208
+ transform: 'translateX(-50%)',
209
+ }}
210
+ >
211
+ <span
212
+ style={{
213
+ background: VisualTokens.color.brandPrimary,
214
+ color: VisualTokens.color.textOnPrimary,
215
+ fontSize: VisualTokens.fontSize.caption,
216
+ fontWeight: VisualTokens.fontWeight.strong,
217
+ padding: `${VisualTokens.spacing.xs}px ${VisualTokens.spacing.xl}px`,
218
+ borderRadius: VisualTokens.radius.pill,
219
+ whiteSpace: 'nowrap',
220
+ }}
221
+ >
222
+ {labels.planComparison.mostPopular}
223
+ </span>
224
+ </div>
225
+ )}
226
+
227
+ {plan.badge && (
228
+ <div style={{ marginBottom: VisualTokens.spacing.xl }}>
229
+ <span
230
+ style={{
231
+ fontSize: VisualTokens.fontSize.caption,
232
+ fontWeight: VisualTokens.fontWeight.strong,
233
+ textTransform: 'uppercase',
234
+ letterSpacing: '0.05em',
235
+ padding: `${VisualTokens.spacing.xs}px ${VisualTokens.spacing.lg}px`,
236
+ borderRadius: VisualTokens.radius.pill,
237
+ background: VisualTokens.color.surfaceSubtle,
238
+ color: VisualTokens.color.brandPrimary,
239
+ }}
240
+ >
241
+ {plan.badge}
242
+ </span>
243
+ </div>
244
+ )}
245
+
246
+ <h3
247
+ style={{
248
+ fontSize: VisualTokens.fontSize.title,
249
+ fontWeight: VisualTokens.fontWeight.strong,
250
+ color: VisualTokens.color.textPrimary,
251
+ margin: 0,
252
+ marginBottom: VisualTokens.spacing.sm,
253
+ }}
254
+ >
255
+ {plan.name}
256
+ </h3>
257
+ <p
258
+ style={{
259
+ fontSize: VisualTokens.fontSize.body,
260
+ color: VisualTokens.color.textMuted,
261
+ margin: 0,
262
+ marginBottom: VisualTokens.spacing.section,
263
+ }}
264
+ >
265
+ {plan.description}
266
+ </p>
267
+
268
+ <div style={{ marginBottom: VisualTokens.spacing.section }}>
269
+ <div style={{ display: 'flex', alignItems: 'baseline', gap: VisualTokens.spacing.xs }}>
270
+ <span
271
+ style={{
272
+ fontSize: 32,
273
+ fontWeight: 800,
274
+ color: VisualTokens.color.textPrimary,
275
+ }}
276
+ >
277
+ {formatPrice(price, plan.currency)}
278
+ </span>
279
+ <span style={{ color: VisualTokens.color.textMuted, fontSize: VisualTokens.fontSize.body }}>
280
+ /{cycle === 'monthly'
281
+ ? labels.planComparison.monthlyShort
282
+ : labels.planComparison.yearlyShort}
283
+ </span>
284
+ </div>
285
+
286
+ {cycle === 'yearly' && discount > 0 && (
287
+ <p
288
+ style={{
289
+ fontSize: VisualTokens.fontSize.body,
290
+ color: VisualTokens.color.badgeActiveFg,
291
+ margin: 0,
292
+ marginTop: VisualTokens.spacing.xs,
293
+ }}
294
+ >
295
+ {formatLabel(labels.planComparison.saveWithYearly, { percent: discount })}
296
+ </p>
297
+ )}
298
+ </div>
299
+
300
+ {showFeatures && plan.features.length > 0 && (
301
+ <ul
302
+ style={{
303
+ listStyle: 'none',
304
+ margin: 0,
305
+ padding: 0,
306
+ display: 'flex',
307
+ flexDirection: 'column',
308
+ gap: VisualTokens.spacing.lg,
309
+ marginBottom: VisualTokens.spacing.section,
310
+ flex: 1,
311
+ }}
312
+ >
313
+ {plan.features.map((feature, index) => {
314
+ const { text, bold, included } = formatFeature(feature);
315
+ const isExcluded = included === false;
316
+ return (
317
+ <li
318
+ key={`${plan.id}-feature-${index}`}
319
+ style={{
320
+ display: 'flex',
321
+ alignItems: 'flex-start',
322
+ gap: VisualTokens.spacing.lg,
323
+ fontSize: VisualTokens.fontSize.body,
324
+ opacity: isExcluded ? VisualTokens.opacity.disabled : 1,
325
+ textDecoration: isExcluded ? 'line-through' : 'none',
326
+ }}
327
+ >
328
+ {isExcluded ? (
329
+ <X
330
+ size={20}
331
+ style={{ flexShrink: 0, marginTop: 2 }}
332
+ color={VisualTokens.color.textMuted}
333
+ aria-hidden="true"
334
+ />
335
+ ) : (
336
+ <Check
337
+ size={20}
338
+ style={{ flexShrink: 0, marginTop: 2 }}
339
+ color={VisualTokens.color.brandPrimary}
340
+ aria-hidden="true"
341
+ />
342
+ )}
343
+ <span
344
+ style={{
345
+ fontWeight: bold ? VisualTokens.fontWeight.strong : VisualTokens.fontWeight.regular,
346
+ color: VisualTokens.color.textPrimary,
347
+ }}
348
+ >
349
+ {text}
350
+ </span>
351
+ </li>
352
+ );
353
+ })}
354
+ </ul>
355
+ )}
356
+
357
+ <PortalButton
358
+ type="button"
359
+ pill
360
+ fullWidth
361
+ onClick={(e) => {
362
+ e.stopPropagation();
363
+ onSelect?.(plan.id);
364
+ }}
365
+ disabled={loading}
366
+ aria-label={isSelected
367
+ ? labels.planComparison.currentPlan
368
+ : `${labels.planComparison.selectPlan} — ${plan.name}`}
369
+ variant={isSelected ? 'default' : 'ghost'}
370
+ style={
371
+ isSelected
372
+ ? undefined
373
+ : {
374
+ background: VisualTokens.color.surfaceSubtle,
375
+ color: VisualTokens.color.textPrimary,
376
+ padding: `${VisualTokens.spacing.lg}px 0`,
377
+ }
378
+ }
379
+ >
380
+ {loading ? (
381
+ <>
382
+ <PortalSpinner size={16} />
383
+ {labels.planComparison.processing}
384
+ </>
385
+ ) : isSelected ? (
386
+ labels.planComparison.currentPlan
387
+ ) : (
388
+ labels.planComparison.selectPlan
389
+ )}
390
+ </PortalButton>
391
+ </div>
392
+ );
393
+ };
394
+
395
+ const pickLargestDiscount = (plans: PlanTier[]): number => {
396
+ if (plans.length === 0) return 0;
397
+ return plans.reduce((max, plan) => {
398
+ const d = calculateDiscount(plan.monthlyPrice, plan.yearlyPrice);
399
+ return d > max ? d : max;
400
+ }, 0);
401
+ };
402
+
403
+ export default PlanComparison;
404
+
405
+ export { RADIO_GROUP_NAME };