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
+
+
+
+
+
+ {/* Live WhatsApp Bubble Preview */}
+
+
+
+
+ Tiempo Real
+
+
+
+ {/* WhatsApp Chat Simulation Container */}
+
+ {/* Simulated Message Bubble */}
+
+
+
+ {new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+ ✓✓
+
+
+
+
+
+ 📱 Simulación directa del mensaje en el teléfono del destinatario.
+
+
+
+
+
+
+
+ {/* Variables Reference Guide */}
+
+
+
+ Guía de Variables Disponibles para esta Plantilla
+
+
+ {editingTemplate.availableVariables.map((v) => (
+
+
+ {`{${v.key}}`}
+ {v.label}
+
+
+ {v.description || 'Variable del sistema'} • Ej: "{v.example}"
+
+
+ ))}
+
+
+
+
+ {/* Modal Footer */}
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/dripzone-app/src/app/admin/productos/page.tsx b/dripzone-app/src/app/admin/productos/page.tsx
index ef2a800..38194e7 100644
--- a/dripzone-app/src/app/admin/productos/page.tsx
+++ b/dripzone-app/src/app/admin/productos/page.tsx
@@ -1,11 +1,15 @@
'use client';
import React, { useState, useEffect } from 'react';
-import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck, Trash2, Edit, Wand2, ArrowRight, Layers, Palette, CheckSquare, Square } from 'lucide-react';
+import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck, Trash2, Edit, Wand2, ArrowRight, Layers, Palette, CheckSquare, Square, Split } from 'lucide-react';
import { Product, Supplier } from '@/types';
import { INITIAL_PRODUCTS, INITIAL_SUPPLIERS } from '@/lib/data-service';
import { formatCOP } from '@/lib/pricing-engine';
import { GroupProductsModal } from '@/components/admin/GroupProductsModal';
+import { ManageVariantsModal } from '@/components/admin/ManageVariantsModal';
+import { renderClientTemplate, initClientTemplates } from '@/lib/templates-client';
+
+
export default function AdminProductsPage() {
const [products, setProducts] = useState(INITIAL_PRODUCTS);
@@ -18,8 +22,17 @@ export default function AdminProductsPage() {
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
const [selectedProductIds, setSelectedProductIds] = useState([]);
const [editingProduct, setEditingProduct] = useState(null);
+ const [managingVariantsProduct, setManagingVariantsProduct] = useState(null);
const [toastMessage, setToastMessage] = useState(null);
+ const refreshProductsList = () => {
+ fetch('/api/products?all=true')
+ .then((r) => r.json())
+ .then((d) => d.success && setProducts(d.products))
+ .catch(() => {});
+ };
+
+
// New product form
const [name, setName] = useState('');
const [brand, setBrand] = useState('Nike');
@@ -58,6 +71,8 @@ export default function AdminProductsPage() {
.then((r) => r.json())
.then((d) => d.success && d.suppliers && setSuppliers(d.suppliers))
.catch(() => {});
+
+ initClientTemplates();
}, []);
const handleToggleProductStatus = async (product: Product) => {
@@ -91,12 +106,17 @@ export default function AdminProductsPage() {
const handleContactSupplier = (product: Product) => {
const phone = product.supplierPhone || '573134848203';
const suppName = product.supplierName || 'Proveedor Mayorista';
- const text = encodeURIComponent(
- `Hola ${suppName}! 👋 Consulta sobre disponibilidad del modelo *${product.name}* (Costo base: ${formatCOP(product.supplierPrice)}).`
- );
+ const rawText = renderClientTemplate('supplier_product_inquiry', {
+ proveedor_nombre: suppName,
+ zapatilla_nombre: product.name,
+ costo_proveedor: formatCOP(product.supplierPrice),
+ marca: product.brand || 'Sneakers',
+ });
+ const text = encodeURIComponent(rawText);
window.open(`https://wa.me/${phone}?text=${text}`, '_blank');
};
+
const handleDeleteProduct = async (product: Product) => {
if (!window.confirm(`¿Estás seguro de eliminar permanentemente la publicación "${product.name}"?`)) {
return;
@@ -544,11 +564,24 @@ export default function AdminProductsPage() {
{prod.name}
{prod.category}
- {colorwaysCount > 1 && (
-
+ {colorwaysCount > 1 ? (
+
+
{colorwaysCount} Colores
+
+ ) : (
+
)}
@@ -590,9 +623,20 @@ export default function AdminProductsPage() {
- {/* Action buttons (Edit & Delete) */}
+ {/* Action buttons (Manage Variants, Edit & Delete) */}
+
|