style(admin-web): format dashboard sources
This commit is contained in:
parent
20e1ac0e67
commit
7465b21d91
@ -37,10 +37,10 @@ curl -X POST "http://localhost:3001/api/seed?secret=<SEED_SECRET>"
|
||||
|
||||
### Default Logins
|
||||
|
||||
| Email | Password | Role |
|
||||
| ------------------ | ----------- | ----------- |
|
||||
| `admin@example.com` | `Admin123!` | Super Admin |
|
||||
| `viewer@example.com` | `viewer123` | Viewer |
|
||||
| Email | Password | Role |
|
||||
| -------------------- | ----------- | ----------- |
|
||||
| `admin@example.com` | `Admin123!` | Super Admin |
|
||||
| `viewer@example.com` | `viewer123` | Viewer |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
||||
@ -52,13 +52,15 @@ describe('GET /api/settings/kill-switch', () => {
|
||||
|
||||
it('returns existing kill_switch flag state', async () => {
|
||||
mockListFlags.mockResolvedValue({
|
||||
flags: [{
|
||||
key: 'kill_switch',
|
||||
enabled: true,
|
||||
platforms: ['desktop', 'ios'],
|
||||
description: 'Maintenance window',
|
||||
updatedAt: '2026-02-16T00:00:00Z',
|
||||
}],
|
||||
flags: [
|
||||
{
|
||||
key: 'kill_switch',
|
||||
enabled: true,
|
||||
platforms: ['desktop', 'ios'],
|
||||
description: 'Maintenance window',
|
||||
updatedAt: '2026-02-16T00:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const res = await makeGet();
|
||||
|
||||
@ -138,7 +138,7 @@ export default function ExtractionPage() {
|
||||
}
|
||||
}, [inputText, selectedTask]);
|
||||
|
||||
const currentTask = tasks.find((t) => t.id === selectedTask);
|
||||
const currentTask = tasks.find(t => t.id === selectedTask);
|
||||
|
||||
// Group extractions by class
|
||||
const groupedExtractions = result?.extractions.reduce(
|
||||
@ -148,7 +148,7 @@ export default function ExtractionPage() {
|
||||
acc[cls].push(e);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ExtractionEntity[]>,
|
||||
{} as Record<string, ExtractionEntity[]>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -189,7 +189,7 @@ export default function ExtractionPage() {
|
||||
className="w-full min-h-[200px] rounded-lg border border-border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Paste a transcript, meeting notes, or any text to extract structured entities from..."
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onChange={e => setInputText(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleExtract} disabled={loading || !inputText.trim()}>
|
||||
@ -212,10 +212,10 @@ export default function ExtractionPage() {
|
||||
<select
|
||||
className="w-full rounded-lg border border-border bg-background p-2 text-sm"
|
||||
value={selectedTask}
|
||||
onChange={(e) => setSelectedTask(e.target.value)}
|
||||
onChange={e => setSelectedTask(e.target.value)}
|
||||
>
|
||||
{tasks.length > 0 ? (
|
||||
tasks.map((t) => (
|
||||
tasks.map(t => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} {t.builtIn ? '(built-in)' : ''}
|
||||
</option>
|
||||
@ -249,7 +249,7 @@ export default function ExtractionPage() {
|
||||
<CardContent className="pt-4 space-y-2 text-xs">
|
||||
<p className="text-muted-foreground">{currentTask.description}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{currentTask.classes.map((cls) => (
|
||||
{currentTask.classes.map(cls => (
|
||||
<Badge
|
||||
key={cls}
|
||||
variant="outline"
|
||||
@ -365,11 +365,7 @@ export default function ExtractionPage() {
|
||||
{e.attributes && Object.keys(e.attributes).length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{Object.entries(e.attributes).map(([k, v]) => (
|
||||
<Badge
|
||||
key={k}
|
||||
variant="secondary"
|
||||
className="text-[10px]"
|
||||
>
|
||||
<Badge key={k} variant="secondary" className="text-[10px]">
|
||||
{k}: {v}
|
||||
</Badge>
|
||||
))}
|
||||
@ -400,7 +396,10 @@ export default function ExtractionPage() {
|
||||
{result.extractions.map((e, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={`text-xs ${getClassColor(e.extraction_class)}`}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${getClassColor(e.extraction_class)}`}
|
||||
>
|
||||
{e.extraction_class}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
@ -1,14 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Flag,
|
||||
Plus,
|
||||
Loader2,
|
||||
Trash2,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
} from 'lucide-react';
|
||||
import { Flag, Plus, Loader2, Trash2, ToggleLeft, ToggleRight } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@ -83,7 +76,10 @@ export default function FlagsPage() {
|
||||
enabled: form.enabled,
|
||||
percentage: form.percentage,
|
||||
platforms: form.platforms
|
||||
? form.platforms.split(',').map(s => s.trim()).filter(Boolean)
|
||||
? form.platforms
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
}),
|
||||
});
|
||||
@ -251,9 +247,7 @@ export default function FlagsPage() {
|
||||
<div>
|
||||
<CardTitle className="text-sm font-mono">{flag.key}</CardTitle>
|
||||
{flag.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{flag.description}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{flag.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -284,19 +278,9 @@ export default function FlagsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{flag.platforms.length > 0 && (
|
||||
<span>
|
||||
Platforms: {flag.platforms.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{flag.segments.length > 0 && (
|
||||
<span>
|
||||
Segments: {flag.segments.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
Updated {new Date(flag.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
{flag.platforms.length > 0 && <span>Platforms: {flag.platforms.join(', ')}</span>}
|
||||
{flag.segments.length > 0 && <span>Segments: {flag.segments.join(', ')}</span>}
|
||||
<span>Updated {new Date(flag.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{flag.enabled && (
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
|
||||
@ -1,17 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Key,
|
||||
Search,
|
||||
Plus,
|
||||
Copy,
|
||||
Check,
|
||||
Loader2,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Key, Search, Plus, Copy, Check, Loader2, Monitor, Smartphone, X } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@ -131,9 +121,7 @@ export default function LicensesPage() {
|
||||
body: JSON.stringify({ key, action: 'revoke' }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setLicenses(prev =>
|
||||
prev.map(l => (l.key === key ? { ...l, status: 'revoked' } : l))
|
||||
);
|
||||
setLicenses(prev => prev.map(l => (l.key === key ? { ...l, status: 'revoked' } : l)));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@ -220,11 +208,7 @@ export default function LicensesPage() {
|
||||
<code className="text-sm font-mono font-bold text-emerald-800 dark:text-emerald-200">
|
||||
{generatedKey}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(generatedKey)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(generatedKey)}>
|
||||
{copiedKey === generatedKey ? (
|
||||
<Check className="h-4 w-4 text-emerald-600" />
|
||||
) : (
|
||||
@ -371,9 +355,7 @@ export default function LicensesPage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No devices activated yet
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground italic">No devices activated yet</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -1,16 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
Search,
|
||||
Loader2,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Bell, Search, Loader2, Monitor, Smartphone, Tablet, Check, X } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@ -86,7 +77,9 @@ export default function NotificationsPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Look Up User</CardTitle>
|
||||
<CardDescription>Search by user ID to view their devices and notification preferences</CardDescription>
|
||||
<CardDescription>
|
||||
Search by user ID to view their devices and notification preferences
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
@ -122,11 +115,17 @@ export default function NotificationsPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Push:</span>
|
||||
{prefs.pushEnabled ? (
|
||||
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400"
|
||||
>
|
||||
<Check className="mr-1 h-3 w-3" /> Enabled
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" /> Disabled
|
||||
</Badge>
|
||||
)}
|
||||
@ -134,11 +133,17 @@ export default function NotificationsPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Email:</span>
|
||||
{prefs.emailEnabled ? (
|
||||
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400"
|
||||
>
|
||||
<Check className="mr-1 h-3 w-3" /> Enabled
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" /> Disabled
|
||||
</Badge>
|
||||
)}
|
||||
@ -167,9 +172,7 @@ export default function NotificationsPage() {
|
||||
{/* Devices */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Registered Devices ({devices.length})
|
||||
</CardTitle>
|
||||
<CardTitle className="text-base">Registered Devices ({devices.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{devices.length === 0 ? (
|
||||
@ -205,11 +208,17 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
{device.pushToken ? (
|
||||
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-[10px]">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-[10px]"
|
||||
>
|
||||
Push token
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400 text-[10px]">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400 text-[10px]"
|
||||
>
|
||||
No push token
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@ -221,9 +221,7 @@ export default function SecretsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Secrets Manager</h2>
|
||||
{vaultUrl && (
|
||||
<p className="text-sm text-muted-foreground mt-1 font-mono">{vaultUrl}</p>
|
||||
)}
|
||||
{vaultUrl && <p className="text-sm text-muted-foreground mt-1 font-mono">{vaultUrl}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchSecrets} disabled={loading}>
|
||||
@ -423,7 +421,11 @@ export default function SecretsPage() {
|
||||
className="h-7 w-7"
|
||||
onClick={() => setShowValue(!showValue)}
|
||||
>
|
||||
{showValue ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
{showValue ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy}>
|
||||
{copied ? (
|
||||
@ -545,8 +547,8 @@ export default function SecretsPage() {
|
||||
<DialogTitle className="text-red-500">Delete Secret</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className="font-mono font-bold">{deleteTarget}</span>? This will soft-delete
|
||||
the secret in Azure Key Vault. It can be recovered within the retention period.
|
||||
<span className="font-mono font-bold">{deleteTarget}</span>? This will soft-delete the
|
||||
secret in Azure Key Vault. It can be recovered within the retention period.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
||||
@ -84,7 +84,11 @@ export default function TelemetryPoliciesPage() {
|
||||
const [formPercentage, setFormPercentage] = useState(100);
|
||||
const [formStartsAt, setFormStartsAt] = useState('');
|
||||
const [formExpiresAt, setFormExpiresAt] = useState('');
|
||||
const [preview, setPreview] = useState<{ matchedClients: number; totalClients: number; sampleSize: number } | null>(null);
|
||||
const [preview, setPreview] = useState<{
|
||||
matchedClients: number;
|
||||
totalClients: number;
|
||||
sampleSize: number;
|
||||
} | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
|
||||
const fetchPolicies = useCallback(async () => {
|
||||
@ -154,7 +158,12 @@ export default function TelemetryPoliciesPage() {
|
||||
enabled: formEnabled,
|
||||
priority: formPriority,
|
||||
eventTypes: formEventTypes,
|
||||
modules: formModules ? formModules.split(',').map(m => m.trim()).filter(Boolean) : [],
|
||||
modules: formModules
|
||||
? formModules
|
||||
.split(',')
|
||||
.map(m => m.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
samplingRate: formSamplingRate,
|
||||
targeting: {
|
||||
platforms: formPlatforms.length > 0 ? formPlatforms : undefined,
|
||||
@ -213,11 +222,7 @@ export default function TelemetryPoliciesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleArrayItem = (
|
||||
arr: string[],
|
||||
item: string,
|
||||
setter: (v: string[]) => void
|
||||
) => {
|
||||
const toggleArrayItem = (arr: string[], item: string, setter: (v: string[]) => void) => {
|
||||
setter(arr.includes(item) ? arr.filter(x => x !== item) : [...arr, item]);
|
||||
};
|
||||
|
||||
@ -471,12 +476,15 @@ export default function TelemetryPoliciesPage() {
|
||||
targeting: {
|
||||
platforms: formPlatforms.length > 0 ? formPlatforms : undefined,
|
||||
channels: formChannels.length > 0 ? formChannels : undefined,
|
||||
releaseChannels: formReleaseChannels.length > 0 ? formReleaseChannels : undefined,
|
||||
releaseChannels:
|
||||
formReleaseChannels.length > 0 ? formReleaseChannels : undefined,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (res.ok) setPreview(await res.json());
|
||||
} catch { /* best effort */ } finally {
|
||||
} catch {
|
||||
/* best effort */
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}}
|
||||
@ -488,7 +496,8 @@ export default function TelemetryPoliciesPage() {
|
||||
<span className="text-sm">
|
||||
<strong className="text-primary">{preview.matchedClients}</strong>
|
||||
<span className="text-muted-foreground">
|
||||
{' '}/ {preview.totalClients} clients would match
|
||||
{' '}
|
||||
/ {preview.totalClients} clients would match
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
(from {preview.sampleSize} recent events)
|
||||
@ -581,9 +590,7 @@ export default function TelemetryPoliciesPage() {
|
||||
<div>
|
||||
<p className="font-medium">{policy.name}</p>
|
||||
{policy.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{policy.description}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{policy.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
@ -625,13 +632,9 @@ export default function TelemetryPoliciesPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{policy.startsAt
|
||||
? new Date(policy.startsAt).toLocaleDateString()
|
||||
: '—'}
|
||||
{policy.startsAt ? new Date(policy.startsAt).toLocaleDateString() : '—'}
|
||||
{' → '}
|
||||
{policy.expiresAt
|
||||
? new Date(policy.expiresAt).toLocaleDateString()
|
||||
: '∞'}
|
||||
{policy.expiresAt ? new Date(policy.expiresAt).toLocaleDateString() : '∞'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
@ -647,18 +650,10 @@ export default function TelemetryPoliciesPage() {
|
||||
<ToggleLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditForm(policy)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditForm(policy)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(policy.id)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(policy.id)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -124,9 +124,16 @@ export default function ProductsPage() {
|
||||
const product = await res.json();
|
||||
setCreateOpen(false);
|
||||
setForm({
|
||||
productId: '', displayName: '', licensePrefix: '', packageName: '',
|
||||
defaultPlan: 'free', trialDays: '14', websiteUrl: '',
|
||||
deviceLimitFree: '1', deviceLimitPro: '3', deviceLimitEnterprise: '10',
|
||||
productId: '',
|
||||
displayName: '',
|
||||
licensePrefix: '',
|
||||
packageName: '',
|
||||
defaultPlan: 'free',
|
||||
trialDays: '14',
|
||||
websiteUrl: '',
|
||||
deviceLimitFree: '1',
|
||||
deviceLimitPro: '3',
|
||||
deviceLimitEnterprise: '10',
|
||||
});
|
||||
// Auto-onboard: seed plans + kill_switch flag
|
||||
await handleOnboard(product.productId);
|
||||
@ -230,9 +237,7 @@ export default function ProductsPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Products</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage registered products in the platform
|
||||
</p>
|
||||
<p className="text-muted-foreground">Manage registered products in the platform</p>
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@ -271,7 +276,9 @@ export default function ProductsPage() {
|
||||
<Input
|
||||
placeholder="PROD"
|
||||
value={form.licensePrefix}
|
||||
onChange={e => setForm({ ...form, licensePrefix: e.target.value.toUpperCase() })}
|
||||
onChange={e =>
|
||||
setForm({ ...form, licensePrefix: e.target.value.toUpperCase() })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -290,7 +297,9 @@ export default function ProductsPage() {
|
||||
value={form.defaultPlan}
|
||||
onValueChange={v => setForm({ ...form, defaultPlan: v as 'free' | 'pro' })}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="free">Free</SelectItem>
|
||||
<SelectItem value="pro">Pro</SelectItem>
|
||||
@ -322,7 +331,8 @@ export default function ProductsPage() {
|
||||
<div>
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Free</p>
|
||||
<Input
|
||||
type="number" min={0}
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.deviceLimitFree}
|
||||
onChange={e => setForm({ ...form, deviceLimitFree: e.target.value })}
|
||||
/>
|
||||
@ -330,7 +340,8 @@ export default function ProductsPage() {
|
||||
<div>
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Pro</p>
|
||||
<Input
|
||||
type="number" min={0}
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.deviceLimitPro}
|
||||
onChange={e => setForm({ ...form, deviceLimitPro: e.target.value })}
|
||||
/>
|
||||
@ -338,7 +349,8 @@ export default function ProductsPage() {
|
||||
<div>
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Enterprise</p>
|
||||
<Input
|
||||
type="number" min={0}
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.deviceLimitEnterprise}
|
||||
onChange={e => setForm({ ...form, deviceLimitEnterprise: e.target.value })}
|
||||
/>
|
||||
@ -367,7 +379,8 @@ export default function ProductsPage() {
|
||||
Product "{onboardResult.productId}" onboarded successfully
|
||||
</p>
|
||||
<p className="text-xs text-emerald-700 dark:text-emerald-300">
|
||||
{onboardResult.plans} plans seeded{onboardResult.flags > 0 ? `, ${onboardResult.flags} flag(s) created` : ''}
|
||||
{onboardResult.plans} plans seeded
|
||||
{onboardResult.flags > 0 ? `, ${onboardResult.flags} flag(s) created` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -403,7 +416,11 @@ export default function ProductsPage() {
|
||||
: 'bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-400'
|
||||
}
|
||||
>
|
||||
{p.status === 'active' ? <Check className="mr-1 h-3 w-3" /> : <X className="mr-1 h-3 w-3" />}
|
||||
{p.status === 'active' ? (
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{p.status}
|
||||
</Badge>
|
||||
<Button
|
||||
@ -444,7 +461,8 @@ export default function ProductsPage() {
|
||||
<div>{p.trialDays}</div>
|
||||
<div className="text-muted-foreground">Device Limits</div>
|
||||
<div>
|
||||
Free: {p.deviceLimits.free} · Pro: {p.deviceLimits.pro} · Ent: {p.deviceLimits.enterprise}
|
||||
Free: {p.deviceLimits.free} · Pro: {p.deviceLimits.pro} · Ent:{' '}
|
||||
{p.deviceLimits.enterprise}
|
||||
</div>
|
||||
{p.websiteUrl && (
|
||||
<>
|
||||
@ -488,7 +506,9 @@ export default function ProductsPage() {
|
||||
value={editForm.status ?? 'active'}
|
||||
onValueChange={v => setEditForm({ ...editForm, status: v })}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
@ -503,7 +523,9 @@ export default function ProductsPage() {
|
||||
value={editForm.defaultPlan ?? 'free'}
|
||||
onValueChange={v => setEditForm({ ...editForm, defaultPlan: v })}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="free">Free</SelectItem>
|
||||
<SelectItem value="pro">Pro</SelectItem>
|
||||
@ -513,7 +535,9 @@ export default function ProductsPage() {
|
||||
<div className="space-y-2">
|
||||
<Label>Trial Days</Label>
|
||||
<Input
|
||||
type="number" min={0} max={365}
|
||||
type="number"
|
||||
min={0}
|
||||
max={365}
|
||||
value={editForm.trialDays ?? '14'}
|
||||
onChange={e => setEditForm({ ...editForm, trialDays: e.target.value })}
|
||||
/>
|
||||
@ -532,7 +556,8 @@ export default function ProductsPage() {
|
||||
<div>
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Free</p>
|
||||
<Input
|
||||
type="number" min={0}
|
||||
type="number"
|
||||
min={0}
|
||||
value={editForm.deviceLimitFree ?? '1'}
|
||||
onChange={e => setEditForm({ ...editForm, deviceLimitFree: e.target.value })}
|
||||
/>
|
||||
@ -540,7 +565,8 @@ export default function ProductsPage() {
|
||||
<div>
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Pro</p>
|
||||
<Input
|
||||
type="number" min={0}
|
||||
type="number"
|
||||
min={0}
|
||||
value={editForm.deviceLimitPro ?? '3'}
|
||||
onChange={e => setEditForm({ ...editForm, deviceLimitPro: e.target.value })}
|
||||
/>
|
||||
@ -548,9 +574,12 @@ export default function ProductsPage() {
|
||||
<div>
|
||||
<p className="text-[10px] text-muted-foreground mb-1">Enterprise</p>
|
||||
<Input
|
||||
type="number" min={0}
|
||||
type="number"
|
||||
min={0}
|
||||
value={editForm.deviceLimitEnterprise ?? '10'}
|
||||
onChange={e => setEditForm({ ...editForm, deviceLimitEnterprise: e.target.value })}
|
||||
onChange={e =>
|
||||
setEditForm({ ...editForm, deviceLimitEnterprise: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -30,7 +30,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiListPromos, apiCreatePromo, apiDeletePromo, apiUpdatePromo, type ApiPromo } from '@/lib/api';
|
||||
import {
|
||||
apiListPromos,
|
||||
apiCreatePromo,
|
||||
apiDeletePromo,
|
||||
apiUpdatePromo,
|
||||
type ApiPromo,
|
||||
} from '@/lib/api';
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
@ -69,7 +75,8 @@ export default function PromosPage() {
|
||||
|
||||
const handleToggleActive = async (promo: ApiPromo) => {
|
||||
const { data } = await apiUpdatePromo(promo.id, { active: !promo.active });
|
||||
if (data) setPromos(prev => prev.map(p => p.id === promo.id ? { ...p, active: !p.active } : p));
|
||||
if (data)
|
||||
setPromos(prev => prev.map(p => (p.id === promo.id ? { ...p, active: !p.active } : p)));
|
||||
};
|
||||
|
||||
const loadPromos = useCallback(async () => {
|
||||
|
||||
@ -435,10 +435,13 @@ export default function SettingsPage() {
|
||||
<CardTitle className="text-base">Azure Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Azure secrets are managed via the{' '}
|
||||
<a href="/ops/secrets" className="text-primary underline underline-offset-2 hover:text-primary/80">
|
||||
<a
|
||||
href="/ops/secrets"
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80"
|
||||
>
|
||||
Secrets Manager
|
||||
</a>
|
||||
{' '}(Key Vault)
|
||||
</a>{' '}
|
||||
(Key Vault)
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@ -449,8 +452,8 @@ export default function SettingsPage() {
|
||||
in Azure Key Vault and resolved at runtime. Use the{' '}
|
||||
<a href="/ops/secrets" className="text-primary underline underline-offset-2">
|
||||
Secrets Manager
|
||||
</a>
|
||||
{' '}to view, rotate, or update them.
|
||||
</a>{' '}
|
||||
to view, rotate, or update them.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -473,7 +476,12 @@ export default function SettingsPage() {
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.rateLimits.globalPerMin}
|
||||
onChange={e => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, globalPerMin: parseInt(e.target.value) || 0 } }))}
|
||||
onChange={e =>
|
||||
setSettings(s => ({
|
||||
...s,
|
||||
rateLimits: { ...s.rateLimits, globalPerMin: parseInt(e.target.value) || 0 },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -481,7 +489,12 @@ export default function SettingsPage() {
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.rateLimits.perUserPerMin}
|
||||
onChange={e => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, perUserPerMin: parseInt(e.target.value) || 0 } }))}
|
||||
onChange={e =>
|
||||
setSettings(s => ({
|
||||
...s,
|
||||
rateLimits: { ...s.rateLimits, perUserPerMin: parseInt(e.target.value) || 0 },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -491,7 +504,12 @@ export default function SettingsPage() {
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.rateLimits.maxTokenBurst}
|
||||
onChange={e => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, maxTokenBurst: parseInt(e.target.value) || 0 } }))}
|
||||
onChange={e =>
|
||||
setSettings(s => ({
|
||||
...s,
|
||||
rateLimits: { ...s.rateLimits, maxTokenBurst: parseInt(e.target.value) || 0 },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -499,7 +517,12 @@ export default function SettingsPage() {
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.rateLimits.abuseThreshold}
|
||||
onChange={e => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, abuseThreshold: parseInt(e.target.value) || 0 } }))}
|
||||
onChange={e =>
|
||||
setSettings(s => ({
|
||||
...s,
|
||||
rateLimits: { ...s.rateLimits, abuseThreshold: parseInt(e.target.value) || 0 },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -513,7 +536,9 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.rateLimits.autoSuspendOnAbuse}
|
||||
onCheckedChange={v => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, autoSuspendOnAbuse: v } }))}
|
||||
onCheckedChange={v =>
|
||||
setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, autoSuspendOnAbuse: v } }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
@ -525,7 +550,9 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.rateLimits.ipBlocklist}
|
||||
onCheckedChange={v => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, ipBlocklist: v } }))}
|
||||
onCheckedChange={v =>
|
||||
setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, ipBlocklist: v } }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -552,7 +579,12 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.notifications.newUserSignup}
|
||||
onCheckedChange={v => setSettings(s => ({ ...s, notifications: { ...s.notifications, newUserSignup: v } }))}
|
||||
onCheckedChange={v =>
|
||||
setSettings(s => ({
|
||||
...s,
|
||||
notifications: { ...s.notifications, newUserSignup: v },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
@ -564,7 +596,12 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.notifications.usageThreshold}
|
||||
onCheckedChange={v => setSettings(s => ({ ...s, notifications: { ...s.notifications, usageThreshold: v } }))}
|
||||
onCheckedChange={v =>
|
||||
setSettings(s => ({
|
||||
...s,
|
||||
notifications: { ...s.notifications, usageThreshold: v },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
@ -576,7 +613,12 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.notifications.failedPayment}
|
||||
onCheckedChange={v => setSettings(s => ({ ...s, notifications: { ...s.notifications, failedPayment: v } }))}
|
||||
onCheckedChange={v =>
|
||||
setSettings(s => ({
|
||||
...s,
|
||||
notifications: { ...s.notifications, failedPayment: v },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
@ -588,7 +630,12 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.notifications.securityAlerts}
|
||||
onCheckedChange={v => setSettings(s => ({ ...s, notifications: { ...s.notifications, securityAlerts: v } }))}
|
||||
onCheckedChange={v =>
|
||||
setSettings(s => ({
|
||||
...s,
|
||||
notifications: { ...s.notifications, securityAlerts: v },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -620,7 +667,9 @@ export default function SettingsPage() {
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.dataRetentionDays}
|
||||
onChange={e => setSettings(s => ({ ...s, dataRetentionDays: parseInt(e.target.value) || 365 }))}
|
||||
onChange={e =>
|
||||
setSettings(s => ({ ...s, dataRetentionDays: parseInt(e.target.value) || 365 }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@ -71,10 +71,7 @@ export default function SubscriptionsPage() {
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [plansRes, usersRes] = await Promise.allSettled([
|
||||
apiListPlans(),
|
||||
apiListUsers(),
|
||||
]);
|
||||
const [plansRes, usersRes] = await Promise.allSettled([apiListPlans(), apiListUsers()]);
|
||||
let loadedPlans: LocalPlan[] = [];
|
||||
if (plansRes.status === 'fulfilled' && plansRes.value.data?.plans?.length) {
|
||||
loadedPlans = plansRes.value.data.plans.filter(p => p.active).map(planDocToLocal);
|
||||
@ -238,7 +235,9 @@ export default function SubscriptionsPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalActiveUsers > 0 ? formatCurrency(totalMRR / totalActiveUsers) : '$0.00'}</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{totalActiveUsers > 0 ? formatCurrency(totalMRR / totalActiveUsers) : '$0.00'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">ARPU</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -50,7 +50,13 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { mockUsers, formatNumber, formatCurrency, formatDate, type User } from '@/lib/mock-data';
|
||||
import { apiListUsers, apiUpdateUser, apiDeleteUser, apiCreateInvitation, type ApiUser } from '@/lib/api';
|
||||
import {
|
||||
apiListUsers,
|
||||
apiUpdateUser,
|
||||
apiDeleteUser,
|
||||
apiCreateInvitation,
|
||||
type ApiUser,
|
||||
} from '@/lib/api';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
|
||||
@ -127,10 +133,15 @@ export default function UsersPage() {
|
||||
});
|
||||
setInviteCreating(false);
|
||||
if (data) {
|
||||
const userDashboardUrl = process.env.NEXT_PUBLIC_USER_DASHBOARD_URL || 'http://localhost:3002';
|
||||
const userDashboardUrl =
|
||||
process.env.NEXT_PUBLIC_USER_DASHBOARD_URL || 'http://localhost:3002';
|
||||
setInviteLink(`${userDashboardUrl}/login?ref=${encodeURIComponent(data.code)}`);
|
||||
} else {
|
||||
toast({ title: 'Failed to create invite', description: error || 'Unknown error', variant: 'error' });
|
||||
toast({
|
||||
title: 'Failed to create invite',
|
||||
description: error || 'Unknown error',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -160,7 +171,10 @@ export default function UsersPage() {
|
||||
setUsers(prev =>
|
||||
prev.map(u => (u.id === user.id ? { ...u, status: newStatus as User['status'] } : u))
|
||||
);
|
||||
toast({ title: `User ${newStatus === 'suspended' ? 'suspended' : 'activated'}`, variant: newStatus === 'suspended' ? 'warning' : 'success' });
|
||||
toast({
|
||||
title: `User ${newStatus === 'suspended' ? 'suspended' : 'activated'}`,
|
||||
variant: newStatus === 'suspended' ? 'warning' : 'success',
|
||||
});
|
||||
} else {
|
||||
toast({ title: 'Action failed', description: error, variant: 'error' });
|
||||
}
|
||||
@ -232,13 +246,15 @@ export default function UsersPage() {
|
||||
<h1 className="text-3xl font-bold tracking-tight">Users</h1>
|
||||
<p className="text-muted-foreground">Manage platform users and their subscriptions</p>
|
||||
</div>
|
||||
<Button onClick={() => {
|
||||
setShowInvite(true);
|
||||
setInviteLink(null);
|
||||
setInviteDescription('');
|
||||
setInvitePlan('pro');
|
||||
setInviteCopied(false);
|
||||
}}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowInvite(true);
|
||||
setInviteLink(null);
|
||||
setInviteDescription('');
|
||||
setInvitePlan('pro');
|
||||
setInviteCopied(false);
|
||||
}}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Invite User
|
||||
</Button>
|
||||
@ -434,9 +450,24 @@ export default function UsersPage() {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleChangePlan(user.id, user.plan === 'free' ? 'pro' : user.plan === 'pro' ? 'enterprise' : 'free')}
|
||||
onClick={() =>
|
||||
handleChangePlan(
|
||||
user.id,
|
||||
user.plan === 'free'
|
||||
? 'pro'
|
||||
: user.plan === 'pro'
|
||||
? 'enterprise'
|
||||
: 'free'
|
||||
)
|
||||
}
|
||||
>
|
||||
Cycle Plan ({user.plan} → {user.plan === 'free' ? 'pro' : user.plan === 'pro' ? 'enterprise' : 'free'})
|
||||
Cycle Plan ({user.plan} →{' '}
|
||||
{user.plan === 'free'
|
||||
? 'pro'
|
||||
: user.plan === 'pro'
|
||||
? 'enterprise'
|
||||
: 'free'}
|
||||
)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@ -578,12 +609,7 @@ export default function UsersPage() {
|
||||
<code className="flex-1 text-sm font-mono break-all select-all">
|
||||
{inviteLink}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={copyInviteLink}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="shrink-0" onClick={copyInviteLink}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -13,10 +13,7 @@ function getSecretClient(): SecretClient {
|
||||
}
|
||||
|
||||
/** GET /api/ops/secrets/[name] — read a specific secret value */
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ name: string }> }) {
|
||||
try {
|
||||
const { name } = await params;
|
||||
const client = getSecretClient();
|
||||
@ -41,10 +38,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
/** DELETE /api/ops/secrets/[name] — soft-delete a secret */
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ name: string }> }) {
|
||||
try {
|
||||
const { name } = await params;
|
||||
const client = getSecretClient();
|
||||
@ -55,7 +49,7 @@ export async function DELETE(
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
{ status: 500 },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,7 +49,7 @@ export async function GET() {
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
{ status: 500 },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -67,10 +67,7 @@ export async function POST(req: NextRequest) {
|
||||
};
|
||||
|
||||
if (!name || !value) {
|
||||
return NextResponse.json(
|
||||
{ error: 'name and value are required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
return NextResponse.json({ error: 'name and value are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getSecretClient();
|
||||
@ -89,7 +86,7 @@ export async function POST(req: NextRequest) {
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
{ status: 500 },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,7 +34,10 @@ function parseInfoValue(info: string, key: string): string | undefined {
|
||||
return line?.split(':').slice(1).join(':');
|
||||
}
|
||||
|
||||
async function getPreview(client: ReturnType<typeof createClient>, key: string): Promise<ValkeyPreview> {
|
||||
async function getPreview(
|
||||
client: ReturnType<typeof createClient>,
|
||||
key: string
|
||||
): Promise<ValkeyPreview> {
|
||||
const [type, ttlSeconds] = await Promise.all([client.type(key), client.ttl(key)]);
|
||||
|
||||
if (type === 'string') {
|
||||
@ -76,7 +79,10 @@ async function getPreview(client: ReturnType<typeof createClient>, key: string):
|
||||
}
|
||||
|
||||
if (type === 'zset') {
|
||||
const [size, entries] = await Promise.all([client.zCard(key), client.zRangeWithScores(key, 0, 4)]);
|
||||
const [size, entries] = await Promise.all([
|
||||
client.zCard(key),
|
||||
client.zRangeWithScores(key, 0, 4),
|
||||
]);
|
||||
return {
|
||||
key,
|
||||
type,
|
||||
@ -142,7 +148,10 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unable to inspect Valkey';
|
||||
return NextResponse.json({ error: message }, { status: message === 'Unauthorized' ? 401 : 500 });
|
||||
return NextResponse.json(
|
||||
{ error: message },
|
||||
{ status: message === 'Unauthorized' ? 401 : 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -215,6 +224,9 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Valkey write failed';
|
||||
return NextResponse.json({ error: message }, { status: message === 'Unauthorized' ? 401 : 500 });
|
||||
return NextResponse.json(
|
||||
{ error: message },
|
||||
{ status: message === 'Unauthorized' ? 401 : 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,7 +76,10 @@ export async function PUT(req: NextRequest) {
|
||||
const reason = typeof body.reason === 'string' ? body.reason : '';
|
||||
|
||||
const platforms: PlatformFlags = body.platforms ?? {
|
||||
desktop: true, ios: true, android: true, web: true,
|
||||
desktop: true,
|
||||
ios: true,
|
||||
android: true,
|
||||
web: true,
|
||||
};
|
||||
|
||||
const result = await listFlags();
|
||||
|
||||
@ -8,10 +8,7 @@ function getJwt(req: NextRequest): string {
|
||||
return req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const jwt = getJwt(req);
|
||||
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
updateTelemetryPolicy,
|
||||
deleteTelemetryPolicy,
|
||||
} from '@/lib/platform-client';
|
||||
import { updateTelemetryPolicy, deleteTelemetryPolicy } from '@/lib/platform-client';
|
||||
|
||||
function getJwt(req: NextRequest): string {
|
||||
const cookie = req.headers.get('cookie') ?? '';
|
||||
@ -11,10 +8,7 @@ function getJwt(req: NextRequest): string {
|
||||
return req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const jwt = getJwt(req);
|
||||
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
@ -28,10 +22,7 @@ export async function PUT(
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const jwt = getJwt(req);
|
||||
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
listTelemetryPolicies,
|
||||
createTelemetryPolicy,
|
||||
} from '@/lib/platform-client';
|
||||
import { listTelemetryPolicies, createTelemetryPolicy } from '@/lib/platform-client';
|
||||
|
||||
function getJwt(req: NextRequest): string {
|
||||
const cookie = req.headers.get('cookie') ?? '';
|
||||
|
||||
@ -1,58 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
default: 'bg-background text-foreground',
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
));
|
||||
Alert.displayName = 'Alert';
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
AlertTitle.displayName = 'AlertTitle';
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||
));
|
||||
AlertDescription.displayName = 'AlertDescription';
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import * as React from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import * as React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface CheckboxProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
checked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
export interface CheckboxProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
@ -28,7 +27,7 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
{checked && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
)
|
||||
)
|
||||
Checkbox.displayName = 'Checkbox'
|
||||
);
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
|
||||
export { Checkbox }
|
||||
export { Checkbox };
|
||||
|
||||
@ -11,10 +11,7 @@ const Slider = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full touch-none select-none items-center',
|
||||
className
|
||||
)}
|
||||
className={cn('relative flex w-full touch-none select-none items-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||
|
||||
@ -597,7 +597,9 @@ export async function apiGetBroadcast(id: string) {
|
||||
return apiFetch<ApiBroadcast>(`/admin/broadcasts/${id}`);
|
||||
}
|
||||
|
||||
export async function apiCreateBroadcast(body: Omit<ApiBroadcast, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>) {
|
||||
export async function apiCreateBroadcast(
|
||||
body: Omit<ApiBroadcast, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>
|
||||
) {
|
||||
return apiFetch<ApiBroadcast>('/admin/broadcasts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
@ -661,7 +663,16 @@ export interface ApiSurvey {
|
||||
description?: string;
|
||||
questions: {
|
||||
id: string;
|
||||
type: 'single_choice' | 'multiple_choice' | 'rating' | 'nps' | 'text_short' | 'text_long' | 'dropdown' | 'scale' | 'ranking';
|
||||
type:
|
||||
| 'single_choice'
|
||||
| 'multiple_choice'
|
||||
| 'rating'
|
||||
| 'nps'
|
||||
| 'text_short'
|
||||
| 'text_long'
|
||||
| 'dropdown'
|
||||
| 'scale'
|
||||
| 'ranking';
|
||||
text: string;
|
||||
description?: string;
|
||||
required: boolean;
|
||||
@ -676,7 +687,11 @@ export interface ApiSurvey {
|
||||
status: 'draft' | 'active' | 'paused' | 'closed';
|
||||
startsAt?: string;
|
||||
endsAt?: string;
|
||||
displayTrigger: { type: 'immediate' } | { type: 'delay_seconds'; seconds: number } | { type: 'event'; eventName: string } | { type: 'page_view'; pagePattern: string };
|
||||
displayTrigger:
|
||||
| { type: 'immediate' }
|
||||
| { type: 'delay_seconds'; seconds: number }
|
||||
| { type: 'event'; eventName: string }
|
||||
| { type: 'page_view'; pagePattern: string };
|
||||
incentive?: { type: 'pro_days' | 'credits'; amount: number };
|
||||
metrics: {
|
||||
impressions: number;
|
||||
@ -733,7 +748,9 @@ export async function apiGetSurvey(id: string) {
|
||||
return apiFetch<ApiSurvey>(`/admin/surveys/${id}`);
|
||||
}
|
||||
|
||||
export async function apiCreateSurvey(body: Omit<ApiSurvey, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>) {
|
||||
export async function apiCreateSurvey(
|
||||
body: Omit<ApiSurvey, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>
|
||||
) {
|
||||
return apiFetch<ApiSurvey>('/admin/surveys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
@ -759,12 +776,17 @@ export async function apiPauseSurvey(id: string) {
|
||||
return apiFetch<{ success: boolean }>(`/admin/surveys/${id}/pause`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function apiGetSurveyResponses(id: string, options?: { isComplete?: boolean; limit?: number; offset?: number }) {
|
||||
export async function apiGetSurveyResponses(
|
||||
id: string,
|
||||
options?: { isComplete?: boolean; limit?: number; offset?: number }
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.isComplete !== undefined) params.set('isComplete', String(options.isComplete));
|
||||
if (options?.limit) params.set('limit', String(options.limit));
|
||||
if (options?.offset) params.set('offset', String(options.offset));
|
||||
return apiFetch<{ responses: ApiSurveyResponse[]; total: number }>(`/admin/surveys/${id}/responses?${params}`);
|
||||
return apiFetch<{ responses: ApiSurveyResponse[]; total: number }>(
|
||||
`/admin/surveys/${id}/responses?${params}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiGetSurveyRespondents(id: string) {
|
||||
|
||||
@ -58,7 +58,9 @@ export async function updateSubscription(
|
||||
|
||||
// ── Usage ───────────────────────────────────────────────────────
|
||||
|
||||
export async function listUsage(options: { userId?: string; days?: number; limit?: number; productId?: string } = {}) {
|
||||
export async function listUsage(
|
||||
options: { userId?: string; days?: number; limit?: number; productId?: string } = {}
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if (options.userId) params.set('userId', options.userId);
|
||||
if (options.days) params.set('days', String(options.days));
|
||||
|
||||
@ -180,7 +180,9 @@ export function createDiagnosticsClient(config: DiagnosticsClientConfig) {
|
||||
if (options.limit) params.set('limit', options.limit.toString());
|
||||
if (options.offset) params.set('offset', options.offset.toString());
|
||||
|
||||
const result = await client.safeFetch<QuerySessionsResult>(`/api/diagnostics/sessions?${params.toString()}`);
|
||||
const result = await client.safeFetch<QuerySessionsResult>(
|
||||
`/api/diagnostics/sessions?${params.toString()}`
|
||||
);
|
||||
if (result.error) throw new Error(result.error);
|
||||
return result.data!;
|
||||
},
|
||||
@ -202,11 +204,14 @@ export function createDiagnosticsClient(config: DiagnosticsClientConfig) {
|
||||
},
|
||||
|
||||
async updateSession(sessionId: string, request: UpdateSessionRequest): Promise<DebugSession> {
|
||||
const result = await client.safeFetch<DebugSession>(`/api/diagnostics/sessions/${sessionId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
const result = await client.safeFetch<DebugSession>(
|
||||
`/api/diagnostics/sessions/${sessionId}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
}
|
||||
);
|
||||
if (result.error) throw new Error(result.error);
|
||||
return result.data!;
|
||||
},
|
||||
@ -267,7 +272,10 @@ export function createDiagnosticsClient(config: DiagnosticsClientConfig) {
|
||||
// Screenshots
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async getScreenshots(sessionId: string, productId: string): Promise<
|
||||
async getScreenshots(
|
||||
sessionId: string,
|
||||
productId: string
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
blobUrl: string;
|
||||
|
||||
@ -54,7 +54,10 @@ export async function restartServiceContainer(serviceId: string): Promise<{ cont
|
||||
throw new Error('Service is not restartable from admin ops');
|
||||
}
|
||||
|
||||
const response = await dockerRequest('POST', `/containers/${encodeURIComponent(container)}/restart?t=10`);
|
||||
const response = await dockerRequest(
|
||||
'POST',
|
||||
`/containers/${encodeURIComponent(container)}/restart?t=10`
|
||||
);
|
||||
if (![204, 304].includes(response.statusCode)) {
|
||||
throw new Error(response.body || `Docker restart failed with status ${response.statusCode}`);
|
||||
}
|
||||
|
||||
@ -48,7 +48,7 @@ export interface ExtractionTask {
|
||||
export async function extractText(
|
||||
text: string,
|
||||
taskId?: string,
|
||||
modelId?: string,
|
||||
modelId?: string
|
||||
): Promise<ExtractResponse | null> {
|
||||
try {
|
||||
return await extractionApi.fetch<ExtractResponse>('/extract', {
|
||||
@ -66,7 +66,7 @@ export async function extractTranscript(text: string): Promise<ExtractResponse |
|
||||
|
||||
export async function extractBatch(
|
||||
inputs: Array<{ text: string; taskId?: string }>,
|
||||
modelId?: string,
|
||||
modelId?: string
|
||||
): Promise<ExtractResponse[] | null> {
|
||||
try {
|
||||
const result = await extractionApi.fetch<{ results: ExtractResponse[] }>('/extract/batch', {
|
||||
@ -102,7 +102,9 @@ export async function getTask(id: string): Promise<ExtractionTask | null> {
|
||||
|
||||
export async function getSidecarHealth(): Promise<{ status: string; sidecar?: unknown } | null> {
|
||||
try {
|
||||
return await extractionApi.fetch<{ status: string; sidecar?: unknown }>('/extract/sidecar-health');
|
||||
return await extractionApi.fetch<{ status: string; sidecar?: unknown }>(
|
||||
'/extract/sidecar-health'
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -60,7 +60,9 @@ export async function getProductHealthDetail(productId: string): Promise<Product
|
||||
}
|
||||
|
||||
export async function getHealthTrends(productId: string, days = 30): Promise<ProductHealth[]> {
|
||||
return predictiveApi.fetch<ProductHealth[]>(`/predictive/health/${productId}/trends?days=${days}`);
|
||||
return predictiveApi.fetch<ProductHealth[]>(
|
||||
`/predictive/health/${productId}/trends?days=${days}`
|
||||
);
|
||||
}
|
||||
|
||||
// ── Churn Prediction ─────────────────────────────────────────
|
||||
@ -89,7 +91,11 @@ export interface ChurnPrediction {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getChurnScore(userId: string, productId: string, horizon = 30): Promise<ChurnPrediction> {
|
||||
export async function getChurnScore(
|
||||
userId: string,
|
||||
productId: string,
|
||||
horizon = 30
|
||||
): Promise<ChurnPrediction> {
|
||||
return predictiveApi.fetch<ChurnPrediction>('/predictive/churn-score', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, productId, horizon: String(horizon) }),
|
||||
@ -106,18 +112,22 @@ export interface AtRiskUser {
|
||||
predictionTimestamp: string;
|
||||
}
|
||||
|
||||
export async function getAtRiskUsers(options: {
|
||||
productId?: string;
|
||||
segment?: RiskSegment;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}): Promise<{ users: AtRiskUser[]; total: number }> {
|
||||
export async function getAtRiskUsers(
|
||||
options: {
|
||||
productId?: string;
|
||||
segment?: RiskSegment;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {}
|
||||
): Promise<{ users: AtRiskUser[]; total: number }> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.productId) params.set('productId', options.productId);
|
||||
if (options.segment) params.set('segment', options.segment);
|
||||
if (options.limit) params.set('limit', String(options.limit));
|
||||
if (options.offset) params.set('offset', String(options.offset));
|
||||
return predictiveApi.fetch<{ users: AtRiskUser[]; total: number }>(`/predictive/at-risk-users?${params}`);
|
||||
return predictiveApi.fetch<{ users: AtRiskUser[]; total: number }>(
|
||||
`/predictive/at-risk-users?${params}`
|
||||
);
|
||||
}
|
||||
|
||||
export interface UserRiskProfile extends ChurnPrediction {
|
||||
@ -149,8 +159,12 @@ export async function getModelPerformance(): Promise<ModelPerformance> {
|
||||
return predictiveApi.fetch<ModelPerformance>('/predictive/model/performance');
|
||||
}
|
||||
|
||||
export async function getFeatureImportance(): Promise<Array<{ feature: string; importance: number }>> {
|
||||
const res = await predictiveApi.fetch<{ features: Array<{ feature: string; importance: number }> }>('/predictive/model/features');
|
||||
export async function getFeatureImportance(): Promise<
|
||||
Array<{ feature: string; importance: number }>
|
||||
> {
|
||||
const res = await predictiveApi.fetch<{
|
||||
features: Array<{ feature: string; importance: number }>;
|
||||
}>('/predictive/model/features');
|
||||
return res.features;
|
||||
}
|
||||
|
||||
@ -242,7 +256,10 @@ export async function getCampaignStats(id: string): Promise<Campaign['stats']> {
|
||||
return predictiveApi.fetch<Campaign['stats']>(`/predictive/campaigns/${id}/stats`);
|
||||
}
|
||||
|
||||
export async function triggerCampaign(id: string, testUserId?: string): Promise<{ triggered: number }> {
|
||||
export async function triggerCampaign(
|
||||
id: string,
|
||||
testUserId?: string
|
||||
): Promise<{ triggered: number }> {
|
||||
return predictiveApi.fetch<{ triggered: number }>(`/predictive/campaigns/${id}/trigger`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(testUserId ? { testUserId } : {}),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user