wacrm/src/components/settings/template-manager.tsx
Arnas Donauskas a45cbe6822
feat(settings): redesign Settings into a grouped rail + Overview landing
Replace the flat 8-tab Settings page with a cleaner information
architecture: a grouped left rail (Account / Workspace) plus a new
Overview landing with live status tiles. Related concerns are merged —
"Login & security" (password + sessions) and "Fields & tags" (tags +
custom fields).

- New shell: settings-sections (section model + legacy ?tab= mapping,
  tags/custom-fields -> fields), settings-rail, settings-overview
  (live counts + identity), security-panel, fields-and-tags-panel,
  settings-panel-head, settings-chip, role-meta (shared role chip
  metadata, de-duped from members-tab).
- Restyle the light panels to the mockup; reframe the heavy panels
  (WhatsApp, Templates) with the shared panel header, internals intact.
- Tokens: add --card-2 (light+dark) and @theme mappings for
  primary-hover/soft/soft-2 so the redesign stays fully tokenized.
- ?tab= remains the URL source of truth, keeping existing sidebar/header
  deep-links working.

Overview decouples the slow WhatsApp Meta health-ping from the cheap
count queries so a flaky ping never blanks the whole landing; template
counts use head-only count queries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 15:08:33 +03:00

1135 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<string, string> = {
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<MessageTemplate[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [syncing, setSyncing] = useState(false);
const [form, setForm] = useState<TemplateFormData>(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<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(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<MessageTemplate | null>(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<HTMLInputElement>(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 (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
);
}
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 (
<section className="animate-in fade-in-50 space-y-4 duration-200">
<SettingsPanelHead
title="Message templates"
description={
'Create templates and submit them to Meta for approval. Use "Sync from Meta" to pull templates approved elsewhere.'
}
action={
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleSyncFromMeta}
disabled={syncing}
title="Pull approved templates from your Meta WhatsApp Business Account"
>
<RefreshCw className={`size-4 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing…' : 'Sync from Meta'}
</Button>
<Button onClick={openCreate}>
<Plus className="size-4" />
New Template
</Button>
</div>
}
/>
{templates.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-muted-foreground text-sm">No templates yet.</p>
<p className="text-muted-foreground text-xs mt-1">
Create your first message template to get started.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{templates.map((template) => {
const statusKey = template.status || 'DRAFT';
const status = templateStatusConfig[statusKey];
return (
<Card key={template.id}>
<CardContent className="flex items-start justify-between pt-4">
<div className="space-y-2 min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-medium text-foreground">{template.name}</h3>
<Badge
className={`text-xs border ${categoryColors[template.category] || ''}`}
>
{template.category}
</Badge>
<Badge className={`text-xs border ${status.classes}`}>
{status.label}
</Badge>
{template.language && (
<span className="text-xs text-muted-foreground uppercase">
{template.language}
</span>
)}
{template.quality_score && (
<span
className={`text-[10px] uppercase font-medium ${
template.quality_score === 'GREEN'
? 'text-emerald-400'
: template.quality_score === 'YELLOW'
? 'text-yellow-400'
: 'text-red-400'
}`}
title="Meta quality score"
>
{template.quality_score}
</span>
)}
</div>
<p className="text-sm text-muted-foreground line-clamp-2">
{template.body_text}
</p>
{template.footer_text && (
<p className="text-xs text-muted-foreground italic">
{template.footer_text}
</p>
)}
{(template.rejection_reason || template.submission_error) && (
<div className="flex items-start gap-1.5 text-xs text-red-400 bg-red-950/20 border border-red-900/40 rounded px-2 py-1.5">
<AlertCircle className="size-3.5 mt-0.5 shrink-0" />
<span>
{template.rejection_reason || template.submission_error}
</span>
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0 ml-2">
{statusKey === 'APPROVED' && (
<Button
variant="ghost"
size="sm"
onClick={() => openEdit(template)}
title="Editing triggers Meta re-review — status flips to PENDING."
aria-label="Edit template"
className="text-muted-foreground hover:text-primary hover:bg-primary/10 h-8 px-2"
>
<Pencil className="size-3.5" />
Edit
</Button>
)}
{(statusKey === 'REJECTED' || statusKey === 'PAUSED') && (
<Button
variant="ghost"
size="sm"
onClick={() => openEdit(template)}
title="Edit the template and resubmit to Meta for review."
aria-label="Edit and resubmit template"
className="text-muted-foreground hover:text-primary hover:bg-primary/10 h-8 px-2"
>
<RotateCcw className="size-3.5" />
Resubmit
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => setTemplateToDelete(template)}
disabled={deletingId === template.id}
aria-label={
template.meta_template_id
? 'Delete template from Meta and locally'
: 'Delete template locally'
}
title={
template.meta_template_id
? 'Delete from Meta and locally'
: 'Delete locally'
}
className="text-muted-foreground hover:text-red-400 hover:bg-red-950/30 h-8 w-8"
>
{deletingId === template.id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
setEditingId(null);
setForm(emptyForm);
}
}}
>
<DialogContent className="bg-popover border-border sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-popover-foreground">
{editingId ? 'Edit Message Template' : 'New Message Template'}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{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.'}
</DialogDescription>
</DialogHeader>
{form.category === 'Authentication' && (
<div className="flex items-start gap-2 rounded border border-amber-700/40 bg-amber-950/30 px-3 py-2 text-xs text-amber-300">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<p>
AUTHENTICATION templates have a fixed body + OTP button shape
that needs a different builder. Create them in Meta WhatsApp
Manager for now and use <strong>Sync from Meta</strong> to
bring them in.
</p>
</div>
)}
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label className="text-muted-foreground">Template Name</Label>
<Input
placeholder="e.g. order_confirmation"
value={form.name}
onChange={(e) => 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"
/>
<p className="text-[11px] text-muted-foreground">
{editingId
? 'Name is fixed once a template exists on Meta — create a new template to change it.'
: 'Lowercase letters, digits, and underscores only.'}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-muted-foreground">Category</Label>
<Select
value={form.category}
onValueChange={(val) =>
setForm({
...form,
category: val as MessageTemplate['category'],
})
}
>
<SelectTrigger className="w-full bg-muted border-border text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-popover border-border">
{CATEGORIES.map((cat) => (
<SelectItem
key={cat}
value={cat}
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Language</Label>
<Input
list="template-language-codes"
placeholder="en_US"
value={form.language}
onChange={(e) =>
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"
/>
<datalist id="template-language-codes">
{COMMON_LANGUAGE_CODES.map((code) => (
<option key={code} value={code} />
))}
</datalist>
<p className="text-[11px] text-muted-foreground">
{editingId
? 'Language is fixed once a template exists on Meta.'
: (
<>
Must match the exact code on Meta — <code>en_US</code>{' '}
and <code>en</code> are distinct.
</>
)}
</p>
</div>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Header</Label>
<Select
value={form.header_format}
onValueChange={(val) =>
// Preserve header_content, header_media_url, and
// header_sample across format switches. The submit
// payload builder only reads the field that matches
// the active format, so an orphan value on a hidden
// field is harmless — and keeping it lets the user
// switch formats to compare without losing typing.
setForm({
...form,
header_format: (val || 'none') as HeaderFormat,
})
}
>
<SelectTrigger className="w-full bg-muted border-border text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-popover border-border">
{HEADER_FORMATS.map((type) => (
<SelectItem
key={type}
value={type}
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
>
{type === 'none'
? 'None'
: type.charAt(0).toUpperCase() + type.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
{form.header_format === 'text' && (
<div className="space-y-2 mt-2">
<Input
id="template-header-text"
aria-label="Header text"
placeholder="Header text (max 60 chars, optional {{1}})"
value={form.header_content}
onChange={(e) =>
setForm({ ...form, header_content: e.target.value })
}
maxLength={TEMPLATE_LIMITS.headerTextMaxLength}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
{headerVarCount > 0 && (
<Input
id="template-header-sample"
aria-label="Sample value for header variable"
placeholder="Sample value for {{1}} (required for Meta review)"
value={form.header_sample}
onChange={(e) =>
setForm({ ...form, header_sample: e.target.value })
}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
)}
</div>
)}
{headerNeedsMedia && (
<div className="space-y-2 mt-2">
{form.header_format === 'image' && (
<div className="flex items-center gap-2">
<input
ref={headerFileRef}
type="file"
accept="image/jpeg,image/png"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleHeaderImageFile(f);
e.target.value = '';
}}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingHeader}
onClick={() => headerFileRef.current?.click()}
>
{uploadingHeader ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
Upload image
</Button>
<span className="text-[11px] text-muted-foreground">
JPEG or PNG, ≤5 MB
</span>
</div>
)}
<Input
placeholder={`https://… (or paste a public ${form.header_format} link)`}
value={form.header_media_url}
onChange={(e) =>
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
<img
src={form.header_media_url}
alt="Header sample"
className="max-h-28 rounded-md border border-border object-contain"
/>
)}
<p className="text-[11px] text-muted-foreground leading-relaxed">
{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.'}
</p>
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Body Text</Label>
<Textarea
placeholder="Hello {{1}}, your order {{2}} is confirmed."
value={form.body_text}
onChange={(e) =>
setForm({ ...form, body_text: e.target.value })
}
rows={4}
maxLength={TEMPLATE_LIMITS.bodyMaxLength}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none"
/>
<p className="text-[11px] text-muted-foreground">
Use {`{{1}}`}, {`{{2}}`} for variables (must be contiguous
starting at {`{{1}}`}).
</p>
{bodyVarCount > 0 && (
<div className="space-y-1.5 pt-1">
<Label className="text-[11px] text-muted-foreground">
Sample values (Meta uses these to review your template)
</Label>
{form.body_samples.map((val, i) => {
const inputId = `template-body-sample-${i}`;
return (
<Input
key={i}
id={inputId}
aria-label={`Sample value for body variable {{${i + 1}}}`}
placeholder={`Sample for {{${i + 1}}}`}
value={val}
onChange={(e) => {
const next = [...form.body_samples];
next[i] = e.target.value;
setForm({ ...form, body_samples: next });
}}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
);
})}
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Footer (optional)</Label>
<Input
placeholder="Optional footer text (max 60 chars)"
value={form.footer_text}
onChange={(e) =>
setForm({ ...form, footer_text: e.target.value })
}
maxLength={TEMPLATE_LIMITS.footerMaxLength}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-muted-foreground">Buttons (optional)</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addButton}
disabled={form.buttons.length >= TEMPLATE_LIMITS.maxButtonsTotal}
className="border-border bg-transparent text-muted-foreground hover:bg-muted h-7 text-xs"
>
<Plus className="size-3" />
Add Button
</Button>
</div>
{form.buttons.length === 0 ? (
<p className="text-[11px] text-muted-foreground">
Up to {TEMPLATE_LIMITS.maxButtonsTotal} buttons. QUICK_REPLY
buttons must come before URL / phone / copy-code buttons.
</p>
) : (
<div className="space-y-2">
{form.buttons.map((btn, i) => (
<div
key={i}
className="space-y-2 rounded border border-border bg-muted/50 p-2"
>
<div className="flex items-center gap-2">
<Select
value={btn.type}
onValueChange={(val) => {
// Same null guard as the Header Select
// (per PR 148): @base-ui Select fires
// onValueChange(null) on deselect.
if (!val) return;
changeButtonType(i, val as TemplateButton['type']);
}}
>
<SelectTrigger className="w-40 bg-muted border-border text-foreground h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-popover border-border">
<SelectItem
value="QUICK_REPLY"
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
>
Quick Reply
</SelectItem>
<SelectItem
value="URL"
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
>
URL
</SelectItem>
<SelectItem
value="PHONE_NUMBER"
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
>
Phone
</SelectItem>
<SelectItem
value="COPY_CODE"
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
>
Copy Code
</SelectItem>
</SelectContent>
</Select>
<Input
placeholder="Button label"
value={btn.text}
maxLength={TEMPLATE_LIMITS.buttonTextMaxLength}
onChange={(e) =>
updateButton(i, { text: e.target.value })
}
className="flex-1 bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeButton(i)}
className="text-muted-foreground hover:text-red-400 hover:bg-red-950/30 size-7"
>
<X className="size-3.5" />
</Button>
</div>
{btn.type === 'URL' && (
<div className="space-y-1 pl-1">
<Input
placeholder="https://example.com/path or with {{1}} suffix"
value={btn.url}
onChange={(e) =>
updateButton(i, { url: e.target.value })
}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs"
/>
{extractVariableIndices(btn.url).length > 0 && (
<Input
placeholder="Example value for {{1}} (required when URL has a variable)"
value={btn.example ?? ''}
onChange={(e) =>
updateButton(i, { example: e.target.value })
}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs"
/>
)}
</div>
)}
{btn.type === 'PHONE_NUMBER' && (
<Input
placeholder="+15551234567"
value={btn.phone_number}
onChange={(e) =>
updateButton(i, { phone_number: e.target.value })
}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs"
/>
)}
{btn.type === 'COPY_CODE' && (
<Input
placeholder="Example code (e.g. SUMMER20)"
value={btn.example}
onChange={(e) =>
updateButton(i, { example: e.target.value })
}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground h-8 text-xs"
/>
)}
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => setDialogOpen(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={submitting || form.category === 'Authentication'}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" />
{editingId ? 'Saving…' : 'Submitting…'}
</>
) : editingId ? (
'Save & Resubmit'
) : (
'Submit for Approval'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Confirm-delete dialog. Surfacing the meta_template_id case
separately so users understand a real Meta delete is happening,
not just a local cleanup. */}
<Dialog
open={templateToDelete !== null}
onOpenChange={(open) => {
if (!open) setTemplateToDelete(null);
}}
>
<DialogContent className="bg-popover border-border sm:max-w-sm">
<DialogHeader>
<DialogTitle className="text-popover-foreground">Delete template?</DialogTitle>
<DialogDescription className="text-muted-foreground">
{templateToDelete?.meta_template_id
? `"${templateToDelete?.name}" will be deleted from Meta and from wacrm. Active broadcasts using this template will start failing on their next send. This can't be undone.`
: `"${templateToDelete?.name}" will be deleted from wacrm. It was never submitted to Meta, so no remote cleanup is needed.`}
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => setTemplateToDelete(null)}
disabled={deletingId !== null}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
onClick={confirmDelete}
disabled={deletingId !== null}
className="bg-red-600 hover:bg-red-700 text-white"
>
{deletingId !== null ? (
<>
<Loader2 className="size-4 animate-spin" />
Deleting…
</>
) : (
'Delete'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}