@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.
- package/README.md +64 -27
- package/client/dashboard-api.ts +286 -0
- package/client/stripe-mirror.css +272 -159
- package/client/stripe-mirror.tsx +1384 -541
- package/dist/client/dashboard-api.d.ts +107 -0
- package/dist/client/dashboard-api.js +238 -0
- package/dist/client/dashboard-api.ts +286 -0
- package/dist/client/stripe-mirror.bundle.js +236 -0
- package/dist/client/stripe-mirror.css +275 -0
- package/dist/client/stripe-mirror.d.ts +134 -0
- package/dist/client/stripe-mirror.js +823 -0
- package/dist/client/stripe-mirror.tsx +1534 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +39 -0
- package/dist/src/generated/events.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +73 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +1065 -0
- package/dist/src/screens/checkout.d.ts +31 -0
- package/dist/src/screens/checkout.js +241 -0
- package/dist/src/screens/consent-skin.d.ts +4 -0
- package/dist/src/screens/consent-skin.js +18 -0
- package/dist/src/screens/financial-connections.d.ts +5 -0
- package/dist/src/screens/financial-connections.js +90 -0
- package/dist/src/screens/identity.d.ts +5 -0
- package/dist/src/screens/identity.js +86 -0
- package/dist/src/screens/industries.d.ts +1 -0
- package/dist/src/screens/industries.js +267 -0
- package/dist/src/screens/onboarding.d.ts +13 -0
- package/dist/src/screens/onboarding.js +225 -0
- package/dist/src/screens/portal.d.ts +5 -0
- package/dist/src/screens/portal.js +214 -0
- package/dist/src/screens/public-details.d.ts +5 -0
- package/dist/src/screens/public-details.js +90 -0
- package/dist/src/semantics/after-payment.d.ts +22 -0
- package/dist/src/semantics/after-payment.js +93 -0
- package/dist/src/semantics/apps-secrets.d.ts +2 -0
- package/dist/src/semantics/apps-secrets.js +54 -0
- package/dist/src/semantics/balance.d.ts +11 -0
- package/dist/src/semantics/balance.js +195 -0
- package/dist/src/semantics/billing.d.ts +2 -0
- package/dist/src/semantics/billing.js +220 -0
- package/dist/src/semantics/charges.d.ts +28 -0
- package/dist/src/semantics/charges.js +201 -0
- package/dist/src/semantics/checkout.d.ts +15 -0
- package/dist/src/semantics/checkout.js +303 -0
- package/dist/src/semantics/connect.d.ts +5 -0
- package/dist/src/semantics/connect.js +476 -0
- package/dist/src/semantics/coupons.d.ts +6 -0
- package/dist/src/semantics/coupons.js +92 -0
- package/dist/src/semantics/credit-notes.d.ts +2 -0
- package/dist/src/semantics/credit-notes.js +172 -0
- package/dist/src/semantics/customers.d.ts +6 -0
- package/dist/src/semantics/customers.js +429 -0
- package/dist/src/semantics/disputes.d.ts +2 -0
- package/dist/src/semantics/disputes.js +51 -0
- package/dist/src/semantics/entitlements.d.ts +2 -0
- package/dist/src/semantics/entitlements.js +95 -0
- package/dist/src/semantics/ephemeral-keys.d.ts +2 -0
- package/dist/src/semantics/ephemeral-keys.js +34 -0
- package/dist/src/semantics/files.d.ts +2 -0
- package/dist/src/semantics/files.js +125 -0
- package/dist/src/semantics/invoices.d.ts +18 -0
- package/dist/src/semantics/invoices.js +541 -0
- package/dist/src/semantics/issuing.d.ts +13 -0
- package/dist/src/semantics/issuing.js +570 -0
- package/dist/src/semantics/ledger.d.ts +54 -0
- package/dist/src/semantics/ledger.js +181 -0
- package/dist/src/semantics/payment-intents.d.ts +18 -0
- package/dist/src/semantics/payment-intents.js +404 -0
- package/dist/src/semantics/payment-links.d.ts +2 -0
- package/dist/src/semantics/payment-links.js +133 -0
- package/dist/src/semantics/payment-methods.d.ts +20 -0
- package/dist/src/semantics/payment-methods.js +138 -0
- package/dist/src/semantics/plans.d.ts +5 -0
- package/dist/src/semantics/plans.js +121 -0
- package/dist/src/semantics/platform.d.ts +9 -0
- package/dist/src/semantics/platform.js +206 -0
- package/dist/src/semantics/products.d.ts +2 -0
- package/dist/src/semantics/products.js +140 -0
- package/dist/src/semantics/radar.d.ts +2 -0
- package/dist/src/semantics/radar.js +83 -0
- package/dist/src/semantics/refunds.d.ts +9 -0
- package/dist/src/semantics/refunds.js +195 -0
- package/dist/src/semantics/renewals.d.ts +47 -0
- package/dist/src/semantics/renewals.js +251 -0
- package/dist/src/semantics/setup-intents.d.ts +2 -0
- package/dist/src/semantics/setup-intents.js +84 -0
- package/dist/src/semantics/shared.d.ts +78 -0
- package/dist/src/semantics/shared.js +192 -0
- package/dist/src/semantics/subscription-schedules.d.ts +2 -0
- package/dist/src/semantics/subscription-schedules.js +119 -0
- package/dist/src/semantics/subscriptions.d.ts +11 -0
- package/dist/src/semantics/subscriptions.js +605 -0
- package/dist/src/semantics/tax.d.ts +2 -0
- package/dist/src/semantics/tax.js +197 -0
- package/dist/src/semantics/terminal.d.ts +5 -0
- package/dist/src/semantics/terminal.js +182 -0
- package/dist/src/semantics/test-clocks.d.ts +6 -0
- package/dist/src/semantics/test-clocks.js +73 -0
- package/dist/src/semantics/tokens.d.ts +4 -0
- package/dist/src/semantics/tokens.js +44 -0
- package/dist/src/semantics/transfers.d.ts +2 -0
- package/dist/src/semantics/transfers.js +154 -0
- package/dist/src/semantics/treasury.d.ts +2 -0
- package/dist/src/semantics/treasury.js +377 -0
- package/dist/src/semantics/webhook-endpoints.d.ts +3 -0
- package/dist/src/semantics/webhook-endpoints.js +85 -0
- package/dist/src/stripe-budget.d.ts +55 -0
- package/dist/src/stripe-budget.js +155 -0
- package/dist/src/stripe-capabilities.d.ts +3 -0
- package/dist/src/stripe-capabilities.js +5052 -0
- package/dist/src/stripe-conformance.d.ts +41 -0
- package/dist/src/stripe-conformance.js +96 -0
- package/dist/src/stripe-connector.d.ts +161 -0
- package/dist/src/stripe-connector.js +414 -0
- package/dist/src/stripe-emit.d.ts +2 -0
- package/dist/src/stripe-emit.js +145 -0
- package/dist/src/stripe-events.d.ts +93 -0
- package/dist/src/stripe-events.js +388 -0
- package/dist/src/stripe-js.d.ts +4 -0
- package/dist/src/stripe-js.js +70 -0
- package/dist/src/stripe-mirror-ui.d.ts +15 -0
- package/dist/src/stripe-mirror-ui.js +87 -0
- package/dist/src/stripe-params.d.ts +3 -0
- package/dist/src/stripe-params.js +43 -0
- package/dist/src/stripe-perform-harness.d.ts +9 -0
- package/dist/src/stripe-perform-harness.js +26 -0
- package/dist/src/stripe-server.d.ts +33 -0
- package/dist/src/stripe-server.js +326 -0
- package/dist/src/stripe-shared.d.ts +106 -0
- package/dist/src/stripe-shared.js +273 -0
- package/dist/src/stripe-twin.d.ts +155 -0
- package/dist/src/stripe-twin.js +1226 -0
- package/dist/src/stripe-ui-conformance.d.ts +5 -0
- package/dist/src/stripe-ui-conformance.js +79 -0
- package/dist/src/stripe-ui-structure.d.ts +3 -0
- package/dist/src/stripe-ui-structure.js +168 -0
- package/dist/src/stripe-version.d.ts +10 -0
- package/dist/src/stripe-version.js +285 -0
- package/dist/test-fixtures/stripe-known-deviations.json +105 -0
- package/dist/test-fixtures/stripe-openapi-operations.SOURCE.md +14 -0
- package/dist/test-fixtures/stripe-openapi-operations.json +4717 -0
- package/dist/test-fixtures/stripe-schemas.SOURCE.md +35 -0
- package/dist/test-fixtures/stripe-schemas.json +3740 -0
- package/package.json +18 -10
- package/src/cli.ts +7 -7
- package/src/generated/events.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +31 -9
- package/src/manifest.ts +1097 -0
- package/src/screens/checkout.tsx +252 -0
- package/src/screens/consent-skin.ts +20 -0
- package/src/screens/financial-connections.tsx +101 -0
- package/src/screens/identity.tsx +96 -0
- package/src/screens/industries.ts +267 -0
- package/src/screens/onboarding.tsx +243 -0
- package/src/screens/portal.tsx +218 -0
- package/src/screens/public-details.tsx +105 -0
- package/src/semantics/after-payment.ts +113 -0
- package/src/semantics/apps-secrets.ts +58 -0
- package/src/semantics/balance.ts +209 -0
- package/src/semantics/billing.ts +216 -0
- package/src/semantics/charges.ts +211 -0
- package/src/semantics/checkout.ts +297 -0
- package/src/semantics/connect.ts +471 -0
- package/src/semantics/coupons.ts +97 -0
- package/src/semantics/credit-notes.ts +168 -0
- package/src/semantics/customers.ts +432 -0
- package/src/semantics/disputes.ts +62 -0
- package/src/semantics/entitlements.ts +94 -0
- package/src/semantics/ephemeral-keys.ts +34 -0
- package/src/semantics/files.ts +143 -0
- package/src/semantics/invoices.ts +541 -0
- package/src/semantics/issuing.ts +585 -0
- package/src/semantics/ledger.ts +216 -0
- package/src/semantics/payment-intents.ts +420 -0
- package/src/semantics/payment-links.ts +148 -0
- package/src/semantics/payment-methods.ts +143 -0
- package/src/semantics/plans.ts +131 -0
- package/src/semantics/platform.ts +220 -0
- package/src/semantics/products.ts +154 -0
- package/src/semantics/radar.ts +85 -0
- package/src/semantics/refunds.ts +218 -0
- package/src/semantics/renewals.ts +274 -0
- package/src/semantics/setup-intents.ts +87 -0
- package/src/semantics/shared.ts +215 -0
- package/src/semantics/subscription-schedules.ts +129 -0
- package/src/semantics/subscriptions.ts +610 -0
- package/src/semantics/tax.ts +220 -0
- package/src/semantics/terminal.ts +195 -0
- package/src/semantics/test-clocks.ts +77 -0
- package/src/semantics/tokens.ts +52 -0
- package/src/semantics/transfers.ts +174 -0
- package/src/semantics/treasury.ts +383 -0
- package/src/semantics/webhook-endpoints.ts +87 -0
- package/src/stripe-budget.ts +4 -4
- package/src/stripe-capabilities.ts +1456 -222
- package/src/stripe-conformance.ts +6 -5
- package/src/stripe-connector.ts +68 -40
- package/src/stripe-emit.ts +14 -7
- package/src/stripe-events.ts +94 -36
- package/src/stripe-js.ts +70 -0
- package/src/stripe-mirror-ui.ts +28 -298
- package/src/stripe-params.ts +44 -0
- package/src/stripe-perform-harness.ts +29 -0
- package/src/stripe-server.ts +263 -38
- package/src/stripe-shared.ts +294 -0
- package/src/stripe-twin.ts +429 -5325
- package/src/stripe-ui-conformance.ts +70 -107
- package/src/stripe-ui-structure.ts +124 -348
- package/src/stripe-version.ts +278 -0
- package/test-fixtures/stripe-known-deviations.json +2 -7
- package/test-fixtures/stripe-openapi-operations.json +1188 -2855
- package/src/stripe-form.ts +0 -35
package/client/stripe-mirror.tsx
CHANGED
|
@@ -1,130 +1,171 @@
|
|
|
1
|
-
// Stripe
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
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,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
//
|
|
66
|
-
|
|
67
|
-
export
|
|
68
|
-
const
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
<
|
|
79
|
-
<
|
|
80
|
-
|
|
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
|
-
/**
|
|
86
|
-
export function
|
|
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
|
-
<
|
|
89
|
-
{
|
|
90
|
-
|
|
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
|
-
<
|
|
114
|
-
<
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
if (
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
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="
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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="
|
|
242
|
-
<
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
</
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
305
|
-
<
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
<button
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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,
|
|
341
|
-
succeededPayments: succeeded.length,
|
|
342
|
-
|
|
343
|
-
customers:
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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
|
|
352
|
-
|
|
353
|
-
{ key: '
|
|
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: '
|
|
356
|
-
{ key: 'customers', label: '
|
|
357
|
-
{ key: '
|
|
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
|
-
{
|
|
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
|
-
<
|
|
371
|
-
<
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
408
|
-
const
|
|
409
|
-
const
|
|
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
|
-
|
|
412
|
-
<
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
619
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
633
|
-
|
|
634
|
-
<
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
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
|
}
|