chore(admin-web): clear dashboard warning sweep
What changed: - Removed unused admin dashboard imports and props. - Wrapped dashboard loaders in stable callbacks for hook dependency correctness. - Rendered experiment list load errors and migrated the security QR image to next/image. Warning impact: - @bytelyst/admin-web scoped warnings: 16 -> 0. - Workspace warning total: 173 -> 157. Verification: - pnpm --filter @bytelyst/admin-web exec eslint . --ext .ts,.tsx - pnpm --filter @bytelyst/admin-web typecheck - pnpm --filter @bytelyst/admin-web test - pnpm --filter @bytelyst/admin-web build - pnpm lint
This commit is contained in:
parent
97b23f7ca5
commit
10895977d4
@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
@ -20,7 +20,6 @@ import {
|
||||
TrendingUp,
|
||||
Clock,
|
||||
Sparkles,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -43,13 +42,7 @@ export default function ExperimentDetailPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExperimentData();
|
||||
const interval = setInterval(fetchExperimentData, 30000); // Auto-refresh every 30s
|
||||
return () => clearInterval(interval);
|
||||
}, [experimentId]);
|
||||
|
||||
async function fetchExperimentData() {
|
||||
const fetchExperimentData = useCallback(async () => {
|
||||
try {
|
||||
const [expResponse, resultsResponse] = await Promise.all([
|
||||
fetch(`/api/experiments/${experimentId}`),
|
||||
@ -67,7 +60,13 @@ export default function ExperimentDetailPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [experimentId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExperimentData();
|
||||
const interval = setInterval(fetchExperimentData, 30000); // Auto-refresh every 30s
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchExperimentData]);
|
||||
|
||||
async function updateStatus(status: string) {
|
||||
try {
|
||||
@ -166,8 +165,8 @@ export default function ExperimentDetailPage() {
|
||||
<Trophy className="h-4 w-4 text-green-600" />
|
||||
<AlertTitle>Winner Found!</AlertTitle>
|
||||
<AlertDescription>
|
||||
Variant has {((results.winnerProbability || 0) * 100).toFixed(1)}% probability of being best.
|
||||
Recommended action: {results.statisticalSummary.recommendedAction}.
|
||||
Variant has {((results.winnerProbability || 0) * 100).toFixed(1)}% probability of being
|
||||
best. Recommended action: {results.statisticalSummary.recommendedAction}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@ -211,7 +210,6 @@ export default function ExperimentDetailPage() {
|
||||
key={variant.id}
|
||||
variant={variant}
|
||||
isControl={variant.isControl}
|
||||
experiment={experiment}
|
||||
result={results?.variantResults.find(vr => vr.variantId === variant.id)}
|
||||
/>
|
||||
))}
|
||||
@ -243,9 +241,7 @@ export default function ExperimentDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<div className="text-sm text-muted-foreground mb-1">
|
||||
Recommended Action
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-1">Recommended Action</div>
|
||||
<div className="text-2xl font-bold capitalize">
|
||||
{results.statisticalSummary.recommendedAction}
|
||||
</div>
|
||||
@ -256,7 +252,10 @@ export default function ExperimentDetailPage() {
|
||||
<h4 className="font-semibold mb-4">Variant Comparison</h4>
|
||||
<div className="space-y-3">
|
||||
{results.variantResults.map(vr => (
|
||||
<div key={vr.variantId} className="flex items-center gap-4 p-3 border rounded-lg">
|
||||
<div
|
||||
key={vr.variantId}
|
||||
className="flex items-center gap-4 p-3 border rounded-lg"
|
||||
>
|
||||
<div className="w-32 font-medium">{vr.variantName}</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
@ -267,8 +266,11 @@ export default function ExperimentDetailPage() {
|
||||
<Progress value={vr.probabilityBeatsControl * 100} className="h-2" />
|
||||
</div>
|
||||
<div className="w-24 text-right">
|
||||
<div className={`text-sm font-medium ${vr.expectedLiftPercent > 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{vr.expectedLiftPercent > 0 ? '+' : ''}{vr.expectedLiftPercent.toFixed(1)}%
|
||||
<div
|
||||
className={`text-sm font-medium ${vr.expectedLiftPercent > 0 ? 'text-green-600' : 'text-red-600'}`}
|
||||
>
|
||||
{vr.expectedLiftPercent > 0 ? '+' : ''}
|
||||
{vr.expectedLiftPercent.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -327,7 +329,9 @@ export default function ExperimentDetailPage() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Allocation Strategy</label>
|
||||
<p className="text-muted-foreground capitalize">{experiment.allocationStrategy}</p>
|
||||
<p className="text-muted-foreground capitalize">
|
||||
{experiment.allocationStrategy}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Target Traffic</label>
|
||||
@ -339,15 +343,21 @@ export default function ExperimentDetailPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Min Sample Size</label>
|
||||
<p className="text-muted-foreground">{experiment.guardrails?.minSampleSizePerVariant} per variant</p>
|
||||
<p className="text-muted-foreground">
|
||||
{experiment.guardrails?.minSampleSizePerVariant} per variant
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Max Duration</label>
|
||||
<p className="text-muted-foreground">{experiment.guardrails?.maxDurationDays} days</p>
|
||||
<p className="text-muted-foreground">
|
||||
{experiment.guardrails?.maxDurationDays} days
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Auto Stop</label>
|
||||
<p className="text-muted-foreground">{experiment.guardrails?.autoStopEnabled ? 'Enabled' : 'Disabled'}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{experiment.guardrails?.autoStopEnabled ? 'Enabled' : 'Disabled'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -388,13 +398,15 @@ function StatCard({
|
||||
function VariantCard({
|
||||
variant,
|
||||
isControl,
|
||||
experiment,
|
||||
result,
|
||||
}: {
|
||||
variant: VariantDoc;
|
||||
isControl: boolean;
|
||||
experiment: ExperimentDoc;
|
||||
result?: { probabilityBeatsControl: number; expectedLiftPercent: number; credibleInterval: { lower: number; mean: number; upper: number } };
|
||||
result?: {
|
||||
probabilityBeatsControl: number;
|
||||
expectedLiftPercent: number;
|
||||
credibleInterval: { lower: number; mean: number; upper: number };
|
||||
};
|
||||
}) {
|
||||
const conversionRate = variant.stats?.conversionRate || 0;
|
||||
const participants = variant.stats?.participants || 0;
|
||||
@ -412,12 +424,13 @@ function VariantCard({
|
||||
Control
|
||||
</Badge>
|
||||
)}
|
||||
{variant.bayesianResults?.probabilityBeatsControl && variant.bayesianResults.probabilityBeatsControl > 0.95 && (
|
||||
<Badge className="bg-green-500 text-white">
|
||||
<Trophy className="h-3 w-3 mr-1" />
|
||||
Winner
|
||||
</Badge>
|
||||
)}
|
||||
{variant.bayesianResults?.probabilityBeatsControl &&
|
||||
variant.bayesianResults.probabilityBeatsControl > 0.95 && (
|
||||
<Badge className="bg-green-500 text-white">
|
||||
<Trophy className="h-3 w-3 mr-1" />
|
||||
Winner
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{variant.description}</p>
|
||||
</div>
|
||||
@ -475,5 +488,7 @@ function getStatusBadge(status: string) {
|
||||
stopped: 'bg-red-500',
|
||||
completed: 'bg-blue-500',
|
||||
};
|
||||
return <Badge className={`${colors[status] || colors.draft} text-white capitalize`}>{status}</Badge>;
|
||||
return (
|
||||
<Badge className={`${colors[status] || colors.draft} text-white capitalize`}>{status}</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import type { ExperimentDoc } from '@/lib/experiments-types';
|
||||
|
||||
const statusConfig: Record<string, { color: string; icon: typeof Play; label: string }> = {
|
||||
@ -140,6 +141,13 @@ export default function ExperimentsPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="all" className="space-y-6">
|
||||
<TabsList>
|
||||
|
||||
@ -1,13 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -23,16 +29,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Users,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ArrowRight,
|
||||
TrendingUp,
|
||||
Mail,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { AlertTriangle, Users, RefreshCw, Search, TrendingUp, Mail, Activity } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
getAtRiskUsers,
|
||||
@ -42,22 +39,25 @@ import {
|
||||
type UserRiskProfile,
|
||||
} from '@/lib/predictive-client';
|
||||
|
||||
const riskSegmentConfig: Record<RiskSegment, { color: string; label: string; icon: React.ReactNode }> = {
|
||||
critical: { color: 'bg-red-500', label: 'Critical Risk', icon: <AlertTriangle className="h-3 w-3" /> },
|
||||
const riskSegmentConfig: Record<
|
||||
RiskSegment,
|
||||
{ color: string; label: string; icon: React.ReactNode }
|
||||
> = {
|
||||
critical: {
|
||||
color: 'bg-red-500',
|
||||
label: 'Critical Risk',
|
||||
icon: <AlertTriangle className="h-3 w-3" />,
|
||||
},
|
||||
high: { color: 'bg-orange-500', label: 'High Risk', icon: <TrendingUp className="h-3 w-3" /> },
|
||||
medium: { color: 'bg-yellow-500', label: 'Medium Risk', icon: <Activity className="h-3 w-3" /> },
|
||||
low: { color: 'bg-green-500', label: 'Low Risk', icon: <Users className="h-3 w-3" /> },
|
||||
};
|
||||
|
||||
function UserDetailDialog({ userId, productId }: { userId: string; productId: string }) {
|
||||
function UserDetailDialog({ userId }: { userId: string }) {
|
||||
const [profile, setProfile] = useState<UserRiskProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, [userId, productId]);
|
||||
|
||||
async function loadProfile() {
|
||||
const loadProfile = useCallback(async () => {
|
||||
try {
|
||||
const data = await getUserRiskProfile(userId);
|
||||
setProfile(data);
|
||||
@ -66,7 +66,11 @@ function UserDetailDialog({ userId, productId }: { userId: string; productId: st
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, [loadProfile]);
|
||||
|
||||
if (loading) {
|
||||
return <Skeleton className="h-40" />;
|
||||
@ -98,7 +102,9 @@ function UserDetailDialog({ userId, productId }: { userId: string; productId: st
|
||||
{profile.explanation.topRiskFactors.slice(0, 5).map((factor, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between text-sm">
|
||||
<span>{factor.feature}</span>
|
||||
<span className={factor.direction === 'negative' ? 'text-red-500' : 'text-green-500'}>
|
||||
<span
|
||||
className={factor.direction === 'negative' ? 'text-red-500' : 'text-green-500'}
|
||||
>
|
||||
{(factor.contribution * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
@ -129,11 +135,14 @@ function UserDetailDialog({ userId, productId }: { userId: string; productId: st
|
||||
{new Date(intervention.timestamp).toLocaleDateString()}
|
||||
</span>
|
||||
{intervention.outcome && (
|
||||
<Badge variant="outline" className={
|
||||
intervention.outcome === 'retained' || intervention.outcome === 'responded'
|
||||
? 'text-green-500'
|
||||
: 'text-red-500'
|
||||
}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
intervention.outcome === 'retained' || intervention.outcome === 'responded'
|
||||
? 'text-green-500'
|
||||
: 'text-red-500'
|
||||
}
|
||||
>
|
||||
{intervention.outcome}
|
||||
</Badge>
|
||||
)}
|
||||
@ -157,11 +166,7 @@ export default function AtRiskUsersPage() {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const limit = 20;
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, [selectedSegment, selectedProduct, offset]);
|
||||
|
||||
async function loadUsers() {
|
||||
const loadUsers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@ -178,17 +183,21 @@ export default function AtRiskUsersPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [offset, selectedProduct, selectedSegment]);
|
||||
|
||||
const filteredUsers = users.filter((user) =>
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
const filteredUsers = users.filter(user =>
|
||||
searchQuery ? user.userId.toLowerCase().includes(searchQuery.toLowerCase()) : true
|
||||
);
|
||||
|
||||
const segmentCounts = {
|
||||
critical: users.filter((u) => u.riskSegment === 'critical').length,
|
||||
high: users.filter((u) => u.riskSegment === 'high').length,
|
||||
medium: users.filter((u) => u.riskSegment === 'medium').length,
|
||||
low: users.filter((u) => u.riskSegment === 'low').length,
|
||||
critical: users.filter(u => u.riskSegment === 'critical').length,
|
||||
high: users.filter(u => u.riskSegment === 'high').length,
|
||||
medium: users.filter(u => u.riskSegment === 'medium').length,
|
||||
low: users.filter(u => u.riskSegment === 'low').length,
|
||||
};
|
||||
|
||||
return (
|
||||
@ -256,11 +265,11 @@ export default function AtRiskUsersPage() {
|
||||
<Input
|
||||
placeholder="Search by user ID..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={selectedSegment} onValueChange={(v) => setSelectedSegment(v as RiskSegment)}>
|
||||
<Select value={selectedSegment} onValueChange={v => setSelectedSegment(v as RiskSegment)}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Risk segment" />
|
||||
</SelectTrigger>
|
||||
@ -337,7 +346,7 @@ export default function AtRiskUsersPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
filteredUsers.map(user => (
|
||||
<TableRow key={`${user.userId}-${user.productId}`}>
|
||||
<TableCell className="font-mono text-xs">{user.userId}</TableCell>
|
||||
<TableCell>{user.productId}</TableCell>
|
||||
@ -361,7 +370,7 @@ export default function AtRiskUsersPage() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>User Risk Profile</DialogTitle>
|
||||
</DialogHeader>
|
||||
<UserDetailDialog userId={user.userId} productId={user.productId} />
|
||||
<UserDetailDialog userId={user.userId} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableCell>
|
||||
@ -374,7 +383,8 @@ export default function AtRiskUsersPage() {
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {offset + 1}-{Math.min(offset + filteredUsers.length, total)} of {total} users
|
||||
Showing {offset + 1}-{Math.min(offset + filteredUsers.length, total)} of {total}{' '}
|
||||
users
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@ -8,7 +8,13 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -26,7 +32,6 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Mail,
|
||||
RefreshCw,
|
||||
@ -92,9 +97,7 @@ function CreateCampaignDialog({ onCreated }: { onCreated: () => void }) {
|
||||
productId,
|
||||
trigger: {
|
||||
type: triggerType,
|
||||
conditions: [
|
||||
{ field: 'riskSegment', operator: 'in', value: riskSegments },
|
||||
],
|
||||
conditions: [{ field: 'riskSegment', operator: 'in', value: riskSegments }],
|
||||
},
|
||||
audience: {
|
||||
riskSegments,
|
||||
@ -132,11 +135,19 @@ function CreateCampaignDialog({ onCreated }: { onCreated: () => void }) {
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Campaign Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g., Q1 Churn Prevention" />
|
||||
<Input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g., Q1 Churn Prevention"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What does this campaign do?" />
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="What does this campaign do?"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Product</Label>
|
||||
@ -156,7 +167,10 @@ function CreateCampaignDialog({ onCreated }: { onCreated: () => void }) {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Trigger</Label>
|
||||
<Select value={triggerType} onValueChange={(v) => setTriggerType(v as CampaignTriggerType)}>
|
||||
<Select
|
||||
value={triggerType}
|
||||
onValueChange={v => setTriggerType(v as CampaignTriggerType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@ -171,14 +185,14 @@ function CreateCampaignDialog({ onCreated }: { onCreated: () => void }) {
|
||||
<div className="space-y-2">
|
||||
<Label>Target Risk Segments</Label>
|
||||
<div className="flex gap-2">
|
||||
{(['critical', 'high', 'medium', 'low'] as const).map((segment) => (
|
||||
{(['critical', 'high', 'medium', 'low'] as const).map(segment => (
|
||||
<Badge
|
||||
key={segment}
|
||||
variant={riskSegments.includes(segment) ? 'default' : 'outline'}
|
||||
className="cursor-pointer capitalize"
|
||||
onClick={() =>
|
||||
setRiskSegments((prev) =>
|
||||
prev.includes(segment) ? prev.filter((s) => s !== segment) : [...prev, segment]
|
||||
setRiskSegments(prev =>
|
||||
prev.includes(segment) ? prev.filter(s => s !== segment) : [...prev, segment]
|
||||
)
|
||||
}
|
||||
>
|
||||
@ -206,13 +220,7 @@ function CampaignStatsDialog({ campaign }: { campaign: Campaign }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadStats();
|
||||
}
|
||||
}, [open, campaign.id]);
|
||||
|
||||
async function loadStats() {
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getCampaignStats(campaign.id);
|
||||
@ -222,7 +230,13 @@ function CampaignStatsDialog({ campaign }: { campaign: Campaign }) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [campaign.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadStats();
|
||||
}
|
||||
}, [open, loadStats]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@ -305,11 +319,7 @@ export default function CampaignsPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedProduct, setSelectedProduct] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
loadCampaigns();
|
||||
}, [selectedProduct]);
|
||||
|
||||
async function loadCampaigns() {
|
||||
const loadCampaigns = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@ -320,7 +330,11 @@ export default function CampaignsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [selectedProduct]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCampaigns();
|
||||
}, [loadCampaigns]);
|
||||
|
||||
async function handleToggleStatus(campaign: Campaign) {
|
||||
const newStatus = campaign.status === 'active' ? 'paused' : 'active';
|
||||
@ -341,7 +355,7 @@ export default function CampaignsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const activeCount = campaigns.filter((c) => c.status === 'active').length;
|
||||
const activeCount = campaigns.filter(c => c.status === 'active').length;
|
||||
const totalTriggered = campaigns.reduce((sum, c) => sum + c.stats.triggered, 0);
|
||||
const totalConverted = campaigns.reduce((sum, c) => sum + c.stats.converted, 0);
|
||||
|
||||
@ -404,7 +418,9 @@ export default function CampaignsPage() {
|
||||
<CheckCircle className="h-4 w-4 text-emerald-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-emerald-600">{totalConverted.toLocaleString()}</div>
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
{totalConverted.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Users retained</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -433,7 +449,9 @@ export default function CampaignsPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Campaigns</CardTitle>
|
||||
<CardDescription>Manage automated retention campaigns and view performance</CardDescription>
|
||||
<CardDescription>
|
||||
Manage automated retention campaigns and view performance
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
@ -462,7 +480,7 @@ export default function CampaignsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{campaigns.map((campaign) => (
|
||||
{campaigns.map(campaign => (
|
||||
<TableRow key={campaign.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{campaign.name}</div>
|
||||
@ -490,7 +508,10 @@ export default function CampaignsPage() {
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{campaign.stats.converted}</span>
|
||||
<span className="text-muted-foreground"> / {campaign.stats.triggered} converted</span>
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
/ {campaign.stats.triggered} converted
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{campaign.stats.opened} opens, {campaign.stats.clicked} clicks
|
||||
@ -503,7 +524,11 @@ export default function CampaignsPage() {
|
||||
size="sm"
|
||||
onClick={() => handleToggleStatus(campaign)}
|
||||
>
|
||||
{campaign.status === 'active' ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
{campaign.status === 'active' ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<CampaignStatsDialog campaign={campaign} />
|
||||
<Button variant="ghost" size="sm" onClick={() => handleTrigger(campaign)}>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
ShieldCheck,
|
||||
@ -218,9 +219,12 @@ export default function SecuritySettingsPage() {
|
||||
{/* QR Code */}
|
||||
<div className="flex justify-center">
|
||||
{setupData.qrDataUrl ? (
|
||||
<img
|
||||
<Image
|
||||
src={setupData.qrDataUrl}
|
||||
alt="TOTP QR Code"
|
||||
width={192}
|
||||
height={192}
|
||||
unoptimized
|
||||
className="h-48 w-48 rounded-lg border"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -1,13 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
apiCreateSurvey,
|
||||
apiGetSurvey,
|
||||
apiUpdateSurvey,
|
||||
type ApiSurvey,
|
||||
} from '@/lib/api';
|
||||
import { apiCreateSurvey, apiGetSurvey, apiUpdateSurvey, type ApiSurvey } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@ -37,7 +32,7 @@ const TRIGGERS = ['immediate', 'delay_seconds', 'event', 'page_view'] as const;
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
type: typeof QUESTION_TYPES[number]['id'];
|
||||
type: (typeof QUESTION_TYPES)[number]['id'];
|
||||
text: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
@ -62,17 +57,11 @@ export default function SurveyEditorPage() {
|
||||
platforms: [] as string[],
|
||||
userSegments: [] as string[],
|
||||
},
|
||||
displayTrigger: { type: 'immediate' as typeof TRIGGERS[number] },
|
||||
displayTrigger: { type: 'immediate' as (typeof TRIGGERS)[number] },
|
||||
incentive: null as { type: 'pro_days' | 'credits'; amount: number } | null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (surveyId) {
|
||||
loadSurvey();
|
||||
}
|
||||
}, [surveyId]);
|
||||
|
||||
async function loadSurvey() {
|
||||
const loadSurvey = useCallback(async () => {
|
||||
try {
|
||||
const { data, error } = await apiGetSurvey(surveyId!);
|
||||
if (error) throw new Error(error);
|
||||
@ -98,7 +87,13 @@ export default function SurveyEditorPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [surveyId, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (surveyId) {
|
||||
loadSurvey();
|
||||
}
|
||||
}, [loadSurvey, surveyId]);
|
||||
|
||||
function addQuestion(type: Question['type']) {
|
||||
const newQuestion: Question = {
|
||||
@ -163,11 +158,19 @@ export default function SurveyEditorPage() {
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
const { error } = await apiUpdateSurvey(surveyId!, payload as unknown as Partial<ApiSurvey>);
|
||||
const { error } = await apiUpdateSurvey(
|
||||
surveyId!,
|
||||
payload as unknown as Partial<ApiSurvey>
|
||||
);
|
||||
if (error) throw new Error(error);
|
||||
toast({ title: 'Success', description: 'Survey updated', variant: 'success' });
|
||||
} else {
|
||||
const { error } = await apiCreateSurvey(payload as unknown as Omit<ApiSurvey, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>);
|
||||
const { error } = await apiCreateSurvey(
|
||||
payload as unknown as Omit<
|
||||
ApiSurvey,
|
||||
'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'
|
||||
>
|
||||
);
|
||||
if (error) throw new Error(error);
|
||||
toast({ title: 'Success', description: 'Survey created', variant: 'success' });
|
||||
}
|
||||
@ -205,7 +208,9 @@ export default function SurveyEditorPage() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{isEdit ? 'Edit Survey' : 'New Survey'}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{isEdit ? 'Update your survey questions' : 'Create an in-app survey with conditional logic'}
|
||||
{isEdit
|
||||
? 'Update your survey questions'
|
||||
: 'Create an in-app survey with conditional logic'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -228,7 +233,7 @@ export default function SurveyEditorPage() {
|
||||
<Input
|
||||
id="title"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
onChange={e => setForm({ ...form, title: e.target.value })}
|
||||
placeholder="Enter survey title"
|
||||
/>
|
||||
</div>
|
||||
@ -237,7 +242,7 @@ export default function SurveyEditorPage() {
|
||||
<Textarea
|
||||
id="description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
onChange={e => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="Enter survey description"
|
||||
rows={2}
|
||||
/>
|
||||
@ -249,7 +254,7 @@ export default function SurveyEditorPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">Questions ({form.questions.length})</h3>
|
||||
<div className="flex gap-2">
|
||||
{QUESTION_TYPES.map((type) => (
|
||||
{QUESTION_TYPES.map(type => (
|
||||
<Button
|
||||
key={type.id}
|
||||
variant="outline"
|
||||
@ -268,7 +273,9 @@ export default function SurveyEditorPage() {
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground cursor-move" />
|
||||
<Badge variant="outline">{QUESTION_TYPES.find((t) => t.id === question.type)?.label}</Badge>
|
||||
<Badge variant="outline">
|
||||
{QUESTION_TYPES.find(t => t.id === question.type)?.label}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">Q{index + 1}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -285,7 +292,7 @@ export default function SurveyEditorPage() {
|
||||
<Label>Question Text *</Label>
|
||||
<Input
|
||||
value={question.text}
|
||||
onChange={(e) => updateQuestion(index, { text: e.target.value })}
|
||||
onChange={e => updateQuestion(index, { text: e.target.value })}
|
||||
placeholder="Enter your question"
|
||||
/>
|
||||
</div>
|
||||
@ -293,7 +300,7 @@ export default function SurveyEditorPage() {
|
||||
<Label>Description (optional)</Label>
|
||||
<Input
|
||||
value={question.description}
|
||||
onChange={(e) => updateQuestion(index, { description: e.target.value })}
|
||||
onChange={e => updateQuestion(index, { description: e.target.value })}
|
||||
placeholder="Additional context for this question"
|
||||
/>
|
||||
</div>
|
||||
@ -306,7 +313,7 @@ export default function SurveyEditorPage() {
|
||||
<div key={option.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={option.text}
|
||||
onChange={(e) => updateOption(index, optIndex, e.target.value)}
|
||||
onChange={e => updateOption(index, optIndex, e.target.value)}
|
||||
placeholder={`Option ${optIndex + 1}`}
|
||||
/>
|
||||
<Button
|
||||
@ -351,7 +358,7 @@ export default function SurveyEditorPage() {
|
||||
<div className="space-y-2">
|
||||
<Label>Display Trigger</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TRIGGERS.map((trigger) => (
|
||||
{TRIGGERS.map(trigger => (
|
||||
<Badge
|
||||
key={trigger}
|
||||
variant={form.displayTrigger.type === trigger ? 'default' : 'outline'}
|
||||
@ -377,7 +384,7 @@ export default function SurveyEditorPage() {
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.incentive?.amount || ''}
|
||||
onChange={(e) =>
|
||||
onChange={e =>
|
||||
setForm({
|
||||
...form,
|
||||
incentive: {
|
||||
@ -407,7 +414,7 @@ export default function SurveyEditorPage() {
|
||||
<div className="space-y-2">
|
||||
<Label>Platforms</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PLATFORMS.map((platform) => (
|
||||
{PLATFORMS.map(platform => (
|
||||
<Badge
|
||||
key={platform}
|
||||
variant={form.target.platforms.includes(platform) ? 'default' : 'outline'}
|
||||
@ -418,7 +425,7 @@ export default function SurveyEditorPage() {
|
||||
target: {
|
||||
...form.target,
|
||||
platforms: form.target.platforms.includes(platform)
|
||||
? form.target.platforms.filter((p) => p !== platform)
|
||||
? form.target.platforms.filter(p => p !== platform)
|
||||
: [...form.target.platforms, platform],
|
||||
},
|
||||
})
|
||||
@ -433,7 +440,7 @@ export default function SurveyEditorPage() {
|
||||
<div className="space-y-2">
|
||||
<Label>User Segments</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SEGMENTS.map((segment) => (
|
||||
{SEGMENTS.map(segment => (
|
||||
<Badge
|
||||
key={segment}
|
||||
variant={form.target.userSegments.includes(segment) ? 'default' : 'outline'}
|
||||
@ -444,7 +451,7 @@ export default function SurveyEditorPage() {
|
||||
target: {
|
||||
...form.target,
|
||||
userSegments: form.target.userSegments.includes(segment)
|
||||
? form.target.userSegments.filter((s) => s !== segment)
|
||||
? form.target.userSegments.filter(s => s !== segment)
|
||||
: [...form.target.userSegments, segment],
|
||||
},
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
apiListSurveys,
|
||||
apiDeleteSurvey,
|
||||
@ -11,13 +11,7 @@ import {
|
||||
type ApiSurveyAnalytics,
|
||||
} from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -64,11 +58,7 @@ export default function SurveysPage() {
|
||||
} | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadSurveys();
|
||||
}, []);
|
||||
|
||||
async function loadSurveys() {
|
||||
const loadSurveys = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data, error } = await apiListSurveys();
|
||||
@ -83,7 +73,11 @@ export default function SurveysPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSurveys();
|
||||
}, [loadSurveys]);
|
||||
|
||||
async function handleDelete(survey: ApiSurvey) {
|
||||
try {
|
||||
@ -149,7 +143,7 @@ export default function SurveysPage() {
|
||||
|
||||
async function exportCSV(survey: ApiSurvey) {
|
||||
try {
|
||||
const csv = await import('@/lib/api').then((m) => m.apiExportSurveyCSV(survey.id));
|
||||
const csv = await import('@/lib/api').then(m => m.apiExportSurveyCSV(survey.id));
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@ -168,7 +162,7 @@ export default function SurveysPage() {
|
||||
}
|
||||
|
||||
const filteredSurveys = surveys.filter(
|
||||
(s) =>
|
||||
s =>
|
||||
s.title.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(s.description?.toLowerCase() || '').includes(filter.toLowerCase())
|
||||
);
|
||||
@ -206,14 +200,12 @@ export default function SurveysPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>All Surveys</CardTitle>
|
||||
<CardDescription>
|
||||
{surveys.length} total surveys
|
||||
</CardDescription>
|
||||
<CardDescription>{surveys.length} total surveys</CardDescription>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Filter surveys..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
@ -232,7 +224,7 @@ export default function SurveysPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredSurveys.map((survey) => (
|
||||
{filteredSurveys.map(survey => (
|
||||
<TableRow key={survey.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{survey.title}</div>
|
||||
@ -241,18 +233,19 @@ export default function SurveysPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={statusColors[survey.status]}>
|
||||
{survey.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{survey.questions.length} questions
|
||||
<Badge className={statusColors[survey.status]}>{survey.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{survey.questions.length} questions</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>Starts: {survey.metrics.starts}</div>
|
||||
<div className="text-muted-foreground">
|
||||
Completion: {((survey.metrics.completions / Math.max(survey.metrics.starts, 1)) * 100).toFixed(1)}%
|
||||
Completion:{' '}
|
||||
{(
|
||||
(survey.metrics.completions / Math.max(survey.metrics.starts, 1)) *
|
||||
100
|
||||
).toFixed(1)}
|
||||
%
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
@ -265,9 +258,7 @@ export default function SurveysPage() {
|
||||
<span className="text-muted-foreground">None</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(survey.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>{new Date(survey.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@ -326,8 +317,8 @@ export default function SurveysPage() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Survey</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{deleteDialog?.title}"? This action
|
||||
cannot be undone.
|
||||
Are you sure you want to delete "{deleteDialog?.title}"? This action cannot
|
||||
be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@ -380,11 +371,11 @@ export default function SurveysPage() {
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
{analyticsDialog?.analytics.questionAnalytics.map((qa) => (
|
||||
{analyticsDialog?.analytics.questionAnalytics.map(qa => (
|
||||
<Card key={qa.questionId}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">
|
||||
Q: {analyticsDialog.survey.questions.find((q) => q.id === qa.questionId)?.text}
|
||||
Q: {analyticsDialog.survey.questions.find(q => q.id === qa.questionId)?.text}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user