@volter/twin-stripe 0.1.2 → 2.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 (219) hide show
  1. package/README.md +64 -27
  2. package/client/dashboard-api.ts +286 -0
  3. package/client/stripe-mirror.css +272 -159
  4. package/client/stripe-mirror.tsx +1384 -541
  5. package/dist/client/dashboard-api.d.ts +107 -0
  6. package/dist/client/dashboard-api.js +238 -0
  7. package/dist/client/dashboard-api.ts +286 -0
  8. package/dist/client/stripe-mirror.bundle.js +236 -0
  9. package/dist/client/stripe-mirror.css +275 -0
  10. package/dist/client/stripe-mirror.d.ts +134 -0
  11. package/dist/client/stripe-mirror.js +823 -0
  12. package/dist/client/stripe-mirror.tsx +1534 -0
  13. package/dist/src/cli.d.ts +2 -0
  14. package/dist/src/cli.js +39 -0
  15. package/dist/src/generated/events.gen.json +1 -0
  16. package/dist/src/generated/surface.gen.json +1 -0
  17. package/dist/src/generated/ui.gen.json +1 -0
  18. package/dist/src/index.d.ts +14 -0
  19. package/dist/src/index.js +73 -0
  20. package/dist/src/manifest.d.ts +2 -0
  21. package/dist/src/manifest.js +1065 -0
  22. package/dist/src/screens/checkout.d.ts +31 -0
  23. package/dist/src/screens/checkout.js +241 -0
  24. package/dist/src/screens/consent-skin.d.ts +4 -0
  25. package/dist/src/screens/consent-skin.js +18 -0
  26. package/dist/src/screens/financial-connections.d.ts +5 -0
  27. package/dist/src/screens/financial-connections.js +90 -0
  28. package/dist/src/screens/identity.d.ts +5 -0
  29. package/dist/src/screens/identity.js +86 -0
  30. package/dist/src/screens/industries.d.ts +1 -0
  31. package/dist/src/screens/industries.js +267 -0
  32. package/dist/src/screens/onboarding.d.ts +13 -0
  33. package/dist/src/screens/onboarding.js +225 -0
  34. package/dist/src/screens/portal.d.ts +5 -0
  35. package/dist/src/screens/portal.js +214 -0
  36. package/dist/src/screens/public-details.d.ts +5 -0
  37. package/dist/src/screens/public-details.js +90 -0
  38. package/dist/src/semantics/after-payment.d.ts +22 -0
  39. package/dist/src/semantics/after-payment.js +93 -0
  40. package/dist/src/semantics/apps-secrets.d.ts +2 -0
  41. package/dist/src/semantics/apps-secrets.js +54 -0
  42. package/dist/src/semantics/balance.d.ts +11 -0
  43. package/dist/src/semantics/balance.js +195 -0
  44. package/dist/src/semantics/billing.d.ts +2 -0
  45. package/dist/src/semantics/billing.js +220 -0
  46. package/dist/src/semantics/charges.d.ts +28 -0
  47. package/dist/src/semantics/charges.js +201 -0
  48. package/dist/src/semantics/checkout.d.ts +15 -0
  49. package/dist/src/semantics/checkout.js +303 -0
  50. package/dist/src/semantics/connect.d.ts +5 -0
  51. package/dist/src/semantics/connect.js +476 -0
  52. package/dist/src/semantics/coupons.d.ts +6 -0
  53. package/dist/src/semantics/coupons.js +92 -0
  54. package/dist/src/semantics/credit-notes.d.ts +2 -0
  55. package/dist/src/semantics/credit-notes.js +172 -0
  56. package/dist/src/semantics/customers.d.ts +6 -0
  57. package/dist/src/semantics/customers.js +429 -0
  58. package/dist/src/semantics/disputes.d.ts +2 -0
  59. package/dist/src/semantics/disputes.js +51 -0
  60. package/dist/src/semantics/entitlements.d.ts +2 -0
  61. package/dist/src/semantics/entitlements.js +95 -0
  62. package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
  63. package/dist/src/semantics/ephemeral-keys.js +34 -0
  64. package/dist/src/semantics/files.d.ts +2 -0
  65. package/dist/src/semantics/files.js +125 -0
  66. package/dist/src/semantics/invoices.d.ts +18 -0
  67. package/dist/src/semantics/invoices.js +541 -0
  68. package/dist/src/semantics/issuing.d.ts +13 -0
  69. package/dist/src/semantics/issuing.js +570 -0
  70. package/dist/src/semantics/ledger.d.ts +54 -0
  71. package/dist/src/semantics/ledger.js +181 -0
  72. package/dist/src/semantics/payment-intents.d.ts +18 -0
  73. package/dist/src/semantics/payment-intents.js +404 -0
  74. package/dist/src/semantics/payment-links.d.ts +2 -0
  75. package/dist/src/semantics/payment-links.js +133 -0
  76. package/dist/src/semantics/payment-methods.d.ts +20 -0
  77. package/dist/src/semantics/payment-methods.js +138 -0
  78. package/dist/src/semantics/plans.d.ts +5 -0
  79. package/dist/src/semantics/plans.js +121 -0
  80. package/dist/src/semantics/platform.d.ts +9 -0
  81. package/dist/src/semantics/platform.js +206 -0
  82. package/dist/src/semantics/products.d.ts +2 -0
  83. package/dist/src/semantics/products.js +140 -0
  84. package/dist/src/semantics/radar.d.ts +2 -0
  85. package/dist/src/semantics/radar.js +83 -0
  86. package/dist/src/semantics/refunds.d.ts +9 -0
  87. package/dist/src/semantics/refunds.js +195 -0
  88. package/dist/src/semantics/renewals.d.ts +47 -0
  89. package/dist/src/semantics/renewals.js +251 -0
  90. package/dist/src/semantics/setup-intents.d.ts +2 -0
  91. package/dist/src/semantics/setup-intents.js +84 -0
  92. package/dist/src/semantics/shared.d.ts +78 -0
  93. package/dist/src/semantics/shared.js +192 -0
  94. package/dist/src/semantics/subscription-schedules.d.ts +2 -0
  95. package/dist/src/semantics/subscription-schedules.js +119 -0
  96. package/dist/src/semantics/subscriptions.d.ts +11 -0
  97. package/dist/src/semantics/subscriptions.js +605 -0
  98. package/dist/src/semantics/tax.d.ts +2 -0
  99. package/dist/src/semantics/tax.js +197 -0
  100. package/dist/src/semantics/terminal.d.ts +5 -0
  101. package/dist/src/semantics/terminal.js +182 -0
  102. package/dist/src/semantics/test-clocks.d.ts +6 -0
  103. package/dist/src/semantics/test-clocks.js +73 -0
  104. package/dist/src/semantics/tokens.d.ts +4 -0
  105. package/dist/src/semantics/tokens.js +44 -0
  106. package/dist/src/semantics/transfers.d.ts +2 -0
  107. package/dist/src/semantics/transfers.js +154 -0
  108. package/dist/src/semantics/treasury.d.ts +2 -0
  109. package/dist/src/semantics/treasury.js +377 -0
  110. package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
  111. package/dist/src/semantics/webhook-endpoints.js +85 -0
  112. package/dist/src/stripe-budget.d.ts +55 -0
  113. package/dist/src/stripe-budget.js +155 -0
  114. package/dist/src/stripe-capabilities.d.ts +3 -0
  115. package/dist/src/stripe-capabilities.js +5052 -0
  116. package/dist/src/stripe-conformance.d.ts +41 -0
  117. package/dist/src/stripe-conformance.js +96 -0
  118. package/dist/src/stripe-connector.d.ts +161 -0
  119. package/dist/src/stripe-connector.js +414 -0
  120. package/dist/src/stripe-emit.d.ts +2 -0
  121. package/dist/src/stripe-emit.js +145 -0
  122. package/dist/src/stripe-events.d.ts +93 -0
  123. package/dist/src/stripe-events.js +388 -0
  124. package/dist/src/stripe-js.d.ts +4 -0
  125. package/dist/src/stripe-js.js +70 -0
  126. package/dist/src/stripe-mirror-ui.d.ts +15 -0
  127. package/dist/src/stripe-mirror-ui.js +87 -0
  128. package/dist/src/stripe-params.d.ts +3 -0
  129. package/dist/src/stripe-params.js +43 -0
  130. package/dist/src/stripe-perform-harness.d.ts +9 -0
  131. package/dist/src/stripe-perform-harness.js +26 -0
  132. package/dist/src/stripe-server.d.ts +33 -0
  133. package/dist/src/stripe-server.js +326 -0
  134. package/dist/src/stripe-shared.d.ts +106 -0
  135. package/dist/src/stripe-shared.js +273 -0
  136. package/dist/src/stripe-twin.d.ts +155 -0
  137. package/dist/src/stripe-twin.js +1226 -0
  138. package/dist/src/stripe-ui-conformance.d.ts +5 -0
  139. package/dist/src/stripe-ui-conformance.js +79 -0
  140. package/dist/src/stripe-ui-structure.d.ts +3 -0
  141. package/dist/src/stripe-ui-structure.js +168 -0
  142. package/dist/src/stripe-version.d.ts +10 -0
  143. package/dist/src/stripe-version.js +285 -0
  144. package/dist/test-fixtures/stripe-known-deviations.json +105 -0
  145. package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
  146. package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
  147. package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
  148. package/dist/test-fixtures/stripe-schemas.json +3740 -0
  149. package/package.json +18 -10
  150. package/src/cli.ts +7 -7
  151. package/src/generated/events.gen.json +1 -0
  152. package/src/generated/surface.gen.json +1 -0
  153. package/src/generated/ui.gen.json +1 -0
  154. package/src/index.ts +31 -9
  155. package/src/manifest.ts +1097 -0
  156. package/src/screens/checkout.tsx +252 -0
  157. package/src/screens/consent-skin.ts +20 -0
  158. package/src/screens/financial-connections.tsx +101 -0
  159. package/src/screens/identity.tsx +96 -0
  160. package/src/screens/industries.ts +267 -0
  161. package/src/screens/onboarding.tsx +243 -0
  162. package/src/screens/portal.tsx +218 -0
  163. package/src/screens/public-details.tsx +105 -0
  164. package/src/semantics/after-payment.ts +113 -0
  165. package/src/semantics/apps-secrets.ts +58 -0
  166. package/src/semantics/balance.ts +209 -0
  167. package/src/semantics/billing.ts +216 -0
  168. package/src/semantics/charges.ts +211 -0
  169. package/src/semantics/checkout.ts +297 -0
  170. package/src/semantics/connect.ts +471 -0
  171. package/src/semantics/coupons.ts +97 -0
  172. package/src/semantics/credit-notes.ts +168 -0
  173. package/src/semantics/customers.ts +432 -0
  174. package/src/semantics/disputes.ts +62 -0
  175. package/src/semantics/entitlements.ts +94 -0
  176. package/src/semantics/ephemeral-keys.ts +34 -0
  177. package/src/semantics/files.ts +143 -0
  178. package/src/semantics/invoices.ts +541 -0
  179. package/src/semantics/issuing.ts +585 -0
  180. package/src/semantics/ledger.ts +216 -0
  181. package/src/semantics/payment-intents.ts +420 -0
  182. package/src/semantics/payment-links.ts +148 -0
  183. package/src/semantics/payment-methods.ts +143 -0
  184. package/src/semantics/plans.ts +131 -0
  185. package/src/semantics/platform.ts +220 -0
  186. package/src/semantics/products.ts +154 -0
  187. package/src/semantics/radar.ts +85 -0
  188. package/src/semantics/refunds.ts +218 -0
  189. package/src/semantics/renewals.ts +274 -0
  190. package/src/semantics/setup-intents.ts +87 -0
  191. package/src/semantics/shared.ts +215 -0
  192. package/src/semantics/subscription-schedules.ts +129 -0
  193. package/src/semantics/subscriptions.ts +610 -0
  194. package/src/semantics/tax.ts +220 -0
  195. package/src/semantics/terminal.ts +195 -0
  196. package/src/semantics/test-clocks.ts +77 -0
  197. package/src/semantics/tokens.ts +52 -0
  198. package/src/semantics/transfers.ts +174 -0
  199. package/src/semantics/treasury.ts +383 -0
  200. package/src/semantics/webhook-endpoints.ts +87 -0
  201. package/src/stripe-budget.ts +4 -4
  202. package/src/stripe-capabilities.ts +1456 -222
  203. package/src/stripe-conformance.ts +6 -5
  204. package/src/stripe-connector.ts +68 -40
  205. package/src/stripe-emit.ts +14 -7
  206. package/src/stripe-events.ts +94 -36
  207. package/src/stripe-js.ts +70 -0
  208. package/src/stripe-mirror-ui.ts +28 -298
  209. package/src/stripe-params.ts +44 -0
  210. package/src/stripe-perform-harness.ts +29 -0
  211. package/src/stripe-server.ts +263 -38
  212. package/src/stripe-shared.ts +294 -0
  213. package/src/stripe-twin.ts +429 -5325
  214. package/src/stripe-ui-conformance.ts +70 -107
  215. package/src/stripe-ui-structure.ts +124 -348
  216. package/src/stripe-version.ts +278 -0
  217. package/test-fixtures/stripe-known-deviations.json +2 -7
  218. package/test-fixtures/stripe-openapi-operations.json +1188 -2855
  219. package/src/stripe-form.ts +0 -35
