@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
@@ -1,130 +1,171 @@
1
- // Stripe UI mirror — React/TSX client (Bun-bundled). A Stripe-dashboard-style view
2
- // in the shared world UI-mirror design language: top bar + left nav + list/detail
3
- // split. Renders by consuming the twin's own REST API (/v1/<collection>).
4
- //
5
- // The pure format/resolution helpers live in ../src/stripe-mirror-ui.ts so they can
6
- // be unit-tested AND shared here (Bun tree-shakes the server-only exports out of
7
- // this browser bundle).
8
- import React, { useEffect, useMemo, useState } from 'react';
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';
9
7
  import { createRoot } from 'react-dom/client';
10
8
  import {
11
- formatStripeAmount, formatRecurring, statusTone, flattenStripeValue,
12
- resolveCrossRefs, referenceCollection, formatPaymentMethod, productImageUrls,
13
- formatPaymentError, formatBalanceSummary, formatAccountFlags, accountCurrentlyDue,
14
- type StripeRow, type FlatLine, type CrossRefs,
15
- } from '../src/stripe-mirror-ui.ts';
16
-
17
- export type Section = { key: string; label: string; title: (r: StripeRow) => string; subtitle: (r: StripeRow) => string };
18
-
19
- export const SECTIONS: Section[] = [
20
- { key: 'customers', label: 'Customers', title: (r) => r.name || r.email || r.id, subtitle: (r) => r.email || r.id },
21
- { key: 'payment_intents', label: 'Payments', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${r.status} · ${r.id}` },
22
- { key: 'setup_intents', label: 'Setup Intents', title: (r) => r.id, subtitle: (r) => `${r.status ?? ''} · ${r.usage ?? ''}` },
23
- { key: 'subscriptions', label: 'Subscriptions', title: (r) => r.id, subtitle: (r) => `${r.status} · ${r.customer ?? ''}` },
24
- { key: 'invoices', label: 'Invoices', title: (r) => `${formatStripeAmount(r.total ?? r.amount_due, r.currency)}`, subtitle: (r) => `${r.status} · ${r.id}` },
25
- { key: 'invoiceitems', label: 'Invoice Items', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${r.customer ?? ''} · ${r.id}` },
26
- { key: 'credit_notes', label: 'Credit Notes', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${r.status ?? ''} · ${r.invoice ?? r.id}` },
27
- { key: 'products', label: 'Products', title: (r) => r.name || r.id, subtitle: (r) => r.id },
28
- { key: 'prices', label: 'Prices', title: (r) => `${formatStripeAmount(r.unit_amount, r.currency)}${formatRecurring(r.recurring) ? ` ${formatRecurring(r.recurring)}` : ''}`, subtitle: (r) => r.product ?? r.id },
29
- // --- billing & commerce (coupons, promotion codes, payment links, quotes) ---
30
- { key: 'coupons', label: 'Coupons', title: (r) => r.name || (r.percent_off != null ? `${r.percent_off}% off` : `${formatStripeAmount(r.amount_off, r.currency)} off`), subtitle: (r) => `${r.duration ?? ''} · ${r.id}` },
31
- { key: 'promotion_codes', label: 'Promotion Codes', title: (r) => r.code || r.id, subtitle: (r) => `${r.active ? 'active' : 'inactive'} · ${r.coupon ?? ''}` },
32
- { key: 'payment_links', label: 'Payment Links', title: (r) => r.url || r.id, subtitle: (r) => `${r.active ? 'active' : 'inactive'} · ${r.id}` },
33
- { key: 'quotes', label: 'Quotes', title: (r) => formatStripeAmount(r.amount_total, r.currency), subtitle: (r) => `${r.status ?? ''} · ${r.customer ?? r.id}` },
34
- { key: 'charges', label: 'Charges', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${r.status} · ${r.id}` },
35
- { key: 'refunds', label: 'Refunds', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${r.status ?? ''} · ${r.charge ?? r.id}` },
36
- { key: 'payment_methods', label: 'Payment Methods', title: (r) => formatPaymentMethod(r), subtitle: (r) => `${r.type ?? ''} · ${r.id}` },
37
- { key: 'disputes', label: 'Disputes', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${r.reason ?? ''} · ${r.status ?? ''}` },
38
- { key: 'payouts', label: 'Payouts', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${r.status ?? ''} · ${r.id}` },
39
- { key: 'balance_transactions', label: 'Balance Transactions', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `${r.type ?? ''} · ${r.id}` },
40
- { key: 'balance', label: 'Balance', title: (r) => r.id, subtitle: (r) => 'account balance summary' },
41
- // --- Connect (connected accounts + transfers) ---
42
- { key: 'accounts', label: 'Connected Accounts', title: (r) => r.email || r.id, subtitle: (r) => `${r.type ?? 'account'} · ${r.country ?? ''}` },
43
- { key: 'transfers', label: 'Transfers', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `→ ${r.destination ?? '?'}` },
44
- // --- customer billing (tax IDs + credit-balance ledger) ---
45
- { key: 'tax_ids', label: 'Customer Tax IDs', title: (r) => `${r.value ?? r.id}`, subtitle: (r) => `${r.type ?? ''} · ${r.customer ?? ''}` },
46
- { key: 'customer_balance_transactions', label: 'Customer Balance', title: (r) => formatStripeAmount(r.amount, r.currency), subtitle: (r) => `bal ${formatStripeAmount(r.ending_balance, r.currency)} · ${r.customer ?? ''}` },
47
- // --- Stripe Tax (tax rates + calculations) ---
48
- { key: 'tax_rates', label: 'Tax Rates', title: (r) => `${r.display_name ?? 'Tax'} · ${r.percentage ?? 0}%`, subtitle: (r) => `${r.inclusive ? 'inclusive' : 'exclusive'} · ${r.jurisdiction ?? r.id}` },
49
- { key: 'tax/calculations', label: 'Tax Calculations', title: (r) => formatStripeAmount(r.amount_total, r.currency), subtitle: (r) => `tax ${formatStripeAmount(r.tax_amount_exclusive, r.currency)} · ${r.id}` },
50
- { key: 'events', label: 'Events', title: (r) => r.type || r.id, subtitle: (r) => r.id },
51
- { key: 'webhook_endpoints', label: 'Webhook Endpoints', title: (r) => r.url || r.id, subtitle: (r) => `${r.status ?? ''} · ${r.id}` },
52
- // --- Radar (fraud prevention): reviews, value lists, custom rules ---
53
- { key: 'reviews', label: 'Radar Reviews', title: (r) => r.charge || r.payment_intent || r.id, subtitle: (r) => `${r.open ? 'open' : (r.closed_reason ?? 'closed')} · ${r.id}` },
54
- { key: 'radar/value_lists', label: 'Radar Value Lists', title: (r) => r.name || r.alias || r.id, subtitle: (r) => `${r.item_type ?? ''} · ${r.alias ?? r.id}` },
55
- { key: 'radar/rules', label: 'Radar Rules', title: (r) => r.predicate || r.id, subtitle: (r) => `${r.action ?? ''} · ${r.id}` },
56
- // --- Terminal (in-person) + Issuing (card issuing) screens ---
57
- { key: 'terminal/readers', label: 'Terminal Readers', title: (r) => r.label || r.id, subtitle: (r) => `${r.status ?? ''} · ${r.device_type ?? ''}` },
58
- { key: 'terminal/locations', label: 'Terminal Locations', title: (r) => r.display_name || r.id, subtitle: (r) => `${(r.address as any)?.city ?? ''} · ${r.id}` },
59
- { key: 'issuing/cards', label: 'Issuing Cards', title: (r) => `•••• ${r.last4 ?? ''}`, subtitle: (r) => `${r.status ?? ''} · ${r.type ?? ''}` },
60
- { key: 'issuing/cardholders', label: 'Issuing Cardholders', title: (r) => r.name || r.id, subtitle: (r) => `${r.type ?? ''} · ${r.status ?? ''}` },
61
- // --- Reporting (financial reports / Sigma report runs) ---
62
- { key: 'reporting/report_runs', label: 'Reports', title: (r) => r.report_type || r.id, subtitle: (r) => `${r.status ?? ''} · ${r.id}` },
63
- ];
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';
64
17
 
65
- // `balance` is the one section backed by a synthesized summary (GET /v1/balance), not a
66
- // list collection; the fetch loop special-cases it (no `data` array).
67
- export const BALANCE_KEY = 'balance';
68
- const SECTION_BY_KEY: Record<string, Section> = Object.fromEntries(SECTIONS.map((s) => [s.key, s]));
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;
69
27
 
70
- export function StatusPill({ value }: { value: any }) {
71
- const v = String(value ?? '');
72
- return <span className={`pill ${statusTone(v)}`}>{v.replace(/_/g, ' ') || '—'}</span>;
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}`;
73
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 : '—');
74
45
 
75
- /** A clickable id reference that jumps to its collection + selects the row. */
76
- export function RefChip({ collection, id, label, onJump }: { collection: string; id: string; label: string; onJump: (c: string, id: string) => void }) {
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
77
  return (
78
- <button className="ref-chip" title={id} onClick={() => onJump(collection, id)}>
79
- <span className="ref-label">{label}</span>
80
- <span className="ref-id mono">{id}</span>
81
- </button>
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>
82
81
  );
83
82
  }
84
83
 
85
- /** Render the flattened nested-field lines (replaces the old "[object Object]"). */
86
- export function NestedLines({ lines, onJump }: { lines: FlatLine[]; onJump: (c: string, id: string) => void }) {
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 };
87
136
  return (
88
- <div className="nested">
89
- {lines.map((ln, i) => (
90
- <div className="nested-line" key={i} style={{ paddingLeft: `${ln.depth * 14}px` }}>
91
- {ln.label ? <span className="nested-label">{ln.label}</span> : null}
92
- {ln.ref ? (
93
- <button className="ref-inline mono" title={ln.value} onClick={() => onJump(referenceCollection(ln.label) ?? '', ln.value)}>{ln.value}</button>
94
- ) : ln.value ? (
95
- <span className="nested-value">{ln.value}</span>
96
- ) : null}
97
- </div>
98
- ))}
99
- </div>
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>
100
140
  );
101
141
  }
