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';
|
'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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)}>
|
||||||
|
|||||||
@ -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"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 "{deleteDialog?.title}"? This action
|
Are you sure you want to delete "{deleteDialog?.title}"? 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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user