@fantastic.dev/better-stripe 0.8.0 → 0.9.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +19 -3
  3. package/dist/client/connect/disputes.d.ts +1 -1
  4. package/dist/client/connect/disputes.d.ts.map +1 -1
  5. package/dist/client/connect/disputes.js +12 -6
  6. package/dist/client/connect/disputes.js.map +1 -1
  7. package/dist/component/_generated/component.d.ts +35 -0
  8. package/dist/component/_generated/component.d.ts.map +1 -1
  9. package/dist/component/connect/queries.d.ts +30 -1
  10. package/dist/component/connect/queries.d.ts.map +1 -1
  11. package/dist/component/connect/queries.js +19 -2
  12. package/dist/component/connect/queries.js.map +1 -1
  13. package/dist/react/components/DisputeDetail.d.ts +4 -1
  14. package/dist/react/components/DisputeDetail.d.ts.map +1 -1
  15. package/dist/react/components/DisputeDetail.js +12 -6
  16. package/dist/react/components/DisputeDetail.js.map +1 -1
  17. package/dist/react/components/DisputesList.d.ts.map +1 -1
  18. package/dist/react/components/DisputesList.js +2 -1
  19. package/dist/react/components/DisputesList.js.map +1 -1
  20. package/dist/react/index.d.ts +1 -1
  21. package/dist/react/index.d.ts.map +1 -1
  22. package/dist/react/index.js +1 -1
  23. package/dist/react/index.js.map +1 -1
  24. package/dist/react/workflows/AffiliatePayoutEditor.d.ts +41 -0
  25. package/dist/react/workflows/AffiliatePayoutEditor.d.ts.map +1 -0
  26. package/dist/react/workflows/AffiliatePayoutEditor.js +80 -0
  27. package/dist/react/workflows/AffiliatePayoutEditor.js.map +1 -0
  28. package/dist/react/workflows/AffiliateRateEditor.d.ts +26 -0
  29. package/dist/react/workflows/AffiliateRateEditor.d.ts.map +1 -0
  30. package/dist/react/workflows/AffiliateRateEditor.js +49 -0
  31. package/dist/react/workflows/AffiliateRateEditor.js.map +1 -0
  32. package/dist/react/workflows/CheckoutForms.d.ts +2 -0
  33. package/dist/react/workflows/CheckoutForms.d.ts.map +1 -1
  34. package/dist/react/workflows/CheckoutForms.js +22 -16
  35. package/dist/react/workflows/CheckoutForms.js.map +1 -1
  36. package/dist/react/workflows/Disputes.d.ts.map +1 -1
  37. package/dist/react/workflows/Disputes.js +9 -9
  38. package/dist/react/workflows/Disputes.js.map +1 -1
  39. package/dist/react/workflows/IntegrationSwitch.js +2 -2
  40. package/dist/react/workflows/IntegrationSwitch.js.map +1 -1
  41. package/dist/react/workflows/Pagination.d.ts +32 -0
  42. package/dist/react/workflows/Pagination.d.ts.map +1 -0
  43. package/dist/react/workflows/Pagination.js +49 -0
  44. package/dist/react/workflows/Pagination.js.map +1 -0
  45. package/dist/react/workflows/Payouts.d.ts +1 -0
  46. package/dist/react/workflows/Payouts.d.ts.map +1 -1
  47. package/dist/react/workflows/Payouts.js +3 -2
  48. package/dist/react/workflows/Payouts.js.map +1 -1
  49. package/dist/react/workflows/PlanPricingEditor.d.ts +35 -0
  50. package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -0
  51. package/dist/react/workflows/PlanPricingEditor.js +64 -0
  52. package/dist/react/workflows/PlanPricingEditor.js.map +1 -0
  53. package/dist/react/workflows/index.d.ts +8 -0
  54. package/dist/react/workflows/index.d.ts.map +1 -1
  55. package/dist/react/workflows/index.js +4 -0
  56. package/dist/react/workflows/index.js.map +1 -1
  57. package/docs/react-workflows-audit.md +41 -0
  58. package/docs/react-workflows.md +92 -1
  59. package/package.json +5 -4
  60. package/src/client/connect/disputes.test.ts +15 -0
  61. package/src/client/connect/disputes.ts +13 -7
  62. package/src/component/_generated/component.ts +41 -0
  63. package/src/component/connect/queries.ts +26 -2
  64. package/src/component/earnings-totals.test.ts +13 -0
  65. package/src/component/transfers.test.ts +48 -0
  66. package/src/react/components/DisputeDetail.tsx +14 -5
  67. package/src/react/components/DisputesList.render.test.tsx +21 -0
  68. package/src/react/components/DisputesList.tsx +2 -1
  69. package/src/react/components/EvidenceForm.render.test.tsx +1 -2
  70. package/src/react/index.ts +4 -1
  71. package/src/react/workflows/AffiliatePayoutEditor.test.tsx +90 -0
  72. package/src/react/workflows/AffiliatePayoutEditor.tsx +201 -0
  73. package/src/react/workflows/AffiliateRateEditor.test.tsx +36 -0
  74. package/src/react/workflows/AffiliateRateEditor.tsx +92 -0
  75. package/src/react/workflows/CheckoutForms.test.tsx +40 -0
  76. package/src/react/workflows/CheckoutForms.tsx +37 -16
  77. package/src/react/workflows/Disputes.test.tsx +41 -2
  78. package/src/react/workflows/Disputes.tsx +49 -40
  79. package/src/react/workflows/IntegrationSwitch.test.tsx +25 -0
  80. package/src/react/workflows/IntegrationSwitch.tsx +2 -2
  81. package/src/react/workflows/Pagination.test.tsx +53 -0
  82. package/src/react/workflows/Pagination.tsx +93 -0
  83. package/src/react/workflows/Payouts.test.tsx +30 -0
  84. package/src/react/workflows/Payouts.tsx +4 -1
  85. package/src/react/workflows/PlanPricingEditor.test.tsx +79 -0
  86. package/src/react/workflows/PlanPricingEditor.tsx +156 -0
  87. package/src/react/workflows/index.ts +22 -0