102
142
 
103
- /**
104
- * The test-card decline state (payment_intent / charge `last_payment_error`) rendered as
105
- * a prominent error banner — this is the vendor-faithful decline reason, not just another
106
- * nested field, so the mirror surfaces it like a real Stripe payment-failed screen.
107
- */
143
+ /** A declined payment's reason, as Stripe states it above the payment. */
108
144
  export function PaymentErrorBanner({ error }: { error: any }) {
109
145
  const text = formatPaymentError(error);
110
146
  if (!text) return null;
147
+ const message = typeof error?.message === 'string' ? error.message : text;
111
148
  return (
112
149
  <div className="pay-error" role="alert">
113
- <span className="pay-error-label">Payment error</span>
114
- <span className="pay-error-text">{text}</span>
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>
115
155
  </div>
116
156
  );
117
157
  }
118
158
 
119
- /** The synthesized account balance summary (available / pending per currency). */
159
+ /** The balance's per-currency buckets: incoming (pending) and available. */
120
160
  export function BalanceSummary({ row }: { row: StripeRow }) {
121
161
  const buckets = formatBalanceSummary(row);
122
162
  if (buckets.length === 0) return null;
163
+ const label = (b: string) => (b === 'pending' ? 'Incoming' : 'Available');
123
164
  return (
124
165
  <div className="balance-summary">
125
- {buckets.map((b) => (
166
+ {[...buckets].reverse().map((b) => (
126
167
  <div className={`balance-bucket ${b.bucket}`} key={b.bucket}>
127
- <span className="balance-bucket-label">{b.bucket}</span>
168
+ <span className="balance-bucket-label">{label(b.bucket)}</span>
128
169
  <span className="balance-bucket-amount">{b.text}</span>
129
170
  </div>
130
171
  ))}
@@ -132,12 +173,7 @@ export function BalanceSummary({ row }: { row: StripeRow }) {
132
173
  );
133
174
  }
134
175
 
135
- /**
136
- * Connect connected-account panel: the three enablement flags (charges/payouts/details)
137
- * a real Stripe Connect dashboard shows up front, plus the outstanding onboarding
138
- * requirements (`currently_due`). Surfaces the account's readiness like Stripe does,
139
- * instead of leaving these buried in the generic field grid.
140
- */
176
+ /** A connected account's readiness: charges, payouts, details, and what is currently due. */
141
177
  export function ConnectAccountPanel({ row }: { row: StripeRow }) {
142
178
  const flags = formatAccountFlags(row);
143
179
  const due = accountCurrentlyDue(row);
@@ -148,544 +184,1351 @@ export function ConnectAccountPanel({ row }: { row: StripeRow }) {
148
184
  {flags.map((f) => (
149
185
  <div className={`connect-flag ${f.enabled ? 'ok' : 'warn'}`} key={f.key}>
150
186
  <span className="connect-flag-label">{f.label}</span>
151
- <span className="connect-flag-state">{f.enabled ? 'enabled' : 'disabled'}</span>
187
+ <StatusPill value={f.enabled ? 'enabled' : 'disabled'} />
152
188
  </div>
153
189
  ))}
154
190
  </div>
155
191
  {due.length > 0 && (
156
192
  <div className="connect-requirements" role="status">
157
193
  <span className="connect-req-label">Requirements currently due</span>
158
- <ul className="connect-req-list">
159
- {due.map((d) => <li className="connect-req-item mono" key={d}>{d}</li>)}
160
- </ul>
194
+ <ul className="connect-req-list">{due.map((d) => <li className="connect-req-item mono" key={d}>{d}</li>)}</ul>
161
195
  </div>
162
196
  )}
163
197
  </div>
164
198
  );
165
199
  }
166
200
 
167
- /**
168
- * Resource action bar — the lifecycle verbs a real Stripe dashboard exposes on a detail
169
- * (Capture / Cancel an authorized PaymentIntent, etc.). Each button POSTs the real twin
170
- * sub-action endpoint (e.g. /v1/payment_intents/:id/capture); the polling loop then
171
- * reflects the new status. Kept faithful: a Capture only shows for a requires_capture PI,
172
- * a Cancel only for a still-cancelable one — matching when Stripe actually allows them.
173
- */
174
- export function detailActions(collection: string, row: StripeRow): Array<{ verb: string; label: string }> {
175
- const actions: Array<{ verb: string; label: string }> = [];
176
- if (collection === 'payment_intents') {
177
- if (row.status === 'requires_capture') actions.push({ verb: 'capture', label: 'Capture' });
178
- if (row.status && !['succeeded', 'canceled'].includes(String(row.status))) actions.push({ verb: 'cancel', label: 'Cancel' });
179
- }
180
- return actions;
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
+ );
181
262
  }
182
263
 
183
- export function DetailActions({ collection, row, onDone }: { collection: string; row: StripeRow; onDone: () => void }) {
184
- const [busy, setBusy] = useState<string | null>(null);
185
- const [err, setErr] = useState<string | null>(null);
186
- const actions = detailActions(collection, row);
187
- if (actions.length === 0) return null;
188
- const run = async (verb: string) => {
189
- setBusy(verb); setErr(null);
190
- try {
191
- const res = await fetch(`/v1/${collection}/${row.id}/${verb}`, { method: 'POST' }).then((r) => r.json());
192
- if (res.error) throw new Error(res.error.message);
193
- onDone();
194
- } catch (e: any) {
195
- setErr(String(e.message ?? e));
196
- } finally {
197
- setBusy(null);
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) });
198
274
  }
199
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(); };
200
305
  return (
201
- <div className="detail-actions">
202
- {actions.map((a) => (
203
- <button key={a.verb} className={`action-btn action-${a.verb}`} disabled={busy !== null} onClick={() => run(a.verb)}>
204
- {busy === a.verb ? `${a.label}…` : a.label}
205
- </button>
206
- ))}
207
- {err && <span className="action-error" role="alert">{err}</span>}
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}
208
329
  </div>
209
330
  );
