From ee3b23711f3e16ba47a0a2412988437273eeab69 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Tue, 3 Mar 2026 07:21:37 -0800 Subject: [PATCH] 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 --- .../src/app/(dashboard)/broadcasts/page.tsx | 337 +++++++++++++++ .../src/app/(dashboard)/surveys/page.tsx | 403 ++++++++++++++++++ .../admin-web/src/components/sidebar-nav.tsx | 4 + dashboards/admin-web/src/lib/api.ts | 243 +++++++++++ 4 files changed, 987 insertions(+) create mode 100644 dashboards/admin-web/src/app/(dashboard)/broadcasts/page.tsx create mode 100644 dashboards/admin-web/src/app/(dashboard)/surveys/page.tsx diff --git a/dashboards/admin-web/src/app/(dashboard)/broadcasts/page.tsx b/dashboards/admin-web/src/app/(dashboard)/broadcasts/page.tsx new file mode 100644 index 00000000..0e957ca3 --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/broadcasts/page.tsx @@ -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 = { + 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([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState(''); + const [deleteDialog, setDeleteDialog] = useState(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 ( +
+
+
+
+
+
+ ); + } + + return ( +
+
+
+

Broadcasts

+

+ Manage targeted messaging campaigns across all channels +

+
+ + + +
+ + + +
+
+ All Broadcasts + + {broadcasts.length} total broadcasts + +
+ setFilter(e.target.value)} + className="w-64" + /> +
+
+ + + + + Title + Status + Channels + Target + Metrics + Created + + + + + {filteredBroadcasts.map((broadcast) => ( + + +
{broadcast.title}
+
+ {broadcast.body} +
+
+ + + {broadcast.status} + + + +
+ {broadcast.channels.map((ch) => ( + + {ch} + + ))} +
+
+ +
+ {broadcast.target.platforms?.join(', ') || 'All platforms'} +
+ {broadcast.target.percentageRollout && ( +
+ {broadcast.target.percentageRollout}% rollout +
+ )} +
+ +
+
Sent: {broadcast.metrics.sentCount}
+
+ Opened: {broadcast.metrics.openedCount} | Clicked:{' '} + {broadcast.metrics.clickedCount} +
+
+
+ + {new Date(broadcast.createdAt).toLocaleDateString()} + + + + + + + + + Edit + + + + + View Metrics + + + {broadcast.status === 'draft' && ( + handleSend(broadcast.id)}> + + Send Now + + )} + {broadcast.status === 'sending' && ( + handlePause(broadcast.id)}> + + Pause + + )} + handleClone(broadcast, 'treatment')} + > + + Clone (A/B Test) + + setDeleteDialog(broadcast)} + > + + Delete + + + + +
+ ))} + {filteredBroadcasts.length === 0 && ( + + + {filter ? 'No broadcasts match your filter' : 'No broadcasts yet'} + + + )} +
+
+
+
+ + setDeleteDialog(null)}> + + + Delete Broadcast + + Are you sure you want to delete "{deleteDialog?.title}"? This action + cannot be undone. + + + + + + + + +
+ ); +} diff --git a/dashboards/admin-web/src/app/(dashboard)/surveys/page.tsx b/dashboards/admin-web/src/app/(dashboard)/surveys/page.tsx new file mode 100644 index 00000000..653b123c --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/surveys/page.tsx @@ -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 = { + draft: 'bg-gray-500', + active: 'bg-green-500', + paused: 'bg-orange-500', + closed: 'bg-red-500', +}; + +export default function SurveysPage() { + const [surveys, setSurveys] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState(''); + const [deleteDialog, setDeleteDialog] = useState(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 ( +
+
+
+
+
+
+ ); + } + + return ( +
+
+
+

Surveys

+

+ Create and manage in-app surveys with conditional logic +

+
+ + + +
+ + + +
+
+ All Surveys + + {surveys.length} total surveys + +
+ setFilter(e.target.value)} + className="w-64" + /> +
+
+ + + + + Title + Status + Questions + Metrics + Incentive + Created + + + + + {filteredSurveys.map((survey) => ( + + +
{survey.title}
+
+ {survey.description || 'No description'} +
+
+ + + {survey.status} + + + + {survey.questions.length} questions + + +
+
Starts: {survey.metrics.starts}
+
+ Completion: {((survey.metrics.completions / Math.max(survey.metrics.starts, 1)) * 100).toFixed(1)}% +
+
+
+ + {survey.incentive ? ( + + {survey.incentive.amount} {survey.incentive.type} + + ) : ( + None + )} + + + {new Date(survey.createdAt).toLocaleDateString()} + + + + + + + + + Edit + + viewAnalytics(survey)}> + + View Analytics + + exportCSV(survey)}> + + Export CSV + + {survey.status === 'active' && ( + handlePause(survey.id)}> + + Pause + + )} + handleDuplicate(survey)}> + + Duplicate + + setDeleteDialog(survey)} + > + + Delete + + + + +
+ ))} + {filteredSurveys.length === 0 && ( + + + {filter ? 'No surveys match your filter' : 'No surveys yet'} + + + )} +
+
+
+
+ + setDeleteDialog(null)}> + + + Delete Survey + + Are you sure you want to delete "{deleteDialog?.title}"? This action + cannot be undone. + + + + + + + + + + setAnalyticsDialog(null)}> + + + Survey Analytics: {analyticsDialog?.survey.title} + + {analyticsDialog?.analytics.totalStarts} starts,{' '} + {analyticsDialog?.analytics.totalCompletions} completions + + +
+
+ + + + {analyticsDialog?.analytics.completionRate.toFixed(1)}% + + Completion Rate + + + + + + {Math.round(analyticsDialog?.analytics.avgTimeSeconds || 0)}s + + Avg. Time + + + + + + {analyticsDialog?.survey.metrics.incentiveClaims} + + Incentives Claimed + + +
+ {analyticsDialog?.analytics.questionAnalytics.map((qa) => ( + + + + Q: {analyticsDialog.survey.questions.find((q) => q.id === qa.questionId)?.text} + + + +
+ {qa.totalResponses} responses + {qa.average !== undefined && ` • Avg: ${qa.average.toFixed(2)}`} +
+
+
+ ))} +
+
+
+
+ ); +} diff --git a/dashboards/admin-web/src/components/sidebar-nav.tsx b/dashboards/admin-web/src/components/sidebar-nav.tsx index 829da9be..42978547 100644 --- a/dashboards/admin-web/src/components/sidebar-nav.tsx +++ b/dashboards/admin-web/src/components/sidebar-nav.tsx @@ -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 }, diff --git a/dashboards/admin-web/src/lib/api.ts b/dashboards/admin-web/src/lib/api.ts index 410308ee..6a1b6d40 100644 --- a/dashboards/admin-web/src/lib/api.ts +++ b/dashboards/admin-web/src/lib/api.ts @@ -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 = {}; @@ -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(`/admin/broadcasts/${id}`); +} + +export async function apiCreateBroadcast(body: Omit) { + return apiFetch('/admin/broadcasts', { + method: 'POST', + body: JSON.stringify(body), + }); +} + +export async function apiUpdateBroadcast(id: string, body: Partial) { + return apiFetch(`/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(`/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; + platforms: Record; + countries: Record; + }; + 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; + 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; + average?: number; + min?: number; + max?: number; + distribution?: Record; + 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(`/admin/surveys/${id}`); +} + +export async function apiCreateSurvey(body: Omit) { + return apiFetch('/admin/surveys', { + method: 'POST', + body: JSON.stringify(body), + }); +} + +export async function apiUpdateSurvey(id: string, body: Partial) { + return apiFetch(`/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(`/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(`/admin/surveys/${id}/analytics`); +} + +export async function apiExportSurveyCSV(id: string): Promise { + 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(); +}