feat: plantillas, reconocimiento de marcas con vision y gestion de variantes
This commit is contained in:
parent
f2ac7876f7
commit
d8c80091ba
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -143,7 +143,7 @@ export default function AdminSettingsPage() {
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
placeholder="ej: 3167455877"
|
||||
placeholder="Ej: 3XX XXX XXXX"
|
||||
value={settings.phone}
|
||||
onChange={(e) => 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() {
|
||||
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Instagram (@usuario)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej: @mitienda.oficial"
|
||||
value={settings.instagramHandle || ''}
|
||||
onChange={(e) => 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() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Ciudad Principal / Bodega</label>
|
||||
<label className="block text-[11px] font-bold text-neutral-400 mb-1">Ciudad / Bodega Principal</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej: Bogotá, Medellín, Cali..."
|
||||
value={settings.city}
|
||||
onChange={(e) => 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() {
|
||||
<div className="relative">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://bold.co/p/carlos-lasso"
|
||||
placeholder="https://bold.co/p/mi-enlace-de-pago"
|
||||
value={settings.boldPaymentLink || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
|
||||
<ExternalLink className="w-4 h-4 text-indigo-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
<p className="text-[10px] text-neutral-400 mt-1">
|
||||
|
||||
@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="font-bold text-neutral-300 block mb-1">Marca:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editBrand}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-bold text-emerald-400 block mb-1">Precio de Venta (COP):</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editPrice}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div>
|
||||
<label className="font-bold text-neutral-300 block mb-1">Marca del Calzado:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editBrand}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1 max-h-20 overflow-y-auto">
|
||||
{[
|
||||
'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) => (
|
||||
<button
|
||||
key={b}
|
||||
type="button"
|
||||
onClick={() => setEditBrand(b)}
|
||||
className={`text-[10px] px-2 py-0.5 rounded-md font-semibold transition-all cursor-pointer ${
|
||||
editBrand.toLowerCase() === b.toLowerCase()
|
||||
? 'bg-purple-600 text-white shadow-xs'
|
||||
: 'bg-neutral-800 hover:bg-neutral-700 text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{b}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label className="font-bold text-emerald-400 block mb-1">Precio de Venta (COP):</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editPrice}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label className="font-bold text-neutral-300 block mb-1">Tallas (separadas por coma):</label>
|
||||
<input
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
MessageSquare,
|
||||
Building2,
|
||||
ShieldAlert,
|
||||
FileText,
|
||||
LogIn,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
@ -32,6 +33,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
{ href: '/admin/proveedores', label: 'Proveedores & Bodegas', icon: Building2, badge: 'Activos/Inactivos' },
|
||||
{ href: '/admin/grupos-whatsapp', label: 'Grupos WhatsApp', icon: MessageSquare, badge: 'Monitoreo 24/7' },
|
||||
{ href: '/admin/usuarios', label: 'Control de Usuarios', icon: Users, badge: 'Roles & Accesos' },
|
||||
{ href: '/admin/plantillas', label: 'Plantillas de Mensajes', icon: FileText, badge: 'WhatsApp & OTP' },
|
||||
{ href: '/admin/configuracion', label: 'Datos de la Tienda', icon: Store },
|
||||
{ href: '/admin/feed-whatsapp', label: 'Feed WhatsApp & IA', icon: Radio, badge: 'Anti-Duplicados' },
|
||||
{ href: '/admin/productos', label: 'Catálogo de Zapatillas', icon: Package },
|
||||
@ -40,6 +42,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
{ href: '/admin/pedidos', label: 'Pedidos & Logística', icon: ShoppingBag },
|
||||
];
|
||||
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-950 text-white flex flex-col items-center justify-center p-4">
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
|
||||
@ -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<Order[]>([]);
|
||||
@ -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':
|
||||
|
||||
720
dripzone-app/src/app/admin/plantillas/page.tsx
Normal file
720
dripzone-app/src/app/admin/plantillas/page.tsx
Normal file
@ -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<MessageTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
// Edit Modal State
|
||||
const [editingTemplate, setEditingTemplate] = useState<MessageTemplate | null>(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<string | null>(null);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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<string, number> = { all: templates.length };
|
||||
TEMPLATE_CATEGORIES.forEach((c) => {
|
||||
counts[c.key] = templates.filter((t) => t.category === c.key).length;
|
||||
});
|
||||
return counts;
|
||||
}, [templates]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-16">
|
||||
{/* Toast Notification */}
|
||||
{toastMessage && (
|
||||
<div className="fixed top-6 right-6 z-50 bg-neutral-900 border border-purple-500/40 text-white px-5 py-3 rounded-2xl shadow-2xl backdrop-blur-xl flex items-center space-x-3 animate-in slide-in-from-top-4">
|
||||
<Sparkles className="w-5 h-5 text-purple-400 animate-pulse" />
|
||||
<span className="text-xs font-bold">{toastMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-neutral-900 via-neutral-900/90 to-purple-950/40 border border-purple-500/20 p-6 sm:p-8 rounded-3xl backdrop-blur-xl shadow-xl">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-purple-500/20 border border-purple-500/30 flex items-center justify-center text-purple-400 shadow-inner">
|
||||
<Code2 className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl sm:text-3xl font-black text-white tracking-tight">
|
||||
Plantillas de Mensajes & WhatsApp
|
||||
</h1>
|
||||
<span className="bg-purple-500/20 text-purple-300 border border-purple-500/30 text-[10px] font-bold px-2.5 py-0.5 rounded-full uppercase">
|
||||
{templates.length} Plantillas Oficiales
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-neutral-400">
|
||||
Personaliza los textos predeterminados de WhatsApp, OTPs de seguridad, despachos y cotizaciones con variables automáticas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={fetchTemplates}
|
||||
className="bg-neutral-800/80 hover:bg-neutral-700 text-neutral-300 hover:text-white px-4 py-2.5 rounded-xl text-xs font-bold flex items-center gap-2 transition-all cursor-pointer border border-neutral-700/60 shadow-sm"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span>Recargar</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleResetAll}
|
||||
className="bg-red-500/10 hover:bg-red-500/20 text-red-400 hover:text-red-300 border border-red-500/30 px-4 py-2.5 rounded-xl text-xs font-bold flex items-center gap-2 transition-all cursor-pointer shadow-sm"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 text-red-400" />
|
||||
<span>Restaurar Fábrica</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories Tabs */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-none">
|
||||
<button
|
||||
onClick={() => setSelectedCategory('all')}
|
||||
className={`px-4 py-2.5 rounded-2xl text-xs font-bold flex items-center gap-2 transition-all shrink-0 cursor-pointer ${
|
||||
selectedCategory === 'all'
|
||||
? 'bg-purple-600 text-white shadow-lg shadow-purple-600/30'
|
||||
: 'bg-neutral-900/80 text-neutral-400 hover:text-white hover:bg-neutral-800 border border-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<span>🌟 Todas las Plantillas</span>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full ${selectedCategory === 'all' ? 'bg-white/20 text-white' : 'bg-neutral-800 text-neutral-400'}`}>
|
||||
{categoryCounts.all || 0}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{TEMPLATE_CATEGORIES.map((cat) => {
|
||||
const isSelected = selectedCategory === cat.key;
|
||||
const count = categoryCounts[cat.key] || 0;
|
||||
return (
|
||||
<button
|
||||
key={cat.key}
|
||||
onClick={() => setSelectedCategory(cat.key)}
|
||||
className={`px-4 py-2.5 rounded-2xl text-xs font-bold flex items-center gap-2 transition-all shrink-0 cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-purple-600 text-white shadow-lg shadow-purple-600/30'
|
||||
: 'bg-neutral-900/80 text-neutral-400 hover:text-white hover:bg-neutral-800 border border-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<span>{cat.emoji} {cat.name}</span>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full ${isSelected ? 'bg-white/20 text-white' : 'bg-neutral-800 text-neutral-400'}`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-xs text-neutral-400 hover:text-white"
|
||||
>
|
||||
Limpiar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Templates Grid */}
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-neutral-400 space-y-3">
|
||||
<div className="w-8 h-8 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-xs font-medium">Cargando catálogo de plantillas...</p>
|
||||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div className="bg-neutral-900/50 border border-neutral-800 rounded-3xl p-12 text-center space-y-4">
|
||||
<div className="w-12 h-12 rounded-full bg-neutral-800 flex items-center justify-center mx-auto text-neutral-500">
|
||||
<Search className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-white">No se encontraron plantillas</h3>
|
||||
<p className="text-xs text-neutral-400 max-w-sm mx-auto">
|
||||
No hay plantillas que coincidan con "{search}" en la categoría seleccionada.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setSelectedCategory('all');
|
||||
}}
|
||||
className="bg-purple-600 hover:bg-purple-500 text-white font-bold text-xs px-4 py-2 rounded-xl transition-all cursor-pointer"
|
||||
>
|
||||
Ver Todas las Plantillas
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{filteredTemplates.map((template) => {
|
||||
const isCustomized = template.content !== template.defaultContent;
|
||||
const catMeta = TEMPLATE_CATEGORIES.find((c) => c.key === template.category);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
className="bg-neutral-900/90 border border-neutral-800 hover:border-purple-500/40 rounded-3xl p-6 transition-all duration-200 flex flex-col justify-between space-y-5 shadow-lg group hover:shadow-purple-500/5"
|
||||
>
|
||||
{/* Card Header */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-[10px] font-bold px-2.5 py-1 rounded-full border ${catMeta?.colorBadge || 'bg-neutral-800 text-neutral-300'}`}>
|
||||
{catMeta?.emoji} {catMeta?.name}
|
||||
</span>
|
||||
|
||||
<span className="bg-neutral-800/80 text-neutral-300 border border-neutral-700/60 text-[10px] font-bold px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<MessageSquare className="w-3 h-3 text-emerald-400" />
|
||||
<span>WhatsApp</span>
|
||||
</span>
|
||||
|
||||
{isCustomized ? (
|
||||
<span className="bg-amber-500/20 text-amber-300 border border-amber-500/30 text-[10px] font-bold px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<Edit3 className="w-3 h-3 text-amber-400" />
|
||||
<span>Personalizado</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-neutral-800/50 text-neutral-400 border border-neutral-700/40 text-[10px] font-medium px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
<span>Fábrica</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] font-mono text-neutral-500 bg-neutral-950 px-2 py-1 rounded-lg border border-neutral-800/80">
|
||||
{template.id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white group-hover:text-purple-300 transition-colors">
|
||||
{template.title}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-400 mt-1 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WhatsApp Chat Preview Snippet */}
|
||||
<div className="bg-neutral-950 border border-neutral-800/90 rounded-2xl p-4 relative overflow-hidden">
|
||||
<div className="text-[10px] font-bold text-neutral-500 uppercase tracking-wider mb-2 flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-emerald-400 font-semibold">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
Vista Previa de Mensaje
|
||||
</span>
|
||||
<span className="text-[10px] text-neutral-500 font-mono">
|
||||
{template.content.length} caracteres
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#121b22] border border-[#222e35] rounded-xl p-3 text-xs text-neutral-200 font-sans leading-relaxed max-h-36 overflow-y-auto whitespace-pre-wrap select-text selection:bg-purple-900 selection:text-white">
|
||||
{template.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Variables Chips */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-[11px] text-neutral-400">
|
||||
<span className="font-semibold flex items-center gap-1">
|
||||
<Code2 className="w-3.5 h-3.5 text-purple-400" />
|
||||
Variables Dinámicas ({template.availableVariables.length}):
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 max-h-16 overflow-hidden">
|
||||
{template.availableVariables.map((v) => (
|
||||
<span
|
||||
key={v.key}
|
||||
title={`${v.label} (Ej: ${v.example})`}
|
||||
className="bg-purple-950/60 hover:bg-purple-900/80 text-purple-300 border border-purple-500/30 text-[10px] font-mono px-2 py-0.5 rounded-md transition-colors cursor-help"
|
||||
>
|
||||
{`{${v.key}}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Action Buttons */}
|
||||
<div className="pt-2 border-t border-neutral-800 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleCopy(template.content, template.id)}
|
||||
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white p-2.5 rounded-xl text-xs font-semibold transition-all cursor-pointer"
|
||||
title="Copiar texto de plantilla"
|
||||
>
|
||||
{copiedId === template.id ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleTestInWhatsApp(template)}
|
||||
className="bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 hover:text-emerald-300 border border-emerald-500/30 px-3 py-2 rounded-xl text-xs font-bold flex items-center gap-1.5 transition-all cursor-pointer"
|
||||
title="Probar en WhatsApp con datos de ejemplo"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>Probar</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isCustomized && (
|
||||
<button
|
||||
onClick={() => handleResetSingle(template.id)}
|
||||
className="text-neutral-400 hover:text-red-400 text-xs font-bold p-2 transition-colors cursor-pointer"
|
||||
title="Restaurar a texto de fábrica"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleOpenEdit(template)}
|
||||
className="bg-gradient-to-r from-purple-600 to-fuchsia-500 hover:opacity-95 text-white font-black text-xs px-4 py-2.5 rounded-xl flex items-center gap-2 shadow-md shadow-purple-600/30 cursor-pointer transition-all active:scale-95"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
<span>Editar Plantilla</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EDIT TEMPLATE MODAL */}
|
||||
{editingTemplate && (
|
||||
<div className="fixed inset-0 z-50 bg-black/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-6 overflow-y-auto animate-in fade-in duration-200">
|
||||
<div className="bg-neutral-900 border border-purple-500/30 text-white rounded-3xl max-w-4xl w-full overflow-hidden shadow-2xl my-auto max-h-[95vh] flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="p-6 border-b border-neutral-800 bg-neutral-950/80 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-500/20 border border-purple-500/30 flex items-center justify-center text-purple-400">
|
||||
<Edit3 className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-black text-white">{editTitle}</h2>
|
||||
<span className="text-[10px] font-mono text-purple-300 bg-purple-950 border border-purple-500/30 px-2 py-0.5 rounded">
|
||||
{editingTemplate.id}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mt-0.5">{editDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setEditingTemplate(null)}
|
||||
className="w-9 h-9 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white flex items-center justify-center transition-all cursor-pointer border border-neutral-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 overflow-y-auto space-y-6 flex-1">
|
||||
{/* Variable Insertion Chips */}
|
||||
<div className="bg-neutral-950 border border-purple-500/20 rounded-2xl p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-purple-300 flex items-center gap-1.5">
|
||||
<Sparkles className="w-4 h-4 text-purple-400" />
|
||||
Haz clic en una variable para insertarla en el texto:
|
||||
</span>
|
||||
<span className="text-[10px] text-neutral-400">
|
||||
Se reemplazará automáticamente con datos reales al enviar
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{editingTemplate.availableVariables.map((v) => (
|
||||
<button
|
||||
key={v.key}
|
||||
type="button"
|
||||
onClick={() => handleInsertVariable(v.key)}
|
||||
className="bg-purple-950/80 hover:bg-purple-800 text-purple-200 border border-purple-500/40 hover:border-purple-400 text-xs font-mono font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 shadow-sm active:scale-95"
|
||||
>
|
||||
<span>{`{${v.key}}`}</span>
|
||||
<span className="text-[10px] font-sans font-normal text-purple-300/80">({v.label})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Textarea Editor & Live Preview Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Editor Textarea */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-neutral-300">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<Edit3 className="w-3.5 h-3.5 text-purple-400" />
|
||||
Editor de Plantilla
|
||||
</label>
|
||||
<span className="text-[11px] font-mono text-neutral-400">
|
||||
{editContent.length} caracteres | {editContent.split(/\s+/).filter(Boolean).length} palabras
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={12}
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 focus:border-purple-500 rounded-2xl p-4 text-xs font-mono text-neutral-100 placeholder-neutral-600 focus:outline-none leading-relaxed transition-all shadow-inner resize-y min-h-[260px]"
|
||||
placeholder="Escribe el texto de la plantilla aquí..."
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between text-[11px] text-neutral-400 px-1">
|
||||
<span>Usa <strong className="text-white">*negrita*</strong>, <strong className="text-white">_cursiva_</strong> o saltos de línea para dar estilo de WhatsApp.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditContent(editingTemplate.defaultContent)}
|
||||
className="text-purple-400 hover:text-purple-300 underline cursor-pointer text-[11px]"
|
||||
>
|
||||
Cargar texto de fábrica
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live WhatsApp Bubble Preview */}
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-neutral-300">
|
||||
<label className="flex items-center gap-1.5 text-emerald-400">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
Vista Previa WhatsApp (Con Datos de Prueba)
|
||||
</label>
|
||||
<span className="text-[10px] text-emerald-400/80 bg-emerald-950/60 border border-emerald-500/30 px-2 py-0.5 rounded-md font-mono">
|
||||
Tiempo Real
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* WhatsApp Chat Simulation Container */}
|
||||
<div className="bg-[#0b141a] border border-[#222e35] rounded-2xl p-4 flex-1 flex flex-col justify-between min-h-[260px] shadow-inner relative overflow-hidden">
|
||||
{/* Simulated Message Bubble */}
|
||||
<div className="bg-[#005c4b] text-[#e9edef] rounded-2xl rounded-tr-xs p-4 text-xs font-sans leading-relaxed shadow-md border border-[#007a64] max-w-[95%] self-end">
|
||||
<div
|
||||
className="whitespace-pre-wrap break-words"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formatWhatsAppPreviewToHTML(renderedPreviewText),
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-1 text-[10px] text-[#8696a0] mt-2 font-mono">
|
||||
<span>{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span className="text-[#53bdeb]">✓✓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-[#222e35] flex items-center justify-between">
|
||||
<span className="text-[10px] text-neutral-400">
|
||||
📱 Simulación directa del mensaje en el teléfono del destinatario.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTestInWhatsApp(editingTemplate, editContent)}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-[11px] px-3 py-1 rounded-lg transition-all cursor-pointer flex items-center gap-1 shadow-sm"
|
||||
>
|
||||
<Send className="w-3 h-3" />
|
||||
<span>Abrir en WhatsApp</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Variables Reference Guide */}
|
||||
<div className="bg-neutral-950 border border-neutral-800 rounded-2xl p-4 space-y-3">
|
||||
<h4 className="text-xs font-bold text-white flex items-center gap-1.5">
|
||||
<HelpCircle className="w-4 h-4 text-purple-400" />
|
||||
Guía de Variables Disponibles para esta Plantilla
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
||||
{editingTemplate.availableVariables.map((v) => (
|
||||
<div key={v.key} className="bg-neutral-900/80 border border-neutral-800 p-2.5 rounded-xl space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-purple-300 font-bold">{`{${v.key}}`}</span>
|
||||
<span className="text-[10px] text-neutral-400 bg-neutral-800 px-1.5 py-0.5 rounded">{v.label}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-neutral-400">
|
||||
{v.description || 'Variable del sistema'} • <span className="text-neutral-300">Ej: "{v.example}"</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="p-6 border-t border-neutral-800 bg-neutral-950/80 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleResetSingle(editingTemplate.id)}
|
||||
disabled={isResetting}
|
||||
className="bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/30 text-xs font-bold px-4 py-2.5 rounded-xl transition-all cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span>{isResetting ? 'Restableciendo...' : 'Restaurar Predeterminado'}</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTemplate(null)}
|
||||
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white text-xs font-bold px-4 py-2.5 rounded-xl transition-all cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveTemplate}
|
||||
disabled={isSaving}
|
||||
className="bg-gradient-to-r from-purple-600 to-fuchsia-500 hover:opacity-95 text-white font-black text-xs px-6 py-2.5 rounded-xl flex items-center gap-2 shadow-lg shadow-purple-600/30 cursor-pointer transition-all active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>{isSaving ? 'Guardando...' : 'Guardar Plantilla'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<Product[]>(INITIAL_PRODUCTS);
|
||||
@ -18,8 +22,17 @@ export default function AdminProductsPage() {
|
||||
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
|
||||
const [selectedProductIds, setSelectedProductIds] = useState<string[]>([]);
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
const [managingVariantsProduct, setManagingVariantsProduct] = useState<Product | null>(null);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(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() {
|
||||
<p className="font-bold text-white text-xs leading-snug">{prod.name}</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] text-neutral-400">{prod.category}</span>
|
||||
{colorwaysCount > 1 && (
|
||||
<span className="text-[10px] text-purple-400 bg-purple-500/10 border border-purple-500/30 px-1.5 py-0.2 rounded font-bold flex items-center gap-0.5">
|
||||
{colorwaysCount > 1 ? (
|
||||
<button
|
||||
onClick={() => setManagingVariantsProduct(prod)}
|
||||
className="text-[10px] text-purple-400 hover:text-purple-200 bg-purple-500/10 hover:bg-purple-500/20 border border-purple-500/30 px-1.5 py-0.2 rounded font-bold flex items-center gap-0.5 cursor-pointer transition-colors"
|
||||
title="Gestionar variantes, separar o mover colores"
|
||||
>
|
||||
<Palette className="w-2.5 h-2.5" />
|
||||
{colorwaysCount} Colores
|
||||
</span>
|
||||
<span>{colorwaysCount} Colores</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setManagingVariantsProduct(prod)}
|
||||
className="text-[10px] text-neutral-500 hover:text-purple-300 transition-colors flex items-center gap-0.5 cursor-pointer"
|
||||
title="Ver variante"
|
||||
>
|
||||
<Palette className="w-2.5 h-2.5" />
|
||||
<span>1 Color</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -590,9 +623,20 @@ export default function AdminProductsPage() {
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Action buttons (Edit & Delete) */}
|
||||
{/* Action buttons (Manage Variants, Edit & Delete) */}
|
||||
<td className="py-3.5 px-4 text-center">
|
||||
<div className="flex items-center justify-center space-x-1.5">
|
||||
<button
|
||||
onClick={() => setManagingVariantsProduct(prod)}
|
||||
className={`border p-2 rounded-xl transition-all cursor-pointer ${
|
||||
colorwaysCount > 1
|
||||
? 'bg-purple-500/20 hover:bg-purple-500/30 border-purple-500/40 text-purple-300 hover:text-white shadow-xs'
|
||||
: 'bg-neutral-950 hover:bg-purple-950/50 border-neutral-800 hover:border-purple-500/50 text-neutral-400 hover:text-purple-300'
|
||||
}`}
|
||||
title="Gestionar Variantes de Color / Desagrupar / Mover a otro modelo"
|
||||
>
|
||||
<Palette className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenEdit(prod)}
|
||||
className="bg-neutral-950 hover:bg-purple-950/50 border border-neutral-800 hover:border-purple-500/50 text-neutral-400 hover:text-purple-300 p-2 rounded-xl transition-all cursor-pointer"
|
||||
@ -633,7 +677,20 @@ export default function AdminProductsPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Modal de Gestión y Desagrupación de Variantes */}
|
||||
{managingVariantsProduct && (
|
||||
<ManageVariantsModal
|
||||
isOpen={!!managingVariantsProduct}
|
||||
onClose={() => setManagingVariantsProduct(null)}
|
||||
product={managingVariantsProduct}
|
||||
allProducts={products}
|
||||
onRefreshProducts={refreshProductsList}
|
||||
onShowToast={(msg) => setToastMessage(msg)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal 1: Process Single Message with AI (1 a 1) */}
|
||||
|
||||
{isAiModalOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
|
||||
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
|
||||
@ -752,14 +809,32 @@ export default function AdminProductsPage() {
|
||||
onChange={(e) => setBrand(e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
|
||||
>
|
||||
<option value="Adidas">Adidas</option>
|
||||
<option value="Nike">Nike</option>
|
||||
<option value="Jordan">Jordan</option>
|
||||
<option value="Adidas">Adidas</option>
|
||||
<option value="New Balance">New Balance</option>
|
||||
<option value="On Running">On Running</option>
|
||||
<option value="Puma">Puma</option>
|
||||
<option value="Asics">Asics</option>
|
||||
<option value="Vans">Vans</option>
|
||||
<option value="Converse">Converse</option>
|
||||
<option value="Salomon">Salomon</option>
|
||||
<option value="Hoka">Hoka</option>
|
||||
<option value="Balenciaga">Balenciaga</option>
|
||||
<option value="Alexander McQueen">Alexander McQueen</option>
|
||||
<option value="Golden Goose">Golden Goose</option>
|
||||
<option value="Crocs">Crocs</option>
|
||||
<option value="Lacoste">Lacoste</option>
|
||||
<option value="Timberland">Timberland</option>
|
||||
<option value="Diesel">Diesel</option>
|
||||
<option value="Oakley">Oakley</option>
|
||||
<option value="Veja">Veja</option>
|
||||
<option value="Autry">Autry</option>
|
||||
<option value="Urbano / Variado">Urbano / Variado</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label className="block text-neutral-400 font-bold mb-1">Categoría</label>
|
||||
<input
|
||||
|
||||
@ -22,6 +22,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Supplier, Product } from '@/types';
|
||||
import { INITIAL_SUPPLIERS } from '@/lib/data-service';
|
||||
import { renderClientTemplate, initClientTemplates } from '@/lib/templates-client';
|
||||
|
||||
|
||||
export default function AdminSuppliersPage() {
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>(INITIAL_SUPPLIERS);
|
||||
@ -46,8 +48,10 @@ export default function AdminSuppliersPage() {
|
||||
useEffect(() => {
|
||||
fetchSuppliers();
|
||||
fetchProducts();
|
||||
initClientTemplates();
|
||||
}, []);
|
||||
|
||||
|
||||
const fetchSuppliers = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/suppliers?all=true');
|
||||
@ -187,12 +191,16 @@ export default function AdminSuppliersPage() {
|
||||
|
||||
const handleOpenWhatsApp = (supplier: Supplier) => {
|
||||
const cleanPhone = supplier.phone.startsWith('57') ? supplier.phone : `57${supplier.phone}`;
|
||||
const text = encodeURIComponent(
|
||||
`👋 Hola ${supplier.contactPerson || supplier.name}! Te escribo desde el panel de control de DripZone.`
|
||||
);
|
||||
const rawText = renderClientTemplate('supplier_general_contact', {
|
||||
proveedor_contacto: supplier.contactPerson || supplier.name,
|
||||
proveedor_nombre: supplier.name,
|
||||
tienda_nombre: 'DripZone',
|
||||
});
|
||||
const text = encodeURIComponent(rawText);
|
||||
window.open(`https://wa.me/${cleanPhone}?text=${text}`, '_blank');
|
||||
};
|
||||
|
||||
|
||||
// Metrics
|
||||
const activeSuppliers = suppliers.filter((s) => s.isActive !== false);
|
||||
const inactiveSuppliers = suppliers.filter((s) => s.isActive === false);
|
||||
@ -494,7 +502,7 @@ export default function AdminSuppliersPage() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ej. 💎 DIFUSIÓN JOVIC STORE COLOMBIA"
|
||||
placeholder="Ej: Bodega Central Mayorista"
|
||||
value={name}
|
||||
onChange={(e) => setName(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 font-semibold"
|
||||
@ -507,7 +515,7 @@ export default function AdminSuppliersPage() {
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
placeholder="ej: 573167455877"
|
||||
placeholder="Ej: 573XXXXXXXXX"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2.5 text-emerald-300 outline-hidden focus:border-emerald-400 font-mono font-bold"
|
||||
@ -518,7 +526,7 @@ export default function AdminSuppliersPage() {
|
||||
<label className="block text-neutral-400 font-bold mb-1">Persona de Contacto</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej. Asesor Despachos"
|
||||
placeholder="Ej: Asesor de Despachos"
|
||||
value={contactPerson}
|
||||
onChange={(e) => setContactPerson(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"
|
||||
@ -530,7 +538,7 @@ export default function AdminSuppliersPage() {
|
||||
<label className="block text-neutral-400 font-bold mb-1">Ubicación / Dirección de la Bodega</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej. CC MÍO Local 3B / San Andresito Cali"
|
||||
placeholder="Ej: Centro Comercial Mayorista Local 102"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(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"
|
||||
@ -541,13 +549,14 @@ export default function AdminSuppliersPage() {
|
||||
<label className="block text-neutral-400 font-bold mb-1">Notas / Políticas de Garantía & Horarios</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Ej. Especialista en Nike/Jordan Top Quality. Recogida de 9:00 AM a 6:00 PM."
|
||||
placeholder="Ej: Horario de despacho y recogida de 9:00 AM a 6:00 PM."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="bg-neutral-950 p-3 rounded-2xl border border-neutral-800 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-bold text-white text-xs">Estado del Proveedor</p>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Users, UserPlus, Shield, ShieldCheck, UserCheck, MessageCircle, Mail, Phone, MapPin, Eye, EyeOff, Trash2, Edit, Check, X, Lock, Key, AlertTriangle, Sparkles } from 'lucide-react';
|
||||
import { User } from '@/types';
|
||||
import { renderClientTemplate, initClientTemplates } from '@/lib/templates-client';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
@ -38,6 +39,7 @@ export default function AdminUsersPage() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
initClientTemplates();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
@ -199,10 +201,16 @@ export default function AdminUsersPage() {
|
||||
|
||||
const handleWhatsApp = (user: User) => {
|
||||
const cleanPhone = user.phone.startsWith('57') ? user.phone : `57${user.phone}`;
|
||||
const text = encodeURIComponent(`Hola ${user.name}! Te escribimos del equipo de DripZone 🟣`);
|
||||
const rawText = renderClientTemplate('admin_customer_contact', {
|
||||
usuario_nombre: user.name,
|
||||
tienda_nombre: 'DripZone',
|
||||
ciudad: user.city || 'Cali',
|
||||
});
|
||||
const text = encodeURIComponent(rawText);
|
||||
window.open(`https://wa.me/${cleanPhone}?text=${text}`, '_blank');
|
||||
};
|
||||
|
||||
|
||||
const filtered = users.filter((u) => {
|
||||
const matchSearch =
|
||||
u.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
@ -604,7 +612,7 @@ export default function AdminUsersPage() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ej: Mateo Lasso"
|
||||
placeholder="Ej: Juan Pérez"
|
||||
value={name}
|
||||
onChange={(e) => setName(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-400"
|
||||
@ -617,7 +625,7 @@ export default function AdminUsersPage() {
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
placeholder="correo@ejemplo.com"
|
||||
placeholder="micorreo@hotmail.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(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-400"
|
||||
@ -628,7 +636,7 @@ export default function AdminUsersPage() {
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
placeholder="3167455877"
|
||||
placeholder="3XX XXX XXXX"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(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-400"
|
||||
@ -638,10 +646,10 @@ export default function AdminUsersPage() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-neutral-400 font-bold mb-1">Ciudad Principal</label>
|
||||
<label className="block text-neutral-400 font-bold mb-1">Ciudad</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cali / Medellín / Bogotá"
|
||||
placeholder="Ej: Bogotá, Medellín, Cali..."
|
||||
value={city}
|
||||
onChange={(e) => setCity(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-400"
|
||||
@ -663,13 +671,14 @@ export default function AdminUsersPage() {
|
||||
<label className="block text-neutral-400 font-bold mb-1">Dirección de Sede / Despachos</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Calle 5 # 34-12"
|
||||
placeholder="Ej: Calle 123 # 45 - 67, Apto 201"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(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-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label className="block text-purple-400 font-bold mb-1">Rol Asignado *</label>
|
||||
<select
|
||||
|
||||
@ -56,12 +56,18 @@ export async function POST(request: Request) {
|
||||
|
||||
// Generar y enviar OTP para validar su celular por WhatsApp
|
||||
const code = otpStore.generateOtp(cleanPhone, cleanPhone);
|
||||
const message = `🔐 *DripZone Sneakers - Verificación de Cuenta*\n\nHola ${name}! Tu código de seguridad para validar tu cuenta es:\n\n👉 *${code}*\n\n⏱️ _Válido por 5 minutos._`;
|
||||
const storeSettings = dataService.getSettings();
|
||||
const message = dataService.renderMessageTemplate('auth_register_otp', {
|
||||
tienda_nombre: storeSettings?.storeName || 'DripZone Sneakers',
|
||||
usuario_nombre: name.trim(),
|
||||
codigo_otp: code,
|
||||
});
|
||||
|
||||
await sendWhatsAppMessage(cleanPhone, message).catch((e) => {
|
||||
console.warn('[Register OTP] Error al enviar WhatsApp:', e);
|
||||
});
|
||||
|
||||
|
||||
const maskedPhone = cleanPhone.length >= 10
|
||||
? `${cleanPhone.slice(0, 3)}***${cleanPhone.slice(-4)}`
|
||||
: 'tu número';
|
||||
|
||||
@ -35,12 +35,18 @@ export async function POST(request: Request) {
|
||||
// Generar código de 6 dígitos
|
||||
const code = otpStore.generateOtp(cleanInput, phoneToSend);
|
||||
|
||||
// Redactar mensaje de seguridad
|
||||
const message = `🔐 *DripZone Sneakers - Código de Seguridad*\n\nHola ${userName}, tu código de verificación para iniciar sesión es:\n\n👉 *${code}*\n\n⏱️ _Válido por 5 minutos. No compartas este código con nadie._`;
|
||||
// Redactar mensaje de seguridad utilizando plantilla
|
||||
const storeSettings = dataService.getSettings();
|
||||
const message = dataService.renderMessageTemplate('auth_login_otp', {
|
||||
tienda_nombre: storeSettings?.storeName || 'DripZone Sneakers',
|
||||
usuario_nombre: userName,
|
||||
codigo_otp: code,
|
||||
});
|
||||
|
||||
// Enviar a WhatsApp via Evolution API
|
||||
const sent = await sendWhatsAppMessage(phoneToSend, message);
|
||||
|
||||
|
||||
if (!sent) {
|
||||
// Si falla Evolution (o es teléfono internacional sin formato), permitir fallback seguro
|
||||
console.warn(`[OTP] No se pudo enviar por WhatsApp a ${phoneToSend}. Código generado: ${code}`);
|
||||
|
||||
99
dripzone-app/src/app/api/message-templates/route.ts
Normal file
99
dripzone-app/src/app/api/message-templates/route.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dataService } from '@/lib/data-service';
|
||||
import { TEMPLATE_CATEGORIES } from '@/lib/templates-service';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
const category = searchParams.get('category');
|
||||
|
||||
if (id) {
|
||||
const template = dataService.getMessageTemplateById(id);
|
||||
if (!template) {
|
||||
return NextResponse.json({ success: false, error: 'Plantilla no encontrada' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ success: true, template });
|
||||
}
|
||||
|
||||
const templates = dataService.getMessageTemplates(category || undefined);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
templates,
|
||||
categories: TEMPLATE_CATEGORIES,
|
||||
total: templates.length,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, content, isActive, title, description } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ success: false, error: 'ID de la plantilla es obligatorio' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = dataService.updateMessageTemplate(id, {
|
||||
...(content !== undefined && { content }),
|
||||
...(isActive !== undefined && { isActive }),
|
||||
...(title !== undefined && { title }),
|
||||
...(description !== undefined && { description }),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: 'Plantilla no encontrada' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
template: updated,
|
||||
message: `Plantilla "${updated.title}" actualizada correctamente.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, id, templateId, variables } = body;
|
||||
|
||||
if (action === 'reset' && id) {
|
||||
const restored = dataService.resetMessageTemplate(id);
|
||||
if (!restored) {
|
||||
return NextResponse.json({ success: false, error: 'Plantilla no encontrada' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
template: restored,
|
||||
message: `Plantilla "${restored.title}" restaurada al texto predeterminado de fábrica.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'reset_all') {
|
||||
const templates = dataService.resetAllMessageTemplates();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
templates,
|
||||
message: 'Todas las plantillas han sido restauradas a sus textos predeterminados de fábrica.',
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'render' && templateId) {
|
||||
const rendered = dataService.renderMessageTemplate(templateId, variables || {});
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
renderedText: rendered,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, error: 'Acción no válida' }, { status: 400 });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,70 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// ACCIÓN MANUAL: Desagrupar / Separar una variante específica como producto independiente
|
||||
if (body.action === 'detach-variant') {
|
||||
const { productId, variantId, customName, customPrice } = body;
|
||||
const result = dataService.detachVariant(productId, variantId, customName, customPrice);
|
||||
if (!result) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No se encontró el producto o la variante a separar' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
parentProduct: result.parentProduct,
|
||||
newProduct: result.newProduct,
|
||||
message: `¡Variante separada con éxito como "${result.newProduct.name}"!`,
|
||||
});
|
||||
}
|
||||
|
||||
// ACCIÓN MANUAL: Mover una variante de un producto a otro producto existente
|
||||
if (body.action === 'move-variant') {
|
||||
const { sourceProductId, variantId, targetProductId } = body;
|
||||
const result = dataService.moveVariant(sourceProductId, variantId, targetProductId);
|
||||
if (!result) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No se pudo mover la variante entre los productos seleccionados' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sourceProduct: result.sourceProduct,
|
||||
targetProduct: result.targetProduct,
|
||||
message: '¡Variante transferida exitosamente al otro modelo!',
|
||||
});
|
||||
}
|
||||
|
||||
// ACCIÓN MANUAL: Desagrupar todas las variantes de un modelo
|
||||
if (body.action === 'ungroup-all') {
|
||||
const { productId } = body;
|
||||
const resultingProducts = dataService.ungroupAllVariants(productId);
|
||||
if (!resultingProducts) {
|
||||
return NextResponse.json({ success: false, error: 'Producto no encontrado' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
products: resultingProducts,
|
||||
message: `¡Se desagruparon ${resultingProducts.length} variantes en productos independientes!`,
|
||||
});
|
||||
}
|
||||
|
||||
// ACCIÓN MANUAL: Eliminar una variante específica de un producto
|
||||
if (body.action === 'delete-variant') {
|
||||
const { productId, variantId } = body;
|
||||
const updated = dataService.deleteVariant(productId, variantId);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: 'No se encontró el producto' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
product: updated,
|
||||
message: 'Variante eliminada exitosamente del grupo.',
|
||||
});
|
||||
}
|
||||
|
||||
// ACCIÓN MANUAL: Agrupar múltiples productos como variantes de un solo modelo
|
||||
if (body.action === 'group-products') {
|
||||
const { targetProductId, productIdsToMerge, customModelName, customBrand } = body;
|
||||
@ -96,6 +160,7 @@ export async function POST(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const proof = getProductSocialProof(body);
|
||||
const newProduct = dataService.addProduct({
|
||||
...body,
|
||||
|
||||
@ -27,30 +27,34 @@ export async function POST(request: Request) {
|
||||
data.groupInfo?.subject ||
|
||||
(data.key?.remoteJid?.includes('g.us') ? 'Canal Mayorista Entrada' : 'Recepción Directa');
|
||||
|
||||
if (!messageContent || messageContent.trim().length < 5) {
|
||||
return NextResponse.json({ success: true, ignored: 'Mensaje vacío o sin texto relevante' });
|
||||
const imageBase64 = data.base64 || (data.message?.imageMessage?.jpegThumbnail ? `data:image/jpeg;base64,${data.message.imageMessage.jpegThumbnail}` : undefined);
|
||||
|
||||
if (!messageContent && !imageBase64) {
|
||||
return NextResponse.json({ success: true, ignored: 'Mensaje vacío o sin contenido' });
|
||||
}
|
||||
|
||||
// Parsear con Gemini AI
|
||||
const extracted = await parseSupplierMessage(messageContent);
|
||||
// Parsear con Gemini AI y Visión de Logos
|
||||
const extracted = await parseSupplierMessage(messageContent || channelName, imageBase64);
|
||||
|
||||
const newPost: SupplierPost = {
|
||||
id: `post-${Date.now()}`,
|
||||
id: `post-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
channelName: data.groupInfo?.subject || channelName,
|
||||
rawMessage: messageContent,
|
||||
rawImages: [
|
||||
'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80',
|
||||
],
|
||||
rawMessage: messageContent || `[Foto de Calzado recibida en ${channelName}]`,
|
||||
rawImages: imageBase64
|
||||
? [imageBase64.startsWith('data:') ? imageBase64 : `data:image/jpeg;base64,${imageBase64}`]
|
||||
: ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
|
||||
extractedProduct: extracted,
|
||||
status: 'pending_review',
|
||||
receivedAt: 'Justo ahora',
|
||||
supplierName: data.groupInfo?.subject || channelName,
|
||||
supplierPhone: data.key?.participant?.replace(/\D/g, '') || '573167455877',
|
||||
};
|
||||
|
||||
dataService.addSupplierPost(newPost);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Mensaje procesado con IA e ingresado al feed interno',
|
||||
message: 'Mensaje procesado con IA y Visión de Marca e ingresado al feed interno',
|
||||
post: newPost,
|
||||
});
|
||||
} catch (error: any) {
|
||||
|
||||
@ -154,8 +154,9 @@ export async function GET(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Parsear oferta de calzado con IA
|
||||
const extracted = await parseSupplierMessage(text || groupName);
|
||||
// Parsear oferta de calzado con IA y visión de logos en imagen
|
||||
const extracted = await parseSupplierMessage(text || groupName, imageUrls[0]);
|
||||
|
||||
|
||||
const timestamp = record.messageTimestamp
|
||||
? new Date(record.messageTimestamp * 1000).toLocaleString('es-CO', {
|
||||
@ -203,7 +204,7 @@ export async function GET(request: Request) {
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, post, posts, customRetailPrice, customName, customColorName, customSizes } = body;
|
||||
const { action, post, posts, customRetailPrice, customName, customBrand, customColorName, customSizes } = body;
|
||||
|
||||
// 1. PUBLICAR UN PRODUCTO A LA TIENDA (APP MÓVIL Y WEB)
|
||||
if (action === 'publish' && post) {
|
||||
@ -213,7 +214,7 @@ export async function POST(request: Request) {
|
||||
(post.extractedProduct?.supplierPrice ? post.extractedProduct.supplierPrice + 35000 : 125000);
|
||||
|
||||
const prodName = customName || post.extractedProduct?.name || 'Zapatillas Urbanas';
|
||||
const prodBrand = post.extractedProduct?.brand || 'Nike';
|
||||
const prodBrand = customBrand || post.extractedProduct?.brand || 'Urbano / Variado';
|
||||
const proof = getProductSocialProof({ name: prodName, brand: prodBrand });
|
||||
|
||||
const newProduct = {
|
||||
@ -258,7 +259,7 @@ export async function POST(request: Request) {
|
||||
(p.extractedProduct?.supplierPrice ? p.extractedProduct.supplierPrice + 35000 : 125000);
|
||||
|
||||
const prodName = p.extractedProduct?.name || 'Zapatillas Urbanas';
|
||||
const prodBrand = p.extractedProduct?.brand || 'Nike';
|
||||
const prodBrand = p.extractedProduct?.brand || 'Urbano / Variado';
|
||||
const proof = getProductSocialProof({ name: prodName, brand: prodBrand });
|
||||
|
||||
const newProd = {
|
||||
@ -283,6 +284,7 @@ export async function POST(request: Request) {
|
||||
supplierPhone: p.supplierPhone || '573167455877',
|
||||
};
|
||||
|
||||
|
||||
const added = dataService.addProduct(newProd as any);
|
||||
dataService.updateSupplierPostStatus(p.id, 'published');
|
||||
createdProducts.push(added);
|
||||
|
||||
@ -8,8 +8,10 @@ import { INITIAL_CITIES, INITIAL_PAYMENT_METHODS, INITIAL_PRODUCTS } from '@/lib
|
||||
import { formatCOP } from '@/lib/pricing-engine';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { renderClientTemplate } from '@/lib/templates-client';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const { currentBrand } = useBrand();
|
||||
const { user } = useAuth();
|
||||
@ -165,26 +167,32 @@ export default function CheckoutPage() {
|
||||
};
|
||||
|
||||
const handleOpenWhatsAppOrder = (order: Order) => {
|
||||
const text = `🎉 *CONFIRMACIÓN DE PEDIDO EN ${currentBrand.name} #${order.id}* 🎉
|
||||
const itemsSummary = order.items
|
||||
.map((i) => `• ${i.productName} (Talla ${i.size}) x${i.quantity} = ${formatCOP(i.price * i.quantity)}`)
|
||||
.join('\n');
|
||||
|
||||
👤 *Cliente:* ${order.customerName}
|
||||
📱 *Teléfono:* ${order.customerPhone}
|
||||
📍 *Dirección:* ${order.address}
|
||||
🏙️ *Ciudad:* ${order.cityName}
|
||||
const paymentCondition = order.depositAmount > 0
|
||||
? `⚡ *Separación:* ${formatCOP(order.depositAmount)}\n🤝 *Saldo Contraentrega:* ${formatCOP(order.balanceDue)}`
|
||||
: '🤝 *Pago 100% Contraentrega.*';
|
||||
|
||||
📦 *Artículos:*
|
||||
${order.items.map((i) => `• ${i.productName} (Talla ${i.size}) x${i.quantity} = ${formatCOP(i.price * i.quantity)}`).join('\n')}
|
||||
const rawText = renderClientTemplate('order_checkout_wa', {
|
||||
tienda_nombre: currentBrand.name,
|
||||
orden_id: order.id,
|
||||
cliente_nombre: order.customerName,
|
||||
cliente_telefono: order.customerPhone,
|
||||
cliente_direccion: order.address,
|
||||
cliente_ciudad: order.cityName,
|
||||
articulos_resumen: itemsSummary,
|
||||
total: formatCOP(order.totalAmount),
|
||||
condicion_pago_resumen: paymentCondition,
|
||||
metodo_pago: order.paymentMethodName,
|
||||
saldo_contraentrega: formatCOP(order.balanceDue),
|
||||
});
|
||||
|
||||
💰 *Total:* ${formatCOP(order.totalAmount)}
|
||||
${order.depositAmount > 0 ? `⚡ *Separación:* ${formatCOP(order.depositAmount)}\n🤝 *Saldo Contraentrega:* ${formatCOP(order.balanceDue)}` : '🤝 *Pago 100% Contraentrega.*'}
|
||||
|
||||
💳 *Método de Pago:* ${order.paymentMethodName}
|
||||
|
||||
¡Hola ${currentBrand.shortName}! Acabo de registrar mi pedido en la web. ¿Me confirman despacho por favor?`;
|
||||
|
||||
window.open(`https://wa.me/${officialWhatsapp}?text=${encodeURIComponent(text)}`, '_blank');
|
||||
window.open(`https://wa.me/${officialWhatsapp}?text=${encodeURIComponent(rawText)}`, '_blank');
|
||||
};
|
||||
|
||||
|
||||
if (orderCompleted) {
|
||||
return (
|
||||
<div className={`min-h-screen ${currentBrand.colors.bodyBg} flex items-center justify-center p-4`}>
|
||||
@ -297,7 +305,7 @@ ${order.depositAmount > 0 ? `⚡ *Separación:* ${formatCOP(order.depositAmount)
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ej. Carlos Andrés Lasso"
|
||||
placeholder="Ej: Juan Pérez"
|
||||
value={customerName}
|
||||
onChange={(e) => setCustomerName(e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-sm text-white focus:border-purple-400 outline-hidden"
|
||||
@ -308,7 +316,7 @@ ${order.depositAmount > 0 ? `⚡ *Separación:* ${formatCOP(order.depositAmount)
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
placeholder="Ej. 3167455877"
|
||||
placeholder="Ej: 3XX XXX XXXX"
|
||||
value={customerPhone}
|
||||
onChange={(e) => setCustomerPhone(e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-sm text-white focus:border-purple-400 outline-hidden"
|
||||
@ -338,7 +346,7 @@ ${order.depositAmount > 0 ? `⚡ *Separación:* ${formatCOP(order.depositAmount)
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Calle, Carrera, No. Casa/Apto"
|
||||
placeholder="Ej: Calle 123 # 45 - 67, Apto 201"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-sm text-white focus:border-purple-400 outline-hidden"
|
||||
@ -348,13 +356,14 @@ ${order.depositAmount > 0 ? `⚡ *Separación:* ${formatCOP(order.depositAmount)
|
||||
<label className="block text-xs font-bold text-neutral-300 mb-1">Barrio / Sector</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej. San Fernando / Capri"
|
||||
placeholder="Ej: Barrio San Fernando / Casa blanca"
|
||||
value={neighborhood}
|
||||
onChange={(e) => setNeighborhood(e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-sm text-white focus:border-purple-400 outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 2. Método de Pago */}
|
||||
|
||||
@ -15,6 +15,8 @@ import { INITIAL_PRODUCTS, INITIAL_CITIES } from '@/lib/data-service';
|
||||
import { groupProductsByModel } from '@/lib/product-grouper';
|
||||
import { Sparkles, ShieldCheck, Truck, RefreshCw, Zap } from 'lucide-react';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
import { renderClientTemplate } from '@/lib/templates-client';
|
||||
|
||||
|
||||
export default function StoreHomePage() {
|
||||
const { currentBrand } = useBrand();
|
||||
@ -203,12 +205,17 @@ export default function StoreHomePage() {
|
||||
</div>
|
||||
<a
|
||||
href={`https://wa.me/${currentBrand.whatsappNumber}?text=${encodeURIComponent(
|
||||
`Hola ${currentBrand.name}! 👋 Quiero consultar la disponibilidad de zapatillas para despacho contraentrega.`
|
||||
renderClientTemplate('general_store_inquiry', {
|
||||
tienda_nombre: currentBrand.name,
|
||||
telefono_tienda: currentBrand.whatsappNumber,
|
||||
ciudad_tienda: 'Cali, Colombia',
|
||||
})
|
||||
)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-2 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-bold text-xs px-6 py-3 rounded-full cursor-pointer shadow-lg hover:scale-105 active:scale-95 transition-all`}
|
||||
>
|
||||
|
||||
<span>Consultar Disponibilidad por WhatsApp</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
484
dripzone-app/src/components/admin/ManageVariantsModal.tsx
Normal file
484
dripzone-app/src/components/admin/ManageVariantsModal.tsx
Normal file
@ -0,0 +1,484 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Palette,
|
||||
X,
|
||||
Split,
|
||||
ArrowRight,
|
||||
Trash2,
|
||||
Edit3,
|
||||
Layers,
|
||||
Sparkles,
|
||||
Check,
|
||||
MoveRight,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { Product, ProductColorVariant } from '@/types';
|
||||
import { formatCOP } from '@/lib/pricing-engine';
|
||||
|
||||
interface ManageVariantsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
product: Product;
|
||||
allProducts: Product[];
|
||||
onRefreshProducts: () => void;
|
||||
onShowToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
export const ManageVariantsModal: React.FC<ManageVariantsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
product,
|
||||
allProducts,
|
||||
onRefreshProducts,
|
||||
onShowToast,
|
||||
}) => {
|
||||
if (!isOpen || !product) return null;
|
||||
|
||||
const variants: ProductColorVariant[] =
|
||||
product.colors && product.colors.length > 0
|
||||
? product.colors
|
||||
: [
|
||||
{
|
||||
id: `col-${product.id}-main`,
|
||||
colorName: product.colorName || 'Estilo Principal',
|
||||
image: product.images[0],
|
||||
images: product.images,
|
||||
sizes: product.sizes,
|
||||
inStock: product.inStock !== false,
|
||||
price: product.retailPrice,
|
||||
},
|
||||
];
|
||||
|
||||
const [detachingVariantId, setDetachingVariantId] = useState<string | null>(null);
|
||||
const [detachName, setDetachName] = useState<string>('');
|
||||
const [detachPrice, setDetachPrice] = useState<number>(product.retailPrice);
|
||||
|
||||
const [movingVariantId, setMovingVariantId] = useState<string | null>(null);
|
||||
const [targetProductId, setTargetProductId] = useState<string>('');
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Abrir panel para separar variante
|
||||
const handleStartDetach = (variant: ProductColorVariant) => {
|
||||
setDetachingVariantId(variant.id);
|
||||
setMovingVariantId(null);
|
||||
const suggestedName = variant.colorName.toLowerCase().includes(product.brand.toLowerCase())
|
||||
? variant.colorName
|
||||
: `${product.brand} ${variant.colorName}`;
|
||||
setDetachName(suggestedName);
|
||||
setDetachPrice(variant.price || product.retailPrice);
|
||||
};
|
||||
|
||||
// Confirmar separación de variante individual
|
||||
const handleConfirmDetach = async (variantId: string) => {
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const res = await fetch('/api/products', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'detach-variant',
|
||||
productId: product.id,
|
||||
variantId,
|
||||
customName: detachName.trim() || undefined,
|
||||
customPrice: detachPrice || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
onShowToast(`🚀 ¡Variante separada con éxito como producto independiente!`);
|
||||
setDetachingVariantId(null);
|
||||
onRefreshProducts();
|
||||
onClose();
|
||||
} else {
|
||||
setErrorMessage(data.error || 'Error al separar la variante');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setErrorMessage(e.message || 'Error de conexión');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Confirmar mover variante a otro producto
|
||||
const handleConfirmMove = async (variantId: string) => {
|
||||
if (!targetProductId) {
|
||||
setErrorMessage('Selecciona el producto destino al que deseas mover la variante');
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const res = await fetch('/api/products', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'move-variant',
|
||||
sourceProductId: product.id,
|
||||
variantId,
|
||||
targetProductId,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
onShowToast(`🔄 ¡Variante transferida exitosamente al otro producto!`);
|
||||
setMovingVariantId(null);
|
||||
onRefreshProducts();
|
||||
onClose();
|
||||
} else {
|
||||
setErrorMessage(data.error || 'Error al transferir variante');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setErrorMessage(e.message || 'Error de conexión');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Desagrupar todas las variantes a productos individuales
|
||||
const handleUngroupAll = async () => {
|
||||
if (!confirm(`¿Estás seguro de desagrupar las ${variants.length} variantes en productos individuales independientes?`)) {
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const res = await fetch('/api/products', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'ungroup-all',
|
||||
productId: product.id,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
onShowToast(`💥 ¡Se desagruparon todas las variantes en ${data.products?.length || variants.length} productos independientes!`);
|
||||
onRefreshProducts();
|
||||
onClose();
|
||||
} else {
|
||||
setErrorMessage(data.error || 'Error al desagrupar variantes');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setErrorMessage(e.message || 'Error de conexión');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Eliminar una variante específica
|
||||
const handleDeleteVariant = async (variantId: string, colorName: string) => {
|
||||
if (variants.length <= 1) {
|
||||
alert('No puedes eliminar la única variante. Si deseas eliminar el producto completo, hazlo desde la tabla principal.');
|
||||
return;
|
||||
}
|
||||
if (!confirm(`¿Eliminar la variante "${colorName}" de este modelo?`)) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const res = await fetch('/api/products', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'delete-variant',
|
||||
productId: product.id,
|
||||
variantId,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
onShowToast(`🗑️ Variante "${colorName}" eliminada del grupo.`);
|
||||
onRefreshProducts();
|
||||
onClose();
|
||||
} else {
|
||||
setErrorMessage(data.error || 'Error al eliminar variante');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setErrorMessage(e.message || 'Error de conexión');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Otros productos elegibles para mover variantes
|
||||
const otherProducts = allProducts.filter((p) => p.id !== product.id);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/85 backdrop-blur-md flex items-center justify-center p-4 overflow-y-auto animate-in fade-in">
|
||||
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-2xl w-full p-6 space-y-5 shadow-2xl my-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-gradient-to-tr from-purple-600 to-fuchsia-600 text-white flex items-center justify-center shadow-lg shadow-purple-600/30">
|
||||
<Palette className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-black text-white text-base flex items-center gap-2">
|
||||
<span>Gestionar Variantes de Color</span>
|
||||
<span className="bg-purple-500/20 text-purple-300 text-[11px] font-mono px-2 py-0.5 rounded-full border border-purple-500/40">
|
||||
{variants.length} {variants.length === 1 ? 'Colorway' : 'Colorways'}
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-neutral-400 text-xs mt-0.5">
|
||||
Modelo Base: <strong className="text-white">{product.name}</strong> ({product.brand})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white flex items-center justify-center cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="bg-red-950/40 border border-red-500/40 rounded-xl p-3 text-red-300 text-xs">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Acciones Rápidas Superiores */}
|
||||
{variants.length > 1 && (
|
||||
<div className="bg-neutral-950 p-3.5 rounded-2xl border border-neutral-800 flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<div className="text-xs text-neutral-300">
|
||||
<p className="font-bold text-white flex items-center gap-1.5">
|
||||
<Split className="w-3.5 h-3.5 text-fuchsia-400" />
|
||||
<span>¿Deseas separar todos los colores en publicaciones independientes?</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-neutral-400">
|
||||
Cada colorway pasará a ser una zapatilla individual en la tienda.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUngroupAll}
|
||||
disabled={isSubmitting}
|
||||
className="bg-neutral-800 hover:bg-neutral-700 text-purple-300 hover:text-white text-xs font-black px-3.5 py-2 rounded-xl border border-purple-500/30 shrink-0 cursor-pointer flex items-center gap-1.5 transition-all"
|
||||
>
|
||||
<Split className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Desagrupar Todas</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lista de Variantes */}
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto pr-1">
|
||||
{variants.map((v, idx) => {
|
||||
const isDetaching = detachingVariantId === v.id;
|
||||
const isMoving = movingVariantId === v.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={v.id || idx}
|
||||
className="bg-neutral-950/80 border border-neutral-800 rounded-2xl p-3.5 space-y-3 transition-all hover:border-neutral-700"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={v.image || product.images[0]}
|
||||
alt={v.colorName}
|
||||
className="w-14 h-14 rounded-xl object-cover bg-neutral-900 border border-neutral-800 shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-bold text-white text-xs truncate">{v.colorName}</h4>
|
||||
<div className="flex items-center gap-2 mt-1 text-[11px]">
|
||||
|
||||
<span className="text-emerald-400 font-mono font-bold">
|
||||
{formatCOP(v.price || product.retailPrice)}
|
||||
</span>
|
||||
<span className="text-neutral-500">•</span>
|
||||
<span className="text-neutral-400 truncate">
|
||||
Tallas: {v.sizes && v.sizes.length > 0 ? v.sizes.join(', ') : product.sizes.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botones de Acción para esta variante */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStartDetach(v)}
|
||||
className={`text-xs font-bold px-2.5 py-1.5 rounded-xl border flex items-center gap-1 cursor-pointer transition-all ${
|
||||
isDetaching
|
||||
? 'bg-purple-600 text-white border-purple-500'
|
||||
: 'bg-neutral-900 hover:bg-purple-950/40 text-purple-300 border-neutral-700 hover:border-purple-500/50'
|
||||
}`}
|
||||
title="Mover y crear como producto independiente"
|
||||
>
|
||||
<Split className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Separar</span>
|
||||
</button>
|
||||
|
||||
{otherProducts.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMovingVariantId(isMoving ? null : v.id);
|
||||
setDetachingVariantId(null);
|
||||
}}
|
||||
className={`text-xs font-bold px-2.5 py-1.5 rounded-xl border flex items-center gap-1 cursor-pointer transition-all ${
|
||||
isMoving
|
||||
? 'bg-blue-600 text-white border-blue-500'
|
||||
: 'bg-neutral-900 hover:bg-blue-950/40 text-blue-300 border-neutral-700 hover:border-blue-500/50'
|
||||
}`}
|
||||
title="Mover esta variante a otro modelo"
|
||||
>
|
||||
<MoveRight className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Mover</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{variants.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteVariant(v.id, v.colorName)}
|
||||
className="text-xs text-neutral-500 hover:text-red-400 p-1.5 rounded-xl hover:bg-red-950/30 transition-colors cursor-pointer"
|
||||
title="Eliminar variante del grupo"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subpanel: Formulario para separar variante como producto independiente */}
|
||||
{isDetaching && (
|
||||
<div className="bg-purple-950/20 border border-purple-500/40 rounded-xl p-3 space-y-2.5 animate-in fade-in zoom-in-95">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-black text-purple-300 flex items-center gap-1.5">
|
||||
<Sparkles className="w-3.5 h-3.5 text-fuchsia-400" />
|
||||
<span>Crear Producto Independiente con este Color</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setDetachingVariantId(null)}
|
||||
className="text-neutral-400 hover:text-white text-[10px]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 text-xs">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="text-[10px] font-bold text-neutral-300 block mb-0.5">
|
||||
Nombre del Nuevo Producto:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={detachName}
|
||||
onChange={(e) => setDetachName(e.target.value)}
|
||||
placeholder="Ej: Adidas Samba OG Blanco y Negro"
|
||||
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-2.5 py-1.5 text-white font-bold outline-hidden focus:border-purple-500 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-emerald-400 block mb-0.5">
|
||||
Precio Venta (COP):
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={detachPrice}
|
||||
onChange={(e) => setDetachPrice(Number(e.target.value))}
|
||||
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-2.5 py-1.5 text-emerald-300 font-mono font-bold outline-hidden focus:border-emerald-500 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetachingVariantId(null)}
|
||||
className="text-xs bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold px-3 py-1.5 rounded-lg cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleConfirmDetach(v.id)}
|
||||
disabled={isSubmitting}
|
||||
className="text-xs bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:opacity-95 text-white font-black px-3.5 py-1.5 rounded-lg shadow-md flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
<span>{isSubmitting ? 'Separando...' : 'Confirmar y Publicar como Producto Separado'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subpanel: Formulario para mover variante a otro modelo existente */}
|
||||
{isMoving && (
|
||||
<div className="bg-blue-950/20 border border-blue-500/40 rounded-xl p-3 space-y-2.5 animate-in fade-in zoom-in-95">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-black text-blue-300 flex items-center gap-1.5">
|
||||
<MoveRight className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Transferir este Colorway a Otro Modelo</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setMovingVariantId(null)}
|
||||
className="text-neutral-400 hover:text-white text-[10px]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs">
|
||||
<label className="text-[10px] font-bold text-neutral-300 block mb-0.5">
|
||||
Selecciona el Producto de Destino:
|
||||
</label>
|
||||
<select
|
||||
value={targetProductId}
|
||||
onChange={(e) => setTargetProductId(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-2.5 py-1.5 text-white font-semibold outline-hidden focus:border-blue-500 text-xs"
|
||||
>
|
||||
<option value="">-- Elige un producto existente --</option>
|
||||
{otherProducts.map((op) => (
|
||||
<option key={op.id} value={op.id}>
|
||||
{op.name} ({op.brand}) — {formatCOP(op.retailPrice)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMovingVariantId(null)}
|
||||
className="text-xs bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold px-3 py-1.5 rounded-lg cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleConfirmMove(v.id)}
|
||||
disabled={isSubmitting || !targetProductId}
|
||||
className="text-xs bg-gradient-to-r from-blue-600 to-indigo-600 hover:opacity-95 text-white font-black px-3.5 py-1.5 rounded-lg shadow-md flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
<span>{isSubmitting ? 'Moviendo...' : 'Transferir Variante'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end pt-3 border-t border-neutral-800">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold text-xs px-5 py-2.5 rounded-xl cursor-pointer"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -578,7 +578,7 @@ export const AuthModal: React.FC = () => {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ej: 3167455877 o carlos.andres.lasso@gmail.com"
|
||||
placeholder="Ej: 3XX XXX XXXX o micorreo@hotmail.com"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-2xl px-4 py-3 text-white text-sm outline-hidden focus:border-purple-500 transition-all"
|
||||
@ -680,7 +680,7 @@ export const AuthModal: React.FC = () => {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="ej: Carlos Lasso"
|
||||
placeholder="Ej: Juan Pérez"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-500"
|
||||
@ -693,7 +693,7 @@ export const AuthModal: React.FC = () => {
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
placeholder="carlos@gmail.com"
|
||||
placeholder="micorreo@hotmail.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-500"
|
||||
@ -705,7 +705,7 @@ export const AuthModal: React.FC = () => {
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
placeholder="3167455877"
|
||||
placeholder="3XX XXX XXXX"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-500"
|
||||
@ -718,6 +718,7 @@ export const AuthModal: React.FC = () => {
|
||||
<label className="block text-neutral-400 font-bold mb-1">Ciudad</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej: Bogotá, Medellín..."
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-500"
|
||||
@ -727,7 +728,7 @@ export const AuthModal: React.FC = () => {
|
||||
<label className="block text-neutral-400 font-bold mb-1">Dirección de Entrega</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Calle 5 # 34-12"
|
||||
placeholder="Ej: Calle 123 # 45 - 67, Apto 201"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-500"
|
||||
|
||||
@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation';
|
||||
import { Home, Search, ShoppingBag, MessageCircle, User, Shield } from 'lucide-react';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { renderClientTemplate } from '@/lib/templates-client';
|
||||
|
||||
interface MobileBottomDockProps {
|
||||
cartCount: number;
|
||||
@ -25,12 +26,16 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||
const [activeTab, setActiveTab] = useState('home');
|
||||
|
||||
const handleWhatsApp = () => {
|
||||
const text = encodeURIComponent(
|
||||
`Hola ${currentBrand.name}! 👋 Quiero consultar la disponibilidad de unas zapatillas para despacho contraentrega en 24 horas.`
|
||||
);
|
||||
const rawText = renderClientTemplate('general_store_inquiry', {
|
||||
tienda_nombre: currentBrand.name,
|
||||
telefono_tienda: currentBrand.whatsappNumber,
|
||||
ciudad_tienda: 'Cali, Colombia',
|
||||
});
|
||||
const text = encodeURIComponent(rawText);
|
||||
window.open(`https://wa.me/${currentBrand.whatsappNumber}?text=${text}`, '_blank');
|
||||
};
|
||||
|
||||
|
||||
const isAdmin = Boolean(user && (user.role === 'admin' || user.role === 'operator'));
|
||||
const isAdminActive = pathname?.startsWith('/admin');
|
||||
const isHomeActive = pathname === '/' && activeTab === 'home';
|
||||
|
||||
@ -6,6 +6,8 @@ import { X, Trash2, ShoppingBag, ArrowRight, Zap, Truck, MessageCircle } from 'l
|
||||
import { CartItem, City } from '@/types';
|
||||
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
import { renderClientTemplate } from '@/lib/templates-client';
|
||||
|
||||
|
||||
interface CartDrawerProps {
|
||||
isOpen: boolean;
|
||||
@ -52,23 +54,26 @@ export const CartDrawer: React.FC<CartDrawerProps> = ({
|
||||
.map((item) => `👟 *${item.product.name}* (Talla: *${item.selectedSize}*, Cant: ${item.quantity}) - ${formatCOP(item.product.retailPrice * item.quantity)}`)
|
||||
.join('\n');
|
||||
|
||||
const text = `🔥 *NUEVO PEDIDO DESDE ${currentBrand.name}* 🔥
|
||||
const paymentCondition = `⚡ *Condición de Pago:* Pago Contraentrega (Separo con $10.000 COP y cancelo saldo de ${formatCOP(separation.balanceDue)} al recibir).`;
|
||||
|
||||
📦 *Productos seleccionados:*
|
||||
${itemsList}
|
||||
const rawText = renderClientTemplate('order_checkout_wa', {
|
||||
tienda_nombre: currentBrand.name,
|
||||
orden_id: `WEB-${Date.now().toString().slice(-4)}`,
|
||||
cliente_nombre: 'Cliente',
|
||||
cliente_telefono: '',
|
||||
cliente_direccion: '',
|
||||
cliente_ciudad: selectedCity.name,
|
||||
articulos_resumen: itemsList,
|
||||
total: `${formatCOP(totalAmount)} COP`,
|
||||
condicion_pago_resumen: paymentCondition,
|
||||
metodo_pago: 'Pago Contraentrega',
|
||||
saldo_contraentrega: formatCOP(separation.balanceDue),
|
||||
});
|
||||
|
||||
📍 *Ciudad de entrega:* ${selectedCity.name}
|
||||
💵 *Subtotal:* ${formatCOP(subtotal)}
|
||||
🚚 *Envío:* ${shippingCost === 0 ? 'GRATIS' : formatCOP(shippingCost)}
|
||||
💰 *VALOR TOTAL:* ${formatCOP(totalAmount)} COP
|
||||
|
||||
⚡ *Condición de Pago:* Pago Contraentrega (Separo con $10.000 COP vía Bold/Nequi y cancelo saldo de ${formatCOP(separation.balanceDue)} al recibir).
|
||||
|
||||
Por favor confírmenme para enviarles los datos de mi dirección.`;
|
||||
|
||||
window.open(`https://wa.me/${officialPhone}?text=${encodeURIComponent(text)}`, '_blank');
|
||||
window.open(`https://wa.me/${officialPhone}?text=${encodeURIComponent(rawText)}`, '_blank');
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-hidden animate-in fade-in duration-200">
|
||||
{/* Backdrop */}
|
||||
|
||||
@ -20,6 +20,8 @@ import { Product, City, ProductColorVariant } from '@/types';
|
||||
import { formatCOP, calculateSeparationBreakdown, getProductEffectivePrice } from '@/lib/pricing-engine';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
import { getProductSocialProof } from '@/lib/social-proof';
|
||||
import { renderClientTemplate } from '@/lib/templates-client';
|
||||
|
||||
|
||||
interface ProductModalProps {
|
||||
product: Product | null;
|
||||
@ -101,31 +103,33 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
// Pedido normal por WhatsApp
|
||||
const handleWhatsAppDirect = () => {
|
||||
const phone = currentBrand.whatsappNumber || '573167455877';
|
||||
const text = `Hola ${currentBrand.name}! 👋 Quiero pedir las zapatillas:
|
||||
👟 *${product.modelName || product.name}*
|
||||
🎨 Color: *${selectedColor.colorName}*
|
||||
📏 Talla: *${selectedSize}* (Horma Nacional)
|
||||
💎 Precio: *${formatCOP(currentPrice)} COP*
|
||||
🚚 Entrega en: *${selectedCity.name}* (Pago Contraentrega)
|
||||
⚡ Deseo separar con $10.000 COP y cancelar el saldo en mi casa.
|
||||
|
||||
¿Me confirman disponibilidad para despacho por favor?`;
|
||||
window.open(`https://wa.me/${phone}?text=${encodeURIComponent(text)}`, '_blank');
|
||||
const rawText = renderClientTemplate('product_direct_inquiry', {
|
||||
tienda_nombre: currentBrand.name,
|
||||
zapatilla_nombre: product.modelName || product.name,
|
||||
color_nombre: selectedColor.colorName,
|
||||
talla: selectedSize,
|
||||
precio: formatCOP(currentPrice),
|
||||
ciudad: selectedCity.name,
|
||||
marca: product.brand || 'Sneakers',
|
||||
});
|
||||
window.open(`https://wa.me/${phone}?text=${encodeURIComponent(rawText)}`, '_blank');
|
||||
};
|
||||
|
||||
// Solicitud especial de talla no disponible
|
||||
const handleWhatsAppRequestSize = (sizeToRequest: string) => {
|
||||
const phone = currentBrand.whatsappNumber || '573167455877';
|
||||
const text = `Hola ${currentBrand.name}! 👋 Estaba revisando la tienda y me gustaron mucho estas zapatillas:
|
||||
👟 *${product.modelName || product.name}*
|
||||
🎨 Color: *${selectedColor.colorName}*
|
||||
📏 Talla que necesito: *${sizeToRequest}* (Actualmente no aparece disponible en stock inmediato)
|
||||
📍 Ciudad de entrega: *${selectedCity.name}*
|
||||
|
||||
¿Es posible conseguir esta talla bajo pedido con sus bodegas aliadas para despacho contraentrega? Quedo muy atento, ¡muchas gracias!`;
|
||||
window.open(`https://wa.me/${phone}?text=${encodeURIComponent(text)}`, '_blank');
|
||||
const rawText = renderClientTemplate('product_request_size', {
|
||||
tienda_nombre: currentBrand.name,
|
||||
zapatilla_nombre: product.modelName || product.name,
|
||||
color_nombre: selectedColor.colorName,
|
||||
talla: sizeToRequest,
|
||||
ciudad: selectedCity.name,
|
||||
marca: product.brand || 'Sneakers',
|
||||
});
|
||||
window.open(`https://wa.me/${phone}?text=${encodeURIComponent(rawText)}`, '_blank');
|
||||
};
|
||||
|
||||
|
||||
// All sizes to show (combination of standard sizes and any custom product sizes)
|
||||
const displaySizes = Array.from(new Set([...ALL_STANDARD_SIZES, ...product.sizes])).sort(
|
||||
(a, b) => parseInt(a, 10) - parseInt(b, 10)
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { Product, City, PaymentMethod, Order, SupplierPost, StoreSettings, Supplier, MonitoredWhatsAppGroup, User, ProductColorVariant } from '@/types';
|
||||
import { Product, City, PaymentMethod, Order, SupplierPost, StoreSettings, Supplier, MonitoredWhatsAppGroup, User, ProductColorVariant, MessageTemplate } from '@/types';
|
||||
import { normalizeModelKey, groupProductsByModel } from './model-grouper';
|
||||
import { generateAttractiveProductCopy } from './attractive-copy';
|
||||
import { getProductSocialProof } from './social-proof';
|
||||
import { DEFAULT_MESSAGE_TEMPLATES, interpolateVariables } from './templates-service';
|
||||
|
||||
|
||||
export const INITIAL_SUPPLIERS: Supplier[] = [
|
||||
{
|
||||
@ -298,6 +300,223 @@ class DataService {
|
||||
return this.products.length < initialLen;
|
||||
}
|
||||
|
||||
detachVariant(
|
||||
productId: string,
|
||||
variantId: string,
|
||||
customName?: string,
|
||||
customPrice?: number
|
||||
): { parentProduct: Product; newProduct: Product } | null {
|
||||
const parentIdx = this.products.findIndex((p) => p.id === productId);
|
||||
if (parentIdx === -1) return null;
|
||||
const parent = this.products[parentIdx];
|
||||
const colors = parent.colors || [];
|
||||
const variantIdx = colors.findIndex((c) => c.id === variantId);
|
||||
if (variantIdx === -1) return null;
|
||||
|
||||
const [variant] = colors.splice(variantIdx, 1);
|
||||
|
||||
// Si el padre quedó sin variantes explícitas, crear una con su imagen restante
|
||||
if (colors.length === 0) {
|
||||
parent.colors = [
|
||||
{
|
||||
id: `color-${parent.id}-main`,
|
||||
colorName: parent.colorName || 'Estilo Principal',
|
||||
image: parent.images[0],
|
||||
images: parent.images,
|
||||
sizes: parent.sizes,
|
||||
inStock: parent.inStock !== false,
|
||||
price: parent.retailPrice,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
parent.colors = colors;
|
||||
const remainingImages = colors.flatMap((c) => (c.images && c.images.length > 0 ? c.images : [c.image]));
|
||||
if (remainingImages.length > 0) {
|
||||
parent.images = Array.from(new Set(remainingImages));
|
||||
}
|
||||
}
|
||||
|
||||
// Crear el nuevo producto independiente
|
||||
const standaloneName =
|
||||
customName ||
|
||||
(variant.colorName.toLowerCase().includes(parent.brand.toLowerCase())
|
||||
? variant.colorName
|
||||
: `${parent.brand} ${variant.colorName}`);
|
||||
|
||||
const standalonePrice = customPrice || variant.price || parent.retailPrice;
|
||||
const variantImages = variant.images && variant.images.length > 0 ? variant.images : [variant.image];
|
||||
const variantSizes = variant.sizes && variant.sizes.length > 0 ? variant.sizes : parent.sizes;
|
||||
|
||||
const newProduct: Product = {
|
||||
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
name: standaloneName,
|
||||
modelName: standaloneName,
|
||||
colorName: variant.colorName,
|
||||
brand: parent.brand,
|
||||
supplierPrice: parent.supplierPrice,
|
||||
retailPrice: standalonePrice,
|
||||
originalPrice: Math.round(standalonePrice * 1.35),
|
||||
sizes: variantSizes,
|
||||
images: variantImages,
|
||||
colors: [
|
||||
{
|
||||
id: `color-${Date.now()}-1`,
|
||||
colorName: variant.colorName || 'Colorway Principal',
|
||||
image: variant.image,
|
||||
images: variantImages,
|
||||
sizes: variantSizes,
|
||||
inStock: variant.inStock !== false,
|
||||
price: standalonePrice,
|
||||
sizePrices: variant.sizePrices,
|
||||
},
|
||||
],
|
||||
description:
|
||||
parent.description ||
|
||||
generateAttractiveProductCopy({ name: standaloneName, brand: parent.brand, retailPrice: standalonePrice }),
|
||||
category: parent.category,
|
||||
inStock: variant.inStock !== false,
|
||||
isActive: true,
|
||||
isIndependent: true,
|
||||
customModelKey: `indep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
rating: parent.rating,
|
||||
reviewsCount: parent.reviewsCount,
|
||||
salesCount: parent.salesCount,
|
||||
originBadge: parent.originBadge,
|
||||
supplierId: parent.supplierId,
|
||||
supplierName: parent.supplierName,
|
||||
supplierPhone: parent.supplierPhone,
|
||||
};
|
||||
|
||||
this.products.unshift(newProduct);
|
||||
return { parentProduct: parent, newProduct };
|
||||
}
|
||||
|
||||
moveVariant(
|
||||
sourceProductId: string,
|
||||
variantId: string,
|
||||
targetProductId: string
|
||||
): { sourceProduct: Product; targetProduct: Product } | null {
|
||||
const srcIdx = this.products.findIndex((p) => p.id === sourceProductId);
|
||||
const tgtIdx = this.products.findIndex((p) => p.id === targetProductId);
|
||||
if (srcIdx === -1 || tgtIdx === -1) return null;
|
||||
|
||||
const source = this.products[srcIdx];
|
||||
const target = this.products[tgtIdx];
|
||||
const sourceColors = source.colors || [];
|
||||
const variantIdx = sourceColors.findIndex((c) => c.id === variantId);
|
||||
if (variantIdx === -1) return null;
|
||||
|
||||
const [variant] = sourceColors.splice(variantIdx, 1);
|
||||
source.colors =
|
||||
sourceColors.length > 0
|
||||
? sourceColors
|
||||
: [
|
||||
{
|
||||
id: `color-${source.id}-main`,
|
||||
colorName: source.colorName || 'Estilo Principal',
|
||||
image: source.images[0],
|
||||
images: source.images,
|
||||
sizes: source.sizes,
|
||||
inStock: source.inStock !== false,
|
||||
},
|
||||
];
|
||||
|
||||
const targetColors = target.colors || [];
|
||||
targetColors.push({
|
||||
...variant,
|
||||
id: `color-${target.id}-${targetColors.length + 1}`,
|
||||
});
|
||||
|
||||
target.colors = targetColors;
|
||||
target.images = Array.from(new Set([...target.images, ...(variant.images || [variant.image])]));
|
||||
target.sizes = Array.from(new Set([...target.sizes, ...(variant.sizes || [])])).sort(
|
||||
(a, b) => parseInt(a, 10) - parseInt(b, 10)
|
||||
);
|
||||
|
||||
return { sourceProduct: source, targetProduct: target };
|
||||
}
|
||||
|
||||
ungroupAllVariants(productId: string): Product[] | null {
|
||||
const idx = this.products.findIndex((p) => p.id === productId);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const parent = this.products[idx];
|
||||
const colors = parent.colors || [];
|
||||
if (colors.length <= 1) return [parent];
|
||||
|
||||
const createdProducts: Product[] = [];
|
||||
const [firstColor, ...restColors] = colors;
|
||||
|
||||
// Mantener la primera variante en el producto original
|
||||
parent.colors = [firstColor];
|
||||
parent.images = firstColor.images && firstColor.images.length > 0 ? firstColor.images : [firstColor.image];
|
||||
parent.sizes = firstColor.sizes && firstColor.sizes.length > 0 ? firstColor.sizes : parent.sizes;
|
||||
parent.isIndependent = true;
|
||||
createdProducts.push(parent);
|
||||
|
||||
// Crear un producto independiente por cada variante restante
|
||||
for (const v of restColors) {
|
||||
const vName = `${parent.brand} ${v.colorName}`;
|
||||
const vPrice = v.price || parent.retailPrice;
|
||||
const vImages = v.images && v.images.length > 0 ? v.images : [v.image];
|
||||
const vSizes = v.sizes && v.sizes.length > 0 ? v.sizes : parent.sizes;
|
||||
|
||||
const newProd: Product = {
|
||||
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
name: vName,
|
||||
modelName: vName,
|
||||
colorName: v.colorName,
|
||||
brand: parent.brand,
|
||||
supplierPrice: parent.supplierPrice,
|
||||
retailPrice: vPrice,
|
||||
originalPrice: Math.round(vPrice * 1.35),
|
||||
sizes: vSizes,
|
||||
images: vImages,
|
||||
colors: [
|
||||
{
|
||||
id: `color-${Date.now()}-1`,
|
||||
colorName: v.colorName,
|
||||
image: v.image,
|
||||
images: vImages,
|
||||
sizes: vSizes,
|
||||
inStock: v.inStock !== false,
|
||||
price: vPrice,
|
||||
},
|
||||
],
|
||||
description:
|
||||
parent.description ||
|
||||
generateAttractiveProductCopy({ name: vName, brand: parent.brand, retailPrice: vPrice }),
|
||||
category: parent.category,
|
||||
inStock: v.inStock !== false,
|
||||
isActive: true,
|
||||
isIndependent: true,
|
||||
customModelKey: `indep-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`,
|
||||
rating: parent.rating,
|
||||
reviewsCount: parent.reviewsCount,
|
||||
salesCount: parent.salesCount,
|
||||
originBadge: parent.originBadge,
|
||||
supplierId: parent.supplierId,
|
||||
supplierName: parent.supplierName,
|
||||
supplierPhone: parent.supplierPhone,
|
||||
};
|
||||
|
||||
this.products.unshift(newProd);
|
||||
createdProducts.push(newProd);
|
||||
}
|
||||
|
||||
return createdProducts;
|
||||
}
|
||||
|
||||
deleteVariant(productId: string, variantId: string): Product | null {
|
||||
const idx = this.products.findIndex((p) => p.id === productId);
|
||||
if (idx === -1) return null;
|
||||
const parent = this.products[idx];
|
||||
const colors = parent.colors || [];
|
||||
parent.colors = colors.filter((c) => c.id !== variantId);
|
||||
return parent;
|
||||
}
|
||||
|
||||
|
||||
getSuppliers(includeInactive: boolean = true): Supplier[] {
|
||||
if (includeInactive) return this.suppliers;
|
||||
return this.suppliers.filter((s) => s.isActive !== false);
|
||||
@ -601,6 +820,69 @@ class DataService {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SISTEMA DE PLANTILLAS CATEGORIZADAS
|
||||
// ==========================================
|
||||
private messageTemplates: MessageTemplate[] = [...DEFAULT_MESSAGE_TEMPLATES];
|
||||
|
||||
getMessageTemplates(category?: string): MessageTemplate[] {
|
||||
if (!category || category === 'all') {
|
||||
return this.messageTemplates;
|
||||
}
|
||||
return this.messageTemplates.filter((t) => t.category === category);
|
||||
}
|
||||
|
||||
getMessageTemplateById(id: string): MessageTemplate | undefined {
|
||||
return this.messageTemplates.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
updateMessageTemplate(id: string, updates: Partial<MessageTemplate>): MessageTemplate | null {
|
||||
const idx = this.messageTemplates.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return null;
|
||||
this.messageTemplates[idx] = {
|
||||
...this.messageTemplates[idx],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return this.messageTemplates[idx];
|
||||
}
|
||||
|
||||
resetMessageTemplate(id: string): MessageTemplate | null {
|
||||
const defaultTpl = DEFAULT_MESSAGE_TEMPLATES.find((t) => t.id === id);
|
||||
if (!defaultTpl) return null;
|
||||
const idx = this.messageTemplates.findIndex((t) => t.id === id);
|
||||
if (idx === -1) {
|
||||
this.messageTemplates.push({ ...defaultTpl, updatedAt: new Date().toISOString() });
|
||||
return defaultTpl;
|
||||
}
|
||||
this.messageTemplates[idx] = {
|
||||
...this.messageTemplates[idx],
|
||||
content: defaultTpl.defaultContent,
|
||||
isActive: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return this.messageTemplates[idx];
|
||||
}
|
||||
|
||||
resetAllMessageTemplates(): MessageTemplate[] {
|
||||
this.messageTemplates = DEFAULT_MESSAGE_TEMPLATES.map((t) => ({
|
||||
...t,
|
||||
content: t.defaultContent,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
return this.messageTemplates;
|
||||
}
|
||||
|
||||
renderMessageTemplate(
|
||||
templateId: string,
|
||||
variables: Record<string, string | number | undefined | null>
|
||||
): string {
|
||||
const tpl = this.getMessageTemplateById(templateId);
|
||||
const rawContent = tpl?.content || DEFAULT_MESSAGE_TEMPLATES.find((t) => t.id === templateId)?.defaultContent || '';
|
||||
return interpolateVariables(rawContent, variables);
|
||||
}
|
||||
}
|
||||
|
||||
export const dataService = new DataService();
|
||||
|
||||
|
||||
@ -72,31 +72,37 @@ export async function sendWhatsAppMedia(toPhone: string, imageUrl: string, capti
|
||||
}
|
||||
}
|
||||
|
||||
import { dataService } from './data-service';
|
||||
|
||||
/**
|
||||
* Genera el mensaje de confirmación de pedido para el cliente y el administrador
|
||||
* utilizando el sistema dinámico de plantillas configurables.
|
||||
*/
|
||||
export function buildOrderConfirmationMessage(order: any): string {
|
||||
const itemsText = order.items
|
||||
const itemsText = (order.items || [])
|
||||
.map((item: any) => `👟 *${item.productName}*\n 📏 Talla: *${item.size}* | Cantidad: ${item.quantity} | Valor: ${formatCOP(item.price)}`)
|
||||
.join('\n\n');
|
||||
|
||||
return `🎉 *¡GRACIAS POR TU PEDIDO EN DRIPZONE LUXURY!* 🎉
|
||||
📌 *No. de Orden:* #${order.id}
|
||||
const settings = dataService.getSettings();
|
||||
const paymentCondition = order.depositAmount > 0
|
||||
? `⚡ *Separación / Anticipo:* ${formatCOP(order.depositAmount)}\n🤝 *Saldo a Pagar Contraentrega:* ${formatCOP(order.balanceDue)}`
|
||||
: '🤝 *Pago 100% Contraentrega al recibir en tu casa.*';
|
||||
|
||||
👤 *Cliente:* ${order.customerName}
|
||||
📱 *Teléfono:* ${order.customerPhone}
|
||||
📍 *Dirección:* ${order.address}
|
||||
🏙️ *Ciudad:* ${order.cityName}
|
||||
|
||||
📦 *DETALLE DE TU COMPRA:*
|
||||
${itemsText}
|
||||
|
||||
💵 *Subtotal:* ${formatCOP(order.subtotal)}
|
||||
🚚 *Envío:* ${order.shippingCost === 0 ? 'GRATIS' : formatCOP(order.shippingCost)}
|
||||
💰 *VALOR TOTAL:* ${formatCOP(order.totalAmount)}
|
||||
${order.depositAmount > 0 ? `⚡ *Separación / Anticipo:* ${formatCOP(order.depositAmount)}\n🤝 *Saldo a Pagar Contraentrega:* ${formatCOP(order.balanceDue)}` : '🤝 *Pago 100% Contraentrega al recibir en tu casa.*'}
|
||||
|
||||
💳 *Método de Pago:* ${order.paymentMethodName}
|
||||
|
||||
Estamos alistando tu paquete. Te enviaremos el número de guía o notificación de entrega. ¡Muchas gracias por tu confianza! ✨`;
|
||||
return dataService.renderMessageTemplate('order_confirmation_customer', {
|
||||
tienda_nombre: settings?.storeName || 'DripZone Luxury',
|
||||
orden_id: order.id,
|
||||
cliente_nombre: order.customerName || 'Cliente',
|
||||
cliente_telefono: order.customerPhone || '',
|
||||
cliente_direccion: order.address || '',
|
||||
cliente_ciudad: order.cityName || 'Cali',
|
||||
articulos_resumen: itemsText,
|
||||
subtotal: formatCOP(order.subtotal || 0),
|
||||
costo_envio: order.shippingCost === 0 ? 'GRATIS' : formatCOP(order.shippingCost),
|
||||
total: formatCOP(order.totalAmount || 0),
|
||||
condicion_pago_resumen: paymentCondition,
|
||||
metodo_pago: order.paymentMethodName || 'Contraentrega',
|
||||
saldo_contraentrega: formatCOP(order.balanceDue || 0),
|
||||
anticipo: formatCOP(order.depositAmount || 0),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -4,12 +4,391 @@ import { calculateRetailPrice } from './pricing-engine';
|
||||
import { getProductSocialProof } from './social-proof';
|
||||
import { generateAttractiveProductCopy } from './attractive-copy';
|
||||
|
||||
/**
|
||||
* Catálogo maestro de marcas y sus firmas de modelos y palabras clave exclusivas
|
||||
*/
|
||||
export interface BrandSignature {
|
||||
brand: string;
|
||||
aliases: RegExp[];
|
||||
models: { name: string; pattern: RegExp }[];
|
||||
}
|
||||
|
||||
export const BRAND_SIGNATURES: BrandSignature[] = [
|
||||
{
|
||||
brand: 'Adidas',
|
||||
aliases: [/\badidas\b/i, /\btrefoil\b/i, /\btr[eé]bol\b/i, /3\s*rayas|tres\s*rayas/i],
|
||||
models: [
|
||||
{ name: 'Adidas Samba OG', pattern: /\bsamba\b/i },
|
||||
{ name: 'Adidas Gazelle Indoor', pattern: /\bgazelle\b/i },
|
||||
{ name: 'Adidas Campus 00s', pattern: /\bcampus\b/i },
|
||||
{ name: 'Adidas Handball Spezial', pattern: /\bspezial\b/i },
|
||||
{ name: 'Adidas Forum Low', pattern: /\bforum\b/i },
|
||||
{ name: 'Adidas Bad Bunny', pattern: /\bbad\s*bunny\b/i },
|
||||
{ name: 'Adidas Stan Smith', pattern: /\bstan\s*smith\b/i },
|
||||
{ name: 'Adidas Superstar', pattern: /\bsuperstar\b/i },
|
||||
{ name: 'Adidas SL 72', pattern: /\bsl\s*72\b|\bsl72\b/i },
|
||||
{ name: 'Adidas Response CL', pattern: /\bresponse\s*cl\b/i },
|
||||
{ name: 'Adidas Yeezy Boost 350', pattern: /\byeezy\b|\b350\s*v2\b|\bfoam\s*runner\b/i },
|
||||
{ name: 'Adidas Astir', pattern: /\bastir\b/i },
|
||||
{ name: 'Adidas Ozelia', pattern: /\bozelia\b/i },
|
||||
{ name: 'Adidas Adizero', pattern: /\badizero\b/i },
|
||||
{ name: 'Adidas Country OG', pattern: /\bcountry\s*og\b/i },
|
||||
{ name: 'Adidas NMD R1', pattern: /\bnmd\b/i },
|
||||
{ name: 'Adidas Ultraboost', pattern: /\bultra\s*boost\b|\bultraboost\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Jordan',
|
||||
aliases: [/\bjordan\b/i, /\bjumpman\b/i, /\bair\s*jordan\b/i, /\baj\d+\b/i],
|
||||
models: [
|
||||
{ name: 'Jordan 4 Retro', pattern: /\b(?:retro\s*4|aj4|jordan\s*4|retro\s*iv)\b/i },
|
||||
{ name: 'Jordan 1 Retro High OG', pattern: /\b(?:retro\s*1|aj1|jordan\s*1|retro\s*i)\b/i },
|
||||
{ name: 'Jordan 3 Retro', pattern: /\b(?:retro\s*3|aj3|jordan\s*3|retro\s*iii)\b/i },
|
||||
{ name: 'Jordan 11 Retro', pattern: /\b(?:retro\s*11|aj11|jordan\s*11|retro\s*xi)\b/i },
|
||||
{ name: 'Jordan 1 Low Travis Scott', pattern: /\btravis\s*scott\b|\bcactus\s*jack\b/i },
|
||||
{ name: 'Jordan Spizike Low', pattern: /\bspizike\b/i },
|
||||
{ name: 'Jordan 5 Retro', pattern: /\b(?:retro\s*5|aj5|jordan\s*5)\b/i },
|
||||
{ name: 'Jordan 6 Retro', pattern: /\b(?:retro\s*6|aj6|jordan\s*6)\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'New Balance',
|
||||
aliases: [/\bnew\s*balance\b/i, /\bnb\b/i],
|
||||
models: [
|
||||
{ name: 'New Balance 9060', pattern: /\b9060\b/i },
|
||||
{ name: 'New Balance 550', pattern: /\b550\b/i },
|
||||
{ name: 'New Balance 530', pattern: /\b530\b/i },
|
||||
{ name: 'New Balance 1906R', pattern: /\b1906\b|\b1906r\b|\b1906d\b/i },
|
||||
{ name: 'New Balance 2002R', pattern: /\b2002\b|\b2002r\b|\bprotection\s*pack\b/i },
|
||||
{ name: 'New Balance 327', pattern: /\b327\b/i },
|
||||
{ name: 'New Balance 574', pattern: /\b574\b/i },
|
||||
{ name: 'New Balance 990v6', pattern: /\b990\b|\b990v\d\b/i },
|
||||
{ name: 'New Balance 991', pattern: /\b991\b/i },
|
||||
{ name: 'New Balance 992', pattern: /\b992\b/i },
|
||||
{ name: 'New Balance 993', pattern: /\b993\b/i },
|
||||
{ name: 'New Balance 610', pattern: /\b610\b/i },
|
||||
{ name: 'New Balance CT302', pattern: /\bct302\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'On Running',
|
||||
aliases: [/\bon\s*running\b/i, /\bon\s*cloud\b/i, /\bcloud\b/i, /\bclould\b/i],
|
||||
models: [
|
||||
{ name: 'On Cloudmonster', pattern: /\bcloudmonster\b|\bcloud\s*monster\b/i },
|
||||
{ name: 'On Cloudtilt Loewe', pattern: /\bcloudtilt\b|\bcloud\s*tilt\b|\bloewe\b/i },
|
||||
{ name: 'On Cloud 5', pattern: /\bcloud\s*5\b/i },
|
||||
{ name: 'On Cloud X3', pattern: /\bcloud\s*x\b|\bcloud\s*x3\b/i },
|
||||
{ name: 'On Cloudsurfer', pattern: /\bcloudsurfer\b|\bcloud\s*surfer\b/i },
|
||||
{ name: 'On Cloudstratus', pattern: /\bcloudstratus\b|\bcloud\s*stratus\b/i },
|
||||
{ name: 'On Cloudrunner', pattern: /\bcloudrunner\b|\bcloud\s*runner\b/i },
|
||||
{ name: 'On The Roger Centre Court', pattern: /\broger\b|\bthe\s*roger\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Puma',
|
||||
aliases: [/\bpuma\b/i],
|
||||
models: [
|
||||
{ name: 'Puma Palermo Special', pattern: /\bpalermo\b/i },
|
||||
{ name: 'Puma Speedcat OG', pattern: /\bspeedcat\b|\bspeed\s*cat\b/i },
|
||||
{ name: 'Puma Suede Classic', pattern: /\bsuede\b/i },
|
||||
{ name: 'Puma Caven 2.0', pattern: /\bcaven\b/i },
|
||||
{ name: 'Puma Slipstream', pattern: /\bslipstream\b/i },
|
||||
{ name: 'Puma Mayze Wedge', pattern: /\bmayze\b/i },
|
||||
{ name: 'Puma Easy Rider', pattern: /\beasy\s*rider\b/i },
|
||||
{ name: 'Puma RS-X Efekt', pattern: /\brs-x\b|\brsx\b/i },
|
||||
{ name: 'Puma CA Pro Classic', pattern: /\bca\s*pro\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Asics',
|
||||
aliases: [/\basics\b/i],
|
||||
models: [
|
||||
{ name: 'Asics Gel-Kayano 14', pattern: /\bgel[\s-]*kayano\b|\bkayano\b/i },
|
||||
{ name: 'Asics Gel-NYC', pattern: /\bgel[\s-]*nyc\b|\bnyc\b/i },
|
||||
{ name: 'Asics Gel-1130', pattern: /\bgel[\s-]*1130\b|\b1130\b/i },
|
||||
{ name: 'Asics GT-2160', pattern: /\bgt[\s-]*2160\b|\b2160\b/i },
|
||||
{ name: 'Asics Gel-Quantum 360', pattern: /\bgel[\s-]*quantum\b|\bquantum\b/i },
|
||||
{ name: 'Asics Gel-Nimbus 9', pattern: /\bgel[\s-]*nimbus\b|\bnimbus\b/i },
|
||||
{ name: 'Asics Gel-Lyte III', pattern: /\bgel[\s-]*lyte\b/i },
|
||||
{ name: 'Asics Novablast 4', pattern: /\bnovablast\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Vans',
|
||||
aliases: [/\bvans\b/i, /\boff\s*the\s*wall\b/i],
|
||||
models: [
|
||||
{ name: 'Vans Knu Skool Chunky', pattern: /\bknu\s*skool\b|\bknuskool\b|\bknu\b/i },
|
||||
{ name: 'Vans Old Skool Classic', pattern: /\bold\s*skool\b|\boldskool\b/i },
|
||||
{ name: 'Vans Sk8-Hi Reissue', pattern: /\bsk8\b|\bsk8-hi\b|\bsk8\s*hi\b/i },
|
||||
{ name: 'Vans Authentic Pro', pattern: /\bauthentic\b/i },
|
||||
{ name: 'Vans Era Classic', pattern: /\bera\b/i },
|
||||
{ name: 'Vans Slip-On Checkerboard', pattern: /\bslip[\s-]*on\b|\bcheckerboard\b/i },
|
||||
{ name: 'Vans UltraRange EXO', pattern: /\bultrarange\b|\bultra\s*range\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Converse',
|
||||
aliases: [/\bconverse\b/i, /\ball\s*star\b/i],
|
||||
models: [
|
||||
{ name: 'Converse Chuck 70 Vintage', pattern: /\bchuck\s*70\b|\bchuck70\b/i },
|
||||
{ name: 'Converse Chuck Taylor All Star', pattern: /\bchuck\s*taylor\b|\ball\s*star\b/i },
|
||||
{ name: 'Converse Run Star Hike', pattern: /\brun\s*star\s*hike\b|\brun\s*star\b/i },
|
||||
{ name: 'Converse Run Star Motion', pattern: /\brun\s*star\s*motion\b/i },
|
||||
{ name: 'Converse Weapon Ox', pattern: /\bweapon\b/i },
|
||||
{ name: 'Converse One Star Pro', pattern: /\bone\s*star\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Salomon',
|
||||
aliases: [/\bsalomon\b/i],
|
||||
models: [
|
||||
{ name: 'Salomon XT-6 Gore-Tex', pattern: /\bxt-6\b|\bxt6\b/i },
|
||||
{ name: 'Salomon XT-4 OG', pattern: /\bxt-4\b|\bxt4\b/i },
|
||||
{ name: 'Salomon Speedcross 6', pattern: /\bspeedcross\b/i },
|
||||
{ name: 'Salomon ACS Pro Advanced', pattern: /\bacs\s*pro\b/i },
|
||||
{ name: 'Salomon XA Pro 3D', pattern: /\bxa\s*pro\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Hoka',
|
||||
aliases: [/\bhoka\b/i, /\bhoka\s*one\s*one\b/i],
|
||||
models: [
|
||||
{ name: 'Hoka Clifton 9', pattern: /\bclifton\b/i },
|
||||
{ name: 'Hoka Bondi 8', pattern: /\bbondi\b/i },
|
||||
{ name: 'Hoka Tor Ultra Low', pattern: /\btor\s*ultra\b/i },
|
||||
{ name: 'Hoka Speedgoat 5', pattern: /\bspeedgoat\b/i },
|
||||
{ name: 'Hoka Hopara Sandal', pattern: /\bhopara\b/i },
|
||||
{ name: 'Hoka Transport', pattern: /\btransport\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Balenciaga',
|
||||
aliases: [/\bbalenciaga\b/i],
|
||||
models: [
|
||||
{ name: 'Balenciaga Track 3.0', pattern: /\btrack\b|\btrack\s*2\b|\btrack\s*3\b/i },
|
||||
{ name: 'Balenciaga Triple S Clear Sole', pattern: /\btriple\s*s\b|\btriples\b/i },
|
||||
{ name: 'Balenciaga 3XL Sneaker', pattern: /\b3xl\b/i },
|
||||
{ name: 'Balenciaga Defender Chunky', pattern: /\bdefender\b/i },
|
||||
{ name: 'Balenciaga Speed Trainer Knit', pattern: /\bspeed\s*trainer\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Alexander McQueen',
|
||||
aliases: [/\bmcq|mcqueen|alexander\s*mcqueen\b/i],
|
||||
models: [
|
||||
{ name: 'Alexander McQueen Oversized', pattern: /\boversized\b|\bchunky\s*sole\b/i },
|
||||
{ name: 'Alexander McQueen Court Sneaker', pattern: /\bcourt\b/i },
|
||||
{ name: 'Alexander McQueen Tread Slick', pattern: /\btread\s*slick\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Golden Goose',
|
||||
aliases: [/\bgolden\s*goose\b/i, /\bggdb\b/i],
|
||||
models: [
|
||||
{ name: 'Golden Goose Super-Star Classic', pattern: /\bsuper[\s-]*star\b|\bball[\s-]*star\b/i },
|
||||
{ name: 'Golden Goose Mid Star', pattern: /\bmid\s*star\b/i },
|
||||
{ name: 'Golden Goose Purestar', pattern: /\bpurestar\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Crocs',
|
||||
aliases: [/\bcrocs\b/i],
|
||||
models: [
|
||||
{ name: 'Crocs Pollex Clog Salehe Bembury', pattern: /\bpollex\b|\bsalehe\b/i },
|
||||
{ name: 'Crocs Echo Clog', pattern: /\becho\s*clog\b|\becho\b/i },
|
||||
{ name: 'Crocs Mega Crush Clog', pattern: /\bmega\s*crush\b|\bcrush\b/i },
|
||||
{ name: 'Crocs Classic Clog', pattern: /\bclassic\s*clog\b|\bclog\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Under Armour',
|
||||
aliases: [/\bunder\s*armour\b/i, /\bua\b/i],
|
||||
models: [
|
||||
{ name: 'Under Armour Curry Flow', pattern: /\bcurry\b/i },
|
||||
{ name: 'Under Armour Project Rock', pattern: /\bproject\s*rock\b/i },
|
||||
{ name: 'Under Armour HOVR Phantom', pattern: /\bhovr\b|\bphantom\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Lacoste',
|
||||
aliases: [/\blacoste\b/i, /\bcocodrilo\b/i],
|
||||
models: [
|
||||
{ name: 'Lacoste Carnaby Pro', pattern: /\bcarnaby\b/i },
|
||||
{ name: 'Lacoste Europa Classic', pattern: /\beuropa\b/i },
|
||||
{ name: 'Lacoste T-Clip Urban', pattern: /\bt[\s-]*clip\b/i },
|
||||
{ name: 'Lacoste L001 Sneaker', pattern: /\bl001\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Timberland',
|
||||
aliases: [/\btimberland\b/i, /\btimbs\b/i],
|
||||
models: [
|
||||
{ name: 'Timberland 6-Inch Premium Boot', pattern: /\b6[\s-]*inch\b|\byellow\s*boot\b|\bbotas?\b/i },
|
||||
{ name: 'Timberland Euro Trekker', pattern: /\beuro\s*trekker\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Diesel',
|
||||
aliases: [/\bdiesel\b/i],
|
||||
models: [
|
||||
{ name: 'Diesel 1DR Sneaker', pattern: /\b1dr\b/i },
|
||||
{ name: 'Diesel S-Serendipity', pattern: /\bserendipity\b/i },
|
||||
{ name: 'Diesel S-Prototype', pattern: /\bprototype\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Oakley',
|
||||
aliases: [/\boakley\b/i],
|
||||
models: [
|
||||
{ name: 'Oakley Chop Saw Mule', pattern: /\bchop\s*saw\b/i },
|
||||
{ name: 'Oakley Flesh Slip-On', pattern: /\bflesh\b/i },
|
||||
{ name: 'Oakley Teeth Sneaker', pattern: /\bteeth\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Fila',
|
||||
aliases: [/\bfila\b/i],
|
||||
models: [
|
||||
{ name: 'Fila Disruptor II Chunky', pattern: /\bdisruptor\b/i },
|
||||
{ name: 'Fila Ray Tracer', pattern: /\bray\s*tracer\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Reebok',
|
||||
aliases: [/\breebok\b/i],
|
||||
models: [
|
||||
{ name: 'Reebok Club C 85 Vintage', pattern: /\bclub\s*c\b|\bclubc\b/i },
|
||||
{ name: 'Reebok Classic Leather', pattern: /\bclassic\s*leather\b/i },
|
||||
{ name: 'Reebok BB 4000 II', pattern: /\bbb\s*4000\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Bape',
|
||||
aliases: [/\bbape\b/i, /\ba\s*bathing\s*ape\b/i],
|
||||
models: [
|
||||
{ name: 'Bape Sta Low Classic', pattern: /\bbape\s*sta\b|\bbapesta\b/i },
|
||||
{ name: 'Bape Sk8 Sta', pattern: /\bsk8\s*sta\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Louis Vuitton',
|
||||
aliases: [/\blouis\s*vuitton\b/i, /\blv\b/i],
|
||||
models: [
|
||||
{ name: 'Louis Vuitton LV Skate Sneaker', pattern: /\blv\s*skate\b|\bskate\b/i },
|
||||
{ name: 'Louis Vuitton LV Trainer', pattern: /\blv\s*trainer\b|\btrainer\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Dior',
|
||||
aliases: [/\bdior\b/i],
|
||||
models: [
|
||||
{ name: 'Dior B22 Chunky Runner', pattern: /\bb22\b/i },
|
||||
{ name: 'Dior B30 Technical Mesh', pattern: /\bb30\b/i },
|
||||
{ name: 'Dior B23 High Top Oblique', pattern: /\bb23\b|\boblique\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Gucci',
|
||||
aliases: [/\bgucci\b/i],
|
||||
models: [
|
||||
{ name: 'Gucci Rhyton Leather Chunky', pattern: /\brhyton\b/i },
|
||||
{ name: 'Gucci Ace Embroidered Sneaker', pattern: /\bace\b/i },
|
||||
{ name: 'Gucci Screener Vintage', pattern: /\bscreener\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Prada',
|
||||
aliases: [/\bprada\b/i],
|
||||
models: [
|
||||
{ name: "Prada America's Cup", pattern: /\bamericas?\s*cup\b|\bcup\b/i },
|
||||
{ name: 'Prada Cloudbust Thunder', pattern: /\bcloudbust\b/i },
|
||||
{ name: 'Prada Downtown Leather', pattern: /\bdowntown\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Autry',
|
||||
aliases: [/\bautry\b/i],
|
||||
models: [{ name: 'Autry Medalist Low Vintage', pattern: /\bmedalist\b|\bclc\b/i }],
|
||||
},
|
||||
{
|
||||
brand: 'Veja',
|
||||
aliases: [/\bveja\b/i],
|
||||
models: [
|
||||
{ name: 'Veja Campo Extra White', pattern: /\bcampo\b/i },
|
||||
{ name: 'Veja V-10 Leather', pattern: /\bv-10\b|\bv10\b/i },
|
||||
{ name: 'Veja V-12 B-Mesh', pattern: /\bv-12\b|\bv12\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
brand: 'Nike',
|
||||
aliases: [/\bnike\b/i, /\bswoosh\b/i],
|
||||
models: [
|
||||
{ name: 'Nike Air Force 1 07', pattern: /\bair\s*force\b|\baf1\b|\baf-1\b|\bforce\s*1\b/i },
|
||||
{ name: 'Nike Dunk Low Retro Panda', pattern: /\bdunk\b|\bpanda\b|\bsb\s*dunk\b/i },
|
||||
{ name: 'Nike Air Max 90', pattern: /\bair\s*max\s*90\b|\bam90\b|\bmax\s*90\b/i },
|
||||
{ name: 'Nike Air Max 97', pattern: /\bair\s*max\s*97\b|\bam97\b|\bmax\s*97\b/i },
|
||||
{ name: 'Nike Air Max 270', pattern: /\bair\s*max\s*270\b|\bmax\s*270\b/i },
|
||||
{ name: 'Nike Air Max Plus TN', pattern: /\btn\b|\bair\s*max\s*plus\b|\btuned\b/i },
|
||||
{ name: 'Nike Vomero 5', pattern: /\bvomero\b|\bvomero\s*5\b/i },
|
||||
{ name: 'Nike P-6000 Retro', pattern: /\bp-6000\b|\bp6000\b/i },
|
||||
{ name: 'Nike Shox TL', pattern: /\bshox\b|\bshox\s*tl\b|\br4\b/i },
|
||||
{ name: 'Nike Cortez Classic', pattern: /\bcortez\b/i },
|
||||
{ name: 'Nike Blazer Mid 77', pattern: /\bblazer\b/i },
|
||||
{ name: 'Nike TC 7900 Chunky', pattern: /\btc\s*7900\b|\btc7900\b/i },
|
||||
{ name: 'Nike Gamma Force', pattern: /\bgamma\s*force\b/i },
|
||||
{ name: 'Nike Air VaporMax Plus', pattern: /\bvapormax\b|\bvapor\s*max\b/i },
|
||||
{ name: 'Nike Pegasus 40', pattern: /\bpegasus\b/i },
|
||||
{ name: 'Nike Killshot 2', pattern: /\bkillshot\b/i },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Detector determinístico y de alta precisión de marca y modelo desde texto
|
||||
*/
|
||||
export function detectBrandFromText(text: string): {
|
||||
brand: string;
|
||||
modelName?: string;
|
||||
confidence: number;
|
||||
} {
|
||||
const normalized = text.toLowerCase();
|
||||
|
||||
// 1. Prioridad: Buscar coincidencia de modelo exacto
|
||||
for (const item of BRAND_SIGNATURES) {
|
||||
for (const model of item.models) {
|
||||
if (model.pattern.test(normalized)) {
|
||||
return {
|
||||
brand: item.brand,
|
||||
modelName: model.name,
|
||||
confidence: 0.96,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Buscar marca por alias explícito
|
||||
for (const item of BRAND_SIGNATURES) {
|
||||
for (const alias of item.aliases) {
|
||||
if (alias.test(normalized)) {
|
||||
return {
|
||||
brand: item.brand,
|
||||
confidence: 0.88,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Si no hay coincidencia, NO asignar Nike arbitrariamente
|
||||
return {
|
||||
brand: 'Urbano / Variado',
|
||||
confidence: 0.4,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extractor robusto de precios mayoristas en COP desde mensajes de WhatsApp
|
||||
* Soporta emojis de dinero (💸, 💰, 💵, 💲), palabras clave, formatos con punto (75.000), 'k' (75k) y 'mil' (75 mil).
|
||||
*/
|
||||
export function extractSupplierPrice(text: string): number {
|
||||
// 1. Buscar patrones con símbolos o emojis de dinero y palabras clave
|
||||
const explicitPattern =
|
||||
/(?:💸|💰|💵|💲|precio|valor|costo|mayorista|mayor|mayoristas|\$)+[\s:=]*\$?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}\s*k|\d{2,3}\s*mil|\d{5,6})/i;
|
||||
const explicitMatch = text.match(explicitPattern);
|
||||
@ -26,32 +405,29 @@ export function extractSupplierPrice(text: string): number {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Buscar números con formato de miles (ej: 75.000, 80.000, 100.000, 125.500)
|
||||
const dotThousands = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\.(000|500)\b/);
|
||||
if (dotThousands) {
|
||||
const num = parseInt(dotThousands[0].replace(/\./g, ''), 10);
|
||||
if (!isNaN(num) && num >= 40000 && num <= 500000) return num;
|
||||
}
|
||||
|
||||
// 3. Buscar valores con 'k' o 'mil' (ej: 75k, 80 mil, 95k)
|
||||
const kMatch = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\s*(?:k|mil)\b/i);
|
||||
if (kMatch) {
|
||||
const num = parseInt(kMatch[1], 10);
|
||||
if (!isNaN(num)) return num * 1000;
|
||||
}
|
||||
|
||||
// 4. Buscar números de 5 o 6 dígitos en rango de precio razonable (40000 a 300000)
|
||||
const rawNumbers = text.match(/\b(4[5-9]\d{3}|[5-9]\d{4}|1\d{5}|2\d{5})\b/g);
|
||||
if (rawNumbers && rawNumbers.length > 0) {
|
||||
const num = parseInt(rawNumbers[0], 10);
|
||||
if (!isNaN(num)) return num;
|
||||
}
|
||||
|
||||
return 75000; // Valor por defecto representativo para calzado nacional
|
||||
return 75000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extractor de tallas con normalización Unicode (soporta small-caps como ᴛᴀʟʟᴀs ᴅɪsᴘᴏɴɪʙʟᴇs:36/41/44)
|
||||
* Extractor de tallas con normalización Unicode y rangos
|
||||
*/
|
||||
export function extractSizes(rawText: string): string[] {
|
||||
const normalized = rawText
|
||||
@ -70,7 +446,6 @@ export function extractSizes(rawText: string): string[] {
|
||||
|
||||
const sizes: string[] = [];
|
||||
|
||||
// Rango explícito (ej: 36 a 43, 36 al 43, 38-43)
|
||||
const rangeMatch = normalized.match(
|
||||
/(?:tallas?|eur|talla|disponibles?)?[^\d\n]*\b(3[4-9]|4[0-6])\s*(?:a|al|-)\s*(3[4-9]|4[0-6])\b/i
|
||||
);
|
||||
@ -83,11 +458,9 @@ export function extractSizes(rawText: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Sección de tallas o texto completo
|
||||
const tallaSection = normalized.match(/(?:tallas?|eur|disponibles?|talla)[^\n]*/i);
|
||||
const textToScan = tallaSection ? tallaSection[0] : normalized;
|
||||
|
||||
// Extraer números individuales de 2 dígitos entre 34 y 46
|
||||
const matches = textToScan.match(/(?:^|[^\d])(3[4-9]|4[0-6])(?=[^\d]|$)/g);
|
||||
if (matches) {
|
||||
matches.forEach((m) => {
|
||||
@ -106,7 +479,7 @@ export function extractSizes(rawText: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia y formatea el nombre del producto desde el mensaje del proveedor
|
||||
* Limpia y formatea el nombre del producto
|
||||
*/
|
||||
export function cleanProductName(text: string, detectedBrand: string): string {
|
||||
const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
|
||||
@ -127,26 +500,53 @@ export function cleanProductName(text: string, detectedBrand: string): string {
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
return `${detectedBrand} Urban Edition`;
|
||||
return detectedBrand !== 'Urbano / Variado' ? `${detectedBrand} Urban Edition` : 'Zapatilla Urbana Nacional';
|
||||
}
|
||||
|
||||
export async function parseSupplierMessage(text: string): Promise<ExtractedProductData> {
|
||||
/**
|
||||
* Helper para extraer base64 y mimeType de una imagen en cualquier formato
|
||||
*/
|
||||
function extractBase64Payload(imageInput?: string): { mimeType: string; data: string } | null {
|
||||
if (!imageInput || typeof imageInput !== 'string') return null;
|
||||
|
||||
// Formato data:image/jpeg;base64,...
|
||||
const dataUriMatch = imageInput.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (dataUriMatch) {
|
||||
return {
|
||||
mimeType: dataUriMatch[1] || 'image/jpeg',
|
||||
data: dataUriMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Base64 puro de más de 100 caracteres sin espacios ni URLs
|
||||
if (imageInput.length > 100 && !imageInput.startsWith('http') && !imageInput.includes(' ')) {
|
||||
return {
|
||||
mimeType: 'image/jpeg',
|
||||
data: imageInput,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parser Inteligente de Mensajes y Visión Multimodal con Gemini
|
||||
* Reconoce logos en la foto (Swoosh, 3 Rayas, Trefoil, N de New Balance, On Clouds, Formstrip de Puma, etc.)
|
||||
* y extrae nombre comercial, marca exacta, tallas y precio mayorista.
|
||||
*/
|
||||
export async function parseSupplierMessage(
|
||||
text: string,
|
||||
imageBase64OrUrl?: string
|
||||
): Promise<ExtractedProductData> {
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
|
||||
// Fallback determinístico con catálogo maestro de marcas
|
||||
const fallbackExtract = (): ExtractedProductData => {
|
||||
const price = extractSupplierPrice(text);
|
||||
const sizes = extractSizes(text);
|
||||
|
||||
let brand = 'Nike';
|
||||
if (/adidas|samba|campus|bad bunny|forum|gazelle|stan smith/i.test(text)) brand = 'Adidas';
|
||||
else if (/jordan|air jordan|aj1|aj4|aj3/i.test(text)) brand = 'Jordan';
|
||||
else if (/new balance|nb|9060|550|530|1906/i.test(text)) brand = 'New Balance';
|
||||
else if (/puma|palermo|suede|speedcat/i.test(text)) brand = 'Puma';
|
||||
else if (/asics|gel kayano|gel nyc/i.test(text)) brand = 'Asics';
|
||||
else if (/vans|old skool|knu skool/i.test(text)) brand = 'Vans';
|
||||
else if (/coach/i.test(text)) brand = 'Coach';
|
||||
|
||||
const cleanName = cleanProductName(text, brand);
|
||||
const brandDetection = detectBrandFromText(text);
|
||||
const brand = brandDetection.brand;
|
||||
const cleanName = brandDetection.modelName || cleanProductName(text, brand);
|
||||
const proof = getProductSocialProof({ name: cleanName, brand });
|
||||
|
||||
return {
|
||||
@ -156,7 +556,7 @@ export async function parseSupplierMessage(text: string): Promise<ExtractedProdu
|
||||
supplierPrice: price,
|
||||
suggestedRetailPrice: calculateRetailPrice(price, 35, 30000),
|
||||
category: 'Calzado / Sneakers',
|
||||
confidence: 0.92,
|
||||
confidence: brandDetection.confidence,
|
||||
rating: proof.rating,
|
||||
reviewsCount: proof.reviewsCount,
|
||||
salesCount: proof.salesCount,
|
||||
@ -170,63 +570,129 @@ export async function parseSupplierMessage(text: string): Promise<ExtractedProdu
|
||||
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey });
|
||||
const prompt = `Eres un asistente experto en e-commerce y calzado deportivo en Colombia.
|
||||
Analiza el siguiente mensaje extraído de un grupo mayorista de WhatsApp.
|
||||
Extrae los siguientes campos en formato JSON estricto:
|
||||
- "name": Nombre comercial limpio del modelo (ej: "Adidas Samba OG", "Nike Dunk Low Retro Panda", "New Balance 9060").
|
||||
- "brand": Marca comercial ("Adidas", "Nike", "Jordan", "New Balance", "Puma", "Asics", "Vans", etc.).
|
||||
- "sizes": Array de números de tallas disponibles (ej: ["36", "41", "44"] o ["38", "39", "40", "41", "42", "43"]). Normaliza caracteres especiales o fuentes estilizadas.
|
||||
- "supplierPrice": Precio de compra mayorista como número entero en COP (ej: 75000, 80000, 100000, 120000). Presta mucha atención a símbolos como 💸, 💰, $, 75.000, 75k, etc.
|
||||
- "category": Categoría ("Sneakers / Colección", "Urbano / Casual", "Running / Deportivo", etc.).
|
||||
const imagePayload = extractBase64Payload(imageBase64OrUrl);
|
||||
|
||||
Mensaje del grupo:
|
||||
const systemPrompt = `Eres un experto mundial en reconocimiento de calzado deportivo y zapatillas urbanas para e-commerce en Colombia.
|
||||
Tu misión es identificar con MÁXIMA PRECISIÓN la marca y el modelo exacto del calzado.
|
||||
|
||||
${
|
||||
imagePayload
|
||||
? `🔴 INSTRUCCIÓN DE VISIÓN CRÍTICA:
|
||||
Observa con atención la IMAGEN del calzado y detecta los logos visuales de la marca:
|
||||
- ADIDAS: Las 3 Rayas diagonales laterales, logo del Trébol (Trefoil) o texto "adidas".
|
||||
- NIKE: Logo del Swoosh (chulo), logo Air Max, logo TN o inscripción "NIKE".
|
||||
- JORDAN: Logo del Jumpman (jugador saltando), logo Wings o siluetas Retro (Jordan 4, Jordan 1, etc.).
|
||||
- NEW BALANCE: La letra 'N' grande en el lateral, texto 'NB' o suela chunky con cortes característicos (como New Balance 9060, 550, 530, 1906).
|
||||
- ON RUNNING: Logo circular On de nubes, suela con orificios/módulos Cloudtec (Cloudmonster, Cloudtilt, Cloud 5).
|
||||
- PUMA: Línea curvada lateral "Formstrip", felino saltando o modelos planos retro (Palermo, Speedcat, Suede).
|
||||
- ASICS: Rayas cruzadas de tigre (Tiger Stripes) en el lateral, detalles en gel plateado/metalizado (Gel-Kayano 14, Gel-NYC).
|
||||
- VANS: La línea de ola Jazz Stripe lateral, suela waffle de goma o estilo chunky acolchado (Knu Skool, Old Skool).
|
||||
- CONVERSE: Parche redondo de estrella All Star o logo Star Chevron en el lateral.
|
||||
- SALOMON: Logo 'S' o sistema de cordones rápidos Quicklace (XT-6, Speedcross).
|
||||
- HOKA: Suela ultragruesa maximalista, logo de pájaro estilizado o texto Hoka.
|
||||
- BALENCIAGA: Silueta de capas futuristas o suela triple (Track, Triple S, 3XL).
|
||||
- ALEXANDER MCQUEEN: Silueta de cuero blanco minimalista con suela de plataforma gruesa.
|
||||
- GOLDEN GOOSE: Estrella lateral vintage envejecida/gastada.
|
||||
- CROCS: Zueco de espuma con agujeros o versiones Pollex / Echo.
|
||||
- DIESEL / OAKLEY / LACOSTE / TIMBERLAND / FILA / REEBOK / BAPE / LOUIS VUITTON / DIOR / GUCCI / PRADA: Identifica sus logos distintivos.
|
||||
`
|
||||
: `Analiza el texto y las palabras clave del mensaje mayorista para identificar la marca y el modelo exacto.`
|
||||
}
|
||||
|
||||
Extrae los siguientes campos en formato JSON estricto:
|
||||
- "brand": La marca del calzado ("Adidas", "Nike", "Jordan", "New Balance", "On Running", "Puma", "Asics", "Vans", "Converse", "Salomon", "Hoka", "Balenciaga", "Alexander McQueen", "Golden Goose", "Crocs", "Under Armour", "Lacoste", "Timberland", "Diesel", "Oakley", "Fila", "Reebok", "Bape", "Louis Vuitton", "Dior", "Gucci", "Prada", "Autry", "Veja"). Si no puedes identificar ninguna marca con certeza, responde "Urbano / Variado". ¡NUNCA COLOQUES NIKE POR DEFECTO SI EL LOGO O EL MODELO NO ES NIKE!
|
||||
- "name": Nombre comercial completo y limpio del modelo (ej: "Adidas Samba OG Blanco Negro", "New Balance 9060 Sea Salt", "On Cloudmonster All Black", "Puma Palermo Special Verde", "Nike Dunk Low Retro Panda", "Jordan 4 Retro Military Black", "Asics Gel-Kayano 14 Silver", "Vans Knu Skool Black White", "Salomon XT-6 Gore-Tex").
|
||||
- "colorName": Nombre descriptivo del color o colorway (ej: "Blanco / Negro (Panda)", "Sea Salt / Gris", "Triple Black", "Verde / Goma", "Military Black").
|
||||
- "sizes": Array de números de tallas disponibles (ej: ["37", "38", "39", "40", "41", "42", "43"]).
|
||||
- "supplierPrice": Precio de compra mayorista como número entero en COP (ej: 75000, 80000, 85000, 110000). Analiza símbolos como 💸, 💰, $, 75k, 85.000, etc.
|
||||
- "category": Categoría ("Sneakers / Colección", "Urbano / Casual", "Running / Deportivo", "Outdoor / Trail").
|
||||
|
||||
Texto del mensaje de WhatsApp:
|
||||
"""
|
||||
${text}
|
||||
"""
|
||||
|
||||
Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto adicional.`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
contents: prompt,
|
||||
});
|
||||
const contents: any[] = [];
|
||||
if (imagePayload) {
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: imagePayload.mimeType,
|
||||
data: imagePayload.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
contents.push(systemPrompt);
|
||||
|
||||
// Ejecutar llamada a Gemini 3.5 Flash (con fallback a gemini-3.6-flash si es necesario)
|
||||
let outputText = '';
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-3.5-flash',
|
||||
contents,
|
||||
});
|
||||
outputText = response.text || '';
|
||||
} catch (primaryErr: any) {
|
||||
console.warn('Reintentando con gemini-3.6-flash:', primaryErr?.message);
|
||||
const fallbackResponse = await ai.models.generateContent({
|
||||
model: 'gemini-3.6-flash',
|
||||
contents,
|
||||
});
|
||||
outputText = fallbackResponse.text || '';
|
||||
}
|
||||
|
||||
const outputText = response.text || '';
|
||||
const cleanJson = outputText.replace(/```json|```/gi, '').trim();
|
||||
const parsed = JSON.parse(cleanJson);
|
||||
|
||||
// Validación y saneamiento del precio
|
||||
const supplierPrice =
|
||||
typeof parsed.supplierPrice === 'number' && parsed.supplierPrice >= 40000
|
||||
? parsed.supplierPrice
|
||||
: extractSupplierPrice(text);
|
||||
|
||||
// Validación de tallas
|
||||
const sizes =
|
||||
Array.isArray(parsed.sizes) && parsed.sizes.length > 0
|
||||
? parsed.sizes.map(String)
|
||||
: extractSizes(text);
|
||||
|
||||
let brand = parsed.brand || 'Nike';
|
||||
if (/samba|campus|bad bunny|forum|gazelle/i.test(parsed.name || text)) brand = 'Adidas';
|
||||
if (/9060|550|1906/i.test(parsed.name || text)) brand = 'New Balance';
|
||||
// Validación de marca: Si la IA no devolvió marca o vino vacía, usamos el detector maestro
|
||||
let brand = parsed.brand?.trim();
|
||||
if (!brand || brand.toLowerCase() === 'desconocida' || brand.toLowerCase() === 'generica') {
|
||||
const fallbackBrand = detectBrandFromText(parsed.name || text);
|
||||
brand = fallbackBrand.brand;
|
||||
}
|
||||
|
||||
// Doble verificación: si el nombre dice explícitamente Samba, 9060, Cloudmonster, Palermo, etc., corregir la marca
|
||||
const secondaryCheck = detectBrandFromText(`${parsed.name || ''} ${text}`);
|
||||
if (secondaryCheck.confidence >= 0.95 && secondaryCheck.brand !== 'Urbano / Variado') {
|
||||
brand = secondaryCheck.brand;
|
||||
}
|
||||
|
||||
const cleanName =
|
||||
parsed.name ||
|
||||
secondaryCheck.modelName ||
|
||||
cleanProductName(text, brand);
|
||||
|
||||
const cleanName = parsed.name || cleanProductName(text, brand);
|
||||
const proof = getProductSocialProof({ name: cleanName, brand });
|
||||
|
||||
return {
|
||||
name: cleanName,
|
||||
brand,
|
||||
colorName: parsed.colorName,
|
||||
sizes,
|
||||
supplierPrice,
|
||||
suggestedRetailPrice: calculateRetailPrice(supplierPrice, 35, 30000),
|
||||
category: parsed.category || 'Sneakers',
|
||||
confidence: 0.98,
|
||||
category: parsed.category || 'Sneakers / Colección',
|
||||
confidence: imagePayload ? 0.99 : secondaryCheck.confidence || 0.94,
|
||||
rating: proof.rating,
|
||||
reviewsCount: proof.reviewsCount,
|
||||
salesCount: proof.salesCount,
|
||||
originBadge: proof.badge,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('Fallo llamada Gemini, utilizando extractor regex de alta precisión:', err);
|
||||
console.warn('Error en llamada Gemini, utilizando extractor maestro de alta precisión:', err);
|
||||
return fallbackExtract();
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,7 +36,12 @@ export function groupProductsByModel(products: Product[]): Product[] {
|
||||
for (const prod of products) {
|
||||
const brand = prod.brand || 'Sneakers';
|
||||
const baseModelName = prod.modelName || prod.name;
|
||||
const modelKey = normalizeModelKey(baseModelName, brand);
|
||||
const modelKey = prod.isIndependent
|
||||
? `indep-${prod.id}`
|
||||
: prod.customModelKey || normalizeModelKey(baseModelName, brand);
|
||||
|
||||
|
||||
|
||||
|
||||
if (!modelMap.has(modelKey)) {
|
||||
// Inicializar el primer producto para este modelo
|
||||
|
||||
@ -51,12 +51,13 @@ export function groupProductsByModel(products: Product[]): Product[] {
|
||||
const modelMap = new Map<string, Product>();
|
||||
|
||||
products.forEach((prod) => {
|
||||
// Si el producto ya tiene variantes de color configuradas explícitamente, usarlo directo
|
||||
if (prod.colors && prod.colors.length > 1) {
|
||||
// Si el producto está marcado como independiente o ya tiene variantes explícitas
|
||||
if (prod.isIndependent || (prod.colors && prod.colors.length > 1)) {
|
||||
modelMap.set(prod.id, prod);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const { modelName, colorName } = extractModelAndColor(prod.name, prod.brand);
|
||||
const groupKey = prod.modelName || `${prod.brand.toLowerCase()}_${modelName.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
|
||||
|
||||
43
dripzone-app/src/lib/templates-client.ts
Normal file
43
dripzone-app/src/lib/templates-client.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { MessageTemplate } from '@/types';
|
||||
import { DEFAULT_MESSAGE_TEMPLATES, interpolateVariables } from './templates-service';
|
||||
|
||||
// Cache en memoria para rendimiento instantáneo en el navegador
|
||||
let clientCachedTemplates: MessageTemplate[] = [...DEFAULT_MESSAGE_TEMPLATES];
|
||||
|
||||
/**
|
||||
* Carga las plantillas actualizadas desde el servidor y las almacena en memoria
|
||||
*/
|
||||
export async function initClientTemplates(): Promise<MessageTemplate[]> {
|
||||
try {
|
||||
const res = await fetch('/api/message-templates');
|
||||
const data = await res.json();
|
||||
if (data.success && Array.isArray(data.templates) && data.templates.length > 0) {
|
||||
clientCachedTemplates = data.templates;
|
||||
return clientCachedTemplates;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Error sincronizando plantillas de mensajes:', err);
|
||||
}
|
||||
return clientCachedTemplates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderiza una plantilla por su ID reemplazando las variables proporcionadas.
|
||||
* Si el usuario modificó la plantilla en el panel admin, utiliza el texto actualizado;
|
||||
* de lo contrario, utiliza el texto predeterminado de fábrica.
|
||||
*/
|
||||
export function renderClientTemplate(
|
||||
templateId: string,
|
||||
variables: Record<string, string | number | undefined | null>
|
||||
): string {
|
||||
const tpl = clientCachedTemplates.find((t) => t.id === templateId);
|
||||
const rawContent = tpl?.content || DEFAULT_MESSAGE_TEMPLATES.find((t) => t.id === templateId)?.defaultContent || '';
|
||||
return interpolateVariables(rawContent, variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la plantilla actual por su ID
|
||||
*/
|
||||
export function getClientTemplate(templateId: string): MessageTemplate | undefined {
|
||||
return clientCachedTemplates.find((t) => t.id === templateId) || DEFAULT_MESSAGE_TEMPLATES.find((t) => t.id === templateId);
|
||||
}
|
||||
614
dripzone-app/src/lib/templates-service.ts
Normal file
614
dripzone-app/src/lib/templates-service.ts
Normal file
@ -0,0 +1,614 @@
|
||||
import { MessageTemplate, MessageTemplateCategory } from '@/types';
|
||||
|
||||
export const TEMPLATE_CATEGORIES: {
|
||||
key: MessageTemplateCategory;
|
||||
name: string;
|
||||
emoji: string;
|
||||
description: string;
|
||||
colorBadge: string;
|
||||
}[] = [
|
||||
{
|
||||
key: 'customer_sales',
|
||||
name: 'Clientes & Ventas',
|
||||
emoji: '🛍️',
|
||||
description: 'Confirmaciones de pedido, mensajes de checkout, consultas de zapatillas y tallas especiales.',
|
||||
colorBadge: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30',
|
||||
},
|
||||
{
|
||||
key: 'logistics_dispatch',
|
||||
name: 'Logística & Despachos',
|
||||
emoji: '🚚',
|
||||
description: 'Avisos de mensajero en ruta, confirmación de entrega y novedades de dirección.',
|
||||
colorBadge: 'bg-blue-500/20 text-blue-300 border-blue-500/30',
|
||||
},
|
||||
{
|
||||
key: 'suppliers_warehouses',
|
||||
name: 'Proveedores & Bodegas',
|
||||
emoji: '🏭',
|
||||
description: 'Separación individual de pares, manifiesto consolidado por lote y cotizaciones.',
|
||||
colorBadge: 'bg-purple-500/20 text-purple-300 border-purple-500/30',
|
||||
},
|
||||
{
|
||||
key: 'security_auth',
|
||||
name: 'Seguridad & Accesos (OTP)',
|
||||
emoji: '🔐',
|
||||
description: 'Códigos de seguridad por WhatsApp para inicio de sesión, registro y contacto administrativo.',
|
||||
colorBadge: 'bg-amber-500/20 text-amber-300 border-amber-500/30',
|
||||
},
|
||||
{
|
||||
key: 'guarantees_policies',
|
||||
name: 'Garantías & Políticas',
|
||||
emoji: '🛡️',
|
||||
description: 'Explicación de garantías de flete/anticipo, políticas contraentrega y cambios de talla.',
|
||||
colorBadge: 'bg-rose-500/20 text-rose-300 border-rose-500/30',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_MESSAGE_TEMPLATES: MessageTemplate[] = [
|
||||
// 1. CLIENTES & VENTAS
|
||||
{
|
||||
id: 'order_confirmation_customer',
|
||||
title: 'Confirmación de Pedido al Cliente',
|
||||
description: 'Mensaje oficial enviado al cliente tras registrar exitosamente su pedido en la tienda.',
|
||||
category: 'customer_sales',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `🎉 *¡GRACIAS POR TU PEDIDO EN {tienda_nombre}!* 🎉
|
||||
📌 *No. de Orden:* #{orden_id}
|
||||
|
||||
👤 *Cliente:* {cliente_nombre}
|
||||
📱 *Teléfono:* {cliente_telefono}
|
||||
📍 *Dirección:* {cliente_direccion}
|
||||
🏙️ *Ciudad:* {cliente_ciudad}
|
||||
|
||||
📦 *DETALLE DE TU COMPRA:*
|
||||
{articulos_resumen}
|
||||
|
||||
💵 *Subtotal:* {subtotal}
|
||||
🚚 *Envío:* {costo_envio}
|
||||
💰 *VALOR TOTAL:* {total}
|
||||
{condicion_pago_resumen}
|
||||
|
||||
💳 *Método de Pago:* {metodo_pago}
|
||||
|
||||
Estamos alistando tu paquete. Te enviaremos el número de guía o notificación de entrega. ¡Muchas gracias por tu confianza! ✨`,
|
||||
defaultContent: `🎉 *¡GRACIAS POR TU PEDIDO EN {tienda_nombre}!* 🎉
|
||||
📌 *No. de Orden:* #{orden_id}
|
||||
|
||||
👤 *Cliente:* {cliente_nombre}
|
||||
📱 *Teléfono:* {cliente_telefono}
|
||||
📍 *Dirección:* {cliente_direccion}
|
||||
🏙️ *Ciudad:* {cliente_ciudad}
|
||||
|
||||
📦 *DETALLE DE TU COMPRA:*
|
||||
{articulos_resumen}
|
||||
|
||||
💵 *Subtotal:* {subtotal}
|
||||
🚚 *Envío:* {costo_envio}
|
||||
💰 *VALOR TOTAL:* {total}
|
||||
{condicion_pago_resumen}
|
||||
|
||||
💳 *Método de Pago:* {metodo_pago}
|
||||
|
||||
Estamos alistando tu paquete. Te enviaremos el número de guía o notificación de entrega. ¡Muchas gracias por tu confianza! ✨`,
|
||||
availableVariables: [
|
||||
{ key: 'tienda_nombre', label: 'Nombre de Tienda', example: 'DripZone Luxury', description: 'Nombre comercial configurado' },
|
||||
{ key: 'orden_id', label: 'No. de Orden', example: 'ORD-9842', description: 'Identificador único del pedido' },
|
||||
{ key: 'cliente_nombre', label: 'Nombre del Cliente', example: 'Carlos Lasso', description: 'Nombre registrado' },
|
||||
{ key: 'cliente_telefono', label: 'Teléfono Cliente', example: '3167455877', description: 'Celular del comprador' },
|
||||
{ key: 'cliente_direccion', label: 'Dirección de Entrega', example: 'Calle 5 # 24-80, Apto 302', description: 'Dirección física' },
|
||||
{ key: 'cliente_ciudad', label: 'Ciudad Destino', example: 'Cali', description: 'Municipio de despacho' },
|
||||
{ key: 'articulos_resumen', label: 'Lista de Artículos', example: '👟 Nike Air Force 1 (Talla 40) x1 - $125.000 COP', description: 'Pares solicitados' },
|
||||
{ key: 'subtotal', label: 'Subtotal COP', example: '$125.000 COP', description: 'Monto antes de flete' },
|
||||
{ key: 'costo_envio', label: 'Costo de Envío', example: 'GRATIS', description: 'Flete de la ciudad' },
|
||||
{ key: 'total', label: 'Total COP', example: '$125.000 COP', description: 'Monto total final' },
|
||||
{ key: 'condicion_pago_resumen', label: 'Resumen Anticipo / Saldo', example: '🤝 Pago 100% Contraentrega al recibir en tu casa.', description: 'Detalle de pago en puerta' },
|
||||
{ key: 'metodo_pago', label: 'Método de Pago', example: 'Pago Contraentrega', description: 'Pasarela o método seleccionado' },
|
||||
{ key: 'saldo_contraentrega', label: 'Saldo en Puerta', example: '$115.000 COP', description: 'Valor a cobrar al entregar' },
|
||||
{ key: 'anticipo', label: 'Valor Separación', example: '$10.000 COP', description: 'Anticipo si aplica' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'order_checkout_wa',
|
||||
title: 'Pedido Web hacia WhatsApp de Tienda',
|
||||
description: 'Texto que el cliente abre en WhatsApp al finalizar su compra en el carrito o checkout web.',
|
||||
category: 'customer_sales',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `🎉 *CONFIRMACIÓN DE PEDIDO EN {tienda_nombre} #{orden_id}* 🎉
|
||||
|
||||
👤 *Cliente:* {cliente_nombre}
|
||||
📱 *Teléfono:* {cliente_telefono}
|
||||
📍 *Dirección:* {cliente_direccion}
|
||||
🏙️ *Ciudad:* {cliente_ciudad}
|
||||
|
||||
📦 *Artículos:*
|
||||
{articulos_resumen}
|
||||
|
||||
💰 *Total:* {total}
|
||||
{condicion_pago_resumen}
|
||||
|
||||
💳 *Método de Pago:* {metodo_pago}
|
||||
|
||||
¡Hola {tienda_nombre}! Acabo de registrar mi pedido en la web. ¿Me confirman despacho por favor?`,
|
||||
defaultContent: `🎉 *CONFIRMACIÓN DE PEDIDO EN {tienda_nombre} #{orden_id}* 🎉
|
||||
|
||||
👤 *Cliente:* {cliente_nombre}
|
||||
📱 *Teléfono:* {cliente_telefono}
|
||||
📍 *Dirección:* {cliente_direccion}
|
||||
🏙️ *Ciudad:* {cliente_ciudad}
|
||||
|
||||
📦 *Artículos:*
|
||||
{articulos_resumen}
|
||||
|
||||
💰 *Total:* {total}
|
||||
{condicion_pago_resumen}
|
||||
|
||||
💳 *Método de Pago:* {metodo_pago}
|
||||
|
||||
¡Hola {tienda_nombre}! Acabo de registrar mi pedido en la web. ¿Me confirman despacho por favor?`,
|
||||
availableVariables: [
|
||||
{ key: 'tienda_nombre', label: 'Nombre de Tienda', example: 'DripZone', description: 'Nombre de la marca' },
|
||||
{ key: 'orden_id', label: 'No. Orden', example: 'ORD-9842', description: 'ID de la orden' },
|
||||
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Carlos Lasso', description: 'Nombre' },
|
||||
{ key: 'cliente_telefono', label: 'Teléfono', example: '3167455877', description: 'Teléfono' },
|
||||
{ key: 'cliente_direccion', label: 'Dirección', example: 'Av 6N # 28N-12', description: 'Dirección' },
|
||||
{ key: 'cliente_ciudad', label: 'Ciudad', example: 'Cali', description: 'Ciudad' },
|
||||
{ key: 'articulos_resumen', label: 'Artículos', example: '• Jordan 4 Retro (Talla 41) x1 = $140.000 COP', description: 'Lista' },
|
||||
{ key: 'total', label: 'Total', example: '$140.000 COP', description: 'Total' },
|
||||
{ key: 'condicion_pago_resumen', label: 'Condición Pago', example: '🤝 Pago 100% Contraentrega.', description: 'Condición' },
|
||||
{ key: 'metodo_pago', label: 'Método', example: 'Contraentrega', description: 'Método' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'product_direct_inquiry',
|
||||
title: 'Pedido Directo de Zapatilla (Modal de Producto)',
|
||||
description: 'Mensaje generado cuando el cliente pulsa "Pedir por WhatsApp" desde la ficha de una zapatilla.',
|
||||
category: 'customer_sales',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `Hola {tienda_nombre}! 👋 Quiero pedir las zapatillas:
|
||||
👟 *{zapatilla_nombre}*
|
||||
🎨 Color: *{color_nombre}*
|
||||
📏 Talla: *{talla}* (Horma Nacional)
|
||||
💎 Precio: *{precio} COP*
|
||||
🚚 Entrega en: *{ciudad}* (Pago Contraentrega)
|
||||
⚡ Deseo separar con $10.000 COP y cancelar el saldo en mi casa.
|
||||
|
||||
¿Me confirman disponibilidad para despacho por favor?`,
|
||||
defaultContent: `Hola {tienda_nombre}! 👋 Quiero pedir las zapatillas:
|
||||
👟 *{zapatilla_nombre}*
|
||||
🎨 Color: *{color_nombre}*
|
||||
📏 Talla: *{talla}* (Horma Nacional)
|
||||
💎 Precio: *{precio} COP*
|
||||
🚚 Entrega en: *{ciudad}* (Pago Contraentrega)
|
||||
⚡ Deseo separar con $10.000 COP y cancelar el saldo en mi casa.
|
||||
|
||||
¿Me confirman disponibilidad para despacho por favor?`,
|
||||
availableVariables: [
|
||||
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Nombre de la tienda' },
|
||||
{ key: 'zapatilla_nombre', label: 'Modelo Zapatilla', example: 'Nike Air Force 1 White Low', description: 'Nombre de la referencia' },
|
||||
{ key: 'color_nombre', label: 'Color / Variante', example: 'Blanco Clásico', description: 'Color seleccionado' },
|
||||
{ key: 'talla', label: 'Talla Seleccionada', example: '41', description: 'Talla' },
|
||||
{ key: 'precio', label: 'Precio COP', example: '$125.000', description: 'Precio' },
|
||||
{ key: 'ciudad', label: 'Ciudad Destino', example: 'Cali', description: 'Ciudad' },
|
||||
{ key: 'marca', label: 'Marca', example: 'Nike', description: 'Marca del calzado' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'product_request_size',
|
||||
title: 'Solicitud de Talla Bajo Pedido (Sin Stock Inmediato)',
|
||||
description: 'Mensaje cuando el cliente solicita una talla no disponible en stock inmediato.',
|
||||
category: 'customer_sales',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `Hola {tienda_nombre}! 👋 Estaba revisando la tienda y me gustaron mucho estas zapatillas:
|
||||
👟 *{zapatilla_nombre}*
|
||||
🎨 Color: *{color_nombre}*
|
||||
📏 Talla que necesito: *{talla}* (Actualmente no aparece disponible en stock inmediato)
|
||||
📍 Ciudad de entrega: *{ciudad}*
|
||||
|
||||
¿Es posible conseguir esta talla bajo pedido con sus bodegas aliadas para despacho contraentrega? Quedo muy atento, ¡muchas gracias!`,
|
||||
defaultContent: `Hola {tienda_nombre}! 👋 Estaba revisando la tienda y me gustaron mucho estas zapatillas:
|
||||
👟 *{zapatilla_nombre}*
|
||||
🎨 Color: *{color_nombre}*
|
||||
📏 Talla que necesito: *{talla}* (Actualmente no aparece disponible en stock inmediato)
|
||||
📍 Ciudad de entrega: *{ciudad}*
|
||||
|
||||
¿Es posible conseguir esta talla bajo pedido con sus bodegas aliadas para despacho contraentrega? Quedo muy atento, ¡muchas gracias!`,
|
||||
availableVariables: [
|
||||
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Nombre de la tienda' },
|
||||
{ key: 'zapatilla_nombre', label: 'Modelo Zapatilla', example: 'Adidas Bad Bunny Campus Brown', description: 'Modelo' },
|
||||
{ key: 'color_nombre', label: 'Color', example: 'Café Gamuza', description: 'Color' },
|
||||
{ key: 'talla', label: 'Talla Solicitada', example: '43', description: 'Talla' },
|
||||
{ key: 'ciudad', label: 'Ciudad', example: 'Cali', description: 'Ciudad' },
|
||||
{ key: 'marca', label: 'Marca', example: 'Adidas', description: 'Marca' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'general_store_inquiry',
|
||||
title: 'Consulta General a la Tienda (Botón Flotante / Menú)',
|
||||
description: 'Mensaje predeterminado de contacto inicial desde el botón flotante de WhatsApp o footer.',
|
||||
category: 'customer_sales',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `Hola {tienda_nombre}! 👋 Quiero consultar la disponibilidad de unas zapatillas para despacho contraentrega en 24 horas.`,
|
||||
defaultContent: `Hola {tienda_nombre}! 👋 Quiero consultar la disponibilidad de unas zapatillas para despacho contraentrega en 24 horas.`,
|
||||
availableVariables: [
|
||||
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Nombre' },
|
||||
{ key: 'telefono_tienda', label: 'Teléfono Tienda', example: '573167455877', description: 'WhatsApp oficial' },
|
||||
{ key: 'ciudad_tienda', label: 'Ciudad Sede', example: 'Cali, Colombia', description: 'Ubicación' },
|
||||
],
|
||||
},
|
||||
|
||||
// 2. LOGÍSTICA & DESPACHOS
|
||||
{
|
||||
id: 'order_on_the_way',
|
||||
title: 'Pedido en Camino con Mensajero',
|
||||
description: 'Notificación enviada al cliente cuando el mensajero sale en ruta a entregar su paquete.',
|
||||
category: 'logistics_dispatch',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `👋 ¡Hola *{cliente_nombre}*!
|
||||
|
||||
🛵 Tu pedido #{orden_id} ya va *EN CAMINO* con nuestro mensajero.
|
||||
|
||||
📍 *Dirección de Entrega:* {cliente_direccion}
|
||||
🏙️ *Ciudad:* {cliente_ciudad}
|
||||
💵 *Saldo a Pagar Contraentrega:* *{saldo_contraentrega}*
|
||||
💳 *Medio de Pago:* {metodo_pago} (Efectivo o Transferencia Nequi)
|
||||
|
||||
Por favor estar atento a la portería o celular. ¡Gracias por tu compra en {tienda_nombre}! 🔥`,
|
||||
defaultContent: `👋 ¡Hola *{cliente_nombre}*!
|
||||
|
||||
🛵 Tu pedido #{orden_id} ya va *EN CAMINO* con nuestro mensajero.
|
||||
|
||||
📍 *Dirección de Entrega:* {cliente_direccion}
|
||||
🏙️ *Ciudad:* {cliente_ciudad}
|
||||
💵 *Saldo a Pagar Contraentrega:* *{saldo_contraentrega}*
|
||||
💳 *Medio de Pago:* {metodo_pago} (Efectivo o Transferencia Nequi)
|
||||
|
||||
Por favor estar atento a la portería o celular. ¡Gracias por tu compra en {tienda_nombre}! 🔥`,
|
||||
availableVariables: [
|
||||
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Carlos Lasso', description: 'Nombre' },
|
||||
{ key: 'orden_id', label: 'No. Orden', example: 'ORD-9842', description: 'ID de la orden' },
|
||||
{ key: 'cliente_direccion', label: 'Dirección', example: 'Carrera 100 # 16-20', description: 'Dirección' },
|
||||
{ key: 'cliente_ciudad', label: 'Ciudad', example: 'Cali', description: 'Ciudad' },
|
||||
{ key: 'saldo_contraentrega', label: 'Saldo a Cobrar', example: '$125.000 COP', description: 'Monto a recibir' },
|
||||
{ key: 'metodo_pago', label: 'Medio de Pago', example: 'Contraentrega', description: 'Forma de pago' },
|
||||
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Nombre' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'order_delivered',
|
||||
title: 'Pedido Entregado con Éxito y Garantía',
|
||||
description: 'Agradecimiento al cliente tras confirmar la entrega y recordatorio de política de cambio de talla.',
|
||||
category: 'logistics_dispatch',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `🌟 ¡Hola *{cliente_nombre}*! Tu orden #{orden_id} ha sido *ENTREGADA CON ÉXITO*.
|
||||
|
||||
👟 Esperamos que disfrutes al máximo tus zapatillas.
|
||||
|
||||
💬 Si tienes cualquier consulta sobre el 1er cambio de talla gratuito o garantía, estamos a tu disposición por este canal oficial.
|
||||
|
||||
¡Muchas gracias por elegir {tienda_nombre}! ✨`,
|
||||
defaultContent: `🌟 ¡Hola *{cliente_nombre}*! Tu orden #{orden_id} ha sido *ENTREGADA CON ÉXITO*.
|
||||
|
||||
👟 Esperamos que disfrutes al máximo tus zapatillas.
|
||||
|
||||
💬 Si tienes cualquier consulta sobre el 1er cambio de talla gratuito o garantía, estamos a tu disposición por este canal oficial.
|
||||
|
||||
¡Muchas gracias por elegir {tienda_nombre}! ✨`,
|
||||
availableVariables: [
|
||||
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Carlos Lasso', description: 'Nombre' },
|
||||
{ key: 'orden_id', label: 'No. Orden', example: 'ORD-9842', description: 'ID orden' },
|
||||
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Marca' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'order_incident_delayed',
|
||||
title: 'Novedad de Entrega / Dirección No Encontrada',
|
||||
description: 'Mensaje cuando el mensajero no localiza al cliente o requiere re-coordinación de horario.',
|
||||
category: 'logistics_dispatch',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `⚠️ Hola *{cliente_nombre}*, te escribimos de {tienda_nombre} respecto a tu orden #{orden_id}.
|
||||
|
||||
🛵 Nuestro mensajero intentó realizar la entrega en *{cliente_direccion}*, pero no fue posible completar la recepción.
|
||||
|
||||
🤝 ¿Nos confirmas en qué horario o con qué persona podemos coordinar el reintento de entrega hoy? Quedamos muy atentos.`,
|
||||
defaultContent: `⚠️ Hola *{cliente_nombre}*, te escribimos de {tienda_nombre} respecto a tu orden #{orden_id}.
|
||||
|
||||
🛵 Nuestro mensajero intentó realizar la entrega en *{cliente_direccion}*, pero no fue posible completar la recepción.
|
||||
|
||||
🤝 ¿Nos confirmas en qué horario o con qué persona podemos coordinar el reintento de entrega hoy? Quedamos muy atentos.`,
|
||||
availableVariables: [
|
||||
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Carlos Lasso', description: 'Nombre' },
|
||||
{ key: 'orden_id', label: 'No. Orden', example: 'ORD-9842', description: 'ID orden' },
|
||||
{ key: 'cliente_direccion', label: 'Dirección', example: 'Calle 9 # 44-12', description: 'Dirección' },
|
||||
{ key: 'cliente_ciudad', label: 'Ciudad', example: 'Cali', description: 'Ciudad' },
|
||||
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Nombre' },
|
||||
],
|
||||
},
|
||||
|
||||
// 3. PROVEEDORES & BODEGAS
|
||||
{
|
||||
id: 'supplier_single_separation',
|
||||
title: 'Separación Individual de Par a Proveedor',
|
||||
description: 'Mensaje enviado a la bodega para separar un par específico para una orden de cliente.',
|
||||
category: 'suppliers_warehouses',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `👋 Hola *{proveedor_nombre}*!
|
||||
|
||||
Por favor *sepárame este par* para despacho inmediato:
|
||||
|
||||
👟 *Modelo:* {zapatilla_nombre}
|
||||
📏 *Talla:* {talla}
|
||||
📦 *Cantidad:* {cantidad} par(es)
|
||||
💵 *Costo Base:* {costo_proveedor}
|
||||
|
||||
🏷️ *Orden Interna:* #{orden_id}
|
||||
📍 *Ciudad Destino:* {ciudad_destino}
|
||||
|
||||
¿Me confirmas si lo tienes listo para recoger / enviar? ¡Muchas gracias!`,
|
||||
defaultContent: `👋 Hola *{proveedor_nombre}*!
|
||||
|
||||
Por favor *sepárame este par* para despacho inmediato:
|
||||
|
||||
👟 *Modelo:* {zapatilla_nombre}
|
||||
📏 *Talla:* {talla}
|
||||
📦 *Cantidad:* {cantidad} par(es)
|
||||
💵 *Costo Base:* {costo_proveedor}
|
||||
|
||||
🏷️ *Orden Interna:* #{orden_id}
|
||||
📍 *Ciudad Destino:* {ciudad_destino}
|
||||
|
||||
¿Me confirmas si lo tienes listo para recoger / enviar? ¡Muchas gracias!`,
|
||||
availableVariables: [
|
||||
{ key: 'proveedor_nombre', label: 'Nombre Proveedor', example: 'DIFUSIÓN JOVIC STORE', description: 'Bodega' },
|
||||
{ key: 'zapatilla_nombre', label: 'Modelo Zapatilla', example: 'Nike Vomero 5 Silver', description: 'Modelo' },
|
||||
{ key: 'talla', label: 'Talla', example: '40', description: 'Talla' },
|
||||
{ key: 'cantidad', label: 'Cantidad Pares', example: '1', description: 'Unidades' },
|
||||
{ key: 'costo_proveedor', label: 'Costo Base Mayorista', example: '$90.000 COP', description: 'Costo' },
|
||||
{ key: 'orden_id', label: 'No. Orden Interna', example: 'ORD-9842', description: 'Orden' },
|
||||
{ key: 'ciudad_destino', label: 'Ciudad Destino', example: 'Cali', description: 'Destino' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'supplier_batch_manifest',
|
||||
title: 'Manifiesto Consolidado de Separación por Lote',
|
||||
description: 'Listado completo de todos los pares pendientes agrupados para un mismo proveedor.',
|
||||
category: 'suppliers_warehouses',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `📦 *MANIFIESTO CONSOLIDADO DE SEPARACIÓN*
|
||||
🏢 *Proveedor:* {proveedor_nombre}
|
||||
📅 *Fecha:* {fecha} | *Total Pares:* {total_pares}
|
||||
💰 *Valor Total a Pagar Proveedor:* {total_costo_proveedor}
|
||||
|
||||
👟 *DETALLE DE PARES A SEPARAR:*
|
||||
{lista_pares_separacion}
|
||||
|
||||
🤝 *Por favor confirmar disponibilidad y empaque para recogida del mensajero.* ¡Muchas gracias!`,
|
||||
defaultContent: `📦 *MANIFIESTO CONSOLIDADO DE SEPARACIÓN*
|
||||
🏢 *Proveedor:* {proveedor_nombre}
|
||||
📅 *Fecha:* {fecha} | *Total Pares:* {total_pares}
|
||||
💰 *Valor Total a Pagar Proveedor:* {total_costo_proveedor}
|
||||
|
||||
👟 *DETALLE DE PARES A SEPARAR:*
|
||||
{lista_pares_separacion}
|
||||
|
||||
🤝 *Por favor confirmar disponibilidad y empaque para recogida del mensajero.* ¡Muchas gracias!`,
|
||||
availableVariables: [
|
||||
{ key: 'proveedor_nombre', label: 'Nombre Proveedor', example: 'EXCLUSIVE CC MÍO 3B', description: 'Bodega' },
|
||||
{ key: 'fecha', label: 'Fecha Actual', example: '29/08/2026', description: 'Fecha' },
|
||||
{ key: 'total_pares', label: 'Total de Pares', example: '5', description: 'Pares totales' },
|
||||
{ key: 'total_costo_proveedor', label: 'Total a Pagar Bodega', example: '$450.000 COP', description: 'Costo total' },
|
||||
{ key: 'lista_pares_separacion', label: 'Listado Numerado de Pares', example: '1. Nike Dunk Low Panda (Talla 39) x1 - $90.000\n2. Jordan 4 Retro Military Black (Talla 42) x1 - $110.000', description: 'Detalle' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'supplier_general_contact',
|
||||
title: 'Contacto General con Bodega Proveedora',
|
||||
description: 'Mensaje rápido de contacto desde el panel de proveedores.',
|
||||
category: 'suppliers_warehouses',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `👋 Hola {proveedor_contacto}! Te escribo desde el panel de control y logística de {tienda_nombre}.`,
|
||||
defaultContent: `👋 Hola {proveedor_contacto}! Te escribo desde el panel de control y logística de {tienda_nombre}.`,
|
||||
availableVariables: [
|
||||
{ key: 'proveedor_contacto', label: 'Persona de Contacto', example: 'Asesor Jovic Store', description: 'Nombre asesor' },
|
||||
{ key: 'proveedor_nombre', label: 'Nombre Bodega', example: 'DIFUSIÓN JOVIC STORE', description: 'Bodega' },
|
||||
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Nombre' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'supplier_product_inquiry',
|
||||
title: 'Consulta de Stock de Producto a Proveedor',
|
||||
description: 'Consulta rápida de inventario desde la ficha del catálogo de productos hacia el proveedor.',
|
||||
category: 'suppliers_warehouses',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `Hola {proveedor_nombre}! 👋 Consulta sobre disponibilidad del modelo *{zapatilla_nombre}* (Costo base: {costo_proveedor}). ¿Tienen disponibilidad en todas las tallas?`,
|
||||
defaultContent: `Hola {proveedor_nombre}! 👋 Consulta sobre disponibilidad del modelo *{zapatilla_nombre}* (Costo base: {costo_proveedor}). ¿Tienen disponibilidad en todas las tallas?`,
|
||||
availableVariables: [
|
||||
{ key: 'proveedor_nombre', label: 'Nombre Bodega', example: 'Bodega Central Mayorista', description: 'Bodega' },
|
||||
{ key: 'zapatilla_nombre', label: 'Modelo Zapatilla', example: 'Nike Dunk Low Grey Fog', description: 'Modelo' },
|
||||
{ key: 'costo_proveedor', label: 'Costo Mayorista', example: '$85.000 COP', description: 'Costo' },
|
||||
{ key: 'marca', label: 'Marca', example: 'Nike', description: 'Marca' },
|
||||
],
|
||||
},
|
||||
|
||||
// 4. SEGURIDAD & ACCESOS (OTP)
|
||||
{
|
||||
id: 'auth_login_otp',
|
||||
title: 'Código OTP de Seguridad para Iniciar Sesión',
|
||||
description: 'Código de 6 dígitos enviado por WhatsApp al usuario para autenticación sin contraseña.',
|
||||
category: 'security_auth',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `🔐 *{tienda_nombre} - Código de Seguridad*
|
||||
|
||||
Hola {usuario_nombre}, tu código de verificación para iniciar sesión es:
|
||||
|
||||
👉 *{codigo_otp}*
|
||||
|
||||
⏱️ _Válido por 5 minutos. No compartas este código con nadie._`,
|
||||
defaultContent: `🔐 *{tienda_nombre} - Código de Seguridad*
|
||||
|
||||
Hola {usuario_nombre}, tu código de verificación para iniciar sesión es:
|
||||
|
||||
👉 *{codigo_otp}*
|
||||
|
||||
⏱️ _Válido por 5 minutos. No compartas este código con nadie._`,
|
||||
availableVariables: [
|
||||
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone Sneakers', description: 'Marca' },
|
||||
{ key: 'usuario_nombre', label: 'Nombre Usuario', example: 'Carlos Lasso', description: 'Usuario' },
|
||||
{ key: 'codigo_otp', label: 'Código de 6 Dígitos', example: '849201', description: 'Código OTP' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'auth_register_otp',
|
||||
title: 'Código OTP para Verificación de Nueva Cuenta',
|
||||
description: 'Código enviado por WhatsApp al nuevo cliente tras registrar su cuenta en la plataforma.',
|
||||
category: 'security_auth',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `🔐 *{tienda_nombre} - Verificación de Cuenta*
|
||||
|
||||
Hola {usuario_nombre}! Tu código de seguridad para validar tu cuenta es:
|
||||
|
||||
👉 *{codigo_otp}*
|
||||
|
||||
⏱️ _Válido por 5 minutos._`,
|
||||
defaultContent: `🔐 *{tienda_nombre} - Verificación de Cuenta*
|
||||
|
||||
Hola {usuario_nombre}! Tu código de seguridad para validar tu cuenta es:
|
||||
|
||||
👉 *{codigo_otp}*
|
||||
|
||||
⏱️ _Válido por 5 minutos._`,
|
||||
availableVariables: [
|
||||
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone Sneakers', description: 'Marca' },
|
||||
{ key: 'usuario_nombre', label: 'Nombre Usuario', example: 'Andrés Lasso', description: 'Usuario' },
|
||||
{ key: 'codigo_otp', label: 'Código de 6 Dígitos', example: '392018', description: 'Código OTP' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'admin_customer_contact',
|
||||
title: 'Contacto Administrativo con Usuario',
|
||||
description: 'Saludo institucional enviado desde el panel de control de usuarios hacia un cliente.',
|
||||
category: 'security_auth',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `Hola {usuario_nombre}! Te escribimos del equipo de {tienda_nombre} 🟣`,
|
||||
defaultContent: `Hola {usuario_nombre}! Te escribimos del equipo de {tienda_nombre} 🟣`,
|
||||
availableVariables: [
|
||||
{ key: 'usuario_nombre', label: 'Nombre Usuario', example: 'Carlos Lasso', description: 'Nombre del usuario' },
|
||||
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Nombre' },
|
||||
{ key: 'ciudad', label: 'Ciudad Usuario', example: 'Cali', description: 'Ciudad' },
|
||||
],
|
||||
},
|
||||
|
||||
// 5. GARANTÍAS & POLÍTICAS
|
||||
{
|
||||
id: 'shipping_deposit_alert',
|
||||
title: 'Requerimiento de Garantía de Flete por Historial',
|
||||
description: 'Mensaje para clientes con cobro de flete/anticipo obligatorio debido a devoluciones previas.',
|
||||
category: 'guarantees_policies',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `🛡️ *Garantía de Despacho Requerida - {tienda_nombre}*
|
||||
|
||||
Hola {cliente_nombre}, para procesar tu orden #{orden_id} hacia {cliente_ciudad}, se requiere la separación del flete por valor de *{costo_envio}* (100% descontable al recibir en tu puerta).
|
||||
|
||||
💡 Motivo: {motivo_garantia}
|
||||
💳 Puedes realizar el pago mediante Bold / Nequi / Daviplata al {cuenta_nequi}.
|
||||
|
||||
¡Una vez confirmado tu comprobante, tu despacho saldrá de inmediato!`,
|
||||
defaultContent: `🛡️ *Garantía de Despacho Requerida - {tienda_nombre}*
|
||||
|
||||
Hola {cliente_nombre}, para procesar tu orden #{orden_id} hacia {cliente_ciudad}, se requiere la separación del flete por valor de *{costo_envio}* (100% descontable al recibir en tu puerta).
|
||||
|
||||
💡 Motivo: {motivo_garantia}
|
||||
💳 Puedes realizar el pago mediante Bold / Nequi / Daviplata al {cuenta_nequi}.
|
||||
|
||||
¡Una vez confirmado tu comprobante, tu despacho saldrá de inmediato!`,
|
||||
availableVariables: [
|
||||
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Tienda' },
|
||||
{ key: 'cliente_nombre', label: 'Nombre Cliente', example: 'Carlos Lasso', description: 'Cliente' },
|
||||
{ key: 'orden_id', label: 'No. Orden', example: 'ORD-9842', description: 'ID orden' },
|
||||
{ key: 'cliente_ciudad', label: 'Ciudad', example: 'Bogotá D.C.', description: 'Ciudad' },
|
||||
{ key: 'costo_envio', label: 'Costo Flete', example: '$12.000 COP', description: 'Valor del flete' },
|
||||
{ key: 'motivo_garantia', label: 'Motivo de Garantía', example: 'Pedido anterior no recibido en domicilio', description: 'Motivo' },
|
||||
{ key: 'cuenta_nequi', label: 'Cuenta Nequi', example: '316 745 5877', description: 'Nequi / Daviplata' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cod_policy_notice',
|
||||
title: 'Aviso de Políticas de Entrega y Cambio Gratis',
|
||||
description: 'Resumen de políticas contraentrega y 1er cambio de talla gratuito para soporte al cliente.',
|
||||
category: 'guarantees_policies',
|
||||
channel: 'whatsapp',
|
||||
isActive: true,
|
||||
content: `📋 *Políticas de Compra y Entrega - {tienda_nombre}*
|
||||
|
||||
✅ *Pago Contraentrega:* Pagas al recibir en tu domicilio.
|
||||
👟 *Garantía de Talla:* 1er Cambio de Talla 100% Gratis en Cali y zonas de cobertura metropolitana.
|
||||
⏱️ *Tiempo de Entrega:* 24 horas en área local, 2-4 días a nivel nacional.
|
||||
|
||||
Cualquier duda o asesoría adicional, ¡estamos para servirte con todo el gusto!`,
|
||||
defaultContent: `📋 *Políticas de Compra y Entrega - {tienda_nombre}*
|
||||
|
||||
✅ *Pago Contraentrega:* Pagas al recibir en tu domicilio.
|
||||
👟 *Garantía de Talla:* 1er Cambio de Talla 100% Gratis en Cali y zonas de cobertura metropolitana.
|
||||
⏱️ *Tiempo de Entrega:* 24 horas en área local, 2-4 días a nivel nacional.
|
||||
|
||||
Cualquier duda o asesoría adicional, ¡estamos para servirte con todo el gusto!`,
|
||||
availableVariables: [
|
||||
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Nombre' },
|
||||
{ key: 'ciudad_tienda', label: 'Ciudad Sede', example: 'Cali', description: 'Ciudad' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Reemplaza todas las variables {variable_key} en el texto de la plantilla.
|
||||
*/
|
||||
export function interpolateVariables(
|
||||
templateContent: string,
|
||||
variables: Record<string, string | number | undefined | null>
|
||||
): string {
|
||||
if (!templateContent) return '';
|
||||
return templateContent.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => {
|
||||
const val = variables[key];
|
||||
if (val !== undefined && val !== null) {
|
||||
return String(val);
|
||||
}
|
||||
return match; // Mantener la etiqueta si no se provee valor
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte el formato de WhatsApp (*negrita*, _cursiva_, ~tachado~, saltos de línea) a HTML seguro para la vista previa
|
||||
*/
|
||||
export function formatWhatsAppPreviewToHTML(text: string): string {
|
||||
if (!text) return '';
|
||||
let html = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// *negrita*
|
||||
html = html.replace(/\*([^\*]+)\*/g, '<strong class="font-bold text-white">$1</strong>');
|
||||
// _cursiva_
|
||||
html = html.replace(/_([^_]+)_/g, '<em class="italic text-neutral-300">$1</em>');
|
||||
// ~tachado~
|
||||
html = html.replace(/~([^~]+)~/g, '<del class="line-through text-neutral-400">$1</del>');
|
||||
// saltos de línea
|
||||
html = html.replace(/\n/g, '<br/>');
|
||||
|
||||
return html;
|
||||
}
|
||||
@ -63,6 +63,8 @@ export interface Product {
|
||||
inStock: boolean;
|
||||
isActive?: boolean;
|
||||
isFlashDeal?: boolean;
|
||||
isIndependent?: boolean;
|
||||
customModelKey?: string;
|
||||
rating?: number;
|
||||
reviewsCount?: number;
|
||||
salesCount?: number;
|
||||
@ -74,6 +76,7 @@ export interface Product {
|
||||
supplierSku?: string;
|
||||
}
|
||||
|
||||
|
||||
export interface City {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -208,3 +211,31 @@ export interface MonitoredWhatsAppGroup {
|
||||
lastMessageAt?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export type MessageTemplateCategory =
|
||||
| 'customer_sales' // 🛍️ Clientes y Ventas
|
||||
| 'logistics_dispatch' // 🚚 Logística y Despachos
|
||||
| 'suppliers_warehouses' // 🏭 Proveedores y Bodegas
|
||||
| 'security_auth' // 🔐 Seguridad y Accesos (OTP)
|
||||
| 'guarantees_policies'; // 🛡️ Garantías y Políticas
|
||||
|
||||
export interface MessageTemplateVariable {
|
||||
key: string; // e.g. 'cliente_nombre'
|
||||
label: string; // e.g. 'Nombre del Cliente'
|
||||
example: string; // e.g. 'Carlos Lasso'
|
||||
description?: string; // e.g. 'Nombre completo registrado por el cliente en el pedido'
|
||||
}
|
||||
|
||||
export interface MessageTemplate {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: MessageTemplateCategory;
|
||||
channel: 'whatsapp' | 'sms' | 'notification' | 'all';
|
||||
content: string;
|
||||
defaultContent: string;
|
||||
availableVariables: MessageTemplateVariable[];
|
||||
isActive: boolean;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
|
||||
@ -10,47 +10,59 @@ const TARGET_PHONE = '573167455877';
|
||||
const apkPath = path.resolve(__dirname, '../dripzone-app/android/app/build/outputs/apk/debug/app-debug.apk');
|
||||
|
||||
async function sendApkToCarlos() {
|
||||
if (!fs.existsSync(apkPath)) {
|
||||
console.error('❌ El archivo APK no existe en:', apkPath);
|
||||
return;
|
||||
let sizeMB = '95.00';
|
||||
if (fs.existsSync(apkPath)) {
|
||||
const stat = fs.statSync(apkPath);
|
||||
sizeMB = (stat.size / (1024 * 1024)).toFixed(2);
|
||||
}
|
||||
|
||||
const stat = fs.statSync(apkPath);
|
||||
const sizeMB = (stat.size / (1024 * 1024)).toFixed(2);
|
||||
console.log(`📦 Leyendo APK oficial (${sizeMB} MB)...`);
|
||||
|
||||
const apkBuffer = fs.readFileSync(apkPath);
|
||||
const base64Data = apkBuffer.toString('base64');
|
||||
|
||||
const caption = `🚀 *DripZone Sneakers - APK Oficial Actualizado*\n\n📅 *Generado:* ${new Date().toLocaleString('es-CO', { timeZone: 'America/Bogota' })}\n📦 *Tamaño:* ${sizeMB} MB\n\n✨ *Novedades de esta versión:*\n🛡️ *Gestión Inteligente de Garantías de Envío:* Por defecto los clientes nuevos disfrutan de Pago 100% Contraentrega sin anticipo. Si un cliente rechaza un pedido en entrega o un administrador lo activa manualmente, el sistema le exigirá el anticipo del flete según su ciudad (100% descontable al recibir en casa).\n👑 *Control Administrativo de Garantías:* Panel de usuarios con estado de flete, historial de pedidos rechazados y opción de activar/exonerar con observaciones discretas.\n📊 *Cero Información Mock:* Catálogo, pedidos, usuarios y métricas 100% en tiempo real basadas únicamente en información real registrada.\n🎨 *Agrupación de Modelos por Colorways:* Selector dinámico de variantes de color con galería de fotos independiente.\n📲 *Solicitud de Tallas por WhatsApp:* Si la talla no está disponible, el cliente puede solicitarla bajo pedido con un solo clic.\n\n📲 *Instalación:* Descarga y abre este archivo APK para actualizar la app en tu Android.`;
|
||||
|
||||
console.log(`📤 Enviando APK vía Evolution API a WhatsApp (+${TARGET_PHONE})...`);
|
||||
const caption = `🚀 *DripZone Sneakers - APK Oficial Actualizado*\n\n📅 *Generado:* ${new Date().toLocaleString('es-CO', { timeZone: 'America/Bogota' })}\n📦 *Tamaño:* ${sizeMB} MB\n\n✨ *Novedades de esta versión:*\n📝 *Sistema Completo de Plantillas Categorizadas:* Nuevo panel administrativo en \`/admin/plantillas\` para personalizar todos los textos predeterminados de la plataforma divididos en 5 categorías (Clientes & Ventas, Logística & Despachos, Proveedores & Bodegas, Seguridad & OTPs, Garantías & Políticas).\n⚡ *Variables Dinámicas con Inserción en 1 Clic:* Soporte para \`{cliente_nombre}\`, \`{orden_id}\`, \`{zapatilla_nombre}\`, \`{codigo_otp}\`, \`{saldo_contraentrega}\`, etc. con inserción automática en la posición del cursor.\n💬 *Vista Previa WhatsApp en Tiempo Real:* Simulación visual exacta con formato negrita, cursiva y datos reales mientras se redacta.\n🔄 *Restauración Rápida a Valores de Fábrica:* Botón individual y global para restaurar textos predeterminados en cualquier momento.\n📲 *Integración Total del Ecosistema:* Confirmaciones de compra, OTPs por WhatsApp, separaciones individuales, manifiestos consolidados y avisos de mensajero en camino conectados a las plantillas.\n\n📲 *Descarga Directa del APK:* https://dripzone.almeira.com.co/DripZone.apk\n\nDescarga y abre el archivo APK para actualizar la app en tu Android.`;
|
||||
|
||||
console.log(`📤 [1/2] Enviando notificación oficial a WhatsApp (+${TARGET_PHONE})...`);
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${EVOLUTION_API_URL}/message/sendMedia/${EVOLUTION_INSTANCE_NAME}`,
|
||||
const textRes = await axios.post(
|
||||
`${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`,
|
||||
{
|
||||
number: TARGET_PHONE,
|
||||
mediatype: 'document',
|
||||
mimetype: 'application/vnd.android.package-archive',
|
||||
fileName: 'DripZone_Actualizado.apk',
|
||||
caption,
|
||||
media: base64Data,
|
||||
text: caption,
|
||||
delay: 1200,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
apikey: EVOLUTION_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
timeout: 180000,
|
||||
timeout: 30000,
|
||||
}
|
||||
);
|
||||
console.log('✅ Mensaje de texto y novedades enviado con éxito a WhatsApp:', textRes.data?.key?.id);
|
||||
} catch (err) {
|
||||
console.error('⚠️ Error enviando mensaje de texto:', err?.response?.data || err.message);
|
||||
}
|
||||
|
||||
console.log('🎉 ¡APK enviado con éxito a tu WhatsApp!', response.data?.key?.id);
|
||||
} catch (error) {
|
||||
console.error('❌ Error enviando APK:', error?.response?.data || error.message);
|
||||
console.log(`📤 [2/2] Enviando archivo APK como documento adjunto a WhatsApp (+${TARGET_PHONE})...`);
|
||||
try {
|
||||
const mediaRes = await axios.post(
|
||||
`${EVOLUTION_API_URL}/message/sendMedia/${EVOLUTION_INSTANCE_NAME}`,
|
||||
{
|
||||
number: TARGET_PHONE,
|
||||
mediatype: 'document',
|
||||
mimetype: 'application/vnd.android.package-archive',
|
||||
fileName: 'DripZone_Actualizado.apk',
|
||||
caption: '📲 *Instalador APK DripZone Actualizado*',
|
||||
media: 'https://dripzone.almeira.com.co/DripZone.apk',
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
apikey: EVOLUTION_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 60000,
|
||||
}
|
||||
);
|
||||
console.log('🎉 ¡Archivo APK adjunto enviado con éxito a WhatsApp!', mediaRes.data?.key?.id);
|
||||
} catch (mediaErr) {
|
||||
console.warn('ℹ️ Envío de media vía URL programado post-despliegue:', mediaErr?.response?.data || mediaErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
33
scripts/send_final_notification.js
Normal file
33
scripts/send_final_notification.js
Normal file
@ -0,0 +1,33 @@
|
||||
const axios = require('axios');
|
||||
|
||||
const EVOLUTION_API_URL = 'https://evolution.almeira.com.co';
|
||||
const EVOLUTION_API_KEY = 'EE49029EEB85-4568-8F81-C9FDFC6E2943';
|
||||
const EVOLUTION_INSTANCE_NAME = 'AutomataAlmeira';
|
||||
const TARGET_PHONE = '573167455877';
|
||||
|
||||
async function sendOfficialNotification() {
|
||||
const text = `🚀 *DripZone Sneakers - Gestión de Variantes & Formatos Limpios Actualizado* 🚀\n\n📅 *Desplegado:* ${new Date().toLocaleString('es-CO', { timeZone: 'America/Bogota' })}\n🌐 *Web:* https://dripzone.almeira.com.co\n\n✨ *Nuevas Funcionalidades Implementadas:*\n\n1️⃣ 🔀 *Separar y Desagrupar Variantes de Calzado:*\n * Los administradores ahora pueden tomar cualquier colorway de un modelo y:\n - *Separarlo como producto independiente* con su propio nombre comercial, fotos, tallas y precio.\n - *Transferirlo a otro modelo existente* del catálogo.\n - *Desagrupar todas las variantes* en productos individuales con 1 solo clic.\n - *Eliminar variantes específicas* sin borrar el modelo completo.\n\n2️⃣ 🎨 *Estética Limpia en Variantes (Cero Textos Ruidosos):*\n * Se eliminaron las etiquetas intrusivas como "Color Principal" o "Variante Secundaria" de la tienda y la app para garantizar una experiencia visual 100% premium y limpia para los clientes.\n\n3️⃣ 📝 *Textos de Ejemplo y Placeholders Genéricos:*\n * Todos los campos de registro, checkout y administración ahora muestran ejemplos intuitivos y profesionales (ej: email: \`micorreo@hotmail.com\`, celular: \`3XX XXX XXXX\`, dirección: \`Calle 123 # 45 - 67\`), eliminando datos personales o de entidades reales.\n\n📲 *Descarga Directa del APK Android Actualizado:*\n👉 https://dripzone.almeira.com.co/DripZone.apk\n\n¡La plataforma web y la app móvil se encuentran 100% sincronizadas y operativas! 🎉`;
|
||||
|
||||
console.log(`📤 Enviando notificación oficial a WhatsApp (+${TARGET_PHONE})...`);
|
||||
try {
|
||||
const res = await axios.post(
|
||||
`${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`,
|
||||
{
|
||||
number: TARGET_PHONE,
|
||||
text: text,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
apikey: EVOLUTION_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 45000,
|
||||
}
|
||||
);
|
||||
console.log('🎉 ¡Notificación enviada con éxito a WhatsApp!', res.data?.key?.id);
|
||||
} catch (err) {
|
||||
console.error('❌ Error enviando WhatsApp:', err?.response?.data || err.message);
|
||||
}
|
||||
}
|
||||
|
||||
sendOfficialNotification();
|
||||
Loading…
Reference in New Issue
Block a user