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:
Saravana Achu Mac 2026-05-04 16:27:02 -07:00
parent 97b23f7ca5
commit 10895977d4
7 changed files with 237 additions and 177 deletions

View File

@ -5,7 +5,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { import {
@ -20,7 +20,6 @@ import {
TrendingUp, TrendingUp,
Clock, Clock,
Sparkles, Sparkles,
Download,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -43,13 +42,7 @@ export default function ExperimentDetailPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { const fetchExperimentData = useCallback(async () => {
fetchExperimentData();
const interval = setInterval(fetchExperimentData, 30000); // Auto-refresh every 30s
return () => clearInterval(interval);
}, [experimentId]);
async function fetchExperimentData() {
try { try {
const [expResponse, resultsResponse] = await Promise.all([ const [expResponse, resultsResponse] = await Promise.all([
fetch(`/api/experiments/${experimentId}`), fetch(`/api/experiments/${experimentId}`),
@ -67,7 +60,13 @@ export default function ExperimentDetailPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, [experimentId]);
useEffect(() => {
fetchExperimentData();
const interval = setInterval(fetchExperimentData, 30000); // Auto-refresh every 30s
return () => clearInterval(interval);
}, [fetchExperimentData]);
async function updateStatus(status: string) { async function updateStatus(status: string) {
try { try {
@ -166,8 +165,8 @@ export default function ExperimentDetailPage() {
<Trophy className="h-4 w-4 text-green-600" /> <Trophy className="h-4 w-4 text-green-600" />
<AlertTitle>Winner Found!</AlertTitle> <AlertTitle>Winner Found!</AlertTitle>
<AlertDescription> <AlertDescription>
Variant has {((results.winnerProbability || 0) * 100).toFixed(1)}% probability of being best. Variant has {((results.winnerProbability || 0) * 100).toFixed(1)}% probability of being
Recommended action: {results.statisticalSummary.recommendedAction}. best. Recommended action: {results.statisticalSummary.recommendedAction}.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@ -211,7 +210,6 @@ export default function ExperimentDetailPage() {
key={variant.id} key={variant.id}
variant={variant} variant={variant}
isControl={variant.isControl} isControl={variant.isControl}
experiment={experiment}
result={results?.variantResults.find(vr => vr.variantId === variant.id)} result={results?.variantResults.find(vr => vr.variantId === variant.id)}
/> />
))} ))}
@ -243,9 +241,7 @@ export default function ExperimentDetailPage() {
</div> </div>
</div> </div>
<div className="p-4 bg-muted rounded-lg"> <div className="p-4 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1"> <div className="text-sm text-muted-foreground mb-1">Recommended Action</div>
Recommended Action
</div>
<div className="text-2xl font-bold capitalize"> <div className="text-2xl font-bold capitalize">
{results.statisticalSummary.recommendedAction} {results.statisticalSummary.recommendedAction}
</div> </div>
@ -256,7 +252,10 @@ export default function ExperimentDetailPage() {
<h4 className="font-semibold mb-4">Variant Comparison</h4> <h4 className="font-semibold mb-4">Variant Comparison</h4>
<div className="space-y-3"> <div className="space-y-3">
{results.variantResults.map(vr => ( {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="w-32 font-medium">{vr.variantName}</div>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-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" /> <Progress value={vr.probabilityBeatsControl * 100} className="h-2" />
</div> </div>
<div className="w-24 text-right"> <div className="w-24 text-right">
<div className={`text-sm font-medium ${vr.expectedLiftPercent > 0 ? 'text-green-600' : 'text-red-600'}`}> <div
{vr.expectedLiftPercent > 0 ? '+' : ''}{vr.expectedLiftPercent.toFixed(1)}% className={`text-sm font-medium ${vr.expectedLiftPercent > 0 ? 'text-green-600' : 'text-red-600'}`}
>
{vr.expectedLiftPercent > 0 ? '+' : ''}
{vr.expectedLiftPercent.toFixed(1)}%
</div> </div>
</div> </div>
</div> </div>
@ -327,7 +329,9 @@ export default function ExperimentDetailPage() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="text-sm font-medium">Allocation Strategy</label> <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>
<div> <div>
<label className="text-sm font-medium">Target Traffic</label> <label className="text-sm font-medium">Target Traffic</label>
@ -339,15 +343,21 @@ export default function ExperimentDetailPage() {
</div> </div>
<div> <div>
<label className="text-sm font-medium">Min Sample Size</label> <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>
<div> <div>
<label className="text-sm font-medium">Max Duration</label> <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>
<div> <div>
<label className="text-sm font-medium">Auto Stop</label> <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>
</div> </div>
</CardContent> </CardContent>
@ -388,13 +398,15 @@ function StatCard({
function VariantCard({ function VariantCard({
variant, variant,
isControl, isControl,
experiment,
result, result,
}: { }: {
variant: VariantDoc; variant: VariantDoc;
isControl: boolean; isControl: boolean;
experiment: ExperimentDoc; result?: {
result?: { probabilityBeatsControl: number; expectedLiftPercent: number; credibleInterval: { lower: number; mean: number; upper: number } }; probabilityBeatsControl: number;
expectedLiftPercent: number;
credibleInterval: { lower: number; mean: number; upper: number };
};
}) { }) {
const conversionRate = variant.stats?.conversionRate || 0; const conversionRate = variant.stats?.conversionRate || 0;
const participants = variant.stats?.participants || 0; const participants = variant.stats?.participants || 0;
@ -412,12 +424,13 @@ function VariantCard({
Control Control
</Badge> </Badge>
)} )}
{variant.bayesianResults?.probabilityBeatsControl && variant.bayesianResults.probabilityBeatsControl > 0.95 && ( {variant.bayesianResults?.probabilityBeatsControl &&
<Badge className="bg-green-500 text-white"> variant.bayesianResults.probabilityBeatsControl > 0.95 && (
<Trophy className="h-3 w-3 mr-1" /> <Badge className="bg-green-500 text-white">
Winner <Trophy className="h-3 w-3 mr-1" />
</Badge> Winner
)} </Badge>
)}
</div> </div>
<p className="text-sm text-muted-foreground">{variant.description}</p> <p className="text-sm text-muted-foreground">{variant.description}</p>
</div> </div>
@ -475,5 +488,7 @@ function getStatusBadge(status: string) {
stopped: 'bg-red-500', stopped: 'bg-red-500',
completed: 'bg-blue-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>
);
} }

View File

@ -25,6 +25,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Alert, AlertDescription } from '@/components/ui/alert';
import type { ExperimentDoc } from '@/lib/experiments-types'; import type { ExperimentDoc } from '@/lib/experiments-types';
const statusConfig: Record<string, { color: string; icon: typeof Play; label: string }> = { const statusConfig: Record<string, { color: string; icon: typeof Play; label: string }> = {
@ -140,6 +141,13 @@ export default function ExperimentsPage() {
</Card> </Card>
</div> </div>
{error && (
<Alert variant="destructive" className="mb-6">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Tabs */} {/* Tabs */}
<Tabs defaultValue="all" className="space-y-6"> <Tabs defaultValue="all" className="space-y-6">
<TabsList> <TabsList>

View File

@ -1,13 +1,19 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; 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 { import {
Table, Table,
TableBody, TableBody,
@ -23,16 +29,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { import { AlertTriangle, Users, RefreshCw, Search, TrendingUp, Mail, Activity } from 'lucide-react';
AlertTriangle,
Users,
RefreshCw,
Search,
ArrowRight,
TrendingUp,
Mail,
Activity,
} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { import {
getAtRiskUsers, getAtRiskUsers,
@ -42,22 +39,25 @@ import {
type UserRiskProfile, type UserRiskProfile,
} from '@/lib/predictive-client'; } from '@/lib/predictive-client';
const riskSegmentConfig: Record<RiskSegment, { color: string; label: string; icon: React.ReactNode }> = { const riskSegmentConfig: Record<
critical: { color: 'bg-red-500', label: 'Critical Risk', icon: <AlertTriangle className="h-3 w-3" /> }, 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" /> }, 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" /> }, 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" /> }, 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 [profile, setProfile] = useState<UserRiskProfile | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { const loadProfile = useCallback(async () => {
loadProfile();
}, [userId, productId]);
async function loadProfile() {
try { try {
const data = await getUserRiskProfile(userId); const data = await getUserRiskProfile(userId);
setProfile(data); setProfile(data);
@ -66,7 +66,11 @@ function UserDetailDialog({ userId, productId }: { userId: string; productId: st
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, [userId]);
useEffect(() => {
loadProfile();
}, [loadProfile]);
if (loading) { if (loading) {
return <Skeleton className="h-40" />; 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) => ( {profile.explanation.topRiskFactors.slice(0, 5).map((factor, idx) => (
<div key={idx} className="flex items-center justify-between text-sm"> <div key={idx} className="flex items-center justify-between text-sm">
<span>{factor.feature}</span> <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)}% {(factor.contribution * 100).toFixed(1)}%
</span> </span>
</div> </div>
@ -129,11 +135,14 @@ function UserDetailDialog({ userId, productId }: { userId: string; productId: st
{new Date(intervention.timestamp).toLocaleDateString()} {new Date(intervention.timestamp).toLocaleDateString()}
</span> </span>
{intervention.outcome && ( {intervention.outcome && (
<Badge variant="outline" className={ <Badge
intervention.outcome === 'retained' || intervention.outcome === 'responded' variant="outline"
? 'text-green-500' className={
: 'text-red-500' intervention.outcome === 'retained' || intervention.outcome === 'responded'
}> ? 'text-green-500'
: 'text-red-500'
}
>
{intervention.outcome} {intervention.outcome}
</Badge> </Badge>
)} )}
@ -157,11 +166,7 @@ export default function AtRiskUsersPage() {
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const limit = 20; const limit = 20;
useEffect(() => { const loadUsers = useCallback(async () => {
loadUsers();
}, [selectedSegment, selectedProduct, offset]);
async function loadUsers() {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -178,17 +183,21 @@ export default function AtRiskUsersPage() {
} finally { } finally {
setLoading(false); 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 searchQuery ? user.userId.toLowerCase().includes(searchQuery.toLowerCase()) : true
); );
const segmentCounts = { const segmentCounts = {
critical: users.filter((u) => u.riskSegment === 'critical').length, critical: users.filter(u => u.riskSegment === 'critical').length,
high: users.filter((u) => u.riskSegment === 'high').length, high: users.filter(u => u.riskSegment === 'high').length,
medium: users.filter((u) => u.riskSegment === 'medium').length, medium: users.filter(u => u.riskSegment === 'medium').length,
low: users.filter((u) => u.riskSegment === 'low').length, low: users.filter(u => u.riskSegment === 'low').length,
}; };
return ( return (
@ -256,11 +265,11 @@ export default function AtRiskUsersPage() {
<Input <Input
placeholder="Search by user ID..." placeholder="Search by user ID..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
className="pl-9" className="pl-9"
/> />
</div> </div>
<Select value={selectedSegment} onValueChange={(v) => setSelectedSegment(v as RiskSegment)}> <Select value={selectedSegment} onValueChange={v => setSelectedSegment(v as RiskSegment)}>
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue placeholder="Risk segment" /> <SelectValue placeholder="Risk segment" />
</SelectTrigger> </SelectTrigger>
@ -337,7 +346,7 @@ export default function AtRiskUsersPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredUsers.map((user) => ( filteredUsers.map(user => (
<TableRow key={`${user.userId}-${user.productId}`}> <TableRow key={`${user.userId}-${user.productId}`}>
<TableCell className="font-mono text-xs">{user.userId}</TableCell> <TableCell className="font-mono text-xs">{user.userId}</TableCell>
<TableCell>{user.productId}</TableCell> <TableCell>{user.productId}</TableCell>
@ -361,7 +370,7 @@ export default function AtRiskUsersPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>User Risk Profile</DialogTitle> <DialogTitle>User Risk Profile</DialogTitle>
</DialogHeader> </DialogHeader>
<UserDetailDialog userId={user.userId} productId={user.productId} /> <UserDetailDialog userId={user.userId} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</TableCell> </TableCell>
@ -374,7 +383,8 @@ export default function AtRiskUsersPage() {
{/* Pagination */} {/* Pagination */}
<div className="flex items-center justify-between mt-4"> <div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground"> <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>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@ -8,7 +8,13 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; 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 { import {
Table, Table,
TableBody, TableBody,
@ -26,7 +32,6 @@ import {
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
Mail, Mail,
RefreshCw, RefreshCw,
@ -92,9 +97,7 @@ function CreateCampaignDialog({ onCreated }: { onCreated: () => void }) {
productId, productId,
trigger: { trigger: {
type: triggerType, type: triggerType,
conditions: [ conditions: [{ field: 'riskSegment', operator: 'in', value: riskSegments }],
{ field: 'riskSegment', operator: 'in', value: riskSegments },
],
}, },
audience: { audience: {
riskSegments, riskSegments,
@ -132,11 +135,19 @@ function CreateCampaignDialog({ onCreated }: { onCreated: () => void }) {
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Campaign Name</Label> <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>
<div className="space-y-2"> <div className="space-y-2">
<Label>Description</Label> <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>
<div className="space-y-2"> <div className="space-y-2">
<Label>Product</Label> <Label>Product</Label>
@ -156,7 +167,10 @@ function CreateCampaignDialog({ onCreated }: { onCreated: () => void }) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Trigger</Label> <Label>Trigger</Label>
<Select value={triggerType} onValueChange={(v) => setTriggerType(v as CampaignTriggerType)}> <Select
value={triggerType}
onValueChange={v => setTriggerType(v as CampaignTriggerType)}
>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@ -171,14 +185,14 @@ function CreateCampaignDialog({ onCreated }: { onCreated: () => void }) {
<div className="space-y-2"> <div className="space-y-2">
<Label>Target Risk Segments</Label> <Label>Target Risk Segments</Label>
<div className="flex gap-2"> <div className="flex gap-2">
{(['critical', 'high', 'medium', 'low'] as const).map((segment) => ( {(['critical', 'high', 'medium', 'low'] as const).map(segment => (
<Badge <Badge
key={segment} key={segment}
variant={riskSegments.includes(segment) ? 'default' : 'outline'} variant={riskSegments.includes(segment) ? 'default' : 'outline'}
className="cursor-pointer capitalize" className="cursor-pointer capitalize"
onClick={() => onClick={() =>
setRiskSegments((prev) => setRiskSegments(prev =>
prev.includes(segment) ? prev.filter((s) => s !== segment) : [...prev, segment] 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 [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
useEffect(() => { const loadStats = useCallback(async () => {
if (open) {
loadStats();
}
}, [open, campaign.id]);
async function loadStats() {
try { try {
setLoading(true); setLoading(true);
const data = await getCampaignStats(campaign.id); const data = await getCampaignStats(campaign.id);
@ -222,7 +230,13 @@ function CampaignStatsDialog({ campaign }: { campaign: Campaign }) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, [campaign.id]);
useEffect(() => {
if (open) {
loadStats();
}
}, [open, loadStats]);
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@ -305,11 +319,7 @@ export default function CampaignsPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedProduct, setSelectedProduct] = useState<string>(''); const [selectedProduct, setSelectedProduct] = useState<string>('');
useEffect(() => { const loadCampaigns = useCallback(async () => {
loadCampaigns();
}, [selectedProduct]);
async function loadCampaigns() {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -320,7 +330,11 @@ export default function CampaignsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, [selectedProduct]);
useEffect(() => {
loadCampaigns();
}, [loadCampaigns]);
async function handleToggleStatus(campaign: Campaign) { async function handleToggleStatus(campaign: Campaign) {
const newStatus = campaign.status === 'active' ? 'paused' : 'active'; 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 totalTriggered = campaigns.reduce((sum, c) => sum + c.stats.triggered, 0);
const totalConverted = campaigns.reduce((sum, c) => sum + c.stats.converted, 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" /> <CheckCircle className="h-4 w-4 text-emerald-500" />
</CardHeader> </CardHeader>
<CardContent> <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> <p className="text-xs text-muted-foreground">Users retained</p>
</CardContent> </CardContent>
</Card> </Card>
@ -433,7 +449,9 @@ export default function CampaignsPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>All Campaigns</CardTitle> <CardTitle>All Campaigns</CardTitle>
<CardDescription>Manage automated retention campaigns and view performance</CardDescription> <CardDescription>
Manage automated retention campaigns and view performance
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loading ? ( {loading ? (
@ -462,7 +480,7 @@ export default function CampaignsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{campaigns.map((campaign) => ( {campaigns.map(campaign => (
<TableRow key={campaign.id}> <TableRow key={campaign.id}>
<TableCell> <TableCell>
<div className="font-medium">{campaign.name}</div> <div className="font-medium">{campaign.name}</div>
@ -490,7 +508,10 @@ export default function CampaignsPage() {
<TableCell> <TableCell>
<div className="text-sm"> <div className="text-sm">
<span className="font-medium">{campaign.stats.converted}</span> <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>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{campaign.stats.opened} opens, {campaign.stats.clicked} clicks {campaign.stats.opened} opens, {campaign.stats.clicked} clicks
@ -503,7 +524,11 @@ export default function CampaignsPage() {
size="sm" size="sm"
onClick={() => handleToggleStatus(campaign)} 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> </Button>
<CampaignStatsDialog campaign={campaign} /> <CampaignStatsDialog campaign={campaign} />
<Button variant="ghost" size="sm" onClick={() => handleTrigger(campaign)}> <Button variant="ghost" size="sm" onClick={() => handleTrigger(campaign)}>

View File

@ -1,5 +1,6 @@
'use client'; 'use client';
import Image from 'next/image';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
ShieldCheck, ShieldCheck,
@ -218,9 +219,12 @@ export default function SecuritySettingsPage() {
{/* QR Code */} {/* QR Code */}
<div className="flex justify-center"> <div className="flex justify-center">
{setupData.qrDataUrl ? ( {setupData.qrDataUrl ? (
<img <Image
src={setupData.qrDataUrl} src={setupData.qrDataUrl}
alt="TOTP QR Code" alt="TOTP QR Code"
width={192}
height={192}
unoptimized
className="h-48 w-48 rounded-lg border" className="h-48 w-48 rounded-lg border"
/> />
) : ( ) : (

View File

@ -1,13 +1,8 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { import { apiCreateSurvey, apiGetSurvey, apiUpdateSurvey, type ApiSurvey } from '@/lib/api';
apiCreateSurvey,
apiGetSurvey,
apiUpdateSurvey,
type ApiSurvey,
} from '@/lib/api';
import { Button } from '@/components/ui/button'; 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 { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -37,7 +32,7 @@ const TRIGGERS = ['immediate', 'delay_seconds', 'event', 'page_view'] as const;
interface Question { interface Question {
id: string; id: string;
type: typeof QUESTION_TYPES[number]['id']; type: (typeof QUESTION_TYPES)[number]['id'];
text: string; text: string;
description: string; description: string;
required: boolean; required: boolean;
@ -62,17 +57,11 @@ export default function SurveyEditorPage() {
platforms: [] as string[], platforms: [] as string[],
userSegments: [] 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, incentive: null as { type: 'pro_days' | 'credits'; amount: number } | null,
}); });
useEffect(() => { const loadSurvey = useCallback(async () => {
if (surveyId) {
loadSurvey();
}
}, [surveyId]);
async function loadSurvey() {
try { try {
const { data, error } = await apiGetSurvey(surveyId!); const { data, error } = await apiGetSurvey(surveyId!);
if (error) throw new Error(error); if (error) throw new Error(error);
@ -98,7 +87,13 @@ export default function SurveyEditorPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, [surveyId, toast]);
useEffect(() => {
if (surveyId) {
loadSurvey();
}
}, [loadSurvey, surveyId]);
function addQuestion(type: Question['type']) { function addQuestion(type: Question['type']) {
const newQuestion: Question = { const newQuestion: Question = {
@ -163,11 +158,19 @@ export default function SurveyEditorPage() {
}; };
if (isEdit) { 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); if (error) throw new Error(error);
toast({ title: 'Success', description: 'Survey updated', variant: 'success' }); toast({ title: 'Success', description: 'Survey updated', variant: 'success' });
} else { } 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); if (error) throw new Error(error);
toast({ title: 'Success', description: 'Survey created', variant: 'success' }); toast({ title: 'Success', description: 'Survey created', variant: 'success' });
} }
@ -205,7 +208,9 @@ export default function SurveyEditorPage() {
<div> <div>
<h1 className="text-3xl font-bold">{isEdit ? 'Edit Survey' : 'New Survey'}</h1> <h1 className="text-3xl font-bold">{isEdit ? 'Edit Survey' : 'New Survey'}</h1>
<p className="text-muted-foreground"> <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> </p>
</div> </div>
</div> </div>
@ -228,7 +233,7 @@ export default function SurveyEditorPage() {
<Input <Input
id="title" id="title"
value={form.title} value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })} onChange={e => setForm({ ...form, title: e.target.value })}
placeholder="Enter survey title" placeholder="Enter survey title"
/> />
</div> </div>
@ -237,7 +242,7 @@ export default function SurveyEditorPage() {
<Textarea <Textarea
id="description" id="description"
value={form.description} value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })} onChange={e => setForm({ ...form, description: e.target.value })}
placeholder="Enter survey description" placeholder="Enter survey description"
rows={2} rows={2}
/> />
@ -249,7 +254,7 @@ export default function SurveyEditorPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Questions ({form.questions.length})</h3> <h3 className="text-lg font-medium">Questions ({form.questions.length})</h3>
<div className="flex gap-2"> <div className="flex gap-2">
{QUESTION_TYPES.map((type) => ( {QUESTION_TYPES.map(type => (
<Button <Button
key={type.id} key={type.id}
variant="outline" variant="outline"
@ -268,7 +273,9 @@ export default function SurveyEditorPage() {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground cursor-move" /> <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> <span className="text-sm text-muted-foreground">Q{index + 1}</span>
<Button <Button
variant="ghost" variant="ghost"
@ -285,7 +292,7 @@ export default function SurveyEditorPage() {
<Label>Question Text *</Label> <Label>Question Text *</Label>
<Input <Input
value={question.text} value={question.text}
onChange={(e) => updateQuestion(index, { text: e.target.value })} onChange={e => updateQuestion(index, { text: e.target.value })}
placeholder="Enter your question" placeholder="Enter your question"
/> />
</div> </div>
@ -293,7 +300,7 @@ export default function SurveyEditorPage() {
<Label>Description (optional)</Label> <Label>Description (optional)</Label>
<Input <Input
value={question.description} 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" placeholder="Additional context for this question"
/> />
</div> </div>
@ -306,7 +313,7 @@ export default function SurveyEditorPage() {
<div key={option.id} className="flex items-center gap-2"> <div key={option.id} className="flex items-center gap-2">
<Input <Input
value={option.text} value={option.text}
onChange={(e) => updateOption(index, optIndex, e.target.value)} onChange={e => updateOption(index, optIndex, e.target.value)}
placeholder={`Option ${optIndex + 1}`} placeholder={`Option ${optIndex + 1}`}
/> />
<Button <Button
@ -351,7 +358,7 @@ export default function SurveyEditorPage() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Display Trigger</Label> <Label>Display Trigger</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{TRIGGERS.map((trigger) => ( {TRIGGERS.map(trigger => (
<Badge <Badge
key={trigger} key={trigger}
variant={form.displayTrigger.type === trigger ? 'default' : 'outline'} variant={form.displayTrigger.type === trigger ? 'default' : 'outline'}
@ -377,7 +384,7 @@ export default function SurveyEditorPage() {
type="number" type="number"
min={0} min={0}
value={form.incentive?.amount || ''} value={form.incentive?.amount || ''}
onChange={(e) => onChange={e =>
setForm({ setForm({
...form, ...form,
incentive: { incentive: {
@ -407,7 +414,7 @@ export default function SurveyEditorPage() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Platforms</Label> <Label>Platforms</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{PLATFORMS.map((platform) => ( {PLATFORMS.map(platform => (
<Badge <Badge
key={platform} key={platform}
variant={form.target.platforms.includes(platform) ? 'default' : 'outline'} variant={form.target.platforms.includes(platform) ? 'default' : 'outline'}
@ -418,7 +425,7 @@ export default function SurveyEditorPage() {
target: { target: {
...form.target, ...form.target,
platforms: form.target.platforms.includes(platform) platforms: form.target.platforms.includes(platform)
? form.target.platforms.filter((p) => p !== platform) ? form.target.platforms.filter(p => p !== platform)
: [...form.target.platforms, platform], : [...form.target.platforms, platform],
}, },
}) })
@ -433,7 +440,7 @@ export default function SurveyEditorPage() {
<div className="space-y-2"> <div className="space-y-2">
<Label>User Segments</Label> <Label>User Segments</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{SEGMENTS.map((segment) => ( {SEGMENTS.map(segment => (
<Badge <Badge
key={segment} key={segment}
variant={form.target.userSegments.includes(segment) ? 'default' : 'outline'} variant={form.target.userSegments.includes(segment) ? 'default' : 'outline'}
@ -444,7 +451,7 @@ export default function SurveyEditorPage() {
target: { target: {
...form.target, ...form.target,
userSegments: form.target.userSegments.includes(segment) userSegments: form.target.userSegments.includes(segment)
? form.target.userSegments.filter((s) => s !== segment) ? form.target.userSegments.filter(s => s !== segment)
: [...form.target.userSegments, segment], : [...form.target.userSegments, segment],
}, },
}) })

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
apiListSurveys, apiListSurveys,
apiDeleteSurvey, apiDeleteSurvey,
@ -11,13 +11,7 @@ import {
type ApiSurveyAnalytics, type ApiSurveyAnalytics,
} from '@/lib/api'; } from '@/lib/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { import {
Table, Table,
TableBody, TableBody,
@ -64,11 +58,7 @@ export default function SurveysPage() {
} | null>(null); } | null>(null);
const { toast } = useToast(); const { toast } = useToast();
useEffect(() => { const loadSurveys = useCallback(async () => {
loadSurveys();
}, []);
async function loadSurveys() {
try { try {
setLoading(true); setLoading(true);
const { data, error } = await apiListSurveys(); const { data, error } = await apiListSurveys();
@ -83,7 +73,11 @@ export default function SurveysPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, [toast]);
useEffect(() => {
loadSurveys();
}, [loadSurveys]);
async function handleDelete(survey: ApiSurvey) { async function handleDelete(survey: ApiSurvey) {
try { try {
@ -149,7 +143,7 @@ export default function SurveysPage() {
async function exportCSV(survey: ApiSurvey) { async function exportCSV(survey: ApiSurvey) {
try { 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 blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@ -168,7 +162,7 @@ export default function SurveysPage() {
} }
const filteredSurveys = surveys.filter( const filteredSurveys = surveys.filter(
(s) => s =>
s.title.toLowerCase().includes(filter.toLowerCase()) || s.title.toLowerCase().includes(filter.toLowerCase()) ||
(s.description?.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 className="flex items-center justify-between">
<div> <div>
<CardTitle>All Surveys</CardTitle> <CardTitle>All Surveys</CardTitle>
<CardDescription> <CardDescription>{surveys.length} total surveys</CardDescription>
{surveys.length} total surveys
</CardDescription>
</div> </div>
<Input <Input
placeholder="Filter surveys..." placeholder="Filter surveys..."
value={filter} value={filter}
onChange={(e) => setFilter(e.target.value)} onChange={e => setFilter(e.target.value)}
className="w-64" className="w-64"
/> />
</div> </div>
@ -232,7 +224,7 @@ export default function SurveysPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredSurveys.map((survey) => ( {filteredSurveys.map(survey => (
<TableRow key={survey.id}> <TableRow key={survey.id}>
<TableCell> <TableCell>
<div className="font-medium">{survey.title}</div> <div className="font-medium">{survey.title}</div>
@ -241,18 +233,19 @@ export default function SurveysPage() {
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge className={statusColors[survey.status]}> <Badge className={statusColors[survey.status]}>{survey.status}</Badge>
{survey.status}
</Badge>
</TableCell>
<TableCell>
{survey.questions.length} questions
</TableCell> </TableCell>
<TableCell>{survey.questions.length} questions</TableCell>
<TableCell> <TableCell>
<div className="text-sm space-y-1"> <div className="text-sm space-y-1">
<div>Starts: {survey.metrics.starts}</div> <div>Starts: {survey.metrics.starts}</div>
<div className="text-muted-foreground"> <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>
</div> </div>
</TableCell> </TableCell>
@ -265,9 +258,7 @@ export default function SurveysPage() {
<span className="text-muted-foreground">None</span> <span className="text-muted-foreground">None</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>{new Date(survey.createdAt).toLocaleDateString()}</TableCell>
{new Date(survey.createdAt).toLocaleDateString()}
</TableCell>
<TableCell> <TableCell>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -326,8 +317,8 @@ export default function SurveysPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>Delete Survey</DialogTitle> <DialogTitle>Delete Survey</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to delete &quot;{deleteDialog?.title}&quot;? This action Are you sure you want to delete &quot;{deleteDialog?.title}&quot;? This action cannot
cannot be undone. be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@ -380,11 +371,11 @@ export default function SurveysPage() {
</CardHeader> </CardHeader>
</Card> </Card>
</div> </div>
{analyticsDialog?.analytics.questionAnalytics.map((qa) => ( {analyticsDialog?.analytics.questionAnalytics.map(qa => (
<Card key={qa.questionId}> <Card key={qa.questionId}>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm"> <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> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>