210
331
  }
211
332
 
212
- export function Detail({ collection, row, refs, onJump, onAction }: { collection: string; row?: StripeRow; refs: CrossRefs; onJump: (c: string, id: string) => void; onAction?: () => void }) {
213
- if (!row) return <aside className="detail empty">Select a row to inspect it.</aside>;
214
- const ccy = typeof row.currency === 'string' ? row.currency : undefined;
215
- // Split scalar fields (a plain key/value grid) from nested objects/arrays (recursive).
216
- // `last_payment_error` is lifted out into a prominent banner (below) rather than buried
217
- // in the generic nested list, so the decline reason reads like a real payment screen.
218
- const scalars: Array<[string, any]> = [];
219
- const nested: Array<[string, any]> = [];
220
- for (const [k, v] of Object.entries(row)) {
221
- if (v === null || v === undefined || k === 'object' || k === 'last_payment_error') continue;
222
- (typeof v === 'object' ? nested : scalars).push([k, v]);
223
- }
224
- return (
225
- <aside className="detail">
226
- <div className="detail-head">
227
- <span className="mono">{row.id}</span>
228
- {row.status ? <StatusPill value={row.status} /> : row.active !== undefined ? <StatusPill value={String(row.active)} /> : null}
229
- </div>
230
-
231
- <DetailActions collection={collection} row={row} onDone={() => onAction?.()} />
232
- <PaymentErrorBanner error={row.last_payment_error} />
233
- {collection === BALANCE_KEY ? <BalanceSummary row={row} /> : null}
234
- {collection === 'accounts' ? <ConnectAccountPanel row={row} /> : null}
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
+ };
235
353
 
236
- <dl>
237
- {scalars.map(([k, v]) => {
238
- const ref = typeof v === 'string' && referenceCollection(k);
239
- const isMoney = k === 'amount' || k === 'unit_amount' || /amount|total|subtotal|balance/.test(k);
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));
240
376
  return (
241
- <div className="kv" key={k}>
242
- <dt>{k}</dt>
243
- <dd className={k === 'id' || String(k).endsWith('_id') ? 'mono' : ''}>
244
- {ref ? (
245
- <button className="ref-inline mono" onClick={() => onJump(ref, v)}>{v}</button>
246
- ) : isMoney && typeof v === 'number' ? formatStripeAmount(v, ccy) : String(v)}
247
- </dd>
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}
248
382
  </div>
249
383
  );
250
384
  })}
