feat(admin): Phase 2.1 - Broadcast and Survey list pages
- broadcasts/page.tsx: List, filter, delete, send, pause, clone actions - surveys/page.tsx: List, filter, delete, pause, duplicate, analytics, CSV export - api.ts: Add all broadcast and survey API functions with types - sidebar-nav.tsx: Add navigation items with Megaphone and ClipboardList icons
This commit is contained in:
parent
cc9129bc60
commit
ee3b23711f
337
dashboards/admin-web/src/app/(dashboard)/broadcasts/page.tsx
Normal file
337
dashboards/admin-web/src/app/(dashboard)/broadcasts/page.tsx
Normal file
@ -0,0 +1,337 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
apiListBroadcasts,
|
||||
apiDeleteBroadcast,
|
||||
apiSendBroadcast,
|
||||
apiPauseBroadcast,
|
||||
apiCloneBroadcast,
|
||||
type ApiBroadcast,
|
||||
} from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { MoreHorizontal, Plus, Send, Pause, Copy, Trash2, BarChart3 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
|
||||
const statusColors: Record<ApiBroadcast['status'], string> = {
|
||||
draft: 'bg-gray-500',
|
||||
scheduled: 'bg-blue-500',
|
||||
sending: 'bg-yellow-500',
|
||||
sent: 'bg-green-500',
|
||||
paused: 'bg-orange-500',
|
||||
};
|
||||
|
||||
export default function BroadcastsPage() {
|
||||
const [broadcasts, setBroadcasts] = useState<ApiBroadcast[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [deleteDialog, setDeleteDialog] = useState<ApiBroadcast | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadBroadcasts();
|
||||
}, []);
|
||||
|
||||
async function loadBroadcasts() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data, error } = await apiListBroadcasts();
|
||||
if (error) throw new Error(error);
|
||||
setBroadcasts(data?.broadcasts ?? []);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err instanceof Error ? err.message : 'Failed to load broadcasts',
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(broadcast: ApiBroadcast) {
|
||||
try {
|
||||
const { error } = await apiDeleteBroadcast(broadcast.id);
|
||||
if (error) throw new Error(error);
|
||||
toast({ title: 'Success', description: 'Broadcast deleted', variant: 'success' });
|
||||
loadBroadcasts();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err instanceof Error ? err.message : 'Failed to delete',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
setDeleteDialog(null);
|
||||
}
|
||||
|
||||
async function handleSend(id: string) {
|
||||
try {
|
||||
const { error } = await apiSendBroadcast(id);
|
||||
if (error) throw new Error(error);
|
||||
toast({ title: 'Success', description: 'Broadcast sending started', variant: 'success' });
|
||||
loadBroadcasts();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err instanceof Error ? err.message : 'Failed to send',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePause(id: string) {
|
||||
try {
|
||||
const { error } = await apiPauseBroadcast(id);
|
||||
if (error) throw new Error(error);
|
||||
toast({ title: 'Success', description: 'Broadcast paused', variant: 'success' });
|
||||
loadBroadcasts();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err instanceof Error ? err.message : 'Failed to pause',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClone(broadcast: ApiBroadcast, variant?: 'control' | 'treatment') {
|
||||
try {
|
||||
const { error } = await apiCloneBroadcast(broadcast.id, variant);
|
||||
if (error) throw new Error(error);
|
||||
toast({ title: 'Success', description: 'Broadcast cloned', variant: 'success' });
|
||||
loadBroadcasts();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err instanceof Error ? err.message : 'Failed to clone',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filteredBroadcasts = broadcasts.filter(
|
||||
(b) =>
|
||||
b.title.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
b.body.toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 w-48 bg-muted rounded" />
|
||||
<div className="h-64 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Broadcasts</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage targeted messaging campaigns across all channels
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/broadcasts/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Broadcast
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>All Broadcasts</CardTitle>
|
||||
<CardDescription>
|
||||
{broadcasts.length} total broadcasts
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Filter broadcasts..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Channels</TableHead>
|
||||
<TableHead>Target</TableHead>
|
||||
<TableHead>Metrics</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-12" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredBroadcasts.map((broadcast) => (
|
||||
<TableRow key={broadcast.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{broadcast.title}</div>
|
||||
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||
{broadcast.body}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={statusColors[broadcast.status]}>
|
||||
{broadcast.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{broadcast.channels.map((ch) => (
|
||||
<Badge key={ch} variant="outline">
|
||||
{ch}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
{broadcast.target.platforms?.join(', ') || 'All platforms'}
|
||||
</div>
|
||||
{broadcast.target.percentageRollout && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{broadcast.target.percentageRollout}% rollout
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm space-y-1">
|
||||
<div>Sent: {broadcast.metrics.sentCount}</div>
|
||||
<div className="text-muted-foreground">
|
||||
Opened: {broadcast.metrics.openedCount} | Clicked:{' '}
|
||||
{broadcast.metrics.clickedCount}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(broadcast.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/broadcasts/${broadcast.id}`}>Edit</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/broadcasts/${broadcast.id}/metrics`}>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
View Metrics
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{broadcast.status === 'draft' && (
|
||||
<DropdownMenuItem onClick={() => handleSend(broadcast.id)}>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Send Now
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{broadcast.status === 'sending' && (
|
||||
<DropdownMenuItem onClick={() => handlePause(broadcast.id)}>
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
Pause
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleClone(broadcast, 'treatment')}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Clone (A/B Test)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setDeleteDialog(broadcast)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredBroadcasts.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
{filter ? 'No broadcasts match your filter' : 'No broadcasts yet'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={!!deleteDialog} onOpenChange={() => setDeleteDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Broadcast</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{deleteDialog?.title}"? This action
|
||||
cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialog(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteDialog && handleDelete(deleteDialog)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
403
dashboards/admin-web/src/app/(dashboard)/surveys/page.tsx
Normal file
403
dashboards/admin-web/src/app/(dashboard)/surveys/page.tsx
Normal file
@ -0,0 +1,403 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
apiListSurveys,
|
||||
apiDeleteSurvey,
|
||||
apiPauseSurvey,
|
||||
apiDuplicateSurvey,
|
||||
apiGetSurveyAnalytics,
|
||||
type ApiSurvey,
|
||||
type ApiSurveyAnalytics,
|
||||
} from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { MoreHorizontal, Plus, Pause, Copy, Trash2, BarChart3, Download } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
|
||||
const statusColors: Record<ApiSurvey['status'], string> = {
|
||||
draft: 'bg-gray-500',
|
||||
active: 'bg-green-500',
|
||||
paused: 'bg-orange-500',
|
||||
closed: 'bg-red-500',
|
||||
};
|
||||
|
||||
export default function SurveysPage() {
|
||||
const [surveys, setSurveys] = useState<ApiSurvey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [deleteDialog, setDeleteDialog] = useState<ApiSurvey | null>(null);
|
||||
const [analyticsDialog, setAnalyticsDialog] = useState<{
|
||||
survey: ApiSurvey;
|
||||
analytics: ApiSurveyAnalytics;
|
||||
} | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadSurveys();
|
||||
}, []);
|
||||
|
||||
async function loadSurveys() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data, error } = await apiListSurveys();
|
||||
if (error) throw new Error(error);
|
||||
setSurveys(data?.surveys ?? []);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err instanceof Error ? err.message : 'Failed to load surveys',
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(survey: ApiSurvey) {
|
||||
try {
|
||||
const { error } = await apiDeleteSurvey(survey.id);
|
||||
if (error) throw new Error(error);
|
||||
toast({ title: 'Success', description: 'Survey deleted', variant: 'success' });
|
||||
loadSurveys();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err instanceof Error ? err.message : 'Failed to delete',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
setDeleteDialog(null);
|
||||
}
|
||||
|
||||
async function handlePause(id: string) {
|
||||
try {
|
||||
const { error } = await apiPauseSurvey(id);
|
||||
if (error) throw new Error(error);
|
||||
toast({ title: 'Success', description: 'Survey paused', variant: 'success' });
|
||||
loadSurveys();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err instanceof Error ? err.message : 'Failed to pause',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicate(survey: ApiSurvey) {
|
||||
try {
|
||||
const { error } = await apiDuplicateSurvey(survey.id);
|
||||
if (error) throw new Error(error);
|
||||
toast({ title: 'Success', description: 'Survey duplicated', variant: 'success' });
|
||||
loadSurveys();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err instanceof Error ? err.message : 'Failed to duplicate',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function viewAnalytics(survey: ApiSurvey) {
|
||||
try {
|
||||
const { data, error } = await apiGetSurveyAnalytics(survey.id);
|
||||
if (error) throw new Error(error);
|
||||
if (data) {
|
||||
setAnalyticsDialog({ survey, analytics: data });
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err instanceof Error ? err.message : 'Failed to load analytics',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCSV(survey: ApiSurvey) {
|
||||
try {
|
||||
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');
|
||||
a.href = url;
|
||||
a.download = `${survey.id}_responses.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast({ title: 'Success', description: 'CSV exported', variant: 'success' });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err instanceof Error ? err.message : 'Failed to export',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSurveys = surveys.filter(
|
||||
(s) =>
|
||||
s.title.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(s.description?.toLowerCase() || '').includes(filter.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 w-48 bg-muted rounded" />
|
||||
<div className="h-64 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Surveys</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create and manage in-app surveys with conditional logic
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/surveys/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Survey
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>All Surveys</CardTitle>
|
||||
<CardDescription>
|
||||
{surveys.length} total surveys
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Filter surveys..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Questions</TableHead>
|
||||
<TableHead>Metrics</TableHead>
|
||||
<TableHead>Incentive</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-12" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredSurveys.map((survey) => (
|
||||
<TableRow key={survey.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{survey.title}</div>
|
||||
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||
{survey.description || 'No description'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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)}%
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{survey.incentive ? (
|
||||
<Badge variant="outline">
|
||||
{survey.incentive.amount} {survey.incentive.type}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">None</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(survey.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/surveys/${survey.id}`}>Edit</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => viewAnalytics(survey)}>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
View Analytics
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => exportCSV(survey)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export CSV
|
||||
</DropdownMenuItem>
|
||||
{survey.status === 'active' && (
|
||||
<DropdownMenuItem onClick={() => handlePause(survey.id)}>
|
||||
<Pause className="mr-2 h-4 w-4" />
|
||||
Pause
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => handleDuplicate(survey)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setDeleteDialog(survey)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredSurveys.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
{filter ? 'No surveys match your filter' : 'No surveys yet'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={!!deleteDialog} onOpenChange={() => setDeleteDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Survey</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{deleteDialog?.title}"? This action
|
||||
cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialog(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteDialog && handleDelete(deleteDialog)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!analyticsDialog} onOpenChange={() => setAnalyticsDialog(null)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Survey Analytics: {analyticsDialog?.survey.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{analyticsDialog?.analytics.totalStarts} starts,{' '}
|
||||
{analyticsDialog?.analytics.totalCompletions} completions
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-2xl">
|
||||
{analyticsDialog?.analytics.completionRate.toFixed(1)}%
|
||||
</CardTitle>
|
||||
<CardDescription>Completion Rate</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-2xl">
|
||||
{Math.round(analyticsDialog?.analytics.avgTimeSeconds || 0)}s
|
||||
</CardTitle>
|
||||
<CardDescription>Avg. Time</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-2xl">
|
||||
{analyticsDialog?.survey.metrics.incentiveClaims}
|
||||
</CardTitle>
|
||||
<CardDescription>Incentives Claimed</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
{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}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{qa.totalResponses} responses
|
||||
{qa.average !== undefined && ` • Avg: ${qa.average.toFixed(2)}`}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -29,6 +29,8 @@ import {
|
||||
FileText,
|
||||
Shield,
|
||||
MessageSquare,
|
||||
Megaphone,
|
||||
ClipboardList,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
@ -45,6 +47,8 @@ const navItems = [
|
||||
{ href: '/invitations', label: 'Invitations', icon: Ticket },
|
||||
{ href: '/promos', label: 'Promo Codes', icon: Tag },
|
||||
{ href: '/referrals', label: 'Referrals', icon: Gift },
|
||||
{ href: '/broadcasts', label: 'Broadcasts', icon: Megaphone },
|
||||
{ href: '/surveys', label: 'Surveys', icon: ClipboardList },
|
||||
{ href: '/themes', label: 'Themes', icon: Palette },
|
||||
{ href: '/billing', label: 'Billing', icon: Wallet },
|
||||
{ href: '/products', label: 'Products', icon: Package },
|
||||
|
||||
@ -6,6 +6,11 @@
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
function getBaseUrl(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
if (typeof window === 'undefined') return {};
|
||||
const headers: Record<string, string> = {};
|
||||
@ -539,3 +544,241 @@ export async function apiSeed(secret: string) {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// ── Broadcasts ─────────────────────────────────────────────────────
|
||||
|
||||
export interface ApiBroadcast {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
bodyMarkdown?: string;
|
||||
ctaText?: string;
|
||||
ctaUrl?: string;
|
||||
imageUrl?: string;
|
||||
target: {
|
||||
userSegments?: string[];
|
||||
platforms?: string[];
|
||||
appVersionMin?: string;
|
||||
appVersionMax?: string;
|
||||
countryCodes?: string[];
|
||||
regionCodes?: string[];
|
||||
osVersionMin?: string;
|
||||
osVersionMax?: string;
|
||||
percentageRollout?: number;
|
||||
specificUserIds?: string[];
|
||||
};
|
||||
channels: ('push' | 'in_app' | 'email')[];
|
||||
status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'paused';
|
||||
scheduledAt?: string;
|
||||
sentAt?: string;
|
||||
variant?: 'control' | 'treatment';
|
||||
experimentId?: string;
|
||||
parentBroadcastId?: string;
|
||||
metrics: {
|
||||
targetedCount: number;
|
||||
sentCount: number;
|
||||
deliveredCount: number;
|
||||
openedCount: number;
|
||||
clickedCount: number;
|
||||
dismissedCount: number;
|
||||
convertedCount: number;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export async function apiListBroadcasts(status?: ApiBroadcast['status']) {
|
||||
const params = status ? `?status=${status}` : '';
|
||||
return apiFetch<{ broadcasts: ApiBroadcast[]; total: number }>(`/admin/broadcasts${params}`);
|
||||
}
|
||||
|
||||
export async function apiGetBroadcast(id: string) {
|
||||
return apiFetch<ApiBroadcast>(`/admin/broadcasts/${id}`);
|
||||
}
|
||||
|
||||
export async function apiCreateBroadcast(body: Omit<ApiBroadcast, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>) {
|
||||
return apiFetch<ApiBroadcast>('/admin/broadcasts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiUpdateBroadcast(id: string, body: Partial<ApiBroadcast>) {
|
||||
return apiFetch<ApiBroadcast>(`/admin/broadcasts/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiDeleteBroadcast(id: string) {
|
||||
return apiFetch<{ success: boolean }>(`/admin/broadcasts/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function apiCloneBroadcast(id: string, variant?: 'control' | 'treatment') {
|
||||
return apiFetch<ApiBroadcast>(`/admin/broadcasts/${id}/clone`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ variant }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiSendBroadcast(id: string) {
|
||||
return apiFetch<{ success: boolean }>(`/admin/broadcasts/${id}/send`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function apiPauseBroadcast(id: string) {
|
||||
return apiFetch<{ success: boolean }>(`/admin/broadcasts/${id}/pause`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function apiGetBroadcastMetrics(id: string) {
|
||||
return apiFetch<{
|
||||
broadcastId: string;
|
||||
metrics: ApiBroadcast['metrics'];
|
||||
status: ApiBroadcast['status'];
|
||||
}>(`/admin/broadcasts/${id}/metrics`);
|
||||
}
|
||||
|
||||
export async function apiEstimateBroadcastReach(target: ApiBroadcast['target']) {
|
||||
return apiFetch<{
|
||||
estimatedCount: number;
|
||||
targetBreakdown: {
|
||||
userSegments: Record<string, number>;
|
||||
platforms: Record<string, number>;
|
||||
countries: Record<string, number>;
|
||||
};
|
||||
sampleUserIds: string[];
|
||||
}>('/admin/broadcasts/estimate-reach', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(target),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Surveys ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ApiSurvey {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
questions: {
|
||||
id: string;
|
||||
type: 'single_choice' | 'multiple_choice' | 'rating' | 'nps' | 'text_short' | 'text_long' | 'dropdown' | 'scale' | 'ranking';
|
||||
text: string;
|
||||
description?: string;
|
||||
required: boolean;
|
||||
options?: { id: string; text: string; emoji?: string }[];
|
||||
showIf?: unknown;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
}[];
|
||||
target: ApiBroadcast['target'];
|
||||
status: 'draft' | 'active' | 'paused' | 'closed';
|
||||
startsAt?: string;
|
||||
endsAt?: string;
|
||||
displayTrigger: { type: 'immediate' } | { type: 'delay_seconds'; seconds: number } | { type: 'event'; eventName: string } | { type: 'page_view'; pagePattern: string };
|
||||
incentive?: { type: 'pro_days' | 'credits'; amount: number };
|
||||
metrics: {
|
||||
impressions: number;
|
||||
starts: number;
|
||||
completions: number;
|
||||
avgTimeSeconds: number;
|
||||
incentiveClaims: number;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface ApiSurveyResponse {
|
||||
id: string;
|
||||
surveyId: string;
|
||||
userId: string;
|
||||
answers: Record<string, unknown>;
|
||||
currentQuestionIndex: number;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
isComplete: boolean;
|
||||
incentiveClaimed: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ApiSurveyAnalytics {
|
||||
totalStarts: number;
|
||||
totalCompletions: number;
|
||||
completionRate: number;
|
||||
avgTimeSeconds: number;
|
||||
questionAnalytics: {
|
||||
questionId: string;
|
||||
questionType: string;
|
||||
totalResponses: number;
|
||||
optionCounts?: Record<string, number>;
|
||||
average?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
distribution?: Record<number, number>;
|
||||
sampleResponses?: string[];
|
||||
}[];
|
||||
computedMetrics: {
|
||||
completionRate: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function apiListSurveys(status?: ApiSurvey['status']) {
|
||||
const params = status ? `?status=${status}` : '';
|
||||
return apiFetch<{ surveys: ApiSurvey[]; total: number }>(`/admin/surveys${params}`);
|
||||
}
|
||||
|
||||
export async function apiGetSurvey(id: string) {
|
||||
return apiFetch<ApiSurvey>(`/admin/surveys/${id}`);
|
||||
}
|
||||
|
||||
export async function apiCreateSurvey(body: Omit<ApiSurvey, 'id' | 'metrics' | 'createdAt' | 'updatedAt' | 'createdBy'>) {
|
||||
return apiFetch<ApiSurvey>('/admin/surveys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiUpdateSurvey(id: string, body: Partial<ApiSurvey>) {
|
||||
return apiFetch<ApiSurvey>(`/admin/surveys/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiDeleteSurvey(id: string) {
|
||||
return apiFetch<{ success: boolean }>(`/admin/surveys/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function apiDuplicateSurvey(id: string) {
|
||||
return apiFetch<ApiSurvey>(`/admin/surveys/${id}/duplicate`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function apiPauseSurvey(id: string) {
|
||||
return apiFetch<{ success: boolean }>(`/admin/surveys/${id}/pause`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function apiGetSurveyResponses(id: string, options?: { isComplete?: boolean; limit?: number; offset?: number }) {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.isComplete !== undefined) params.set('isComplete', String(options.isComplete));
|
||||
if (options?.limit) params.set('limit', String(options.limit));
|
||||
if (options?.offset) params.set('offset', String(options.offset));
|
||||
return apiFetch<{ responses: ApiSurveyResponse[]; total: number }>(`/admin/surveys/${id}/responses?${params}`);
|
||||
}
|
||||
|
||||
export async function apiGetSurveyRespondents(id: string) {
|
||||
return apiFetch<{ userIds: string[]; count: number }>(`/admin/surveys/${id}/respondents`);
|
||||
}
|
||||
|
||||
export async function apiGetSurveyAnalytics(id: string) {
|
||||
return apiFetch<ApiSurveyAnalytics>(`/admin/surveys/${id}/analytics`);
|
||||
}
|
||||
|
||||
export async function apiExportSurveyCSV(id: string): Promise<string> {
|
||||
const res = await fetch(`${getBaseUrl()}/admin/surveys/${id}/export.csv`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Export failed: ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user