From dbc9ffd328267ce12d694d181893d705469e438d Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Tue, 3 Mar 2026 07:28:56 -0800 Subject: [PATCH] feat(admin): Phase 2.2 - Broadcast create/edit wizard --- .../app/(dashboard)/broadcasts/[id]/page.tsx | 523 ++++++++++++++++++ .../admin-web/src/components/ui/checkbox.tsx | 34 ++ .../admin-web/src/components/ui/textarea.tsx | 23 + 3 files changed, 580 insertions(+) create mode 100644 dashboards/admin-web/src/app/(dashboard)/broadcasts/[id]/page.tsx create mode 100644 dashboards/admin-web/src/components/ui/checkbox.tsx create mode 100644 dashboards/admin-web/src/components/ui/textarea.tsx diff --git a/dashboards/admin-web/src/app/(dashboard)/broadcasts/[id]/page.tsx b/dashboards/admin-web/src/app/(dashboard)/broadcasts/[id]/page.tsx new file mode 100644 index 00000000..4e2a3a41 --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/broadcasts/[id]/page.tsx @@ -0,0 +1,523 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { + apiCreateBroadcast, + apiGetBroadcast, + apiUpdateBroadcast, + apiEstimateBroadcastReach, + type ApiBroadcast, +} from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useToast } from '@/components/ui/toast'; +import { ArrowLeft, Users, Loader2 } from 'lucide-react'; +import Link from 'next/link'; + +const CHANNELS = [ + { id: 'push', label: 'Push Notification', description: 'Send to mobile devices' }, + { id: 'in_app', label: 'In-App Message', description: 'Show in app UI' }, + { id: 'email', label: 'Email', description: 'Send email to users' }, +] as const; + +const PLATFORMS = ['ios', 'android', 'web', 'macos', 'windows'] as const; +const SEGMENTS = ['free', 'pro', 'enterprise', 'churned', 'active'] as const; + +export default function BroadcastEditorPage() { + const params = useParams(); + const router = useRouter(); + const { toast } = useToast(); + const isEdit = params.id && params.id !== 'new'; + const broadcastId = isEdit ? (params.id as string) : null; + + const [loading, setLoading] = useState(isEdit); + const [saving, setSaving] = useState(false); + const [estimating, setEstimating] = useState(false); + const [reachEstimate, setReachEstimate] = useState<{ + estimatedCount: number; + sampleUserIds: string[]; + } | null>(null); + + const [form, setForm] = useState({ + title: '', + body: '', + bodyMarkdown: '', + ctaText: '', + ctaUrl: '', + imageUrl: '', + channels: [] as string[], + target: { + platforms: [] as string[], + userSegments: [] as string[], + countryCodes: '', + appVersionMin: '', + appVersionMax: '', + percentageRollout: 100, + specificUserIds: '', + }, + scheduledAt: '', + }); + + useEffect(() => { + if (broadcastId) { + loadBroadcast(); + } + }, [broadcastId]); + + async function loadBroadcast() { + try { + const { data, error } = await apiGetBroadcast(broadcastId!); + if (error) throw new Error(error); + if (data) { + setForm({ + title: data.title, + body: data.body, + bodyMarkdown: data.bodyMarkdown || '', + ctaText: data.ctaText || '', + ctaUrl: data.ctaUrl || '', + imageUrl: data.imageUrl || '', + channels: data.channels, + target: { + platforms: data.target.platforms || [], + userSegments: data.target.userSegments || [], + countryCodes: data.target.countryCodes?.join(', ') || '', + appVersionMin: data.target.appVersionMin || '', + appVersionMax: data.target.appVersionMax || '', + percentageRollout: data.target.percentageRollout ?? 100, + specificUserIds: data.target.specificUserIds?.join(', ') || '', + }, + scheduledAt: data.scheduledAt || '', + }); + } + } catch (err) { + toast({ + title: 'Error', + description: err instanceof Error ? err.message : 'Failed to load broadcast', + variant: 'error', + }); + } finally { + setLoading(false); + } + } + + async function handleEstimateReach() { + setEstimating(true); + try { + const { data, error } = await apiEstimateBroadcastReach({ + platforms: form.target.platforms, + userSegments: form.target.userSegments as ApiBroadcast['target']['userSegments'], + countryCodes: form.target.countryCodes + .split(',') + .map((c) => c.trim()) + .filter(Boolean), + percentageRollout: form.target.percentageRollout, + specificUserIds: form.target.specificUserIds + .split(',') + .map((id) => id.trim()) + .filter(Boolean), + }); + if (error) throw new Error(error); + if (data) setReachEstimate(data); + } catch (err) { + toast({ + title: 'Error', + description: err instanceof Error ? err.message : 'Failed to estimate reach', + variant: 'error', + }); + } finally { + setEstimating(false); + } + } + + async function handleSave(asDraft = false) { + setSaving(true); + try { + const payload = { + ...form, + channels: form.channels as ('push' | 'email' | 'in_app')[], + target: { + ...form.target, + countryCodes: form.target.countryCodes + .split(',') + .map((c) => c.trim()) + .filter(Boolean), + specificUserIds: form.target.specificUserIds + .split(',') + .map((id) => id.trim()) + .filter(Boolean), + }, + status: asDraft ? 'draft' : undefined, + }; + + if (isEdit) { + const { error } = await apiUpdateBroadcast(broadcastId!, payload as unknown as Partial); + if (error) throw new Error(error); + toast({ title: 'Success', description: 'Broadcast updated', variant: 'success' }); + } else { + const { error } = await apiCreateBroadcast(payload as unknown as Omit); + if (error) throw new Error(error); + toast({ title: 'Success', description: 'Broadcast created', variant: 'success' }); + } + router.push('/broadcasts'); + } catch (err) { + toast({ + title: 'Error', + description: err instanceof Error ? err.message : 'Failed to save', + variant: 'error', + }); + } finally { + setSaving(false); + } + } + + if (loading) { + return ( +
+
+
+
+
+
+ ); + } + + return ( +
+
+ + + +
+

+ {isEdit ? 'Edit Broadcast' : 'New Broadcast'} +

+

+ {isEdit ? 'Update your broadcast details' : 'Create a targeted message campaign'} +

+
+
+ + + + Content + Targeting + Channels + + + + + + Message Content + + Craft your broadcast message + + + +
+ + setForm({ ...form, title: e.target.value })} + placeholder="Enter broadcast title" + /> +
+ +
+ +