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:
saravanakumardb1 2026-03-03 07:21:37 -08:00
parent cc9129bc60
commit ee3b23711f
4 changed files with 987 additions and 0 deletions

View 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 &quot;{deleteDialog?.title}&quot;? 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>
);
}

View 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 &quot;{deleteDialog?.title}&quot;? 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>
);
}

View File

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

View File

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