From d8c80091ba4cd3a461e4400bde69ae80db273a26 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Sat, 29 Aug 2026 13:17:53 -0500 Subject: [PATCH] feat: plantillas, reconocimiento de marcas con vision y gestion de variantes --- deploy_dripzone_vps.py | 5 +- dripzone-app/docker-compose.prod.yml | 18 +- .../src/app/admin/configuracion/page.tsx | 9 +- .../src/app/admin/feed-whatsapp/page.tsx | 80 +- dripzone-app/src/app/admin/layout.tsx | 3 + .../src/app/admin/metodos-pago/page.tsx | 3 +- dripzone-app/src/app/admin/pedidos/page.tsx | 45 +- .../src/app/admin/plantillas/page.tsx | 720 ++++++++++++++++++ dripzone-app/src/app/admin/productos/page.tsx | 95 ++- .../src/app/admin/proveedores/page.tsx | 25 +- dripzone-app/src/app/admin/usuarios/page.tsx | 23 +- .../src/app/api/auth/register/route.ts | 8 +- .../src/app/api/auth/send-otp/route.ts | 10 +- .../src/app/api/message-templates/route.ts | 99 +++ dripzone-app/src/app/api/products/route.ts | 65 ++ .../src/app/api/webhook/whatsapp/route.ts | 24 +- .../src/app/api/whatsapp-feed/route.ts | 12 +- dripzone-app/src/app/checkout/page.tsx | 47 +- dripzone-app/src/app/page.tsx | 9 +- .../components/admin/ManageVariantsModal.tsx | 484 ++++++++++++ .../src/components/auth/AuthModal.tsx | 11 +- .../components/mobile/MobileBottomDock.tsx | 11 +- .../src/components/store/CartDrawer.tsx | 31 +- .../src/components/store/ProductModal.tsx | 40 +- dripzone-app/src/lib/data-service.ts | 284 ++++++- dripzone-app/src/lib/evolution-api.ts | 44 +- dripzone-app/src/lib/gemini-parser.ts | 558 ++++++++++++-- dripzone-app/src/lib/model-grouper.ts | 7 +- dripzone-app/src/lib/product-grouper.ts | 5 +- dripzone-app/src/lib/templates-client.ts | 43 ++ dripzone-app/src/lib/templates-service.ts | 614 +++++++++++++++ dripzone-app/src/types/index.ts | 31 + scripts/send_apk_to_whatsapp.js | 64 +- scripts/send_final_notification.js | 33 + 34 files changed, 3320 insertions(+), 240 deletions(-) create mode 100644 dripzone-app/src/app/admin/plantillas/page.tsx create mode 100644 dripzone-app/src/app/api/message-templates/route.ts create mode 100644 dripzone-app/src/components/admin/ManageVariantsModal.tsx create mode 100644 dripzone-app/src/lib/templates-client.ts create mode 100644 dripzone-app/src/lib/templates-service.ts create mode 100644 scripts/send_final_notification.js diff --git a/deploy_dripzone_vps.py b/deploy_dripzone_vps.py index 3b7802e..65138bb 100644 --- a/deploy_dripzone_vps.py +++ b/deploy_dripzone_vps.py @@ -24,6 +24,8 @@ def create_tar(): # Excluir node_modules, .next, android (para mantener paquete ligero) dirs[:] = [d for d in dirs if d not in ['node_modules', '.next', 'android', '.git']] for file in files: + if file.endswith('.apk'): + continue full_path = os.path.join(root, file) rel_path = os.path.relpath(full_path, LOCAL_DIR) tar.add(full_path, arcname=rel_path) @@ -47,7 +49,7 @@ def deploy(): mkdir -p {REMOTE_DIR} tar -xzf {REMOTE_TAR} -C {REMOTE_DIR} rm -f {REMOTE_TAR} - sudo chown -R carlos:carlos /home/carlos/.docker || true + echo '{PASSWORD}' | sudo -S chown -R carlos:carlos /home/carlos/.docker || true chmod -R 777 /home/carlos/.docker || true cd {REMOTE_DIR} docker compose -f docker-compose.prod.yml down --remove-orphans || true @@ -58,6 +60,7 @@ def deploy(): print("[4/4] Ejecutando compilacion y levantando contenedor en Produccion...") stdin, stdout, stderr = client.exec_command(commands) + while not stdout.channel.exit_status_ready(): line = stdout.readline() if line: diff --git a/dripzone-app/docker-compose.prod.yml b/dripzone-app/docker-compose.prod.yml index d13bd2e..cb8e58c 100644 --- a/dripzone-app/docker-compose.prod.yml +++ b/dripzone-app/docker-compose.prod.yml @@ -23,14 +23,22 @@ services: - coolify labels: - "traefik.enable=true" - - "traefik.http.routers.dripzone-http.rule=Host(`dripzone.almeira.com.co`)" - - "traefik.http.routers.dripzone-http.entrypoints=http" - - "traefik.http.routers.dripzone-https.rule=Host(`dripzone.almeira.com.co`)" - - "traefik.http.routers.dripzone-https.entrypoints=https" + - "traefik.docker.network=coolify" + - "traefik.http.middlewares.gzip.compress=true" + - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" + - "traefik.http.routers.dripzone-http.entryPoints=http" + - "traefik.http.routers.dripzone-http.middlewares=redirect-to-https" + - "traefik.http.routers.dripzone-http.rule=Host(`dripzone.almeira.com.co`) && PathPrefix(`/`)" + - "traefik.http.routers.dripzone-http.service=dripzone-svc" + - "traefik.http.routers.dripzone-https.entryPoints=https" + - "traefik.http.routers.dripzone-https.middlewares=gzip" + - "traefik.http.routers.dripzone-https.rule=Host(`dripzone.almeira.com.co`) && PathPrefix(`/`)" + - "traefik.http.routers.dripzone-https.service=dripzone-svc" - "traefik.http.routers.dripzone-https.tls=true" - "traefik.http.routers.dripzone-https.tls.certresolver=letsencrypt" - - "traefik.http.services.dripzone.loadbalancer.server.port=3000" + - "traefik.http.services.dripzone-svc.loadbalancer.server.port=3000" networks: coolify: external: true + diff --git a/dripzone-app/src/app/admin/configuracion/page.tsx b/dripzone-app/src/app/admin/configuracion/page.tsx index 8afa8fe..42e814d 100644 --- a/dripzone-app/src/app/admin/configuracion/page.tsx +++ b/dripzone-app/src/app/admin/configuracion/page.tsx @@ -143,7 +143,7 @@ export default function AdminSettingsPage() { handlePhoneChange(e.target.value)} className="w-full bg-neutral-950 border border-neutral-800 rounded-xl pl-9 pr-3.5 py-2.5 text-emerald-300 font-bold outline-hidden focus:border-emerald-400" @@ -159,6 +159,7 @@ export default function AdminSettingsPage() { handleChange('instagramHandle', e.target.value)} className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-purple-400" @@ -166,9 +167,10 @@ export default function AdminSettingsPage() {
- + handleChange('city', e.target.value)} className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-purple-400" @@ -203,11 +205,12 @@ export default function AdminSettingsPage() {
handleChange('boldPaymentLink', e.target.value)} className="w-full bg-neutral-950 border border-indigo-500/40 rounded-xl pl-9 pr-3.5 py-2.5 text-white font-mono outline-hidden focus:border-indigo-400" /> +

