'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { Plus, Trash2, Loader2, RefreshCw, AlertCircle, X, Pencil, RotateCcw, Upload, } from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; import { uploadAccountMedia, MEDIA_MAX_BYTES_BY_KIND, } from '@/lib/storage/upload-media'; import { useAuth } from '@/hooks/use-auth'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; import { SettingsPanelHead } from './settings-panel-head'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import type { MessageTemplate, TemplateButton, TemplateSampleValues, } from '@/types'; import { templateStatusConfig } from '@/lib/template-status'; import { extractVariableIndices, TEMPLATE_LIMITS, } from '@/lib/whatsapp/template-validators'; const CATEGORIES = ['Marketing', 'Utility', 'Authentication'] as const; type HeaderFormat = 'none' | 'text' | 'image' | 'video' | 'document'; const HEADER_FORMATS: HeaderFormat[] = ['none', 'text', 'image', 'video', 'document']; const categoryColors: Record = { Marketing: 'bg-purple-600/20 text-purple-400 border-purple-600/30', Utility: 'bg-blue-600/20 text-blue-400 border-blue-600/30', Authentication: 'bg-amber-600/20 text-amber-400 border-amber-600/30', }; interface TemplateFormData { name: string; category: MessageTemplate['category']; language: string; header_format: HeaderFormat; header_content: string; header_media_url: string; header_sample: string; body_text: string; body_samples: string[]; footer_text: string; buttons: TemplateButton[]; } const emptyForm: TemplateFormData = { name: '', category: 'Marketing', language: 'en_US', header_format: 'none', header_content: '', header_media_url: '', header_sample: '', body_text: '', body_samples: [], footer_text: '', buttons: [], }; const COMMON_LANGUAGE_CODES = [ 'en_US', 'en_GB', 'en', 'es', 'es_ES', 'es_MX', 'fr', 'fr_FR', 'de', 'it', 'pt_BR', 'pt_PT', 'nl', 'pl', 'ru', 'tr', 'lt', ]; function emptyButton(type: TemplateButton['type']): TemplateButton { switch (type) { case 'QUICK_REPLY': return { type: 'QUICK_REPLY', text: '' }; case 'URL': return { type: 'URL', text: '', url: '' }; case 'PHONE_NUMBER': return { type: 'PHONE_NUMBER', text: '', phone_number: '' }; case 'COPY_CODE': return { type: 'COPY_CODE', text: '', example: '' }; } } export function TemplateManager() { const supabase = createClient(); const { user, loading: authLoading } = useAuth(); const [loading, setLoading] = useState(true); const [templates, setTemplates] = useState([]); const [dialogOpen, setDialogOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [syncing, setSyncing] = useState(false); const [form, setForm] = useState(emptyForm); // Non-null when the dialog is editing an existing row — switches the // submit handler from POST /submit to PATCH /[id] and changes the // dialog title + CTA. Set to the template id to pre-fill from a row. const [editingId, setEditingId] = useState(null); const [deletingId, setDeletingId] = useState(null); // Template selected for the confirm-delete dialog. The destructive // action goes through this two-step so a slip on the trash icon // doesn't take the template off Meta as well as locally. const [templateToDelete, setTemplateToDelete] = useState(null); // Header-image upload (issue #230). Uploads to the account-scoped // chat-media bucket and stores the public URL in header_media_url; the // submit route turns that into a Meta Resumable-Upload handle. const [uploadingHeader, setUploadingHeader] = useState(false); const headerFileRef = useRef(null); // Body variable indices — `[1, 2, 3]` for "{{1}} {{2}} {{3}}". We // re-run the extractor on every render to keep the sample-value rows // in sync with what the user typed. const bodyVarCount = useMemo( () => extractVariableIndices(form.body_text).length, [form.body_text], ); const headerVarCount = useMemo( () => form.header_format === 'text' ? extractVariableIndices(form.header_content).length : 0, [form.header_format, form.header_content], ); // Resize body_samples so it always has exactly bodyVarCount entries. // (We mutate via setForm in an effect so React owns the state.) useEffect(() => { setForm((prev) => { if (prev.body_samples.length === bodyVarCount) return prev; const next = prev.body_samples.slice(0, bodyVarCount); while (next.length < bodyVarCount) next.push(''); return { ...prev, body_samples: next }; }); }, [bodyVarCount]); useEffect(() => { if (authLoading) return; if (!user) { setLoading(false); return; } fetchTemplates(user.id); // eslint-disable-next-line react-hooks/exhaustive-deps }, [authLoading, user?.id]); async function fetchTemplates(userId: string) { try { setLoading(true); const { data, error } = await supabase .from('message_templates') .select('*') .eq('user_id', userId) .order('created_at', { ascending: false }); if (error) throw error; setTemplates(data || []); } catch (err) { console.error('Failed to fetch templates:', err); toast.error('Failed to load templates'); } finally { setLoading(false); } } function buildSubmitPayload() { const sample_values: TemplateSampleValues = {}; if (form.body_samples.some((v) => v.trim())) { sample_values.body = form.body_samples.map((v) => v.trim()); } if (form.header_format === 'text' && form.header_sample.trim()) { sample_values.header = [form.header_sample.trim()]; } return { name: form.name.trim(), category: form.category, language: form.language.trim() || 'en_US', header_type: form.header_format === 'none' ? undefined : form.header_format, header_content: form.header_format === 'text' ? form.header_content.trim() : undefined, header_media_url: form.header_format !== 'none' && form.header_format !== 'text' ? form.header_media_url.trim() || undefined : undefined, body_text: form.body_text.trim(), footer_text: form.footer_text.trim() || undefined, buttons: form.buttons.length > 0 ? form.buttons : undefined, sample_values: Object.keys(sample_values).length > 0 ? sample_values : undefined, }; } function openEdit(template: MessageTemplate) { setEditingId(template.id); setForm({ name: template.name, category: template.category, language: template.language || 'en_US', header_format: (template.header_type ?? 'none') as HeaderFormat, header_content: template.header_content ?? '', header_media_url: template.header_media_url ?? '', header_sample: template.sample_values?.header?.[0] ?? '', body_text: template.body_text, body_samples: template.sample_values?.body ?? [], footer_text: template.footer_text ?? '', buttons: template.buttons ?? [], }); setDialogOpen(true); } function openCreate() { setEditingId(null); setForm(emptyForm); setDialogOpen(true); } async function handleSubmit() { // AUTHENTICATION is blocked by the persistent banner + disabled // submit button; this is a defensive second line of defense. if (form.category === 'Authentication') return; try { setSubmitting(true); const isEdit = editingId !== null; const url = isEdit ? `/api/whatsapp/templates/${editingId}` : '/api/whatsapp/templates/submit'; const res = await fetch(url, { method: isEdit ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(buildSubmitPayload()), }); const data = await res.json(); if (!res.ok) { throw new Error( data?.error || `${isEdit ? 'Edit' : 'Submit'} failed (HTTP ${res.status})`, ); } // Refresh first, then close — re-opening the dialog // immediately should not show a stale list. if (user) await fetchTemplates(user.id); toast.success( data.dry_run ? isEdit ? 'Template updated (dry-run — no Meta call)' : 'Template saved (dry-run — no Meta call)' : isEdit ? 'Edit submitted — Meta typically reviews within 24 hours.' : 'Submitted to Meta — typical review time is 24 hours. Status updates automatically.', ); setDialogOpen(false); setForm(emptyForm); setEditingId(null); } catch (err) { console.error('Submit error:', err); toast.error(err instanceof Error ? err.message : 'Failed to submit'); } finally { setSubmitting(false); } } async function handleSyncFromMeta() { if (!user) return; setSyncing(true); try { const res = await fetch('/api/whatsapp/templates/sync', { method: 'POST' }); const data = await res.json(); if (!res.ok) { throw new Error(data?.error || `Sync failed (HTTP ${res.status})`); } toast.success( `Synced ${data.total} template${data.total === 1 ? '' : 's'} from Meta` + (data.inserted || data.updated ? ` (${data.inserted} new, ${data.updated} updated)` : ''), ); if (Array.isArray(data.errors) && data.errors.length > 0) { const preview = data.errors.slice(0, 3).map( (e: { name: string; language: string; message: string }) => `${e.name} (${e.language})`, ); const suffix = data.errors.length > 3 ? `, +${data.errors.length - 3} more` : ''; toast.error(`Failed to sync: ${preview.join(', ')}${suffix}`); } if (data.truncated) { // Use error (not warning) so the message survives long // enough to read — sonner's `warning` auto-dismisses on // the same short timer as `success`. toast.error( 'Synced the first 2000 templates only — your account has more. Sync again to continue, or contact support if this persists.', { duration: 10000 }, ); } await fetchTemplates(user.id); } catch (err) { console.error('Template sync error:', err); toast.error(err instanceof Error ? err.message : 'Failed to sync templates'); } finally { setSyncing(false); } } async function confirmDelete() { const target = templateToDelete; if (!target || deletingId) return; setDeletingId(target.id); try { // Route handler scopes the Meta delete via hsm_id (so sibling // language variants survive) and falls through to remove the // local row. Local-only rows skip the Meta call. const res = await fetch(`/api/whatsapp/templates/${target.id}`, { method: 'DELETE', }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data?.error || `Delete failed (HTTP ${res.status})`); } toast.success('Template deleted'); setTemplates((prev) => prev.filter((t) => t.id !== target.id)); setTemplateToDelete(null); } catch (err) { console.error('Delete error:', err); toast.error(err instanceof Error ? err.message : 'Failed to delete template'); } finally { setDeletingId(null); } } // The patch type unions every field across button variants. The // conditional rendering below ensures only fields valid for the // current button's `type` reach this function, so the runtime // assertion + per-type spread preserves discriminated-union // invariants without forcing every call site to thread the type // through generics (which TS can't infer from a partial literal). type ButtonPatch = { text?: string; url?: string; phone_number?: string; example?: string; }; function updateButton(index: number, patch: ButtonPatch) { setForm((prev) => { const current = prev.buttons[index]; if (!current) return prev; const next = [...prev.buttons]; // Per-variant spread keeps the discriminant pinned. Switch // exhaustiveness is enforced by TypeScript. switch (current.type) { case 'QUICK_REPLY': next[index] = { ...current, ...(patch.text !== undefined && { text: patch.text }), }; break; case 'URL': next[index] = { ...current, ...(patch.text !== undefined && { text: patch.text }), ...(patch.url !== undefined && { url: patch.url }), ...(patch.example !== undefined && { example: patch.example }), }; break; case 'PHONE_NUMBER': next[index] = { ...current, ...(patch.text !== undefined && { text: patch.text }), ...(patch.phone_number !== undefined && { phone_number: patch.phone_number, }), }; break; case 'COPY_CODE': next[index] = { ...current, ...(patch.text !== undefined && { text: patch.text }), ...(patch.example !== undefined && { example: patch.example }), }; break; } return { ...prev, buttons: next }; }); } function changeButtonType(index: number, type: TemplateButton['type']) { setForm((prev) => { const next = [...prev.buttons]; next[index] = emptyButton(type); return { ...prev, buttons: next }; }); } function removeButton(index: number) { setForm((prev) => ({ ...prev, buttons: prev.buttons.filter((_, i) => i !== index), })); } function addButton() { if (form.buttons.length >= TEMPLATE_LIMITS.maxButtonsTotal) return; setForm((prev) => ({ ...prev, buttons: [...prev.buttons, emptyButton('QUICK_REPLY')], })); } if (loading) { return (
); } const headerNeedsMedia = form.header_format !== 'none' && form.header_format !== 'text'; async function handleHeaderImageFile(file: File) { if (!['image/jpeg', 'image/png'].includes(file.type)) { toast.error('Header image must be a JPEG or PNG.'); return; } if (file.size > MEDIA_MAX_BYTES_BY_KIND.image) { toast.error( `Image is ${(file.size / 1024 / 1024).toFixed(1)} MB — Meta's limit is 5 MB.`, ); return; } setUploadingHeader(true); try { const { publicUrl } = await uploadAccountMedia('chat-media', file); setForm((f) => ({ ...f, header_media_url: publicUrl })); toast.success('Image uploaded.'); } catch (err) { toast.error(err instanceof Error ? err.message : 'Upload failed.'); } finally { setUploadingHeader(false); } } return (
} /> {templates.length === 0 ? (

No templates yet.

Create your first message template to get started.

) : (
{templates.map((template) => { const statusKey = template.status || 'DRAFT'; const status = templateStatusConfig[statusKey]; return (

{template.name}

{template.category} {status.label} {template.language && ( {template.language} )} {template.quality_score && ( {template.quality_score} )}

{template.body_text}

{template.footer_text && (

{template.footer_text}

)} {(template.rejection_reason || template.submission_error) && (
{template.rejection_reason || template.submission_error}
)}
{statusKey === 'APPROVED' && ( )} {(statusKey === 'REJECTED' || statusKey === 'PAUSED') && ( )}
); })}
)} { setDialogOpen(open); if (!open) { setEditingId(null); setForm(emptyForm); } }} > {editingId ? 'Edit Message Template' : 'New Message Template'} {editingId ? 'Save your changes to re-submit to Meta. Status will flip back to PENDING during review.' : 'Build a template and submit it to Meta for approval. Once approved, you can use it in broadcasts and the inbox.'} {form.category === 'Authentication' && (

AUTHENTICATION templates have a fixed body + OTP button shape that needs a different builder. Create them in Meta WhatsApp Manager for now and use Sync from Meta to bring them in.

)}
setForm({ ...form, name: e.target.value })} disabled={editingId !== null} className="bg-muted border-border text-foreground placeholder:text-muted-foreground disabled:opacity-60 disabled:cursor-not-allowed" />