@@ -0,0 +1,92 @@
1
+ "use client";
2
+ import { useId, useState, type ReactNode } from "react";
3
+ import { useWorkflowAction } from "./useWorkflowAction.js";
4
+ export type AffiliateRateUpdate = {
5
+ affiliateBasisPoints: number | null;
6
+ expectedVersion: number;
7
+ };
8
+ export type AffiliateRateEditorState = {
9
+ percentage: string;
10
+ setPercentage: (value: string) => void;
11
+ effectiveLabel: string;
12
+ isPending: boolean;
13
+ error: string | null;
14
+ message: string | undefined;
15
+ save: () => Promise<unknown>;
16
+ };
17
+ export type AffiliateRateEditorProps = {
18
+ scopeKey: string;
19
+ affiliateBasisPoints: number | null;
20
+ effectiveBasisPoints: number;
21
+ source: "plan" | "product" | "price" | "store" | "global" | "none";
22
+ version: number;
23
+ onSave: (update: AffiliateRateUpdate) => Promise<unknown>;
24
+ children?: (state: AffiliateRateEditorState) => ReactNode;
25
+ };
26
+ /** Unstyled nullable commission editor. Blank inherits the next applicable rate. */
27
+ export function AffiliateRateEditor(props: AffiliateRateEditorProps) {
28
+ const id = useId();
29
+ const scope = `${props.scopeKey}:${props.version}`;
30
+ const initial = {
31
+ scope,
32
+ percentage:
33
+ props.affiliateBasisPoints === null
34
+ ? ""
35
+ : String(props.affiliateBasisPoints / 100),
36
+ };
37
+ const [fields, setFields] = useState(initial);
38
+ if (fields.scope !== scope) setFields(initial);
39
+ const action = useWorkflowAction(async () => {
40
+ const value = fields.percentage.trim();
41
+ const rate = value === "" ? null : Math.round(Number(value) * 100);
42
+ if (
43
+ rate !== null &&
44
+ (!/^\d+(\.\d{1,2})?$/.test(value) ||
45
+ !Number.isSafeInteger(rate) ||
46
+ rate < 0 ||
47
+ rate > 10000)
48
+ )
49
+ throw new Error(
50
+ "Enter a percentage from 0 to 100 with at most two decimal places, or leave it blank to inherit.",
51
+ );
52
+ await props.onSave({
53
+ affiliateBasisPoints: rate,
54
+ expectedVersion: props.version,
55
+ });
56
+ return "Affiliate payout saved.";
57
+ }, props.scopeKey);
58
+ const state: AffiliateRateEditorState = {
59
+ percentage: fields.percentage,
60
+ setPercentage: (percentage) =>
61
+ setFields((previous) => ({ ...previous, percentage })),
62
+ effectiveLabel: `Effective payout: ${props.effectiveBasisPoints / 100}% from ${props.source === "plan" ? "store plan" : props.source}.`,
63
+ isPending: action.isPending,
64
+ error: action.error,
65
+ message: action.result,
66
+ save: action.run,
67
+ };
68
+ if (props.children) return <>{props.children(state)}</>;
69
+ return (
70
+ <form
71
+ onSubmit={(event) => {
72
+ event.preventDefault();
73
+ void state.save();
74
+ }}
75
+ >
76
+ <label htmlFor={id}>Affiliate payout (%)</label>
77
+ <input
78
+ id={id}
79
+ inputMode="decimal"
80
+ value={state.percentage}
81
+ disabled={state.isPending}
82
+ onChange={(event) => state.setPercentage(event.target.value)}
83
+ />
84
+ <p>{state.effectiveLabel} Leave blank to inherit.</p>
85
+ {state.error && <p role="alert">{state.error}</p>}
86
+ {state.message && <p role="status">{state.message}</p>}
87
+ <button type="submit" disabled={state.isPending}>
88
+ {state.isPending ? "Saving…" : "Save payout"}
89
+ </button>
90
+ </form>
91
+ );
92
+ }
@@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
4
4
  import { afterEach, beforeEach, expect, it, vi } from "vitest";
