@wopr-network/platform-ui-core 1.24.1 → 1.25.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/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/lib/brand-config.ts +30 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { Input } from "@/components/ui/input";
|
|
8
|
+
import { Label } from "@/components/ui/label";
|
|
9
|
+
import { toUserMessage } from "@/lib/errors";
|
|
10
|
+
|
|
11
|
+
interface BillingConfig {
|
|
12
|
+
stripePublishableKey: string | null;
|
|
13
|
+
creditPrices: Record<string, number>;
|
|
14
|
+
affiliateBaseUrl: string | null;
|
|
15
|
+
affiliateMatchRate: string;
|
|
16
|
+
affiliateMaxCap: number;
|
|
17
|
+
dividendRate: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface BillingFormProps {
|
|
21
|
+
initial: BillingConfig;
|
|
22
|
+
onSave: (endpoint: string, data: unknown) => Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function BillingForm({ initial, onSave }: BillingFormProps) {
|
|
26
|
+
const [form, setForm] = useState<BillingConfig>(initial);
|
|
27
|
+
const [saving, setSaving] = useState(false);
|
|
28
|
+
|
|
29
|
+
function setStr(key: keyof BillingConfig, value: string) {
|
|
30
|
+
setForm((prev) => ({ ...prev, [key]: value || null }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function setRate(key: "affiliateMatchRate" | "dividendRate", value: string) {
|
|
34
|
+
setForm((prev) => ({ ...prev, [key]: value }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function setNum(key: "affiliateMaxCap", value: string) {
|
|
38
|
+
setForm((prev) => ({ ...prev, [key]: value === "" ? 0 : Number.parseInt(value, 10) }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setCreditPrice(tier: string, value: string) {
|
|
42
|
+
const n = Number.parseFloat(value);
|
|
43
|
+
setForm((prev) => ({
|
|
44
|
+
...prev,
|
|
45
|
+
creditPrices: { ...prev.creditPrices, [tier]: Number.isNaN(n) ? 0 : n },
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function handleSave() {
|
|
50
|
+
setSaving(true);
|
|
51
|
+
try {
|
|
52
|
+
await onSave("updateBilling", form);
|
|
53
|
+
toast.success("Billing settings saved.");
|
|
54
|
+
} catch (err) {
|
|
55
|
+
toast.error(toUserMessage(err, "Failed to save billing settings"));
|
|
56
|
+
} finally {
|
|
57
|
+
setSaving(false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const creditTiers = Object.keys(form.creditPrices);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Card>
|
|
65
|
+
<CardHeader>
|
|
66
|
+
<CardTitle>Billing Configuration</CardTitle>
|
|
67
|
+
</CardHeader>
|
|
68
|
+
<CardContent className="space-y-5">
|
|
69
|
+
<div className="space-y-1.5">
|
|
70
|
+
<Label htmlFor="billing-stripeKey">Stripe Publishable Key</Label>
|
|
71
|
+
<Input
|
|
72
|
+
id="billing-stripeKey"
|
|
73
|
+
value={form.stripePublishableKey ?? ""}
|
|
74
|
+
onChange={(e) => setStr("stripePublishableKey", e.target.value)}
|
|
75
|
+
placeholder="pk_live_..."
|
|
76
|
+
/>
|
|
77
|
+
<p className="text-xs text-muted-foreground">Publishable key only — never the secret.</p>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{creditTiers.length > 0 && (
|
|
81
|
+
<div className="space-y-3">
|
|
82
|
+
<p className="text-sm font-medium">Credit Price Tiers</p>
|
|
83
|
+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
|
84
|
+
{creditTiers.map((tier) => (
|
|
85
|
+
<div key={tier} className="space-y-1.5">
|
|
86
|
+
<Label htmlFor={`billing-price-${tier}`} className="capitalize">
|
|
87
|
+
{tier}
|
|
88
|
+
</Label>
|
|
89
|
+
<Input
|
|
90
|
+
id={`billing-price-${tier}`}
|
|
91
|
+
type="number"
|
|
92
|
+
step="0.0001"
|
|
93
|
+
value={form.creditPrices[tier]}
|
|
94
|
+
onChange={(e) => setCreditPrice(tier, e.target.value)}
|
|
95
|
+
min={0}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
<div className="border-t border-border pt-4 space-y-4">
|
|
104
|
+
<p className="text-sm font-medium text-muted-foreground">Affiliate & Dividends</p>
|
|
105
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
106
|
+
<div className="col-span-full space-y-1.5">
|
|
107
|
+
<Label htmlFor="billing-affiliateUrl">Affiliate Base URL</Label>
|
|
108
|
+
<Input
|
|
109
|
+
id="billing-affiliateUrl"
|
|
110
|
+
value={form.affiliateBaseUrl ?? ""}
|
|
111
|
+
onChange={(e) => setStr("affiliateBaseUrl", e.target.value)}
|
|
112
|
+
placeholder="https://wopr.bot/ref"
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="space-y-1.5">
|
|
116
|
+
<Label htmlFor="billing-matchRate">Affiliate Match Rate</Label>
|
|
117
|
+
<Input
|
|
118
|
+
id="billing-matchRate"
|
|
119
|
+
value={form.affiliateMatchRate}
|
|
120
|
+
onChange={(e) => setRate("affiliateMatchRate", e.target.value)}
|
|
121
|
+
placeholder="0.10"
|
|
122
|
+
/>
|
|
123
|
+
<p className="text-xs text-muted-foreground">Decimal, e.g. 0.10 = 10%</p>
|
|
124
|
+
</div>
|
|
125
|
+
<div className="space-y-1.5">
|
|
126
|
+
<Label htmlFor="billing-maxCap">Affiliate Max Cap (credits)</Label>
|
|
127
|
+
<Input
|
|
128
|
+
id="billing-maxCap"
|
|
129
|
+
type="number"
|
|
130
|
+
value={form.affiliateMaxCap}
|
|
131
|
+
onChange={(e) => setNum("affiliateMaxCap", e.target.value)}
|
|
132
|
+
min={0}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
<div className="space-y-1.5">
|
|
136
|
+
<Label htmlFor="billing-dividendRate">Dividend Rate</Label>
|
|
137
|
+
<Input
|
|
138
|
+
id="billing-dividendRate"
|
|
139
|
+
value={form.dividendRate}
|
|
140
|
+
onChange={(e) => setRate("dividendRate", e.target.value)}
|
|
141
|
+
placeholder="0.05"
|
|
142
|
+
/>
|
|
143
|
+
<p className="text-xs text-muted-foreground">Decimal, e.g. 0.05 = 5%</p>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div className="flex justify-end pt-2">
|
|
149
|
+
<Button onClick={handleSave} disabled={saving}>
|
|
150
|
+
{saving ? "Saving…" : "Save Billing"}
|
|
151
|
+
</Button>
|
|
152
|
+
</div>
|
|
153
|
+
</CardContent>
|
|
154
|
+
</Card>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { Input } from "@/components/ui/input";
|
|
8
|
+
import { Label } from "@/components/ui/label";
|
|
9
|
+
import { toUserMessage } from "@/lib/errors";
|
|
10
|
+
|
|
11
|
+
interface BrandConfig {
|
|
12
|
+
id: string;
|
|
13
|
+
slug: string;
|
|
14
|
+
brandName: string;
|
|
15
|
+
productName: string;
|
|
16
|
+
tagline: string;
|
|
17
|
+
domain: string;
|
|
18
|
+
appDomain: string;
|
|
19
|
+
cookieDomain: string;
|
|
20
|
+
companyLegal: string;
|
|
21
|
+
priceLabel: string;
|
|
22
|
+
defaultImage: string;
|
|
23
|
+
emailSupport: string;
|
|
24
|
+
emailPrivacy: string;
|
|
25
|
+
emailLegal: string;
|
|
26
|
+
fromEmail: string;
|
|
27
|
+
homePath: string;
|
|
28
|
+
storagePrefix: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface BrandFormProps {
|
|
32
|
+
initial: BrandConfig;
|
|
33
|
+
onSave: (endpoint: string, data: unknown) => Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function BrandForm({ initial, onSave }: BrandFormProps) {
|
|
37
|
+
const [form, setForm] = useState<BrandConfig>(initial);
|
|
38
|
+
const [saving, setSaving] = useState(false);
|
|
39
|
+
|
|
40
|
+
function set(key: keyof BrandConfig, value: string) {
|
|
41
|
+
setForm((prev) => ({ ...prev, [key]: value }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function handleSave() {
|
|
45
|
+
setSaving(true);
|
|
46
|
+
try {
|
|
47
|
+
await onSave("updateBrand", form);
|
|
48
|
+
toast.success("Brand settings saved.");
|
|
49
|
+
} catch (err) {
|
|
50
|
+
toast.error(toUserMessage(err, "Failed to save brand settings"));
|
|
51
|
+
} finally {
|
|
52
|
+
setSaving(false);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const fields: Array<{ key: keyof BrandConfig; label: string; placeholder?: string }> = [
|
|
57
|
+
{ key: "brandName", label: "Brand Name", placeholder: "WOPR" },
|
|
58
|
+
{ key: "productName", label: "Product Name", placeholder: "WOPR Platform" },
|
|
59
|
+
{ key: "tagline", label: "Tagline", placeholder: "Your AI platform" },
|
|
60
|
+
{ key: "slug", label: "Slug", placeholder: "wopr" },
|
|
61
|
+
{ key: "domain", label: "Domain", placeholder: "wopr.bot" },
|
|
62
|
+
{ key: "appDomain", label: "App Domain", placeholder: "app.wopr.bot" },
|
|
63
|
+
{ key: "cookieDomain", label: "Cookie Domain", placeholder: ".wopr.bot" },
|
|
64
|
+
{ key: "companyLegal", label: "Company Legal Name", placeholder: "WOPR Inc." },
|
|
65
|
+
{ key: "priceLabel", label: "Price Label", placeholder: "credits" },
|
|
66
|
+
{ key: "defaultImage", label: "Default Image URL", placeholder: "/og-image.png" },
|
|
67
|
+
{ key: "emailSupport", label: "Support Email", placeholder: "support@wopr.bot" },
|
|
68
|
+
{ key: "emailPrivacy", label: "Privacy Email", placeholder: "privacy@wopr.bot" },
|
|
69
|
+
{ key: "emailLegal", label: "Legal Email", placeholder: "legal@wopr.bot" },
|
|
70
|
+
{ key: "fromEmail", label: "From Email", placeholder: "noreply@wopr.bot" },
|
|
71
|
+
{ key: "homePath", label: "Home Path", placeholder: "/dashboard" },
|
|
72
|
+
{ key: "storagePrefix", label: "Storage Prefix", placeholder: "wopr" },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Card>
|
|
77
|
+
<CardHeader>
|
|
78
|
+
<CardTitle>Brand Configuration</CardTitle>
|
|
79
|
+
</CardHeader>
|
|
80
|
+
<CardContent className="space-y-4">
|
|
81
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
82
|
+
{fields.map(({ key, label, placeholder }) => (
|
|
83
|
+
<div key={key} className="space-y-1.5">
|
|
84
|
+
<Label htmlFor={`brand-${key}`}>{label}</Label>
|
|
85
|
+
<Input
|
|
86
|
+
id={`brand-${key}`}
|
|
87
|
+
value={form[key]}
|
|
88
|
+
onChange={(e) => set(key, e.target.value)}
|
|
89
|
+
placeholder={placeholder}
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
<div className="flex justify-end pt-2">
|
|
95
|
+
<Button onClick={handleSave} disabled={saving}>
|
|
96
|
+
{saving ? "Saving…" : "Save Brand"}
|
|
97
|
+
</Button>
|
|
98
|
+
</div>
|
|
99
|
+
</CardContent>
|
|
100
|
+
</Card>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
8
|
+
import { Input } from "@/components/ui/input";
|
|
9
|
+
import { Label } from "@/components/ui/label";
|
|
10
|
+
import { toUserMessage } from "@/lib/errors";
|
|
11
|
+
|
|
12
|
+
interface FeaturesConfig {
|
|
13
|
+
chatEnabled: boolean;
|
|
14
|
+
onboardingEnabled: boolean;
|
|
15
|
+
onboardingDefaultModel: string | null;
|
|
16
|
+
onboardingMaxCredits: number;
|
|
17
|
+
onboardingWelcomeMsg: string | null;
|
|
18
|
+
sharedModuleBilling: boolean;
|
|
19
|
+
sharedModuleMonitoring: boolean;
|
|
20
|
+
sharedModuleAnalytics: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface FeaturesFormProps {
|
|
24
|
+
initial: FeaturesConfig;
|
|
25
|
+
onSave: (endpoint: string, data: unknown) => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function FeaturesForm({ initial, onSave }: FeaturesFormProps) {
|
|
29
|
+
const [form, setForm] = useState<FeaturesConfig>(initial);
|
|
30
|
+
const [saving, setSaving] = useState(false);
|
|
31
|
+
|
|
32
|
+
function setBool(key: keyof FeaturesConfig, value: boolean) {
|
|
33
|
+
setForm((prev) => ({ ...prev, [key]: value }));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function setStr(key: keyof FeaturesConfig, value: string) {
|
|
37
|
+
setForm((prev) => ({ ...prev, [key]: value || null }));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setNum(key: keyof FeaturesConfig, value: string) {
|
|
41
|
+
const n = Number.parseInt(value, 10);
|
|
42
|
+
if (!Number.isNaN(n)) setForm((prev) => ({ ...prev, [key]: n }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function handleSave() {
|
|
46
|
+
setSaving(true);
|
|
47
|
+
try {
|
|
48
|
+
await onSave("updateFeatures", form);
|
|
49
|
+
toast.success("Feature settings saved.");
|
|
50
|
+
} catch (err) {
|
|
51
|
+
toast.error(toUserMessage(err, "Failed to save feature settings"));
|
|
52
|
+
} finally {
|
|
53
|
+
setSaving(false);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const boolFields: Array<{ key: keyof FeaturesConfig; label: string }> = [
|
|
58
|
+
{ key: "chatEnabled", label: "Chat Enabled" },
|
|
59
|
+
{ key: "onboardingEnabled", label: "Onboarding Enabled" },
|
|
60
|
+
{ key: "sharedModuleBilling", label: "Shared Module: Billing" },
|
|
61
|
+
{ key: "sharedModuleMonitoring", label: "Shared Module: Monitoring" },
|
|
62
|
+
{ key: "sharedModuleAnalytics", label: "Shared Module: Analytics" },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Card>
|
|
67
|
+
<CardHeader>
|
|
68
|
+
<CardTitle>Feature Flags</CardTitle>
|
|
69
|
+
</CardHeader>
|
|
70
|
+
<CardContent className="space-y-5">
|
|
71
|
+
<div className="space-y-3">
|
|
72
|
+
{boolFields.map(({ key, label }) => (
|
|
73
|
+
<div key={key} className="flex items-center gap-3">
|
|
74
|
+
<Checkbox
|
|
75
|
+
id={`feature-${key}`}
|
|
76
|
+
checked={Boolean(form[key])}
|
|
77
|
+
onCheckedChange={(checked) => setBool(key, Boolean(checked))}
|
|
78
|
+
/>
|
|
79
|
+
<Label htmlFor={`feature-${key}`}>{label}</Label>
|
|
80
|
+
</div>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div className="border-t border-border pt-4 space-y-4">
|
|
85
|
+
<p className="text-sm font-medium text-muted-foreground">Onboarding</p>
|
|
86
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
87
|
+
<div className="space-y-1.5">
|
|
88
|
+
<Label htmlFor="feature-defaultModel">Default Model</Label>
|
|
89
|
+
<Input
|
|
90
|
+
id="feature-defaultModel"
|
|
91
|
+
value={form.onboardingDefaultModel ?? ""}
|
|
92
|
+
onChange={(e) => setStr("onboardingDefaultModel", e.target.value)}
|
|
93
|
+
placeholder="claude-3-5-sonnet"
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
<div className="space-y-1.5">
|
|
97
|
+
<Label htmlFor="feature-maxCredits">Max Onboarding Credits</Label>
|
|
98
|
+
<Input
|
|
99
|
+
id="feature-maxCredits"
|
|
100
|
+
type="number"
|
|
101
|
+
value={form.onboardingMaxCredits}
|
|
102
|
+
onChange={(e) => setNum("onboardingMaxCredits", e.target.value)}
|
|
103
|
+
min={0}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
<div className="col-span-full space-y-1.5">
|
|
107
|
+
<Label htmlFor="feature-welcomeMsg">Welcome Message</Label>
|
|
108
|
+
<Input
|
|
109
|
+
id="feature-welcomeMsg"
|
|
110
|
+
value={form.onboardingWelcomeMsg ?? ""}
|
|
111
|
+
onChange={(e) => setStr("onboardingWelcomeMsg", e.target.value)}
|
|
112
|
+
placeholder="Welcome to the platform!"
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div className="flex justify-end pt-2">
|
|
119
|
+
<Button onClick={handleSave} disabled={saving}>
|
|
120
|
+
{saving ? "Saving…" : "Save Features"}
|
|
121
|
+
</Button>
|
|
122
|
+
</div>
|
|
123
|
+
</CardContent>
|
|
124
|
+
</Card>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { Input } from "@/components/ui/input";
|
|
8
|
+
import { Label } from "@/components/ui/label";
|
|
9
|
+
import {
|
|
10
|
+
Select,
|
|
11
|
+
SelectContent,
|
|
12
|
+
SelectItem,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
SelectValue,
|
|
15
|
+
} from "@/components/ui/select";
|
|
16
|
+
import { toUserMessage } from "@/lib/errors";
|
|
17
|
+
|
|
18
|
+
interface FleetConfig {
|
|
19
|
+
containerImage: string;
|
|
20
|
+
containerPort: number;
|
|
21
|
+
lifecycle: string;
|
|
22
|
+
billingModel: string;
|
|
23
|
+
maxInstances: number;
|
|
24
|
+
dockerNetwork: string;
|
|
25
|
+
placementStrategy: string;
|
|
26
|
+
fleetDataDir: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface FleetFormProps {
|
|
30
|
+
initial: FleetConfig;
|
|
31
|
+
onSave: (endpoint: string, data: unknown) => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function FleetForm({ initial, onSave }: FleetFormProps) {
|
|
35
|
+
const [form, setForm] = useState<FleetConfig>(initial);
|
|
36
|
+
const [saving, setSaving] = useState(false);
|
|
37
|
+
|
|
38
|
+
function setStr(key: keyof FleetConfig, value: string) {
|
|
39
|
+
setForm((prev) => ({ ...prev, [key]: value }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function setNum(key: keyof FleetConfig, value: string) {
|
|
43
|
+
setForm((prev) => ({ ...prev, [key]: value === "" ? 0 : Number.parseInt(value, 10) }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function handleSave() {
|
|
47
|
+
setSaving(true);
|
|
48
|
+
try {
|
|
49
|
+
await onSave("updateFleet", form);
|
|
50
|
+
toast.success("Fleet settings saved.");
|
|
51
|
+
} catch (err) {
|
|
52
|
+
toast.error(toUserMessage(err, "Failed to save fleet settings"));
|
|
53
|
+
} finally {
|
|
54
|
+
setSaving(false);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Card>
|
|
60
|
+
<CardHeader>
|
|
61
|
+
<CardTitle>Fleet Configuration</CardTitle>
|
|
62
|
+
</CardHeader>
|
|
63
|
+
<CardContent className="space-y-4">
|
|
64
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
65
|
+
<div className="col-span-full space-y-1.5">
|
|
66
|
+
<Label htmlFor="fleet-image">Container Image</Label>
|
|
67
|
+
<Input
|
|
68
|
+
id="fleet-image"
|
|
69
|
+
value={form.containerImage}
|
|
70
|
+
onChange={(e) => setStr("containerImage", e.target.value)}
|
|
71
|
+
placeholder="ghcr.io/wopr-network/agent:latest"
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div className="space-y-1.5">
|
|
76
|
+
<Label htmlFor="fleet-port">Container Port</Label>
|
|
77
|
+
<Input
|
|
78
|
+
id="fleet-port"
|
|
79
|
+
type="number"
|
|
80
|
+
value={form.containerPort}
|
|
81
|
+
onChange={(e) => setNum("containerPort", e.target.value)}
|
|
82
|
+
min={1}
|
|
83
|
+
max={65535}
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div className="space-y-1.5">
|
|
88
|
+
<Label htmlFor="fleet-maxInstances">Max Instances</Label>
|
|
89
|
+
<Input
|
|
90
|
+
id="fleet-maxInstances"
|
|
91
|
+
type="number"
|
|
92
|
+
value={form.maxInstances}
|
|
93
|
+
onChange={(e) => setNum("maxInstances", e.target.value)}
|
|
94
|
+
min={1}
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div className="space-y-1.5">
|
|
99
|
+
<Label htmlFor="fleet-lifecycle">Lifecycle</Label>
|
|
100
|
+
<Select value={form.lifecycle} onValueChange={(v) => setStr("lifecycle", v)}>
|
|
101
|
+
<SelectTrigger id="fleet-lifecycle">
|
|
102
|
+
<SelectValue placeholder="Select lifecycle" />
|
|
103
|
+
</SelectTrigger>
|
|
104
|
+
<SelectContent>
|
|
105
|
+
<SelectItem value="managed">Managed</SelectItem>
|
|
106
|
+
<SelectItem value="ephemeral">Ephemeral</SelectItem>
|
|
107
|
+
</SelectContent>
|
|
108
|
+
</Select>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div className="space-y-1.5">
|
|
112
|
+
<Label htmlFor="fleet-billingModel">Billing Model</Label>
|
|
113
|
+
<Select value={form.billingModel} onValueChange={(v) => setStr("billingModel", v)}>
|
|
114
|
+
<SelectTrigger id="fleet-billingModel">
|
|
115
|
+
<SelectValue placeholder="Select billing model" />
|
|
116
|
+
</SelectTrigger>
|
|
117
|
+
<SelectContent>
|
|
118
|
+
<SelectItem value="monthly">Monthly</SelectItem>
|
|
119
|
+
<SelectItem value="per_use">Per Use</SelectItem>
|
|
120
|
+
<SelectItem value="none">None</SelectItem>
|
|
121
|
+
</SelectContent>
|
|
122
|
+
</Select>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div className="space-y-1.5">
|
|
126
|
+
<Label htmlFor="fleet-placementStrategy">Placement Strategy</Label>
|
|
127
|
+
<Select
|
|
128
|
+
value={form.placementStrategy}
|
|
129
|
+
onValueChange={(v) => setStr("placementStrategy", v)}
|
|
130
|
+
>
|
|
131
|
+
<SelectTrigger id="fleet-placementStrategy">
|
|
132
|
+
<SelectValue placeholder="Select strategy" />
|
|
133
|
+
</SelectTrigger>
|
|
134
|
+
<SelectContent>
|
|
135
|
+
<SelectItem value="round_robin">Round Robin</SelectItem>
|
|
136
|
+
<SelectItem value="least_loaded">Least Loaded</SelectItem>
|
|
137
|
+
<SelectItem value="random">Random</SelectItem>
|
|
138
|
+
</SelectContent>
|
|
139
|
+
</Select>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div className="space-y-1.5">
|
|
143
|
+
<Label htmlFor="fleet-dockerNetwork">Docker Network</Label>
|
|
144
|
+
<Input
|
|
145
|
+
id="fleet-dockerNetwork"
|
|
146
|
+
value={form.dockerNetwork}
|
|
147
|
+
onChange={(e) => setStr("dockerNetwork", e.target.value)}
|
|
148
|
+
placeholder="platform"
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div className="col-span-full space-y-1.5">
|
|
153
|
+
<Label htmlFor="fleet-dataDir">Fleet Data Directory</Label>
|
|
154
|
+
<Input
|
|
155
|
+
id="fleet-dataDir"
|
|
156
|
+
value={form.fleetDataDir}
|
|
157
|
+
onChange={(e) => setStr("fleetDataDir", e.target.value)}
|
|
158
|
+
placeholder="/data/fleet"
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div className="flex justify-end pt-2">
|
|
164
|
+
<Button onClick={handleSave} disabled={saving}>
|
|
165
|
+
{saving ? "Saving…" : "Save Fleet"}
|
|
166
|
+
</Button>
|
|
167
|
+
</div>
|
|
168
|
+
</CardContent>
|
|
169
|
+
</Card>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
8
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
9
|
+
import { Input } from "@/components/ui/input";
|
|
10
|
+
import { Label } from "@/components/ui/label";
|
|
11
|
+
import { toUserMessage } from "@/lib/errors";
|
|
12
|
+
|
|
13
|
+
interface NavItem {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
href: string;
|
|
17
|
+
icon: string | null;
|
|
18
|
+
sortOrder: number;
|
|
19
|
+
requiresRole: string | null;
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface NavEditorProps {
|
|
24
|
+
initial: NavItem[];
|
|
25
|
+
onSave: (endpoint: string, data: unknown) => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function newItem(sortOrder: number): NavItem {
|
|
29
|
+
return {
|
|
30
|
+
id: crypto.randomUUID(),
|
|
31
|
+
label: "",
|
|
32
|
+
href: "",
|
|
33
|
+
icon: null,
|
|
34
|
+
sortOrder,
|
|
35
|
+
requiresRole: null,
|
|
36
|
+
enabled: true,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function NavEditor({ initial, onSave }: NavEditorProps) {
|
|
41
|
+
const [items, setItems] = useState<NavItem[]>(
|
|
42
|
+
[...initial].sort((a, b) => a.sortOrder - b.sortOrder),
|
|
43
|
+
);
|
|
44
|
+
const [saving, setSaving] = useState(false);
|
|
45
|
+
|
|
46
|
+
function update(id: string, patch: Partial<NavItem>) {
|
|
47
|
+
setItems((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item)));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function remove(id: string) {
|
|
51
|
+
setItems((prev) => prev.filter((item) => item.id !== id));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function move(index: number, direction: "up" | "down") {
|
|
55
|
+
setItems((prev) => {
|
|
56
|
+
const next = [...prev];
|
|
57
|
+
const target = direction === "up" ? index - 1 : index + 1;
|
|
58
|
+
if (target < 0 || target >= next.length) return prev;
|
|
59
|
+
[next[index], next[target]] = [next[target], next[index]];
|
|
60
|
+
return next.map((item, i) => ({ ...item, sortOrder: i }));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function addItem() {
|
|
65
|
+
setItems((prev) => [...prev, newItem(prev.length)]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function handleSave() {
|
|
69
|
+
setSaving(true);
|
|
70
|
+
try {
|
|
71
|
+
const normalized = items.map((item, i) => ({ ...item, sortOrder: i }));
|
|
72
|
+
await onSave("updateNavItems", normalized);
|
|
73
|
+
toast.success("Navigation saved.");
|
|
74
|
+
} catch (err) {
|
|
75
|
+
toast.error(toUserMessage(err, "Failed to save navigation"));
|
|
76
|
+
} finally {
|
|
77
|
+
setSaving(false);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Card>
|
|
83
|
+
<CardHeader>
|
|
84
|
+
<CardTitle>Navigation Items</CardTitle>
|
|
85
|
+
</CardHeader>
|
|
86
|
+
<CardContent className="space-y-3">
|
|
87
|
+
{items.length === 0 && (
|
|
88
|
+
<p className="text-sm text-muted-foreground">No navigation items. Add one below.</p>
|
|
89
|
+
)}
|
|
90
|
+
{items.map((item, index) => (
|
|
91
|
+
<div
|
|
92
|
+
key={item.id}
|
|
93
|
+
className="flex items-start gap-3 rounded-md border border-border bg-muted/30 p-3"
|
|
94
|
+
>
|
|
95
|
+
<div className="flex flex-col gap-1 pt-1">
|
|
96
|
+
<Button
|
|
97
|
+
variant="ghost"
|
|
98
|
+
size="sm"
|
|
99
|
+
className="h-6 w-6 p-0"
|
|
100
|
+
onClick={() => move(index, "up")}
|
|
101
|
+
disabled={index === 0}
|
|
102
|
+
>
|
|
103
|
+
<ArrowUp className="h-3 w-3" />
|
|
104
|
+
</Button>
|
|
105
|
+
<Button
|
|
106
|
+
variant="ghost"
|
|
107
|
+
size="sm"
|
|
108
|
+
className="h-6 w-6 p-0"
|
|
109
|
+
onClick={() => move(index, "down")}
|
|
110
|
+
disabled={index === items.length - 1}
|
|
111
|
+
>
|
|
112
|
+
<ArrowDown className="h-3 w-3" />
|
|
113
|
+
</Button>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="grid flex-1 grid-cols-2 gap-2 sm:grid-cols-4">
|
|
116
|
+
<div className="space-y-1">
|
|
117
|
+
<Label className="text-xs text-muted-foreground">Label</Label>
|
|
118
|
+
<Input
|
|
119
|
+
value={item.label}
|
|
120
|
+
onChange={(e) => update(item.id, { label: e.target.value })}
|
|
121
|
+
placeholder="Dashboard"
|
|
122
|
+
className="h-8"
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
<div className="space-y-1">
|
|
126
|
+
<Label className="text-xs text-muted-foreground">Href</Label>
|
|
127
|
+
<Input
|
|
128
|
+
value={item.href}
|
|
129
|
+
onChange={(e) => update(item.id, { href: e.target.value })}
|
|
130
|
+
placeholder="/dashboard"
|
|
131
|
+
className="h-8"
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
<div className="space-y-1">
|
|
135
|
+
<Label className="text-xs text-muted-foreground">Icon</Label>
|
|
136
|
+
<Input
|
|
137
|
+
value={item.icon ?? ""}
|
|
138
|
+
onChange={(e) => update(item.id, { icon: e.target.value || null })}
|
|
139
|
+
placeholder="LayoutDashboard"
|
|
140
|
+
className="h-8"
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
<div className="space-y-1">
|
|
144
|
+
<Label className="text-xs text-muted-foreground">Requires Role</Label>
|
|
145
|
+
<Input
|
|
146
|
+
value={item.requiresRole ?? ""}
|
|
147
|
+
onChange={(e) => update(item.id, { requiresRole: e.target.value || null })}
|
|
148
|
+
placeholder="platform_admin"
|
|
149
|
+
className="h-8"
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
<div className="flex items-center gap-2 pt-5">
|
|
154
|
+
<Checkbox
|
|
155
|
+
id={`nav-enabled-${item.id}`}
|
|
156
|
+
checked={item.enabled}
|
|
157
|
+
onCheckedChange={(checked) => update(item.id, { enabled: Boolean(checked) })}
|
|
158
|
+
/>
|
|
159
|
+
<Label htmlFor={`nav-enabled-${item.id}`} className="text-xs">
|
|
160
|
+
Enabled
|
|
161
|
+
</Label>
|
|
162
|
+
</div>
|
|
163
|
+
<Button
|
|
164
|
+
variant="ghost"
|
|
165
|
+
size="sm"
|
|
166
|
+
className="mt-4 h-7 w-7 p-0 text-destructive hover:text-destructive"
|
|
167
|
+
onClick={() => remove(item.id)}
|
|
168
|
+
>
|
|
169
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
170
|
+
</Button>
|
|
171
|
+
</div>
|
|
172
|
+
))}
|
|
173
|
+
<div className="flex items-center justify-between pt-2">
|
|
174
|
+
<Button variant="outline" size="sm" onClick={addItem}>
|
|
175
|
+
<Plus className="mr-1.5 h-4 w-4" />
|
|
176
|
+
Add Item
|
|
177
|
+
</Button>
|
|
178
|
+
<Button onClick={handleSave} disabled={saving}>
|
|
179
|
+
{saving ? "Saving…" : "Save Navigation"}
|
|
180
|
+
</Button>
|
|
181
|
+
</div>
|
|
182
|
+
</CardContent>
|
|
183
|
+
</Card>
|
|
184
|
+
);
|
|
185
|
+
}
|
package/src/lib/brand-config.ts
CHANGED
|
@@ -247,3 +247,33 @@ export function eventName(event: string): string {
|
|
|
247
247
|
export function envKey(suffix: string): string {
|
|
248
248
|
return `${_config.envVarPrefix}_${suffix}`;
|
|
249
249
|
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Fetch brand config from the platform API and apply it.
|
|
253
|
+
* Call once in root layout server component.
|
|
254
|
+
* Falls back to env var defaults if API unavailable.
|
|
255
|
+
*
|
|
256
|
+
* @param apiBaseUrl - The platform API base URL (e.g. from NEXT_PUBLIC_API_URL)
|
|
257
|
+
*/
|
|
258
|
+
export async function initBrandConfig(apiBaseUrl: string): Promise<void> {
|
|
259
|
+
try {
|
|
260
|
+
const res = await fetch(`${apiBaseUrl}/trpc/product.getBrandConfig`, {
|
|
261
|
+
credentials: "include",
|
|
262
|
+
next: { revalidate: 60 },
|
|
263
|
+
});
|
|
264
|
+
if (!res.ok) return;
|
|
265
|
+
const text = await res.text();
|
|
266
|
+
let json: unknown;
|
|
267
|
+
try {
|
|
268
|
+
json = JSON.parse(text);
|
|
269
|
+
} catch {
|
|
270
|
+
return; // Non-JSON response (proxy error, HTML page, etc.)
|
|
271
|
+
}
|
|
272
|
+
const data = (json as { result?: { data?: unknown } })?.result?.data;
|
|
273
|
+
if (data) {
|
|
274
|
+
setBrandConfig(data as Partial<BrandConfig>);
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
// API unavailable — env var defaults remain active
|
|
278
|
+
}
|
|
279
|
+
}
|