@umituz/web-payment 0.0.0-stage → 1.0.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 (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,248 @@
1
+
2
+ import { CreditCard, FileText, BarChart3, Settings, AlertCircle } from 'lucide-react';
3
+ import type { LucideIcon } from 'lucide-react';
4
+ import { VisualTokens } from '../../theme/visual-tokens';
5
+ import type { BillingPortalProps, BillingTabId } from '../types';
6
+ import { BillingLabelsProvider, useBillingLabels } from '../labels';
7
+ import { PaymentMethodsList } from './PaymentMethodsList';
8
+ import { InvoiceCard } from './InvoiceCard';
9
+ import { PlanTab } from './PlanTab';
10
+ import { OverviewTab } from './OverviewTab';
11
+ import { PortalSpinner } from './PortalSpinner';
12
+ import { stack, visuallyHidden } from '../styles';
13
+
14
+ const TABS: ReadonlyArray<{ id: BillingTabId; labelKey: 'overview' | 'paymentMethods' | 'invoices' | 'plan'; icon: LucideIcon }> = [
15
+ { id: 'overview', labelKey: 'overview', icon: BarChart3 },
16
+ { id: 'payment-methods', labelKey: 'paymentMethods', icon: CreditCard },
17
+ { id: 'invoices', labelKey: 'invoices', icon: FileText },
18
+ { id: 'plan', labelKey: 'plan', icon: Settings },
19
+ ];
20
+
21
+ export const BillingPortal = ({
22
+ billing,
23
+ loading = false,
24
+ error,
25
+ showTabs = true,
26
+ activeTab = 'overview',
27
+ locale = 'en-US',
28
+ onTabChange,
29
+ onAddPaymentMethod,
30
+ onSetDefaultPaymentMethod,
31
+ onRemovePaymentMethod,
32
+ onViewInvoice,
33
+ onChangePlan,
34
+ onCancelSubscription,
35
+ labels,
36
+ }: BillingPortalProps) => {
37
+ return (
38
+ <BillingLabelsProvider labels={labels}>
39
+ <BillingPortalBody
40
+ billing={billing}
41
+ loading={loading}
42
+ error={error}
43
+ showTabs={showTabs}
44
+ activeTab={activeTab}
45
+ locale={locale}
46
+ onTabChange={onTabChange}
47
+ onAddPaymentMethod={onAddPaymentMethod}
48
+ onSetDefaultPaymentMethod={onSetDefaultPaymentMethod}
49
+ onRemovePaymentMethod={onRemovePaymentMethod}
50
+ onViewInvoice={onViewInvoice}
51
+ onChangePlan={onChangePlan}
52
+ onCancelSubscription={onCancelSubscription}
53
+ />
54
+ </BillingLabelsProvider>
55
+ );
56
+ };
57
+
58
+ const BillingPortalBody = ({
59
+ billing,
60
+ loading = false,
61
+ error,
62
+ showTabs = true,
63
+ activeTab = 'overview',
64
+ locale = 'en-US',
65
+ onTabChange,
66
+ onAddPaymentMethod,
67
+ onSetDefaultPaymentMethod,
68
+ onRemovePaymentMethod,
69
+ onViewInvoice,
70
+ onChangePlan,
71
+ onCancelSubscription,
72
+ }: Omit<BillingPortalProps, 'labels'>) => {
73
+ const labels = useBillingLabels();
74
+
75
+ if (loading) {
76
+ return (
77
+ <div
78
+ style={{
79
+ display: 'flex',
80
+ alignItems: 'center',
81
+ justifyContent: 'center',
82
+ padding: `${VisualTokens.spacing.page * 3}px 0`,
83
+ color: VisualTokens.color.textMuted,
84
+ }}
85
+ role="status"
86
+ aria-live="polite"
87
+ >
88
+ <PortalSpinner size={48} />
89
+ <span style={visuallyHidden}>{labels.common.loading}</span>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ if (error) {
95
+ return (
96
+ <div
97
+ style={{
98
+ display: 'flex',
99
+ alignItems: 'center',
100
+ justifyContent: 'center',
101
+ gap: VisualTokens.spacing.xl,
102
+ padding: `${VisualTokens.spacing.page * 3}px 0`,
103
+ color: VisualTokens.color.brandDangerText,
104
+ }}
105
+ role="alert"
106
+ >
107
+ <AlertCircle size={24} aria-hidden="true" />
108
+ <p style={{ margin: 0 }}>{error}</p>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ if (!billing) return null;
114
+
115
+ const renderContent = () => {
116
+ switch (activeTab) {
117
+ case 'overview':
118
+ return <OverviewTab billing={billing} locale={locale} />;
119
+ case 'payment-methods':
120
+ return (
121
+ <PaymentMethodsList
122
+ paymentMethods={billing.paymentMethods}
123
+ onAddNew={onAddPaymentMethod}
124
+ onSetDefault={onSetDefaultPaymentMethod}
125
+ onRemove={onRemovePaymentMethod}
126
+ />
127
+ );
128
+ case 'invoices':
129
+ return (
130
+ <InvoiceList invoices={billing.recentInvoices} onView={onViewInvoice} locale={locale} />
131
+ );
132
+ case 'plan':
133
+ return (
134
+ <PlanTab
135
+ subscription={billing.subscription}
136
+ onChangePlan={onChangePlan}
137
+ onCancelSubscription={onCancelSubscription}
138
+ locale={locale}
139
+ />
140
+ );
141
+ default:
142
+ return null;
143
+ }
144
+ };
145
+
146
+ return (
147
+ <div style={{ width: '100%' }}>
148
+ {showTabs && (
149
+ <div
150
+ style={{ borderBottom: `1px solid ${VisualTokens.color.borderSubtle}`, marginBottom: VisualTokens.spacing.section }}
151
+ role="tablist"
152
+ aria-orientation="horizontal"
153
+ >
154
+ <div style={{ display: 'flex', gap: VisualTokens.spacing.section }}>
155
+ {TABS.map((tab) => {
156
+ const Icon = tab.icon;
157
+ const isActive = activeTab === tab.id;
158
+ return (
159
+ <button
160
+ key={tab.id}
161
+ id={`billing-tab-${tab.id}`}
162
+ role="tab"
163
+ type="button"
164
+ aria-selected={isActive}
165
+ aria-controls="billing-tab-panel"
166
+ tabIndex={isActive ? 0 : -1}
167
+ onClick={() => onTabChange?.(tab.id)}
168
+ onKeyDown={(e) => {
169
+ if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
170
+ e.preventDefault();
171
+ const idx = TABS.findIndex((tt) => tt.id === activeTab);
172
+ const nextIdx =
173
+ e.key === 'ArrowRight'
174
+ ? (idx + 1) % TABS.length
175
+ : (idx - 1 + TABS.length) % TABS.length;
176
+ onTabChange?.(TABS[nextIdx].id);
177
+ }
178
+ }}
179
+ style={{
180
+ display: 'flex',
181
+ alignItems: 'center',
182
+ gap: VisualTokens.spacing.sm,
183
+ paddingBottom: VisualTokens.spacing.section,
184
+ border: 'none',
185
+ borderBottom: `2px solid ${isActive ? VisualTokens.color.brandPrimary : 'transparent'}`,
186
+ background: 'transparent',
187
+ fontSize: VisualTokens.fontSize.body,
188
+ fontWeight: isActive ? VisualTokens.fontWeight.medium : VisualTokens.fontWeight.regular,
189
+ color: isActive ? VisualTokens.color.textPrimary : VisualTokens.color.textMuted,
190
+ cursor: 'pointer',
191
+ }}
192
+ >
193
+ <Icon size={16} aria-hidden="true" />
194
+ <span>{labels.tabs[tab.labelKey]}</span>
195
+ </button>
196
+ );
197
+ })}
198
+ </div>
199
+ </div>
200
+ )}
201
+
202
+ <div
203
+ id="billing-tab-panel"
204
+ role="tabpanel"
205
+ aria-labelledby={`billing-tab-${activeTab}`}
206
+ >
207
+ {renderContent()}
208
+ </div>
209
+ </div>
210
+ );
211
+ };
212
+
213
+ const InvoiceList = ({
214
+ invoices,
215
+ onView,
216
+ locale,
217
+ }: {
218
+ invoices: NonNullable<BillingPortalProps['billing']>['recentInvoices'];
219
+ onView?: (invoice: NonNullable<BillingPortalProps['billing']>['recentInvoices'][number]) => void;
220
+ locale: string;
221
+ }) => {
222
+ const labels = useBillingLabels();
223
+
224
+ if (invoices.length === 0) {
225
+ return (
226
+ <div
227
+ style={{
228
+ textAlign: 'center',
229
+ padding: `${VisualTokens.spacing.page * 1.5}px 0`,
230
+ color: VisualTokens.color.textMuted,
231
+ }}
232
+ >
233
+ <FileText size={48} style={{ opacity: 0.5, marginBottom: VisualTokens.spacing.xl }} aria-hidden="true" />
234
+ <p style={{ margin: 0 }}>{labels.invoices.empty}</p>
235
+ </div>
236
+ );
237
+ }
238
+
239
+ return (
240
+ <div style={stack}>
241
+ {invoices.map((invoice) => (
242
+ <InvoiceCard key={invoice.id} invoice={invoice} onClick={onView} locale={locale} />
243
+ ))}
244
+ </div>
245
+ );
246
+ };
247
+
248
+ export default BillingPortal;
@@ -0,0 +1,275 @@
1
+
2
+ import { FileText, Download, ExternalLink } from 'lucide-react';
3
+ import type { KeyboardEvent } from 'react';
4
+ import { VisualTokens, TONE_PAIRS } from '../../theme/visual-tokens';
5
+ import { useBillingLabels } from '../labels';
6
+ import type { InvoiceCardProps } from '../types';
7
+ import { cardInteractive, mutedText, row } from '../styles';
8
+ import {
9
+ formatPrice,
10
+ formatNumber,
11
+ getInvoiceStatusTone,
12
+ } from '../utils/billing';
13
+
14
+ const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
15
+ year: 'numeric',
16
+ month: 'long',
17
+ day: 'numeric',
18
+ };
19
+
20
+ const openExternal = (url: string) => {
21
+ window.open(url, '_blank', 'noopener,noreferrer');
22
+ };
23
+
24
+ export const InvoiceCard = ({
25
+ invoice,
26
+ compact = false,
27
+ locale = 'en-US',
28
+ onClick,
29
+ onDownload,
30
+ }: InvoiceCardProps) => {
31
+ const labels = useBillingLabels();
32
+ const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
33
+ if (e.key === 'Enter' || e.key === ' ') {
34
+ e.preventDefault();
35
+ onClick?.(invoice);
36
+ }
37
+ };
38
+
39
+ const statusTone = getInvoiceStatusTone(invoice.status);
40
+
41
+ const iconBox = {
42
+ width: compact ? 40 : 48,
43
+ height: compact ? 40 : 48,
44
+ borderRadius: VisualTokens.radius.md,
45
+ background: VisualTokens.color.surfaceMuted,
46
+ display: 'flex' as const,
47
+ alignItems: 'center' as const,
48
+ justifyContent: 'center' as const,
49
+ color: VisualTokens.color.textMuted,
50
+ flexShrink: 0,
51
+ };
52
+
53
+ if (compact) {
54
+ return (
55
+ <div
56
+ role={onClick ? 'button' : undefined}
57
+ tabIndex={onClick ? 0 : undefined}
58
+ aria-label={onClick ? `Invoice ${invoice.number}` : undefined}
59
+ onClick={onClick ? () => onClick(invoice) : undefined}
60
+ onKeyDown={onClick ? handleKeyDown : undefined}
61
+ style={{ ...cardInteractive, ...row, justifyContent: 'space-between', padding: VisualTokens.spacing.xl }}
62
+ >
63
+ <div style={{ ...row, gap: VisualTokens.spacing.lg }}>
64
+ <div style={iconBox}>
65
+ <FileText size={20} />
66
+ </div>
67
+ <div>
68
+ <p
69
+ style={{
70
+ fontSize: VisualTokens.fontSize.body,
71
+ fontWeight: VisualTokens.fontWeight.medium,
72
+ color: VisualTokens.color.textPrimary,
73
+ margin: 0,
74
+ }}
75
+ >
76
+ {invoice.number}
77
+ </p>
78
+ <p style={{ ...mutedText, fontSize: VisualTokens.fontSize.caption, margin: 0 }}>
79
+ {new Date(invoice.date).toLocaleDateString(locale)}
80
+ </p>
81
+ </div>
82
+ </div>
83
+
84
+ <div style={{ textAlign: 'right' }}>
85
+ <p
86
+ style={{
87
+ fontWeight: VisualTokens.fontWeight.strong,
88
+ color: VisualTokens.color.textPrimary,
89
+ margin: 0,
90
+ }}
91
+ >
92
+ {formatPrice(invoice.amount, invoice.currency)}
93
+ </p>
94
+ <p
95
+ style={{
96
+ fontSize: VisualTokens.fontSize.caption,
97
+ fontWeight: VisualTokens.fontWeight.medium,
98
+ color: TONE_PAIRS[statusTone].fg,
99
+ margin: 0,
100
+ }}
101
+ >
102
+ {labels.invoiceStatus[invoice.status]}
103
+ </p>
104
+ </div>
105
+ </div>
106
+ );
107
+ }
108
+
109
+ return (
110
+ <div
111
+ role={onClick ? 'button' : undefined}
112
+ tabIndex={onClick ? 0 : undefined}
113
+ aria-label={onClick ? `Invoice ${invoice.number}` : undefined}
114
+ onClick={onClick ? () => onClick(invoice) : undefined}
115
+ onKeyDown={onClick ? handleKeyDown : undefined}
116
+ style={cardInteractive}
117
+ >
118
+ <div
119
+ style={{
120
+ ...row,
121
+ justifyContent: 'space-between',
122
+ gap: VisualTokens.spacing.lg,
123
+ marginBottom: VisualTokens.spacing.xl,
124
+ }}
125
+ >
126
+ <div style={{ ...row, gap: VisualTokens.spacing.lg }}>
127
+ <div style={iconBox}>
128
+ <FileText size={24} />
129
+ </div>
130
+ <div>
131
+ <p
132
+ style={{
133
+ fontWeight: VisualTokens.fontWeight.strong,
134
+ color: VisualTokens.color.textPrimary,
135
+ margin: 0,
136
+ }}
137
+ >
138
+ {invoice.number}
139
+ </p>
140
+ <p style={{ ...mutedText, margin: 0 }}>
141
+ {new Date(invoice.date).toLocaleDateString(locale, DATE_FORMAT_OPTIONS)}
142
+ </p>
143
+ </div>
144
+ </div>
145
+
146
+ <div style={{ textAlign: 'right' }}>
147
+ <p
148
+ style={{
149
+ fontSize: VisualTokens.fontSize.display,
150
+ fontWeight: VisualTokens.fontWeight.strong,
151
+ color: VisualTokens.color.textPrimary,
152
+ margin: 0,
153
+ }}
154
+ >
155
+ {formatPrice(invoice.amount, invoice.currency)}
156
+ </p>
157
+ <p
158
+ style={{
159
+ fontSize: VisualTokens.fontSize.body,
160
+ fontWeight: VisualTokens.fontWeight.medium,
161
+ color: TONE_PAIRS[statusTone].fg,
162
+ margin: 0,
163
+ }}
164
+ >
165
+ {labels.invoiceStatus[invoice.status]}
166
+ </p>
167
+ </div>
168
+ </div>
169
+
170
+ {invoice.items && invoice.items.length > 0 && (
171
+ <div
172
+ style={{
173
+ display: 'flex',
174
+ flexDirection: 'column',
175
+ gap: VisualTokens.spacing.sm,
176
+ marginBottom: VisualTokens.spacing.xl,
177
+ }}
178
+ >
179
+ {invoice.items.slice(0, 2).map((item, index) => (
180
+ <div
181
+ key={`${item.description}-${index}`}
182
+ style={{ ...row, justifyContent: 'space-between', fontSize: VisualTokens.fontSize.body }}
183
+ >
184
+ <span style={{ color: VisualTokens.color.textMuted }}>{item.description}</span>
185
+ <span style={{ color: VisualTokens.color.textPrimary }}>
186
+ {formatPrice(item.amount, invoice.currency)}
187
+ </span>
188
+ </div>
189
+ ))}
190
+ {invoice.items.length > 2 && (
191
+ <p style={{ ...mutedText, fontSize: VisualTokens.fontSize.caption, margin: 0 }}>
192
+ +{formatNumber(invoice.items.length - 2, locale)} {labels.invoices.moreItems}
193
+ </p>
194
+ )}
195
+ </div>
196
+ )}
197
+
198
+ <div
199
+ style={{
200
+ ...row,
201
+ justifyContent: 'space-between',
202
+ paddingTop: VisualTokens.spacing.xl,
203
+ borderTop: `1px solid ${VisualTokens.color.borderSubtle}`,
204
+ }}
205
+ >
206
+ <p style={{ ...mutedText, fontSize: VisualTokens.fontSize.caption, margin: 0 }}>
207
+ {labels.invoices.due}{' '}
208
+ {new Date(invoice.dueDate).toLocaleDateString(locale)}
209
+ </p>
210
+
211
+ <div style={{ ...row, gap: VisualTokens.spacing.sm }}>
212
+ {invoice.invoiceUrl && (
213
+ <PortalGhostIconButton
214
+ label={labels.invoices.viewInvoice}
215
+ onClick={(e) => {
216
+ e.stopPropagation();
217
+ openExternal(invoice.invoiceUrl!);
218
+ }}
219
+ >
220
+ <ExternalLink size={16} />
221
+ </PortalGhostIconButton>
222
+ )}
223
+ {invoice.pdfUrl && (
224
+ <PortalGhostIconButton
225
+ label={labels.invoices.downloadPdf}
226
+ onClick={(e) => {
227
+ e.stopPropagation();
228
+ if (onDownload) {
229
+ onDownload(invoice);
230
+ } else {
231
+ openExternal(invoice.pdfUrl!);
232
+ }
233
+ }}
234
+ >
235
+ <Download size={16} />
236
+ </PortalGhostIconButton>
237
+ )}
238
+ </div>
239
+ </div>
240
+ </div>
241
+ );
242
+ };
243
+
244
+ const PortalGhostIconButton = ({
245
+ label,
246
+ onClick,
247
+ children,
248
+ }: {
249
+ label: string;
250
+ onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
251
+ children: React.ReactNode;
252
+ }) => (
253
+ <button
254
+ type="button"
255
+ aria-label={label}
256
+ onClick={onClick}
257
+ style={{
258
+ display: 'inline-flex',
259
+ alignItems: 'center',
260
+ justifyContent: 'center',
261
+ padding: VisualTokens.spacing.xs,
262
+ borderRadius: VisualTokens.radius.sm,
263
+ border: '1px solid transparent',
264
+ background: 'transparent',
265
+ color: VisualTokens.color.textMuted,
266
+ cursor: 'pointer',
267
+ }}
268
+ >
269
+ {children}
270
+ </button>
271
+ );
272
+
273
+ export default InvoiceCard;
274
+
275
+ export type { Invoice } from '../types';
@@ -0,0 +1,137 @@
1
+
2
+ import { VisualTokens, TONE_PAIRS } from '../../theme/visual-tokens';
3
+ import { useBillingLabels } from '../labels';
4
+ import type { BillingSummary } from '../types';
5
+ import { UsageCard } from './UsageCard';
6
+ import { card, sectionTitle, stack } from '../styles';
7
+ import {
8
+ formatPrice,
9
+ getDaysRemaining,
10
+ getPlanPrice,
11
+ getStatusTone,
12
+ } from '../utils/billing';
13
+
14
+ export interface OverviewTabProps {
15
+ billing: BillingSummary;
16
+ locale: string;
17
+ }
18
+
19
+ export const OverviewTab = ({ billing, locale }: OverviewTabProps) => {
20
+ const labels = useBillingLabels();
21
+ const { subscription, usage, upcomingInvoice } = billing;
22
+ const statusTone = getStatusTone(subscription.status);
23
+
24
+ return (
25
+ <div style={stack}>
26
+ <section style={card} aria-labelledby="overview-subscription-heading">
27
+ <h3 id="overview-subscription-heading" style={sectionTitle}>
28
+ {labels.overview.currentSubscription}
29
+ </h3>
30
+ <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: VisualTokens.spacing.lg }}>
31
+ <div>
32
+ <p
33
+ style={{
34
+ fontSize: VisualTokens.fontSize.display,
35
+ fontWeight: VisualTokens.fontWeight.strong,
36
+ color: VisualTokens.color.textPrimary,
37
+ margin: 0,
38
+ }}
39
+ >
40
+ {subscription.plan.name}
41
+ </p>
42
+ <p
43
+ style={{
44
+ fontSize: VisualTokens.fontSize.body,
45
+ fontWeight: VisualTokens.fontWeight.medium,
46
+ color: TONE_PAIRS[statusTone].fg,
47
+ margin: 0,
48
+ }}
49
+ >
50
+ {labels.status[subscription.status]}
51
+ </p>
52
+ <p
53
+ style={{
54
+ fontSize: VisualTokens.fontSize.body,
55
+ color: VisualTokens.color.textMuted,
56
+ margin: 0,
57
+ marginTop: VisualTokens.spacing.xs,
58
+ }}
59
+ >
60
+ {formatPrice(
61
+ getPlanPrice(subscription.plan, subscription.cycle),
62
+ subscription.plan.currency,
63
+ )}
64
+ /{subscription.cycle}
65
+ </p>
66
+ </div>
67
+ <div style={{ textAlign: 'right' }}>
68
+ {subscription.status === 'trialing' && subscription.trialEnd && (
69
+ <p
70
+ style={{
71
+ fontSize: VisualTokens.fontSize.body,
72
+ color: VisualTokens.color.textMuted,
73
+ margin: 0,
74
+ }}
75
+ >
76
+ {getDaysRemaining(subscription.trialEnd)}{' '}
77
+ {labels.overview.trialDaysLeft}
78
+ </p>
79
+ )}
80
+ {upcomingInvoice && (
81
+ <p
82
+ style={{
83
+ fontSize: VisualTokens.fontSize.body,
84
+ color: VisualTokens.color.textMuted,
85
+ margin: 0,
86
+ }}
87
+ >
88
+ {labels.overview.nextBilling}{' '}
89
+ {new Date(upcomingInvoice.date).toLocaleDateString(locale)}
90
+ </p>
91
+ )}
92
+ </div>
93
+ </div>
94
+ </section>
95
+
96
+ {usage.length > 0 && (
97
+ <section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: VisualTokens.spacing.xl }} aria-label={labels.common.usage}>
98
+ {usage.map((metric) => (
99
+ <UsageCard key={metric.id} metric={metric} locale={locale} />
100
+ ))}
101
+ </section>
102
+ )}
103
+
104
+ {upcomingInvoice && (
105
+ <section style={card} aria-labelledby="overview-upcoming-heading">
106
+ <h3 id="overview-upcoming-heading" style={{ ...sectionTitle, marginBottom: VisualTokens.spacing.md }}>
107
+ {labels.overview.upcomingInvoice}
108
+ </h3>
109
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
110
+ <div>
111
+ <p
112
+ style={{
113
+ fontSize: VisualTokens.fontSize.display,
114
+ fontWeight: VisualTokens.fontWeight.strong,
115
+ color: VisualTokens.color.textPrimary,
116
+ margin: 0,
117
+ }}
118
+ >
119
+ {formatPrice(upcomingInvoice.amount, upcomingInvoice.currency)}
120
+ </p>
121
+ <p
122
+ style={{
123
+ fontSize: VisualTokens.fontSize.body,
124
+ color: VisualTokens.color.textMuted,
125
+ margin: 0,
126
+ }}
127
+ >
128
+ {labels.invoices.due}{' '}
129
+ {new Date(upcomingInvoice.date).toLocaleDateString(locale)}
130
+ </p>
131
+ </div>
132
+ </div>
133
+ </section>
134
+ )}
135
+ </div>
136
+ );
137
+ };