5
5
  import type { ReactNode } from "react";
6
6
  const mocks = vi.hoisted(() => ({
7
+ sessionEmail: null as string | null,
7
8
  updateEmail: vi.fn(),
8
9
  confirm: vi.fn(),
9
10
  submit: vi.fn(),
@@ -21,6 +22,7 @@ vi.mock("@stripe/react-stripe-js/checkout", () => ({
21
22
  type: "success",
22
23
  checkout: {
23
24
  id: "cs_test",
25
+ email: mocks.sessionEmail,
24
26
  canConfirm: false,
25
27
  updateEmail: mocks.updateEmail,
26
28
  confirm: mocks.confirm,
@@ -36,6 +38,7 @@ import { CheckoutElementsForm, PaymentIntentForm } from "./CheckoutForms.js";
36
38
  afterEach(cleanup);
37
39
  beforeEach(() => {
38
40
  vi.clearAllMocks();
41
+ mocks.sessionEmail = null;
39
42
  mocks.updateEmail.mockResolvedValue({ type: "success" });
40
43
  mocks.confirm.mockResolvedValue({
41
44
  type: "error",
@@ -53,6 +56,27 @@ const props = {
53
56
  email: "buyer@example.com",
54
57
  onNavigate: vi.fn(),
55
58
  };
59
+ it("confirms a session with a server-set email without attempting to update it", async () => {
60
+ mocks.sessionEmail = "account@example.com";
61
+ mocks.updateEmail.mockResolvedValue({
62
+ type: "error",
63
+ error: { message: "You cannot update the email" },
64
+ });
65
+ mocks.confirm.mockResolvedValue({ type: "success" });
66
+ const navigate = vi.fn();
67
+ render(<CheckoutElementsForm {...props} onNavigate={navigate} />);
68
+ const input = screen.getByRole("textbox", {
69
+ name: "Email for your receipt",
70
+ }) as HTMLInputElement;
71
+ expect(input.value).toBe("account@example.com");
72
+ expect(input.readOnly).toBe(true);
73
+ await userEvent.click(screen.getByRole("button", { name: "Pay" }));
74
+ expect(mocks.updateEmail).not.toHaveBeenCalled();
75
+ expect(mocks.confirm).toHaveBeenCalledOnce();
76
+ expect(navigate).toHaveBeenCalledWith(
77
+ "https://app.test/receipt?session_id=cs_test",
78
+ );
79
+ });
56
80
  it("sends the email before confirming, including when canConfirm is initially false", async () => {
57
81
  render(<CheckoutElementsForm {...props} />);
58
82
  await userEvent.click(screen.getByRole("button", { name: "Pay" }));
@@ -70,6 +94,22 @@ it("does not confirm after Stripe rejects the email", async () => {
70
94
  expect(screen.getByRole("alert").textContent).toContain("Invalid email");
71
95
  expect(mocks.confirm).not.toHaveBeenCalled();
72
96
  });
97
+ it("keeps a guest email editable after a declined payment", async () => {
98
+ mocks.updateEmail.mockImplementation(async (email: string) => {
99
+ mocks.sessionEmail = email;
100
+ return { type: "success" };
101
+ });
102
+ render(<CheckoutElementsForm {...props} />);
103
+ await userEvent.click(screen.getByRole("button", { name: "Pay" }));
104
+ await screen.findByText("Card declined");
105
+ const input = screen.getByRole("textbox", { name: "Email for your receipt" });
106
+ expect((input as HTMLInputElement).readOnly).toBe(false);
107
+ await userEvent.clear(input);
108
+ await userEvent.type(input, "other@example.com");
109
+ await userEvent.click(screen.getByRole("button", { name: "Pay" }));
110
+ expect(mocks.updateEmail).toHaveBeenLastCalledWith("other@example.com");
111
+ expect(mocks.confirm).toHaveBeenCalledTimes(2);
112
+ });
73
113
  it("disables confirmation while Stripe is processing and supports a new attempt after failure", async () => {
74
114
  let resolve!: (value: unknown) => void;
75
115
  mocks.confirm.mockImplementationOnce(
@@ -24,6 +24,8 @@ import { StripeProvider } from "../components/StripeProvider.js";
24
24
  import { useWorkflowAction } from "./useWorkflowAction.js";
25
25
  export type CheckoutFormState = {
26
26
  email: string;
27
+ /** Stripe supplied this email when the session loaded; do not edit it. */
28
+ emailReadOnly?: boolean;
27
29
  setEmail: (email: string) => void;
28
30
  emailInputId: string;
29
31
  isPending: boolean;
@@ -44,6 +46,7 @@ export type CheckoutFormProps = CheckoutFormOptions & {
44
46
  };
45
47
  function NativeForm({
46
48
  email,
49
+ emailReadOnly,
47
50
  setEmail,
48
51
  emailInputId,
49
52
  paymentElement,
@@ -65,6 +68,7 @@ function NativeForm({
65
68
  required
66
69
  autoComplete="email"
67
70
  value={email}
71
+ readOnly={emailReadOnly}
68
72
  onChange={(event) => setEmail(event.target.value)}
69
73
  />
70
74
  {paymentElement}
@@ -86,31 +90,48 @@ export function CheckoutElementsForm(props: CheckoutFormProps) {
86
90
  </CheckoutSessionProvider>
87
91
  );
88
92
  }
89
- function SessionForm({
93
+ function SessionForm(props: CheckoutFormProps) {
94
+ const checkout = useCheckout();
95
+ if (checkout.type === "loading")
96
+ return <p role="status">Loading checkout…</p>;
97
+ if (checkout.type === "error")
98
+ return <p role="alert">{checkout.error.message}</p>;
99
+ return (
100
+ <ReadySessionForm
101
+ key={checkout.checkout.id}
102
+ {...props}
103
+ checkout={checkout.checkout}
104
+ />
105
+ );
106
+ }
107
+ function ReadySessionForm({
108
+ checkout,
90
109
  email: initialEmail = "",
91
110
  returnUrl,
92
111
  onNavigate,
93
112
  renderForm,
94
- }: CheckoutFormProps) {
95
- const checkout = useCheckout();
96
- const [email, setEmail] = useState(initialEmail);
113
+ }: CheckoutFormProps & {
114
+ checkout: Extract<
115
+ ReturnType<typeof useCheckout>,
116
+ { type: "success" }
117
+ >["checkout"];
118
+ }) {
119
+ // Snapshot once per session: a guest update must not lock their input on retry.
120
+ const [sessionEmail] = useState(() => checkout.email);
121
+ const [email, setEmail] = useState(sessionEmail ?? initialEmail);
97
122
  const emailInputId = useId();
98
123
  const action = useWorkflowAction(async () => {
99
- if (checkout.type !== "success") throw new Error("Checkout is not ready.");
100
- const updated = await checkout.checkout.updateEmail(email);
101
- if (updated.type === "error") throw new Error(updated.error.message);
102
- const confirmed = await checkout.checkout.confirm();
124
+ if (!sessionEmail) {
125
+ const updated = await checkout.updateEmail(email);
126
+ if (updated.type === "error") throw new Error(updated.error.message);
127
+ }
128
+ const confirmed = await checkout.confirm();
103
129
  if (confirmed.type === "error") throw new Error(confirmed.error.message);
104
- onNavigate(
105
- returnUrl.replace("{CHECKOUT_SESSION_ID}", checkout.checkout.id),
106
- );
130
+ onNavigate(returnUrl.replace("{CHECKOUT_SESSION_ID}", checkout.id));
107
131
  });
108
- if (checkout.type === "loading")
109
- return <p role="status">Loading checkout…</p>;
110
- if (checkout.type === "error")
111
- return <p role="alert">{checkout.error.message}</p>;
112
132
  const state: CheckoutFormState = {
113
- email,
133
+ email: sessionEmail ?? email,
134
+ emailReadOnly: !!sessionEmail,
114
135
  setEmail,
115
136
  emailInputId,
116
137
  paymentElement: <PaymentElement />,
@@ -1,7 +1,14 @@
1
1
  // @vitest-environment jsdom
2
- import { act, cleanup, renderHook } from "@testing-library/react";
2
+ import {
3
+ act,
4
+ cleanup,
5
+ renderHook,
6
+ render,
7
+ screen,
8
+ fireEvent,
9
+ } from "@testing-library/react";
3
10
  import { afterEach, expect, it, vi } from "vitest";
4
- import { useDisputeResponse } from "./Disputes.js";
11
+ import { Disputes, useDisputeResponse } from "./Disputes.js";
5
12
  afterEach(cleanup);
6
13
  it("requires explicit confirmation and clears it when selecting another dispute", async () => {
7
14
  const acceptDispute = vi.fn().mockResolvedValue(null);
@@ -23,3 +30,35 @@ it("requires explicit confirmation and clears it when selecting another dispute"
23
30
  expect(acceptDispute).toHaveBeenCalledWith("dp_two");
24
31
  expect(result.current.acceptStatus).toBe("accepted");
25
32
  });
33
+
34
+ it.each(["won", "lost", "under_review"] as const)(
35
+ "keeps %s disputes read-only",
36
+ (status) => {
37
+ const detail = {
38
+ _id: "doc",
39
+ _creationTime: 0,
40
+ stripeDisputeId: "dp_closed",
41
+ amount: 1000,
42
+ currency: "usd",
43
+ status,
44
+ reason: "product_not_received",
45
+ isChargeRefundable: false,
46
+ countdown: { daysRemaining: 9, isOverdue: false },
47
+ };
48
+ render(
49
+ <Disputes
50
+ scopeKey="seller"
51
+ disputes={[detail]}
52
+ detail={detail}
53
+ acceptDispute={vi.fn()}
54
+ onUpdateEvidence={vi.fn()}
55
+ />,
56
+ );
57
+ fireEvent.click(screen.getByRole("button", { name: /dp_closed/ }));
58
+ expect(screen.queryByRole("button", { name: "Accept dispute" })).toBeNull();
59
+ expect(
60
+ screen.queryByRole("button", { name: "Submit evidence" }),
61
+ ).toBeNull();
62
+ expect(screen.queryByText("Evidence due in 9 days")).toBeNull();
63
+ },
64
+ );
@@ -4,7 +4,10 @@ import {
4
4
  DisputesList,
5
5
  type DisputesListRow,
6
6
  } from "../components/DisputesList.js";
7
- import { DisputeDetail } from "../components/DisputeDetail.js";
7
+ import {
8
+ DisputeDetail,
9
+ canRespondToDispute,
10
+ } from "../components/DisputeDetail.js";
8
11
  import {
9
12
  EvidenceForm,
10
13
  type EvidenceFormUpdateArgs,
@@ -118,45 +121,51 @@ function DisputesBody(props: HeadlessDisputesProps) {
118
121
  ) : (
119
122
  <>
120
123
  <DisputeDetail dispute={detail} isLoading={!detail} />
121
- {props.acceptDispute &&
122
- (state.acceptStatus === "confirming" ? (
123
- <div>
124
- <p>
125
- Accepting permanently closes this dispute. This cannot be
126
- undone.
127
- </p>
128
- <button
129
- type="button"
130
- disabled={state.isPending}
131
- onClick={() => {
132
- void state.accept();
133
- }}
134
- >
135
- Yes, accept dispute
136
- </button>
137
- <button type="button" onClick={state.cancelAccept}>
138
- Cancel
139
- </button>
140
- </div>
141
- ) : (
142
- <button
143
- type="button"
144
- disabled={state.isPending || state.acceptStatus === "accepted"}
145
- onClick={state.requestAccept}
146
- >
147
- {state.isPending
148
- ? "Accepting…"
149
- : state.acceptStatus === "accepted"
150
- ? "Dispute accepted"
151
- : "Accept dispute"}
152
- </button>
153
- ))}
154
- {state.acceptError && <p role="alert">{state.acceptError}</p>}
155
- <EvidenceForm
156
- key={state.selectedId}
157
- stripeDisputeId={state.selectedId}
158
- onUpdateDispute={props.onUpdateEvidence}
159
- />
124
+ {canRespondToDispute(detail?.status) && (
125
+ <>
126
+ {props.acceptDispute &&
127
+ (state.acceptStatus === "confirming" ? (
128
+ <div>
129
+ <p>
130
+ Accepting permanently closes this dispute. This cannot be
131
+ undone.
132
+ </p>
133
+ <button
134
+ type="button"
135
+ disabled={state.isPending}
136
+ onClick={() => {
137
+ void state.accept();
138
+ }}
139
+ >
140
+ Yes, accept dispute
141
+ </button>
142
+ <button type="button" onClick={state.cancelAccept}>
143
+ Cancel
144
+ </button>
145
+ </div>
146
+ ) : (
147
+ <button
148
+ type="button"
149
+ disabled={
150
+ state.isPending || state.acceptStatus === "accepted"
151
+ }
152
+ onClick={state.requestAccept}
153
+ >
154
+ {state.isPending
155
+ ? "Accepting…"
156
+ : state.acceptStatus === "accepted"
157
+ ? "Dispute accepted"
158
+ : "Accept dispute"}
159
+ </button>
160
+ ))}
161
+ {state.acceptError && <p role="alert">{state.acceptError}</p>}
162
+ <EvidenceForm
163
+ key={state.selectedId}
164
+ stripeDisputeId={state.selectedId}
165
+ onUpdateDispute={props.onUpdateEvidence}
166
+ />
167
+ </>
168
+ )}
160
169
  </>
161
170
  )}
162
171
  </section>
@@ -34,3 +34,28 @@ it("allows an uncontrolled choice without requiring a router", async () => {
34
34
  expect(screen.getByText("Stripe component")).toBeTruthy();
35
35
  expect(screen.queryByText("API controls")).toBeNull();
36
36
  });
37
+ it("names API, BetterStripe components, and Stripe UI distinctly while preserving mode values", async () => {
38
+ const changed = vi.fn();
39
+ render(
40
+ <IntegrationSwitch
41
+ views={{
42
+ headless: <p>API controls</p>,
43
+ components: <p>Package UI</p>,
44
+ embedded: <p>Stripe embed</p>,
45
+ hosted: <p>Stripe portal</p>,
46
+ }}
47
+ onValueChange={changed}
48
+ />,
49
+ );
50
+ expect(screen.getByRole("button", { name: "via API" })).toBeTruthy();
51
+ expect(screen.getByRole("button", { name: "Stripe embedded" })).toBeTruthy();
52
+ expect(screen.getByRole("button", { name: "Stripe hosted" })).toBeTruthy();
53
+ await userEvent.click(
54
+ screen.getByRole("button", { name: "BetterStripe components" }),
55
+ );
56
+ expect(changed).toHaveBeenLastCalledWith("components");
57
+ expect(screen.getByText("Package UI")).toBeTruthy();
58
+ await userEvent.click(screen.getByRole("button", { name: "via API" }));
59
+ expect(changed).toHaveBeenLastCalledWith("headless");
60
+ expect(screen.queryByRole("button", { name: "Headless" })).toBeNull();
61
+ });
@@ -2,8 +2,8 @@
2
2
  import { useState, type ReactNode } from "react";
3
3
  export type IntegrationMode = "headless" | "components" | "embedded" | "hosted";
4
4
  const labels: Record<IntegrationMode, string> = {
5
- headless: "Headless",
6
- components: "Components",
5
+ headless: "via API",
6
+ components: "BetterStripe components",
7
7
  embedded: "Stripe embedded",
8
8
  hosted: "Stripe hosted",
9
9
  };
@@ -0,0 +1,53 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { afterEach, expect, it } from "vitest";
5
+ import { Pagination, useCursorPagination } from "./Pagination.js";
6
+ afterEach(cleanup);
7
+ it("navigates cursor history and resets when the account changes", async () => {
8
+ function View({ account }: { account: string }) {
9
+ const state = useCursorPagination(account);
10
+ return (
11
+ <>
12
+ <output>{state.cursor ?? "first"}</output>
13
+ <Pagination
14
+ page={state.page}
15
+ canPrevious={state.canPrevious}
16
+ canNext={state.page < 3}
17
+ onPrevious={state.previous}
18
+ onNext={() => state.next(`cursor-${state.page}`)}
19
+ />
20
+ </>
21
+ );
22
+ }
23
+ const { rerender } = render(<View account="a" />);
24
+ expect(
25
+ screen.getByRole("button", { name: "Previous" }).hasAttribute("disabled"),
26
+ ).toBe(true);
27
+ await userEvent.click(screen.getByRole("button", { name: "Next" }));
28
+ expect(screen.getByText("cursor-1")).toBeTruthy();
29
+ await userEvent.click(screen.getByRole("button", { name: "Next" }));
30
+ expect(screen.getByText("Page 3")).toBeTruthy();
31
+ expect(
32
+ screen.getByRole("button", { name: "Next" }).hasAttribute("disabled"),
33
+ ).toBe(true);
34
+ await userEvent.click(screen.getByRole("button", { name: "Previous" }));
35
+ expect(screen.getByText("cursor-1")).toBeTruthy();
36
+ rerender(<View account="b" />);
37
+ expect(screen.getByText("Page 1")).toBeTruthy();
38
+ expect(screen.getByText("first")).toBeTruthy();
39
+ });
40
+ it("disables navigation while a page is loading", () => {
41
+ render(
42
+ <Pagination
43
+ page={2}
44
+ canPrevious
45
+ canNext
46
+ isLoading
47
+ onPrevious={() => {}}
48
+ onNext={() => {}}
49
+ />,
50
+ );
51
+ for (const button of screen.getAllByRole("button"))
52
+ expect(button.hasAttribute("disabled")).toBe(true);
53
+ });
@@ -0,0 +1,93 @@
1
+ "use client";
2
+ import { useState, type ReactNode } from "react";
3
+
4
+ /** Cursor history for bounded server pages. Changing scope resets navigation. */
5
+ export function useCursorPagination(scopeKey: string) {
6
+ const initial = { scopeKey, cursors: [null] as (string | null)[], index: 0 };
7
+ const [state, setState] = useState(initial);
8
+ const current = state.scopeKey === scopeKey ? state : initial;
9
+ if (state.scopeKey !== scopeKey) setState(initial);
10
+ return {
11
+ cursor: current.cursors[current.index],
12
+ page: current.index + 1,
13
+ canPrevious: current.index > 0,
14
+ previous: () =>
15
+ setState((previous) => ({
16
+ ...previous,
17
+ index: Math.max(0, previous.index - 1),
18
+ })),
19
+ next: (cursor: string) =>
20
+ setState((previous) => ({
21
+ ...previous,
22
+ cursors: [...previous.cursors.slice(0, previous.index + 1), cursor],
23
+ index: previous.index + 1,
24
+ })),
25
+ };
26
+ }
27
+ export type PaginationProps = {
28
+ page: number;
29
+ canPrevious: boolean;
30
+ canNext: boolean;
31
+ isLoading?: boolean;
32
+ onPrevious: () => void;
33
+ onNext: () => void;
34
+ previousLabel?: string;
35
+ nextLabel?: string;
36
+ pageLabel?: string;
37
+ children?: (state: {
38
+ pageLabel: string;
39
+ previousLabel: string;
40
+ nextLabel: string;
41
+ previousDisabled: boolean;
42
+ nextDisabled: boolean;
43
+ previous: () => void;
44
+ next: () => void;
45
+ }) => ReactNode;
46
+ };
47
+ /** Unstyled navigation with render props for application design systems. */
48
+ export function Pagination({
49
+ page,
50
+ canPrevious,
51
+ canNext,
52
+ isLoading = false,
53
+ onPrevious,
54
+ onNext,
55
+ previousLabel = "Previous",
56
+ nextLabel = "Next",
57
+ pageLabel = `Page ${page}`,
58
+ children,
59
+ }: PaginationProps) {
60
+ const previousDisabled = isLoading || !canPrevious;
61
+ const nextDisabled = isLoading || !canNext;
62
+ const state = {
63
+ pageLabel,
64
+ previousLabel,
65
+ nextLabel,
66
+ previousDisabled,
67
+ nextDisabled,
68
+ previous: () => {
69
+ if (!previousDisabled) onPrevious();
70
+ },
71
+ next: () => {
72
+ if (!nextDisabled) onNext();
73
+ },
74
+ };
75
+ if (children) return <>{children(state)}</>;
76
+ return (
77
+ <nav aria-label="Pagination">
78
+ <button
79
+ type="button"
80
+ disabled={previousDisabled}
81
+ onClick={state.previous}
82
+ >
83
+ {previousLabel}
84
+ </button>
85
+ <span role="status" aria-live="polite">
86
+ {pageLabel}
87
+ </span>
88
+ <button type="button" disabled={nextDisabled} onClick={state.next}>
89
+ {nextLabel}
90
+ </button>
91
+ </nav>
92
+ );
93
+ }
@@ -80,3 +80,33 @@ it("exports payout history with explicit empty/loading states and date formattin
80
80
  expect(screen.getByText("po_history")).toBeTruthy();
81
81
  expect(screen.getByText("Not supplied by Stripe")).toBeTruthy();
82
82
  });
83
+ it("keeps completed payouts out of upcoming and sorts the remaining arrivals", async () => {
84
+ const pending = {
85
+ ...row("po_pending", "pending"),
86
+ arrivalDate: 1900000000000,
87
+ };
88
+ const transit = {
89
+ ...row("po_transit", "in_transit"),
90
+ arrivalDate: 1800000000000,
91
+ };
92
+ const paid = row("po_paid");
93
+ const failed = row("po_failed", "failed");
94
+ const load = async () => ({
95
+ balances: [],
96
+ upcoming: [paid, pending, failed, transit],
97
+ payouts: [paid, pending, failed, transit],
98
+ nextCursor: null,
99
+ });
100
+ const { container } = render(<Payouts scopeKey="seller" load={load} />);
101
+ await screen.findByText("po_pending");
102
+ const headings = Array.from(container.querySelectorAll("h3"));
103
+ const upcomingTable = headings.find(
104
+ (node) => node.textContent === "Upcoming payouts",
105
+ )!.nextElementSibling!;
106
+ expect(upcomingTable.textContent).not.toContain("po_paid");
107
+ expect(upcomingTable.textContent).not.toContain("po_failed");
108
+ expect(upcomingTable.textContent!.indexOf("po_transit")).toBeLessThan(
109
+ upcomingTable.textContent!.indexOf("po_pending"),
110
+ );
111
+ expect(screen.getAllByText("po_paid")).toHaveLength(1);
112
+ });
@@ -30,6 +30,7 @@ export type PayoutsRenderProps = {
30
30
  isPending: boolean;
31
31
  nextPayout: PayoutRow | undefined;
32
32
  previousPayouts: PayoutRow[];
33
+ upcomingPayouts: PayoutRow[];
33
34
  refresh: () => void;
34
35
  loadMore: () => Promise<void>;
35
36
  };
@@ -101,6 +102,7 @@ function PayoutsBody({ load, scopeKey, children }: HeadlessPayoutsProps) {
101
102
  isPending: resource.isLoading || more.isPending,
102
103
  error: resource.error ?? more.error,
103
104
  nextPayout: sortedUpcoming[0],
105
+ upcomingPayouts: sortedUpcoming,
104
106
  previousPayouts: data?.payouts.filter((row) => !upcoming(row)) ?? [],
105
107
  refresh: resource.refresh,
106
108
  loadMore,
@@ -155,6 +157,7 @@ function NativePayouts({
155
157
  error,
156
158
  nextPayout,
157
159
  previousPayouts,
160
+ upcomingPayouts,
158
161
  refresh,
159
162
  loadMore,
160
163
  }: PayoutsRenderProps) {
@@ -186,7 +189,7 @@ function NativePayouts({
186
189
  </dl>
187
190
  <h3>Upcoming payouts</h3>
188
191
  <PayoutHistory
189
- rows={data.upcoming}
192
+ rows={upcomingPayouts}
190
193
  emptyLabel="No upcoming payouts scheduled."
191
194
  />
192
195
  <h3>Previous payouts</h3>