251
- </dl>
252
-
253
- {nested.map(([k, v]) => {
254
- const thumbs = collection === 'products' && k === 'images' ? productImageUrls(v) : [];
255
- return (
256
- <div className="detail-block" key={k}>
257
- <h4>{k}</h4>
258
- {thumbs.length > 0 ? (
259
- <div className="thumbs">
260
- {thumbs.map((src, i) => (
261
- <a className="thumb" key={i} href={src} target="_blank" rel="noreferrer" title={src}>
262
- <img src={src} alt={`${row.name ?? row.id} image ${i + 1}`} loading="lazy" />
263
- </a>
264
- ))}
265
- </div>
266
- ) : (
267
- <NestedLines lines={flattenStripeValue(v, { currency: ccy })} onJump={onJump} />
268
- )}
269
- </div>
270
- );
271
- })}
272
-
273
- {(refs.outgoing.length > 0 || refs.incoming.length > 0) && (
274
- <div className="detail-block refs">
275
- <h4>Related</h4>
276
- {refs.outgoing.length > 0 && (
277
- <div className="ref-group">
278
- <span className="ref-group-label">References</span>
279
- {refs.outgoing.map((r, i) => <RefChip key={`o${i}`} {...r} onJump={onJump} />)}
280
- </div>
281
- )}
282
- {refs.incoming.length > 0 && (
283
- <div className="ref-group">
284
- <span className="ref-group-label">Referenced by</span>
285
- {refs.incoming.map((r, i) => <RefChip key={`i${i}`} {...r} onJump={onJump} />)}
286
- </div>
287
- )}
288
- </div>
289
- )}
290
- </aside>
385
+ </div>
386
+ <div className="nav-foot">{link({ to: 'developers', label: 'Developers', icon: 'developers', match: ['developers', 'events', 'webhooks'] })}</div>
387
+ </nav>
291
388
  );
292
389
  }
293
390
 
294
- /** The left navigation: one item per collection (section), each with a live count. */
295
- export function SideNav({ sections, counts, activeKey, onSelect, onHome, onSettings }: {
296
- sections: Section[];
297
- counts: Record<string, number>;
298
- activeKey: string;
299
- onSelect: (key: string) => void;
300
- onHome?: () => void;
301
- onSettings?: () => void;
302
- }) {
391
+ function TopBar({ go }: { go: Go }) {
303
392
  return (
304
- <nav className="side">
305
- <button className={`nav-home ${activeKey === '__home' ? 'active' : ''}`} onClick={onHome}>
306
- <span>Home</span>
307
- </button>
308
- {sections.map((s) => (
309
- <button key={s.key} className={s.key === activeKey ? 'active' : ''} onClick={() => onSelect(s.key)}>
310
- <span>{s.label}</span>
311
- <span className="nav-count">{counts[s.key] ?? 0}</span>
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>
312
452
  </button>
313
453
  ))}
314
- <button className={`nav-settings ${activeKey === '__settings' ? 'active' : ''}`} onClick={onSettings}>
315
- <span>Settings</span>
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}
316
466
  </button>
317
- </nav>
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>
318
478
  );
319
479
  }
