@fantastic.dev/better-stripe 0.13.0 → 0.15.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/CHANGELOG.md +17 -0
- package/README.md +63 -1
- package/dist/client/billing/checkoutOptions.d.ts +27 -0
- package/dist/client/billing/checkoutOptions.d.ts.map +1 -0
- package/dist/client/billing/checkoutOptions.js +2 -0
- package/dist/client/billing/checkoutOptions.js.map +1 -0
- package/dist/client/billing/intents.d.ts +32 -0
- package/dist/client/billing/intents.d.ts.map +1 -0
- package/dist/client/billing/intents.js +71 -0
- package/dist/client/billing/intents.js.map +1 -0
- package/dist/client/connect/disputes.d.ts +20 -0
- package/dist/client/connect/disputes.d.ts.map +1 -1
- package/dist/client/connect/disputes.js +55 -0
- package/dist/client/connect/disputes.js.map +1 -1
- package/dist/client/core/fees.d.ts +2 -0
- package/dist/client/core/fees.d.ts.map +1 -1
- package/dist/client/core/fees.js +7 -0
- package/dist/client/core/fees.js.map +1 -1
- package/dist/client/costRecovery-invoices.d.ts +10 -4
- package/dist/client/costRecovery-invoices.d.ts.map +1 -1
- package/dist/client/costRecovery-invoices.js +4 -0
- package/dist/client/costRecovery-invoices.js.map +1 -1
- package/dist/client/costRecovery.d.ts +18 -2
- package/dist/client/costRecovery.d.ts.map +1 -1
- package/dist/client/costRecovery.js +4 -0
- package/dist/client/costRecovery.js.map +1 -1
- package/dist/client/errors.d.ts +1 -1
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js.map +1 -1
- package/dist/client/index.d.ts +20 -37
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +18 -20
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +59 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/costRecovery/claims.d.ts +12 -0
- package/dist/component/costRecovery/claims.d.ts.map +1 -1
- package/dist/component/costRecovery/imports.d.ts +53 -0
- package/dist/component/costRecovery/imports.d.ts.map +1 -0
- package/dist/component/costRecovery/imports.js +67 -0
- package/dist/component/costRecovery/imports.js.map +1 -0
- package/dist/component/costRecovery/mutations.d.ts +47 -0
- package/dist/component/costRecovery/mutations.d.ts.map +1 -1
- package/dist/component/costRecovery/mutations.js +7 -1
- package/dist/component/costRecovery/mutations.js.map +1 -1
- package/dist/component/costRecovery/queries.d.ts +12 -0
- package/dist/component/costRecovery/queries.d.ts.map +1 -1
- package/dist/component/costRecovery/schema.d.ts +72 -2
- package/dist/component/costRecovery/schema.d.ts.map +1 -1
- package/dist/component/costRecovery/schema.js +13 -0
- package/dist/component/costRecovery/schema.js.map +1 -1
- package/dist/component/schema.d.ts +19 -1
- package/dist/react/hooks/useConfirmPayment.d.ts +1 -1
- package/dist/react/hooks/useConfirmPayment.d.ts.map +1 -1
- package/dist/react/hooks/useConfirmPayment.js +13 -2
- package/dist/react/hooks/useConfirmPayment.js.map +1 -1
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/workflows/EvidenceUpload.d.ts +18 -0
- package/dist/react/workflows/EvidenceUpload.d.ts.map +1 -0
- package/dist/react/workflows/EvidenceUpload.js +35 -0
- package/dist/react/workflows/EvidenceUpload.js.map +1 -0
- package/dist/react/workflows/Onboarding.d.ts +3 -3
- package/dist/react/workflows/Onboarding.d.ts.map +1 -1
- package/dist/react/workflows/Onboarding.js +8 -6
- package/dist/react/workflows/Onboarding.js.map +1 -1
- package/dist/react/workflows/SaveCard.d.ts +27 -0
- package/dist/react/workflows/SaveCard.d.ts.map +1 -0
- package/dist/react/workflows/SaveCard.js +56 -0
- package/dist/react/workflows/SaveCard.js.map +1 -0
- package/dist/react/workflows/index.d.ts +2 -0
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +1 -0
- package/dist/react/workflows/index.js.map +1 -1
- package/dist/react/workflows/useWorkflowAction.d.ts +1 -1
- package/dist/react/workflows/useWorkflowAction.d.ts.map +1 -1
- package/dist/react/workflows/useWorkflowAction.js +6 -2
- package/dist/react/workflows/useWorkflowAction.js.map +1 -1
- package/docs/react-workflows.md +83 -0
- package/package.json +1 -1
- package/src/client/billing/checkoutOptions.ts +27 -0
- package/src/client/billing/intents.test.ts +216 -0
- package/src/client/billing/intents.ts +120 -0
- package/src/client/billing/intentsTransport.test.ts +94 -0
- package/src/client/connect/dispute-upload.test.ts +208 -0
- package/src/client/connect/disputes.ts +103 -0
- package/src/client/core/fees.ts +8 -0
- package/src/client/costRecovery-import.test.ts +174 -0
- package/src/client/costRecovery-invoices.test.ts +69 -0
- package/src/client/costRecovery-invoices.ts +32 -0
- package/src/client/costRecovery.ts +16 -0
- package/src/client/errors.ts +1 -0
- package/src/client/index.ts +50 -52
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +65 -0
- package/src/component/costRecovery/imports.ts +74 -0
- package/src/component/costRecovery/mutations.ts +8 -0
- package/src/component/costRecovery/schema.ts +13 -0
- package/src/react/hooks/useConfirmPayment.test.tsx +47 -0
- package/src/react/hooks/useConfirmPayment.ts +16 -2
- package/src/react/index.ts +3 -0
- package/src/react/workflows/EvidenceUpload.test.tsx +56 -0
- package/src/react/workflows/EvidenceUpload.tsx +71 -0
- package/src/react/workflows/Onboarding.test.tsx +73 -0
- package/src/react/workflows/Onboarding.tsx +8 -5
- package/src/react/workflows/SaveCard.test.tsx +127 -0
- package/src/react/workflows/SaveCard.tsx +117 -0
- package/src/react/workflows/index.ts +3 -0
- package/src/react/workflows/useWorkflowAction.ts +6 -1
|
@@ -73,3 +73,76 @@ it("offers the shared example countries in the native onboarding UI", async () =
|
|
|
73
73
|
);
|
|
74
74
|
expect(screen.getByRole("option", { name: "United Kingdom" })).toBeTruthy();
|
|
75
75
|
});
|
|
76
|
+
|
|
77
|
+
for (const flow of ["create", "continue"] as const) {
|
|
78
|
+
for (const transition of ["unmount", "scope", "current"] as const) {
|
|
79
|
+
it(`${flow} navigates only while current after ${transition}`, async () => {
|
|
80
|
+
let resolve!: (value: { url: string; onboardingUrl: string }) => void;
|
|
81
|
+
const response = new Promise<{ url: string; onboardingUrl: string }>(
|
|
82
|
+
(done) => {
|
|
83
|
+
resolve = done;
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
const navigate = vi.fn();
|
|
87
|
+
const { result, rerender, unmount } = renderHook(
|
|
88
|
+
({ scope }) =>
|
|
89
|
+
useOnboarding({
|
|
90
|
+
scopeKey: scope,
|
|
91
|
+
createAccount: () => response,
|
|
92
|
+
continueOnboarding: () => response,
|
|
93
|
+
onNavigate: navigate,
|
|
94
|
+
}),
|
|
95
|
+
{ initialProps: { scope: "seller-a" } },
|
|
96
|
+
);
|
|
97
|
+
let pending!: Promise<unknown>;
|
|
98
|
+
act(() => {
|
|
99
|
+
pending = result.current[flow]();
|
|
100
|
+
});
|
|
101
|
+
if (transition === "unmount") unmount();
|
|
102
|
+
if (transition === "scope") rerender({ scope: "seller-b" });
|
|
103
|
+
await act(async () => {
|
|
104
|
+
resolve({
|
|
105
|
+
url: "https://connect.stripe.com/setup",
|
|
106
|
+
onboardingUrl: "https://connect.stripe.com/setup",
|
|
107
|
+
});
|
|
108
|
+
await pending;
|
|
109
|
+
});
|
|
110
|
+
expect(navigate).toHaveBeenCalledTimes(transition === "current" ? 1 : 0);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const flow of ["create", "continue"] as const) {
|
|
116
|
+
it(`${flow} suppresses the default location assignment after unmount`, async () => {
|
|
117
|
+
let resolve!: (value: { url: string; onboardingUrl: string }) => void;
|
|
118
|
+
const response = new Promise<{ url: string; onboardingUrl: string }>(
|
|
119
|
+
(done) => {
|
|
120
|
+
resolve = done;
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
const assign = vi.fn();
|
|
124
|
+
const { result, unmount } = renderHook(() =>
|
|
125
|
+
useOnboarding({
|
|
126
|
+
scopeKey: "seller",
|
|
127
|
+
createAccount: () => response,
|
|
128
|
+
continueOnboarding: () => response,
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
let pending!: Promise<unknown>;
|
|
132
|
+
act(() => {
|
|
133
|
+
pending = result.current[flow]();
|
|
134
|
+
});
|
|
135
|
+
unmount();
|
|
136
|
+
vi.stubGlobal("window", { location: { assign } });
|
|
137
|
+
try {
|
|
138
|
+
resolve({
|
|
139
|
+
url: "https://connect.stripe.com/setup",
|
|
140
|
+
onboardingUrl: "https://connect.stripe.com/setup",
|
|
141
|
+
});
|
|
142
|
+
await pending;
|
|
143
|
+
expect(assign).not.toHaveBeenCalled();
|
|
144
|
+
} finally {
|
|
145
|
+
vi.unstubAllGlobals();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -51,10 +51,13 @@ export function useOnboarding(props: OnboardingActions) {
|
|
|
51
51
|
? result.url
|
|
52
52
|
: undefined;
|
|
53
53
|
if (!url) throw new Error("Stripe did not return an onboarding URL.");
|
|
54
|
-
|
|
55
|
-
return true;
|
|
54
|
+
return url;
|
|
56
55
|
},
|
|
57
56
|
props.scopeKey,
|
|
57
|
+
(url) => {
|
|
58
|
+
if (typeof url === "string")
|
|
59
|
+
(props.onNavigate ?? ((url) => window.location.assign(url)))(url);
|
|
60
|
+
},
|
|
58
61
|
);
|
|
59
62
|
const run = action.run;
|
|
60
63
|
return {
|
|
@@ -69,9 +72,9 @@ export function useOnboarding(props: OnboardingActions) {
|
|
|
69
72
|
createError: operation === "create" ? action.error : null,
|
|
70
73
|
linkError: operation === "link" ? action.error : null,
|
|
71
74
|
restartError: operation === "close" ? action.error : null,
|
|
72
|
-
create: () => run("create"),
|
|
73
|
-
continue: () => run("link"),
|
|
74
|
-
close: () => run("close"),
|
|
75
|
+
create: async () => ((await run("create")) ? true : undefined),
|
|
76
|
+
continue: async () => ((await run("link")) ? true : undefined),
|
|
77
|
+
close: async () => ((await run("close")) ? true : undefined),
|
|
75
78
|
};
|
|
76
79
|
}
|
|
77
80
|
export type OnboardingAccount = {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
5
|
+
const confirm = vi.hoisted(() => vi.fn());
|
|
6
|
+
vi.mock("../components/StripeProvider.js", () => ({
|
|
7
|
+
StripeProvider: ({ children }: { children: React.ReactNode }) => children,
|
|
8
|
+
}));
|
|
9
|
+
vi.mock("@stripe/react-stripe-js", () => ({
|
|
10
|
+
PaymentElement: () => <div>Card details</div>,
|
|
11
|
+
}));
|
|
12
|
+
vi.mock("../hooks/useConfirmPayment.js", () => ({
|
|
13
|
+
useConfirmPayment: () => ({
|
|
14
|
+
confirmSetup: confirm,
|
|
15
|
+
isReady: true,
|
|
16
|
+
hasElements: true,
|
|
17
|
+
}),
|
|
18
|
+
}));
|
|
19
|
+
import { SaveCard } from "./SaveCard.js";
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
cleanup();
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
});
|
|
24
|
+
it("retries provisioning with the same logical request and requires explicit consent before confirmation", async () => {
|
|
25
|
+
const create = vi
|
|
26
|
+
.fn()
|
|
27
|
+
.mockRejectedValueOnce(new Error("offline"))
|
|
28
|
+
.mockResolvedValue({ setupIntentId: "seti_one", clientSecret: "secret" });
|
|
29
|
+
const success = vi.fn();
|
|
30
|
+
confirm.mockResolvedValue({ success: true });
|
|
31
|
+
render(
|
|
32
|
+
<SaveCard
|
|
33
|
+
publishableKey="pk_test"
|
|
34
|
+
scopeKey="buyer"
|
|
35
|
+
requestId="save-one"
|
|
36
|
+
returnUrl="https://app.test/cards"
|
|
37
|
+
createSetupIntent={create}
|
|
38
|
+
onSuccess={success}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
await screen.findByText("offline");
|
|
42
|
+
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
|
|
43
|
+
await screen.findByText("Card details");
|
|
44
|
+
expect(create.mock.calls).toEqual([
|
|
45
|
+
[{ requestId: "save-one" }],
|
|
46
|
+
[{ requestId: "save-one" }],
|
|
47
|
+
]);
|
|
48
|
+
await userEvent.click(screen.getByRole("button", { name: "Save card" }));
|
|
49
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
50
|
+
await userEvent.click(screen.getByRole("checkbox"));
|
|
51
|
+
await userEvent.click(screen.getByRole("button", { name: "Save card" }));
|
|
52
|
+
await waitFor(() => expect(success).toHaveBeenCalledWith("seti_one"));
|
|
53
|
+
expect(confirm).toHaveBeenCalledWith("https://app.test/cards", "always");
|
|
54
|
+
});
|
|
55
|
+
it("keeps failed authentication retryable and does not report completion", async () => {
|
|
56
|
+
confirm
|
|
57
|
+
.mockResolvedValueOnce({ success: false, error: "Authentication failed" })
|
|
58
|
+
.mockResolvedValueOnce({ success: true });
|
|
59
|
+
const success = vi.fn();
|
|
60
|
+
render(
|
|
61
|
+
<SaveCard
|
|
62
|
+
publishableKey="pk_test"
|
|
63
|
+
scopeKey="buyer"
|
|
64
|
+
returnUrl="https://app.test/cards"
|
|
65
|
+
createSetupIntent={async () => ({
|
|
66
|
+
setupIntentId: "seti_one",
|
|
67
|
+
clientSecret: "secret",
|
|
68
|
+
})}
|
|
69
|
+
onSuccess={success}
|
|
70
|
+
/>,
|
|
71
|
+
);
|
|
72
|
+
await screen.findByText("Card details");
|
|
73
|
+
await userEvent.click(screen.getByRole("checkbox"));
|
|
74
|
+
await userEvent.click(screen.getByRole("button", { name: "Save card" }));
|
|
75
|
+
await screen.findByRole("alert");
|
|
76
|
+
expect(success).not.toHaveBeenCalled();
|
|
77
|
+
await userEvent.click(screen.getByRole("button", { name: "Save card" }));
|
|
78
|
+
await waitFor(() => expect(success).toHaveBeenCalledOnce());
|
|
79
|
+
});
|
|
80
|
+
it("does not confirm the same completed setup twice", async () => {
|
|
81
|
+
confirm.mockResolvedValue({ success: true });
|
|
82
|
+
const success = vi.fn();
|
|
83
|
+
render(
|
|
84
|
+
<SaveCard
|
|
85
|
+
publishableKey="pk_test"
|
|
86
|
+
scopeKey="buyer"
|
|
87
|
+
returnUrl="https://app.test/cards"
|
|
88
|
+
createSetupIntent={async () => ({
|
|
89
|
+
setupIntentId: "seti_one",
|
|
90
|
+
clientSecret: "secret",
|
|
91
|
+
})}
|
|
92
|
+
onSuccess={success}
|
|
93
|
+
/>,
|
|
94
|
+
);
|
|
95
|
+
await screen.findByText("Card details");
|
|
96
|
+
await userEvent.click(screen.getByRole("checkbox"));
|
|
97
|
+
await userEvent.click(screen.getByRole("button", { name: "Save card" }));
|
|
98
|
+
await waitFor(() => expect(success).toHaveBeenCalledOnce());
|
|
99
|
+
await userEvent.click(screen.getByRole("button", { name: "Save card" }));
|
|
100
|
+
expect(confirm).toHaveBeenCalledOnce();
|
|
101
|
+
});
|
|
102
|
+
it("blocks repeat confirmation through a custom render callback", async () => {
|
|
103
|
+
confirm.mockResolvedValue({ success: true });
|
|
104
|
+
const success = vi.fn();
|
|
105
|
+
let submit: (() => Promise<void>) | undefined;
|
|
106
|
+
render(
|
|
107
|
+
<SaveCard
|
|
108
|
+
publishableKey="pk_test"
|
|
109
|
+
scopeKey="buyer"
|
|
110
|
+
returnUrl="https://app.test/cards"
|
|
111
|
+
createSetupIntent={async () => ({
|
|
112
|
+
setupIntentId: "seti_one",
|
|
113
|
+
clientSecret: "secret",
|
|
114
|
+
})}
|
|
115
|
+
onSuccess={success}
|
|
116
|
+
renderForm={(state) => {
|
|
117
|
+
submit = state.submit;
|
|
118
|
+
return <button onClick={() => state.setConsent(true)}>Consent</button>;
|
|
119
|
+
}}
|
|
120
|
+
/>,
|
|
121
|
+
);
|
|
122
|
+
await userEvent.click(await screen.findByRole("button", { name: "Consent" }));
|
|
123
|
+
await submit!();
|
|
124
|
+
await submit!();
|
|
125
|
+
expect(confirm).toHaveBeenCalledOnce();
|
|
126
|
+
expect(success).toHaveBeenCalledOnce();
|
|
127
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useRef, useState, type ReactNode } from "react";
|
|
3
|
+
import { PaymentElement } from "@stripe/react-stripe-js";
|
|
4
|
+
import {
|
|
5
|
+
StripeProvider,
|
|
6
|
+
type StripeProviderProps,
|
|
7
|
+
} from "../components/StripeProvider.js";
|
|
8
|
+
import { useConfirmPayment } from "../hooks/useConfirmPayment.js";
|
|
9
|
+
import { useWorkflowResource } from "./useWorkflowResource.js";
|
|
10
|
+
import { useWorkflowAction } from "./useWorkflowAction.js";
|
|
11
|
+
export type SaveCardFormState = {
|
|
12
|
+
paymentElement: ReactNode;
|
|
13
|
+
consent: boolean;
|
|
14
|
+
setConsent: (value: boolean) => void;
|
|
15
|
+
submit: () => Promise<void>;
|
|
16
|
+
isPending: boolean;
|
|
17
|
+
error: string | null;
|
|
18
|
+
};
|
|
19
|
+
export type SaveCardProps = Pick<
|
|
20
|
+
StripeProviderProps,
|
|
21
|
+
"publishableKey" | "appearance"
|
|
22
|
+
> & {
|
|
23
|
+
scopeKey: string;
|
|
24
|
+
requestId?: string;
|
|
25
|
+
returnUrl: string;
|
|
26
|
+
/** Authenticated callback authorizes customer, records usage consent and durable request identity. */
|
|
27
|
+
createSetupIntent: (request: {
|
|
28
|
+
requestId: string;
|
|
29
|
+
}) => Promise<{ setupIntentId: string; clientSecret: string }>;
|
|
30
|
+
onSuccess?: (setupIntentId: string) => void;
|
|
31
|
+
renderForm?: (state: SaveCardFormState) => ReactNode;
|
|
32
|
+
};
|
|
33
|
+
/** Unstyled card setup. Host owns usage disclosures and authorization. */
|
|
34
|
+
export function SaveCard(props: SaveCardProps) {
|
|
35
|
+
return (
|
|
36
|
+
<SaveCardBody
|
|
37
|
+
key={`${props.scopeKey}:${props.requestId ?? ""}`}
|
|
38
|
+
{...props}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
function SaveCardBody(props: SaveCardProps) {
|
|
43
|
+
const [requestId] = useState(() => props.requestId ?? crypto.randomUUID());
|
|
44
|
+
const state = useWorkflowResource(() =>
|
|
45
|
+
props.createSetupIntent({ requestId }),
|
|
46
|
+
);
|
|
47
|
+
if (state.error)
|
|
48
|
+
return (
|
|
49
|
+
<div role="alert">
|
|
50
|
+
{state.error}
|
|
51
|
+
<button type="button" onClick={state.refresh}>
|
|
52
|
+
Retry
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
if (!state.data || state.isLoading)
|
|
57
|
+
return <p role="status">Preparing card setup…</p>;
|
|
58
|
+
return (
|
|
59
|
+
<StripeProvider
|
|
60
|
+
publishableKey={props.publishableKey}
|
|
61
|
+
appearance={props.appearance}
|
|
62
|
+
elementsOptions={{ clientSecret: state.data.clientSecret }}
|
|
63
|
+
>
|
|
64
|
+
<SaveCardForm {...props} setupIntentId={state.data.setupIntentId} />
|
|
65
|
+
</StripeProvider>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
function SaveCardForm(props: SaveCardProps & { setupIntentId: string }) {
|
|
69
|
+
const { confirmSetup, isReady, hasElements } = useConfirmPayment();
|
|
70
|
+
const [consent, setConsent] = useState(false);
|
|
71
|
+
const completed = useRef(false);
|
|
72
|
+
const action = useWorkflowAction(async () => {
|
|
73
|
+
if (completed.current) return false;
|
|
74
|
+
if (!consent) throw new Error("Consent is required to save this card.");
|
|
75
|
+
const result = await confirmSetup(props.returnUrl, "always");
|
|
76
|
+
if (!result.success) throw new Error(result.error);
|
|
77
|
+
completed.current = true;
|
|
78
|
+
return true;
|
|
79
|
+
});
|
|
80
|
+
const state: SaveCardFormState = {
|
|
81
|
+
paymentElement: <PaymentElement />,
|
|
82
|
+
consent,
|
|
83
|
+
setConsent,
|
|
84
|
+
submit: async () => {
|
|
85
|
+
const saved = await action.run();
|
|
86
|
+
if (saved) props.onSuccess?.(props.setupIntentId);
|
|
87
|
+
},
|
|
88
|
+
isPending:
|
|
89
|
+
action.isPending ||
|
|
90
|
+
!isReady ||
|
|
91
|
+
!hasElements ||
|
|
92
|
+
action.result !== undefined,
|
|
93
|
+
error: action.error,
|
|
94
|
+
};
|
|
95
|
+
if (props.renderForm) return <>{props.renderForm(state)}</>;
|
|
96
|
+
return (
|
|
97
|
+
<div>
|
|
98
|
+
{state.paymentElement}
|
|
99
|
+
<label>
|
|
100
|
+
<input
|
|
101
|
+
type="checkbox"
|
|
102
|
+
checked={consent}
|
|
103
|
+
onChange={(e) => setConsent(e.target.checked)}
|
|
104
|
+
/>
|
|
105
|
+
Save this card for future payments and show it at checkout.
|
|
106
|
+
</label>
|
|
107
|
+
{state.error && <p role="alert">{state.error}</p>}
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
disabled={!consent || state.isPending}
|
|
111
|
+
onClick={() => void state.submit()}
|
|
112
|
+
>
|
|
113
|
+
Save card
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -12,6 +12,7 @@ export type WorkflowActionState<T, Args extends unknown[]> = {
|
|
|
12
12
|
export function useWorkflowAction<T, Args extends unknown[]>(
|
|
13
13
|
action: (...args: Args) => Promise<T>,
|
|
14
14
|
scope: string = "default",
|
|
15
|
+
onSuccess?: (result: T) => void,
|
|
15
16
|
): WorkflowActionState<T, Args> {
|
|
16
17
|
const initial = {
|
|
17
18
|
scope,
|
|
@@ -22,11 +23,13 @@ export function useWorkflowAction<T, Args extends unknown[]>(
|
|
|
22
23
|
const [state, setState] = useState(initial);
|
|
23
24
|
if (state.scope !== scope) setState(initial);
|
|
24
25
|
const callback = useRef(action);
|
|
26
|
+
const success = useRef(onSuccess);
|
|
25
27
|
const lifecycle = useRef({ version: 0, mounted: false });
|
|
26
28
|
const lock = useRef<number | null>(null);
|
|
27
29
|
useEffect(() => {
|
|
28
30
|
callback.current = action;
|
|
29
|
-
|
|
31
|
+
success.current = onSuccess;
|
|
32
|
+
}, [action, onSuccess]);
|
|
30
33
|
useEffect(() => {
|
|
31
34
|
const version = ++lifecycle.current.version;
|
|
32
35
|
lifecycle.current.mounted = true;
|
|
@@ -51,9 +54,11 @@ export function useWorkflowAction<T, Args extends unknown[]>(
|
|
|
51
54
|
setState({ scope, isPending: true, error: null, result: undefined });
|
|
52
55
|
const current = () =>
|
|
53
56
|
lifecycle.current.mounted && lifecycle.current.version === version;
|
|
57
|
+
const complete = success.current;
|
|
54
58
|
try {
|
|
55
59
|
const result = await callback.current(...args);
|
|
56
60
|
if (!current()) return undefined;
|
|
61
|
+
complete?.(result);
|
|
57
62
|
setState({ scope, isPending: false, error: null, result });
|
|
58
63
|
return result;
|
|
59
64
|
} catch (error) {
|