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,
|
FileText,
|
||||||
Shield,
|
Shield,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Megaphone,
|
||||||
|
ClipboardList,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
@ -45,6 +47,8 @@ const navItems = [
|
|||||||
{ href: '/invitations', label: 'Invitations', icon: Ticket },
|
{ href: '/invitations', label: 'Invitations', icon: Ticket },
|
||||||
{ href: '/promos', label: 'Promo Codes', icon: Tag },
|
{ href: '/promos', label: 'Promo Codes', icon: Tag },
|
||||||
{ href: '/referrals', label: 'Referrals', icon: Gift },
|
{ 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: '/themes', label: 'Themes', icon: Palette },
|
||||||
{ href: '/billing', label: 'Billing', icon: Wallet },
|
{ href: '/billing', label: 'Billing', icon: Wallet },
|
||||||
{ href: '/products', label: 'Products', icon: Package },
|
{ href: '/products', label: 'Products', icon: Package },
|
||||||
|
|||||||
@ -6,6 +6,11 @@
|
|||||||
|
|
||||||
const API_BASE = '/api';
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
function getBaseUrl(): string {
|
||||||
|
if (typeof window === 'undefined') return '';
|
||||||
|
return window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
function getAuthHeaders(): HeadersInit {
|
function getAuthHeaders(): HeadersInit {
|
||||||
if (typeof window === 'undefined') return {};
|
if (typeof window === 'undefined') return {};
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
@ -539,3 +544,241 @@ export async function apiSeed(secret: string) {
|
|||||||
method: 'POST',
|
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