mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
The legal brief on third-party trademark use says: avoid the WhatsApp teal/green palette and any visual treatment that could read as a WhatsApp/Meta product. Emerald-500 was used as the primary brand colour across hero, CTAs, sidebar, charts, badges, and the favicon — close enough in hue to WhatsApp's signature green to muddy the line. Swapping to Tailwind violet (which matches the Hostinger brand purple #7c3aed) makes the visual identity unambiguously ours. Scope: - Mechanical Tailwind class swap: emerald-* → violet-* across src/. - src/app/globals.css: --primary, --ring, --sidebar-primary, --sidebar-ring, --chart-1 oklch tokens swapped from emerald (oklch 0.696 0.17 162) to Hostinger-aligned violet (oklch 0.526 0.247 293). - src/app/icon.tsx: favicon background #10b981 → #7c3aed. - src/app/opengraph-image.tsx: badge bg, accent text, gradient. - src/components/landing/hero.tsx + cta-banner.tsx: radial-gradient hex. - src/components/landing/mock/analytics-mock.tsx: marketing chart line. - src/components/dashboard/conversations-chart.tsx + response-time-chart.tsx: "Outgoing" series colour. Distinguishable from "Incoming" blue at a glance; we can revisit the chart pair later for stronger colourblind contrast (separate concern). - README.md: license badge colour parameter. Out of scope: - src/components/settings/tag-manager.tsx still offers "Emerald" as one of the user-pickable contact-tag colours alongside Blue / Yellow / Red / etc. — that's a user data choice, not a brand mark. Contrast check: white text on #7c3aed measures ~4.78:1 (WCAG AA passing for normal body text). Violet-400 (#a78bfa) on slate-950 measures ~10:1 for accent text, well clear of AA. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
476 lines
16 KiB
TypeScript
476 lines
16 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';
|
|
|
|
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',
|
|
};
|
|
|
|
const statusColors: Record<string, string> = {
|
|
Draft: 'bg-slate-600/20 text-slate-400 border-slate-600/30',
|
|
Pending: 'bg-yellow-600/20 text-yellow-400 border-yellow-600/30',
|
|
Approved: 'bg-violet-600/20 text-violet-400 border-violet-600/30',
|
|
Rejected: 'bg-red-600/20 text-red-400 border-red-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-violet-500" />
|
|
</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 "Sync from Meta" 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-violet-600 hover:bg-violet-700 text-white"
|
|
>
|
|
<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 ${statusColors[template.status || 'Draft'] || ''}`}
|
|
>
|
|
{template.status || 'Draft'}
|
|
</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) => setForm({ ...form, header_type: 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-violet-600 hover:bg-violet-700 text-white"
|
|
>
|
|
{saving ? (
|
|
<>
|
|
<Loader2 className="size-4 animate-spin" />
|
|
Creating...
|
|
</>
|
|
) : (
|
|
'Create Template'
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|