320
480
 
321
- // ── Home / overview screen ──────────────────────────────────────────────────
322
- // The real Stripe Dashboard Home leads with top-line metrics. We compute them from the
323
- // SAME twin state the rest of the dashboard renders (no separate data source): gross
324
- // volume from succeeded payments, the available balance, and live counts. Data-coupled:
325
- // every number here is derived from the fetched /v1/<collection> projections.
326
- export function computeHomeMetrics(data: Record<string, StripeRow[]>): {
327
- grossVolume: number; currency: string; succeededPayments: number;
328
- activeSubscriptions: number; customers: number; openInvoices: number; availableText: string;
329
- } {
330
- const pis = data.payment_intents ?? [];
331
- const succeeded = pis.filter((p) => p.status === 'succeeded');
332
- const currency = (succeeded[0]?.currency as string) || (pis[0]?.currency as string) || 'usd';
333
- const grossVolume = succeeded.reduce((s, p) => s + (Number(p.amount_received ?? p.amount) || 0), 0);
334
- const subs = data.subscriptions ?? [];
335
- const invoices = data.invoices ?? [];
336
- const balanceRow = (data.balance ?? [])[0];
337
- const buckets = formatBalanceSummary(balanceRow);
338
- const availableText = buckets.find((b) => b.bucket === 'available')?.text ?? formatStripeAmount(0, currency);
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;
339
530
  return {
340
- grossVolume, currency,
341
- succeededPayments: succeeded.length,
342
- activeSubscriptions: subs.filter((s) => s.status === 'active' || s.status === 'trialing').length,
343
- customers: (data.customers ?? []).length,
344
- openInvoices: invoices.filter((i) => i.status === 'open').length,
345
- availableText,
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,
346
537
  };
347
538
  }
348
-
349
- export function HomeScreen({ data, onJump }: { data: Record<string, StripeRow[]>; onJump: (c: string, id: string) => void }) {
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} />;
350
560
  const m = computeHomeMetrics(data);
351
- const cards = [
352
- { key: 'gross', label: 'Gross volume', value: formatStripeAmount(m.grossVolume, m.currency) },
353
- { key: 'available', label: 'Available balance', value: m.availableText },
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) },
354
565
  { key: 'payments', label: 'Successful payments', value: String(m.succeededPayments) },
355
- { key: 'subs', label: 'Active subscriptions', value: String(m.activeSubscriptions) },
356
- { key: 'customers', label: 'Customers', value: String(m.customers) },
357
- { key: 'open_invoices', label: 'Open invoices', value: String(m.openInvoices) },
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) },
358
569
  ];
