@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,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-ui-core",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "Brand-agnostic AI agent platform UI — deploy as any brand via env vars",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 } = vi.hoisted(() => ({
6
- mockCreateCryptoCheckout: vi.fn(),
7
- mockIsAllowedRedirectUrl: vi.fn(),
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
- const payBtn = screen.getByRole("button", { name: "Pay with crypto" });
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 crypto" })).toBeEnabled();
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 crypto" }));
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 crypto" }));
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 Opening checkout... while checkout is in progress", async () => {
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 crypto" }));
209
+ await user.click(screen.getByRole("button", { name: "Pay with BTC" }));
155
210
 
156
- expect(await screen.findByText("Opening checkout...")).toBeInTheDocument();
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 crypto" }));
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: () => ({ data: null, isPending: false, error: null }),
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
+ }