@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 +21 -0
- package/README.md +178 -0
- package/package.json +80 -0
- package/src/components/CheckoutDialog.tsx +122 -0
- package/src/components/CheckoutForm.tsx +213 -0
- package/src/components/PaymentHistory.tsx +79 -0
- package/src/components/PaymentStatusBadge.tsx +94 -0
- package/src/components/StripePaymentElement.tsx +274 -0
- package/src/components/appearance.ts +131 -0
- package/src/components/index.ts +21 -0
- package/src/context/CheckoutGuard.tsx +94 -0
- package/src/context/PaymentProvider.tsx +38 -0
- package/src/domain/index.ts +23 -0
- package/src/domain/money.ts +48 -0
- package/src/domain/types.ts +153 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useCheckout.ts +160 -0
- package/src/hooks/usePaymentHistory.ts +80 -0
- package/src/index.ts +64 -0
- package/src/styles.css +14 -0
- package/src/transport/index.ts +13 -0
- package/src/transport/mock-adapter.ts +149 -0
- package/src/transport/stripe-adapter.ts +199 -0
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
|
+
}
|