mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
1135 lines
44 KiB
TypeScript
1135 lines
44 KiB
TypeScript
'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>
|
||
);
|
||
}
|