diff --git a/dripzone-app/src/app/admin/feed-whatsapp/page.tsx b/dripzone-app/src/app/admin/feed-whatsapp/page.tsx index f6dd82b..a8bde4a 100644 --- a/dripzone-app/src/app/admin/feed-whatsapp/page.tsx +++ b/dripzone-app/src/app/admin/feed-whatsapp/page.tsx @@ -157,11 +157,13 @@ export default function SupplierFeedPage() { action: 'publish', post, customName: customData?.name, + customBrand: customData?.brand, customColorName: customData?.colorName, customRetailPrice: customData?.retailPrice, customSizes: customData?.sizes, }), }); + const data = await res.json(); if (data.success) { setPosts((prev) => @@ -732,28 +734,68 @@ export default function SupplierFeedPage() {

)} -
-
- - setEditBrand(e.target.value)} - className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-500" - /> -
- -
- - setEditPrice(Number(e.target.value))} - className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-emerald-300 font-black font-mono outline-hidden focus:border-emerald-500" - /> +
+ + setEditBrand(e.target.value)} + placeholder="Ej: Adidas, New Balance, Nike, On Running..." + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white font-bold outline-hidden focus:border-purple-500 mb-1.5" + /> +
+ {[ + 'Adidas', + 'Nike', + 'Jordan', + 'New Balance', + 'On Running', + 'Puma', + 'Asics', + 'Vans', + 'Converse', + 'Salomon', + 'Hoka', + 'Balenciaga', + 'Alexander McQueen', + 'Golden Goose', + 'Crocs', + 'Lacoste', + 'Timberland', + 'Diesel', + 'Oakley', + 'Veja', + 'Autry', + 'Urbano / Variado', + ].map((b) => ( + + ))}
+ +
+ + setEditPrice(Number(e.target.value))} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-emerald-300 font-black font-mono outline-hidden focus:border-emerald-500" + /> +
+ +
diff --git a/dripzone-app/src/app/admin/metodos-pago/page.tsx b/dripzone-app/src/app/admin/metodos-pago/page.tsx index f80adad..d2b0467 100644 --- a/dripzone-app/src/app/admin/metodos-pago/page.tsx +++ b/dripzone-app/src/app/admin/metodos-pago/page.tsx @@ -141,9 +141,10 @@ export default function AdminPaymentMethodsPage() { value={method.accountInfo || ''} onChange={(e) => handleUpdateField(method.id, 'accountInfo', e.target.value)} className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden" - placeholder="Ej. Nequi / Daviplata: 316 745 5877" + placeholder="Ej: Nequi / Daviplata: 3XX XXX XXXX" />
+
diff --git a/dripzone-app/src/app/admin/pedidos/page.tsx b/dripzone-app/src/app/admin/pedidos/page.tsx index b6006b5..ef89845 100644 --- a/dripzone-app/src/app/admin/pedidos/page.tsx +++ b/dripzone-app/src/app/admin/pedidos/page.tsx @@ -31,6 +31,7 @@ import { } from 'lucide-react'; import { Order, OrderItem, Supplier, City } from '@/types'; import { formatCOP } from '@/lib/pricing-engine'; +import { initClientTemplates, renderClientTemplate } from '@/lib/templates-client'; export default function AdminOrdersPage() { const [orders, setOrders] = useState([]); @@ -66,8 +67,10 @@ export default function AdminOrdersPage() { fetchOrders(); fetchSuppliers(); fetchCities(); + initClientTemplates(); }, []); + const fetchOrders = async () => { try { const r = await fetch('/api/orders'); @@ -271,7 +274,15 @@ export default function AdminOrdersPage() { const supplierName = item.supplierName || 'Proveedor Mayorista'; const supplierCost = item.supplierPrice ? formatCOP(item.supplierPrice) : 'Precio Mayorista'; - const message = `👋 Hola *${supplierName}*!\n\nPor favor *sepárame este par* para despacho inmediato:\n\n👟 *Modelo:* ${item.productName}\n📏 *Talla:* ${item.size}\n📦 *Cantidad:* ${item.quantity} par(es)\n💵 *Costo Base:* ${supplierCost}\n\n🏷️ *Orden Interna:* #${order.id}\n📍 *Ciudad Destino:* ${order.cityName}\n\n¿Me confirmas si lo tienes listo para recoger / enviar? ¡Muchas gracias!`; + const message = renderClientTemplate('supplier_single_separation', { + proveedor_nombre: supplierName, + zapatilla_nombre: item.productName, + talla: item.size, + cantidad: item.quantity, + costo_proveedor: supplierCost, + orden_id: order.id, + ciudad_destino: order.cityName, + }); const url = `https://wa.me/${supplierPhone}?text=${encodeURIComponent(message)}`; window.open(url, '_blank'); @@ -286,21 +297,18 @@ export default function AdminOrdersPage() { const totalPairs = itemsList.reduce((acc, curr) => acc + curr.item.quantity, 0); const totalCost = itemsList.reduce((acc, curr) => acc + (curr.item.supplierPrice || 0) * curr.item.quantity, 0); - let text = `📦 *MANIFIESTO CONSOLIDADO DE SEPARACIÓN*\n`; - text += `🏢 *Proveedor:* ${supplierName}\n`; - text += `📅 *Fecha:* ${new Date().toLocaleDateString('es-CO')} | *Total Pares:* ${totalPairs}\n`; - text += `💰 *Valor Total a Pagar Proveedor:* ${formatCOP(totalCost)}\n\n`; - text += `👟 *DETALLE DE PARES A SEPARAR:*\n`; + const itemsSummary = itemsList.map((entry, idx) => + `${idx + 1}. *${entry.item.productName}*\n • Talla: *${entry.item.size}* (x${entry.item.quantity})\n • Costo Base: ${formatCOP(entry.item.supplierPrice || 0)}\n • Orden Cliente: #${entry.order.id} (${entry.order.cityName})` + ).join('\n\n'); - itemsList.forEach((entry, idx) => { - text += `\n${idx + 1}. *${entry.item.productName}*\n`; - text += ` • Talla: *${entry.item.size}* (x${entry.item.quantity})\n`; - text += ` • Costo Base: ${formatCOP(entry.item.supplierPrice || 0)}\n`; - text += ` • Orden Cliente: #${entry.order.id} (${entry.order.cityName})\n`; + const text = renderClientTemplate('supplier_batch_manifest', { + proveedor_nombre: supplierName, + fecha: new Date().toLocaleDateString('es-CO'), + total_pares: totalPairs, + total_costo_proveedor: formatCOP(totalCost), + lista_pares_separacion: itemsSummary, }); - text += `\n🤝 *Por favor confirmar disponibilidad y empaque para recogida del mensajero.* ¡Muchas gracias!`; - const cleanPhone = supplierPhone.startsWith('57') ? supplierPhone : `57${supplierPhone}`; window.open(`https://wa.me/${cleanPhone}?text=${encodeURIComponent(text)}`, '_blank'); setToastMessage(`📲 Manifiesto de ${totalPairs} pares enviado a ${supplierName}.`); @@ -320,12 +328,21 @@ export default function AdminOrdersPage() { const handleNotifyCustomerOnTheWay = (order: Order) => { const cleanPhone = order.customerPhone.startsWith('57') ? order.customerPhone : `57${order.customerPhone}`; - const text = `👋 ¡Hola *${order.customerName}*!\n\n🛵 Tu pedido ya va *EN CAMINO* con nuestro mensajero.\n\n📍 *Dirección de Entrega:* ${order.address}\n🏙️ *Ciudad:* ${order.cityName}\n💵 *Saldo a Pagar Contraentrega:* *${formatCOP(order.balanceDue)}*\n💳 *Medio de Pago:* Efectivo o Transferencia Nequi\n\nPor favor estar atento a la portería o celular. ¡Gracias por tu compra! 🔥`; + const text = renderClientTemplate('order_on_the_way', { + cliente_nombre: order.customerName, + orden_id: order.id, + cliente_direccion: order.address, + cliente_ciudad: order.cityName, + saldo_contraentrega: formatCOP(order.balanceDue), + metodo_pago: order.paymentMethodName || 'Contraentrega', + tienda_nombre: 'DripZone Luxury', + }); window.open(`https://wa.me/${cleanPhone}?text=${encodeURIComponent(text)}`, '_blank'); handleStatusChange(order.id, 'dispatched'); }; + const getStatusBadge = (status: Order['status']) => { switch (status) { case 'pending': diff --git a/dripzone-app/src/app/admin/plantillas/page.tsx b/dripzone-app/src/app/admin/plantillas/page.tsx new file mode 100644 index 0000000..557f8b7 --- /dev/null +++ b/dripzone-app/src/app/admin/plantillas/page.tsx @@ -0,0 +1,720 @@ +'use client'; + +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import { + FileText, + Search, + RotateCcw, + Save, + CheckCircle2, + Copy, + ExternalLink, + MessageSquare, + Sparkles, + Info, + ShieldCheck, + Send, + SlidersHorizontal, + Layers, + Code2, + Check, + AlertCircle, + HelpCircle, + Lock, + Edit3, + Eye, +} from 'lucide-react'; +import { MessageTemplate, MessageTemplateCategory, MessageTemplateVariable } from '@/types'; +import { TEMPLATE_CATEGORIES, formatWhatsAppPreviewToHTML, interpolateVariables } from '@/lib/templates-service'; + +export default function MessageTemplatesAdminPage() { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('all'); + const [toastMessage, setToastMessage] = useState(null); + + // Edit Modal State + const [editingTemplate, setEditingTemplate] = useState(null); + const [editContent, setEditContent] = useState(''); + const [editTitle, setEditTitle] = useState(''); + const [editDescription, setEditDescription] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [isResetting, setIsResetting] = useState(false); + const [copiedId, setCopiedId] = useState(null); + + const textareaRef = useRef(null); + + // Cargar plantillas desde API + const fetchTemplates = async () => { + try { + setLoading(true); + const res = await fetch('/api/message-templates'); + const data = await res.json(); + if (data.success && data.templates) { + setTemplates(data.templates); + } + } catch (err) { + console.error('Error fetching templates:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchTemplates(); + }, []); + + const showToast = (msg: string) => { + setToastMessage(msg); + setTimeout(() => setToastMessage(null), 4000); + }; + + // Abrir Modal de Edición + const handleOpenEdit = (template: MessageTemplate) => { + setEditingTemplate(template); + setEditContent(template.content); + setEditTitle(template.title); + setEditDescription(template.description); + }; + + // Insertar variable en la posición del cursor + const handleInsertVariable = (variableKey: string) => { + const tag = `{${variableKey}}`; + if (!textareaRef.current) { + setEditContent((prev) => prev + tag); + return; + } + + const start = textareaRef.current.selectionStart; + const end = textareaRef.current.selectionEnd; + const text = editContent; + const newText = text.substring(0, start) + tag + text.substring(end); + setEditContent(newText); + + // Reposicionar cursor + setTimeout(() => { + if (textareaRef.current) { + textareaRef.current.focus(); + textareaRef.current.setSelectionRange(start + tag.length, start + tag.length); + } + }, 50); + }; + + // Guardar Cambios de la Plantilla + const handleSaveTemplate = async () => { + if (!editingTemplate) return; + setIsSaving(true); + + try { + const res = await fetch('/api/message-templates', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: editingTemplate.id, + content: editContent, + title: editTitle, + description: editDescription, + }), + }); + const data = await res.json(); + if (data.success) { + setTemplates((prev) => + prev.map((t) => (t.id === editingTemplate.id ? { ...t, content: editContent, title: editTitle, description: editDescription } : t)) + ); + showToast(`✅ Plantilla "${editTitle}" guardada con éxito.`); + setEditingTemplate(null); + } else { + alert(data.error || 'Error al guardar'); + } + } catch (err) { + console.error(err); + alert('Error de conexión al guardar plantilla'); + } finally { + setIsSaving(false); + } + }; + + // Restaurar una Plantilla individual a fábrica + const handleResetSingle = async (templateId: string) => { + if (!confirm('¿Restablecer el texto de esta plantilla a la versión original de fábrica?')) { + return; + } + + setIsResetting(true); + try { + const res = await fetch('/api/message-templates', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'reset', id: templateId }), + }); + const data = await res.json(); + if (data.success && data.template) { + setTemplates((prev) => + prev.map((t) => (t.id === templateId ? data.template : t)) + ); + if (editingTemplate && editingTemplate.id === templateId) { + setEditContent(data.template.content); + } + showToast(`🔄 Plantilla "${data.template.title}" restaurada al texto original.`); + } + } catch (err) { + console.error(err); + } finally { + setIsResetting(false); + } + }; + + // Restaurar todas las plantillas + const handleResetAll = async () => { + if (!confirm('⚠️ ¿Estás seguro de restablecer TODAS las plantillas a sus valores predeterminados de fábrica? Esta acción sobreescribirá tus textos personalizados.')) { + return; + } + + try { + const res = await fetch('/api/message-templates', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'reset_all' }), + }); + const data = await res.json(); + if (data.success) { + setTemplates(data.templates); + showToast('🎉 Todas las plantillas fueron restauradas a los textos de fábrica.'); + } + } catch (err) { + console.error(err); + } + }; + + // Copiar al Portapapeles + const handleCopy = (text: string, id: string) => { + navigator.clipboard.writeText(text); + setCopiedId(id); + showToast('📋 Texto copiado al portapapeles.'); + setTimeout(() => setCopiedId(null), 2000); + }; + + // Probar en WhatsApp con datos simulados + const handleTestInWhatsApp = (template: MessageTemplate, currentTextOverride?: string) => { + const sampleVars: Record = {}; + template.availableVariables.forEach((v) => { + sampleVars[v.key] = v.example; + }); + + const rendered = interpolateVariables(currentTextOverride || template.content, sampleVars); + const url = `https://wa.me/573167455877?text=${encodeURIComponent(rendered)}`; + window.open(url, '_blank'); + }; + + // Generar vista previa con datos de ejemplo para el modal + const sampleDataForPreview = useMemo(() => { + if (!editingTemplate) return {}; + const data: Record = {}; + editingTemplate.availableVariables.forEach((v) => { + data[v.key] = v.example; + }); + return data; + }, [editingTemplate]); + + const renderedPreviewText = useMemo(() => { + if (!editingTemplate) return ''; + return interpolateVariables(editContent, sampleDataForPreview); + }, [editContent, sampleDataForPreview, editingTemplate]); + + // Filtrado de Plantillas + const filteredTemplates = useMemo(() => { + return templates.filter((t) => { + const matchesCat = selectedCategory === 'all' || t.category === selectedCategory; + const q = search.toLowerCase(); + const matchesSearch = + t.title.toLowerCase().includes(q) || + t.description.toLowerCase().includes(q) || + t.id.toLowerCase().includes(q) || + t.content.toLowerCase().includes(q) || + t.availableVariables.some((v) => v.key.toLowerCase().includes(q) || v.label.toLowerCase().includes(q)); + + return matchesCat && matchesSearch; + }); + }, [templates, selectedCategory, search]); + + // Contadores por categoría + const categoryCounts = useMemo(() => { + const counts: Record = { all: templates.length }; + TEMPLATE_CATEGORIES.forEach((c) => { + counts[c.key] = templates.filter((t) => t.category === c.key).length; + }); + return counts; + }, [templates]); + + return ( +
+ {/* Toast Notification */} + {toastMessage && ( +
+ + {toastMessage} +
+ )} + + {/* Header */} +
+
+
+
+ +
+
+
+

+ Plantillas de Mensajes & WhatsApp +

+ + {templates.length} Plantillas Oficiales + +
+

+ Personaliza los textos predeterminados de WhatsApp, OTPs de seguridad, despachos y cotizaciones con variables automáticas. +

+
+
+
+ + {/* Global Actions */} +
+ + + +
+
+ + {/* Categories Tabs */} +
+ + + {TEMPLATE_CATEGORIES.map((cat) => { + const isSelected = selectedCategory === cat.key; + const count = categoryCounts[cat.key] || 0; + return ( + + ); + })} +
+ + {/* Search Bar */} +
+ + setSearch(e.target.value)} + placeholder="Buscar plantilla por título, contenido, ID o variable (ej. cliente_nombre, orden_id, OTP)..." + className="w-full bg-neutral-900 border border-neutral-800 rounded-2xl pl-11 pr-4 py-3 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-purple-500 transition-colors shadow-inner" + /> + {search && ( + + )} +
+ + {/* Templates Grid */} + {loading ? ( +
+
+

Cargando catálogo de plantillas...

+
+ ) : filteredTemplates.length === 0 ? ( +
+
+ +
+

No se encontraron plantillas

+

+ No hay plantillas que coincidan con "{search}" en la categoría seleccionada. +

+ +
+ ) : ( +
+ {filteredTemplates.map((template) => { + const isCustomized = template.content !== template.defaultContent; + const catMeta = TEMPLATE_CATEGORIES.find((c) => c.key === template.category); + + return ( +
+ {/* Card Header */} +
+
+
+ + {catMeta?.emoji} {catMeta?.name} + + + + + WhatsApp + + + {isCustomized ? ( + + + Personalizado + + ) : ( + + + Fábrica + + )} +
+ +
+ {template.id} +
+
+ +
+

+ {template.title} +

+

+ {template.description} +

+
+
+ + {/* WhatsApp Chat Preview Snippet */} +
+
+ + + Vista Previa de Mensaje + + + {template.content.length} caracteres + +
+ +
+ {template.content} +
+
+ + {/* Available Variables Chips */} +
+
+ + + Variables Dinámicas ({template.availableVariables.length}): + +
+
+ {template.availableVariables.map((v) => ( + + {`{${v.key}}`} + + ))} +
+
+ + {/* Card Action Buttons */} +
+
+ + + +
+ +
+ {isCustomized && ( + + )} + + +
+
+
+ ); + })} +
+ )} + + {/* EDIT TEMPLATE MODAL */} + {editingTemplate && ( +
+
+ {/* Modal Header */} +
+
+
+ +
+
+
+

{editTitle}

+ + {editingTemplate.id} + +
+

{editDescription}

+
+
+ + +
+ + {/* Modal Body */} +
+ {/* Variable Insertion Chips */} +
+
+ + + Haz clic en una variable para insertarla en el texto: + + + Se reemplazará automáticamente con datos reales al enviar + +
+ +
+ {editingTemplate.availableVariables.map((v) => ( + + ))} +
+
+ + {/* Textarea Editor & Live Preview Grid */} +
+ {/* Editor Textarea */} +
+
+ + + {editContent.length} caracteres | {editContent.split(/\s+/).filter(Boolean).length} palabras + +
+ +