@wopr-network/platform-ui-core 1.24.1 → 1.26.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__/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/admin/products/error.tsx +26 -0
- package/src/app/admin/products/page.tsx +234 -0
- package/src/components/admin/products/billing-form.tsx +156 -0
- package/src/components/admin/products/brand-form.tsx +102 -0
- package/src/components/admin/products/features-form.tsx +126 -0
- package/src/components/admin/products/fleet-form.tsx +171 -0
- package/src/components/admin/products/nav-editor.tsx +185 -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 +104 -0
- package/src/components/billing/crypto-checkout.tsx +183 -0
- package/src/components/billing/deposit-view.tsx +82 -0
- package/src/components/billing/payment-method-picker.tsx +133 -0
- package/src/lib/brand-config.ts +30 -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
|
+
});
|
|
@@ -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
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
|
|
6
|
+
export default function ProductsError({
|
|
7
|
+
error,
|
|
8
|
+
reset,
|
|
9
|
+
}: {
|
|
10
|
+
error: Error & { digest?: string };
|
|
11
|
+
reset: () => void;
|
|
12
|
+
}) {
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
console.error("Admin products page error:", error);
|
|
15
|
+
}, [error]);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="flex h-64 flex-col items-center justify-center gap-4 p-6">
|
|
19
|
+
<p className="text-sm text-destructive">Failed to load product configuration.</p>
|
|
20
|
+
<p className="text-xs text-muted-foreground">{error.message}</p>
|
|
21
|
+
<Button variant="outline" size="sm" onClick={reset}>
|
|
22
|
+
Try Again
|
|
23
|
+
</Button>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
import { BillingForm } from "@/components/admin/products/billing-form";
|
|
5
|
+
import { BrandForm } from "@/components/admin/products/brand-form";
|
|
6
|
+
import { FeaturesForm } from "@/components/admin/products/features-form";
|
|
7
|
+
import { FleetForm } from "@/components/admin/products/fleet-form";
|
|
8
|
+
import { NavEditor } from "@/components/admin/products/nav-editor";
|
|
9
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
10
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
11
|
+
import { PLATFORM_BASE_URL } from "@/lib/api-config";
|
|
12
|
+
import { toUserMessage } from "@/lib/errors";
|
|
13
|
+
import { getActiveTenantId } from "@/lib/tenant-context";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Types
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
interface ProductConfig {
|
|
20
|
+
product: {
|
|
21
|
+
id: string;
|
|
22
|
+
slug: string;
|
|
23
|
+
brandName: string;
|
|
24
|
+
productName: string;
|
|
25
|
+
tagline: string;
|
|
26
|
+
domain: string;
|
|
27
|
+
appDomain: string;
|
|
28
|
+
cookieDomain: string;
|
|
29
|
+
companyLegal: string;
|
|
30
|
+
priceLabel: string;
|
|
31
|
+
defaultImage: string;
|
|
32
|
+
emailSupport: string;
|
|
33
|
+
emailPrivacy: string;
|
|
34
|
+
emailLegal: string;
|
|
35
|
+
fromEmail: string;
|
|
36
|
+
homePath: string;
|
|
37
|
+
storagePrefix: string;
|
|
38
|
+
};
|
|
39
|
+
navItems: Array<{
|
|
40
|
+
id: string;
|
|
41
|
+
label: string;
|
|
42
|
+
href: string;
|
|
43
|
+
icon: string | null;
|
|
44
|
+
sortOrder: number;
|
|
45
|
+
requiresRole: string | null;
|
|
46
|
+
enabled: boolean;
|
|
47
|
+
}>;
|
|
48
|
+
features: {
|
|
49
|
+
chatEnabled: boolean;
|
|
50
|
+
onboardingEnabled: boolean;
|
|
51
|
+
onboardingDefaultModel: string | null;
|
|
52
|
+
onboardingMaxCredits: number;
|
|
53
|
+
onboardingWelcomeMsg: string | null;
|
|
54
|
+
sharedModuleBilling: boolean;
|
|
55
|
+
sharedModuleMonitoring: boolean;
|
|
56
|
+
sharedModuleAnalytics: boolean;
|
|
57
|
+
} | null;
|
|
58
|
+
fleet: {
|
|
59
|
+
containerImage: string;
|
|
60
|
+
containerPort: number;
|
|
61
|
+
lifecycle: string;
|
|
62
|
+
billingModel: string;
|
|
63
|
+
maxInstances: number;
|
|
64
|
+
dockerNetwork: string;
|
|
65
|
+
placementStrategy: string;
|
|
66
|
+
fleetDataDir: string;
|
|
67
|
+
} | null;
|
|
68
|
+
billing: {
|
|
69
|
+
stripePublishableKey: string | null;
|
|
70
|
+
creditPrices: Record<string, number>;
|
|
71
|
+
affiliateBaseUrl: string | null;
|
|
72
|
+
affiliateMatchRate: string;
|
|
73
|
+
affiliateMaxCap: number;
|
|
74
|
+
dividendRate: string;
|
|
75
|
+
} | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// API helpers
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
async function adminFetch(path: string, init?: RequestInit): Promise<Response> {
|
|
83
|
+
const tenantId = getActiveTenantId();
|
|
84
|
+
const headers: Record<string, string> = {
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
...(tenantId ? { "x-tenant-id": tenantId } : {}),
|
|
87
|
+
};
|
|
88
|
+
return fetch(`${PLATFORM_BASE_URL}/trpc/${path}`, {
|
|
89
|
+
credentials: "include",
|
|
90
|
+
...init,
|
|
91
|
+
headers: { ...headers, ...(init?.headers as Record<string, string> | undefined) },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function fetchProductConfig(): Promise<ProductConfig> {
|
|
96
|
+
const res = await adminFetch("product.admin.get");
|
|
97
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
98
|
+
const json = (await res.json()) as { result: { data: ProductConfig } };
|
|
99
|
+
return json.result.data;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function mutateProductConfig(endpoint: string, input: unknown): Promise<void> {
|
|
103
|
+
const res = await adminFetch(`product.admin.${endpoint}`, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
body: JSON.stringify(input),
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
const text = await res.text().catch(() => "Unknown error");
|
|
109
|
+
throw new Error(text || `HTTP ${res.status}`);
|
|
110
|
+
}
|
|
111
|
+
const json = (await res.json()) as { error?: { message?: string } };
|
|
112
|
+
if (json.error) {
|
|
113
|
+
throw new Error(json.error.message ?? "Mutation failed");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Default values for missing optional sections
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
const DEFAULT_FEATURES: NonNullable<ProductConfig["features"]> = {
|
|
122
|
+
chatEnabled: false,
|
|
123
|
+
onboardingEnabled: false,
|
|
124
|
+
onboardingDefaultModel: null,
|
|
125
|
+
onboardingMaxCredits: 0,
|
|
126
|
+
onboardingWelcomeMsg: null,
|
|
127
|
+
sharedModuleBilling: false,
|
|
128
|
+
sharedModuleMonitoring: false,
|
|
129
|
+
sharedModuleAnalytics: false,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const DEFAULT_FLEET: NonNullable<ProductConfig["fleet"]> = {
|
|
133
|
+
containerImage: "",
|
|
134
|
+
containerPort: 8080,
|
|
135
|
+
lifecycle: "managed",
|
|
136
|
+
billingModel: "none",
|
|
137
|
+
maxInstances: 10,
|
|
138
|
+
dockerNetwork: "platform",
|
|
139
|
+
placementStrategy: "round_robin",
|
|
140
|
+
fleetDataDir: "/data/fleet",
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const DEFAULT_BILLING: NonNullable<ProductConfig["billing"]> = {
|
|
144
|
+
stripePublishableKey: null,
|
|
145
|
+
creditPrices: {},
|
|
146
|
+
affiliateBaseUrl: null,
|
|
147
|
+
affiliateMatchRate: "0.10",
|
|
148
|
+
affiliateMaxCap: 0,
|
|
149
|
+
dividendRate: "0.05",
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Page component
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
export default function AdminProductsPage() {
|
|
157
|
+
const [config, setConfig] = useState<ProductConfig | null>(null);
|
|
158
|
+
const [loading, setLoading] = useState(true);
|
|
159
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
160
|
+
|
|
161
|
+
const load = useCallback(async () => {
|
|
162
|
+
setLoading(true);
|
|
163
|
+
setLoadError(null);
|
|
164
|
+
try {
|
|
165
|
+
const data = await fetchProductConfig();
|
|
166
|
+
setConfig(data);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
setLoadError(toUserMessage(err, "Failed to load product configuration"));
|
|
169
|
+
} finally {
|
|
170
|
+
setLoading(false);
|
|
171
|
+
}
|
|
172
|
+
}, []);
|
|
173
|
+
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
load();
|
|
176
|
+
}, [load]);
|
|
177
|
+
|
|
178
|
+
if (loading) {
|
|
179
|
+
return (
|
|
180
|
+
<div className="p-6 space-y-6">
|
|
181
|
+
<Skeleton className="h-8 w-48" />
|
|
182
|
+
<Skeleton className="h-10 w-full max-w-sm" />
|
|
183
|
+
<Skeleton className="h-64 w-full" />
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (loadError || !config) {
|
|
189
|
+
return (
|
|
190
|
+
<div className="p-6 flex flex-col items-center justify-center gap-4 h-64">
|
|
191
|
+
<p className="text-sm text-destructive">{loadError ?? "No configuration found."}</p>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div className="p-6 space-y-6">
|
|
198
|
+
<h1 className="text-2xl font-bold tracking-tight">Product Configuration</h1>
|
|
199
|
+
|
|
200
|
+
<Tabs defaultValue="brand">
|
|
201
|
+
<TabsList className="mb-4">
|
|
202
|
+
<TabsTrigger value="brand">Brand</TabsTrigger>
|
|
203
|
+
<TabsTrigger value="navigation">Navigation</TabsTrigger>
|
|
204
|
+
<TabsTrigger value="features">Features</TabsTrigger>
|
|
205
|
+
<TabsTrigger value="fleet">Fleet</TabsTrigger>
|
|
206
|
+
<TabsTrigger value="billing">Billing</TabsTrigger>
|
|
207
|
+
</TabsList>
|
|
208
|
+
|
|
209
|
+
<TabsContent value="brand">
|
|
210
|
+
<BrandForm initial={config.product} onSave={mutateProductConfig} />
|
|
211
|
+
</TabsContent>
|
|
212
|
+
|
|
213
|
+
<TabsContent value="navigation">
|
|
214
|
+
<NavEditor initial={config.navItems} onSave={mutateProductConfig} />
|
|
215
|
+
</TabsContent>
|
|
216
|
+
|
|
217
|
+
<TabsContent value="features">
|
|
218
|
+
<FeaturesForm
|
|
219
|
+
initial={config.features ?? DEFAULT_FEATURES}
|
|
220
|
+
onSave={mutateProductConfig}
|
|
221
|
+
/>
|
|
222
|
+
</TabsContent>
|
|
223
|
+
|
|
224
|
+
<TabsContent value="fleet">
|
|
225
|
+
<FleetForm initial={config.fleet ?? DEFAULT_FLEET} onSave={mutateProductConfig} />
|
|
226
|
+
</TabsContent>
|
|
227
|
+
|
|
228
|
+
<TabsContent value="billing">
|
|
229
|
+
<BillingForm initial={config.billing ?? DEFAULT_BILLING} onSave={mutateProductConfig} />
|
|
230
|
+
</TabsContent>
|
|
231
|
+
</Tabs>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|