@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.
@@ -0,0 +1,79 @@
1
+ 'use client';
2
+
3
+ // ============================================================================
4
+ // @djangocfg/payments — PaymentHistory
5
+ // ============================================================================
6
+ // A DataTable of PaymentRecord rows. Uses the ui-tools DataTable (subpath
7
+ // import, per the in-repo rule) and the PaymentStatusBadge. Pure
8
+ // presentational — the host feeds `records` (e.g. from usePaymentHistory()).
9
+
10
+ import React, { useMemo } from 'react';
11
+ import { DataTable, type DataTableColumn } from '@djangocfg/ui-tools/data-table';
12
+ import { PaymentStatusBadge } from './PaymentStatusBadge';
13
+ import { formatAmount } from '../domain/money';
14
+ import type { PaymentRecord } from '../domain/types';
15
+
16
+ export interface PaymentHistoryProps {
17
+ records: PaymentRecord[];
18
+ loading?: boolean;
19
+ emptyMessage?: string;
20
+ onRowClick?: (record: PaymentRecord) => void;
21
+ locale?: string;
22
+ className?: string;
23
+ }
24
+
25
+ export function PaymentHistory({
26
+ records,
27
+ loading,
28
+ emptyMessage = 'No payments yet.',
29
+ onRowClick,
30
+ locale,
31
+ className,
32
+ }: PaymentHistoryProps) {
33
+ const columns = useMemo<DataTableColumn<PaymentRecord>[]>(
34
+ () => [
35
+ {
36
+ key: 'createdAt',
37
+ header: 'Date',
38
+ sortable: true,
39
+ cell: (p) => new Date(p.createdAt).toLocaleDateString(locale),
40
+ },
41
+ {
42
+ key: 'reference',
43
+ header: 'For',
44
+ cell: (p) => `${p.reference.kind} · ${p.reference.id}`,
45
+ },
46
+ {
47
+ key: 'amount',
48
+ header: 'Amount',
49
+ sortable: true,
50
+ cell: (p) => formatAmount(p.amount, p.currency, locale),
51
+ },
52
+ {
53
+ key: 'provider',
54
+ header: 'Provider',
55
+ cell: (p) => p.provider,
56
+ },
57
+ {
58
+ key: 'status',
59
+ header: 'Status',
60
+ cell: (p) => <PaymentStatusBadge status={p.status} />,
61
+ },
62
+ ],
63
+ [locale],
64
+ );
65
+
66
+ return (
67
+ <div className={className}>
68
+ <DataTable
69
+ data={records}
70
+ columns={columns}
71
+ getRowId={(p) => p.id}
72
+ loading={loading}
73
+ emptyMessage={emptyMessage}
74
+ onRowClick={onRowClick}
75
+ getRowClassName={onRowClick ? () => 'cursor-pointer' : undefined}
76
+ />
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1,94 @@
1
+ 'use client';
2
+
3
+ // ============================================================================
4
+ // @djangocfg/payments — PaymentStatusBadge
5
+ // ============================================================================
6
+ // Stripe-like status pill: a colored dot + label on a subtle tinted chip.
7
+ // Self-contained (no ui-core Badge) so the dot tone and chip tint stay in sync.
8
+
9
+ import React from 'react';
10
+
11
+ import type { PaymentStatus } from '../domain/types';
12
+
13
+ export interface PaymentStatusBadgeProps {
14
+ status: PaymentStatus;
15
+ /** Optional label override (host supplies translated text). */
16
+ label?: string;
17
+ className?: string;
18
+ }
19
+
20
+ interface StatusStyle {
21
+ /** Tailwind classes for the chip (bg tint + text). */
22
+ chip: string;
23
+ /** Tailwind class for the dot. */
24
+ dot: string;
25
+ defaultLabel: string;
26
+ }
27
+
28
+ // ui-core semantic status tokens (success/warning/info/destructive
29
+ // background+foreground pairs) — contrast-tuned per theme, unlike raw
30
+ // palette tints which wash out on light backgrounds. Borderless by design:
31
+ // tint + toned text carry the state (Stripe-dashboard style).
32
+ const STATUS_STYLES: Record<PaymentStatus, StatusStyle> = {
33
+ idle: {
34
+ chip: 'bg-muted text-muted-foreground',
35
+ dot: 'bg-muted-foreground/50',
36
+ defaultLabel: 'Not started',
37
+ },
38
+ creating: {
39
+ chip: 'bg-muted text-muted-foreground',
40
+ dot: 'bg-muted-foreground/60',
41
+ defaultLabel: 'Preparing',
42
+ },
43
+ requires_payment: {
44
+ chip: 'bg-warning-background text-warning-foreground',
45
+ dot: 'bg-warning',
46
+ defaultLabel: 'Awaiting payment',
47
+ },
48
+ processing: {
49
+ chip: 'bg-info-background text-info-foreground',
50
+ dot: 'bg-info animate-pulse',
51
+ defaultLabel: 'Processing',
52
+ },
53
+ requires_action: {
54
+ chip: 'bg-warning-background text-warning-foreground',
55
+ dot: 'bg-warning',
56
+ defaultLabel: 'Action required',
57
+ },
58
+ succeeded: {
59
+ chip: 'bg-success-background text-success-foreground',
60
+ dot: 'bg-success',
61
+ defaultLabel: 'Paid',
62
+ },
63
+ failed: {
64
+ // NB: --destructive-foreground is near-white (text on SOLID destructive),
65
+ // not a chip foreground like the success/warning/info triples — use the
66
+ // base --destructive tone for text on the pale background.
67
+ chip: 'bg-destructive-background text-destructive',
68
+ dot: 'bg-destructive',
69
+ defaultLabel: 'Failed',
70
+ },
71
+ canceled: {
72
+ chip: 'bg-muted text-muted-foreground',
73
+ dot: 'bg-muted-foreground/50',
74
+ defaultLabel: 'Canceled',
75
+ },
76
+ };
77
+
78
+ export function PaymentStatusBadge({ status, label, className }: PaymentStatusBadgeProps) {
79
+ const style = STATUS_STYLES[status];
80
+ return (
81
+ <span
82
+ className={[
83
+ 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
84
+ style.chip,
85
+ className,
86
+ ]
87
+ .filter(Boolean)
88
+ .join(' ')}
89
+ >
90
+ <span className={`h-1.5 w-1.5 rounded-full ${style.dot}`} />
91
+ {label ?? style.defaultLabel}
92
+ </span>
93
+ );
94
+ }
@@ -0,0 +1,274 @@
1
+ 'use client';
2
+
3
+ // ============================================================================
4
+ // @djangocfg/payments — StripePaymentElement
5
+ // ============================================================================
6
+ // One of TWO files allowed to import @stripe/* (the other is stripe-adapter).
7
+ // Wraps the Stripe <Elements> provider + <PaymentElement>, and exposes the
8
+ // live Elements instance to the host via a render prop so it can be handed to
9
+ // the adapter's confirm() (CheckoutForm's `elementsContext`).
10
+ //
11
+ // Theming: Stripe renders in a cross-origin iframe that app CSS can't touch, so
12
+ // we pass an `appearance` derived from the live ui-core design tokens (via
13
+ // useThemePalette) with `theme: 'night' | 'stripe'`. Without this the form is a
14
+ // WHITE box on our dark dialog. Appearance is MUTABLE — we update it in place on
15
+ // a theme toggle (see the <Elements> note below), we do NOT remount, so a
16
+ // half-typed card isn't wiped.
17
+ //
18
+ // Placement: the host decides where the card field goes. Instead of rendering
19
+ // <PaymentElement/> next to the children, we pass a ready-to-place node INTO
20
+ // the render prop, so the host can drop it under CheckoutForm's "CARD" label as
21
+ // the single `paymentField` (no duplicate mock stub).
22
+ //
23
+ // Load handling: loadStripe() REJECTS (does not resolve null) when Stripe.js
24
+ // can't load from the CDN (network/blocked). We await it explicitly so we can
25
+ // render an honest error + Retry instead of a stuck spinner or a misleading
26
+ // fallback. Only mount <Elements> once Stripe actually resolved.
27
+
28
+ import React, { useEffect, useMemo, useState } from 'react';
29
+ import { Elements, PaymentElement, useElements } from '@stripe/react-stripe-js';
30
+ import type {
31
+ Stripe,
32
+ StripeElementsOptionsClientSecret,
33
+ StripePaymentElementOptions,
34
+ } from '@stripe/stripe-js';
35
+ import {
36
+ Alert,
37
+ AlertDescription,
38
+ Button,
39
+ Spinner,
40
+ useResolvedTheme,
41
+ } from '@djangocfg/ui-core';
42
+ import { useThemePalette } from '@djangocfg/ui-core/styles/palette';
43
+ import { AlertTriangle } from 'lucide-react';
44
+ import { buildStripeAppearance } from './appearance';
45
+
46
+ /** Live elements context handed to the host for confirm(). */
47
+ export type StripeElementsContext = ReturnType<typeof useElements>;
48
+
49
+ /**
50
+ * Card-focused defaults — the calm, single-column checkout (plan7).
51
+ *
52
+ * ACCORDION, not tabs. The old 4-tile tab grid (Card · Klarna · Cash App ·
53
+ * Amazon Pay) was the main source of clutter — four equal-weight tiles for a
54
+ * choice that is almost always "card". Accordion (Stripe's default since
55
+ * 2025-03-31) shows the card form open and fillable, with any surviving
56
+ * alternative methods as quiet collapsed rows below it:
57
+ * - `defaultCollapsed: false` → card form open immediately (no extra click).
58
+ * - `radios: 'never'` + `spacedAccordionItems: false` → the two cheapest
59
+ * height wins; tight rows, no radio column, no inter-item gaps.
60
+ *
61
+ * Which methods appear is driven by the intent (server-side / Dashboard), NOT
62
+ * hand-pinned here: for a subscription Stripe auto-hides non-recurring methods,
63
+ * so we don't need `paymentMethodOrder` to filter — dropping it also keeps
64
+ * Link's inline prompt visible. Klarna is disabled in the Dashboard (heaviest
65
+ * row, least subscription value); Cash App / Amazon Pay survive collapsed.
66
+ *
67
+ * Link is LEFT ON: it renders inline *inside* the card form (not a tall express
68
+ * block), so it lifts conversion without bloating the dialog. Pass `email`
69
+ * (and `phone` where available) below so returning Link users go straight to a
70
+ * short OTP. We still suppress the tall Apple/Google Pay express blocks.
71
+ * Prereq: Link enabled in Dashboard + domain registered — until then Link
72
+ * simply doesn't render, so this is safe to ship ahead of it.
73
+ */
74
+ export const DEFAULT_PAYMENT_ELEMENT_OPTIONS: StripePaymentElementOptions = {
75
+ layout: {
76
+ type: 'accordion',
77
+ defaultCollapsed: false,
78
+ radios: 'never',
79
+ spacedAccordionItems: false,
80
+ },
81
+ wallets: { applePay: 'never', googlePay: 'never' },
82
+ fields: { billingDetails: { address: 'never' } },
83
+ };
84
+
85
+ export interface StripePaymentElementProps {
86
+ /** loadStripe(publishableKey), memoized once at host module scope. */
87
+ stripe: Promise<Stripe | null>;
88
+ /** Client secret created on the backend for this PaymentIntent. */
89
+ clientSecret: string;
90
+ /**
91
+ * Render prop receiving the live Stripe Elements context AND a ready-to-place
92
+ * <PaymentElement/> node. Pass `field` where you want the card input to
93
+ * appear (e.g. CheckoutForm's `paymentField`) and `elements` to
94
+ * <CheckoutForm elementsContext={elements}>.
95
+ */
96
+ children: (args: {
97
+ elements: StripeElementsContext;
98
+ field: React.ReactNode;
99
+ }) => React.ReactNode;
100
+ /** Optional Elements appearance/options overrides (merged after appearance). */
101
+ options?: Omit<StripeElementsOptionsClientSecret, 'clientSecret' | 'appearance'>;
102
+ /** Override the compact PaymentElement options (layout, wallets, fields…). */
103
+ paymentElementOptions?: StripePaymentElementOptions;
104
+ /**
105
+ * Logged-in user's email. Passed to the PaymentElement as
106
+ * `defaultValues.billingDetails.email` so Stripe Link can autofill / one-click
107
+ * for returning users. Stripe recommends supplying it when available.
108
+ */
109
+ email?: string;
110
+ /**
111
+ * Logged-in user's phone (E.164). Prefilling it alongside email pre-targets
112
+ * Link's SMS OTP so a returning user lands on the code with fewer fields —
113
+ * shrinks Link's inline block. Optional; omit if unknown.
114
+ */
115
+ phone?: string;
116
+ /**
117
+ * Called when Stripe.js fails to load. The host can disable its own Pay
118
+ * affordance, log, etc. Receives the error message.
119
+ */
120
+ onLoadError?: (message: string) => void;
121
+ }
122
+
123
+ type LoadState =
124
+ | { phase: 'loading' }
125
+ | { phase: 'ready'; stripe: Stripe }
126
+ | { phase: 'error'; message: string };
127
+
128
+ /** Inner component: must live under <Elements> to call useElements(). */
129
+ function ElementsBridge({
130
+ children,
131
+ paymentElementOptions,
132
+ email,
133
+ phone,
134
+ }: {
135
+ children: StripePaymentElementProps['children'];
136
+ paymentElementOptions: StripePaymentElementOptions;
137
+ email?: string;
138
+ phone?: string;
139
+ }) {
140
+ const elements = useElements();
141
+ // Prefill email + phone so Stripe Link recognizes the returning user and
142
+ // targets the SMS OTP immediately (shorter Link block). Merge (don't clobber)
143
+ // any caller-provided defaultValues.
144
+ const options = useMemo<StripePaymentElementOptions>(() => {
145
+ if (!email && !phone) return paymentElementOptions;
146
+ return {
147
+ ...paymentElementOptions,
148
+ defaultValues: {
149
+ ...paymentElementOptions.defaultValues,
150
+ billingDetails: {
151
+ ...paymentElementOptions.defaultValues?.billingDetails,
152
+ ...(email ? { email } : {}),
153
+ ...(phone ? { phone } : {}),
154
+ },
155
+ },
156
+ };
157
+ }, [paymentElementOptions, email, phone]);
158
+ const field = <PaymentElement options={options} />;
159
+ return <>{children({ elements, field })}</>;
160
+ }
161
+
162
+ export function StripePaymentElement({
163
+ stripe,
164
+ clientSecret,
165
+ children,
166
+ options,
167
+ paymentElementOptions = DEFAULT_PAYMENT_ELEMENT_OPTIONS,
168
+ email,
169
+ phone,
170
+ onLoadError,
171
+ }: StripePaymentElementProps) {
172
+ const [state, setState] = useState<LoadState>({ phase: 'loading' });
173
+ // Bump to retry loading.
174
+ const [attempt, setAttempt] = useState(0);
175
+
176
+ // Drive the Stripe appearance from the live design tokens. useThemePalette
177
+ // returns hex (safe for Stripe's core color vars); useResolvedTheme picks the
178
+ // base 'night'/'stripe' theme and is the remount key.
179
+ const theme = useResolvedTheme();
180
+ const palette = useThemePalette();
181
+ const appearance = useMemo(
182
+ () =>
183
+ buildStripeAppearance(theme, {
184
+ primary: palette.primary,
185
+ input: palette.input,
186
+ foreground: palette.foreground,
187
+ mutedForeground: palette.mutedForeground,
188
+ border: palette.border,
189
+ ring: palette.ring,
190
+ destructive: palette.destructive,
191
+ }),
192
+ [theme, palette],
193
+ );
194
+
195
+ useEffect(() => {
196
+ let active = true;
197
+ setState({ phase: 'loading' });
198
+ stripe
199
+ .then((s) => {
200
+ if (!active) return;
201
+ if (s) {
202
+ setState({ phase: 'ready', stripe: s });
203
+ } else {
204
+ const msg = 'Stripe is not configured.';
205
+ setState({ phase: 'error', message: msg });
206
+ onLoadError?.(msg);
207
+ }
208
+ })
209
+ .catch((e: unknown) => {
210
+ if (!active) return;
211
+ const msg =
212
+ e instanceof Error ? e.message : 'Failed to load the payment form.';
213
+ setState({ phase: 'error', message: msg });
214
+ onLoadError?.(msg);
215
+ });
216
+ return () => {
217
+ active = false;
218
+ };
219
+ // `attempt` re-runs the load; `stripe` is a stable host-memoized promise.
220
+ // eslint-disable-next-line react-hooks/exhaustive-deps
221
+ }, [stripe, attempt]);
222
+
223
+ if (state.phase === 'loading') {
224
+ return (
225
+ <div className="flex flex-col items-center gap-2 py-8">
226
+ <Spinner className="h-5 w-5" />
227
+ <p className="text-muted-foreground text-sm">Loading secure payment form…</p>
228
+ </div>
229
+ );
230
+ }
231
+
232
+ if (state.phase === 'error') {
233
+ return (
234
+ <div className="space-y-3 py-2">
235
+ <Alert variant="destructive">
236
+ <AlertTriangle className="h-4 w-4" />
237
+ <AlertDescription>
238
+ {state.message} Check your connection and try again.
239
+ </AlertDescription>
240
+ </Alert>
241
+ <Button
242
+ type="button"
243
+ variant="outline"
244
+ className="w-full"
245
+ onClick={() => setAttempt((n) => n + 1)}
246
+ >
247
+ Retry
248
+ </Button>
249
+ </div>
250
+ );
251
+ }
252
+
253
+ const elementsOptions: StripeElementsOptionsClientSecret = {
254
+ ...options,
255
+ clientSecret,
256
+ appearance,
257
+ };
258
+ return (
259
+ // NO `key={theme}` remount: @stripe/react-stripe-js diffs the `options` prop
260
+ // and calls `elements.update({ appearance })` live for mutable keys
261
+ // (appearance is mutable; only clientSecret/fonts are immutable). Remounting
262
+ // on a theme flip would tear down the iframe and WIPE any half-typed card
263
+ // details — updating in place re-themes without losing input.
264
+ <Elements stripe={state.stripe} options={elementsOptions}>
265
+ <ElementsBridge
266
+ paymentElementOptions={paymentElementOptions}
267
+ email={email}
268
+ phone={phone}
269
+ >
270
+ {children}
271
+ </ElementsBridge>
272
+ </Elements>
273
+ );
274
+ }
@@ -0,0 +1,131 @@
1
+ // ============================================================================
2
+ // @djangocfg/payments — Stripe Appearance builder (design-token driven)
3
+ // ============================================================================
4
+ // Maps the live ui-core design tokens (macos preset) onto Stripe's Appearance
5
+ // API so the Payment Element (rendered in a cross-origin iframe, un-styleable
6
+ // from the app) matches the surrounding dialog in BOTH light and dark themes.
7
+ //
8
+ // Why this file exists: without an `appearance`, Stripe uses its default light
9
+ // `theme: 'stripe'` and renders a WHITE form on our dark dialog. Only the
10
+ // Appearance API can touch the iframe, so we translate our tokens into it.
11
+ //
12
+ // Stripe constraint: the core color variables (colorPrimary/colorBackground/
13
+ // colorText/colorDanger/colorSuccess/colorWarning) must be CONCRETE colors —
14
+ // `var(...)` and `rgba(...)` are rejected there. ui-core's palette hooks return
15
+ // hex, so we read from that (passed in as `tokens`) rather than forwarding raw
16
+ // `hsl(var(--x))` token strings.
17
+
18
+ import type { Appearance } from '@stripe/stripe-js';
19
+
20
+ /** Concrete hex/color values read from the live theme (ui-core palette). */
21
+ export interface AppearanceTokens {
22
+ /** Brand CTA / accent — `--primary`. */
23
+ primary: string;
24
+ /** Input fill — `--input` (falls back to `--background`). */
25
+ input: string;
26
+ /** Body text — `--foreground`. */
27
+ foreground: string;
28
+ /** Secondary text / placeholders — `--muted-foreground`. */
29
+ mutedForeground: string;
30
+ /** Control outline — `--border`. */
31
+ border: string;
32
+ /** Focus ring (blue, ui-core pattern) — `--ring`. */
33
+ ring: string;
34
+ /** Error color — `--destructive`. */
35
+ destructive: string;
36
+ /** Resolved font stack — `--font-sans`. */
37
+ fontFamily?: string;
38
+ /** Control radius, e.g. `0.625rem` — `--radius-control`. */
39
+ borderRadius?: string;
40
+ }
41
+
42
+ /** Sensible dark defaults if the theme can't be read (SSR / probe failure). */
43
+ const DARK_FALLBACK: AppearanceTokens = {
44
+ primary: '#0a84ff',
45
+ input: '#1c1c1e',
46
+ foreground: '#f5f5f7',
47
+ mutedForeground: '#98989d',
48
+ border: '#38383a',
49
+ ring: '#0a84ff',
50
+ destructive: '#ff453a',
51
+ };
52
+
53
+ /** Sensible light defaults if the theme can't be read. */
54
+ const LIGHT_FALLBACK: AppearanceTokens = {
55
+ primary: '#0071e3',
56
+ input: '#ffffff',
57
+ foreground: '#1d1d1f',
58
+ mutedForeground: '#6e6e73',
59
+ border: '#d2d2d7',
60
+ ring: '#0071e3',
61
+ destructive: '#ff3b30',
62
+ };
63
+
64
+ /**
65
+ * Build a Stripe {@link Appearance} from resolved design tokens.
66
+ *
67
+ * @param theme 'light' | 'dark' — picks the base Stripe theme + fallback set.
68
+ * @param tokens Concrete color/typography values from the live theme. When
69
+ * omitted (SSR), a matching light/dark fallback is used so the
70
+ * form is never white-on-dark.
71
+ */
72
+ export function buildStripeAppearance(
73
+ theme: 'light' | 'dark',
74
+ tokens?: Partial<AppearanceTokens>,
75
+ ): Appearance {
76
+ const base = theme === 'dark' ? DARK_FALLBACK : LIGHT_FALLBACK;
77
+ const t: AppearanceTokens = { ...base, ...tokens };
78
+
79
+ const fontFamily =
80
+ t.fontFamily ||
81
+ '-apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif';
82
+ const borderRadius = t.borderRadius || '0.625rem';
83
+
84
+ return {
85
+ theme: theme === 'dark' ? 'night' : 'stripe',
86
+ labels: 'above',
87
+ variables: {
88
+ colorPrimary: t.primary,
89
+ colorBackground: t.input,
90
+ colorText: t.foreground,
91
+ colorTextSecondary: t.mutedForeground,
92
+ colorTextPlaceholder: t.mutedForeground,
93
+ colorDanger: t.destructive,
94
+ colorIcon: t.mutedForeground,
95
+ fontFamily,
96
+ fontSizeBase: '16px', // ≥16px avoids mobile input auto-zoom
97
+ spacingUnit: '4px',
98
+ borderRadius,
99
+ },
100
+ rules: {
101
+ '.Input': {
102
+ border: `1px solid ${t.border}`,
103
+ boxShadow: 'none',
104
+ },
105
+ // Apple-minimal focus: a clean 1px ring, no fat blue glow.
106
+ '.Input:focus': {
107
+ border: `1px solid ${t.ring}`,
108
+ boxShadow: `0 0 0 1px ${t.ring}`,
109
+ outline: 'none',
110
+ },
111
+ '.Input--invalid': {
112
+ border: `1px solid ${t.destructive}`,
113
+ boxShadow: `0 0 0 1px ${t.destructive}`,
114
+ },
115
+ '.Tab': {
116
+ border: `1px solid ${t.border}`,
117
+ boxShadow: 'none',
118
+ },
119
+ '.Tab:hover': {
120
+ border: `1px solid ${t.border}`,
121
+ },
122
+ '.Tab--selected': {
123
+ border: `1px solid ${t.primary}`,
124
+ boxShadow: `0 0 0 1px ${t.primary}`,
125
+ },
126
+ '.Label': {
127
+ color: t.mutedForeground,
128
+ },
129
+ },
130
+ };
131
+ }
@@ -0,0 +1,21 @@
1
+ // ============================================================================
2
+ // @djangocfg/payments — components barrel
3
+ // ============================================================================
4
+
5
+ export { CheckoutDialog } from './CheckoutDialog';
6
+ export type { CheckoutDialogProps } from './CheckoutDialog';
7
+
8
+ export { CheckoutForm } from './CheckoutForm';
9
+ export type { CheckoutFormProps } from './CheckoutForm';
10
+
11
+ export { StripePaymentElement, DEFAULT_PAYMENT_ELEMENT_OPTIONS } from './StripePaymentElement';
12
+ export type { StripePaymentElementProps, StripeElementsContext } from './StripePaymentElement';
13
+
14
+ export { buildStripeAppearance } from './appearance';
15
+ export type { AppearanceTokens } from './appearance';
16
+
17
+ export { PaymentHistory } from './PaymentHistory';
18
+ export type { PaymentHistoryProps } from './PaymentHistory';
19
+
20
+ export { PaymentStatusBadge } from './PaymentStatusBadge';
21
+ export type { PaymentStatusBadgeProps } from './PaymentStatusBadge';
@@ -0,0 +1,94 @@
1
+ 'use client';
2
+
3
+ // ============================================================================
4
+ // @djangocfg/payments — CheckoutGuard (the dialog's own checkout context)
5
+ // ============================================================================
6
+ // A tiny context shared by a checkout dialog and its inner form so BOTH read
7
+ // ONE source of truth for "is a payment in flight". The inner form (which owns
8
+ // the useCheckout machine) publishes its status up; the outer <Dialog> reads
9
+ // `canClose` to gate Esc / overlay-click / the × button, and any component in
10
+ // the tree can read the same status.
11
+ //
12
+ // Why a context and not props: the dialog shell and the form are on opposite
13
+ // sides of the Stripe <Elements> boundary — threading a setter through
14
+ // StripePaymentElement's render prop would be brittle. The provider wraps the
15
+ // whole dialog; the form pushes status via `usePublishCheckoutStatus`, the shell
16
+ // consumes via `useCheckoutGuard`. Matches @rules_2.0 03-DIALOGS (registry owns
17
+ // open/close; this owns the in-flight lock, orthogonally).
18
+
19
+ import {
20
+ createContext,
21
+ useContext,
22
+ useEffect,
23
+ useMemo,
24
+ useState,
25
+ type ReactNode,
26
+ } from 'react';
27
+ import type { PaymentStatus } from '../domain/types';
28
+
29
+ /**
30
+ * Statuses during which the dialog MUST NOT close — a charge is mid-flight or
31
+ * awaiting 3DS, and tearing down <Elements> now would orphan the intent.
32
+ */
33
+ export function isCheckoutLocked(status: PaymentStatus): boolean {
34
+ return status === 'processing' || status === 'requires_action';
35
+ }
36
+
37
+ interface CheckoutGuardValue {
38
+ readonly status: PaymentStatus;
39
+ /** False while a payment is in flight / awaiting action. */
40
+ readonly canClose: boolean;
41
+ readonly setStatus: (status: PaymentStatus) => void;
42
+ }
43
+
44
+ const CheckoutGuardContext = createContext<CheckoutGuardValue | null>(null);
45
+
46
+ export function CheckoutGuardProvider({ children }: { children: ReactNode }) {
47
+ const [status, setStatus] = useState<PaymentStatus>('idle');
48
+ const value = useMemo<CheckoutGuardValue>(
49
+ () => ({ status, canClose: !isCheckoutLocked(status), setStatus }),
50
+ [status],
51
+ );
52
+ return (
53
+ <CheckoutGuardContext.Provider value={value}>
54
+ {children}
55
+ </CheckoutGuardContext.Provider>
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Read the guard from the dialog shell. Returns `canClose` (false while locked)
61
+ * plus the live status. Safe outside a provider — defaults to "can close".
62
+ */
63
+ export function useCheckoutGuard(): Pick<CheckoutGuardValue, 'status' | 'canClose'> {
64
+ const ctx = useContext(CheckoutGuardContext);
65
+ if (!ctx) return { status: 'idle', canClose: true };
66
+ return { status: ctx.status, canClose: ctx.canClose };
67
+ }
68
+
69
+ /**
70
+ * Inner-form hook: pushes the live checkout status into the guard so the shell
71
+ * can gate close. No-op when rendered outside a provider (e.g. a standalone
72
+ * form). Also installs a `beforeunload` guard while locked so a full-page
73
+ * refresh mid-confirm prompts the user.
74
+ */
75
+ export function usePublishCheckoutStatus(status: PaymentStatus): void {
76
+ const ctx = useContext(CheckoutGuardContext);
77
+ const setStatus = ctx?.setStatus;
78
+
79
+ useEffect(() => {
80
+ setStatus?.(status);
81
+ }, [setStatus, status]);
82
+
83
+ const locked = isCheckoutLocked(status);
84
+ useEffect(() => {
85
+ if (!locked) return;
86
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
87
+ e.preventDefault();
88
+ // Legacy Chrome requires a returnValue to trigger the prompt.
89
+ e.returnValue = '';
90
+ };
91
+ window.addEventListener('beforeunload', onBeforeUnload);
92
+ return () => window.removeEventListener('beforeunload', onBeforeUnload);
93
+ }, [locked]);
94
+ }