@wopr-network/platform-ui-core 1.4.0 → 1.5.1
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/package.json
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
2
|
import userEvent from "@testing-library/user-event";
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
|
|
5
|
-
const { mockCreateCryptoCheckout, mockIsAllowedRedirectUrl } =
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
const { mockCreateCryptoCheckout, mockIsAllowedRedirectUrl, mockGetSupportedPaymentMethods } =
|
|
6
|
+
vi.hoisted(() => ({
|
|
7
|
+
mockCreateCryptoCheckout: vi.fn(),
|
|
8
|
+
mockIsAllowedRedirectUrl: vi.fn(),
|
|
9
|
+
mockGetSupportedPaymentMethods: vi.fn(),
|
|
10
|
+
}));
|
|
9
11
|
|
|
10
12
|
vi.mock("@/lib/api", () => ({
|
|
11
13
|
createCryptoCheckout: (...args: unknown[]) => mockCreateCryptoCheckout(...args),
|
|
14
|
+
createEthCheckout: vi.fn(),
|
|
15
|
+
createStablecoinCheckout: vi.fn(),
|
|
16
|
+
getSupportedPaymentMethods: (...args: unknown[]) => mockGetSupportedPaymentMethods(...args),
|
|
12
17
|
}));
|
|
13
18
|
|
|
14
19
|
vi.mock("@/lib/validate-redirect-url", () => ({
|
|
@@ -44,6 +49,9 @@ vi.mock("next/navigation", () => ({
|
|
|
44
49
|
vi.mock("better-auth/react", () => ({
|
|
45
50
|
createAuthClient: () => ({
|
|
46
51
|
useSession: () => ({ data: null, isPending: false, error: null }),
|
|
52
|
+
signIn: { email: vi.fn(), social: vi.fn() },
|
|
53
|
+
signUp: { email: vi.fn() },
|
|
54
|
+
signOut: vi.fn(),
|
|
47
55
|
}),
|
|
48
56
|
}));
|
|
49
57
|
|
|
@@ -55,14 +63,31 @@ vi.mock("lucide-react", async (importOriginal) => {
|
|
|
55
63
|
};
|
|
56
64
|
});
|
|
57
65
|
|
|
66
|
+
const MOCK_USDC_METHOD = {
|
|
67
|
+
id: "usdc:base",
|
|
68
|
+
type: "stablecoin",
|
|
69
|
+
label: "USDC on Base",
|
|
70
|
+
token: "USDC",
|
|
71
|
+
chain: "base",
|
|
72
|
+
};
|
|
73
|
+
const MOCK_BTC_METHOD = {
|
|
74
|
+
id: "btc:btcpay",
|
|
75
|
+
type: "btc",
|
|
76
|
+
label: "BTC",
|
|
77
|
+
token: "BTC",
|
|
78
|
+
chain: "bitcoin",
|
|
79
|
+
};
|
|
80
|
+
|
|
58
81
|
afterEach(() => {
|
|
59
82
|
vi.restoreAllMocks();
|
|
60
83
|
mockIsAllowedRedirectUrl.mockReset();
|
|
61
84
|
mockCreateCryptoCheckout.mockReset();
|
|
85
|
+
mockGetSupportedPaymentMethods.mockReset();
|
|
62
86
|
});
|
|
63
87
|
|
|
64
88
|
describe("BuyCryptoCreditPanel", () => {
|
|
65
89
|
it("renders crypto amount buttons ($10, $25, $50, $100)", async () => {
|
|
90
|
+
mockGetSupportedPaymentMethods.mockResolvedValue([MOCK_USDC_METHOD, MOCK_BTC_METHOD]);
|
|
66
91
|
const { BuyCryptoCreditPanel } = await import("../components/billing/buy-crypto-credits-panel");
|
|
67
92
|
render(<BuyCryptoCreditPanel />);
|
|
68
93
|
|
|
@@ -74,23 +99,35 @@ describe("BuyCryptoCreditPanel", () => {
|
|
|
74
99
|
});
|
|
75
100
|
|
|
76
101
|
it("Pay button is disabled when no amount selected", async () => {
|
|
102
|
+
mockGetSupportedPaymentMethods.mockResolvedValue([MOCK_USDC_METHOD, MOCK_BTC_METHOD]);
|
|
77
103
|
const { BuyCryptoCreditPanel } = await import("../components/billing/buy-crypto-credits-panel");
|
|
78
104
|
render(<BuyCryptoCreditPanel />);
|
|
79
105
|
|
|
80
|
-
|
|
106
|
+
// Wait for methods to load
|
|
107
|
+
await waitFor(() => {
|
|
108
|
+
expect(screen.getByText("Stablecoin")).toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const payBtn = screen.getByRole("button", { name: "Pay with USDC" });
|
|
81
112
|
expect(payBtn).toBeDisabled();
|
|
82
113
|
});
|
|
83
114
|
|
|
84
115
|
it("Pay button is enabled after selecting an amount", async () => {
|
|
116
|
+
mockGetSupportedPaymentMethods.mockResolvedValue([MOCK_USDC_METHOD, MOCK_BTC_METHOD]);
|
|
85
117
|
const user = userEvent.setup();
|
|
86
118
|
const { BuyCryptoCreditPanel } = await import("../components/billing/buy-crypto-credits-panel");
|
|
87
119
|
render(<BuyCryptoCreditPanel />);
|
|
88
120
|
|
|
121
|
+
await waitFor(() => {
|
|
122
|
+
expect(screen.getByText("Stablecoin")).toBeInTheDocument();
|
|
123
|
+
});
|
|
124
|
+
|
|
89
125
|
await user.click(screen.getByText("$25"));
|
|
90
|
-
expect(screen.getByRole("button", { name: "Pay with
|
|
126
|
+
expect(screen.getByRole("button", { name: "Pay with USDC" })).toBeEnabled();
|
|
91
127
|
});
|
|
92
128
|
|
|
93
129
|
it("calls createCryptoCheckout with selected amount and redirects", async () => {
|
|
130
|
+
mockGetSupportedPaymentMethods.mockResolvedValue([MOCK_USDC_METHOD, MOCK_BTC_METHOD]);
|
|
94
131
|
const hrefSetter = vi.fn();
|
|
95
132
|
Object.defineProperty(window, "location", {
|
|
96
133
|
value: { ...window.location, href: "" },
|
|
@@ -112,8 +149,14 @@ describe("BuyCryptoCreditPanel", () => {
|
|
|
112
149
|
const { BuyCryptoCreditPanel } = await import("../components/billing/buy-crypto-credits-panel");
|
|
113
150
|
render(<BuyCryptoCreditPanel />);
|
|
114
151
|
|
|
152
|
+
await waitFor(() => {
|
|
153
|
+
expect(screen.getByText("BTC")).toBeInTheDocument();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Switch to BTC tab
|
|
157
|
+
await user.click(screen.getByText("BTC"));
|
|
115
158
|
await user.click(screen.getByText("$50"));
|
|
116
|
-
await user.click(screen.getByRole("button", { name: "Pay with
|
|
159
|
+
await user.click(screen.getByRole("button", { name: "Pay with BTC" }));
|
|
117
160
|
|
|
118
161
|
expect(mockCreateCryptoCheckout).toHaveBeenCalledWith(50);
|
|
119
162
|
expect(mockIsAllowedRedirectUrl).toHaveBeenCalledWith("https://btcpay.example.com/i/abc123");
|
|
@@ -121,6 +164,7 @@ describe("BuyCryptoCreditPanel", () => {
|
|
|
121
164
|
});
|
|
122
165
|
|
|
123
166
|
it("shows error when redirect URL is not allowed", async () => {
|
|
167
|
+
mockGetSupportedPaymentMethods.mockResolvedValue([MOCK_USDC_METHOD, MOCK_BTC_METHOD]);
|
|
124
168
|
mockCreateCryptoCheckout.mockResolvedValue({
|
|
125
169
|
url: "https://evil.com/steal",
|
|
126
170
|
referenceId: "ref-evil",
|
|
@@ -131,15 +175,21 @@ describe("BuyCryptoCreditPanel", () => {
|
|
|
131
175
|
const { BuyCryptoCreditPanel } = await import("../components/billing/buy-crypto-credits-panel");
|
|
132
176
|
render(<BuyCryptoCreditPanel />);
|
|
133
177
|
|
|
178
|
+
await waitFor(() => {
|
|
179
|
+
expect(screen.getByText("BTC")).toBeInTheDocument();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await user.click(screen.getByText("BTC"));
|
|
134
183
|
await user.click(screen.getByText("$10"));
|
|
135
|
-
await user.click(screen.getByRole("button", { name: "Pay with
|
|
184
|
+
await user.click(screen.getByRole("button", { name: "Pay with BTC" }));
|
|
136
185
|
|
|
137
186
|
expect(
|
|
138
187
|
await screen.findByText("Unexpected checkout URL. Please contact support."),
|
|
139
188
|
).toBeInTheDocument();
|
|
140
189
|
});
|
|
141
190
|
|
|
142
|
-
it("shows
|
|
191
|
+
it("shows Creating checkout... while checkout is in progress", async () => {
|
|
192
|
+
mockGetSupportedPaymentMethods.mockResolvedValue([MOCK_USDC_METHOD, MOCK_BTC_METHOD]);
|
|
143
193
|
mockCreateCryptoCheckout.mockReturnValue(
|
|
144
194
|
new Promise(() => {
|
|
145
195
|
/* intentionally pending */
|
|
@@ -150,29 +200,33 @@ describe("BuyCryptoCreditPanel", () => {
|
|
|
150
200
|
const { BuyCryptoCreditPanel } = await import("../components/billing/buy-crypto-credits-panel");
|
|
151
201
|
render(<BuyCryptoCreditPanel />);
|
|
152
202
|
|
|
203
|
+
await waitFor(() => {
|
|
204
|
+
expect(screen.getByText("BTC")).toBeInTheDocument();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await user.click(screen.getByText("BTC"));
|
|
153
208
|
await user.click(screen.getByText("$100"));
|
|
154
|
-
await user.click(screen.getByRole("button", { name: "Pay with
|
|
209
|
+
await user.click(screen.getByRole("button", { name: "Pay with BTC" }));
|
|
155
210
|
|
|
156
|
-
expect(await screen.findByText("
|
|
211
|
+
expect(await screen.findByText("Creating checkout...")).toBeInTheDocument();
|
|
157
212
|
});
|
|
158
213
|
|
|
159
214
|
it("shows error when crypto checkout API call fails", async () => {
|
|
215
|
+
mockGetSupportedPaymentMethods.mockResolvedValue([MOCK_USDC_METHOD, MOCK_BTC_METHOD]);
|
|
160
216
|
mockCreateCryptoCheckout.mockRejectedValue(new Error("API down"));
|
|
161
217
|
|
|
162
218
|
const user = userEvent.setup();
|
|
163
219
|
const { BuyCryptoCreditPanel } = await import("../components/billing/buy-crypto-credits-panel");
|
|
164
220
|
render(<BuyCryptoCreditPanel />);
|
|
165
221
|
|
|
222
|
+
await waitFor(() => {
|
|
223
|
+
expect(screen.getByText("BTC")).toBeInTheDocument();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await user.click(screen.getByText("BTC"));
|
|
166
227
|
await user.click(screen.getByText("$25"));
|
|
167
|
-
await user.click(screen.getByRole("button", { name: "Pay with
|
|
228
|
+
await user.click(screen.getByRole("button", { name: "Pay with BTC" }));
|
|
168
229
|
|
|
169
230
|
expect(await screen.findByText("Checkout failed. Please try again.")).toBeInTheDocument();
|
|
170
231
|
});
|
|
171
|
-
|
|
172
|
-
it("displays minimum amount note", async () => {
|
|
173
|
-
const { BuyCryptoCreditPanel } = await import("../components/billing/buy-crypto-credits-panel");
|
|
174
|
-
render(<BuyCryptoCreditPanel />);
|
|
175
|
-
|
|
176
|
-
expect(screen.getByText(/Minimum \$10/)).toBeInTheDocument();
|
|
177
|
-
});
|
|
178
232
|
});
|
|
@@ -19,10 +19,17 @@ vi.mock("next/navigation", () => ({
|
|
|
19
19
|
usePathname: () => "/settings/profile",
|
|
20
20
|
}));
|
|
21
21
|
|
|
22
|
-
// Mock better-auth/react
|
|
22
|
+
// Mock better-auth/react — session with user-001 (Alice) so role-gated org UI renders admin view
|
|
23
23
|
vi.mock("better-auth/react", () => ({
|
|
24
24
|
createAuthClient: () => ({
|
|
25
|
-
useSession: () => ({
|
|
25
|
+
useSession: () => ({
|
|
26
|
+
data: {
|
|
27
|
+
user: { id: "user-001", name: "Alice Johnson", email: "alice@example.com" },
|
|
28
|
+
session: { id: "sess-1", userId: "user-001", expiresAt: new Date(Date.now() + 86400000) },
|
|
29
|
+
},
|
|
30
|
+
isPending: false,
|
|
31
|
+
error: null,
|
|
32
|
+
}),
|
|
26
33
|
signIn: { email: vi.fn(), social: vi.fn() },
|
|
27
34
|
signUp: { email: vi.fn() },
|
|
28
35
|
signOut: vi.fn(),
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { BuildingIcon, CheckCircleIcon, Loader2Icon, XCircleIcon } from "lucide-react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { use, useCallback, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { useSession } from "@/lib/auth-client";
|
|
8
|
+
import { productName } from "@/lib/brand-config";
|
|
9
|
+
import { acceptInvite } from "@/lib/org-api";
|
|
10
|
+
|
|
11
|
+
type InviteState = "loading" | "accepting" | "success" | "error" | "login-required";
|
|
12
|
+
|
|
13
|
+
export default function InviteAcceptPage({ params }: { params: Promise<{ token: string }> }) {
|
|
14
|
+
const { token } = use(params);
|
|
15
|
+
const { data: session, isPending: sessionLoading } = useSession();
|
|
16
|
+
const router = useRouter();
|
|
17
|
+
const [state, setState] = useState<InviteState>("loading");
|
|
18
|
+
const [orgName, setOrgName] = useState<string>("");
|
|
19
|
+
const [errorMessage, setErrorMessage] = useState<string>("");
|
|
20
|
+
const acceptedRef = useRef(false);
|
|
21
|
+
|
|
22
|
+
const doAccept = useCallback(async () => {
|
|
23
|
+
if (acceptedRef.current) return;
|
|
24
|
+
acceptedRef.current = true;
|
|
25
|
+
|
|
26
|
+
setState("accepting");
|
|
27
|
+
try {
|
|
28
|
+
const result = await acceptInvite(token);
|
|
29
|
+
setOrgName(result.orgName ?? "your organization");
|
|
30
|
+
setState("success");
|
|
31
|
+
// Redirect to dashboard after brief success message
|
|
32
|
+
setTimeout(() => router.push("/"), 2000);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
const msg = err instanceof Error ? err.message : "Failed to accept invite";
|
|
35
|
+
// Surface specific error messages from the backend
|
|
36
|
+
if (msg.includes("already a member")) {
|
|
37
|
+
setErrorMessage("You are already a member of this organization.");
|
|
38
|
+
} else if (msg.includes("expired")) {
|
|
39
|
+
setErrorMessage("This invite has expired. Please ask the admin to send a new one.");
|
|
40
|
+
} else if (msg.includes("revoked")) {
|
|
41
|
+
setErrorMessage("This invite has been revoked.");
|
|
42
|
+
} else if (msg.includes("not found")) {
|
|
43
|
+
setErrorMessage("Invalid invite link. It may have already been used.");
|
|
44
|
+
} else {
|
|
45
|
+
setErrorMessage(msg);
|
|
46
|
+
}
|
|
47
|
+
setState("error");
|
|
48
|
+
acceptedRef.current = false;
|
|
49
|
+
}
|
|
50
|
+
}, [token, router]);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (sessionLoading) return;
|
|
54
|
+
|
|
55
|
+
if (!session?.user) {
|
|
56
|
+
// Not logged in — redirect to login with callback
|
|
57
|
+
setState("login-required");
|
|
58
|
+
const callbackUrl = encodeURIComponent(`/invite/${token}`);
|
|
59
|
+
router.push(`/login?callbackUrl=${callbackUrl}`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// User is logged in — accept the invite
|
|
64
|
+
doAccept();
|
|
65
|
+
}, [session, sessionLoading, token, router, doAccept]);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Card className="border-terminal/20 bg-card/80 backdrop-blur-sm">
|
|
69
|
+
<CardHeader className="text-center">
|
|
70
|
+
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-terminal/10">
|
|
71
|
+
<BuildingIcon className="h-6 w-6 text-terminal" />
|
|
72
|
+
</div>
|
|
73
|
+
<CardTitle className="text-lg font-semibold tracking-tight">Organization Invite</CardTitle>
|
|
74
|
+
<CardDescription>
|
|
75
|
+
{state === "loading" && "Checking your session..."}
|
|
76
|
+
{state === "login-required" && "Redirecting to sign in..."}
|
|
77
|
+
{state === "accepting" && `Joining organization on ${productName()}...`}
|
|
78
|
+
{state === "success" && `Welcome to ${orgName}!`}
|
|
79
|
+
{state === "error" && "Something went wrong"}
|
|
80
|
+
</CardDescription>
|
|
81
|
+
</CardHeader>
|
|
82
|
+
<CardContent className="flex flex-col items-center gap-4 pb-8">
|
|
83
|
+
{(state === "loading" || state === "login-required" || state === "accepting") && (
|
|
84
|
+
<Loader2Icon className="h-8 w-8 animate-spin text-terminal/60" />
|
|
85
|
+
)}
|
|
86
|
+
|
|
87
|
+
{state === "success" && (
|
|
88
|
+
<>
|
|
89
|
+
<CheckCircleIcon className="h-8 w-8 text-emerald-500" />
|
|
90
|
+
<p className="text-sm text-muted-foreground">Redirecting to your dashboard...</p>
|
|
91
|
+
</>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
{state === "error" && (
|
|
95
|
+
<>
|
|
96
|
+
<XCircleIcon className="h-8 w-8 text-destructive" />
|
|
97
|
+
<p className="text-sm text-muted-foreground text-center">{errorMessage}</p>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={() => router.push("/")}
|
|
101
|
+
className="mt-2 text-sm font-medium text-terminal hover:text-terminal/80 underline underline-offset-4"
|
|
102
|
+
>
|
|
103
|
+
Go to dashboard
|
|
104
|
+
</button>
|
|
105
|
+
</>
|
|
106
|
+
)}
|
|
107
|
+
</CardContent>
|
|
108
|
+
</Card>
|
|
109
|
+
);
|
|
110
|
+
}
|