@@ -0,0 +1,1534 @@
1
+ // The Stripe Dashboard (manifest screen `dashboard`, dashboard.stripe.com/test/{section}): a React app
2
+ // (Bun-bundled) authored as Stripe's Dashboard in test mode, reading and writing only through Stripe's API
3
+ // (client/dashboard-api.ts). Its pages sit at Stripe's own paths: /test/dashboard (Home),
4
+ // /test/balance/overview, /test/payments[/{pi|ch}], /test/customers[/{cus}], /test/subscriptions[/{sub}],
5
+ // /test/invoices[/{in}], /test/products, /test/developers, /test/settings, and the pages under "More".
6
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
7
+ import { createRoot } from 'react-dom/client';
8
+ import {
9
+ formatStripeAmount, formatRecurring, formatPaymentMethod, formatPaymentError, formatBalanceSummary,
10
+ formatAccountFlags, accountCurrentlyDue, publicBusinessName, PLATFORM_DEFAULT_NAME, type StripeRow,
11
+ } from '../src/stripe-shared.ts';
12
+ import {
13
+ API_BASE, list, loadBalances, type CustomerRow, loadCustomer, loadCustomers, loadDevelopers, loadHome, loadInvoices, loadPayment,
14
+ loadPayments, loadProducts, loadSubscription, loadSubscriptions, retrieve, createRefund, cancelSubscription, createObject,
15
+ type CustomerDetail, type Payment, type PaymentDetail, type SubscriptionDetail, type StripeObject,
16
+ } from './dashboard-api.ts';
17
+
18
+ // ── routing: Stripe's paths, inside whatever prefix this page is served under ─────────────────────
19
+ const BASE_PATH = API_BASE;
20
+ export function routeOf(pathname: string): string[] {
21
+ const inside = pathname.startsWith(BASE_PATH) ? pathname.slice(BASE_PATH.length) : pathname;
22
+ const parts = inside.split('/').filter(Boolean);
23
+ return parts[0] === 'test' ? parts.slice(1) : parts;
24
+ }
25
+ const hrefOf = (to: string) => `${BASE_PATH}/test/${to.replace(/^\/+/, '')}`;
26
+ type Go = (to: string) => void;
27
+
28
+ // ── formatting ───────────────────────────────────────────────────────────────
29
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
30
+ export function formatDate(ts: unknown, withTime = true): string {
31
+ const n = Number(ts);
32
+ if (!Number.isFinite(n) || n <= 0) return '—';
33
+ const d = new Date(n * 1000);
34
+ const day = `${MONTHS[d.getMonth()]} ${d.getDate()}`;
35
+ const sameYear = d.getFullYear() === new Date().getFullYear();
36
+ if (!withTime) return `${day}, ${d.getFullYear()}`;
37
+ const h = d.getHours() % 12 || 12;
38
+ const time = `${h}:${String(d.getMinutes()).padStart(2, '0')} ${d.getHours() < 12 ? 'AM' : 'PM'}`;
39
+ return sameYear ? `${day}, ${time}` : `${day}, ${d.getFullYear()}, ${time}`;
40
+ }
41
+ const money = (amount: unknown, currency?: string) => formatStripeAmount(Number(amount) || 0, currency);
42
+ const ccy = (currency?: string) => String(currency ?? 'usd').toUpperCase();
43
+ const titleCase = (s: string) => s.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
44
+ const customerLabel = (c?: StripeObject) => (c ? c.name || c.email || c.id : '—');
45
+
46
+ // ── icons ────────────────────────────────────────────────────────────────────
47
+ const ICONS: Record<string, string> = {
48
+ home: 'M8 1.5 1.5 7v7.5h4.5v-4.5h4v4.5h4.5V7L8 1.5z',
49
+ balance: 'M2 4h12v9H2zM2 7h12M5 10.5h3',
50
+ transactions: 'M2 5h10M9 2l3 3-3 3M14 11H4M7 8l-3 3 3 3',
51
+ customers: 'M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM2.5 14.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5',
52
+ catalog: 'M2 4.5 8 1.5l6 3v7L8 14.5l-6-3v-7zM2 4.5l6 3 6-3M8 7.5v7',
53
+ connect: 'M5.5 10.5 10.5 5.5M7 3.5l1.5-1.5a3 3 0 0 1 4.5 4.5L11.5 8M9 12.5 7.5 14a3 3 0 0 1-4.5-4.5L4.5 8',
54
+ payments: 'M1.5 3.5h13v9h-13zM1.5 6.5h13M4 10h2.5',
55
+ billing: 'M3 1.5h10v13l-2-1.3-2 1.3-2-1.3-2 1.3-2-1.3zM5.5 5h5M5.5 8h5',
56
+ reporting: 'M2 14h12M4 11V7M8 11V3M12 11V8',
57
+ more: 'M3 8h.01M8 8h.01M13 8h.01',
58
+ developers: 'M5 4 1.5 8 5 12M11 4l3.5 4-3.5 4',
59
+ search: 'M7 12.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM11 11l3.5 3.5',
60
+ bell: 'M4 11V7a4 4 0 0 1 8 0v4l1.5 1.5h-11L4 11zM6.5 14h3',
61
+ gear: 'M8 10.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM8 1v2M8 13v2M1 8h2M13 8h2M3 3l1.4 1.4M11.6 11.6 13 13M3 13l1.4-1.4M11.6 4.4 13 3',
62
+ help: 'M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14zM6 6a2 2 0 1 1 2.5 2c-.4.1-.5.4-.5.8V10M8 12v.01',
63
+ plus: 'M8 2v12M2 8h12',
64
+ check: 'M3 8.5 6.5 12 13 4.5',
65
+ x: 'M4 4l8 8M12 4l-8 8',
66
+ refund: 'M4 6h6.5a3 3 0 0 1 0 6H6M6.5 3.5 4 6l2.5 2.5',
67
+ clock: 'M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14zM8 4v4l2.5 1.5',
68
+ chevronDown: 'M4 6l4 4 4-4',
69
+ chevronRight: 'M6 4l4 4-4 4',
70
+ dispute: 'M8 1.5 15 14H1L8 1.5zM8 6v3.5M8 11.5v.01',
71
+ arrowRight: 'M2 8h11M9 4l4 4-4 4',
72
+ card: 'M1.5 3.5h13v9h-13zM1.5 6.5h13',
73
+ download: 'M8 2v8M4.5 6.5 8 10l3.5-3.5M2.5 13.5h11',
74
+ person: 'M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM2.5 14.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5',
75
+ };
76
+ export function Icon({ name, size = 16, className }: { name: string; size?: number; className?: string }) {
77
+ return (
78
+ <svg className={`icon ${className ?? ''}`} width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
79
+ <path d={ICONS[name] ?? ''} />
80
+ </svg>
81
+ );
82
+ }
83
+
84
+ /** The card network mark Stripe shows beside a card's last four. */
85
+ export function CardBrand({ brand }: { brand?: string }) {
86
+ const b = String(brand ?? '').toLowerCase();
87
+ if (b === 'mastercard') {
88
+ return (
89
+ <span className="brand brand-mastercard" title="Mastercard">
90
+ <svg width="20" height="12" viewBox="0 0 20 12" aria-hidden="true"><circle cx="7" cy="6" r="5" fill="#EB001B" /><circle cx="13" cy="6" r="5" fill="#F79E1B" fillOpacity="0.9" /></svg>
91
+ </span>
92
+ );
93
+ }
94
+ if (b === 'amex') return <span className="brand brand-amex" title="American Express">AMEX</span>;
95
+ if (b === 'visa') return <span className="brand brand-visa" title="Visa">VISA</span>;
96
+ return <span className="brand brand-generic" title={brand ?? 'Card'}><Icon name="card" size={12} /></span>;
97
+ }
98
+ export function CardLabel({ pm }: { pm?: StripeObject }) {
99
+ const card = pm?.card;
100
+ if (!card) return <span className="muted">{pm ? formatPaymentMethod(pm) : '—'}</span>;
101
+ return <span className="card-label"><CardBrand brand={card.brand} /><span className="dots">••••</span> {card.last4}</span>;
102
+ }
103
+
104
+ // ── status pills, as Stripe words and colours them ───────────────────────────
105
+ type Tone = 'green' | 'grey' | 'red' | 'blue' | 'orange' | 'purple';
106
+ const STATUS: Record<string, { label: string; tone: Tone; icon?: string }> = {
107
+ succeeded: { label: 'Succeeded', tone: 'green', icon: 'check' },
108
+ refunded: { label: 'Refunded', tone: 'grey', icon: 'refund' },
109
+ partially_refunded: { label: 'Partial refund', tone: 'grey', icon: 'refund' },
110
+ failed: { label: 'Failed', tone: 'red', icon: 'x' },
111
+ incomplete: { label: 'Incomplete', tone: 'grey', icon: 'clock' },
112
+ uncaptured: { label: 'Uncaptured', tone: 'blue', icon: 'clock' },
113
+ canceled: { label: 'Canceled', tone: 'grey', icon: 'x' },
114
+ disputed: { label: 'Disputed', tone: 'orange', icon: 'dispute' },
115
+ processing: { label: 'Pending', tone: 'grey', icon: 'clock' },
116
+ active: { label: 'Active', tone: 'green' },
117
+ trialing: { label: 'Trialing', tone: 'blue' },
118
+ past_due: { label: 'Past due', tone: 'red' },
119
+ unpaid: { label: 'Unpaid', tone: 'red' },
120
+ incomplete_expired: { label: 'Expired', tone: 'grey' },
121
+ paused: { label: 'Paused', tone: 'grey' },
122
+ paid: { label: 'Paid', tone: 'green' },
123
+ open: { label: 'Open', tone: 'blue' },
124
+ draft: { label: 'Draft', tone: 'grey' },
125
+ void: { label: 'Void', tone: 'grey' },
126
+ uncollectible: { label: 'Uncollectible', tone: 'red' },
127
+ pending: { label: 'Pending', tone: 'grey' },
128
+ enabled: { label: 'Enabled', tone: 'green' },
129
+ disabled: { label: 'Disabled', tone: 'grey' },
130
+ true: { label: 'Active', tone: 'green' },
131
+ false: { label: 'Archived', tone: 'grey' },
132
+ };
133
+ export function StatusPill({ value, suffix }: { value: unknown; suffix?: string }) {
134
+ const key = String(value ?? '');
135
+ const s = STATUS[key] ?? { label: titleCase(key || '—'), tone: 'grey' as Tone };
136
+ return (
137
+ <span className={`pill pill-${s.tone}`} data-status={key}>
138
+ {s.label}{suffix ?? ''}{s.icon ? <Icon name={s.icon} size={12} /> : null}
139
+ </span>
140
+ );
141
+ }
142
+
143
+ /** A declined payment's reason, as Stripe states it above the payment. */
144
+ export function PaymentErrorBanner({ error }: { error: any }) {
145
+ const text = formatPaymentError(error);
146
+ if (!text) return null;
147
+ const message = typeof error?.message === 'string' ? error.message : text;
148
+ return (
149
+ <div className="pay-error" role="alert">
150
+ <Icon name="dispute" />
151
+ <div>
152
+ <div className="pay-error-label">Payment error</div>
153
+ <div className="pay-error-text">{message} <span className="mono muted">{text.split(':')[0]}</span></div>
154
+ </div>
155
+ </div>
156
+ );
157
+ }
158
+
159
+ /** The balance's per-currency buckets: incoming (pending) and available. */
160
+ export function BalanceSummary({ row }: { row: StripeRow }) {
161
+ const buckets = formatBalanceSummary(row);
162
+ if (buckets.length === 0) return null;
163
+ const label = (b: string) => (b === 'pending' ? 'Incoming' : 'Available');
164
+ return (
165
+ <div className="balance-summary">
166
+ {[...buckets].reverse().map((b) => (
167
+ <div className={`balance-bucket ${b.bucket}`} key={b.bucket}>
168
+ <span className="balance-bucket-label">{label(b.bucket)}</span>
169
+ <span className="balance-bucket-amount">{b.text}</span>
170
+ </div>
171
+ ))}
172
+ </div>
173
+ );
174
+ }
175
+
176
+ /** A connected account's readiness: charges, payouts, details, and what is currently due. */
177
+ export function ConnectAccountPanel({ row }: { row: StripeRow }) {
178
+ const flags = formatAccountFlags(row);
179
+ const due = accountCurrentlyDue(row);
180
+ if (flags.length === 0) return null;
181
+ return (
182
+ <div className="connect-panel">
183
+ <div className="connect-flags">
184
+ {flags.map((f) => (
185
+ <div className={`connect-flag ${f.enabled ? 'ok' : 'warn'}`} key={f.key}>
186
+ <span className="connect-flag-label">{f.label}</span>
187
+ <StatusPill value={f.enabled ? 'enabled' : 'disabled'} />
188
+ </div>
189
+ ))}
190
+ </div>
191
+ {due.length > 0 && (
192
+ <div className="connect-requirements" role="status">
193
+ <span className="connect-req-label">Requirements currently due</span>
194
+ <ul className="connect-req-list">{due.map((d) => <li className="connect-req-item mono" key={d}>{d}</li>)}</ul>
195
+ </div>
196
+ )}
197
+ </div>
198
+ );
199
+ }
200
+
201
+ // ── the pages under "More": a Stripe list per resource ───────────────────────
202
+ export type Section = { key: string; label: string; path: string; title: (r: StripeRow) => string; subtitle: (r: StripeRow) => string; page?: boolean; columns?: [string, string] };
203
+ const d = (r: StripeRow) => formatDate(r.created);
204
+ export const SECTIONS: Section[] = [
205
+ { key: 'customers', label: 'Customers', path: 'customers', title: (r) => r.name || r.email || r.id, subtitle: (r) => r.email || r.id },
206
+ { key: 'payment_intents', label: 'Payments', path: 'payments', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => r.description || r.id },
207
+ { key: 'subscriptions', label: 'Subscriptions', path: 'subscriptions', title: (r) => r.id, subtitle: (r) => r.customer ?? '' },
208
+ { key: 'invoices', label: 'Invoices', path: 'invoices', title: (r) => formatStripeAmount(r.total ?? r.amount_due, r.currency), subtitle: (r) => r.number || r.id },
209
+ { key: 'products', label: 'Products', path: 'products', title: (r) => r.name || r.id, subtitle: (r) => r.description || r.id },
210
+ { key: 'prices', label: 'Prices', path: 'prices', title: (r) => `${formatStripeAmount(r.unit_amount, r.currency)}${formatRecurring(r.recurring) ? ` ${formatRecurring(r.recurring)}` : ''}`, subtitle: (r) => r.product ?? r.id },
211
+ { key: 'charges', label: 'Charges', path: 'charges', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => r.description || r.id },
212
+ { key: 'refunds', label: 'Refunds', path: 'refunds', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => r.charge ?? r.id },
213
+ { key: 'payment_methods', label: 'Payment Methods', path: 'payment-methods', title: (r) => formatPaymentMethod(r), subtitle: (r) => r.customer ?? r.id },
214
+ { key: 'setup_intents', label: 'Setup Intents', path: 'setup-intents', title: (r) => r.id, subtitle: (r) => r.usage ?? '' },
215
+ { key: 'invoiceitems', label: 'Invoice Items', path: 'invoice-items', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => r.description || r.customer || r.id },
216
+ { key: 'credit_notes', label: 'Credit Notes', path: 'credit-notes', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => r.invoice ?? r.id },
217
+ { key: 'tax_ids', label: 'Tax IDs', path: 'tax-ids', title: (r) => `${r.value ?? r.id}`, subtitle: (r) => `${r.type ?? ''}` },
218
+ { key: 'balance', label: 'Balance', path: 'balance', title: (r) => r.id, subtitle: () => '' },
219
+ // pages the Dashboard has, reached from the navigation
220
+ { key: 'disputes', label: 'Disputes', path: 'disputes', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${titleCase(String(r.reason ?? ''))} · ${r.charge ?? ''}`, page: true, columns: ['Amount', 'Reason'] },
221
+ { key: 'payouts', label: 'Payouts', path: 'payouts', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `Arrives ${formatDate(r.arrival_date, false)}`, page: true, columns: ['Amount', 'Arrival'] },
222
+ { key: 'balance_transactions', label: 'Balance Transactions', path: 'balance-transactions', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${titleCase(String(r.type ?? ''))} · ${r.source ?? r.id}`, page: true, columns: ['Amount', 'Type'] },
223
+ { key: 'payment_links', label: 'Payment Links', path: 'payment-links', title: (r) => r.url || r.id, subtitle: (r) => (r.active ? 'Active' : 'Inactive'), page: true },
224
+ { key: 'coupons', label: 'Coupons', path: 'coupons', title: (r) => r.name || (r.percent_off != null ? `${r.percent_off}% off` : `${formatStripeAmount(r.amount_off, r.currency)} off`), subtitle: (r) => titleCase(String(r.duration ?? '')), page: true, columns: ['Coupon', 'Duration'] },
225
+ { key: 'promotion_codes', label: 'Promotion Codes', path: 'promotion-codes', title: (r) => r.code || r.id, subtitle: (r) => (typeof r.coupon === 'object' ? r.coupon?.id : r.coupon) ?? '', page: true },
226
+ { key: 'quotes', label: 'Quotes', path: 'quotes', title: (r) => formatStripeAmount(r.amount_total, r.currency), subtitle: (r) => r.customer ?? r.id, page: true, columns: ['Amount', 'Customer'] },
227
+ { key: 'accounts', label: 'Connected Accounts', path: 'connect/accounts', title: (r) => r.business_profile?.name || r.email || r.id, subtitle: (r) => `${titleCase(String(r.type ?? 'account'))} · ${r.country ?? ''}`, page: true },
228
+ { key: 'transfers', label: 'Transfers', path: 'connect/transfers', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `To ${r.destination ?? '—'}`, page: true, columns: ['Amount', 'Destination'] },
229
+ { key: 'tax_rates', label: 'Tax Rates', path: 'tax/rates', title: (r) => `${r.display_name ?? 'Tax'} · ${r.percentage ?? 0}%`, subtitle: (r) => `${r.inclusive ? 'Inclusive' : 'Exclusive'} · ${r.jurisdiction ?? ''}`, page: true, columns: ['Tax rate', 'Type'] },
230
+ { key: 'reviews', label: 'Radar Reviews', path: 'radar/reviews', title: (r) => r.charge || r.payment_intent || r.id, subtitle: (r) => (r.open ? 'Open' : titleCase(String(r.closed_reason ?? 'closed'))), page: true },
231
+ { key: 'radar/value_lists', label: 'Radar Value Lists', path: 'radar/lists', title: (r) => r.name || r.alias || r.id, subtitle: (r) => `${r.item_type ?? ''} · ${r.alias ?? ''}`, page: true },
232
+ { key: 'terminal/readers', label: 'Terminal Readers', path: 'terminal/readers', title: (r) => r.label || r.id, subtitle: (r) => `${titleCase(String(r.device_type ?? ''))} · ${r.status ?? ''}`, page: true },
233
+ { key: 'terminal/locations', label: 'Terminal Locations', path: 'terminal/locations', title: (r) => r.display_name || r.id, subtitle: (r) => (r.address as any)?.city ?? '', page: true },
234
+ { key: 'issuing/cards', label: 'Issuing Cards', path: 'issuing/cards', title: (r) => `•••• ${r.last4 ?? ''}`, subtitle: (r) => `${titleCase(String(r.type ?? ''))} · ${typeof r.cardholder === 'object' ? r.cardholder?.name ?? '' : r.cardholder ?? ''}`, page: true },
235
+ { key: 'issuing/cardholders', label: 'Issuing Cardholders', path: 'issuing/cardholders', title: (r) => r.name || r.id, subtitle: (r) => r.email || titleCase(String(r.type ?? '')), page: true },
236
+ { key: 'reporting/report_runs', label: 'Reports', path: 'reports', title: (r) => r.report_type || r.id, subtitle: (r) => titleCase(String(r.status ?? '')), page: true },
237
+ { key: 'events', label: 'Events', path: 'events', title: (r) => r.type || r.id, subtitle: (r) => r.data?.object?.id ?? r.id, page: true, columns: ['Event', 'Object'] },
238
+ { key: 'webhook_endpoints', label: 'Webhook Endpoints', path: 'webhooks', title: (r) => r.url || r.id, subtitle: (r) => `${(r.enabled_events ?? []).length} events`, page: true },
239
+ ];
240
+ const SECTION_BY_PATH = new Map(SECTIONS.filter((s) => s.page).map((s) => [s.path, s]));
241
+ const statusOfRow = (r: StripeRow) => (r.status !== undefined ? r.status : r.active !== undefined ? String(r.active) : undefined);
242
+
243
+ /** A Stripe list table over one resource's objects. */
244
+ export function ListPane({ section, rows, query, currentId, onSelect }: { section: Section; rows: StripeRow[]; query?: string; currentId?: string; onSelect: (id: string) => void }) {
245
+ return (
246
+ <table className="table">
247
+ <thead><tr><th>{section.columns?.[0] ?? section.label.replace(/^(Radar|Terminal|Issuing|Tax) /, '')}</th><th>{section.columns?.[1] ?? 'Details'}</th><th>Status</th><th>ID</th><th className="right">Created</th></tr></thead>
248
+ <tbody>
249
+ {rows.length === 0 && <tr className="empty-row"><td colSpan={5}><EmptyState title={query ? 'No results' : `No ${section.label.toLowerCase()}`} /></td></tr>}
250
+ {rows.map((row, i) => (
251
+ <tr key={row.id ?? i} className={`list-row ${currentId === row.id ? 'current' : ''}`} onClick={() => onSelect(row.id)}>
252
+ <td className="strong">{section.title(row)}</td>
253
+ <td className="muted">{section.subtitle(row)}</td>
254
+ <td>{statusOfRow(row) !== undefined ? <StatusPill value={statusOfRow(row)} /> : null}</td>
255
+ <td className="mono muted">{row.id}</td>
256
+ <td className="right muted nowrap">{d(row)}</td>
257
+ </tr>
258
+ ))}
259
+ </tbody>
260
+ </table>
261
+ );
262
+ }
263
+
264
+ // ── search: the top bar's search over the objects a person looks for ─────────
265
+ export type SearchHit = { to: string; kind: string; label: string; sub: string };
266
+ export function globalSearch(data: Record<string, StripeRow[]>, query: string, limit = 12): SearchHit[] {
267
+ const q = query.trim().toLowerCase();
268
+ if (!q) return [];
269
+ const hits: SearchHit[] = [];
270
+ const add = (rows: StripeRow[] | undefined, kind: string, route: string, label: (r: StripeRow) => string, sub: (r: StripeRow) => string, fields: (r: StripeRow) => unknown[]) => {
271
+ for (const r of rows ?? []) {
272
+ if (hits.length >= limit) return;
273
+ if (fields(r).some((f) => String(f ?? '').toLowerCase().includes(q))) hits.push({ to: `${route}/${r.id}`, kind, label: label(r), sub: sub(r) });
274
+ }
275
+ };
276
+ add(data.customers, 'Customers', 'customers', (r) => r.name || r.email || r.id, (r) => r.email || r.id, (r) => [r.id, r.name, r.email, r.description]);
277
+ add(data.payment_intents, 'Payments', 'payments', (r) => `${formatStripeAmount(r.amount, r.currency)} ${ccy(r.currency)}`, (r) => r.description || r.id, (r) => [r.id, r.description, r.latest_charge, (Number(r.amount) / 100).toFixed(2)]);
278
+ add(data.subscriptions, 'Subscriptions', 'subscriptions', (r) => r.id, (r) => titleCase(String(r.status ?? '')), (r) => [r.id]);
279
+ add(data.invoices, 'Invoices', 'invoices', (r) => r.number || r.id, (r) => `${formatStripeAmount(r.total, r.currency)} · ${titleCase(String(r.status ?? ''))}`, (r) => [r.id, r.number, r.customer_email]);
280
+ add(data.products, 'Products', 'products', (r) => r.name || r.id, (r) => r.description || r.id, (r) => [r.id, r.name, r.description]);
281
+ return hits;
282
+ }
283
+
284
+ function SearchBox({ go }: { go: Go }) {
285
+ const [q, setQ] = useState('');
286
+ const [open, setOpen] = useState(false);
287
+ const [data, setData] = useState<Record<string, StripeRow[]>>({});
288
+ const input = useRef<HTMLInputElement>(null);
289
+ const load = useCallback(async () => {
290
+ const keys = ['customers', 'payment_intents', 'subscriptions', 'invoices', 'products'];
291
+ const lists = await Promise.all(keys.map((k) => list(`/v1/${k}${k === 'subscriptions' ? '?status=all' : ''}`).catch(() => [])));
292
+ setData(Object.fromEntries(keys.map((k, i) => [k, lists[i]!])));
293
+ }, []);
294
+ useEffect(() => {
295
+ const onKey = (e: KeyboardEvent) => {
296
+ const typing = (e.target as HTMLElement)?.closest?.('input,textarea,select');
297
+ if (!typing && (e.key === '/' || ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k'))) { e.preventDefault(); input.current?.focus(); }
298
+ if (e.key === 'Escape') { setOpen(false); input.current?.blur(); }
299
+ };
300
+ window.addEventListener('keydown', onKey);
301
+ return () => window.removeEventListener('keydown', onKey);
302
+ }, []);
303
+ const hits = useMemo(() => globalSearch(data, q), [data, q]);
304
+ const pick = (h: SearchHit) => { go(h.to); setOpen(false); setQ(''); input.current?.blur(); };
305
+ return (
306
+ <div className="search">
307
+ <Icon name="search" className="search-icon" />
308
+ <input
309
+ ref={input} className="search-input cmdk-input" placeholder="Search" value={q}
310
+ onFocus={() => { setOpen(true); void load(); }}
311
+ onBlur={() => window.setTimeout(() => setOpen(false), 150)}
312
+ onChange={(e) => { setQ(e.target.value); setOpen(true); }}
313
+ onKeyDown={(e) => { if (e.key === 'Enter' && hits[0]) pick(hits[0]); }}
314
+ />
315
+ {open && q ? (
316
+ <div className="cmdk" role="listbox" aria-label="Search results">
317
+ {hits.length === 0 && <div className="cmdk-empty">No results for “{q}”</div>}
318
+ {hits.map((h, i) => (
319
+ <React.Fragment key={h.to}>
320
+ {i === 0 || hits[i - 1]!.kind !== h.kind ? <div className="cmdk-group">{h.kind}</div> : null}
321
+ <button className="cmdk-hit" role="option" onMouseDown={(e) => e.preventDefault()} onClick={() => pick(h)}>
322
+ <span className="cmdk-hit-label">{h.label}</span>
323
+ <span className="cmdk-hit-sub">{h.sub}</span>
324
+ </button>
325
+ </React.Fragment>
326
+ ))}
327
+ </div>
328
+ ) : null}
329
+ </div>
330
+ );
331
+ }
332
+
333
+ // ── shell ────────────────────────────────────────────────────────────────────
334
+ type NavItem = { to: string; label: string; icon?: string; match?: string[] };
335
+ const PRIMARY_NAV: NavItem[] = [
336
+ { to: 'dashboard', label: 'Home', icon: 'home' },
337
+ { to: 'balance/overview', label: 'Balances', icon: 'balance', match: ['balance', 'payouts', 'balance-transactions'] },
338
+ { to: 'payments', label: 'Transactions', icon: 'transactions', match: ['payments', 'disputes'] },
339
+ { to: 'customers', label: 'Customers', icon: 'customers' },
340
+ { to: 'products', label: 'Product catalog', icon: 'catalog', match: ['products', 'coupons', 'promotion-codes'] },
341
+ ];
342
+ const PRODUCT_NAV: Array<{ label: string; icon: string; items: NavItem[] }> = [
343
+ { label: 'Connect', icon: 'connect', items: [{ to: 'connect/accounts', label: 'Connected accounts' }, { to: 'connect/transfers', label: 'Transfers' }] },
344
+ { label: 'Payments', icon: 'payments', items: [{ to: 'disputes', label: 'Disputes' }, { to: 'radar/reviews', label: 'Radar' }, { to: 'payment-links', label: 'Payment Links' }, { to: 'terminal/readers', label: 'Terminal' }] },
345
+ { label: 'Billing', icon: 'billing', items: [{ to: 'subscriptions', label: 'Subscriptions' }, { to: 'invoices', label: 'Invoices' }, { to: 'quotes', label: 'Quotes' }] },
346
+ { label: 'Reporting', icon: 'reporting', items: [{ to: 'reports', label: 'Reports' }] },
347
+ { label: 'More', icon: 'more', items: [{ to: 'tax/rates', label: 'Tax' }, { to: 'issuing/cards', label: 'Issuing' }] },
348
+ ];
349
+ const isActive = (route: string[], item: NavItem) => {
350
+ const here = route.join('/');
351
+ return [item.to, ...(item.match ?? [])].some((m) => here === m || here.startsWith(`${m}/`) || route[0] === m);
352
+ };
353
+
354
+ export function SideNav({ route, go }: { route: string[]; go: Go }) {
355
+ const [open, setOpen] = useState<Record<string, boolean>>({ Billing: true });
356
+ const link = (item: NavItem, sub = false) => (
357
+ <a
358
+ key={item.to} href={hrefOf(item.to)} className={`nav-item ${sub ? 'nav-sub' : ''} ${isActive(route, item) ? 'active' : ''}`}
359
+ onClick={(e) => { e.preventDefault(); go(item.to); }}
360
+ >
361
+ {item.icon ? <Icon name={item.icon} /> : null}<span>{item.label}</span>
362
+ </a>
363
+ );
364
+ return (
365
+ <nav className="side" aria-label="Main">
366
+ <button className="account-switcher" onClick={() => go('settings')}>
367
+ <span className="account-mark">T</span>
368
+ <span className="account-name">Twin Inc.</span>
369
+ <Icon name="chevronDown" size={12} />
370
+ </button>
371
+ <div className="nav-group">{PRIMARY_NAV.map((i) => link(i))}</div>
372
+ <div className="nav-heading">Products</div>
373
+ <div className="nav-group">
374
+ {PRODUCT_NAV.map((g) => {
375
+ const expanded = open[g.label] || g.items.some((i) => isActive(route, i));
376
+ return (
377
+ <div key={g.label} className="nav-folder">
378
+ <button className={`nav-item ${expanded ? 'expanded' : ''}`} aria-expanded={expanded} onClick={() => setOpen((o) => ({ ...o, [g.label]: !expanded }))}>
379
+ <Icon name={g.icon} /><span>{g.label}</span><Icon name={expanded ? 'chevronDown' : 'chevronRight'} size={12} className="nav-caret" />
380
+ </button>
381
+ {expanded ? g.items.map((i) => link(i, true)) : null}
382
+ </div>
383
+ );
384
+ })}
385
+ </div>
386
+ <div className="nav-foot">{link({ to: 'developers', label: 'Developers', icon: 'developers', match: ['developers', 'events', 'webhooks'] })}</div>
387
+ </nav>
388
+ );
389
+ }
390
+
391
+ function TopBar({ go }: { go: Go }) {
392
+ return (
393
+ <header className="topbar">
394
+ <SearchBox go={go} />
395
+ <div className="topbar-actions">
396
+ <button className="icon-btn" title="Help" aria-label="Help"><Icon name="help" /></button>
397
+ <button className="icon-btn" title="Notifications" aria-label="Notifications"><Icon name="bell" /></button>
398
+ <button className="icon-btn" title="Settings" aria-label="Settings" onClick={() => go('settings')}><Icon name="gear" /></button>
399
+ <button className="icon-btn create" title="Create" aria-label="Create" onClick={() => go('customers?create=1')}><Icon name="plus" /></button>
400
+ </div>
401
+ </header>
402
+ );
403
+ }
404
+
405
+ export function TestModeBand() {
406
+ return (
407
+ <div className="test-band" role="note">
408
+ <span className="test-band-tag">Test mode</span>
409
+ <span className="test-band-text">You’re using test data. Changes you make here don’t affect your live account.</span>
410
+ </div>
411
+ );
412
+ }
413
+
414
+ // ── data per page ────────────────────────────────────────────────────────────
415
+ /** Load a page's data through the API, refresh it every few seconds, and after a write. */
416
+ function useApi<T>(loader: () => Promise<T>, deps: unknown[]): { data?: T; error?: string; reload: () => void } {
417
+ const [state, setState] = useState<{ data?: T; error?: string }>({});
418
+ const [tick, setTick] = useState(0);
419
+ useEffect(() => {
420
+ let live = true;
421
+ const run = () => loader().then((data) => { if (live) setState({ data }); }, (e) => { if (live) setState((s) => ({ ...s, error: String(e?.message ?? e) })); });
422
+ void run();
423
+ const timer = window.setInterval(run, 4000);
424
+ return () => { live = false; window.clearInterval(timer); };
425
+ // eslint-disable-next-line react-hooks/exhaustive-deps
426
+ }, [...deps, tick]);
427
+ return { ...state, reload: () => setTick((t) => t + 1) };
428
+ }
429
+
430
+ function EmptyState({ title, body }: { title: string; body?: string }) {
431
+ return <div className="empty"><div className="empty-title">{title}</div>{body ? <div className="empty-body">{body}</div> : null}</div>;
432
+ }
433
+ function Loading({ error }: { error?: string }) {
434
+ return error ? <div className="page-error" role="alert">{error}</div> : <div className="loading">Loading…</div>;
435
+ }
436
+ function PageHead({ title, actions, kicker, children }: { title: React.ReactNode; actions?: React.ReactNode; kicker?: React.ReactNode; children?: React.ReactNode }) {
437
+ return (
438
+ <div className="page-head">
439
+ {kicker ? <div className="kicker">{kicker}</div> : null}
440
+ <div className="page-head-row"><h1>{title}</h1>{actions ? <div className="page-actions">{actions}</div> : null}</div>
441
+ {children}
442
+ </div>
443
+ );
444
+ }
445
+ function FilterCards<K extends string>({ options, value, onChange }: { options: Array<{ key: K; label: string; count: number }>; value: K; onChange: (k: K) => void }) {
446
+ return (
447
+ <div className="filter-cards" role="tablist">
448
+ {options.map((o) => (
449
+ <button key={o.key} role="tab" aria-selected={value === o.key} className={`filter-card ${value === o.key ? 'selected' : ''}`} onClick={() => onChange(o.key)}>
450
+ <span className="filter-card-label">{o.label}</span>
451
+ <span className="filter-card-count">{o.count}</span>
452
+ </button>
453
+ ))}
454
+ </div>
455
+ );
456
+ }
457
+ /** One of the toolbar's filter chips: a dashed pill that opens a list of values; a chosen value fills it. */
458
+ function FilterChip({ label, value, options, onChange }: { label: string; value?: string; options: Array<{ key: string; label: string }>; onChange: (v?: string) => void }) {
459
+ const [open, setOpen] = useState(false);
460
+ const chosen = options.find((o) => o.key === value);
461
+ return (
462
+ <div className="chip-wrap">
463
+ <button className={`chip ${chosen ? 'chip-set' : ''}`} aria-expanded={open} onClick={() => setOpen((o) => !o)}>
464
+ <span className="chip-plus" onClick={(e) => { if (chosen) { e.stopPropagation(); onChange(undefined); setOpen(false); } }}><Icon name={chosen ? 'x' : 'plus'} size={10} /></span>
465
+ {label}{chosen ? <><span className="chip-sep">|</span><span className="chip-value">{chosen.label}</span></> : null}
466
+ </button>
467
+ {open ? (
468
+ <div className="popover" role="menu" aria-label={`Filter by ${label}`}>
469
+ <div className="popover-title">Filter by {label.toLowerCase()}</div>
470
+ {options.map((o) => (
471
+ <button key={o.key} role="menuitemradio" aria-checked={o.key === value} className={`popover-item ${o.key === value ? 'checked' : ''}`} onClick={() => { onChange(o.key); setOpen(false); }}>
472
+ <span className="popover-radio" />{o.label}
473
+ </button>
474
+ ))}
475
+ </div>
476
+ ) : null}
477
+ </div>
478
+ );
479
+ }
480
+
481
+ /** Download the rows the table shows as a CSV, as the Dashboard's Export does. */
482
+ function exportCsv(name: string, header: string[], rows: Array<Array<string | number>>) {
483
+ const cell = (v: string | number) => `"${String(v).replace(/"/g, '""')}"`;
484
+ const csv = [header, ...rows].map((r) => r.map(cell).join(',')).join('\n');
485
+ const a = document.createElement('a');
486
+ a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
487
+ a.download = `${name}.csv`;
488
+ a.click();
489
+ URL.revokeObjectURL(a.href);
490
+ }
491
+
492
+ function ResultCount({ n }: { n: number }) {
493
+ return (
494
+ <div className="table-foot">
495
+ <span>{n} {n === 1 ? 'result' : 'results'}</span>
496
+ <span className="pager"><button className="btn" disabled>Previous</button><button className="btn" disabled>Next</button></span>
497
+ </div>
498
+ );
499
+ }
500
+ function Section({ title, actions, children, className }: { title: string; actions?: React.ReactNode; children: React.ReactNode; className?: string }) {
501
+ return (
502
+ <section className={`section ${className ?? ''}`}>
503
+ <div className="section-head"><h2>{title}</h2>{actions}</div>
504
+ {children}
505
+ </section>
506
+ );
507
+ }
508
+ function Props({ rows }: { rows: Array<[string, React.ReactNode]> }) {
509
+ return (
510
+ <dl className="props">
511
+ {rows.map(([k, v]) => <div className="prop" key={k}><dt>{k}</dt><dd>{v ?? '—'}</dd></div>)}
512
+ </dl>
513
+ );
514
+ }
515
+ function Amount({ amount, currency }: { amount: number; currency: string }) {
516
+ return <span className="amount"><span className="amount-value">{money(amount, currency)}</span> <span className="amount-ccy">{ccy(currency)}</span></span>;
517
+ }
518
+ function Link({ to, go, children, className }: { to: string; go: Go; children: React.ReactNode; className?: string }) {
519
+ return <a href={hrefOf(to)} className={`link ${className ?? ''}`} onClick={(e) => { e.preventDefault(); e.stopPropagation(); go(to); }}>{children}</a>;
520
+ }
521
+
522
+ // ── Home ─────────────────────────────────────────────────────────────────────
523
+ export function computeHomeMetrics(data: { payments: Payment[]; customers: StripeObject[]; subscriptions: StripeObject[]; invoices: StripeObject[]; balance?: StripeObject }) {
524
+ const succeeded = data.payments.filter((p) => ['succeeded', 'refunded', 'partially_refunded', 'disputed'].includes(p.status));
525
+ const currency = succeeded[0]?.currency ?? 'usd';
526
+ const grossVolume = succeeded.reduce((s, p) => s + p.amount, 0);
527
+ const refunded = succeeded.reduce((s, p) => s + p.amountRefunded, 0);
528
+ const available = data.balance ? (data.balance.available ?? []).find((b: StripeObject) => b.currency === currency)?.amount ?? 0 : 0;
529
+ const pending = data.balance ? (data.balance.pending ?? []).find((b: StripeObject) => b.currency === currency)?.amount ?? 0 : 0;
530
+ return {
531
+ currency, grossVolume, netVolume: grossVolume - refunded, available: Number(available) || 0, pending: Number(pending) || 0,
532
+ succeededPayments: data.payments.filter((p) => p.status === 'succeeded').length,
533
+ failedPayments: data.payments.filter((p) => p.status === 'failed').length,
534
+ customers: data.customers.length,
535
+ activeSubscriptions: data.subscriptions.filter((s) => s.status === 'active' || s.status === 'trialing').length,
536
+ openInvoices: data.invoices.filter((i) => i.status === 'open').length,
537
+ };
538
+ }
539
+ function Sparkline({ points, of = points.length }: { points: number[]; of?: number }) {
540
+ const w = 280, h = 64;
541
+ const max = Math.max(1, ...points);
542
+ const xy = points.map((p, i) => `${(i / Math.max(1, of - 1)) * w},${h - 4 - (p / max) * (h - 12)}`);
543
+ return (
544
+ <svg className="spark" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" aria-hidden="true">
545
+ <polyline points={xy.join(' ')} fill="none" stroke="#635BFF" strokeWidth="2" vectorEffect="non-scaling-stroke" />
546
+ <line x1="0" x2={w} y1={h - 1} y2={h - 1} stroke="#EBEEF1" />
547
+ </svg>
548
+ );
549
+ }
550
+ function cumulativeByHour(payments: Payment[]): number[] {
551
+ const out = new Array(24).fill(0) as number[];
552
+ for (const p of payments) if (p.status !== 'failed' && p.status !== 'incomplete') out[new Date(p.created * 1000).getHours()]! += p.amount;
553
+ for (let i = 1; i < 24; i++) out[i]! += out[i - 1]!;
554
+ // today's line stops at the current hour, as Stripe's does
555
+ return out.slice(0, new Date().getHours() + 1);
556
+ }
557
+ export function HomeScreen({ go }: { go: Go }) {
558
+ const { data, error } = useApi(loadHome, []);
559
+ if (!data) return <Loading error={error} />;
560
+ const m = computeHomeMetrics(data);
561
+ const curve = cumulativeByHour(data.payments);
562
+ const overview = [
563
+ { key: 'gross', label: 'Gross volume', value: money(m.grossVolume, m.currency) },
564
+ { key: 'net', label: 'Net volume', value: money(m.netVolume, m.currency) },
565
+ { key: 'payments', label: 'Successful payments', value: String(m.succeededPayments) },
566
+ { key: 'failed', label: 'Failed payments', value: String(m.failedPayments) },
567
+ { key: 'customers', label: 'New customers', value: String(m.customers) },
568
+ { key: 'subs', label: 'Active subscribers', value: String(m.activeSubscriptions) },
569
+ ];
570
+ return (
571
+ <div className="home-screen">
572
+ <PageHead title="Today" />
573
+ <div className="today">
574
+ <div className="today-chart">
575
+ <div className="today-label">Gross volume <Icon name="chevronDown" size={12} /></div>
576
+ <div className="today-value">{money(m.grossVolume, m.currency)}</div>
577
+ <Sparkline points={curve} of={24} />
578
+ <div className="today-axis"><span>12:00 AM</span><span>11:59 PM</span></div>
579
+ </div>
580
+ <div className="today-side">
581
+ <div className="today-side-block">
582
+ <div className="today-label">{ccy(m.currency)} balance</div>
583
+ <div className="today-side-value">{money(m.available + m.pending, m.currency)}</div>
584
+ <Link to="balance/overview" go={go}>View</Link>
585
+ </div>
586
+ <div className="today-side-block">
587
+ <div className="today-label">Payouts</div>
588
+ <div className="today-side-value">{money(0, m.currency)}</div>
589
+ <Link to="payouts" go={go}>View</Link>
590
+ </div>
591
+ </div>
592
+ </div>
593
+ <h2 className="overview-title">Your overview</h2>
594
+ <div className="home-metrics">
595
+ {overview.map((c) => (
596
+ <div className="metric-card" key={c.key} data-metric={c.key}>
597
+ <span className="metric-label">{c.label}</span>
598
+ <span className="metric-value">{c.value}</span>
599
+ </div>
600
+ ))}
601
+ </div>
602
+ <Section title="Recent payments" actions={<Link to="payments" go={go}>View all</Link>}>
603
+ <PaymentsTable payments={data.payments.slice(0, 5)} go={go} compact />
604
+ </Section>
605
+ </div>
606
+ );
607
+ }
608
+
609
+ // ── Payments ─────────────────────────────────────────────────────────────────
610
+ type PaymentFilter = 'all' | 'succeeded' | 'refunded' | 'disputed' | 'failed' | 'uncaptured';
611
+ const matchesFilter = (p: Payment, f: PaymentFilter) =>
612
+ f === 'all' || (f === 'refunded' ? p.status === 'refunded' || p.status === 'partially_refunded' : p.status === f);
613
+
614
+ export function PaymentsTable({ payments, go, compact, noCustomer }: { payments: Payment[]; go: Go; compact?: boolean; noCustomer?: boolean }) {
615
+ const cols = (compact ? 6 : 8) - (noCustomer ? 1 : 0);
616
+ return (
617
+ <table className="table payments-table">
618
+ <thead>
619
+ <tr>
620
+ <th>Amount</th><th /><th>Payment method</th><th>Description</th>{noCustomer ? null : <th>Customer</th>}<th>Date</th>
621
+ {compact ? null : <><th>Refunded date</th><th>Decline reason</th></>}
622
+ </tr>
623
+ </thead>
624
+ <tbody>
625
+ {payments.length === 0 && <tr className="empty-row"><td colSpan={cols}><EmptyState title="No payments" body={compact ? undefined : 'Payments you create through the API show up here.'} /></td></tr>}
626
+ {payments.map((p) => (
627
+ <tr key={p.id} className="list-row" onClick={() => go(`payments/${p.id}`)}>
628
+ <td className="nowrap"><Amount amount={p.amount} currency={p.currency} /></td>
629
+ <td><StatusPill value={p.status} /></td>
630
+ <td className="nowrap"><CardLabel pm={p.paymentMethod} /></td>
631
+ <td className="truncate">{p.description ?? <span className="muted">{p.id}</span>}</td>
632
+ {noCustomer ? null : <td className="truncate">{p.customer ? p.customer.email || p.customer.name || p.customer.id : <span className="muted">—</span>}</td>}
633
+ <td className="nowrap muted">{formatDate(p.created)}</td>
634
+ {compact ? null : <>
635
+ <td className="nowrap muted">{p.refundedAt ? formatDate(p.refundedAt) : '—'}</td>
636
+ <td className="nowrap muted">{p.declineReason ?? '—'}</td>
637
+ </>}
638
+ </tr>
639
+ ))}
640
+ </tbody>
641
+ </table>
642
+ );
643
+ }
644
+
645
+ const DAY = 86400;
646
+ const DATE_RANGES = [{ key: 'today', label: 'Today' }, { key: '7d', label: 'Last 7 days' }, { key: '4w', label: 'Last 4 weeks' }, { key: '12m', label: 'Last 12 months' }];
647
+ function inRange(ts: number, range?: string): boolean {
648
+ if (!range) return true;
649
+ const now = Date.now() / 1000;
650
+ if (range === 'today') { const d = new Date(); d.setHours(0, 0, 0, 0); return ts >= d.getTime() / 1000; }
651
+ return ts >= now - ({ '7d': 7, '4w': 28, '12m': 365 }[range] ?? 0) * DAY;
652
+ }
653
+ function PaymentsPage({ go }: { go: Go }) {
654
+ const { data, error } = useApi(loadPayments, []);
655
+ const [filter, setFilter] = useState<PaymentFilter>('all');
656
+ const [chips, setChips] = useState<{ date?: string; currency?: string; status?: string; method?: string }>({});
657
+ const all = data ?? [];
658
+ const payments = all.filter((p) => inRange(p.created, chips.date) && (!chips.currency || p.currency === chips.currency)
659
+ && (!chips.status || p.status === chips.status) && (!chips.method || (p.paymentMethod?.card?.brand ?? p.paymentMethod?.type) === chips.method));
660
+ const count = (f: PaymentFilter) => payments.filter((p) => matchesFilter(p, f)).length;
661
+ const shown = payments.filter((p) => matchesFilter(p, filter));
662
+ const uniq = (xs: Array<string | undefined>) => [...new Set(xs.filter((x): x is string => Boolean(x)))];
663
+ return (
664
+ <div className="page">
665
+ <PageHead title="Transactions">
666
+ <div className="tabs"><Link to="payments" go={go} className="tab active">Payments</Link><Link to="disputes" go={go} className="tab">Disputes</Link></div>
667
+ </PageHead>
668
+ <FilterCards<PaymentFilter>
669
+ value={filter} onChange={setFilter}
670
+ options={[
671
+ { key: 'all', label: 'All', count: payments.length }, { key: 'succeeded', label: 'Succeeded', count: count('succeeded') },
672
+ { key: 'refunded', label: 'Refunded', count: count('refunded') }, { key: 'disputed', label: 'Disputed', count: count('disputed') },
673
+ { key: 'failed', label: 'Failed', count: count('failed') }, { key: 'uncaptured', label: 'Uncaptured', count: count('uncaptured') },
674
+ ]}
675
+ />
676
+ <div className="toolbar">
677
+ <FilterChip label="Date and time" value={chips.date} options={DATE_RANGES} onChange={(date) => setChips((c) => ({ ...c, date }))} />
678
+ <FilterChip label="Currency" value={chips.currency} options={uniq(all.map((p) => p.currency)).map((c) => ({ key: c, label: ccy(c) }))} onChange={(currency) => setChips((c) => ({ ...c, currency }))} />
679
+ <FilterChip label="Status" value={chips.status} options={uniq(all.map((p) => p.status)).map((k) => ({ key: k, label: STATUS[k]?.label ?? k }))} onChange={(status) => setChips((c) => ({ ...c, status }))} />
680
+ <FilterChip label="Payment method" value={chips.method} options={uniq(all.map((p) => p.paymentMethod?.card?.brand ?? p.paymentMethod?.type)).map((b) => ({ key: b, label: b === 'amex' ? 'American Express' : titleCase(b) }))} onChange={(method) => setChips((c) => ({ ...c, method }))} />
681
+ <div className="toolbar-right">
682
+ <button className="btn" onClick={() => exportCsv('payments', ['id', 'Amount', 'Currency', 'Status', 'Description', 'Customer Email', 'Created'], shown.map((p) => [p.id, (p.amount / 100).toFixed(2), p.currency, p.status, p.description ?? '', p.customer?.email ?? '', new Date(p.created * 1000).toISOString()]))}>
683
+ <Icon name="download" size={14} /> Export
684
+ </button>
685
+ </div>
686
+ </div>
687
+ {data ? <><PaymentsTable payments={shown} go={go} /><ResultCount n={shown.length} /></> : <Loading error={error} />}
688
+ </div>
689
+ );
690
+ }
691
+
692
+ const EVENT_TEXT: Record<string, string> = {
693
+ 'payment_intent.created': 'Payment started',
694
+ 'payment_intent.succeeded': 'Payment succeeded',
695
+ 'payment_intent.payment_failed': 'Payment failed',
696
+ 'payment_intent.canceled': 'Payment canceled',
697
+ 'payment_intent.amount_capturable_updated': 'Payment authorized',
698
+ 'payment_intent.requires_action': 'Payment requires authentication',
699
+ 'charge.succeeded': 'Payment succeeded',
700
+ 'charge.failed': 'Payment failed',
701
+ 'charge.captured': 'Payment captured',
702
+ 'charge.dispute.created': 'Payment disputed',
703
+ 'refund.created': 'Refund initiated',
704
+ 'refund.updated': 'Refund updated',
705
+ 'customer.created': 'Customer created',
706
+ 'customer.updated': 'Customer updated',
707
+ 'customer.subscription.created': 'Subscription created',
708
+ 'customer.subscription.updated': 'Subscription updated',
709
+ 'customer.subscription.deleted': 'Subscription canceled',
710
+ 'invoice.created': 'Invoice created',
711
+ 'invoice.finalized': 'Invoice finalized',
712
+ 'invoice.paid': 'Invoice paid',
713
+ 'invoice.payment_failed': 'Invoice payment failed',
714
+ };
715
+ function eventText(e: StripeObject): string {
716
+ const obj = e.data?.object ?? {};
717
+ if (e.type === 'charge.refunded') return `${money(obj.amount_refunded, obj.currency)} ${ccy(obj.currency)} refunded`;
718
+ if (e.type === 'refund.created') return `Refund of ${money(obj.amount, obj.currency)} ${ccy(obj.currency)} initiated`;
719
+ if (e.type === 'payment_intent.payment_failed') return `Payment failed${obj.last_payment_error?.message ? `: ${obj.last_payment_error.message}` : ''}`;
720
+ return EVENT_TEXT[e.type] ?? e.type;
721
+ }
722
+ const eventTone = (type: string) => (/failed|dispute/.test(type) ? 'red' : /succeeded|paid|captured/.test(type) ? 'green' : /refund/.test(type) ? 'grey' : 'blue');
723
+ function Timeline({ events }: { events: StripeObject[] }) {
724
+ if (events.length === 0) return <EmptyState title="No events" />;
725
+ return (
726
+ <ol className="timeline">
727
+ {events.map((e) => (
728
+ <li key={e.id} className="timeline-item">
729
+ <span className={`timeline-dot dot-${eventTone(e.type)}`} />
730
+ <div className="timeline-body">
731
+ <div className="timeline-text">{eventText(e)}</div>
732
+ <div className="timeline-time">{formatDate(e.created)}</div>
733
+ </div>
734
+ </li>
735
+ ))}
736
+ </ol>
737
+ );
738
+ }
739
+
740
+ function PaymentPage({ id, go }: { id: string; go: Go }) {
741
+ const { data, error, reload } = useApi(() => loadPayment(id), [id]);
742
+ const [refunding, setRefunding] = useState(false);
743
+ if (!data) return <Loading error={error} />;
744
+ return (
745
+ <>
746
+ <PaymentDetailView p={data} go={go} onRefund={() => setRefunding(true)} />
747
+ {refunding ? <RefundDialog payment={data} onClose={() => setRefunding(false)} onDone={() => { setRefunding(false); reload(); }} /> : null}
748
+ </>
749
+ );
750
+ }
751
+
752
+ /** A payment's page: amount and status, who and how, the timeline of its events, the money, its refunds. */
753
+ export function PaymentDetailView({ p, go, onRefund }: { p: PaymentDetail; go: Go; onRefund: () => void }) {
754
+ const refundable = Boolean(p.charge) && (p.status === 'succeeded' || p.status === 'partially_refunded') && p.amount - p.amountRefunded > 0;
755
+ const card = p.paymentMethod?.card;
756
+ const net = p.amount - p.amountRefunded;
757
+ return (
758
+ <div className="page detail-page">
759
+ <PageHead
760
+ kicker={<><Icon name="payments" size={12} /> Payment</>}
761
+ title={<><Amount amount={p.amount} currency={p.currency} /> <StatusPill value={p.status} /></>}
762
+ actions={refundable ? <button className="btn" onClick={onRefund}><Icon name="refund" size={14} /> Refund</button> : null}
763
+ >
764
+ <div className="summary">
765
+ <div className="summary-item"><div className="summary-label">Last update</div><div>{formatDate(p.events[0]?.created ?? p.created)}</div></div>
766
+ <div className="summary-item"><div className="summary-label">Customer</div><div>{p.customer ? <Link to={`customers/${p.customer.id}`} go={go}>{p.customer.email || customerLabel(p.customer)}</Link> : '—'}</div></div>
767
+ <div className="summary-item"><div className="summary-label">Payment method</div><div><CardLabel pm={p.paymentMethod} /></div></div>
768
+ {p.declineReason ? <div className="summary-item"><div className="summary-label">Decline reason</div><div>{p.declineReason}</div></div> : null}
769
+ </div>
770
+ </PageHead>
771
+ {p.intent?.last_payment_error ? <PaymentErrorBanner error={p.intent.last_payment_error} /> : null}
772
+ <div className="detail-columns">
773
+ <div className="detail-main">
774
+ <Section title="Timeline"><Timeline events={p.events} /></Section>
775
+ <Section title="Payment breakdown">
776
+ <dl className="breakdown">
777
+ <div><dt>Payment amount</dt><dd>{money(p.amount, p.currency)} {ccy(p.currency)}</dd></div>
778
+ {p.amountRefunded > 0 ? <div><dt>Refunded</dt><dd>−{money(p.amountRefunded, p.currency)} {ccy(p.currency)}</dd></div> : null}
779
+ <div className="total"><dt>Net amount</dt><dd>{money(p.status === 'failed' || p.status === 'incomplete' ? 0 : net, p.currency)} {ccy(p.currency)}</dd></div>
780
+ </dl>
781
+ </Section>
782
+ {p.refunds.length > 0 ? (
783
+ <Section title="Refunds">
784
+ <table className="table">
785
+ <thead><tr><th>Amount</th><th /><th>Reason</th><th>ID</th><th className="right">Date</th></tr></thead>
786
+ <tbody>
787
+ {p.refunds.map((r) => (
788
+ <tr key={r.id}>
789
+ <td><Amount amount={Number(r.amount) || 0} currency={r.currency} /></td>
790
+ <td><StatusPill value={r.status} /></td>
791
+ <td>{r.reason ? titleCase(r.reason) : '—'}</td>
792
+ <td className="mono muted">{r.id}</td>
793
+ <td className="right muted nowrap">{formatDate(r.created)}</td>
794
+ </tr>
795
+ ))}
796
+ </tbody>
797
+ </table>
798
+ </Section>
799
+ ) : null}
800
+ </div>
801
+ <aside className="detail-side">
802
+ <Section title="Payment details">
803
+ <Props rows={[
804
+ ['Statement descriptor', p.charge?.calculated_statement_descriptor ?? p.intent?.statement_descriptor ?? '—'],
805
+ ['Description', p.description ?? '—'],
806
+ ...(p.invoice ? [['Invoice', <Link to={`invoices/${p.invoice.id}`} go={go}>{p.invoice.number || p.invoice.id}</Link>] as [string, React.ReactNode]] : []),
807
+ ['Payment ID', <span className="mono">{p.intent?.id ?? p.id}</span>],
808
+ ...(p.charge ? [['Charge ID', <span className="mono">{p.charge.id}</span>] as [string, React.ReactNode]] : []),
809
+ ]} />
810
+ </Section>
811
+ <Section title="Payment method">
812
+ {p.paymentMethod ? (
813
+ <Props rows={[
814
+ ['ID', <span className="mono">{p.paymentMethod.id}</span>],
815
+ ['Number', card ? <span className="card-label"><span className="dots">•••• •••• ••••</span> {card.last4}</span> : '—'],
816
+ ['Expires', card ? `${String(card.exp_month).padStart(2, '0')} / ${card.exp_year}` : '—'],
817
+ ['Type', card ? `${BRANDS[card.brand] ?? titleCase(String(card.brand))} ${card.funding ?? ''} card` : titleCase(String(p.paymentMethod.type))],
818
+ ['Owner', p.paymentMethod.billing_details?.name ?? p.customer?.name ?? '—'],
819
+ ['Owner email', p.paymentMethod.billing_details?.email ?? p.customer?.email ?? '—'],
820
+ ['Origin', card?.country ? COUNTRIES[card.country] ?? card.country : '—'],
821
+ ['CVC check', card?.checks?.cvc_check ? (card.checks.cvc_check === 'pass' ? <span className="check-pass"><Icon name="check" size={12} /> Passed</span> : titleCase(card.checks.cvc_check)) : '—'],
822
+ ]} />
823
+ ) : <EmptyState title="No payment method" />}
824
+ </Section>
825
+ <Section title="Metadata">
826
+ {Object.keys(p.intent?.metadata ?? p.charge?.metadata ?? {}).length ? (
827
+ <Props rows={Object.entries(p.intent?.metadata ?? p.charge?.metadata ?? {}).map(([k, v]) => [k, String(v)] as [string, React.ReactNode])} />
828
+ ) : <div className="muted small">No metadata</div>}
829
+ </Section>
830
+ </aside>
831
+ </div>
832
+ </div>
833
+ );
834
+ }
835
+
836
+ const BRANDS: Record<string, string> = { visa: 'Visa', mastercard: 'Mastercard', amex: 'American Express', discover: 'Discover', diners: 'Diners Club', jcb: 'JCB', unionpay: 'UnionPay' };
837
+ const COUNTRIES: Record<string, string> = { US: 'United States', GB: 'United Kingdom', CA: 'Canada', DE: 'Germany', FR: 'France', AU: 'Australia', JP: 'Japan', IE: 'Ireland', NL: 'Netherlands', SG: 'Singapore' };
838
+
839
+ // ── dialogs ──────────────────────────────────────────────────────────────────
840
+ function Dialog({ title, onClose, children, footer, label }: { title: string; onClose: () => void; children: React.ReactNode; footer: React.ReactNode; label?: string }) {
841
+ return (
842
+ <div className="overlay" onMouseDown={onClose}>
843
+ <div className="dialog create-modal" role="dialog" aria-modal="true" aria-label={label ?? title} onMouseDown={(e) => e.stopPropagation()}>
844
+ <div className="dialog-head"><h2>{title}</h2><button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button></div>
845
+ <div className="dialog-body">{children}</div>
846
+ <div className="dialog-foot">{footer}</div>
847
+ </div>
848
+ </div>
849
+ );
850
+ }
851
+
852
+ function RefundDialog({ payment, onClose, onDone }: { payment: Payment; onClose: () => void; onDone: () => void }) {
853
+ const remaining = payment.amount - payment.amountRefunded;
854
+ const [amount, setAmount] = useState((remaining / 100).toFixed(2));
855
+ const [reason, setReason] = useState('');
856
+ const [busy, setBusy] = useState(false);
857
+ const [err, setErr] = useState<string | null>(null);
858
+ const cents = Math.round(Number(amount) * 100);
859
+ const submit = async () => {
860
+ if (!Number.isFinite(cents) || cents <= 0) { setErr('Enter an amount greater than 0.'); return; }
861
+ if (cents > remaining) { setErr(`Refund cannot be more than ${money(remaining, payment.currency)}.`); return; }
862
+ setBusy(true); setErr(null);
863
+ try {
864
+ await createRefund(payment, cents === remaining && payment.amountRefunded === 0 ? undefined : cents, reason && reason !== 'other' ? reason : undefined);
865
+ onDone();
866
+ } catch (e: any) { setErr(String(e.message ?? e)); } finally { setBusy(false); }
867
+ };
868
+ return (
869
+ <Dialog
870
+ title="Refund payment" onClose={onClose}
871
+ footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary create-submit" disabled={busy} onClick={submit}>{busy ? 'Refunding…' : 'Refund'}</button></>}
872
+ >
873
+ <p className="dialog-note">Refunds take 5–10 days to appear on a customer’s statement. Stripe’s fees for the original payment won’t be returned, but there are no additional fees for the refund.</p>
874
+ <label className="field create-field">
875
+ <span className="field-label">Refund</span>
876
+ <span className="money-input"><span className="money-prefix">$</span><input aria-label="Refund amount" inputMode="decimal" value={amount} onChange={(e) => setAmount(e.target.value)} /><span className="money-suffix">{ccy(payment.currency)}</span></span>
877
+ {payment.amountRefunded > 0 ? <span className="field-hint">{money(payment.amountRefunded, payment.currency)} already refunded</span> : null}
878
+ </label>
879
+ <label className="field create-field">
880
+ <span className="field-label">Reason</span>
881
+ <select aria-label="Reason" value={reason} onChange={(e) => setReason(e.target.value)}>
882
+ <option value="">Select a reason</option>
883
+ <option value="duplicate">Duplicate</option>
884
+ <option value="fraudulent">Fraudulent</option>
885
+ <option value="requested_by_customer">Requested by customer</option>
886
+ <option value="other">Other</option>
887
+ </select>
888
+ </label>
889
+ {err ? <div className="form-error" role="alert">{err}</div> : null}
890
+ </Dialog>
891
+ );
892
+ }
893
+
894
+ function CancelSubscriptionDialog({ subscription, onClose, onDone }: { subscription: StripeObject; onClose: () => void; onDone: () => void }) {
895
+ const [when, setWhen] = useState<'now' | 'period_end'>('now');
896
+ const [busy, setBusy] = useState(false);
897
+ const [err, setErr] = useState<string | null>(null);
898
+ const submit = async () => {
899
+ setBusy(true); setErr(null);
900
+ try { await cancelSubscription(String(subscription.id), when); onDone(); } catch (e: any) { setErr(String(e.message ?? e)); } finally { setBusy(false); }
901
+ };
902
+ return (
903
+ <Dialog
904
+ title="Cancel subscription" onClose={onClose}
905
+ footer={<><button className="btn" onClick={onClose}>Don’t cancel</button><button className="btn btn-danger create-submit" disabled={busy} onClick={submit}>{busy ? 'Canceling…' : 'Cancel subscription'}</button></>}
906
+ >
907
+ <fieldset className="radio-group">
908
+ <legend className="field-label">Cancel</legend>
909
+ <label className="radio"><input type="radio" name="when" checked={when === 'now'} onChange={() => setWhen('now')} /><span>Immediately<span className="radio-hint">The subscription ends now and no further invoices are created.</span></span></label>
910
+ <label className="radio"><input type="radio" name="when" checked={when === 'period_end'} onChange={() => setWhen('period_end')} /><span>End of the current period<span className="radio-hint">{formatDate(subscription.current_period_end, false)}</span></span></label>
911
+ </fieldset>
912
+ {err ? <div className="form-error" role="alert">{err}</div> : null}
913
+ </Dialog>
914
+ );
915
+ }
916
+
917
+ /** The create forms the Dashboard opens from a list page: each posts the resource's create call. */
918
+ export const CREATE_FORMS: Record<string, { title: string; submit: string; fields: Array<{ name: string; label: string; placeholder?: string; required?: boolean; type?: string }> }> = {
919
+ customers: {
920
+ title: 'Create customer', submit: 'Add customer',
921
+ fields: [{ name: 'name', label: 'Name' }, { name: 'email', label: 'Email', type: 'email' }, { name: 'description', label: 'Description' }, { name: 'phone', label: 'Phone' }],
922
+ },
923
+ products: {
924
+ title: 'Add a product', submit: 'Add product',
925
+ fields: [{ name: 'name', label: 'Name', required: true }, { name: 'description', label: 'Description' }],
926
+ },
927
+ };
928
+
929
+ function CreateDialog({ collection, onClose, onCreated }: { collection: string; onClose: () => void; onCreated: (o: StripeObject) => void }) {
930
+ const form = CREATE_FORMS[collection]!;
931
+ const [values, setValues] = useState<Record<string, string>>({});
932
+ const [busy, setBusy] = useState(false);
933
+ const [err, setErr] = useState<string | null>(null);
934
+ const submit = async () => {
935
+ setBusy(true); setErr(null);
936
+ try { onCreated(await createObject(collection, values)); } catch (e: any) { setErr(String(e.message ?? e)); } finally { setBusy(false); }
937
+ };
938
+ return (
939
+ <Dialog
940
+ title={form.title} onClose={onClose}
941
+ footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary create-submit" disabled={busy} onClick={submit}>{busy ? 'Saving…' : form.submit}</button></>}
942
+ >
943
+ {collection === 'customers' ? <div className="dialog-subhead">Account information</div> : null}
944
+ {form.fields.map((f) => (
945
+ <label className="field create-field" key={f.name}>
946
+ <span className="field-label">{f.label}{f.required ? '' : <span className="optional"> (optional)</span>}</span>
947
+ <input aria-label={f.label} type={f.type ?? 'text'} placeholder={f.placeholder ?? ''} value={values[f.name] ?? ''} onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))} />
948
+ </label>
949
+ ))}
950
+ {err ? <div className="form-error" role="alert">{err}</div> : null}
951
+ </Dialog>
952
+ );
953
+ }
954
+
955
+ // ── Customers ────────────────────────────────────────────────────────────────
956
+ type CustomerSegment = 'all' | 'first_time' | 'repeat' | 'recent' | 'refunds';
957
+ const SEGMENTS: Array<{ key: CustomerSegment; label: string; test: (c: CustomerRow) => boolean }> = [
958
+ { key: 'all', label: 'All customers', test: () => true },
959
+ { key: 'first_time', label: 'First-time customers', test: (c) => c.payments === 1 },
960
+ { key: 'repeat', label: 'Repeat customers', test: (c) => c.payments > 1 },
961
+ { key: 'recent', label: 'Recent customers', test: (c) => Number(c.created) >= Date.now() / 1000 - 30 * DAY },
962
+ { key: 'refunds', label: 'High refunds', test: (c) => c.refunds > 0 },
963
+ ];
964
+ function CustomersPage({ go, create }: { go: Go; create: boolean }) {
965
+ const { data, error, reload } = useApi(loadCustomers, []);
966
+ const [creating, setCreating] = useState(create);
967
+ const [segment, setSegment] = useState<CustomerSegment>('all');
968
+ const [created, setCreated] = useState<string | undefined>();
969
+ useEffect(() => { if (create) setCreating(true); }, [create]);
970
+ const all = (data ?? []).filter((c) => inRange(Number(c.created), created));
971
+ const rows = all.filter(SEGMENTS.find((s) => s.key === segment)!.test);
972
+ return (
973
+ <div className="page">
974
+ <PageHead title="Customers" actions={<button className="btn btn-primary" onClick={() => setCreating(true)}><Icon name="plus" size={14} /> Create customer</button>} />
975
+ <FilterCards<CustomerSegment> value={segment} onChange={setSegment} options={SEGMENTS.map((s) => ({ key: s.key, label: s.label, count: all.filter(s.test).length }))} />
976
+ <div className="toolbar">
977
+ <FilterChip label="Created" value={created} options={DATE_RANGES} onChange={setCreated} />
978
+ <div className="toolbar-right">
979
+ <button className="btn" onClick={() => exportCsv('customers', ['id', 'Name', 'Email', 'Created', 'Total Spend', 'Payment Count', 'Refunded Volume'], rows.map((c) => [c.id, c.name ?? '', c.email ?? '', new Date(Number(c.created) * 1000).toISOString(), (c.totalSpend / 100).toFixed(2), c.payments, (c.refunds / 100).toFixed(2)]))}>
980
+ <Icon name="download" size={14} /> Export
981
+ </button>
982
+ </div>
983
+ </div>
984
+ {data ? (
985
+ <>
986
+ <table className="table">
987
+ <thead><tr><th>Name</th><th>Email</th><th>Default payment method</th><th>Created</th><th className="right">Total spend</th><th className="right">Payments</th><th className="right">Refunds</th></tr></thead>
988
+ <tbody>
989
+ {rows.length === 0 && <tr className="empty-row"><td colSpan={7}><EmptyState title="No customers" body="Customers you create show up here." /></td></tr>}
990
+ {rows.map((c) => (
991
+ <tr key={c.id} className="list-row" onClick={() => go(`customers/${c.id}`)}>
992
+ <td className="strong">{c.name || <span className="muted">—</span>}</td>
993
+ <td>{c.email || <span className="muted">—</span>}</td>
994
+ <td><CardLabel pm={c.defaultCard} /></td>
995
+ <td className="muted nowrap">{formatDate(c.created)}</td>
996
+ <td className="right nowrap">{money(c.totalSpend, c.currency)}</td>
997
+ <td className="right">{c.payments}</td>
998
+ <td className="right nowrap">{money(c.refunds, c.currency)}</td>
999
+ </tr>
1000
+ ))}
1001
+ </tbody>
1002
+ </table>
1003
+ <ResultCount n={rows.length} />
1004
+ </>
1005
+ ) : <Loading error={error} />}
1006
+ {creating ? <CreateDialog collection="customers" onClose={() => { setCreating(false); if (create) go('customers'); }} onCreated={(c) => { setCreating(false); reload(); go(`customers/${c.id}`); }} /> : null}
1007
+ </div>
1008
+ );
1009
+ }
1010
+
1011
+ function subscriptionProduct(sub: StripeObject, products: Map<string, StripeObject>): string {
1012
+ const items: StripeObject[] = sub.items?.data ?? [];
1013
+ const names = items.map((it) => {
1014
+ const productId = typeof it.price?.product === 'string' ? it.price.product : it.price?.product?.id;
1015
+ const name = products.get(productId)?.name ?? it.price?.nickname ?? productId ?? 'Product';
1016
+ return Number(it.quantity) > 1 ? `${name} × ${it.quantity}` : name;
1017
+ });
1018
+ return names.length > 1 ? `${names[0]} and ${names.length - 1} more` : names[0] ?? '—';
1019
+ }
1020
+ function subscriptionAmount(sub: StripeObject): { amount: number; currency: string; interval: string } {
1021
+ const items: StripeObject[] = sub.items?.data ?? [];
1022
+ const amount = items.reduce((n, it) => n + (Number(it.price?.unit_amount) || 0) * (Number(it.quantity) || 1), 0);
1023
+ const interval = items[0]?.price?.recurring?.interval ?? 'month';
1024
+ return { amount, currency: String(items[0]?.price?.currency ?? sub.currency ?? 'usd'), interval };
1025
+ }
1026
+
1027
+ export function InvoicesTable({ invoices, customers, go, noCustomer }: { invoices: StripeObject[]; customers?: Map<string, StripeObject>; go: Go; noCustomer?: boolean }) {
1028
+ return (
1029
+ <table className="table">
1030
+ <thead><tr><th>Amount</th><th /><th>Invoice number</th>{noCustomer ? null : <th>Customer</th>}<th>Due</th><th className="right">Created</th></tr></thead>
1031
+ <tbody>
1032
+ {invoices.length === 0 && <tr className="empty-row"><td colSpan={6}><EmptyState title="No invoices" /></td></tr>}
1033
+ {invoices.map((inv) => {
1034
+ const c = customers?.get(inv.customer);
1035
+ return (
1036
+ <tr key={inv.id} className="list-row" onClick={() => go(`invoices/${inv.id}`)}>
1037
+ <td className="nowrap"><Amount amount={Number(inv.total ?? inv.amount_due) || 0} currency={inv.currency} /></td>
1038
+ <td><StatusPill value={inv.status} /></td>
1039
+ <td className="mono">{inv.number || inv.id}</td>
1040
+ {noCustomer ? null : <td className="truncate">{inv.customer_email || c?.email || c?.name || inv.customer}</td>}
1041
+ <td className="muted nowrap">{inv.due_date ? formatDate(inv.due_date, false) : '—'}</td>
1042
+ <td className="right muted nowrap">{formatDate(inv.created)}</td>
1043
+ </tr>
1044
+ );
1045
+ })}
1046
+ </tbody>
1047
+ </table>
1048
+ );
1049
+ }
1050
+
1051
+ function CustomerPage({ id, go }: { id: string; go: Go }) {
1052
+ const { data, error } = useApi(() => loadCustomer(id), [id]);
1053
+ return data ? <CustomerDetailView data={data} go={go} /> : <Loading error={error} />;
1054
+ }
1055
+
1056
+ /** A customer's page: who they are, their subscriptions, payments, saved cards, invoices and events. */
1057
+ export function CustomerDetailView({ data, go }: { data: CustomerDetail; go: Go }) {
1058
+ const c = data.customer;
1059
+ const defaultPm = c.invoice_settings?.default_payment_method;
1060
+ const spend = data.payments.filter((p) => p.status !== 'failed' && p.status !== 'incomplete').reduce((n, p) => n + p.amount - p.amountRefunded, 0);
1061
+ return (
1062
+ <div className="page detail-page">
1063
+ <PageHead kicker={<><Icon name="person" size={12} /> Customer</>} title={c.name || c.email || c.id}>
1064
+ <div className="subtitle">{c.email ?? ''}</div>
1065
+ <div className="summary">
1066
+ <div className="summary-item"><div className="summary-label">Customer since</div><div>{formatDate(c.created, false)}</div></div>
1067
+ <div className="summary-item"><div className="summary-label">Total spend</div><div>{money(spend, data.payments[0]?.currency ?? 'usd')}</div></div>
1068
+ <div className="summary-item"><div className="summary-label">Payments</div><div>{data.payments.filter((p) => p.status !== 'failed' && p.status !== 'incomplete').length}</div></div>
1069
+ </div>
1070
+ </PageHead>
1071
+ <div className="detail-columns">
1072
+ <div className="detail-main">
1073
+ <Section title="Subscriptions">
1074
+ {data.subscriptions.length === 0 ? <div className="muted small">No subscriptions</div> : (
1075
+ <div className="sub-list">
1076
+ {data.subscriptions.map((s) => {
1077
+ const a = subscriptionAmount(s);
1078
+ return (
1079
+ <a key={s.id} href={hrefOf(`subscriptions/${s.id}`)} className="sub-card list-row" onClick={(e) => { e.preventDefault(); go(`subscriptions/${s.id}`); }}>
1080
+ <div className="sub-card-main"><span className="strong">{subscriptionProduct(s, data.products)}</span> <StatusPill value={s.status} /></div>
1081
+ <div className="muted small">{money(a.amount, a.currency)} / {a.interval} · {s.status === 'canceled' ? `Canceled ${formatDate(s.canceled_at ?? s.ended_at, false)}` : s.status === 'trialing' ? `Trial ends ${formatDate(s.trial_end, false)}` : s.cancel_at_period_end ? `Cancels ${formatDate(s.current_period_end, false)}` : `Next invoice on ${formatDate(s.current_period_end, false)}`}</div>
1082
+ </a>
1083
+ );
1084
+ })}
1085
+ </div>
1086
+ )}
1087
+ </Section>
1088
+ <Section title="Payments" className="customer-payments"><PaymentsTable payments={data.payments} go={go} compact noCustomer /></Section>
1089
+ <Section title="Payment methods">
1090
+ {data.paymentMethods.length === 0 ? <div className="muted small">No payment methods</div> : (
1091
+ <table className="table">
1092
+ <tbody>
1093
+ {data.paymentMethods.map((pm) => (
1094
+ <tr key={pm.id}>
1095
+ <td><CardLabel pm={pm} /> {pm.id === defaultPm ? <span className="pill pill-grey">Default</span> : null}</td>
1096
+ <td className="muted">{pm.card ? `Expires ${String(pm.card.exp_month).padStart(2, '0')} / ${pm.card.exp_year}` : ''}</td>
1097
+ <td className="mono muted right">{pm.id}</td>
1098
+ </tr>
1099
+ ))}
1100
+ </tbody>
1101
+ </table>
1102
+ )}
1103
+ </Section>
1104
+ <Section title="Invoices"><InvoicesTable invoices={data.invoices} go={go} noCustomer /></Section>
1105
+ <Section title="Events"><Timeline events={data.events.slice(0, 10)} /></Section>
1106
+ </div>
1107
+ <aside className="detail-side">
1108
+ <Section title="Details">
1109
+ <Props rows={[
1110
+ ['Customer ID', <span className="mono">{c.id}</span>],
1111
+ ['Customer since', formatDate(c.created, false)],
1112
+ ['Email', c.email ?? '—'],
1113
+ ['Description', c.description || '—'],
1114
+ ['Phone', c.phone || '—'],
1115
+ ['Billing details', c.address?.line1 ? `${c.address.line1}, ${c.address.city ?? ''}` : 'No billing address'],
1116
+ ['Currency', c.currency ? ccy(c.currency) : '—'],
1117
+ ]} />
1118
+ </Section>
1119
+ <Section title="Metadata">
1120
+ {Object.keys(c.metadata ?? {}).length ? <Props rows={Object.entries(c.metadata).map(([k, v]) => [k, String(v)] as [string, React.ReactNode])} /> : <div className="muted small">No metadata</div>}
1121
+ </Section>
1122
+ </aside>
1123
+ </div>
1124
+ </div>
1125
+ );
1126
+ }
1127
+
1128
+ // ── Billing ──────────────────────────────────────────────────────────────────
1129
+ type SubFilter = 'all' | 'active' | 'trialing' | 'past_due' | 'canceled';
1130
+ function SubscriptionsPage({ go }: { go: Go }) {
1131
+ const { data, error } = useApi(loadSubscriptions, []);
1132
+ const [filter, setFilter] = useState<SubFilter>('all');
1133
+ const subs = data?.subscriptions ?? [];
1134
+ const shown = subs.filter((s) => filter === 'all' || s.status === filter);
1135
+ return (
1136
+ <div className="page">
1137
+ <PageHead title="Subscriptions" />
1138
+ <FilterCards<SubFilter>
1139
+ value={filter} onChange={setFilter}
1140
+ options={(['all', 'active', 'trialing', 'past_due', 'canceled'] as SubFilter[]).map((k) => ({ key: k, label: k === 'all' ? 'All' : STATUS[k]!.label, count: k === 'all' ? subs.length : subs.filter((s) => s.status === k).length }))}
1141
+ />
1142
+ {data ? (
1143
+ <>
1144
+ <table className="table">
1145
+ <thead><tr><th>Customer</th><th>Status</th><th>Billing</th><th>Product</th><th className="right">Amount</th><th className="right">Created</th></tr></thead>
1146
+ <tbody>
1147
+ {shown.length === 0 && <tr className="empty-row"><td colSpan={6}><EmptyState title="No subscriptions" /></td></tr>}
1148
+ {shown.map((s) => {
1149
+ const c = data.customers.get(s.customer);
1150
+ const a = subscriptionAmount(s);
1151
+ return (
1152
+ <tr key={s.id} className="list-row" onClick={() => go(`subscriptions/${s.id}`)}>
1153
+ <td><div className="strong">{c?.name || c?.email || s.customer}</div>{c?.name && c?.email ? <div className="muted small">{c.email}</div> : null}</td>
1154
+ <td><StatusPill value={s.status} />{s.cancel_at_period_end && s.status !== 'canceled' ? <span className="muted small"> Cancels {formatDate(s.current_period_end, false)}</span> : null}</td>
1155
+ <td>{s.collection_method === 'send_invoice' ? 'Send invoice' : 'Auto'}</td>
1156
+ <td>{subscriptionProduct(s, data.products)}</td>
1157
+ <td className="right nowrap">{money(a.amount, a.currency)} / {a.interval}</td>
1158
+ <td className="right muted nowrap">{formatDate(s.created)}</td>
1159
+ </tr>
1160
+ );
1161
+ })}
1162
+ </tbody>
1163
+ </table>
1164
+ <ResultCount n={shown.length} />
1165
+ </>
1166
+ ) : <Loading error={error} />}
1167
+ </div>
1168
+ );
1169
+ }
1170
+
1171
+ function SubscriptionPage({ id, go }: { id: string; go: Go }) {
1172
+ const { data, error, reload } = useApi(() => loadSubscription(id), [id]);
1173
+ const [canceling, setCanceling] = useState(false);
1174
+ if (!data) return <Loading error={error} />;
1175
+ return (
1176
+ <>
1177
+ <SubscriptionDetailView data={data} go={go} onCancel={() => setCanceling(true)} />
1178
+ {canceling ? <CancelSubscriptionDialog subscription={data.subscription} onClose={() => setCanceling(false)} onDone={() => { setCanceling(false); reload(); }} /> : null}
1179
+ </>
1180
+ );
1181
+ }
1182
+
1183
+ /** A subscription's page: whose, on what, its period and next invoice, its prices, invoices and events. */
1184
+ export function SubscriptionDetailView({ data, go, onCancel }: { data: SubscriptionDetail; go: Go; onCancel: () => void }) {
1185
+ const s = data.subscription;
1186
+ const c = data.customer;
1187
+ const a = subscriptionAmount(s);
1188
+ const product = subscriptionProduct(s, data.products);
1189
+ const live = s.status !== 'canceled' && s.status !== 'incomplete_expired';
1190
+ const next = s.status === 'canceled' ? `Canceled on ${formatDate(s.canceled_at ?? s.ended_at, false)}`
1191
+ : s.cancel_at_period_end ? `Cancels on ${formatDate(s.current_period_end, false)}`
1192
+ : s.status === 'trialing' ? `Trial ends ${formatDate(s.trial_end, false)} · First invoice for ${money(a.amount, a.currency)}`
1193
+ : `Next invoice on ${formatDate(s.current_period_end, false)} for ${money(a.amount, a.currency)}`;
1194
+ return (
1195
+ <div className="page detail-page">
1196
+ <PageHead
1197
+ kicker={<><Icon name="billing" size={12} /> Subscription</>}
1198
+ title={<>{customerLabel(c)} on {product} <StatusPill value={s.status} /></>}
1199
+ actions={live && !s.cancel_at_period_end ? <button className="btn" onClick={onCancel}>Cancel subscription</button> : null}
1200
+ >
1201
+ <div className="subtitle">Billing {a.interval}ly · {next}</div>
1202
+ <div className="summary">
1203
+ <div className="summary-item"><div className="summary-label">Started</div><div>{formatDate(s.start_date ?? s.created, false)}</div></div>
1204
+ <div className="summary-item"><div className="summary-label">{s.status === 'canceled' ? 'Ended' : s.cancel_at_period_end ? 'Cancels' : 'Next invoice'}</div><div>{s.status === 'canceled' ? formatDate(s.ended_at ?? s.canceled_at, false) : s.cancel_at_period_end ? formatDate(s.current_period_end, false) : `${money(a.amount, a.currency)} on ${formatDate(s.current_period_end, false)}`}</div></div>
1205
+ <div className="summary-item"><div className="summary-label">Customer</div><div>{c ? <Link to={`customers/${c.id}`} go={go}>{c.email || c.name}</Link> : s.customer}</div></div>
1206
+ </div>
1207
+ </PageHead>
1208
+ <Section title="Subscription details">
1209
+ <Props rows={[
1210
+ ['Customer', c ? <Link to={`customers/${c.id}`} go={go}>{customerLabel(c)}</Link> : s.customer],
1211
+ ['Created', formatDate(s.created)],
1212
+ ['Current period', `${formatDate(s.current_period_start, false).replace(/, \d{4}$/, '')} to ${formatDate(s.current_period_end, false)}`],
1213
+ ...(s.trial_end ? [['Trial', `${formatDate(s.trial_start, false)} to ${formatDate(s.trial_end, false)}`] as [string, React.ReactNode]] : []),
1214
+ ['ID', <span className="mono">{s.id}</span>],
1215
+ ['Billing method', s.collection_method === 'send_invoice' ? 'Email invoice to the customer' : 'Charge default payment method'],
1216
+ ['Payment method', <CardLabel pm={data.paymentMethod} />],
1217
+ ['Discounts', (s.discounts ?? []).length ? `${s.discounts.length}` : '—'],
1218
+ ]} />
1219
+ </Section>
1220
+ <Section title="Pricing">
1221
+ <table className="table">
1222
+ <thead><tr><th>Product</th><th className="right">Quantity</th><th className="right">Unit price</th><th className="right">Total</th></tr></thead>
1223
+ <tbody>
1224
+ {(s.items?.data ?? []).map((it: StripeObject) => {
1225
+ const productId = typeof it.price?.product === 'string' ? it.price.product : it.price?.product?.id;
1226
+ const unit = Number(it.price?.unit_amount) || 0;
1227
+ const q = Number(it.quantity) || 1;
1228
+ return (
1229
+ <tr key={it.id}>
1230
+ <td><div className="strong">{data.products.get(productId)?.name ?? productId}</div><div className="muted small">{money(unit, it.price?.currency)} / {it.price?.recurring?.interval ?? 'month'}</div></td>
1231
+ <td className="right">{q}</td>
1232
+ <td className="right">{money(unit, it.price?.currency)}</td>
1233
+ <td className="right">{money(unit * q, it.price?.currency)} {ccy(it.price?.currency)}</td>
1234
+ </tr>
1235
+ );
1236
+ })}
1237
+ </tbody>
1238
+ </table>
1239
+ </Section>
1240
+ <Section title="Invoices"><InvoicesTable invoices={data.invoices} go={go} noCustomer /></Section>
1241
+ <Section title="Events"><Timeline events={data.events} /></Section>
1242
+ </div>
1243
+ );
1244
+ }
1245
+
1246
+ type InvoiceFilter = 'all' | 'draft' | 'open' | 'paid' | 'void' | 'uncollectible';
1247
+ function InvoicesPage({ go }: { go: Go }) {
1248
+ const { data, error } = useApi(loadInvoices, []);
1249
+ const [filter, setFilter] = useState<InvoiceFilter>('all');
1250
+ const invoices = data?.invoices ?? [];
1251
+ const shown = invoices.filter((i) => filter === 'all' || i.status === filter);
1252
+ return (
1253
+ <div className="page">
1254
+ <PageHead title="Invoices" />
1255
+ <FilterCards<InvoiceFilter>
1256
+ value={filter} onChange={setFilter}
1257
+ options={(['all', 'draft', 'open', 'paid', 'void', 'uncollectible'] as InvoiceFilter[]).map((k) => ({ key: k, label: k === 'all' ? 'All invoices' : STATUS[k]!.label, count: k === 'all' ? invoices.length : invoices.filter((i) => i.status === k).length }))}
1258
+ />
1259
+ {data ? <><InvoicesTable invoices={shown} customers={data.customers} go={go} /><ResultCount n={shown.length} /></> : <Loading error={error} />}
1260
+ </div>
1261
+ );
1262
+ }
1263
+
1264
+ function InvoicePage({ id, go }: { id: string; go: Go }) {
1265
+ const { data, error } = useApi(async () => {
1266
+ const invoice = await retrieve(`/v1/invoices/${id}`);
1267
+ const customer = typeof invoice.customer === 'string' ? await retrieve(`/v1/customers/${invoice.customer}`).catch(() => undefined) : undefined;
1268
+ return { invoice, customer };
1269
+ }, [id]);
1270
+ if (!data) return <Loading error={error} />;
1271
+ const inv = data.invoice;
1272
+ const lines: StripeObject[] = inv.lines?.data ?? [];
1273
+ return (
1274
+ <div className="page detail-page">
1275
+ <PageHead kicker={<><Icon name="billing" size={12} /> Invoice</>} title={<><Amount amount={Number(inv.total ?? inv.amount_due) || 0} currency={inv.currency} /> <StatusPill value={inv.status} /></>}>
1276
+ <div className="summary">
1277
+ <div className="summary-item"><div className="summary-label">Invoice number</div><div className="mono">{inv.number || inv.id}</div></div>
1278
+ <div className="summary-item"><div className="summary-label">Customer</div><div>{data.customer ? <Link to={`customers/${data.customer.id}`} go={go}>{data.customer.email || data.customer.name}</Link> : inv.customer}</div></div>
1279
+ <div className="summary-item"><div className="summary-label">{inv.status === 'paid' ? 'Paid' : 'Due'}</div><div>{inv.status === 'paid' ? formatDate(inv.status_transitions?.paid_at) : inv.due_date ? formatDate(inv.due_date, false) : '—'}</div></div>
1280
+ </div>
1281
+ </PageHead>
1282
+ <Section title="Summary">
1283
+ <table className="table">
1284
+ <thead><tr><th>Description</th><th className="right">Qty</th><th className="right">Amount</th></tr></thead>
1285
+ <tbody>
1286
+ {lines.map((l) => <tr key={l.id}><td>{l.description || (l.price?.product ? `Subscription (${formatDate(l.period?.start, false)} – ${formatDate(l.period?.end, false)})` : '—')}</td><td className="right">{l.quantity ?? 1}</td><td className="right">{money(l.amount, l.currency)}</td></tr>)}
1287
+ <tr className="table-total"><td>Total</td><td /><td className="right strong">{money(inv.total, inv.currency)}</td></tr>
1288
+ <tr className="table-total"><td>Amount due</td><td /><td className="right strong">{money(inv.amount_remaining ?? inv.amount_due, inv.currency)}</td></tr>
1289
+ </tbody>
1290
+ </table>
1291
+ </Section>
1292
+ <Section title="Details">
1293
+ <Props rows={[
1294
+ ['ID', <span className="mono">{inv.id}</span>],
1295
+ ['Billing', inv.collection_method === 'send_invoice' ? 'Send invoice' : 'Charge automatically'],
1296
+ ...(inv.subscription ? [['Subscription', <Link to={`subscriptions/${inv.subscription}`} go={go}>{inv.subscription}</Link>] as [string, React.ReactNode]] : []),
1297
+ ...(inv.payment_intent ? [['Payment', <Link to={`payments/${inv.payment_intent}`} go={go}>{inv.payment_intent}</Link>] as [string, React.ReactNode]] : []),
1298
+ ['Created', formatDate(inv.created)],
1299
+ ]} />
1300
+ </Section>
1301
+ </div>
1302
+ );
1303
+ }
1304
+
1305
+ // ── Product catalog ──────────────────────────────────────────────────────────
1306
+ function ProductsPage({ go }: { go: Go }) {
1307
+ const { data, error, reload } = useApi(loadProducts, []);
1308
+ const [creating, setCreating] = useState(false);
1309
+ return (
1310
+ <div className="page">
1311
+ <PageHead title="Product catalog" actions={<button className="btn btn-primary" onClick={() => setCreating(true)}><Icon name="plus" size={14} /> Create product</button>}>
1312
+ <div className="tabs"><Link to="products" go={go} className="tab active">Products</Link><Link to="coupons" go={go} className="tab">Coupons</Link></div>
1313
+ </PageHead>
1314
+ {data ? (
1315
+ <>
1316
+ <table className="table">
1317
+ <thead><tr><th>Name</th><th>Pricing</th><th>Created</th><th className="right">Updated</th></tr></thead>
1318
+ <tbody>
1319
+ {data.products.length === 0 && <tr className="empty-row"><td colSpan={4}><EmptyState title="No products" /></td></tr>}
1320
+ {data.products.map((p) => {
1321
+ const prices = data.prices.filter((x) => x.product === p.id);
1322
+ const first = prices[prices.length - 1];
1323
+ return (
1324
+ <tr key={p.id}>
1325
+ <td><div className="product-cell"><span className="product-thumb">{p.images?.[0] ? <img src={p.images[0]} alt="" /> : <Icon name="catalog" />}</span><div><div className="strong">{p.name}</div>{p.description ? <div className="muted small">{p.description}</div> : null}</div></div></td>
1326
+ <td>{first ? <><span>{money(first.unit_amount, first.currency)} {ccy(first.currency)}{first.recurring ? ` / ${first.recurring.interval}` : ''}</span>{prices.length > 1 ? <div className="muted small">{prices.length} prices</div> : null}</> : <span className="muted">No prices</span>}</td>
1327
+ <td className="muted nowrap">{formatDate(p.created, false)}</td>
1328
+ <td className="right muted nowrap">{formatDate(p.updated ?? p.created, false)}</td>
1329
+ </tr>
1330
+ );
1331
+ })}
1332
+ </tbody>
1333
+ </table>
1334
+ <ResultCount n={data.products.length} />
1335
+ </>
1336
+ ) : <Loading error={error} />}
1337
+ {creating ? <CreateDialog collection="products" onClose={() => setCreating(false)} onCreated={() => { setCreating(false); reload(); }} /> : null}
1338
+ </div>
1339
+ );
1340
+ }
1341
+
1342
+ // ── Balances ─────────────────────────────────────────────────────────────────
1343
+ function BalancesPage({ go }: { go: Go }) {
1344
+ const { data, error } = useApi(loadBalances, []);
1345
+ if (!data) return <Loading error={error} />;
1346
+ const buckets = (k: string) => (data.balance[k] ?? []) as StripeObject[];
1347
+ const currencies = [...new Set([...buckets('available'), ...buckets('pending')].map((b) => b.currency))];
1348
+ return (
1349
+ <div className="page">
1350
+ <PageHead title="Balances" />
1351
+ <Section title="Balance summary">
1352
+ <BalanceSummary row={data.balance} />
1353
+ <table className="table">
1354
+ <thead><tr><th>Payments</th><th className="right">Amount</th></tr></thead>
1355
+ <tbody>
1356
+ {currencies.map((c) => {
1357
+ const pending = buckets('pending').find((b) => b.currency === c)?.amount ?? 0;
1358
+ const available = buckets('available').find((b) => b.currency === c)?.amount ?? 0;
1359
+ return (
1360
+ <React.Fragment key={c}>
1361
+ <tr><td>Incoming</td><td className="right">{money(pending, c)} {ccy(c)}</td></tr>
1362
+ <tr><td>Available to pay out</td><td className="right">{money(available, c)} {ccy(c)}</td></tr>
1363
+ <tr className="table-total"><td>Total</td><td className="right strong">{money(pending + available, c)} {ccy(c)}</td></tr>
1364
+ </React.Fragment>
1365
+ );
1366
+ })}
1367
+ </tbody>
1368
+ </table>
1369
+ </Section>
1370
+ <Section title="Recent activity" actions={<Link to="balance-transactions" go={go}>View all</Link>}>
1371
+ <ListPane section={SECTIONS.find((s) => s.key === 'balance_transactions')!} rows={data.transactions.slice(0, 10)} onSelect={() => {}} />
1372
+ </Section>
1373
+ <Section title="Payouts" actions={<Link to="payouts" go={go}>View all</Link>}>
1374
+ <ListPane section={SECTIONS.find((s) => s.key === 'payouts')!} rows={data.payouts.slice(0, 10)} onSelect={() => {}} />
1375
+ </Section>
1376
+ </div>
1377
+ );
1378
+ }
1379
+
1380
+ // ── Developers and Settings ──────────────────────────────────────────────────
1381
+ export function payoutScheduleText(account: StripeRow | undefined): string {
1382
+ const sched = (account?.settings as any)?.payouts?.schedule;
1383
+ if (!sched || typeof sched !== 'object') return 'Daily';
1384
+ const interval = String(sched.interval ?? 'daily');
1385
+ if (interval === 'weekly' && sched.weekly_anchor) return `Weekly on ${titleCase(String(sched.weekly_anchor))}`;
1386
+ if (interval === 'monthly' && sched.monthly_anchor != null) return `Monthly on day ${sched.monthly_anchor}`;
1387
+ return titleCase(interval);
1388
+ }
1389
+
1390
+ function DevelopersPage({ go }: { go: Go }) {
1391
+ const { data, error } = useApi(loadDevelopers, []);
1392
+ if (!data) return <Loading error={error} />;
1393
+ return (
1394
+ <div className="page">
1395
+ <PageHead title="Developers" />
1396
+ <section className="section settings-block" data-settings="api-keys">
1397
+ <div className="section-head"><h2>API keys</h2></div>
1398
+ <table className="table">
1399
+ <thead><tr><th>Name</th><th>Token</th><th className="right">Created</th></tr></thead>
1400
+ <tbody>
1401
+ <tr><td className="strong">Publishable key</td><td className="mono">pk_test_twin</td><td className="right muted">—</td></tr>
1402
+ <tr><td className="strong">Secret key</td><td className="mono">sk_test_••••twin</td><td className="right muted">—</td></tr>
1403
+ </tbody>
1404
+ </table>
1405
+ </section>
1406
+ <section className="section settings-block" data-settings="webhooks">
1407
+ <div className="section-head"><h2>Webhooks</h2></div>
1408
+ <table className="table">
1409
+ <thead><tr><th>Endpoint</th><th>Status</th><th className="right">Listening to</th></tr></thead>
1410
+ <tbody>
1411
+ {data.webhooks.length === 0 && <tr className="empty-row"><td colSpan={3}><EmptyState title="No webhook endpoints" /></td></tr>}
1412
+ {data.webhooks.map((w) => <tr key={w.id}><td className="mono">{w.url ?? w.id}</td><td><StatusPill value={w.status ?? 'enabled'} /></td><td className="right muted">{(w.enabled_events ?? []).length} events</td></tr>)}
1413
+ </tbody>
1414
+ </table>
1415
+ </section>
1416
+ <Section title="Events" actions={<Link to="events" go={go}>View all</Link>}>
1417
+ <table className="table">
1418
+ <thead><tr><th>Event</th><th>Type</th><th>Object</th><th className="right">Date</th></tr></thead>
1419
+ <tbody>
1420
+ {data.events.slice(0, 15).map((e) => (
1421
+ <tr key={e.id}><td>{eventText(e)}</td><td className="mono muted">{e.type}</td><td className="mono muted">{e.data?.object?.id}</td><td className="right muted nowrap">{formatDate(e.created)}</td></tr>
1422
+ ))}
1423
+ </tbody>
1424
+ </table>
1425
+ </Section>
1426
+ </div>
1427
+ );
1428
+ }
1429
+
1430
+ function SettingsPage() {
1431
+ const { data, error } = useApi(loadDevelopers, []);
1432
+ if (!data) return <Loading error={error} />;
1433
+ const account = data.account;
1434
+ return (
1435
+ <div className="page">
1436
+ <PageHead title="Settings" />
1437
+ <section className="section settings-block" data-settings="account">
1438
+ <div className="section-head"><h2>Business details</h2></div>
1439
+ <Props rows={[
1440
+ ['Account ID', <span className="mono">{account?.id ?? '—'}</span>],
1441
+ ['Account name', account?.settings?.dashboard?.display_name ?? PLATFORM_DEFAULT_NAME],
1442
+ ['Country', (account?.country as string) ?? '—'],
1443
+ ['Default currency', String((account?.default_currency as string) ?? 'usd').toUpperCase()],
1444
+ ]} />
1445
+ </section>
1446
+ <section className="section settings-block" data-settings="public-details">
1447
+ <div className="section-head"><h2>Public details</h2><a className="btn" href={`${API_BASE}/settings/public`}>Edit</a></div>
1448
+ <Props rows={[['Business name', <span className="settings-public-name">{publicBusinessName(account)}</span>]]} />
1449
+ </section>
1450
+ <section className="section settings-block" data-settings="payouts">
1451
+ <div className="section-head"><h2>Payouts</h2></div>
1452
+ <Props rows={[['Payout schedule', <span className="settings-payout-schedule">{payoutScheduleText(account)}</span>]]} />
1453
+ </section>
1454
+ </div>
1455
+ );
1456
+ }
1457
+
1458
+ function ResourcePage({ section, go }: { section: Section; go: Go }) {
1459
+ const { data, error } = useApi(() => list(`/v1/${section.key}`), [section.key]);
1460
+ const account = section.key === 'accounts' ? data?.[0] : undefined;
1461
+ const tabs = section.path.startsWith('radar/') ? ['radar/reviews', 'radar/lists']
1462
+ : section.path.startsWith('terminal/') ? ['terminal/readers', 'terminal/locations']
1463
+ : section.path.startsWith('issuing/') ? ['issuing/cards', 'issuing/cardholders']
1464
+ : section.path.startsWith('connect/') ? ['connect/accounts', 'connect/transfers']
1465
+ : section.key === 'payouts' || section.key === 'balance_transactions' ? ['payouts', 'balance-transactions'] : [];
1466
+ const title = section.path.startsWith('radar/') ? 'Radar' : section.path.startsWith('terminal/') ? 'Terminal' : section.path.startsWith('issuing/') ? 'Issuing'
1467
+ : section.path.startsWith('connect/') ? 'Connect' : section.key === 'tax_rates' ? 'Tax' : section.label;
1468
+ return (
1469
+ <div className="page">
1470
+ <PageHead title={title}>
1471
+ {tabs.length ? <div className="tabs">{tabs.map((t) => <Link key={t} to={t} go={go} className={`tab ${t === section.path ? 'active' : ''}`}>{SECTION_BY_PATH.get(t)!.label.replace(/^(Radar|Terminal|Issuing) /, '')}</Link>)}</div> : null}
1472
+ </PageHead>
1473
+ {account ? <ConnectAccountPanel row={account} /> : null}
1474
+ {data ? <><ListPane section={section} rows={data} onSelect={() => {}} /><ResultCount n={data.length} /></> : <Loading error={error} />}
1475
+ </div>
1476
+ );
1477
+ }
1478
+
1479
+ // ── the app ──────────────────────────────────────────────────────────────────
1480
+ export function Page({ route, search, go }: { route: string[]; search: string; go: Go }) {
1481
+ const [head, id] = route;
1482
+ const joined = route.join('/');
1483
+ if (!head || head === 'dashboard') return <HomeScreen go={go} />;
1484
+ if (head === 'payments') return id ? <PaymentPage id={id} go={go} /> : <PaymentsPage go={go} />;
1485
+ if (head === 'customers') return id ? <CustomerPage id={id} go={go} /> : <CustomersPage go={go} create={new URLSearchParams(search).has('create')} />;
1486
+ if (head === 'subscriptions') return id ? <SubscriptionPage id={id} go={go} /> : <SubscriptionsPage go={go} />;
1487
+ if (head === 'invoices') return id ? <InvoicePage id={id} go={go} /> : <InvoicesPage go={go} />;
1488
+ if (head === 'products') return <ProductsPage go={go} />;
1489
+ if (head === 'balance') return <BalancesPage go={go} />;
1490
+ if (head === 'developers') return <DevelopersPage go={go} />;
1491
+ if (head === 'settings') return <SettingsPage />;
1492
+ const section = SECTION_BY_PATH.get(joined) ?? SECTION_BY_PATH.get(head);
1493
+ if (section) return <ResourcePage key={section.key} section={section} go={go} />;
1494
+ return <div className="page"><EmptyState title="Page not found" body="This page doesn’t exist in the Dashboard." /></div>;
1495
+ }
1496
+
1497
+ export function App() {
1498
+ const [loc, setLoc] = useState(() => ({ path: window.location.pathname, search: window.location.search }));
1499
+ useEffect(() => {
1500
+ const onPop = () => setLoc({ path: window.location.pathname, search: window.location.search });
1501
+ window.addEventListener('popstate', onPop);
1502
+ return () => window.removeEventListener('popstate', onPop);
1503
+ }, []);
1504
+ const go: Go = useCallback((to) => {
1505
+ const href = hrefOf(to);
1506
+ window.history.pushState(null, '', href);
1507
+ const u = new URL(href, window.location.origin);
1508
+ setLoc({ path: u.pathname, search: u.search });
1509
+ window.scrollTo(0, 0);
1510
+ }, []);
1511
+ const route = routeOf(loc.path);
1512
+ useEffect(() => {
1513
+ if (route.length === 0) window.history.replaceState(null, '', hrefOf('dashboard'));
1514
+ const [head, id] = route;
1515
+ const names: Record<string, string> = { dashboard: 'Home', payments: 'Payments', customers: 'Customers', subscriptions: 'Subscriptions', invoices: 'Invoices', products: 'Product catalog', balance: 'Balances', developers: 'Developers', settings: 'Settings' };
1516
+ document.title = `${id ? `${id} – ` : ''}${names[head ?? 'dashboard'] ?? SECTION_BY_PATH.get(route.join('/'))?.label ?? 'Dashboard'} – Stripe`;
1517
+ }, [loc.path]);
1518
+ return (
1519
+ <div className="dash">
1520
+ <TestModeBand />
1521
+ <div className="frame">
1522
+ <SideNav route={route} go={go} />
1523
+ <div className="main">
1524
+ <TopBar go={go} />
1525
+ <main className="content"><Page route={route} search={loc.search} go={go} /></main>
1526
+ </div>
1527
+ </div>
1528
+ </div>
1529
+ );
1530
+ }
1531
+
1532
+ if (typeof document !== 'undefined' && document.getElementById('root')) {
1533
+ createRoot(document.getElementById('root')!).render(<App />);
1534
+ }