359
- const recent = (data.payment_intents ?? []).slice(0, 5);
360
570
  return (
361
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>
362
594
  <div className="home-metrics">
363
- {cards.map((c) => (
595
+ {overview.map((c) => (
364
596
  <div className="metric-card" key={c.key} data-metric={c.key}>
365
597
  <span className="metric-label">{c.label}</span>
366
598
  <span className="metric-value">{c.value}</span>
367
599
  </div>
368
600
  ))}
369
601
  </div>
370
- <div className="home-recent">
371
- <h3>Recent payments</h3>
372
- {recent.length === 0 && <div className="empty">No payments yet.</div>}
373
- {recent.map((p) => (
374
- <button className="home-recent-row" key={p.id} onClick={() => onJump('payment_intents', p.id)}>
375
- <span className="row-title">{formatStripeAmount(p.amount, p.currency)}</span>
376
- <StatusPill value={p.status} />
377
- <span className="row-sub mono">{p.id}</span>
378
- </button>
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>
379
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>
380
686
  </div>
687
+ {data ? <><PaymentsTable payments={shown} go={go} /><ResultCount n={shown.length} /></> : <Loading error={error} />}
381
688
  </div>
382
689
  );
383
690
  }
384
691
 
385
- // ── Global search / command bar ─────────────────────────────────────────────
386
- // Stripe's Cmd-K command bar searches ACROSS every object type. We search the SAME cached
387
- // collection data (id / email / name / status / amount substring) and return ranked,
388
- // cross-collection hits — data-coupled to the twin's real state.
389
- export type SearchHit = { collection: string; id: string; label: string; sub: string };
390
- export function globalSearch(data: Record<string, StripeRow[]>, query: string, limit = 20): SearchHit[] {
391
- const q = query.trim().toLowerCase();
392
- if (!q) return [];
393
- const hits: SearchHit[] = [];
394
- for (const section of SECTIONS) {
395
- for (const row of data[section.key] ?? []) {
396
- const haystack = [row.id, row.email, row.name, row.status, section.title(row), section.subtitle(row)]
397
- .map((f) => String(f ?? '').toLowerCase());
398
- if (haystack.some((f) => f.includes(q))) {
399
- hits.push({ collection: section.key, id: row.id, label: section.title(row), sub: `${section.label} · ${row.id}` });
400
- if (hits.length >= limit) return hits;
401
- }
402
- }
403
- }
404
- return hits;
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
+ );
405
738
  }
406
739
 
407
- export function CommandBar({ data, onJump, onClose }: { data: Record<string, StripeRow[]>; onJump: (c: string, id: string) => void; onClose: () => void }) {
408
- const [q, setQ] = useState('');
409
- const hits = useMemo(() => globalSearch(data, q), [data, q]);
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} />;
410
744
  return (
411
- <div className="cmdk-overlay" role="dialog" aria-label="Search" onClick={onClose}>
412
- <div className="cmdk" onClick={(e) => e.stopPropagation()}>
413
- <input className="cmdk-input" autoFocus placeholder="Search customers, payments, invoices…" value={q} onChange={(e) => setQ(e.target.value)} />
414
- <div className="cmdk-results">
415
- {q && hits.length === 0 && <div className="cmdk-empty">No results for “{q}”.</div>}
416
- {hits.map((h) => (
417
- <button className="cmdk-hit" key={`${h.collection}:${h.id}`} onClick={() => { onJump(h.collection, h.id); onClose(); }}>
418
- <span className="cmdk-hit-label">{h.label}</span>
419
- <span className="cmdk-hit-sub mono">{h.sub}</span>
420
- </button>
421
- ))}
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}
422
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>
423
831
  </div>
424
832
  </div>
425
833
  );
426
834
  }
427
835
 
428
- // ── Settings screen (account / API keys / webhooks) ─────────────────────────
429
- // Backed by real twin state: the account (GET /v1/account, incl. the payout schedule) and
430
- // the registered webhook endpoints (GET /v1/webhook_endpoints). The API keys block shows
431
- // the twin's test-mode keys (it is a test-mode twin) — labeled as such, not fabricated live keys.
432
- export function payoutScheduleText(account: StripeRow | undefined): string {
433
- const sched = (account?.settings as any)?.payouts?.schedule;
434
- if (!sched || typeof sched !== 'object') return 'default';
435
- const interval = String(sched.interval ?? 'daily');
436
- if (interval === 'weekly' && sched.weekly_anchor) return `weekly (on ${sched.weekly_anchor})`;
437
- if (interval === 'monthly' && sched.monthly_anchor != null) return `monthly (on day ${sched.monthly_anchor})`;
438
- return interval;
439
- }
440
-
441
- export function SettingsScreen({ account, webhooks }: { account?: StripeRow; webhooks: StripeRow[] }) {
442
- return (
443
- <div className="settings-screen">
444
- <section className="settings-block" data-settings="account">
445
- <h3>Account</h3>
446
- <dl>
447
- <div className="kv"><dt>Account ID</dt><dd className="mono">{account?.id ?? '—'}</dd></div>
448
- <div className="kv"><dt>Country</dt><dd>{(account?.country as string) ?? '—'}</dd></div>
449
- <div className="kv"><dt>Default currency</dt><dd>{String((account?.default_currency as string) ?? '—').toUpperCase()}</dd></div>
450
- <div className="kv"><dt>Payout schedule</dt><dd className="settings-payout-schedule">{payoutScheduleText(account)}</dd></div>
451
- <div className="kv"><dt>Charges enabled</dt><dd>{String(account?.charges_enabled ?? false)}</dd></div>
452
- </dl>
453
- </section>
454
- <section className="settings-block" data-settings="api-keys">
455
- <h3>API keys</h3>
456
- <div className="api-key-row">
457
- <span className="api-key-label">Publishable key (test)</span>
458
- <span className="api-key-value mono">pk_test_twin</span>
459
- </div>
460
- <div className="api-key-row">
461
- <span className="api-key-label">Secret key (test)</span>
462
- <span className="api-key-value mono">sk_test_twin••••</span>
463
- </div>
464
- </section>
465
- <section className="settings-block" data-settings="webhooks">
466
- <h3>Webhooks</h3>
467
- {webhooks.length === 0 && <div className="empty">No webhook endpoints registered.</div>}
468
- {webhooks.map((w) => (
469
- <div className="webhook-row" key={w.id}>
470
- <span className="webhook-url mono">{w.url ?? w.id}</span>
471
- <StatusPill value={w.status ?? 'enabled'} />
472
- </div>
473
- ))}
474
- </section>
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>
475
848
  </div>
476
849
  );
477
850
  }
478
851
 
479
- // ── Create / edit modal (write forms) ───────────────────────────────────────
480
- // The real Dashboard creates objects via a modal write form that POSTs the create endpoint.
481
- // We model a faithful create form per collection (the few required fields), POSTing the SAME
482
- // /v1/<collection> the SDK uses; on success the polling loop reflects the new row.
483
- export const CREATE_FORMS: Record<string, { label: string; fields: Array<{ name: string; label: string; placeholder?: string; required?: boolean }> }> = {
484
- customers: { label: 'customer', fields: [{ name: 'email', label: 'Email', placeholder: 'jenny@example.com' }, { name: 'name', label: 'Name' }] },
485
- products: { label: 'product', fields: [{ name: 'name', label: 'Name', required: true }, { name: 'description', label: 'Description' }] },
486
- coupons: { label: 'coupon', fields: [{ name: 'percent_off', label: 'Percent off', placeholder: '20' }, { name: 'duration', label: 'Duration', placeholder: 'once' }] },
487
- payment_intents: { label: 'payment', fields: [{ name: 'amount', label: 'Amount (cents)', required: true, placeholder: '1000' }, { name: 'currency', label: 'Currency', required: true, placeholder: 'usd' }] },
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
+ },
488
927
  };
489
928
 
490
- export function CreateModal({ collection, onClose, onCreated }: { collection: string; onClose: () => void; onCreated: () => void }) {
491
- const form = CREATE_FORMS[collection];
929
+ function CreateDialog({ collection, onClose, onCreated }: { collection: string; onClose: () => void; onCreated: (o: StripeObject) => void }) {
930
+ const form = CREATE_FORMS[collection]!;
492
931
  const [values, setValues] = useState<Record<string, string>>({});
493
932
  const [busy, setBusy] = useState(false);
494
933
  const [err, setErr] = useState<string | null>(null);
495
- if (!form) return null;
496
934
  const submit = async () => {
497
935
  setBusy(true); setErr(null);
498
- try {
499
- const body = Object.entries(values).filter(([, v]) => v !== '').map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
500
- const res = await fetch(`/v1/${collection}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body }).then((r) => r.json());
501
- if (res.error) throw new Error(res.error.message);
502
- onCreated(); onClose();
503
- } catch (e: any) {
504
- setErr(String(e.message ?? e));
505
- } finally {
506
- setBusy(false);
507
- }
936
+ try { onCreated(await createObject(collection, values)); } catch (e: any) { setErr(String(e.message ?? e)); } finally { setBusy(false); }
508
937
  };
509
938
  return (
510
- <div className="create-overlay" role="dialog" aria-label={`Create ${form.label}`} onClick={onClose}>
511
- <div className="create-modal" onClick={(e) => e.stopPropagation()}>
512
- <h3 className="create-title">New {form.label}</h3>
513
- {form.fields.map((f) => (
514
- <label className="create-field" key={f.name}>
515
- <span className="create-field-label">{f.label}{f.required ? ' *' : ''}</span>
516
- <input className="create-field-input" placeholder={f.placeholder ?? ''} value={values[f.name] ?? ''} onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))} />
517
- </label>
518
- ))}
519
- {err && <div className="create-error" role="alert">{err}</div>}
520
- <div className="create-actions">
521
- <button className="create-cancel" onClick={onClose}>Cancel</button>
522
- <button className="create-submit" disabled={busy} onClick={submit}>{busy ? 'Creating…' : 'Create'}</button>
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>
523
982
  </div>
524
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}
525
1007
  </div>
526
1008
  );
527
1009
  }
528
1010
 
529
- /** The list pane: one row per object in the active section (or an empty state). */
530
- export function ListPane({ section, rows, query, currentId, onSelect }: {
531
- section: Section;
532
- rows: StripeRow[];
533
- query?: string;
534
- currentId?: string;
535
- onSelect: (id: string) => void;
536
- }) {
537
- return (
538
- <section className="list">
539
- {rows.length === 0 && <div className="empty">{query ? 'No matches.' : `No ${section.label.toLowerCase()} in the twin yet.`}</div>}
540
- {rows.map((row, i) => (
541
- <button key={row.id ?? i} className={`list-row ${currentId === row.id ? 'active' : ''}`} onClick={() => onSelect(row.id)}>
542
- <span className="row-main">
543
- <span className="row-title">{section.title(row)}</span>
544
- <span className="row-sub mono">{section.subtitle(row)}</span>
545
- </span>
546
- {row.status ? <StatusPill value={row.status} /> : row.active !== undefined ? <StatusPill value={String(row.active)} /> : null}
547
- </button>
548
- ))}
549
- </section>
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>
550
1048
  );
551
1049
  }
552
1050
 
553
- export function App() {
554
- const [activeKey, setActiveKey] = useState<string>('__home');
555
- // All collections cached so cross-references resolve without extra round-trips.
556
- const [data, setData] = useState<Record<string, StripeRow[]>>({});
557
- const [account, setAccount] = useState<StripeRow | undefined>(undefined);
558
- const [error, setError] = useState<string | null>(null);
559
- const [selected, setSelected] = useState<string | null>(null);
560
- const [query, setQuery] = useState('');
561
- const [cmdOpen, setCmdOpen] = useState(false);
562
- const [createFor, setCreateFor] = useState<string | null>(null);
563
- // Bumped after a detail action (capture/cancel) to re-fetch immediately rather than
564
- // waiting for the next poll tick.
565
- const [refreshKey, setRefreshKey] = useState(0);
566
-
567
- const active = SECTION_BY_KEY[activeKey];
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
+ }
568
1055
 
569
- useEffect(() => {
570
- let live = true;
571
- const tick = async () => {
572
- try {
573
- const results = await Promise.all(SECTIONS.map((s) =>
574
- fetch(`/v1/${s.key}`).then((r) => r.json()).then((j) => {
575
- if (j.error) throw new Error(j.error.message);
576
- // `balance` returns a single summary object (no `data` array); present it as a
577
- // one-row list so it flows through the same list/detail machinery. Give it a
578
- // stable id so selection/cross-ref code (keyed on `id`) works.
579
- if (s.key === BALANCE_KEY) return [s.key, [{ id: 'balance', ...j } as StripeRow]] as const;
580
- return [s.key, (j.data ?? []) as StripeRow[]] as const;
581
- }),
582
- ));
583
- // the platform account (GET /v1/account) backs the Settings screen.
584
- const acct = await fetch('/v1/account').then((r) => r.json()).catch(() => undefined);
585
- if (!live) return;
586
- setData(Object.fromEntries(results));
587
- if (acct && !acct.error) setAccount(acct as StripeRow);
588
- setError(null);
589
- } catch (e: any) {
590
- if (live) setError(String(e.message ?? e));
591
- }
592
- };
593
- void tick();
594
- const timer = window.setInterval(tick, 2000);
595
- return () => { live = false; window.clearInterval(timer); };
596
- }, [refreshKey]);
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
+ }
597
1127
 
598
- // Cmd/Ctrl-K opens the global command bar (Stripe's search shortcut).
599
- useEffect(() => {
600
- const onKey = (e: KeyboardEvent) => {
601
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setCmdOpen((o) => !o); }
602
- if (e.key === 'Escape') { setCmdOpen(false); setCreateFor(null); }
603
- };
604
- window.addEventListener('keydown', onKey);
605
- return () => window.removeEventListener('keydown', onKey);
606
- }, []);
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
+ }
607
1170
 
608
- const rows = data[activeKey];
609
- const filtered = useMemo(() => {
610
- const q = query.trim().toLowerCase();
611
- if (!active) return [];
612
- if (!q) return rows ?? [];
613
- return (rows ?? []).filter((r) =>
614
- [active.title(r), active.subtitle(r), r.id, r.email, r.name].some((f) => String(f ?? '').toLowerCase().includes(q)),
615
- );
616
- }, [rows, query, active]);
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
+ }
617
1245
 
618
- const current = useMemo(() => (rows ?? []).find((r) => r.id === selected) ?? filtered[0], [rows, selected, filtered]);
619
- const refs = useMemo(() => current ? resolveCrossRefs(activeKey, current, data) : { outgoing: [], incoming: [] }, [current, activeKey, data]);
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
+ }
620
1263
 
621
- const jump = (collection: string, id: string) => {
622
- if (!collection) return;
623
- setActiveKey(collection);
624
- setSelected(id);
625
- setQuery('');
626
- };
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
+ }
627
1457
 
628
- const canCreate = active && CREATE_FORMS[activeKey] !== undefined;
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
+ }
629
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]);
630
1518
  return (
631
1519
  <div className="dash">
632
- <header className="topbar">
633
- <div className="brand"><span className="dot" /> Stripe <span className="tag">twin</span></div>
634
- <button className="search search-trigger" onClick={() => setCmdOpen(true)}>Search… <span className="kbd">⌘K</span></button>
635
- {active ? <input className="search section-filter" placeholder="Filter this section…" value={query} onChange={(e) => setQuery(e.target.value)} /> : null}
636
- <span className="mode">Test mode</span>
637
- </header>
638
- <div className="body">
639
- <SideNav
640
- sections={SECTIONS}
641
- counts={Object.fromEntries(SECTIONS.map((s) => [s.key, data[s.key]?.length ?? 0]))}
642
- activeKey={activeKey}
643
- onSelect={(key) => { setActiveKey(key); setSelected(null); setQuery(''); }}
644
- onHome={() => { setActiveKey('__home'); setSelected(null); setQuery(''); }}
645
- onSettings={() => { setActiveKey('__settings'); setSelected(null); setQuery(''); }}
646
- />
647
- <main className="content">
648
- {activeKey === '__home' ? (
649
- <>
650
- <div className="content-head"><h2>Home</h2></div>
651
- <HomeScreen data={data} onJump={jump} />
652
- </>
653
- ) : activeKey === '__settings' ? (
654
- <>
655
- <div className="content-head"><h2>Settings</h2></div>
656
- <SettingsScreen account={account} webhooks={data.webhook_endpoints ?? []} />
657
- </>
658
- ) : active ? (
659
- <>
660
- <div className="content-head">
661
- <h2>{active.label}</h2>
662
- <span className="count">{filtered.length}{query ? ` of ${rows?.length ?? 0}` : ''}</span>
663
- {canCreate ? <button className="create-trigger" onClick={() => setCreateFor(activeKey)}>+ New</button> : null}
664
- </div>
665
- {error && <div className="error">Failed to load /v1/{activeKey}: {error}</div>}
666
- <div className="split">
667
- <ListPane
668
- section={active}
669
- rows={rows ? filtered : []}
670
- query={query}
671
- currentId={current?.id}
672
- onSelect={(id) => setSelected(id)}
673
- />
674
- <Detail collection={activeKey} row={current} refs={refs} onJump={jump} onAction={() => setRefreshKey((k) => k + 1)} />
675
- </div>
676
- </>
677
- ) : null}
678
- </main>
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>
679
1527
  </div>
680
- {cmdOpen ? <CommandBar data={data} onJump={jump} onClose={() => setCmdOpen(false)} /> : null}
681
- {createFor ? <CreateModal collection={createFor} onClose={() => setCreateFor(null)} onCreated={() => setRefreshKey((k) => k + 1)} /> : null}
682
1528
  </div>
683
1529
  );
684
1530
  }
685
1531
 
686
- // Browser-only mount. Guarded so the presentational components above can be imported
687
- // (and renderToStaticMarkup'd) in a DOM-less test/SSR environment without this top-level
688
- // `document` access throwing. The browser bundle still mounts exactly as before.
689
- if (typeof document !== 'undefined') {
1532
+ if (typeof document !== 'undefined' && document.getElementById('root')) {
690
1533
  createRoot(document.getElementById('root')!).render(<App />);
691
1534
  }