{editingId ? 'Name is fixed once a template exists on Meta — create a new template to change it.' : 'Lowercase letters, digits, and underscores only.'}

setForm({ ...form, language: e.target.value }) } disabled={editingId !== null} className="bg-muted border-border text-foreground placeholder:text-muted-foreground disabled:opacity-60 disabled:cursor-not-allowed" /> {COMMON_LANGUAGE_CODES.map((code) => (

{editingId ? 'Language is fixed once a template exists on Meta.' : ( <> Must match the exact code on Meta — en_US{' '} and en are distinct. )}

{form.header_format === 'text' && (
setForm({ ...form, header_content: e.target.value }) } maxLength={TEMPLATE_LIMITS.headerTextMaxLength} className="bg-muted border-border text-foreground placeholder:text-muted-foreground" /> {headerVarCount > 0 && ( setForm({ ...form, header_sample: e.target.value }) } className="bg-muted border-border text-foreground placeholder:text-muted-foreground" /> )}
)} {headerNeedsMedia && (
{form.header_format === 'image' && (
{ const f = e.target.files?.[0]; if (f) void handleHeaderImageFile(f); e.target.value = ''; }} /> JPEG or PNG, ≤5 MB
)} setForm({ ...form, header_media_url: e.target.value }) } className="bg-muted border-border text-foreground placeholder:text-muted-foreground" /> {form.header_format === 'image' && form.header_media_url && ( // eslint-disable-next-line @next/next/no-img-element Header sample )}

{form.header_format === 'image' ? 'Upload a JPEG/PNG (≤5 MB, ≥800×418 px recommended) or paste a public HTTPS link — we upload it to Meta for review automatically.' : 'Must be a publicly accessible HTTPS link. Meta fetches it once during review, so it needs to stay live for ~24 hrs.'} {form.header_format === 'video' && ' Recommended: MP4 / 3GPP, ≤16 MB, ≤60 seconds.'} {form.header_format === 'document' && ' Recommended: PDF, ≤100 MB.'}

)}