'use client'; import { useEffect, useMemo, useState } from 'react'; import { createClient } from '@/lib/supabase/client'; import { Contact, CustomField, MessageTemplate } from '@/types'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { ArrowLeft, ArrowRight, Eye, ImageIcon, Loader2 } from 'lucide-react'; type VariableType = 'static' | 'field' | 'custom_field'; interface VariableMapping { type: VariableType; value: string; } interface Step3Props { template: MessageTemplate; variables: Record; onUpdate: (variables: Record) => void; /** Media URL for an IMAGE/VIDEO/DOCUMENT header, when the template has one. */ headerMediaUrl: string; onHeaderMediaUrlChange: (url: string) => void; onNext: () => void; onBack: () => void; } const MEDIA_HEADER_TYPES = ['image', 'video', 'document'] as const; type MediaHeaderType = (typeof MEDIA_HEADER_TYPES)[number]; function isMediaHeaderType(value: unknown): value is MediaHeaderType { return MEDIA_HEADER_TYPES.includes(value as MediaHeaderType); } function isValidHttpUrl(value: string): boolean { try { const u = new URL(value); return u.protocol === 'http:' || u.protocol === 'https:'; } catch { return false; } } const contactFields = [ { value: 'name', label: 'Contact Name' }, { value: 'phone', label: 'Phone Number' }, { value: 'email', label: 'Email Address' }, { value: 'company', label: 'Company' }, ]; const SAMPLE_CONTACT: Contact = { id: 'sample', user_id: '', account_id: '', name: 'John Doe', phone: '+1234567890', email: 'john@example.com', company: 'Acme Corp', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; export function Step3Personalize({ template, variables, onUpdate, headerMediaUrl, onHeaderMediaUrlChange, onNext, onBack, }: Step3Props) { const [customFields, setCustomFields] = useState([]); const [loadingFields, setLoadingFields] = useState(true); const [firstContact, setFirstContact] = useState(null); const [firstContactCustomValues, setFirstContactCustomValues] = useState< Map >(new Map()); const [loadingPreview, setLoadingPreview] = useState(true); // Load user's custom fields + a representative contact for the // live preview. Fall back to sample data if no contacts exist yet. useEffect(() => { let cancelled = false; (async () => { const supabase = createClient(); const [fieldsRes, contactRes] = await Promise.all([ supabase.from('custom_fields').select('*').order('field_name'), supabase .from('contacts') .select('*') .order('created_at', { ascending: false }) .limit(1) .maybeSingle(), ]); if (cancelled) return; setCustomFields(fieldsRes.data ?? []); setLoadingFields(false); const contact = contactRes.data ?? null; setFirstContact(contact); if (contact) { const { data: customVals } = await supabase .from('contact_custom_values') .select('custom_field_id, value') .eq('contact_id', contact.id); if (!cancelled) { const map = new Map(); for (const row of customVals ?? []) { map.set(row.custom_field_id, row.value ?? ''); } setFirstContactCustomValues(map); } } setLoadingPreview(false); })(); return () => { cancelled = true; }; }, []); const placeholders = useMemo(() => { const matches = template.body_text.match(/\{\{(\d+)\}\}/g); if (!matches) return []; return [...new Set(matches)].sort(); }, [template.body_text]); // Templates with an IMAGE/VIDEO/DOCUMENT header need a media URL at // send time — Meta requires the media component on every delivery and // rejects the broadcast without it. The field is hidden for text-only // headers. const mediaHeaderType = isMediaHeaderType(template.header_type) ? template.header_type : null; // Seed the field with the template's stored sample URL the first time // we land on a media-header template, so the common "reuse the // approved media" case needs no typing. Only seeds when empty to avoid // clobbering a URL the user already edited. useEffect(() => { if (mediaHeaderType && !headerMediaUrl && template.header_media_url) { onHeaderMediaUrlChange(template.header_media_url); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [mediaHeaderType, template.header_media_url]); const headerMediaError = useMemo<'missing' | 'invalid' | null>(() => { if (!mediaHeaderType) return null; const value = headerMediaUrl.trim(); if (!value) return 'missing'; if (!isValidHttpUrl(value)) return 'invalid'; return null; }, [mediaHeaderType, headerMediaUrl]); /** * A placeholder is "unmapped" if the user hasn't picked either a * static value or a field/custom-field source. Blocks Next until * every placeholder has something — otherwise the broadcast would * ship with empty strings and confuse recipients. */ const unmappedKeys = useMemo(() => { const missing: string[] = []; for (const placeholder of placeholders) { const key = placeholder.replace(/^\{\{|\}\}$/g, ''); const mapping = variables[key]; if (!mapping || !mapping.value?.trim()) { missing.push(placeholder); } } return missing; }, [placeholders, variables]); function updateVariable(key: string, patch: Partial) { const current = variables[key] ?? { type: 'static' as VariableType, value: '' }; onUpdate({ ...variables, [key]: { ...current, ...patch }, }); } /** * Substitute placeholders using the first real contact where * possible. Placeholders keyed by "{{N}}" map to variable key "N". */ const previewText = useMemo(() => { const contact = firstContact ?? SAMPLE_CONTACT; const customValues = firstContact ? firstContactCustomValues : new Map(); let text = template.body_text; for (const placeholder of placeholders) { const key = placeholder.replace(/^\{\{|\}\}$/g, ''); const mapping = variables[key]; let replacement = placeholder; if (mapping) { if (mapping.type === 'static' && mapping.value) { replacement = mapping.value; } else if (mapping.type === 'field' && mapping.value) { const fieldMap: Record = { name: contact.name, phone: contact.phone, email: contact.email, company: contact.company, }; replacement = fieldMap[mapping.value] ?? placeholder; } else if (mapping.type === 'custom_field' && mapping.value) { replacement = customValues.get(mapping.value) || placeholder; } } text = text.replaceAll(placeholder, replacement); } return text; }, [ template.body_text, variables, placeholders, firstContact, firstContactCustomValues, ]); const previewLabel = firstContact ? firstContact.name || firstContact.phone : 'sample data'; return (

Personalize Message

Map template variables to contact fields, custom fields, or static values.

{mediaHeaderType && (

Header media

{mediaHeaderType}
onHeaderMediaUrlChange(e.target.value)} placeholder={`https://example.com/header.${ mediaHeaderType === 'image' ? 'jpg' : mediaHeaderType === 'video' ? 'mp4' : 'pdf' }`} className="border-border bg-muted text-foreground placeholder:text-muted-foreground" />

Public URL of the {mediaHeaderType} sent as the message header. Used for every recipient in this broadcast.

{mediaHeaderType === 'image' && headerMediaError === null && headerMediaUrl.trim() && ( // eslint-disable-next-line @next/next/no-img-element Header preview )} {headerMediaError && (

{headerMediaError === 'missing' ? 'A media URL is required to send this template.' : 'Enter a valid http(s) URL.'}

)}
)} {placeholders.length === 0 && !mediaHeaderType ? (

This template has no variables to personalize.

) : placeholders.length === 0 ? null : (
{placeholders.map((placeholder) => { const key = placeholder.replace(/^\{\{|\}\}$/g, ''); const mapping = variables[key] ?? { type: 'static', value: '' }; return (
{placeholder}
{mapping.type === 'static' ? ( updateVariable(key, { value: e.target.value }) } placeholder="Enter value..." className="border-border bg-muted text-foreground placeholder:text-muted-foreground" /> ) : mapping.type === 'field' ? ( ) : ( )}
); })}
)} {/* Live Preview — rendered as a WhatsApp-style bubble so the user sees approximately what the recipient will see. */}

Live Preview

({previewLabel}) {loadingPreview && ( )}

{previewText}

{unmappedKeys.length > 0 && (
Map every placeholder before continuing — still missing{' '} {unmappedKeys.join(', ')} . Otherwise those placeholders will ship to Meta as empty strings.
)}
); }