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';
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,7 +424,8 @@ function VariantCard({
Control
</Badge>
)}
{variant.bayesianResults?.probabilityBeatsControl && variant.bayesianResults.probabilityBeatsControl > 0.95 && (
{variant.bayesianResults?.probabilityBeatsControl &&
variant.bayesianResults.probabilityBeatsControl > 0.95 && (
<Badge className="bg-green-500 text-white">
<Trophy className="h-3 w-3 mr-1" />
Winner
@ -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>
);
}

View File

@ -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>

View File

@ -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={
<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

View File

@ -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)}>

View File

@ -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"
/>
) : (

View File

@ -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],
},
})

View File

@ -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 &quot;{deleteDialog?.title}&quot;? This action
cannot be undone.
Are you sure you want to delete &quot;{deleteDialog?.title}&quot;? 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>