@wopr-network/platform-ui-core 1.25.0 → 1.27.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/package.json +1 -1
- package/src/__tests__/amount-selector.test.tsx +36 -0
- package/src/__tests__/billing.test.tsx +2 -2
- package/src/__tests__/confirmation-tracker.test.tsx +57 -0
- package/src/__tests__/crypto-checkout.test.tsx +79 -0
- package/src/__tests__/deposit-view.test.tsx +43 -0
- package/src/__tests__/payment-method-picker.test.tsx +88 -0
- package/src/app/(dashboard)/billing/credits/page.tsx +2 -2
- package/src/app/(dashboard)/onboarding/page.tsx +0 -1
- package/src/app/(dashboard)/settings/profile/page.tsx +1 -0
- package/src/app/admin/pool-config/page.tsx +5 -0
- package/src/components/admin/admin-nav.tsx +1 -0
- package/src/components/admin/pool-config-dashboard.tsx +139 -0
- package/src/components/billing/amount-selector.tsx +66 -0
- package/src/components/billing/buy-crypto-credits-panel.tsx +1 -302
- package/src/components/billing/confirmation-tracker.tsx +92 -0
- package/src/components/billing/crypto-checkout.tsx +185 -0
- package/src/components/billing/deposit-view.tsx +90 -0
- package/src/components/billing/payment-method-picker.tsx +129 -0
- package/src/lib/admin-pool-api.ts +15 -0
- package/src/lib/trpc-types.ts +2 -0
package/package.json
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { AmountSelector } from "@/components/billing/amount-selector";
|
|
5
|
+
|
|
6
|
+
describe("AmountSelector", () => {
|
|
7
|
+
it("renders preset amounts", () => {
|
|
8
|
+
render(<AmountSelector onSelect={vi.fn()} />);
|
|
9
|
+
expect(screen.getByText("$10")).toBeInTheDocument();
|
|
10
|
+
expect(screen.getByText("$25")).toBeInTheDocument();
|
|
11
|
+
expect(screen.getByText("$50")).toBeInTheDocument();
|
|
12
|
+
expect(screen.getByText("$100")).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("calls onSelect with chosen amount", async () => {
|
|
16
|
+
const onSelect = vi.fn();
|
|
17
|
+
render(<AmountSelector onSelect={onSelect} />);
|
|
18
|
+
await userEvent.click(screen.getByText("$25"));
|
|
19
|
+
await userEvent.click(screen.getByRole("button", { name: /continue/i }));
|
|
20
|
+
expect(onSelect).toHaveBeenCalledWith(25);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("supports custom amount input", async () => {
|
|
24
|
+
const onSelect = vi.fn();
|
|
25
|
+
render(<AmountSelector onSelect={onSelect} />);
|
|
26
|
+
const input = screen.getByPlaceholderText(/custom/i);
|
|
27
|
+
await userEvent.type(input, "75");
|
|
28
|
+
await userEvent.click(screen.getByRole("button", { name: /continue/i }));
|
|
29
|
+
expect(onSelect).toHaveBeenCalledWith(75);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("disables continue when no amount selected", () => {
|
|
33
|
+
render(<AmountSelector onSelect={vi.fn()} />);
|
|
34
|
+
expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -408,8 +408,8 @@ describe("Payment page", () => {
|
|
|
408
408
|
const amountCells = await screen.findAllByText("$29.00");
|
|
409
409
|
// inv-001 has no downloadUrl and no hostedUrl — the row should render no <a> element at all
|
|
410
410
|
// The 3rd amount cell belongs to inv-001 (oldest invoice, no URLs)
|
|
411
|
-
const inv001Row = amountCells[2].closest("tr")
|
|
412
|
-
expect(within(inv001Row).queryByRole("link")).toBeNull();
|
|
411
|
+
const inv001Row = amountCells[2].closest("tr");
|
|
412
|
+
expect(inv001Row ? within(inv001Row).queryByRole("link") : null).toBeNull();
|
|
413
413
|
});
|
|
414
414
|
|
|
415
415
|
it("renders BYOK messaging", async () => {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { ConfirmationTracker } from "@/components/billing/confirmation-tracker";
|
|
4
|
+
|
|
5
|
+
describe("ConfirmationTracker", () => {
|
|
6
|
+
it("shows confirmation progress", () => {
|
|
7
|
+
render(
|
|
8
|
+
<ConfirmationTracker
|
|
9
|
+
confirmations={8}
|
|
10
|
+
confirmationsRequired={20}
|
|
11
|
+
displayAmount="25.00 USDT"
|
|
12
|
+
credited={false}
|
|
13
|
+
/>,
|
|
14
|
+
);
|
|
15
|
+
expect(screen.getByText(/8/)).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByText(/20/)).toBeInTheDocument();
|
|
17
|
+
expect(screen.getByText(/25\.00 USDT/)).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("shows credited state", () => {
|
|
21
|
+
render(
|
|
22
|
+
<ConfirmationTracker
|
|
23
|
+
confirmations={20}
|
|
24
|
+
confirmationsRequired={20}
|
|
25
|
+
displayAmount="25.00 USDT"
|
|
26
|
+
credited={true}
|
|
27
|
+
/>,
|
|
28
|
+
);
|
|
29
|
+
expect(screen.getByText(/credits applied/i)).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("renders progress bar", () => {
|
|
33
|
+
render(
|
|
34
|
+
<ConfirmationTracker
|
|
35
|
+
confirmations={10}
|
|
36
|
+
confirmationsRequired={20}
|
|
37
|
+
displayAmount="25.00 USDT"
|
|
38
|
+
credited={false}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
const bar = screen.getByRole("progressbar");
|
|
42
|
+
expect(bar).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("shows tx hash when provided", () => {
|
|
46
|
+
render(
|
|
47
|
+
<ConfirmationTracker
|
|
48
|
+
confirmations={5}
|
|
49
|
+
confirmationsRequired={20}
|
|
50
|
+
displayAmount="25.00 USDT"
|
|
51
|
+
credited={false}
|
|
52
|
+
txHash="0xabc123"
|
|
53
|
+
/>,
|
|
54
|
+
);
|
|
55
|
+
expect(screen.getByText(/0xabc123/)).toBeInTheDocument();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
vi.mock("framer-motion", () => ({
|
|
6
|
+
motion: {
|
|
7
|
+
div: ({ children, ...props }: { children?: React.ReactNode; [key: string]: unknown }) => (
|
|
8
|
+
<div {...props}>{children}</div>
|
|
9
|
+
),
|
|
10
|
+
},
|
|
11
|
+
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("qrcode.react", () => ({
|
|
15
|
+
QRCodeSVG: ({ value }: { value: string }) => <div data-testid="qr">{value}</div>,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("@/lib/api", () => ({
|
|
19
|
+
getSupportedPaymentMethods: vi.fn().mockResolvedValue([
|
|
20
|
+
{
|
|
21
|
+
id: "BTC:mainnet",
|
|
22
|
+
type: "native",
|
|
23
|
+
token: "BTC",
|
|
24
|
+
chain: "bitcoin",
|
|
25
|
+
displayName: "Bitcoin",
|
|
26
|
+
decimals: 8,
|
|
27
|
+
displayOrder: 0,
|
|
28
|
+
iconUrl: "",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "USDT:tron",
|
|
32
|
+
type: "erc20",
|
|
33
|
+
token: "USDT",
|
|
34
|
+
chain: "tron",
|
|
35
|
+
displayName: "USDT on Tron",
|
|
36
|
+
decimals: 6,
|
|
37
|
+
displayOrder: 1,
|
|
38
|
+
iconUrl: "",
|
|
39
|
+
},
|
|
40
|
+
]),
|
|
41
|
+
createCheckout: vi.fn().mockResolvedValue({
|
|
42
|
+
depositAddress: "THwbQb1sPiRUpUYunVQxQKc6i4LCmpP1mj",
|
|
43
|
+
displayAmount: "32.24 TRX",
|
|
44
|
+
amountUsd: 10,
|
|
45
|
+
token: "TRX",
|
|
46
|
+
chain: "tron",
|
|
47
|
+
referenceId: "trx:test",
|
|
48
|
+
}),
|
|
49
|
+
getChargeStatus: vi.fn().mockResolvedValue({
|
|
50
|
+
status: "waiting",
|
|
51
|
+
amountExpectedCents: 1000,
|
|
52
|
+
amountReceivedCents: 0,
|
|
53
|
+
confirmations: 0,
|
|
54
|
+
confirmationsRequired: 20,
|
|
55
|
+
credited: false,
|
|
56
|
+
}),
|
|
57
|
+
apiFetch: vi.fn(),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
import { CryptoCheckout } from "@/components/billing/crypto-checkout";
|
|
61
|
+
|
|
62
|
+
describe("CryptoCheckout", () => {
|
|
63
|
+
it("renders amount selector on mount", async () => {
|
|
64
|
+
render(<CryptoCheckout />);
|
|
65
|
+
await waitFor(() => {
|
|
66
|
+
expect(screen.getByText("$25")).toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("advances to payment picker after selecting amount", async () => {
|
|
71
|
+
render(<CryptoCheckout />);
|
|
72
|
+
await waitFor(() => screen.getByText("$25"));
|
|
73
|
+
await userEvent.click(screen.getByText("$25"));
|
|
74
|
+
await userEvent.click(screen.getByRole("button", { name: /continue/i }));
|
|
75
|
+
await waitFor(() => {
|
|
76
|
+
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { DepositView } from "@/components/billing/deposit-view";
|
|
5
|
+
|
|
6
|
+
vi.mock("qrcode.react", () => ({
|
|
7
|
+
QRCodeSVG: ({ value }: { value: string }) => <div data-testid="qr" data-value={value} />,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const CHECKOUT = {
|
|
11
|
+
depositAddress: "THwbQb1sPiRUpUYunVQxQKc6i4LCmpP1mj",
|
|
12
|
+
displayAmount: "32.24 TRX",
|
|
13
|
+
amountUsd: 10,
|
|
14
|
+
token: "TRX",
|
|
15
|
+
chain: "tron",
|
|
16
|
+
referenceId: "trx:test123",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("DepositView", () => {
|
|
20
|
+
it("shows deposit address and amount", () => {
|
|
21
|
+
render(<DepositView checkout={CHECKOUT} status="waiting" onBack={vi.fn()} />);
|
|
22
|
+
expect(screen.getByText(/32\.24 TRX/)).toBeInTheDocument();
|
|
23
|
+
expect(screen.getByText(/THwbQb1s/)).toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("shows waiting status", () => {
|
|
27
|
+
render(<DepositView checkout={CHECKOUT} status="waiting" onBack={vi.fn()} />);
|
|
28
|
+
expect(screen.getByText(/waiting for payment/i)).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders QR code", () => {
|
|
32
|
+
render(<DepositView checkout={CHECKOUT} status="waiting" onBack={vi.fn()} />);
|
|
33
|
+
expect(screen.getByTestId("qr")).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("copies address to clipboard", async () => {
|
|
37
|
+
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
38
|
+
Object.assign(navigator, { clipboard: { writeText } });
|
|
39
|
+
render(<DepositView checkout={CHECKOUT} status="waiting" onBack={vi.fn()} />);
|
|
40
|
+
await userEvent.click(screen.getByRole("button", { name: /copy/i }));
|
|
41
|
+
expect(writeText).toHaveBeenCalledWith(CHECKOUT.depositAddress);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { PaymentMethodPicker } from "@/components/billing/payment-method-picker";
|
|
5
|
+
import type { SupportedPaymentMethod } from "@/lib/api";
|
|
6
|
+
|
|
7
|
+
const METHODS: SupportedPaymentMethod[] = [
|
|
8
|
+
{
|
|
9
|
+
id: "BTC:mainnet",
|
|
10
|
+
type: "native",
|
|
11
|
+
token: "BTC",
|
|
12
|
+
chain: "bitcoin",
|
|
13
|
+
displayName: "Bitcoin",
|
|
14
|
+
decimals: 8,
|
|
15
|
+
displayOrder: 0,
|
|
16
|
+
iconUrl: "",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "USDT:tron",
|
|
20
|
+
type: "erc20",
|
|
21
|
+
token: "USDT",
|
|
22
|
+
chain: "tron",
|
|
23
|
+
displayName: "USDT on Tron",
|
|
24
|
+
decimals: 6,
|
|
25
|
+
displayOrder: 1,
|
|
26
|
+
iconUrl: "",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "ETH:base",
|
|
30
|
+
type: "native",
|
|
31
|
+
token: "ETH",
|
|
32
|
+
chain: "base",
|
|
33
|
+
displayName: "ETH on Base",
|
|
34
|
+
decimals: 18,
|
|
35
|
+
displayOrder: 2,
|
|
36
|
+
iconUrl: "",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "USDC:polygon",
|
|
40
|
+
type: "erc20",
|
|
41
|
+
token: "USDC",
|
|
42
|
+
chain: "polygon",
|
|
43
|
+
displayName: "USDC on Polygon",
|
|
44
|
+
decimals: 6,
|
|
45
|
+
displayOrder: 3,
|
|
46
|
+
iconUrl: "",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "DOGE:dogecoin",
|
|
50
|
+
type: "native",
|
|
51
|
+
token: "DOGE",
|
|
52
|
+
chain: "dogecoin",
|
|
53
|
+
displayName: "Dogecoin",
|
|
54
|
+
decimals: 8,
|
|
55
|
+
displayOrder: 4,
|
|
56
|
+
iconUrl: "",
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
describe("PaymentMethodPicker", () => {
|
|
61
|
+
it("renders all methods", () => {
|
|
62
|
+
render(<PaymentMethodPicker methods={METHODS} onSelect={vi.fn()} />);
|
|
63
|
+
expect(screen.getByText("Bitcoin")).toBeInTheDocument();
|
|
64
|
+
expect(screen.getByText("USDT on Tron")).toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("filters by search text", async () => {
|
|
68
|
+
render(<PaymentMethodPicker methods={METHODS} onSelect={vi.fn()} />);
|
|
69
|
+
await userEvent.type(screen.getByPlaceholderText(/search/i), "tron");
|
|
70
|
+
expect(screen.getByText("USDT on Tron")).toBeInTheDocument();
|
|
71
|
+
expect(screen.queryByText("Bitcoin")).not.toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("filters by Stablecoins pill", async () => {
|
|
75
|
+
render(<PaymentMethodPicker methods={METHODS} onSelect={vi.fn()} />);
|
|
76
|
+
await userEvent.click(screen.getByText("Stablecoins"));
|
|
77
|
+
expect(screen.getByText("USDT on Tron")).toBeInTheDocument();
|
|
78
|
+
expect(screen.getByText("USDC on Polygon")).toBeInTheDocument();
|
|
79
|
+
expect(screen.queryByText("Bitcoin")).not.toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("calls onSelect when a method is clicked", async () => {
|
|
83
|
+
const onSelect = vi.fn();
|
|
84
|
+
render(<PaymentMethodPicker methods={METHODS} onSelect={onSelect} />);
|
|
85
|
+
await userEvent.click(screen.getByText("Bitcoin"));
|
|
86
|
+
expect(onSelect).toHaveBeenCalledWith(METHODS[0]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -5,9 +5,9 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
|
5
5
|
import { Suspense, useEffect, useState } from "react";
|
|
6
6
|
import { AutoTopupCard } from "@/components/billing/auto-topup-card";
|
|
7
7
|
import { BuyCreditsPanel } from "@/components/billing/buy-credits-panel";
|
|
8
|
-
import { BuyCryptoCreditPanel } from "@/components/billing/buy-crypto-credits-panel";
|
|
9
8
|
import { CouponInput } from "@/components/billing/coupon-input";
|
|
10
9
|
import { CreditBalance } from "@/components/billing/credit-balance";
|
|
10
|
+
import { CryptoCheckout } from "@/components/billing/crypto-checkout";
|
|
11
11
|
import { DividendBanner } from "@/components/billing/dividend-banner";
|
|
12
12
|
import { DividendEligibility } from "@/components/billing/dividend-eligibility";
|
|
13
13
|
import { DividendPoolStats } from "@/components/billing/dividend-pool-stats";
|
|
@@ -200,7 +200,7 @@ function CreditsContent() {
|
|
|
200
200
|
|
|
201
201
|
<BuyCreditsPanel />
|
|
202
202
|
<CouponInput />
|
|
203
|
-
<
|
|
203
|
+
<CryptoCheckout />
|
|
204
204
|
<AutoTopupCard />
|
|
205
205
|
<TransactionHistory />
|
|
206
206
|
|
|
@@ -32,7 +32,6 @@ export default function OnboardingPage() {
|
|
|
32
32
|
const [botName, setBotName] = useState("");
|
|
33
33
|
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
|
34
34
|
|
|
35
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: router.push is stable; using [router] causes infinite re-renders
|
|
36
35
|
useEffect(() => {
|
|
37
36
|
if (isOnboardingComplete()) {
|
|
38
37
|
router.push(homePath());
|
|
@@ -253,6 +253,7 @@ export default function ProfilePage() {
|
|
|
253
253
|
disabled={uploading}
|
|
254
254
|
>
|
|
255
255
|
{profile.avatarUrl ? (
|
|
256
|
+
// biome-ignore lint/performance/noImgElement: external avatar URL — domain not configured for next/image
|
|
256
257
|
<img
|
|
257
258
|
src={profile.avatarUrl}
|
|
258
259
|
alt={profile.name}
|
|
@@ -23,6 +23,7 @@ const adminNavItems = [
|
|
|
23
23
|
{ label: "Migrations", href: "/admin/migrations" },
|
|
24
24
|
{ label: "GPU", href: "/admin/gpu" },
|
|
25
25
|
{ label: "Fleet Updates", href: "/admin/fleet-updates" },
|
|
26
|
+
{ label: "Pool", href: "/admin/pool-config" },
|
|
26
27
|
{ label: "Incidents", href: "/admin/incidents" },
|
|
27
28
|
];
|
|
28
29
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Input } from "@/components/ui/input";
|
|
8
|
+
import { getPoolConfig, type PoolConfig, setPoolSize } from "@/lib/admin-pool-api";
|
|
9
|
+
|
|
10
|
+
export function PoolConfigDashboard() {
|
|
11
|
+
const [config, setConfig] = useState<PoolConfig | null>(null);
|
|
12
|
+
const [loading, setLoading] = useState(true);
|
|
13
|
+
const [saving, setSaving] = useState(false);
|
|
14
|
+
const [sizeInput, setSizeInput] = useState("");
|
|
15
|
+
|
|
16
|
+
const load = useCallback(async () => {
|
|
17
|
+
try {
|
|
18
|
+
const data = await getPoolConfig();
|
|
19
|
+
setConfig(data);
|
|
20
|
+
setSizeInput(String(data.poolSize));
|
|
21
|
+
} catch {
|
|
22
|
+
toast.error("Failed to load pool config");
|
|
23
|
+
} finally {
|
|
24
|
+
setLoading(false);
|
|
25
|
+
}
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
load();
|
|
30
|
+
}, [load]);
|
|
31
|
+
|
|
32
|
+
const handleSave = async () => {
|
|
33
|
+
const size = Number.parseInt(sizeInput, 10);
|
|
34
|
+
if (Number.isNaN(size) || size < 0 || size > 50) {
|
|
35
|
+
toast.error("Pool size must be between 0 and 50");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
setSaving(true);
|
|
39
|
+
try {
|
|
40
|
+
const result = await setPoolSize(size);
|
|
41
|
+
setConfig((prev) => (prev ? { ...prev, poolSize: result.poolSize } : prev));
|
|
42
|
+
toast.success(`Pool size updated to ${result.poolSize}`);
|
|
43
|
+
} catch {
|
|
44
|
+
toast.error("Failed to update pool size");
|
|
45
|
+
} finally {
|
|
46
|
+
setSaving(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (loading) {
|
|
51
|
+
return (
|
|
52
|
+
<div className="space-y-6 p-6">
|
|
53
|
+
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
|
|
54
|
+
<div className="grid grid-cols-3 gap-4">
|
|
55
|
+
{[1, 2, 3].map((i) => (
|
|
56
|
+
<div key={i} className="h-24 animate-pulse rounded-lg bg-muted" />
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!config) {
|
|
64
|
+
return <div className="p-6 text-muted-foreground">Failed to load pool configuration.</div>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!config.enabled) {
|
|
68
|
+
return (
|
|
69
|
+
<div className="p-6">
|
|
70
|
+
<h2 className="text-lg font-semibold mb-2">Hot Pool</h2>
|
|
71
|
+
<p className="text-muted-foreground">
|
|
72
|
+
Hot pool is not enabled for this product. Enable the{" "}
|
|
73
|
+
<code className="text-xs bg-muted px-1 py-0.5 rounded">hotPool</code> feature flag in the
|
|
74
|
+
boot config to use pre-provisioned instances.
|
|
75
|
+
</p>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="space-y-6 p-6">
|
|
82
|
+
<div>
|
|
83
|
+
<h2 className="text-lg font-semibold">Hot Pool</h2>
|
|
84
|
+
<p className="text-sm text-muted-foreground">
|
|
85
|
+
Pre-provisioned warm containers for instant instance creation.
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
90
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
91
|
+
<div className="text-sm text-muted-foreground">Target Size</div>
|
|
92
|
+
<div className="mt-1 text-2xl font-bold text-terminal">{config.poolSize}</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
96
|
+
<div className="text-sm text-muted-foreground">Warm Containers</div>
|
|
97
|
+
<div className="mt-1 text-2xl font-bold text-green-400">{config.warmCount}</div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
101
|
+
<div className="text-sm text-muted-foreground">Status</div>
|
|
102
|
+
<div className="mt-1 text-2xl font-bold">
|
|
103
|
+
{config.warmCount >= config.poolSize ? (
|
|
104
|
+
<span className="text-green-400">Full</span>
|
|
105
|
+
) : config.warmCount > 0 ? (
|
|
106
|
+
<span className="text-amber-400">Filling</span>
|
|
107
|
+
) : (
|
|
108
|
+
<span className="text-red-400">Empty</span>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
115
|
+
<div className="text-sm font-medium mb-3">Adjust Pool Size</div>
|
|
116
|
+
<div className="flex items-center gap-3">
|
|
117
|
+
<Input
|
|
118
|
+
type="number"
|
|
119
|
+
min={0}
|
|
120
|
+
max={50}
|
|
121
|
+
value={sizeInput}
|
|
122
|
+
onChange={(e) => setSizeInput(e.target.value)}
|
|
123
|
+
className="w-24"
|
|
124
|
+
/>
|
|
125
|
+
<Button
|
|
126
|
+
onClick={handleSave}
|
|
127
|
+
disabled={saving || sizeInput === String(config.poolSize)}
|
|
128
|
+
size="sm"
|
|
129
|
+
>
|
|
130
|
+
{saving ? "Saving..." : "Update"}
|
|
131
|
+
</Button>
|
|
132
|
+
<span className="text-xs text-muted-foreground">
|
|
133
|
+
The pool manager will replenish to this target within 60 seconds.
|
|
134
|
+
</span>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Input } from "@/components/ui/input";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
|
|
8
|
+
const PRESETS = [10, 25, 50, 100];
|
|
9
|
+
|
|
10
|
+
interface AmountSelectorProps {
|
|
11
|
+
onSelect: (amount: number) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function AmountSelector({ onSelect }: AmountSelectorProps) {
|
|
15
|
+
const [selected, setSelected] = useState<number | null>(null);
|
|
16
|
+
const [custom, setCustom] = useState("");
|
|
17
|
+
|
|
18
|
+
const activeAmount = custom ? Number(custom) : selected;
|
|
19
|
+
const isValid = activeAmount !== null && activeAmount >= 10 && Number.isFinite(activeAmount);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="space-y-4">
|
|
23
|
+
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
24
|
+
{PRESETS.map((amt) => (
|
|
25
|
+
<button
|
|
26
|
+
key={amt}
|
|
27
|
+
type="button"
|
|
28
|
+
onClick={() => {
|
|
29
|
+
setSelected(amt);
|
|
30
|
+
setCustom("");
|
|
31
|
+
}}
|
|
32
|
+
className={cn(
|
|
33
|
+
"rounded-md border p-3 text-lg font-bold transition-colors hover:bg-accent",
|
|
34
|
+
selected === amt && !custom
|
|
35
|
+
? "border-primary bg-primary/5 ring-1 ring-primary"
|
|
36
|
+
: "border-border",
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
${amt}
|
|
40
|
+
</button>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
<Input
|
|
44
|
+
type="number"
|
|
45
|
+
min={10}
|
|
46
|
+
placeholder="Custom amount..."
|
|
47
|
+
value={custom}
|
|
48
|
+
onChange={(e) => {
|
|
49
|
+
setCustom(e.target.value);
|
|
50
|
+
setSelected(null);
|
|
51
|
+
}}
|
|
52
|
+
/>
|
|
53
|
+
<Button
|
|
54
|
+
onClick={() => {
|
|
55
|
+
if (isValid && activeAmount !== null) {
|
|
56
|
+
onSelect(activeAmount);
|
|
57
|
+
}
|
|
58
|
+
}}
|
|
59
|
+
disabled={!isValid}
|
|
60
|
+
className="w-full"
|
|
61
|
+
>
|
|
62
|
+
Continue to payment
|
|
63
|
+
</Button>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|