@djangocfg/payments 2.1.457

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Reforms.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # @djangocfg/payments
2
+
3
+ Provider-agnostic React payments module. **Stripe-first**, but the package
4
+ never depends on a concrete SDK — the host injects an adapter. Battle-tested
5
+ in production (one-time PaymentIntent checkout + recurring subscriptions with
6
+ Stripe Link) before extraction into `@djangocfg/*`.
7
+
8
+ ## The seam
9
+
10
+ The host picks **one** adapter and injects it via `<PaymentProvider>`. Package
11
+ hooks/components read it through `usePaymentAdapter()` and never import Stripe or
12
+ a generated API client directly.
13
+
14
+ ```tsx
15
+ import { PaymentProvider, createMockPaymentAdapter } from '@djangocfg/payments';
16
+
17
+ // Mock-first: build & verify the whole checkout UX before any backend exists.
18
+ <PaymentProvider adapter={createMockPaymentAdapter()}>
19
+ {children}
20
+ </PaymentProvider>
21
+ ```
22
+
23
+ Swapping to real Stripe is a one-line host change:
24
+
25
+ ```tsx
26
+ import { loadStripe } from '@stripe/stripe-js';
27
+ import { PaymentProvider, createStripeAdapter } from '@djangocfg/payments';
28
+
29
+ const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PK!); // once, module scope
30
+ const adapter = createStripeAdapter({
31
+ getStripe: () => stripePromise,
32
+ createIntentOnBackend: (input) => api.payments.createIntent(input), // host transport
33
+ });
34
+
35
+ <PaymentProvider adapter={adapter}>{children}</PaymentProvider>;
36
+ ```
37
+
38
+ ## Layering
39
+
40
+ ```
41
+ domain/ pure types + money helpers (no React, no Stripe)
42
+ context/ <PaymentProvider> + usePaymentAdapter() (the injected seam)
43
+ transport/ mock-adapter | stripe-adapter (only stripe-adapter touches @stripe/*)
44
+ hooks/ useCheckout, usePaymentHistory (state machine over the adapter)
45
+ components/ CheckoutForm, StripePaymentElement, PaymentHistory, PaymentStatusBadge
46
+ ```
47
+
48
+ `@stripe/*` is touched in **exactly two files** (`transport/stripe-adapter.ts`,
49
+ `components/StripePaymentElement.tsx`) and ships as a regular dependency —
50
+ `@stripe/stripe-js` is only the tiny CDN loader (the real SDK loads from
51
+ js.stripe.com at runtime), and tree-shaking drops the Stripe components from
52
+ mock-only bundles.
53
+
54
+ ## Styles (Tailwind v4) — required wiring
55
+
56
+ This package is consumed **from source** and styled with Tailwind utility
57
+ classes. Tailwind v4 only generates classes it can see, so the consumer's
58
+ global CSS must import the package's styles entry (it carries the `@source`
59
+ directive that points Tailwind at this package's sources):
60
+
61
+ ```css
62
+ /* your app's globals.css */
63
+ @import "@djangocfg/ui-core/styles/full";
64
+ @import "@djangocfg/ui-tools/styles";
65
+ @import "@djangocfg/payments/styles"; /* ← add this */
66
+ ```
67
+
68
+ Symptom when missing: components render unstyled in spots — e.g.
69
+ `PaymentStatusBadge` chips lose their background tint and show as plain text.
70
+ Next.js hosts also add the package to `transpilePackages`.
71
+
72
+ ## Public API
73
+
74
+ | Export | Kind | Purpose |
75
+ |---|---|---|
76
+ | `PaymentProvider` / `usePaymentAdapter` | context | inject / read the adapter |
77
+ | `createMockPaymentAdapter` | adapter | full fake state machine (success / declined / 3DS) |
78
+ | `createStripeAdapter` | adapter | real Stripe (host supplies key + create-intent call) |
79
+ | `useCheckout` | hook | `idle → creating → requires_payment → processing → succeeded\|failed\|requires_action` |
80
+ | `usePaymentHistory` | hook | paginated history via `adapter.listPayments` |
81
+ | `CheckoutDialog` | component | close-locked dialog shell (CheckoutGuard + ui-core Dialog); wrap your checkout body in it |
82
+ | `CheckoutForm` | component | provider-agnostic form shell (ui-core only); inject the provider field via `paymentField` |
83
+ | `StripePaymentElement` | component | `<Elements>` + `<PaymentElement>`; render-props the live Elements to the host |
84
+ | `PaymentHistory` | component | DataTable of `PaymentRecord` |
85
+ | `PaymentStatusBadge` | component | status pill on ui-core semantic tokens (success/warning/info/destructive) |
86
+ | `toMinorUnits` / `toMajorUnits` / `formatAmount` | util | money at the boundary (amounts cross as integer minor units) |
87
+
88
+ ## Conventions
89
+
90
+ - Source-consumed (no bundler); `main`/`types`/`exports` → `./src/index.ts`.
91
+ - ui-core flat import; ui-tools subpath-only (`@djangocfg/ui-tools/data-table`).
92
+ - Does **not** mount its own `<UiProviders>` — the host provides it once.
93
+ - Locale-free: user-facing labels are props/slots (host supplies i18n).
94
+ - Verify with `pnpm -F @djangocfg/payments check` (tsc).
95
+
96
+ ---
97
+
98
+ ## Configuration & keys
99
+
100
+ Payments need **three Stripe keys**, split by where they're safe to live. Stripe
101
+ ships them in two flavours — **test** (`sk_test_…`, `pk_test_…`) and **live**
102
+ (`sk_live_…`, `pk_live_…`) — with the **same variable names**; only the values
103
+ differ. Bring up **test first**, verify end-to-end, then switch to live.
104
+
105
+ | Key | Prefix | Lives in | Exposed to browser? |
106
+ |---|---|---|---|
107
+ | Secret key | `sk_test_…` / `sk_live_…` | **Backend only** (`STRIPE__SECRET_KEY`) | ❌ never |
108
+ | Webhook signing secret | `whsec_…` | **Backend only** (`STRIPE__WEBHOOK_SECRET`) | ❌ never |
109
+ | Publishable key | `pk_test_…` / `pk_live_…` | Backend + Frontend | ✅ safe (it's public by design) |
110
+
111
+ > The publishable key is typically returned to the browser by the backend's
112
+ > create-checkout response (`publishable_key`) and/or read from
113
+ > `NEXT_PUBLIC_STRIPE_PK`. The secret and webhook keys **never** leave the server.
114
+
115
+ ### Backend (Django) — `STRIPE__*`
116
+
117
+ This package is frontend-only; it pairs with any Django backend that creates
118
+ PaymentIntents (a built-in `django_cfg.apps.payments` module is planned — see
119
+ `@plans/plan-payments-v1`). Paths below are the reference host's; adjust env
120
+ names to your backend. Set the keys server-side (git-ignored secrets file):
121
+
122
+ ```dotenv
123
+ # --- Stripe (test) ---
124
+ STRIPE__SECRET_KEY=sk_test_xxx
125
+ STRIPE__PUBLISHABLE_KEY=pk_test_xxx
126
+ STRIPE__WEBHOOK_SECRET=whsec_xxx # comma-separate to rotate: whsec_new,whsec_old
127
+
128
+ # --- Stripe (live) — same names, live values ---
129
+ # STRIPE__SECRET_KEY=sk_live_xxx
130
+ # STRIPE__PUBLISHABLE_KEY=pk_live_xxx
131
+ # STRIPE__WEBHOOK_SECRET=whsec_live_xxx
132
+ ```
133
+
134
+ ### Frontend — `NEXT_PUBLIC_STRIPE_PK`
135
+
136
+ ```dotenv
137
+ # your Next.js app .env (test → pk_test_…, live → pk_live_…)
138
+ NEXT_PUBLIC_STRIPE_PK=pk_test_xxx
139
+ ```
140
+
141
+ With **no** `NEXT_PUBLIC_STRIPE_PK` set, the host falls back to the **mock
142
+ adapter** — the whole checkout UX still renders (no real charge), which is the
143
+ mock-first dev path.
144
+
145
+ > ⚠️ **Security:** the secret (`sk_`) and webhook (`whsec_`) keys must live in a
146
+ > **git-ignored** file or a secret manager — never commit them. If the project's
147
+ > `.env*` files are tracked by git, add the secrets file to `.gitignore` and
148
+ > `git rm --cached` it first. Only `pk_…` (publishable) is safe in tracked config.
149
+
150
+ ## Stripe setup (test mode)
151
+
152
+ 1. **Get test keys** — Stripe Dashboard → Developers → API keys (toggle "test
153
+ mode"): copy `pk_test_…` and `sk_test_…`.
154
+ 2. **Webhook** — for local dev, use the Stripe CLI (it prints a `whsec_…` signing
155
+ secret for the forwarded endpoint):
156
+ ```bash
157
+ stripe login
158
+ stripe listen --forward-to localhost:8000/<your-webhook-path>/
159
+ # → copy the "whsec_…" it prints into STRIPE__WEBHOOK_SECRET
160
+ ```
161
+ For a deployed env, create the endpoint in Dashboard → Developers → Webhooks
162
+ pointing at your backend's webhook URL and subscribe to:
163
+ `payment_intent.succeeded`, `payment_intent.payment_failed`, `charge.refunded`.
164
+ 3. **Test a payment** — open an order, hit **Pay**, use a Stripe **test card**
165
+ (`4242 4242 4242 4242`, any future expiry/CVC). Trigger events directly with:
166
+ ```bash
167
+ stripe trigger payment_intent.succeeded
168
+ stripe events resend <evt_id> # test idempotency / replay
169
+ ```
170
+
171
+ ## Going live
172
+
173
+ 1. Switch all three `STRIPE__*` values to their `…_live_…` counterparts and
174
+ `NEXT_PUBLIC_STRIPE_PK` to `pk_live_…`.
175
+ 2. Create a **live** webhook endpoint in the Dashboard (live mode has its own
176
+ signing secret — set it in `STRIPE__WEBHOOK_SECRET`).
177
+ 3. Register your domain for Apple Pay / Google Pay if you enable wallets.
178
+ 4. Do a small real charge to confirm end-to-end.
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@djangocfg/payments",
3
+ "version": "2.1.457",
4
+ "description": "Provider-agnostic React payments module (Stripe-first): checkout state machine, Payment Element wrapper, close-guard context, mock adapter for keyless development",
5
+ "keywords": [
6
+ "payments",
7
+ "stripe",
8
+ "checkout",
9
+ "subscriptions",
10
+ "payment-element",
11
+ "react",
12
+ "django",
13
+ "typescript",
14
+ "hooks"
15
+ ],
16
+ "author": {
17
+ "name": "DjangoCFG",
18
+ "url": "https://djangocfg.com"
19
+ },
20
+ "homepage": "https://djangocfg.com",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/markolofsen/django-cfg.git",
24
+ "directory": "packages/payments"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/markolofsen/django-cfg/issues"
28
+ },
29
+ "license": "MIT",
30
+ "main": "./src/index.ts",
31
+ "module": "./src/index.ts",
32
+ "types": "./src/index.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./src/index.ts",
36
+ "import": "./src/index.ts",
37
+ "require": "./src/index.ts"
38
+ },
39
+ "./styles": "./src/styles.css"
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "src",
44
+ "README.md",
45
+ "LICENSE"
46
+ ],
47
+ "scripts": {
48
+ "check": "tsc --noEmit",
49
+ "lint": "eslint . --max-warnings 0"
50
+ },
51
+ "dependencies": {
52
+ "@stripe/react-stripe-js": "^6.7.0",
53
+ "@stripe/stripe-js": "^9.9.0"
54
+ },
55
+ "peerDependencies": {
56
+ "@djangocfg/ui-core": "^2.1.457",
57
+ "@djangocfg/ui-tools": "^2.1.457",
58
+ "lucide-react": "^0.545.0",
59
+ "react": "^19.2.4",
60
+ "react-dom": "^19.2.4"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "@djangocfg/ui-tools": {
64
+ "optional": true
65
+ }
66
+ },
67
+ "devDependencies": {
68
+ "@djangocfg/typescript-config": "^2.1.457",
69
+ "@djangocfg/ui-core": "^2.1.457",
70
+ "@djangocfg/ui-tools": "^2.1.457",
71
+ "@types/node": "^25.2.3",
72
+ "@types/react": "^19.2.15",
73
+ "@types/react-dom": "^19.2.3",
74
+ "lucide-react": "0.545.0",
75
+ "typescript": "^5.9.3"
76
+ },
77
+ "publishConfig": {
78
+ "access": "public"
79
+ }
80
+ }
@@ -0,0 +1,122 @@
1
+ 'use client';
2
+
3
+ // ============================================================================
4
+ // @djangocfg/payments — CheckoutDialog
5
+ // ============================================================================
6
+ // The host-agnostic dialog shell around a checkout body. Every consumer used
7
+ // to re-write this glue (see cmdop's PaymentCheckoutShell): a Dialog whose
8
+ // close paths (Esc / overlay-click / ×) are gated while a charge is in flight,
9
+ // wired to the CheckoutGuard context that the inner CheckoutForm publishes
10
+ // into via usePublishCheckoutStatus.
11
+ //
12
+ // Layout invariant: the body is height-capped and scrolls INSIDE the dialog
13
+ // (header stays pinned), so the Stripe Payment Element never clips on small
14
+ // viewports.
15
+ //
16
+ // The children are the host's checkout body — typically the intent-creation
17
+ // state machine ending in <CheckoutForm> (with <StripePaymentElement> on the
18
+ // real path). This shell owns open/close + the in-flight lock, nothing else.
19
+
20
+ import { type ReactNode } from 'react';
21
+
22
+ import {
23
+ Dialog,
24
+ DialogContent,
25
+ DialogDescription,
26
+ DialogHeader,
27
+ DialogTitle,
28
+ } from '@djangocfg/ui-core';
29
+
30
+ import { CheckoutGuardProvider, useCheckoutGuard } from '../context/CheckoutGuard';
31
+
32
+ export interface CheckoutDialogProps {
33
+ open: boolean;
34
+ /** Called on any allowed close path (Esc / overlay / ×). */
35
+ onClose: () => void;
36
+ /** Dialog heading. Host supplies translated text. */
37
+ title?: ReactNode;
38
+ /**
39
+ * Accessible description (rendered sr-only). Screen readers announce it;
40
+ * sighted users see the checkout body itself.
41
+ */
42
+ description?: ReactNode;
43
+ /** Extra classes for the DialogContent (e.g. a wider max-w). */
44
+ className?: string;
45
+ children: ReactNode;
46
+ }
47
+
48
+ /**
49
+ * Checkout dialog shell: `CheckoutGuardProvider` + close-locked `Dialog`.
50
+ * While the inner form reports `processing` / `requires_action`, every close
51
+ * path is disabled and the × button is hidden — tearing down the form
52
+ * mid-confirm would orphan the payment intent.
53
+ */
54
+ export function CheckoutDialog({
55
+ open,
56
+ onClose,
57
+ title = 'Checkout',
58
+ description = 'Complete your payment.',
59
+ className,
60
+ children,
61
+ }: CheckoutDialogProps) {
62
+ return (
63
+ <CheckoutGuardProvider>
64
+ <CheckoutDialogShell
65
+ open={open}
66
+ onClose={onClose}
67
+ title={title}
68
+ description={description}
69
+ className={className}
70
+ >
71
+ {children}
72
+ </CheckoutDialogShell>
73
+ </CheckoutGuardProvider>
74
+ );
75
+ }
76
+
77
+ /** Inner shell — must live under the provider to read the guard. */
78
+ function CheckoutDialogShell({
79
+ open,
80
+ onClose,
81
+ title,
82
+ description,
83
+ className,
84
+ children,
85
+ }: CheckoutDialogProps) {
86
+ const { canClose } = useCheckoutGuard();
87
+
88
+ const handleOpenChange = (next: boolean) => {
89
+ if (!next && canClose) onClose();
90
+ };
91
+
92
+ return (
93
+ <Dialog open={open} onOpenChange={handleOpenChange}>
94
+ <DialogContent
95
+ className={[
96
+ 'flex max-h-[88vh] flex-col gap-0 sm:max-w-md',
97
+ className,
98
+ ]
99
+ .filter(Boolean)
100
+ .join(' ')}
101
+ closeButton={canClose ? undefined : false}
102
+ onEscapeKeyDown={(e) => {
103
+ if (!canClose) e.preventDefault();
104
+ }}
105
+ onInteractOutside={(e) => {
106
+ if (!canClose) e.preventDefault();
107
+ }}
108
+ >
109
+ <DialogHeader className="pb-4">
110
+ <DialogTitle>{title}</DialogTitle>
111
+ <DialogDescription className="sr-only">{description}</DialogDescription>
112
+ </DialogHeader>
113
+
114
+ {/* Scroll container: negative margin + padding keeps the scrollbar
115
+ out of the content gutter. */}
116
+ <div className="-mr-2 min-h-0 flex-1 overflow-y-auto overscroll-contain pr-2">
117
+ {children}
118
+ </div>
119
+ </DialogContent>
120
+ </Dialog>
121
+ );
122
+ }
@@ -0,0 +1,213 @@
1
+ 'use client';
2
+
3
+ // ============================================================================
4
+ // @djangocfg/payments — CheckoutForm (provider-agnostic form shell)
5
+ // ============================================================================
6
+ // ui-core only — no Stripe import. The provider's payment field (Stripe
7
+ // PaymentElement, or a mock input) is injected via the `paymentField` slot.
8
+ // Drives the checkout via useCheckout(); the host supplies labels (i18n).
9
+ //
10
+ // Stripe-like composition: order summary on top → divider → payment field →
11
+ // one prominent CTA → secure footer. Clean hierarchy, lots of air.
12
+
13
+ import React, { useCallback } from 'react';
14
+ import { Alert, AlertDescription, Button, Separator, Spinner } from '@djangocfg/ui-core';
15
+ import { Lock, ShieldCheck, AlertTriangle } from 'lucide-react';
16
+ import { useCheckout, type UseCheckoutResult } from '../hooks/useCheckout';
17
+ import { usePublishCheckoutStatus, isCheckoutLocked } from '../context/CheckoutGuard';
18
+ import { formatAmount } from '../domain/money';
19
+ import type { Currency, MinorUnits, PaymentReference } from '../domain/types';
20
+
21
+ export interface CheckoutFormProps {
22
+ amount: MinorUnits;
23
+ currency: Currency;
24
+ reference: PaymentReference;
25
+ /** Summary line-item title (e.g. the order description). Renders the top block. */
26
+ summaryTitle?: string;
27
+ /** Secondary summary line (e.g. "Billed monthly"). */
28
+ summaryNote?: string;
29
+ /** Slot for the provider's payment field (Stripe PaymentElement / mock input). */
30
+ paymentField?: React.ReactNode;
31
+ /**
32
+ * Mock mode (no real Stripe key). Renders a friendly `4242…` placeholder when
33
+ * no `paymentField` is supplied. On the real Stripe path this MUST be false so
34
+ * an absent slot never falls through to the mock stub (that was the duplicate
35
+ * card block under the real PaymentElement).
36
+ */
37
+ isMock?: boolean;
38
+ /**
39
+ * Confirm payload passed straight to the adapter (e.g. a Stripe Elements
40
+ * instance). For the mock adapter this can be undefined.
41
+ */
42
+ elementsContext?: unknown;
43
+ /** Where the provider returns after a redirect (3DS / hosted). */
44
+ returnUrl?: string;
45
+ onSuccess?: (intentId: string) => void;
46
+ onFailure?: (error: string) => void;
47
+ onCancel?: () => void;
48
+ /** Default: `Pay {amount}`. */
49
+ submitLabel?: string;
50
+ cancelLabel?: string;
51
+ /** Field label above the payment input. Default "Card". */
52
+ fieldLabel?: string;
53
+ /** Locale for amount formatting. */
54
+ locale?: string;
55
+ className?: string;
56
+ /**
57
+ * Share the PARENT's checkout state machine instead of creating a private one.
58
+ * Pass this when the parent already created the intent/subscription (via
59
+ * `start` / `startSubscription`) so the form confirms THAT intent rather than
60
+ * spinning up a second `useCheckout` (which would create a duplicate
61
+ * intent/subscription on submit). When omitted, the form is self-driving and
62
+ * lazily creates a one-time intent on submit (legacy one-time path).
63
+ */
64
+ checkout?: UseCheckoutResult;
65
+ }
66
+
67
+ export function CheckoutForm({
68
+ amount,
69
+ currency,
70
+ reference: _reference,
71
+ summaryTitle,
72
+ summaryNote,
73
+ paymentField,
74
+ isMock = false,
75
+ elementsContext,
76
+ returnUrl,
77
+ onSuccess,
78
+ onFailure,
79
+ onCancel,
80
+ submitLabel,
81
+ cancelLabel = 'Cancel',
82
+ fieldLabel = 'Card',
83
+ locale,
84
+ className,
85
+ checkout,
86
+ }: CheckoutFormProps) {
87
+ // Use the parent's shared machine when provided; else self-drive (one-time).
88
+ // Hooks run unconditionally (Rules of Hooks); we just pick which result wins.
89
+ const ownCheckout = useCheckout();
90
+ const { status, error, isBusy, start, confirm, intent } = checkout ?? ownCheckout;
91
+ const isShared = Boolean(checkout);
92
+
93
+ // Publish status into the dialog's CheckoutGuard so the shell can block
94
+ // Esc / overlay-click / × while a charge is in flight (no-op if there's no
95
+ // guard, e.g. a standalone form).
96
+ usePublishCheckoutStatus(status);
97
+ const locked = isCheckoutLocked(status);
98
+
99
+ const formatted = formatAmount(amount, currency, locale);
100
+ const isDone = status === 'succeeded';
101
+ const needsAction = status === 'requires_action';
102
+
103
+ const handlePay = useCallback(
104
+ async (e: React.FormEvent) => {
105
+ e.preventDefault();
106
+ // Self-driving mode only: lazily create the one-time intent on first
107
+ // submit. In shared mode the parent already created the intent/subscription
108
+ // (start / startSubscription) — creating another here would duplicate it,
109
+ // so we go straight to confirm.
110
+ if (!isShared && !intent) {
111
+ await start({ amount, currency, reference: _reference });
112
+ }
113
+ await confirm(elementsContext, returnUrl);
114
+ },
115
+ [isShared, intent, start, amount, currency, _reference, confirm, elementsContext, returnUrl],
116
+ );
117
+
118
+ // Fire host callbacks on terminal states.
119
+ React.useEffect(() => {
120
+ if (status === 'succeeded' && intent) onSuccess?.(intent.id);
121
+ if (status === 'failed' && error) onFailure?.(error);
122
+ // eslint-disable-next-line react-hooks/exhaustive-deps
123
+ }, [status]);
124
+
125
+ return (
126
+ <form onSubmit={handlePay} className={className}>
127
+ <div className="space-y-5">
128
+ {/* Summary block — what you're paying for + the amount. */}
129
+ {summaryTitle ? (
130
+ <div className="space-y-1">
131
+ <p className="text-foreground text-sm font-medium leading-snug">{summaryTitle}</p>
132
+ <p className="text-muted-foreground text-sm">
133
+ <span className="text-foreground font-semibold">{formatted}</span>
134
+ {summaryNote ? <span> · {summaryNote}</span> : null}
135
+ </p>
136
+ </div>
137
+ ) : null}
138
+
139
+ {summaryTitle ? <Separator /> : null}
140
+
141
+ {/* Payment field. No mini "CARD" label — the accordion self-labels its
142
+ rows, so the extra uppercase caption was redundant chrome (plan7). */}
143
+ <div>
144
+ {paymentField ? (
145
+ <div className="min-h-[40px]">{paymentField}</div>
146
+ ) : isMock ? (
147
+ <div className="space-y-2">
148
+ <p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
149
+ {fieldLabel}
150
+ </p>
151
+ <div className="border-border bg-muted/30 text-muted-foreground flex h-10 items-center rounded-md border px-3 text-sm">
152
+ 4242 4242 4242 4242
153
+ </div>
154
+ </div>
155
+ ) : null}
156
+ </div>
157
+
158
+ {error ? (
159
+ <Alert variant="destructive">
160
+ <AlertDescription>{error}</AlertDescription>
161
+ </Alert>
162
+ ) : null}
163
+
164
+ {needsAction ? (
165
+ <Alert>
166
+ <AlertTriangle className="h-4 w-4" />
167
+ <AlertDescription>
168
+ Additional authentication is required to complete this payment.
169
+ </AlertDescription>
170
+ </Alert>
171
+ ) : null}
172
+
173
+ {/* Primary CTA. */}
174
+ <Button type="submit" size="lg" className="w-full" disabled={isBusy || isDone}>
175
+ {isBusy ? (
176
+ <>
177
+ <Spinner className="mr-1.5 h-4 w-4" />
178
+ Processing…
179
+ </>
180
+ ) : isDone ? (
181
+ <>
182
+ <ShieldCheck className="mr-1.5 h-4 w-4" />
183
+ Paid
184
+ </>
185
+ ) : (
186
+ (submitLabel ?? `Pay ${formatted}`)
187
+ )}
188
+ </Button>
189
+
190
+ {/* Hide the dismiss control entirely while a charge is in flight — no
191
+ competing action, and closing now would orphan the intent. */}
192
+ {onCancel && !locked ? (
193
+ <Button
194
+ type="button"
195
+ variant="ghost"
196
+ size="sm"
197
+ className="text-muted-foreground w-full"
198
+ onClick={onCancel}
199
+ disabled={isBusy}
200
+ >
201
+ {cancelLabel}
202
+ </Button>
203
+ ) : null}
204
+
205
+ {/* Secure footer. */}
206
+ <p className="text-muted-foreground flex items-center justify-center gap-1.5 text-xs">
207
+ <Lock className="h-3 w-3" />
208
+ Secure payment · Powered by Stripe
209
+ </p>
210
+ </div>
211
+ </form>
212
+ );
213
+ }