wacrm/src/components/settings/template-manager.tsx
Arnas Donauskas aa4f34c94c
feat(templates): store raw Meta status enum + lay schema groundwork for submission
Switches message_templates.status from the local TitleCase set
(Draft|Pending|Approved|Rejected) to Meta's raw enum (DRAFT|PENDING|
APPROVED|REJECTED|PAUSED|DISABLED|IN_APPEAL|PENDING_DELETION) and
adds the columns the upcoming submit / edit / resubmit / webhook
flows need. No new user-facing behavior — this is groundwork.

Migration 014 also adds:

  * sample_values JSONB        Meta requires these for variable templates
  * meta_template_id TEXT      hsm_id for scoped edit/delete (avoids
                               nuking sibling-language variants)
  * rejection_reason TEXT      surfaced from webhook on REJECTED
  * quality_score TEXT         GREEN | YELLOW | RED, from webhook
  * header_handle TEXT         from Resumable Upload (media headers)
  * header_media_url TEXT      URL fallback for media headers (v1 path)
  * submission_error TEXT      last 4xx from Meta, for retry surface
  * last_submitted_at          rate-limit awareness (100 creates/hour)
  * buttons JSONB shape CHECK  permissive array-of-typed-objects guard
                               (strict per-type validation lives in API)
  * UNIQUE (user_id, name, language)
                               so sync upsert is atomic and prevents
                               duplicate language variants
  * idx on meta_template_id    fast webhook lookup

Sync route now:
  * persists Meta's status verbatim (PAUSED no longer collapses to
    REJECTED — these are different states with different recoverability)
  * parses BUTTONS components into the new discriminated-union shape
  * extracts sample_values from body.example.body_text + header.example
  * persists meta_template_id, quality_score, header_handle

Shared template-status.ts gives the manager / inbox picker / broadcast
picker one source of truth for labels + dark-theme badge classes.

Part of the Issue #147 overhaul plan (PR 2 of 6 in the sequence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 12:12:01 +02:00

479 lines
17 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Plus, Trash2, Loader2, RefreshCw } 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, CardHeader, CardTitle, CardDescription } 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 } from '@/types';
import { templateStatusConfig } from '@/lib/template-status';
const CATEGORIES = ['Marketing', 'Utility', 'Authentication'] as const;
const HEADER_TYPES = ['text', 'image', 'video', 'document'] as const;
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;
body_text: string;
header_type: string;
footer_text: string;
}
// Meta's language codes are exact — "en" and "en_US" are distinct and a
// template approved under one will be rejected if you send with the other
// (Graph API error #132001 "Template name does not exist in the
// translation"). Default to en_US to match the DB default on
// message_templates.language and the broadcasts sender's fallback.
const emptyForm: TemplateFormData = {
name: '',
category: 'Marketing',
language: 'en_US',
body_text: '',
header_type: '',
footer_text: '',
};
// Common Meta template language codes. The field still accepts any
// string — this just offers autocomplete for the usual suspects. Full
// list: https://developers.facebook.com/docs/whatsapp/api/messages/message-templates#supported-languages
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',
];
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 [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [form, setForm] = useState<TemplateFormData>(emptyForm);
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);
}
}
async function handleSave() {
if (!form.name.trim()) {
toast.error('Template name is required');
return;
}
if (!form.body_text.trim()) {
toast.error('Body text is required');
return;
}
try {
setSaving(true);
if (!user) {
toast.error('Not authenticated');
return;
}
const payload = {
user_id: user.id,
name: form.name.trim(),
category: form.category,
language: form.language.trim() || 'en_US',
body_text: form.body_text.trim(),
header_type: form.header_type || null,
footer_text: form.footer_text.trim() || null,
status: 'DRAFT' as const,
};
const { error } = await supabase
.from('message_templates')
.insert(payload);
if (error) throw error;
toast.success('Template created successfully');
setDialogOpen(false);
setForm(emptyForm);
if (user) await fetchTemplates(user.id);
} catch (err) {
console.error('Save error:', err);
toast.error('Failed to create template');
} finally {
setSaving(false);
}
}
/**
* Pull approved templates from Meta and upsert them into the local
* catalog. After this runs, every local row is guaranteed to match
* something Meta will actually accept on send — stops users getting
* stuck on error #132001 "Template name does not exist".
*/
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) {
// Surface per-template failures so users don't trust a green
// toast that hides silent drift.
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) {
toast.warning(
'Hit Meta pagination cap — more templates may exist. Contact support if this persists.',
);
}
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 handleDelete(id: string) {
try {
const { error } = await supabase
.from('message_templates')
.delete()
.eq('id', id);
if (error) throw error;
toast.success('Template deleted');
setTemplates((prev) => prev.filter((t) => t.id !== id));
} catch (err) {
console.error('Delete error:', err);
toast.error('Failed to delete template');
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
);
}
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 and manage your WhatsApp message templates. Meta requires
every template to be approved in the WhatsApp Manager before it can
be sent — use &quot;Sync from Meta&quot; to pull your approved list.
</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={() => {
setForm(emptyForm);
setDialogOpen(true);
}}
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) => (
<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 ${templateStatusConfig[template.status || 'DRAFT'].classes}`}
>
{templateStatusConfig[template.status || 'DRAFT'].label}
</Badge>
{template.language && (
<span className="text-xs text-slate-500 uppercase">{template.language}</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>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(template.id)}
className="text-slate-400 hover:text-red-400 hover:bg-red-950/30 shrink-0 ml-2"
>
<Trash2 className="size-4" />
</Button>
</CardContent>
</Card>
))}
</div>
)}
{/* New Template Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-white">New Message Template</DialogTitle>
<DialogDescription className="text-slate-400">
Create a new WhatsApp message template.
</DialogDescription>
</DialogHeader>
<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 })}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
</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 })}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
<datalist id="template-language-codes">
{COMMON_LANGUAGE_CODES.map((code) => (
<option key={code} value={code} />
))}
</datalist>
<p className="text-[11px] text-slate-500">
Must match the exact language code the template is approved
under on Meta — e.g. <code>en_US</code> and <code>en</code>{' '}
are distinct.
</p>
</div>
</div>
<div className="space-y-2">
<Label className="text-slate-300">Header Type</Label>
<Select
value={form.header_type}
onValueChange={(val) =>
// "none" is a sentinel (the Select disallows value="")
// — normalize it back to empty string so the DB CHECK
// constraint (text|image|video|document) doesn't reject it.
setForm({
...form,
header_type: !val || val === 'none' ? '' : val,
})
}
>
<SelectTrigger className="w-full bg-slate-800 border-slate-700 text-white">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="none" className="text-white focus:bg-slate-700 focus:text-white">
None
</SelectItem>
{HEADER_TYPES.map((type) => (
<SelectItem key={type} value={type} className="text-white focus:bg-slate-700 focus:text-white">
{type.charAt(0).toUpperCase() + type.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-slate-300">Body Text</Label>
<Textarea
placeholder="Enter your template message body. Use {{1}}, {{2}} for variables."
value={form.body_text}
onChange={(e) => setForm({ ...form, body_text: e.target.value })}
rows={4}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 resize-none"
/>
</div>
<div className="space-y-2">
<Label className="text-slate-300">Footer Text</Label>
<Input
placeholder="Optional footer text"
value={form.footer_text}
onChange={(e) => setForm({ ...form, footer_text: e.target.value })}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
</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={handleSave}
disabled={saving}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
{saving ? (
<>
<Loader2 className="size-4 animate-spin" />
Creating...
</>
) : (
'Create Template'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}