mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
A focused cleanup PR that addresses the high-signal findings from a self-review of PRs #148-153. Every change is local to the templates surface; no schema or wire-format changes. Safety ------ * Delete now goes through a confirmation Dialog (mirrors the existing tag-manager pattern). Copy explicitly tells the user when Meta will be hit, so a slip on the trash icon doesn't take the template off Meta as well as locally. * AUTHENTICATION submit is now blocked by a persistent in-dialog banner + disabled submit button. The disappearing toast that used to fire on submit is gone — defensive `return` remains in handleSubmit as a second line of defense. * updateButton uses a per-variant switch + ButtonPatch union instead of the partial-spread + `as TemplateButton` cast. Each variant's spread keeps its discriminant pinned; TS enforces exhaustiveness. * New template-row-guard.ts surfaces malformed Supabase rows at the route boundary instead of letting buildSendComponents crash deep in the stack with an opaque TypeError. * UUID regex check on /[id] PATCH+DELETE so non-UUID payloads return a clean 400 before hitting Supabase. Submit-route cleanup -------------------- * Two near-identical upsert blocks (error path + success path) collapsed into a buildUpsertRow + upsertTemplateRow helper. Adding a column now touches one place, not two. UX copy + feedback ------------------ * Submit success toast sets the right expectation: "typical review time is 24 hours. Status updates automatically." Old copy just said "status will update once approved." * Header media URL helper text is now actually useful: HTTPS requirement, ~24h lifetime, per-format size/format recommendations (JPEG/PNG ≥800×418 ≤5 MB for images, MP4 ≤16 MB for video, etc). * Truncated-sync toast switches from `warning` (which auto-dismisses fast) to `error` with `duration: 10000` and concrete copy: "Synced the first 2000 templates only — your account has more. Sync again to continue." * Edit and Resubmit buttons gain visible text labels alongside their icons + better aria-label/title pairs. A lone rotate icon wasn't self-explanatory. * Broadcast picker drops the redundant "Approved" chip — every template in that list is APPROVED by filter, so the chip carried zero information. Unused templateStatusConfig import removed. State + a11y ------------ * handleSubmit now awaits fetchTemplates BEFORE closing the dialog so re-opening immediately doesn't see a stale list. * Header content / media URL / sample are preserved across format switches in the New Template form. The payload builder is already format-aware, so orphan values on hidden fields are harmless — and keeping them lets users switch formats to compare without losing typing. * Button-type Select gains the same null-guard the header Select got in PR #148 (per @base-ui semantics, onValueChange can fire with null on deselect). * Dynamic body-sample inputs get id + aria-label so each one is individually addressable (WCAG A — screen-reader announcement beat "edit text"). Header text + header sample inputs too. Deliberately NOT here --------------------- * The 940-line template-manager component is not split. It's cohesive (one feature surface) and splitting for size alone would be over-engineering. * extractVariableIndices in the buttons loop is not memoized. Regex on a short URL string is sub-microsecond; a real profiler would never flag it. * No JS heap secret-clearing for access tokens. Strings are immutable in JS — there's no way to actually clear one. Tests: 256/256 pass (no new tests required — pure refactor + copy + new UI elements). Lint 0 errors. Typecheck clean. Build succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1071 lines
41 KiB
TypeScript
1071 lines
41 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
import { toast } from 'sonner';
|
||
import {
|
||
Plus,
|
||
Trash2,
|
||
Loader2,
|
||
RefreshCw,
|
||
AlertCircle,
|
||
X,
|
||
Pencil,
|
||
RotateCcw,
|
||
} from 'lucide-react';
|
||
import { createClient } from '@/lib/supabase/client';
|
||
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 {
|
||
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);
|
||
|
||
// 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';
|
||
|
||
return (
|
||
<div className="space-y-4 mt-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-white">Message Templates</h2>
|
||
<p className="text-sm text-slate-400">
|
||
Create message templates and submit them to Meta for approval. Use
|
||
"Sync from Meta" to pull templates approved elsewhere.
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleSyncFromMeta}
|
||
disabled={syncing}
|
||
className="border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
|
||
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}
|
||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||
>
|
||
<Plus className="size-4" />
|
||
New Template
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{templates.length === 0 ? (
|
||
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||
<p className="text-slate-400 text-sm">No templates yet.</p>
|
||
<p className="text-slate-500 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}
|
||
className="bg-slate-900 border-slate-700 ring-0 ring-transparent"
|
||
>
|
||
<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-white">{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-slate-500 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-slate-400 line-clamp-2">
|
||
{template.body_text}
|
||
</p>
|
||
{template.footer_text && (
|
||
<p className="text-xs text-slate-500 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-slate-300 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-slate-300 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-slate-400 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-slate-900 border-slate-700 sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white">
|
||
{editingId ? 'Edit Message Template' : 'New Message Template'}
|
||
</DialogTitle>
|
||
<DialogDescription className="text-slate-400">
|
||
{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-slate-300">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-slate-800 border-slate-700 text-white placeholder:text-slate-500 disabled:opacity-60 disabled:cursor-not-allowed"
|
||
/>
|
||
<p className="text-[11px] text-slate-500">
|
||
{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-slate-300">Category</Label>
|
||
<Select
|
||
value={form.category}
|
||
onValueChange={(val) =>
|
||
setForm({
|
||
...form,
|
||
category: val as MessageTemplate['category'],
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="w-full bg-slate-800 border-slate-700 text-white">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-slate-800 border-slate-700">
|
||
{CATEGORIES.map((cat) => (
|
||
<SelectItem
|
||
key={cat}
|
||
value={cat}
|
||
className="text-white focus:bg-slate-700 focus:text-white"
|
||
>
|
||
{cat}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-slate-300">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-slate-800 border-slate-700 text-white placeholder:text-slate-500 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-slate-500">
|
||
{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-slate-300">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-slate-800 border-slate-700 text-white">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-slate-800 border-slate-700">
|
||
{HEADER_FORMATS.map((type) => (
|
||
<SelectItem
|
||
key={type}
|
||
value={type}
|
||
className="text-white focus:bg-slate-700 focus:text-white"
|
||
>
|
||
{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-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||
/>
|
||
{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-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{headerNeedsMedia && (
|
||
<div className="space-y-2 mt-2">
|
||
<Input
|
||
placeholder={`https://… (public link to a sample ${form.header_format})`}
|
||
value={form.header_media_url}
|
||
onChange={(e) =>
|
||
setForm({ ...form, header_media_url: e.target.value })
|
||
}
|
||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||
/>
|
||
<p className="text-[11px] text-slate-500 leading-relaxed">
|
||
Must be publicly accessible HTTPS. Meta fetches it once
|
||
during review, so the file needs to stay live for ~24 hrs.
|
||
{form.header_format === 'image' &&
|
||
' Recommended: JPEG or PNG, ≥800×418 px, ≤5 MB.'}
|
||
{form.header_format === 'video' &&
|
||
' Recommended: MP4 / 3GPP, ≤16 MB, ≤60 seconds.'}
|
||
{form.header_format === 'document' &&
|
||
' Recommended: PDF, ≤100 MB.'}
|
||
{' '}Direct file upload is coming in a follow-up.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-slate-300">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-slate-800 border-slate-700 text-white placeholder:text-slate-500 resize-none"
|
||
/>
|
||
<p className="text-[11px] text-slate-500">
|
||
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-slate-400">
|
||
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-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-slate-300">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-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label className="text-slate-300">Buttons (optional)</Label>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={addButton}
|
||
disabled={form.buttons.length >= TEMPLATE_LIMITS.maxButtonsTotal}
|
||
className="border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800 h-7 text-xs"
|
||
>
|
||
<Plus className="size-3" />
|
||
Add Button
|
||
</Button>
|
||
</div>
|
||
{form.buttons.length === 0 ? (
|
||
<p className="text-[11px] text-slate-500">
|
||
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-slate-700 bg-slate-800/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-slate-800 border-slate-700 text-white h-8 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-slate-800 border-slate-700">
|
||
<SelectItem
|
||
value="QUICK_REPLY"
|
||
className="text-white focus:bg-slate-700 focus:text-white"
|
||
>
|
||
Quick Reply
|
||
</SelectItem>
|
||
<SelectItem
|
||
value="URL"
|
||
className="text-white focus:bg-slate-700 focus:text-white"
|
||
>
|
||
URL
|
||
</SelectItem>
|
||
<SelectItem
|
||
value="PHONE_NUMBER"
|
||
className="text-white focus:bg-slate-700 focus:text-white"
|
||
>
|
||
Phone
|
||
</SelectItem>
|
||
<SelectItem
|
||
value="COPY_CODE"
|
||
className="text-white focus:bg-slate-700 focus:text-white"
|
||
>
|
||
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-slate-800 border-slate-700 text-white placeholder:text-slate-500 h-8 text-xs"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => removeButton(i)}
|
||
className="text-slate-400 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-slate-800 border-slate-700 text-white placeholder:text-slate-500 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-slate-800 border-slate-700 text-white placeholder:text-slate-500 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-slate-800 border-slate-700 text-white placeholder:text-slate-500 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-slate-800 border-slate-700 text-white placeholder:text-slate-500 h-8 text-xs"
|
||
/>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter className="bg-slate-900 border-slate-700">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setDialogOpen(false)}
|
||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||
>
|
||
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-slate-900 border-slate-700 sm:max-w-sm">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white">Delete template?</DialogTitle>
|
||
<DialogDescription className="text-slate-400">
|
||
{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-slate-900 border-slate-700">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setTemplateToDelete(null)}
|
||
disabled={deletingId !== null}
|
||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||
>
|
||
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>
|
||
</div>
|
||
);
|
||
}
|