@fantastic.dev/better-stripe 0.8.1 → 0.10.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 +12 -0
- package/MIGRATION.md +12 -0
- package/README.md +46 -2
- package/dist/client/billing/checkout.d.ts +1 -0
- package/dist/client/billing/checkout.d.ts.map +1 -1
- package/dist/client/billing/checkout.js +5 -1
- package/dist/client/billing/checkout.js.map +1 -1
- package/dist/client/billing/providerReads.d.ts +18 -0
- package/dist/client/billing/providerReads.d.ts.map +1 -0
- package/dist/client/billing/providerReads.js +15 -0
- package/dist/client/billing/providerReads.js.map +1 -0
- package/dist/client/billing/subscriptions.d.ts +1 -1
- package/dist/client/billing/subscriptions.d.ts.map +1 -1
- package/dist/client/billing/subscriptions.js.map +1 -1
- package/dist/client/connect/payoutSnapshot.d.ts +28 -0
- package/dist/client/connect/payoutSnapshot.d.ts.map +1 -0
- package/dist/client/connect/payoutSnapshot.js +81 -0
- package/dist/client/connect/payoutSnapshot.js.map +1 -0
- package/dist/client/connect/transfers.d.ts.map +1 -1
- package/dist/client/connect/transfers.js +13 -32
- package/dist/client/connect/transfers.js.map +1 -1
- package/dist/client/core/accounts.d.ts +12 -23
- package/dist/client/core/accounts.d.ts.map +1 -1
- package/dist/client/core/accounts.js +5 -1
- package/dist/client/core/accounts.js.map +1 -1
- package/dist/client/core/idempotency.d.ts +5 -0
- package/dist/client/core/idempotency.d.ts.map +1 -0
- package/dist/client/core/idempotency.js +10 -0
- package/dist/client/core/idempotency.js.map +1 -0
- package/dist/client/index.d.ts +31 -25
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +29 -52
- package/dist/client/index.js.map +1 -1
- package/dist/client/settlement/allocation.d.ts +30 -0
- package/dist/client/settlement/allocation.d.ts.map +1 -0
- package/dist/client/settlement/allocation.js +71 -0
- package/dist/client/settlement/allocation.js.map +1 -0
- package/dist/client/settlement/commissions.d.ts +27 -0
- package/dist/client/settlement/commissions.d.ts.map +1 -0
- package/dist/client/settlement/commissions.js +69 -0
- package/dist/client/settlement/commissions.js.map +1 -0
- package/dist/client/settlement/execution.d.ts +27 -0
- package/dist/client/settlement/execution.d.ts.map +1 -0
- package/dist/client/settlement/execution.js +70 -0
- package/dist/client/settlement/execution.js.map +1 -0
- package/dist/client/settlement/reconciliation.d.ts +10 -0
- package/dist/client/settlement/reconciliation.d.ts.map +1 -0
- package/dist/client/settlement/reconciliation.js +73 -0
- package/dist/client/settlement/reconciliation.js.map +1 -0
- package/dist/client/settlement/settleSale.d.ts +21 -0
- package/dist/client/settlement/settleSale.d.ts.map +1 -0
- package/dist/client/settlement/settleSale.js +27 -0
- package/dist/client/settlement/settleSale.js.map +1 -0
- package/dist/client/settlement/verifiedSale.d.ts +25 -0
- package/dist/client/settlement/verifiedSale.d.ts.map +1 -0
- package/dist/client/settlement/verifiedSale.js +100 -0
- package/dist/client/settlement/verifiedSale.js.map +1 -0
- package/dist/client/webhooks/asyncHooks.d.ts +5 -0
- package/dist/client/webhooks/asyncHooks.d.ts.map +1 -0
- package/dist/client/webhooks/asyncHooks.js +20 -0
- package/dist/client/webhooks/asyncHooks.js.map +1 -0
- package/dist/component/_generated/component.d.ts +115 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/connect/queries.d.ts +30 -1
- package/dist/component/connect/queries.d.ts.map +1 -1
- package/dist/component/connect/queries.js +19 -2
- package/dist/component/connect/queries.js.map +1 -1
- package/dist/component/schema.d.ts +67 -0
- package/dist/component/schema.js +4 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/component/settlement/mutations.d.ts +47 -0
- package/dist/component/settlement/mutations.d.ts.map +1 -0
- package/dist/component/settlement/mutations.js +104 -0
- package/dist/component/settlement/mutations.js.map +1 -0
- package/dist/component/settlement/queries.d.ts +29 -0
- package/dist/component/settlement/queries.d.ts.map +1 -0
- package/dist/component/settlement/queries.js +35 -0
- package/dist/component/settlement/queries.js.map +1 -0
- package/dist/component/settlement/schema.d.ts +99 -0
- package/dist/component/settlement/schema.d.ts.map +1 -0
- package/dist/component/settlement/schema.js +35 -0
- package/dist/component/settlement/schema.js.map +1 -0
- package/dist/react/workflows/AffiliatePayoutEditor.d.ts +41 -0
- package/dist/react/workflows/AffiliatePayoutEditor.d.ts.map +1 -0
- package/dist/react/workflows/AffiliatePayoutEditor.js +80 -0
- package/dist/react/workflows/AffiliatePayoutEditor.js.map +1 -0
- package/dist/react/workflows/AffiliateRateEditor.d.ts +26 -0
- package/dist/react/workflows/AffiliateRateEditor.d.ts.map +1 -0
- package/dist/react/workflows/AffiliateRateEditor.js +49 -0
- package/dist/react/workflows/AffiliateRateEditor.js.map +1 -0
- package/dist/react/workflows/Checkout.d.ts +3 -1
- package/dist/react/workflows/Checkout.d.ts.map +1 -1
- package/dist/react/workflows/Checkout.js +3 -2
- package/dist/react/workflows/Checkout.js.map +1 -1
- package/dist/react/workflows/CheckoutForms.d.ts +2 -0
- package/dist/react/workflows/CheckoutForms.d.ts.map +1 -1
- package/dist/react/workflows/CheckoutForms.js +22 -16
- package/dist/react/workflows/CheckoutForms.js.map +1 -1
- package/dist/react/workflows/IntegrationSwitch.js +2 -2
- package/dist/react/workflows/IntegrationSwitch.js.map +1 -1
- package/dist/react/workflows/Pagination.d.ts +32 -0
- package/dist/react/workflows/Pagination.d.ts.map +1 -0
- package/dist/react/workflows/Pagination.js +49 -0
- package/dist/react/workflows/Pagination.js.map +1 -0
- package/dist/react/workflows/Payouts.d.ts +3 -18
- package/dist/react/workflows/Payouts.d.ts.map +1 -1
- package/dist/react/workflows/Payouts.js +2 -2
- package/dist/react/workflows/Payouts.js.map +1 -1
- package/dist/react/workflows/PlanPricingEditor.d.ts +35 -0
- package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -0
- package/dist/react/workflows/PlanPricingEditor.js +64 -0
- package/dist/react/workflows/PlanPricingEditor.js.map +1 -0
- package/dist/react/workflows/index.d.ts +8 -0
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +4 -0
- package/dist/react/workflows/index.js.map +1 -1
- package/docs/react-workflows-audit.md +19 -0
- package/docs/react-workflows.md +94 -3
- package/docs/settlement.md +158 -0
- package/package.json +6 -4
- package/src/client/billing/checkout-idempotency.test.ts +82 -0
- package/src/client/billing/checkout.ts +6 -1
- package/src/client/billing/providerReads.test.ts +60 -0
- package/src/client/billing/providerReads.ts +30 -0
- package/src/client/billing/subscriptions.ts +1 -8
- package/src/client/connect/payoutSnapshot.test.ts +91 -0
- package/src/client/connect/payoutSnapshot.ts +130 -0
- package/src/client/connect/transfers.test.ts +15 -4
- package/src/client/connect/transfers.ts +13 -42
- package/src/client/core/account-idempotency.test.ts +78 -0
- package/src/client/core/accounts.ts +18 -25
- package/src/client/core/idempotency.test.ts +10 -0
- package/src/client/core/idempotency.ts +11 -0
- package/src/client/index.ts +84 -103
- package/src/client/settlement/allocation.test.ts +98 -0
- package/src/client/settlement/allocation.ts +110 -0
- package/src/client/settlement/commissions.test.ts +103 -0
- package/src/client/settlement/commissions.ts +125 -0
- package/src/client/settlement/execution.test.ts +186 -0
- package/src/client/settlement/execution.ts +134 -0
- package/src/client/settlement/reconciliation.ts +119 -0
- package/src/client/settlement/settleSale.ts +59 -0
- package/src/client/settlement/verifiedSale.test.ts +139 -0
- package/src/client/settlement/verifiedSale.ts +159 -0
- package/src/client/webhooks/asyncHooks.ts +38 -0
- package/src/client/webhooks/processors.test.ts +9 -9
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +141 -0
- package/src/component/connect/queries.ts +26 -2
- package/src/component/earnings-totals.test.ts +13 -0
- package/src/component/schema.ts +8 -0
- package/src/component/settlement/mutations.ts +119 -0
- package/src/component/settlement/queries.ts +38 -0
- package/src/component/settlement/schema.ts +45 -0
- package/src/component/settlement-claims.test.ts +77 -0
- package/src/component/transfers.test.ts +48 -0
- package/src/react/components/EmbeddedDisputes.render.test.tsx +2 -2
- package/src/react/index.payment-element.test.ts +22 -37
- package/src/react/workflows/AffiliatePayoutEditor.test.tsx +90 -0
- package/src/react/workflows/AffiliatePayoutEditor.tsx +201 -0
- package/src/react/workflows/AffiliateRateEditor.test.tsx +36 -0
- package/src/react/workflows/AffiliateRateEditor.tsx +92 -0
- package/src/react/workflows/Checkout.test.tsx +30 -0
- package/src/react/workflows/Checkout.tsx +10 -3
- package/src/react/workflows/CheckoutForms.test.tsx +40 -0
- package/src/react/workflows/CheckoutForms.tsx +37 -16
- package/src/react/workflows/IntegrationSwitch.test.tsx +25 -0
- package/src/react/workflows/IntegrationSwitch.tsx +2 -2
- package/src/react/workflows/Pagination.test.tsx +53 -0
- package/src/react/workflows/Pagination.tsx +93 -0
- package/src/react/workflows/Payouts.test.tsx +33 -0
- package/src/react/workflows/Payouts.tsx +21 -15
- package/src/react/workflows/PlanPricingEditor.test.tsx +79 -0
- package/src/react/workflows/PlanPricingEditor.tsx +156 -0
- package/src/react/workflows/index.ts +22 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useId, useMemo, useState, type ReactNode } from "react";
|
|
3
|
+
import { useWorkflowAction } from "./useWorkflowAction.js";
|
|
4
|
+
export type AffiliateCommissionTerm = "forever" | "one_time";
|
|
5
|
+
export type AffiliatePayoutUpdate = {
|
|
6
|
+
affiliateCommissionTerm?: AffiliateCommissionTerm | null;
|
|
7
|
+
affiliateBasisPoints: number | null;
|
|
8
|
+
/** Major units of the sale currency, for example 5 means EUR 5 on a EUR sale. */
|
|
9
|
+
affiliateFixedAmount: number | null;
|
|
10
|
+
expectedVersion: number;
|
|
11
|
+
};
|
|
12
|
+
export type AffiliatePayoutEditorState = {
|
|
13
|
+
term: AffiliateCommissionTerm | "inherit";
|
|
14
|
+
setTerm: (term: AffiliateCommissionTerm | "inherit") => void;
|
|
15
|
+
value: string;
|
|
16
|
+
mode: "percentage" | "fixed";
|
|
17
|
+
currencySymbol: string;
|
|
18
|
+
setValue: (value: string) => void;
|
|
19
|
+
setMode: (mode: "percentage" | "fixed") => void;
|
|
20
|
+
effectiveLabel: string;
|
|
21
|
+
save: () => Promise<unknown>;
|
|
22
|
+
clear: () => Promise<unknown>;
|
|
23
|
+
isPending: boolean;
|
|
24
|
+
error: string | null;
|
|
25
|
+
message: string | undefined;
|
|
26
|
+
};
|
|
27
|
+
export type AffiliatePayoutEditorProps = {
|
|
28
|
+
scopeKey: string;
|
|
29
|
+
affiliateCommissionTerm?: AffiliateCommissionTerm | null;
|
|
30
|
+
affiliateBasisPoints: number | null;
|
|
31
|
+
affiliateFixedAmount?: number | null;
|
|
32
|
+
effectiveCommissionTerm?: AffiliateCommissionTerm;
|
|
33
|
+
effectiveBasisPoints: number;
|
|
34
|
+
effectiveFixedAmount?: number | null;
|
|
35
|
+
source?: "plan" | "product" | "price" | "store" | "global" | "none";
|
|
36
|
+
currency?: string;
|
|
37
|
+
version: number;
|
|
38
|
+
onSave: (update: AffiliatePayoutUpdate) => Promise<unknown>;
|
|
39
|
+
children?: (state: AffiliatePayoutEditorState) => ReactNode;
|
|
40
|
+
};
|
|
41
|
+
function initialFields(props: AffiliatePayoutEditorProps) {
|
|
42
|
+
return {
|
|
43
|
+
scope: `${props.scopeKey}:${props.version}`,
|
|
44
|
+
term: props.affiliateCommissionTerm ?? ("inherit" as const),
|
|
45
|
+
mode:
|
|
46
|
+
props.affiliateFixedAmount != null
|
|
47
|
+
? ("fixed" as const)
|
|
48
|
+
: ("percentage" as const),
|
|
49
|
+
value:
|
|
50
|
+
props.affiliateFixedAmount != null
|
|
51
|
+
? String(props.affiliateFixedAmount)
|
|
52
|
+
: props.affiliateBasisPoints == null
|
|
53
|
+
? ""
|
|
54
|
+
: String(props.affiliateBasisPoints / 100),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Unstyled inline payout editor. Clear saves inheritance; explicit zero disables payouts. */
|
|
58
|
+
export function AffiliatePayoutEditor(props: AffiliatePayoutEditorProps) {
|
|
59
|
+
const id = useId();
|
|
60
|
+
const currency = (props.currency ?? "usd").toUpperCase();
|
|
61
|
+
const formatter = useMemo(
|
|
62
|
+
() =>
|
|
63
|
+
new Intl.NumberFormat("en-US", {
|
|
64
|
+
style: "currency",
|
|
65
|
+
currency,
|
|
66
|
+
currencyDisplay: "narrowSymbol",
|
|
67
|
+
}),
|
|
68
|
+
[currency],
|
|
69
|
+
);
|
|
70
|
+
const currencySymbol =
|
|
71
|
+
formatter.formatToParts(0).find((part) => part.type === "currency")
|
|
72
|
+
?.value ?? currency;
|
|
73
|
+
const scope = `${props.scopeKey}:${props.version}`;
|
|
74
|
+
const initial = initialFields(props);
|
|
75
|
+
const [fields, setFields] = useState(initial);
|
|
76
|
+
if (fields.scope !== scope) setFields(initial);
|
|
77
|
+
const action = useWorkflowAction(async (clear = false) => {
|
|
78
|
+
const value = clear ? "" : fields.value.trim();
|
|
79
|
+
const amount = value === "" ? null : Number(value);
|
|
80
|
+
if (
|
|
81
|
+
amount !== null &&
|
|
82
|
+
(!/^\d+(\.\d{1,2})?$/.test(value) ||
|
|
83
|
+
!Number.isSafeInteger(Math.round(amount * 100)) ||
|
|
84
|
+
amount < 0 ||
|
|
85
|
+
(fields.mode === "percentage" && amount > 100))
|
|
86
|
+
)
|
|
87
|
+
throw new Error(
|
|
88
|
+
"Enter a non-negative amount with at most two decimals, or a percentage from 0 to 100.",
|
|
89
|
+
);
|
|
90
|
+
await props.onSave({
|
|
91
|
+
affiliateCommissionTerm:
|
|
92
|
+
clear || fields.term === "inherit" ? null : fields.term,
|
|
93
|
+
affiliateBasisPoints:
|
|
94
|
+
fields.mode === "percentage" && amount !== null
|
|
95
|
+
? Math.round(amount * 100)
|
|
96
|
+
: null,
|
|
97
|
+
affiliateFixedAmount: fields.mode === "fixed" ? amount : null,
|
|
98
|
+
expectedVersion: props.version,
|
|
99
|
+
});
|
|
100
|
+
if (clear)
|
|
101
|
+
setFields((previous) =>
|
|
102
|
+
previous.scope === scope
|
|
103
|
+
? { ...previous, value: "", term: "inherit" }
|
|
104
|
+
: previous,
|
|
105
|
+
);
|
|
106
|
+
return clear
|
|
107
|
+
? "Affiliate payout cleared. Using the inherited default."
|
|
108
|
+
: "Affiliate payout saved.";
|
|
109
|
+
}, props.scopeKey);
|
|
110
|
+
const state: AffiliatePayoutEditorState = {
|
|
111
|
+
...fields,
|
|
112
|
+
currencySymbol,
|
|
113
|
+
setTerm: (term) => setFields((previous) => ({ ...previous, term })),
|
|
114
|
+
setValue: (value) => setFields((previous) => ({ ...previous, value })),
|
|
115
|
+
setMode: (mode) => setFields((previous) => ({ ...previous, mode })),
|
|
116
|
+
effectiveLabel: `Commission term: ${(props.effectiveCommissionTerm ?? "forever") === "forever" ? "Forever" : "One-time"}. Effective payout: ${props.effectiveFixedAmount != null ? formatter.format(props.effectiveFixedAmount) : `${props.effectiveBasisPoints / 100}%`}${props.source && props.source !== "none" ? ` from ${props.source === "plan" ? "store plan" : props.source}` : ""}.`,
|
|
117
|
+
save: () => action.run(false),
|
|
118
|
+
clear: () => action.run(true),
|
|
119
|
+
isPending: action.isPending,
|
|
120
|
+
error: action.error,
|
|
121
|
+
message: action.result,
|
|
122
|
+
};
|
|
123
|
+
if (props.children) return <>{props.children(state)}</>;
|
|
124
|
+
return (
|
|
125
|
+
<form
|
|
126
|
+
onSubmit={(event) => {
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
void state.save();
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
<label htmlFor={id}>Affiliate payout</label>
|
|
132
|
+
<input
|
|
133
|
+
id={id}
|
|
134
|
+
inputMode="decimal"
|
|
135
|
+
value={state.value}
|
|
136
|
+
disabled={state.isPending}
|
|
137
|
+
onChange={(event) => state.setValue(event.target.value)}
|
|
138
|
+
/>
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
aria-label="Percentage"
|
|
142
|
+
aria-pressed={state.mode === "percentage"}
|
|
143
|
+
disabled={state.isPending}
|
|
144
|
+
onClick={() => state.setMode("percentage")}
|
|
145
|
+
>
|
|
146
|
+
%
|
|
147
|
+
</button>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
aria-label={`Fixed amount in ${currency}`}
|
|
151
|
+
aria-pressed={state.mode === "fixed"}
|
|
152
|
+
disabled={state.isPending}
|
|
153
|
+
onClick={() => state.setMode("fixed")}
|
|
154
|
+
>
|
|
155
|
+
{currencySymbol}
|
|
156
|
+
</button>
|
|
157
|
+
<label htmlFor={`${id}-term`}>Commission term</label>
|
|
158
|
+
<select
|
|
159
|
+
id={`${id}-term`}
|
|
160
|
+
value={state.term}
|
|
161
|
+
disabled={state.isPending}
|
|
162
|
+
onChange={(event) =>
|
|
163
|
+
state.setTerm(
|
|
164
|
+
event.target.value as AffiliateCommissionTerm | "inherit",
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
>
|
|
168
|
+
<option value="inherit">Inherited term</option>
|
|
169
|
+
<option value="forever">Forever</option>
|
|
170
|
+
<option value="one_time">One-time</option>
|
|
171
|
+
</select>
|
|
172
|
+
<button type="submit" disabled={state.isPending}>
|
|
173
|
+
Save
|
|
174
|
+
</button>
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
aria-label="Clear affiliate payout"
|
|
178
|
+
disabled={state.isPending}
|
|
179
|
+
onClick={() => void state.clear()}
|
|
180
|
+
>
|
|
181
|
+
<svg
|
|
182
|
+
width="16"
|
|
183
|
+
height="16"
|
|
184
|
+
viewBox="0 0 24 24"
|
|
185
|
+
fill="none"
|
|
186
|
+
stroke="currentColor"
|
|
187
|
+
strokeWidth="2"
|
|
188
|
+
aria-hidden="true"
|
|
189
|
+
>
|
|
190
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
191
|
+
</svg>
|
|
192
|
+
</button>
|
|
193
|
+
<p>
|
|
194
|
+
{state.effectiveLabel} Blank inherits the default. Fixed payouts are
|
|
195
|
+
capped at proceeds after fees.
|
|
196
|
+
</p>
|
|
197
|
+
{state.error && <p role="alert">{state.error}</p>}
|
|
198
|
+
{state.message && <p role="status">{state.message}</p>}
|
|
199
|
+
</form>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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, vi } from "vitest";
|
|
5
|
+
import { AffiliateRateEditor } from "./AffiliateRateEditor.js";
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
it("saves a percentage, clears to inherit, and rejects invalid decimals", async () => {
|
|
8
|
+
const save = vi.fn().mockResolvedValue(undefined);
|
|
9
|
+
render(
|
|
10
|
+
<AffiliateRateEditor
|
|
11
|
+
scopeKey="price"
|
|
12
|
+
affiliateBasisPoints={null}
|
|
13
|
+
effectiveBasisPoints={2000}
|
|
14
|
+
source="plan"
|
|
15
|
+
version={0}
|
|
16
|
+
onSave={save}
|
|
17
|
+
/>,
|
|
18
|
+
);
|
|
19
|
+
const input = screen.getByLabelText("Affiliate payout (%)");
|
|
20
|
+
await userEvent.type(input, "12.5");
|
|
21
|
+
await userEvent.click(screen.getByRole("button", { name: "Save payout" }));
|
|
22
|
+
expect(save).toHaveBeenLastCalledWith({
|
|
23
|
+
affiliateBasisPoints: 1250,
|
|
24
|
+
expectedVersion: 0,
|
|
25
|
+
});
|
|
26
|
+
await userEvent.clear(input);
|
|
27
|
+
await userEvent.click(screen.getByRole("button", { name: "Save payout" }));
|
|
28
|
+
expect(save).toHaveBeenLastCalledWith({
|
|
29
|
+
affiliateBasisPoints: null,
|
|
30
|
+
expectedVersion: 0,
|
|
31
|
+
});
|
|
32
|
+
await userEvent.type(input, "100.001");
|
|
33
|
+
await userEvent.click(screen.getByRole("button", { name: "Save payout" }));
|
|
34
|
+
expect(save).toHaveBeenCalledTimes(2);
|
|
35
|
+
expect(screen.getByRole("alert").textContent).toMatch(/percentage/i);
|
|
36
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -83,6 +83,9 @@ it("exposes creation errors and retries without losing the selected integration"
|
|
|
83
83
|
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
|
|
84
84
|
await screen.findByText("Stripe iframe");
|
|
85
85
|
await waitFor(() => expect(createSession).toHaveBeenCalledTimes(2));
|
|
86
|
+
expect(createSession.mock.calls[1][0].requestId).toBe(
|
|
87
|
+
createSession.mock.calls[0][0].requestId,
|
|
88
|
+
);
|
|
86
89
|
});
|
|
87
90
|
it("rejects missing session secrets and initializes Payment Intents through their own callback", async () => {
|
|
88
91
|
const { unmount } = render(
|
|
@@ -116,3 +119,30 @@ it("rejects missing session secrets and initializes Payment Intents through thei
|
|
|
116
119
|
requestId: expect.any(String),
|
|
117
120
|
});
|
|
118
121
|
});
|
|
122
|
+
|
|
123
|
+
it("keeps an explicit request identity through rerenders and replaces generated identity for a new order", async () => {
|
|
124
|
+
const createSession = vi
|
|
125
|
+
.fn()
|
|
126
|
+
.mockResolvedValue({ stripeSessionId: "cs_test", clientSecret: "secret" });
|
|
127
|
+
const props = {
|
|
128
|
+
mode: "embedded" as const,
|
|
129
|
+
orderKey: "order_one",
|
|
130
|
+
publishableKey: "pk_test",
|
|
131
|
+
returnUrl: "https://app.test/receipt",
|
|
132
|
+
createSession,
|
|
133
|
+
};
|
|
134
|
+
const { rerender } = render(
|
|
135
|
+
<Checkout {...props} requestId="persisted-order" />,
|
|
136
|
+
);
|
|
137
|
+
await screen.findByText("Stripe iframe");
|
|
138
|
+
expect(createSession.mock.calls[0][0].requestId).toBe("persisted-order");
|
|
139
|
+
rerender(<Checkout {...props} requestId="persisted-order" />);
|
|
140
|
+
expect(createSession).toHaveBeenCalledTimes(1);
|
|
141
|
+
rerender(<Checkout {...props} orderKey="order_two" />);
|
|
142
|
+
await waitFor(() => expect(createSession).toHaveBeenCalledTimes(2));
|
|
143
|
+
const second = createSession.mock.calls[1][0].requestId;
|
|
144
|
+
expect(second).not.toBe("persisted-order");
|
|
145
|
+
rerender(<Checkout {...props} orderKey="order_three" />);
|
|
146
|
+
await waitFor(() => expect(createSession).toHaveBeenCalledTimes(3));
|
|
147
|
+
expect(createSession.mock.calls[2][0].requestId).not.toBe(second);
|
|
148
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import type
|
|
2
|
+
import { useState, type ReactNode } from "react";
|
|
3
3
|
import { EmbeddedCheckout } from "../components/EmbeddedCheckout.js";
|
|
4
4
|
import { useWorkflowResource } from "./useWorkflowResource.js";
|
|
5
5
|
import {
|
|
@@ -36,6 +36,8 @@ type Common = CheckoutFormOptions & {
|
|
|
36
36
|
publishableKey: string;
|
|
37
37
|
/** Changes when the price, signed-in customer, or referral attribution changes. */
|
|
38
38
|
orderKey: string;
|
|
39
|
+
/** Optional durable logical-order identity, persisted by the host app. */
|
|
40
|
+
requestId?: string;
|
|
39
41
|
returnUrl: string;
|
|
40
42
|
onNavigate?: (url: string) => void;
|
|
41
43
|
children?: (state: CheckoutRenderProps) => ReactNode;
|
|
@@ -57,11 +59,16 @@ export type CheckoutProps = Common &
|
|
|
57
59
|
);
|
|
58
60
|
/** All checkout integrations share one unstyled lifecycle. Callbacks must authenticate and validate prices on the server. */
|
|
59
61
|
export function Checkout(props: CheckoutProps) {
|
|
60
|
-
return
|
|
62
|
+
return (
|
|
63
|
+
<CheckoutBody
|
|
64
|
+
key={`${props.orderKey}:${props.mode}:${props.requestId ?? ""}`}
|
|
65
|
+
{...props}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
61
68
|
}
|
|
62
69
|
function CheckoutBody(props: CheckoutProps) {
|
|
70
|
+
const [requestId] = useState(() => props.requestId ?? crypto.randomUUID());
|
|
63
71
|
const state = useWorkflowResource(async () => {
|
|
64
|
-
const requestId = crypto.randomUUID();
|
|
65
72
|
const result =
|
|
66
73
|
props.mode === "payment-intent"
|
|
67
74
|
? await props.createPaymentIntent({ requestId })
|
|
@@ -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
|
-
|
|
96
|
-
|
|
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 (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 />,
|
|
@@ -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: "
|
|
6
|
-
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
|
+
});
|