feat: colecciones WhatsApp catalog (Sandalias, Zapatillas, Gorras, etc) + imagen al inicio de ficha de edicion

This commit is contained in:
AI Bot 2026-09-10 10:24:51 -05:00
parent ff95bbd98a
commit 0c826b068d
20 changed files with 1328 additions and 115 deletions

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { import {
MessageCircle, MessageCircle,
@ -9,7 +9,6 @@ import {
Check, Check,
ExternalLink, ExternalLink,
ShieldCheck, ShieldCheck,
AlertCircle,
CheckCircle2, CheckCircle2,
Package, Package,
Layers, Layers,
@ -23,11 +22,17 @@ import {
Tag, Tag,
Link2, Link2,
Download, Download,
Send, FolderSync,
ChevronDown,
} from 'lucide-react'; } from 'lucide-react';
import { Product, StoreSettings } from '@/types'; import { Product, StoreSettings } from '@/types';
import { formatCOP } from '@/lib/pricing-engine'; import { formatCOP } from '@/lib/pricing-engine';
import { WhatsAppOrderLinkModal } from '@/components/admin/WhatsAppOrderLinkModal'; import { WhatsAppOrderLinkModal } from '@/components/admin/WhatsAppOrderLinkModal';
import {
WHATSAPP_COLLECTIONS,
OFFICIAL_COLLECTION_NAMES,
resolveProductCollection,
} from '@/lib/whatsapp-catalog';
export default function AdminWhatsAppCatalogPage() { export default function AdminWhatsAppCatalogPage() {
const [products, setProducts] = useState<Product[]>([]); const [products, setProducts] = useState<Product[]>([]);
@ -35,9 +40,10 @@ export default function AdminWhatsAppCatalogPage() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [syncMessage, setSyncMessage] = useState<string | null>(null); const [syncMessage, setSyncMessage] = useState<string | null>(null);
const [copiedFeed, setCopiedFeed] = useState<'xml' | 'csv' | null>(null); const [copiedFeed, setCopiedFeed] = useState<string | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all'); const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [selectedCollection, setSelectedCollection] = useState<string>('all');
const [savingSettings, setSavingSettings] = useState(false); const [savingSettings, setSavingSettings] = useState(false);
// Modal para Generación de Enlaces de Pedido WhatsApp para Asesores // Modal para Generación de Enlaces de Pedido WhatsApp para Asesores
@ -85,9 +91,9 @@ export default function AdminWhatsAppCatalogPage() {
fetchData(); fetchData();
}, []); }, []);
const handleCopyUrl = (type: 'xml' | 'csv', url: string) => { const handleCopyUrl = (key: string, url: string) => {
navigator.clipboard.writeText(url); navigator.clipboard.writeText(url);
setCopiedFeed(type); setCopiedFeed(key);
setTimeout(() => setCopiedFeed(null), 2500); setTimeout(() => setCopiedFeed(null), 2500);
}; };
@ -144,6 +150,84 @@ export default function AdminWhatsAppCatalogPage() {
} }
}; };
const handleNormalizeCollections = async () => {
setIsSyncing(true);
setSyncMessage(null);
try {
const res = await fetch('/api/catalog/collections/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ normalizeProducts: true }),
});
const data = await res.json();
if (data.success) {
setSyncMessage(`✨ ${data.message}`);
fetchData();
} else {
setSyncMessage(`⚠️ ${data.error || 'Error normalizando colecciones'}`);
}
} catch (err: any) {
setSyncMessage(`❌ Error: ${err.message}`);
} finally {
setIsSyncing(false);
setTimeout(() => setSyncMessage(null), 5000);
}
};
const handleSyncMetaProductSets = async () => {
setIsSyncing(true);
setSyncMessage(null);
try {
const res = await fetch('/api/catalog/collections/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
metaCatalogId,
metaAccessToken,
}),
});
const data = await res.json();
if (data.success) {
setSyncMessage(`🚀 ${data.message}`);
} else {
setSyncMessage(`⚠️ ${data.error || 'Alerta en sincronización de colecciones'}`);
}
} catch (err: any) {
setSyncMessage(`❌ Error: ${err.message}`);
} finally {
setIsSyncing(false);
setTimeout(() => setSyncMessage(null), 6000);
}
};
const handleChangeProductCollection = async (product: Product, newCollection: string) => {
// Actualización optimista en UI
setProducts((prev) =>
prev.map((p) => (p.id === product.id ? { ...p, category: newCollection } : p))
);
try {
const res = await fetch('/api/products', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: product.id,
updates: { category: newCollection },
}),
});
const data = await res.json();
if (data.success) {
setSyncMessage(`📦 "${product.name}" asignado a la colección "${newCollection}".`);
setTimeout(() => setSyncMessage(null), 3500);
}
} catch (e: any) {
console.error('Error changing collection:', e);
}
};
const handleToggleProductStatus = async (product: Product) => { const handleToggleProductStatus = async (product: Product) => {
const newStatus = product.isActive === false ? true : false; const newStatus = product.isActive === false ? true : false;
@ -197,15 +281,38 @@ export default function AdminWhatsAppCatalogPage() {
const activeProducts = products.filter((p) => p.isActive !== false); const activeProducts = products.filter((p) => p.isActive !== false);
const inactiveProducts = products.filter((p) => p.isActive === false); const inactiveProducts = products.filter((p) => p.isActive === false);
// Conteo de productos por colección
const collectionCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const name of OFFICIAL_COLLECTION_NAMES) {
counts[name] = 0;
}
for (const p of products) {
const col = resolveProductCollection(p);
counts[col.name] = (counts[col.name] || 0) + 1;
}
return counts;
}, [products]);
const filteredProducts = products.filter((p) => { const filteredProducts = products.filter((p) => {
const col = resolveProductCollection(p);
const matchesSearch = const matchesSearch =
p.name.toLowerCase().includes(search.toLowerCase()) || p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.brand && p.brand.toLowerCase().includes(search.toLowerCase())) || (p.brand && p.brand.toLowerCase().includes(search.toLowerCase())) ||
(p.category && p.category.toLowerCase().includes(search.toLowerCase())); col.name.toLowerCase().includes(search.toLowerCase());
if (statusFilter === 'active') return matchesSearch && p.isActive !== false; const matchesStatus =
if (statusFilter === 'inactive') return matchesSearch && p.isActive === false; statusFilter === 'all'
return matchesSearch; ? true
: statusFilter === 'active'
? p.isActive !== false
: p.isActive === false;
const matchesCollection =
selectedCollection === 'all' ? true : col.name === selectedCollection;
return matchesSearch && matchesStatus && matchesCollection;
}); });
return ( return (
@ -218,31 +325,41 @@ export default function AdminWhatsAppCatalogPage() {
<MessageCircle className="w-6 h-6" /> <MessageCircle className="w-6 h-6" />
</span> </span>
<h1 className="text-2xl sm:text-3xl font-black text-white tracking-tight"> <h1 className="text-2xl sm:text-3xl font-black text-white tracking-tight">
Catálogo Oficial de WhatsApp Business Catálogo de WhatsApp Business & Colecciones
</h1> </h1>
</div> </div>
<p className="text-xs sm:text-sm text-neutral-400 mt-1"> <p className="text-xs sm:text-sm text-neutral-400 mt-1">
Sincronización automática de calzado con foto HD, precio COP, descripción completa y tallas disponibles para la línea oficial DripZone (+57 310 639 0296). Organiza tus productos en colecciones oficiales (Sandalias, Zapatillas, Gorras, etc.) con foto HD, tallas y precio COP para la línea oficial (+57 310 639 0296).
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-2.5">
<button <button
onClick={fetchData} onClick={fetchData}
disabled={isLoading} disabled={isLoading}
className="px-4 py-2.5 bg-neutral-900 border border-neutral-700 hover:border-neutral-500 text-neutral-300 hover:text-white rounded-xl text-xs font-bold flex items-center gap-2 transition-all cursor-pointer" className="px-3.5 py-2 bg-neutral-900 border border-neutral-700 hover:border-neutral-500 text-neutral-300 hover:text-white rounded-xl text-xs font-bold flex items-center gap-1.5 transition-all cursor-pointer"
> >
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin' : ''}`} />
<span>Actualizar</span> <span>Actualizar</span>
</button> </button>
<button
onClick={handleNormalizeCollections}
disabled={isSyncing}
className="px-3.5 py-2 bg-neutral-900 border border-purple-500/40 hover:border-purple-400 text-purple-300 hover:text-white rounded-xl text-xs font-bold flex items-center gap-1.5 transition-all cursor-pointer"
title="Auto-clasificar todos los productos en sus colecciones oficiales"
>
<Sparkles className="w-3.5 h-3.5 text-purple-400" />
<span>Normalizar Colecciones</span>
</button>
<button <button
onClick={handleSyncAll} onClick={handleSyncAll}
disabled={isSyncing} disabled={isSyncing}
className="px-5 py-2.5 bg-gradient-to-r from-emerald-600 to-green-500 hover:from-emerald-500 hover:to-green-400 text-white font-black rounded-xl text-xs flex items-center gap-2 shadow-lg shadow-emerald-600/30 transition-all active:scale-95 cursor-pointer disabled:opacity-50" className="px-4 py-2 bg-gradient-to-r from-emerald-600 to-green-500 hover:from-emerald-500 hover:to-green-400 text-white font-black rounded-xl text-xs flex items-center gap-2 shadow-lg shadow-emerald-600/30 transition-all active:scale-95 cursor-pointer disabled:opacity-50"
> >
<Zap className={`w-4 h-4 ${isSyncing ? 'animate-bounce' : ''}`} /> <Zap className={`w-3.5 h-3.5 ${isSyncing ? 'animate-bounce' : ''}`} />
<span>{isSyncing ? 'Sincronizando...' : 'Sincronizar Todo con WhatsApp'}</span> <span>{isSyncing ? 'Sincronizando...' : 'Sincronizar Catálogo WhatsApp'}</span>
</button> </button>
</div> </div>
</div> </div>
@ -255,15 +372,14 @@ export default function AdminWhatsAppCatalogPage() {
</div> </div>
<div> <div>
<h4 className="text-xs font-black text-white flex items-center gap-2"> <h4 className="text-xs font-black text-white flex items-center gap-2">
<span>Flujo Comercial & Procesamiento de Pedidos DripZone</span> <span>Colecciones y Flujo Comercial en WhatsApp Business</span>
<span className="text-[10px] bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 px-2 py-0.5 rounded-full font-bold"> <span className="text-[10px] bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 px-2 py-0.5 rounded-full font-bold">
Oficial Meta Commerce
</span> </span>
</h4> </h4>
<p className="text-[11px] text-neutral-300 mt-0.5 leading-relaxed"> <p className="text-[11px] text-neutral-300 mt-0.5 leading-relaxed">
El catálogo de WhatsApp permite a los usuarios consultar modelos y comunicarse para asesoría. Las colecciones permiten a tus clientes explorar fácilmente tu catálogo en WhatsApp por secciones: <strong>Sandalias, Zapatillas, Gorras, Bolsos, etc.</strong>
<strong> Todos los pedidos y pagos ($10.000 anticipo Bold + contraentrega) se procesan a través de la web o app móvil</strong> para asegurar factura y seguimiento. Cada producto se sincroniza con su etiqueta oficial de colección (<code className="text-emerald-400 font-mono">custom_label_0</code>) y taxonomía Google/Meta.
Usa el botón <strong>"Cerrar Pedido por WhatsApp"</strong> en cada producto para enviar al cliente su enlace directo con talla lista.
</p> </p>
</div> </div>
</div> </div>
@ -277,7 +393,7 @@ export default function AdminWhatsAppCatalogPage() {
</div> </div>
)} )}
{/* Métricas y Estado */} {/* Métricas y Estado General */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-neutral-900/70 border border-neutral-800 rounded-2xl p-4 flex items-center gap-4"> <div className="bg-neutral-900/70 border border-neutral-800 rounded-2xl p-4 flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-purple-500/10 border border-purple-500/30 text-purple-400 flex items-center justify-center shrink-0"> <div className="w-12 h-12 rounded-xl bg-purple-500/10 border border-purple-500/30 text-purple-400 flex items-center justify-center shrink-0">
@ -285,7 +401,7 @@ export default function AdminWhatsAppCatalogPage() {
</div> </div>
<div> <div>
<div className="text-2xl font-black text-white">{products.length}</div> <div className="text-2xl font-black text-white">{products.length}</div>
<div className="text-[11px] text-neutral-400 uppercase font-bold tracking-wider">Total Modelos</div> <div className="text-[11px] text-neutral-400 uppercase font-bold tracking-wider">Total Productos</div>
</div> </div>
</div> </div>
@ -295,17 +411,17 @@ export default function AdminWhatsAppCatalogPage() {
</div> </div>
<div> <div>
<div className="text-2xl font-black text-emerald-400">{activeProducts.length}</div> <div className="text-2xl font-black text-emerald-400">{activeProducts.length}</div>
<div className="text-[11px] text-neutral-400 uppercase font-bold tracking-wider">Activos en WhatsApp & App</div> <div className="text-[11px] text-neutral-400 uppercase font-bold tracking-wider">Activos en WhatsApp</div>
</div> </div>
</div> </div>
<div className="bg-neutral-900/70 border border-neutral-800 rounded-2xl p-4 flex items-center gap-4"> <div className="bg-neutral-900/70 border border-neutral-800 rounded-2xl p-4 flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-neutral-800 text-neutral-400 flex items-center justify-center shrink-0"> <div className="w-12 h-12 rounded-xl bg-neutral-800 text-neutral-400 flex items-center justify-center shrink-0">
<EyeOff className="w-6 h-6" /> <Layers className="w-6 h-6" />
</div> </div>
<div> <div>
<div className="text-2xl font-black text-neutral-300">{inactiveProducts.length}</div> <div className="text-2xl font-black text-neutral-300">{OFFICIAL_COLLECTION_NAMES.length}</div>
<div className="text-[11px] text-neutral-400 uppercase font-bold tracking-wider">Inactivos / Ocultos</div> <div className="text-[11px] text-neutral-400 uppercase font-bold tracking-wider">Colecciones Oficiales</div>
</div> </div>
</div> </div>
@ -316,11 +432,82 @@ export default function AdminWhatsAppCatalogPage() {
<div> <div>
<div className="text-xs font-bold text-white">Línea Oficial DripZone</div> <div className="text-xs font-bold text-white">Línea Oficial DripZone</div>
<div className="text-[11px] text-emerald-400 font-mono font-bold">+57 310 639 0296</div> <div className="text-[11px] text-emerald-400 font-mono font-bold">+57 310 639 0296</div>
<div className="text-[10px] text-neutral-500">Instancia Evolution: DripZone</div> <div className="text-[10px] text-neutral-500">Instancia: DripZone / Automata</div>
</div> </div>
</div> </div>
</div> </div>
{/* SECCIÓN DE COLECCIONES DE WHATSAPP: PESTAÑAS Y CONTROL DE CATEGORÍAS */}
<div className="bg-neutral-900/90 border border-emerald-500/20 rounded-3xl p-6 space-y-4 shadow-xl">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 border-b border-neutral-800 pb-4">
<div>
<h2 className="text-base font-black text-white flex items-center gap-2">
<Layers className="w-5 h-5 text-emerald-400" />
<span>Colecciones del Catálogo de WhatsApp</span>
</h2>
<p className="text-xs text-neutral-400 mt-0.5">
Filtra por colección para auditar o reasignar productos. Cada producto se sincroniza automáticamente con su colección en Meta Commerce.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleSyncMetaProductSets}
disabled={isSyncing}
className="px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 text-emerald-300 border border-emerald-500/30 hover:border-emerald-500 text-xs font-bold rounded-xl flex items-center gap-1.5 transition-all cursor-pointer"
title="Crear Product Sets de cada colección en Meta Commerce Manager vía Graph API"
>
<FolderSync className="w-3.5 h-3.5" />
<span>Crear Colecciones en Meta API</span>
</button>
</div>
</div>
{/* Pestañas de Colecciones */}
<div className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-none">
<button
onClick={() => setSelectedCollection('all')}
className={`px-3.5 py-2 rounded-xl text-xs font-black shrink-0 transition-all cursor-pointer flex items-center gap-1.5 ${
selectedCollection === 'all'
? 'bg-gradient-to-r from-emerald-600 to-green-500 text-white shadow-md scale-105'
: 'bg-neutral-950 text-neutral-400 hover:text-white border border-neutral-800'
}`}
>
<span>🌟 Todas las Colecciones</span>
<span className="text-[10px] bg-black/40 px-1.5 py-0.5 rounded-full font-mono">
{products.length}
</span>
</button>
{Object.values(WHATSAPP_COLLECTIONS).map((col) => {
const isSelected = selectedCollection === col.name;
const count = collectionCounts[col.name] || 0;
return (
<button
key={col.id}
onClick={() => setSelectedCollection(col.name)}
className={`px-3.5 py-2 rounded-xl text-xs font-bold shrink-0 transition-all cursor-pointer flex items-center gap-1.5 ${
isSelected
? 'bg-gradient-to-r from-emerald-600 to-green-500 text-white shadow-md scale-105'
: 'bg-neutral-950 text-neutral-300 hover:text-white border border-neutral-800 hover:border-neutral-700'
}`}
>
<span>{col.icon}</span>
<span>{col.name}</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-mono ${
isSelected ? 'bg-black/40 text-white' : 'bg-neutral-800 text-neutral-400'
}`}
>
{count}
</span>
</button>
);
})}
</div>
</div>
{/* Sección 1: Enlaces del Feed Automático (Meta Commerce Manager) */} {/* Sección 1: Enlaces del Feed Automático (Meta Commerce Manager) */}
<div className="bg-neutral-900/80 border border-purple-500/20 rounded-3xl p-6 space-y-5"> <div className="bg-neutral-900/80 border border-purple-500/20 rounded-3xl p-6 space-y-5">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-2 border-b border-neutral-800 pb-4"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-2 border-b border-neutral-800 pb-4">
@ -330,7 +517,7 @@ export default function AdminWhatsAppCatalogPage() {
<span>Orígenes de Datos para Meta Commerce & WhatsApp Catalog</span> <span>Orígenes de Datos para Meta Commerce & WhatsApp Catalog</span>
</h2> </h2>
<p className="text-xs text-neutral-400 mt-1"> <p className="text-xs text-neutral-400 mt-1">
Conecta estos enlaces en tu Administrador de Ventas de Meta (Commerce Manager). Meta consultará este feed de forma automática y continua para mantener sincronizado el catálogo de WhatsApp. Conecta estos enlaces en tu Administrador de Ventas de Meta (Commerce Manager). Meta consultará este feed de forma automática y creará los conjuntos de productos por colección usando <code className="text-purple-400 font-mono">custom_label_0</code>.
</p> </p>
</div> </div>
</div> </div>
@ -341,7 +528,7 @@ export default function AdminWhatsAppCatalogPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-bold text-emerald-400 flex items-center gap-1.5"> <span className="text-xs font-bold text-emerald-400 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span> <span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
Feed XML Programado (Recomendado Meta) Feed XML Maestro (Todas las Colecciones)
</span> </span>
<a <a
href={xmlFeedUrl} href={xmlFeedUrl}
@ -357,16 +544,16 @@ export default function AdminWhatsAppCatalogPage() {
<div className="flex items-center gap-2 bg-neutral-900 border border-neutral-800 rounded-xl p-2 font-mono text-[11px] text-neutral-300 select-all overflow-x-auto"> <div className="flex items-center gap-2 bg-neutral-900 border border-neutral-800 rounded-xl p-2 font-mono text-[11px] text-neutral-300 select-all overflow-x-auto">
<span className="truncate flex-1">{xmlFeedUrl}</span> <span className="truncate flex-1">{xmlFeedUrl}</span>
<button <button
onClick={() => handleCopyUrl('xml', xmlFeedUrl)} onClick={() => handleCopyUrl('xml_all', xmlFeedUrl)}
className="px-2.5 py-1 bg-neutral-800 hover:bg-neutral-700 text-white rounded-lg text-xs font-bold flex items-center gap-1 shrink-0 cursor-pointer" className="px-2.5 py-1 bg-neutral-800 hover:bg-neutral-700 text-white rounded-lg text-xs font-bold flex items-center gap-1 shrink-0 cursor-pointer"
> >
{copiedFeed === 'xml' ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />} {copiedFeed === 'xml_all' ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedFeed === 'xml' ? '¡Copiado!' : 'Copiar'}</span> <span>{copiedFeed === 'xml_all' ? '¡Copiado!' : 'Copiar'}</span>
</button> </button>
</div> </div>
<p className="text-[11px] text-neutral-500"> <p className="text-[11px] text-neutral-500">
Formato estándar Google Merchant / Facebook RSS 2.0. Incluye ID, foto HD, título, precio en COP, descripción, tallas y disponibilidad. Formato estándar Google Merchant / Facebook RSS 2.0. Cada producto lleva su etiqueta de colección (<code className="text-emerald-400">custom_label_0</code>), categoría Google y taxonomía de tienda.
</p> </p>
</div> </div>
@ -375,7 +562,7 @@ export default function AdminWhatsAppCatalogPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-bold text-purple-400 flex items-center gap-1.5"> <span className="text-xs font-bold text-purple-400 flex items-center gap-1.5">
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
Feed CSV de Importación Masiva Feed CSV con Columnas de Colección
</span> </span>
<a <a
href={csvFeedUrl} href={csvFeedUrl}
@ -391,22 +578,52 @@ export default function AdminWhatsAppCatalogPage() {
<div className="flex items-center gap-2 bg-neutral-900 border border-neutral-800 rounded-xl p-2 font-mono text-[11px] text-neutral-300 select-all overflow-x-auto"> <div className="flex items-center gap-2 bg-neutral-900 border border-neutral-800 rounded-xl p-2 font-mono text-[11px] text-neutral-300 select-all overflow-x-auto">
<span className="truncate flex-1">{csvFeedUrl}</span> <span className="truncate flex-1">{csvFeedUrl}</span>
<button <button
onClick={() => handleCopyUrl('csv', csvFeedUrl)} onClick={() => handleCopyUrl('csv_all', csvFeedUrl)}
className="px-2.5 py-1 bg-neutral-800 hover:bg-neutral-700 text-white rounded-lg text-xs font-bold flex items-center gap-1 shrink-0 cursor-pointer" className="px-2.5 py-1 bg-neutral-800 hover:bg-neutral-700 text-white rounded-lg text-xs font-bold flex items-center gap-1 shrink-0 cursor-pointer"
> >
{copiedFeed === 'csv' ? <Check className="w-3.5 h-3.5 text-purple-400" /> : <Copy className="w-3.5 h-3.5" />} {copiedFeed === 'csv_all' ? <Check className="w-3.5 h-3.5 text-purple-400" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedFeed === 'csv' ? '¡Copiado!' : 'Copiar'}</span> <span>{copiedFeed === 'csv_all' ? '¡Copiado!' : 'Copiar'}</span>
</button> </button>
</div> </div>
<p className="text-[11px] text-neutral-500"> <p className="text-[11px] text-neutral-500">
Formato delimitado por comas compatible con la carga manual por archivo en Commerce Manager o herramientas de marketing. Compatible con importación masiva por archivo en Commerce Manager. Incluye <code className="text-purple-400">custom_label_0</code> con el nombre exacto de la colección.
</p> </p>
</div> </div>
</div> </div>
{/* Enlaces de Feed por Colección Específica */}
<div className="pt-3 border-t border-neutral-800/80">
<div className="text-[11px] font-bold text-neutral-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Tag className="w-3.5 h-3.5 text-emerald-400" />
<span>Feeds XML Segmentados por Colección Individual (Opcional):</span>
</div>
<div className="flex flex-wrap gap-2">
{Object.values(WHATSAPP_COLLECTIONS).map((c) => {
const url = `${domain}/api/catalog/feed.xml?collection=${encodeURIComponent(c.name)}`;
const count = collectionCounts[c.name] || 0;
return (
<button
key={c.id}
onClick={() => handleCopyUrl(`feed_${c.id}`, url)}
className="px-2.5 py-1.5 bg-neutral-950 hover:bg-neutral-900 border border-neutral-800 hover:border-emerald-500/50 rounded-xl text-[11px] text-neutral-300 flex items-center gap-1.5 transition-all cursor-pointer"
>
<span>{c.icon}</span>
<span className="font-bold">{c.name}</span>
<span className="text-[10px] text-neutral-500">({count})</span>
{copiedFeed === `feed_${c.id}` ? (
<Check className="w-3 h-3 text-emerald-400 ml-1" />
) : (
<Copy className="w-3 h-3 text-neutral-500 ml-1" />
)}
</button>
);
})}
</div>
</div>
</div> </div>
{/* Sección 2: Configuración Meta Commerce Graph API (Opcional para sincronización en tiempo real) */} {/* Sección 2: Configuración Meta Commerce Graph API */}
<form <form
onSubmit={handleSaveMetaSettings} onSubmit={handleSaveMetaSettings}
className="bg-neutral-900/60 border border-neutral-800 rounded-3xl p-6 space-y-5" className="bg-neutral-900/60 border border-neutral-800 rounded-3xl p-6 space-y-5"
@ -482,10 +699,14 @@ export default function AdminWhatsAppCatalogPage() {
<div> <div>
<h2 className="text-base font-black text-white flex items-center gap-2"> <h2 className="text-base font-black text-white flex items-center gap-2">
<Tag className="w-5 h-5 text-emerald-400" /> <Tag className="w-5 h-5 text-emerald-400" />
<span>Productos en Vitrina y Control de Disponibilidad</span> <span>
{selectedCollection === 'all'
? 'Todos los Productos en Catálogo'
: `Colección: ${selectedCollection}`}
</span>
</h2> </h2>
<p className="text-xs text-neutral-400 mt-0.5"> <p className="text-xs text-neutral-400 mt-0.5">
Revisa la imagen, el valor de venta en COP, la descripción y las tallas disponibles. Activa o desactiva productos para mostrarlos u ocultarlos en la app y WhatsApp. Revisa la imagen, el valor en COP, la descripción y las tallas. Puedes cambiar la colección de cualquier producto usando el selector inferior de cada tarjeta.
</p> </p>
</div> </div>
@ -497,7 +718,7 @@ export default function AdminWhatsAppCatalogPage() {
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar zapatilla o marca..." placeholder="Buscar modelo o marca..."
className="bg-neutral-950 border border-neutral-800 focus:border-emerald-500 rounded-xl pl-9 pr-3.5 py-2 text-xs text-white placeholder-neutral-500 focus:outline-none w-48 sm:w-64" className="bg-neutral-950 border border-neutral-800 focus:border-emerald-500 rounded-xl pl-9 pr-3.5 py-2 text-xs text-white placeholder-neutral-500 focus:outline-none w-48 sm:w-64"
/> />
</div> </div>
@ -546,6 +767,7 @@ export default function AdminWhatsAppCatalogPage() {
const sizesList = Array.isArray(product.sizes) && product.sizes.length > 0 const sizesList = Array.isArray(product.sizes) && product.sizes.length > 0
? product.sizes.join(', ') ? product.sizes.join(', ')
: '37 - 43'; : '37 - 43';
const colInfo = resolveProductCollection(product);
return ( return (
<div <div
@ -557,7 +779,7 @@ export default function AdminWhatsAppCatalogPage() {
}`} }`}
> >
<div className="space-y-3"> <div className="space-y-3">
{/* Header de la tarjeta */} {/* Header de la tarjeta con Colección destacada */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{/* Imagen HD */} {/* Imagen HD */}
<div className="relative w-20 h-20 rounded-xl overflow-hidden bg-neutral-900 border border-neutral-800 shrink-0"> <div className="relative w-20 h-20 rounded-xl overflow-hidden bg-neutral-900 border border-neutral-800 shrink-0">
@ -608,6 +830,27 @@ export default function AdminWhatsAppCatalogPage() {
</div> </div>
</div> </div>
{/* Badge de Colección Oficial WhatsApp */}
<div className="flex items-center justify-between bg-neutral-900/90 border border-neutral-800 rounded-xl px-2.5 py-1.5">
<span className="text-[10px] font-bold text-neutral-400 flex items-center gap-1">
<Layers className="w-3 h-3 text-emerald-400" />
<span>Colección WhatsApp:</span>
</span>
<div className="relative">
<select
value={colInfo.name}
onChange={(e) => handleChangeProductCollection(product, e.target.value)}
className="bg-neutral-800 hover:bg-neutral-750 text-emerald-300 font-bold text-[11px] rounded-lg px-2 py-1 border border-neutral-700 focus:outline-none cursor-pointer"
>
{OFFICIAL_COLLECTION_NAMES.map((name) => (
<option key={name} value={name}>
{WHATSAPP_COLLECTIONS[name.toLowerCase()]?.icon || '📦'} {name}
</option>
))}
</select>
</div>
</div>
{/* Tallas disponibles */} {/* Tallas disponibles */}
<div className="bg-neutral-900/90 border border-neutral-800/80 rounded-xl p-2.5 space-y-1"> <div className="bg-neutral-900/90 border border-neutral-800/80 rounded-xl p-2.5 space-y-1">
<div className="text-[10px] text-neutral-400 font-bold uppercase tracking-wider flex items-center justify-between"> <div className="text-[10px] text-neutral-400 font-bold uppercase tracking-wider flex items-center justify-between">
@ -620,8 +863,8 @@ export default function AdminWhatsAppCatalogPage() {
</div> </div>
{/* Descripción de la publicación */} {/* Descripción de la publicación */}
<div className="bg-neutral-900/50 rounded-xl p-2 text-[11px] text-neutral-400 line-clamp-3 leading-relaxed"> <div className="bg-neutral-900/50 rounded-xl p-2 text-[11px] text-neutral-400 line-clamp-2 leading-relaxed">
{product.description || 'Zapatillas de alta calidad con garantía y cambio de talla.'} {product.description || 'Producto de alta calidad con garantía y cambio asegurado.'}
</div> </div>
{/* Botón para Asesor Comercial: Enlace Directo para WhatsApp */} {/* Botón para Asesor Comercial: Enlace Directo para WhatsApp */}
@ -663,7 +906,7 @@ export default function AdminWhatsAppCatalogPage() {
<button <button
onClick={() => handleSyncSingleProduct(product)} onClick={() => handleSyncSingleProduct(product)}
title="Sincronizar este calzado con WhatsApp Catalog" title="Sincronizar este producto con WhatsApp Catalog"
className="p-2 bg-neutral-900 hover:bg-neutral-800 border border-neutral-800 hover:border-emerald-500/40 text-neutral-400 hover:text-emerald-400 rounded-xl transition-all cursor-pointer" className="p-2 bg-neutral-900 hover:bg-neutral-800 border border-neutral-800 hover:border-emerald-500/40 text-neutral-400 hover:text-emerald-400 rounded-xl transition-all cursor-pointer"
> >
<Zap className="w-4 h-4" /> <Zap className="w-4 h-4" />

View File

@ -1281,18 +1281,63 @@ export default function AdminProductsPage() {
{/* Modal 3: Edit Existing Product (PUT) */} {/* Modal 3: Edit Existing Product (PUT) */}
{editingProduct && ( {editingProduct && (
<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="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in overflow-y-auto">
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl"> <div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-xl w-full p-6 space-y-4 shadow-2xl my-8">
<div className="flex items-center justify-between border-b border-neutral-800 pb-3"> <div className="flex items-center justify-between border-b border-neutral-800 pb-3">
<h3 className="font-bold text-white text-base flex items-center gap-2"> <h3 className="font-bold text-white text-base flex items-center gap-2">
<Edit className="w-5 h-5 text-purple-400" /> <Edit className="w-5 h-5 text-purple-400" />
<span>Editar Publicación: {editingProduct.name}</span> <span>Editar Publicación</span>
</h3> </h3>
<button onClick={() => setEditingProduct(null)} className="text-neutral-400 hover:text-white cursor-pointer"> <button onClick={() => setEditingProduct(null)} className="text-neutral-400 hover:text-white cursor-pointer">
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
{/* VISTA PREVIA DE LA IMAGEN AL INICIO DE LA FICHA */}
<div className="bg-neutral-950 border border-neutral-800 rounded-2xl p-3.5 flex items-center gap-4 shadow-inner">
<div className="relative w-24 h-24 sm:w-28 sm:h-28 rounded-xl overflow-hidden bg-neutral-900 border border-neutral-700/60 shrink-0 shadow-md">
<OptimizedImage
src={editImageUrl || editingProduct.images?.[0]}
alt={editingProduct.name}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 min-w-0 space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-[10px] font-extrabold uppercase px-2 py-0.5 rounded-md bg-purple-500/20 text-purple-300 border border-purple-500/30 truncate">
{editBrand || editingProduct.brand || 'Sneakers'}
</span>
<span className="text-[10px] font-bold px-2 py-0.5 rounded-md bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
{editingProduct.isActive !== false ? '🟢 En Vitrina' : '⚪ Oculto'}
</span>
</div>
<div className="text-sm font-black text-white truncate" title={editingProduct.name}>
{editingProduct.name}
</div>
<div className="text-xs text-neutral-400 flex items-center gap-1.5">
<span className="text-neutral-500">Colección:</span>
<span className="text-emerald-400 font-bold">{editCategory || editingProduct.category || 'Zapatillas'}</span>
</div>
{editingProduct.images && editingProduct.images.length > 1 && (
<div className="flex items-center gap-1.5 pt-0.5 overflow-x-auto">
{editingProduct.images.slice(0, 5).map((img, idx) => (
<button
key={idx}
type="button"
onClick={() => setEditImageUrl(img)}
className={`relative w-7 h-7 rounded-lg overflow-hidden border shrink-0 transition-all cursor-pointer ${
editImageUrl === img ? 'border-purple-400 ring-2 ring-purple-400/40' : 'border-neutral-800 opacity-60 hover:opacity-100'
}`}
title="Usar como foto principal"
>
<img src={img} alt="Thumb" className="w-full h-full object-cover" />
</button>
))}
</div>
)}
</div>
</div>
<form onSubmit={handleSaveEdit} className="space-y-3 text-xs"> <form onSubmit={handleSaveEdit} className="space-y-3 text-xs">
<div> <div>
<label className="block text-neutral-400 font-bold mb-1">Nombre del Modelo *</label> <label className="block text-neutral-400 font-bold mb-1">Nombre del Modelo *</label>
@ -1332,13 +1377,25 @@ export default function AdminProductsPage() {
</div> </div>
<div> <div>
<label className="block text-neutral-400 font-bold mb-1">Categoría</label> <label className="block text-neutral-400 font-bold mb-1">Categoría / Colección</label>
<input <input
type="text" type="text"
list="collections-list"
value={editCategory} value={editCategory}
onChange={(e) => setEditCategory(e.target.value)} onChange={(e) => setEditCategory(e.target.value)}
placeholder="Ej: Sandalias, Zapatillas, Gorras..."
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-400" className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-400"
/> />
<datalist id="collections-list">
<option value="Sandalias" />
<option value="Zapatillas" />
<option value="Gorras" />
<option value="Bolsos" />
<option value="Carrieles" />
<option value="Maletines" />
<option value="Ropa" />
<option value="Accesorios" />
</datalist>
</div> </div>
</div> </div>

View File

@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from 'next/server';
import { dataService } from '@/lib/data-service';
import {
WHATSAPP_COLLECTIONS,
resolveProductCollection,
syncMetaCatalogProductSets,
} from '@/lib/whatsapp-catalog';
import { getProductsFromAppwrite, saveProductToAppwrite } from '@/lib/server-appwrite';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
let products = dataService.getProducts();
try {
const appwriteProducts = await getProductsFromAppwrite();
if (appwriteProducts && appwriteProducts.length > 0) {
const existingIds = new Set(products.map((p) => p.id));
for (const ap of appwriteProducts) {
if (!existingIds.has(ap.id)) {
dataService.addProduct(ap);
}
}
products = dataService.getProducts();
}
} catch (_) {}
// Calcular estadísticas por colección
const counts: Record<string, number> = {};
for (const key of Object.keys(WHATSAPP_COLLECTIONS)) {
counts[key] = 0;
}
for (const p of products) {
if (p.isActive !== false) {
const col = resolveProductCollection(p);
counts[col.id] = (counts[col.id] || 0) + 1;
}
}
const collectionsWithCount = Object.values(WHATSAPP_COLLECTIONS).map((c) => ({
...c,
count: counts[c.id] || 0,
xmlFeedUrl: `/api/catalog/feed.xml?collection=${encodeURIComponent(c.name)}`,
csvFeedUrl: `/api/catalog/feed.csv?collection=${encodeURIComponent(c.name)}`,
}));
return NextResponse.json({
success: true,
totalProducts: products.length,
activeProducts: products.filter((p) => p.isActive !== false).length,
collections: collectionsWithCount,
});
} catch (error: any) {
return NextResponse.json(
{ success: false, error: error.message || 'Error obteniendo colecciones' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json().catch(() => ({}));
const settings = dataService.getSettings();
const catalogId = body.metaCatalogId || settings?.metaCatalogId || process.env.META_CATALOG_ID;
const accessToken = body.metaAccessToken || settings?.metaAccessToken || process.env.META_ACCESS_TOKEN;
// 1. Si se solicita normalizar todos los productos a sus colecciones oficiales
if (body.normalizeProducts) {
const products = dataService.getProducts();
let updatedCount = 0;
for (const p of products) {
const col = resolveProductCollection(p);
if (p.category !== col.name) {
const updated = dataService.updateProduct(p.id, { category: col.name });
if (updated) {
await saveProductToAppwrite(updated).catch(() => {});
}
updatedCount++;
}
}
return NextResponse.json({
success: true,
message: `Se normalizaron ${updatedCount} productos a sus colecciones correspondientes en WhatsApp.`,
updatedCount,
});
}
// 2. Sincronización de Product Sets en Meta Commerce Manager vía Graph API
if (!catalogId || !accessToken) {
return NextResponse.json({
success: false,
error: 'Para sincronizar colecciones directamente con Meta API, configura el Meta Catalog ID y Access Token.',
});
}
const syncResult = await syncMetaCatalogProductSets(catalogId, accessToken);
return NextResponse.json({
success: syncResult.success,
results: syncResult.results,
message: syncResult.success
? 'Todas las colecciones (Product Sets) fueron creadas/verificadas en Meta Commerce Manager.'
: 'Algunas colecciones reportaron alertas en Meta Graph API.',
});
} catch (error: any) {
return NextResponse.json(
{ success: false, error: error.message || 'Error sincronizando colecciones con Meta' },
{ status: 500 }
);
}
}

View File

@ -1,4 +1,4 @@
import { NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { dataService } from '@/lib/data-service'; import { dataService } from '@/lib/data-service';
import { generateWhatsAppCatalogCsv } from '@/lib/whatsapp-catalog'; import { generateWhatsAppCatalogCsv } from '@/lib/whatsapp-catalog';
import { getProductsFromAppwrite } from '@/lib/server-appwrite'; import { getProductsFromAppwrite } from '@/lib/server-appwrite';
@ -6,8 +6,11 @@ import { getProductsFromAppwrite } from '@/lib/server-appwrite';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const revalidate = 0; export const revalidate = 0;
export async function GET() { export async function GET(req: NextRequest) {
try { try {
const { searchParams } = new URL(req.url);
const filterCollection = searchParams.get('collection') || searchParams.get('category') || undefined;
let products = dataService.getProducts(); let products = dataService.getProducts();
// Sincronizar siempre con Appwrite para garantizar catálogo en tiempo real // Sincronizar siempre con Appwrite para garantizar catálogo en tiempo real
@ -26,7 +29,7 @@ export async function GET() {
console.warn('Error sincronizando productos con Appwrite para CSV Feed:', e.message); console.warn('Error sincronizando productos con Appwrite para CSV Feed:', e.message);
} }
const csv = generateWhatsAppCatalogCsv(products); const csv = generateWhatsAppCatalogCsv(products, false, filterCollection);
return new Response(csv, { return new Response(csv, {
status: 200, status: 200,

View File

@ -1,4 +1,4 @@
import { NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { dataService } from '@/lib/data-service'; import { dataService } from '@/lib/data-service';
import { generateWhatsAppCatalogXml } from '@/lib/whatsapp-catalog'; import { generateWhatsAppCatalogXml } from '@/lib/whatsapp-catalog';
import { getProductsFromAppwrite } from '@/lib/server-appwrite'; import { getProductsFromAppwrite } from '@/lib/server-appwrite';
@ -6,8 +6,11 @@ import { getProductsFromAppwrite } from '@/lib/server-appwrite';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const revalidate = 0; export const revalidate = 0;
export async function GET() { export async function GET(req: NextRequest) {
try { try {
const { searchParams } = new URL(req.url);
const filterCollection = searchParams.get('collection') || searchParams.get('category') || undefined;
let products = dataService.getProducts(); let products = dataService.getProducts();
// Sincronizar siempre con Appwrite para garantizar catálogo en tiempo real // Sincronizar siempre con Appwrite para garantizar catálogo en tiempo real
@ -27,7 +30,7 @@ export async function GET() {
} }
const settings = dataService.getSettings(); const settings = dataService.getSettings();
const xml = generateWhatsAppCatalogXml(products, settings); const xml = generateWhatsAppCatalogXml(products, settings, false, filterCollection);
return new Response(xml, { return new Response(xml, {
status: 200, status: 200,

View File

@ -20,7 +20,7 @@ import {
getProductsFromAppwrite, getProductsFromAppwrite,
} from '@/lib/server-appwrite'; } from '@/lib/server-appwrite';
import { Product, SupplierPost } from '@/types'; import { Product, SupplierPost } from '@/types';
import { registerProductInWhatsAppCatalog } from '@/lib/whatsapp-catalog'; import { registerProductInWhatsAppCatalog, resolveProductCollection } from '@/lib/whatsapp-catalog';
import { formatCOP } from '@/lib/pricing-engine'; import { formatCOP } from '@/lib/pricing-engine';
// Cache en memoria para evitar procesar mensajes duplicados de WhatsApp en ráfaga // Cache en memoria para evitar procesar mensajes duplicados de WhatsApp en ráfaga
@ -403,10 +403,18 @@ export async function POST(request: Request) {
const prodName = sanitizePublicationTitle(extracted.name || 'Artículo Urbano', forbiddenContext, prodBrand); const prodName = sanitizePublicationTitle(extracted.name || 'Artículo Urbano', forbiddenContext, prodBrand);
const proof = getProductSocialProof({ name: prodName, brand: prodBrand }); const proof = getProductSocialProof({ name: prodName, brand: prodBrand });
// Categorización y Colección Oficial para WhatsApp (Sandalias, Zapatillas, Gorras, etc.)
const colInfo = resolveProductCollection({
name: prodName,
description: messageContent,
category: extracted.category,
});
const finalCategory = colInfo.name;
const prodDescription = generateAttractiveProductCopy({ const prodDescription = generateAttractiveProductCopy({
name: prodName, name: prodName,
brand: prodBrand, brand: prodBrand,
category: extracted.category || 'Zapatillas', category: finalCategory,
retailPrice, retailPrice,
supplierPrice, supplierPrice,
forbiddenTerms: forbiddenContext, forbiddenTerms: forbiddenContext,
@ -431,7 +439,7 @@ export async function POST(request: Request) {
: ['37', '38', '39', '40', '41', '42', '43'], : ['37', '38', '39', '40', '41', '42', '43'],
images: [relativeImageUrl], images: [relativeImageUrl],
description: prodDescription, description: prodDescription,
category: extracted.category || 'Zapatillas', category: finalCategory,
inStock: true, inStock: true,
isActive: true, isActive: true,
rating: proof.rating, rating: proof.rating,

View File

@ -89,29 +89,39 @@ export default function StoreHomePage() {
if (catLow === 'zapatillas') { if (catLow === 'zapatillas') {
return ( return (
prodCat.includes('zapatilla') || prodCat.includes('zapatilla') ||
prodCat.includes('sneaker') ||
prodCat.includes('tenis') ||
(!prodCat.includes('chancla') && (!prodCat.includes('chancla') &&
!prodCat.includes('slide') && !prodCat.includes('sandalia') &&
!prodCat.includes('bolso') && !prodCat.includes('bolso') &&
!prodCat.includes('malet') && !prodCat.includes('malet') &&
!prodCat.includes('morral') && !prodCat.includes('morral') &&
!prodCat.includes('carriel') && !prodCat.includes('carriel') &&
!prodCat.includes('gorra') && !prodCat.includes('gorra') &&
!prodCat.includes('ropa') &&
!prodCat.includes('accesorio') &&
!prodName.includes('chancla') && !prodName.includes('chancla') &&
!prodName.includes('sandalia') &&
!prodName.includes('gorra') &&
!prodName.includes('morral') && !prodName.includes('morral') &&
!prodName.includes('carriel')) !prodName.includes('carriel'))
); );
} }
if (catLow === 'chanclas') { if (catLow === 'sandalias' || catLow === 'chanclas') {
return ( return (
prodCat.includes('sandalia') ||
prodCat.includes('chancla') || prodCat.includes('chancla') ||
prodCat.includes('slide') || prodCat.includes('slide') ||
prodCat.includes('sandalia') ||
prodCat.includes('croc') || prodCat.includes('croc') ||
prodName.includes('sandalia') ||
prodName.includes('chancla') || prodName.includes('chancla') ||
prodName.includes('slide') || prodName.includes('slide') ||
prodName.includes('croc') prodName.includes('croc')
); );
} }
if (catLow === 'gorras') {
return prodCat.includes('gorra') || prodCat.includes('cap') || prodName.includes('gorra');
}
if (catLow === 'bolsos') { if (catLow === 'bolsos') {
return ( return (
prodCat.includes('bolso') || prodCat.includes('bolso') ||
@ -142,8 +152,31 @@ export default function StoreHomePage() {
prodName.includes('bandolera') prodName.includes('bandolera')
); );
} }
if (catLow === 'gorras') { if (catLow === 'ropa') {
return prodCat.includes('gorra') || prodCat.includes('cap') || prodName.includes('gorra'); return (
prodCat.includes('ropa') ||
prodCat.includes('camiseta') ||
prodCat.includes('buzo') ||
prodCat.includes('hoodie') ||
prodCat.includes('pantalon') ||
prodCat.includes('short') ||
prodCat.includes('streetwear') ||
prodName.includes('camiseta') ||
prodName.includes('hoodie') ||
prodName.includes('buzo')
);
}
if (catLow === 'accesorios') {
return (
prodCat.includes('accesorio') ||
prodCat.includes('billetera') ||
prodCat.includes('cinturon') ||
prodCat.includes('correa') ||
prodCat.includes('reloj') ||
prodCat.includes('gafas') ||
prodName.includes('billetera') ||
prodName.includes('cinturon')
);
} }
// Por marca directa (Nike, Adidas, etc.) o coincidencia exacta // Por marca directa (Nike, Adidas, etc.) o coincidencia exacta

View File

@ -10,12 +10,14 @@ interface CategoryPillsProps {
const CATEGORIES = [ const CATEGORIES = [
{ id: 'all', label: '🔥 Todo el Catálogo' }, { id: 'all', label: '🔥 Todo el Catálogo' },
{ id: 'Sandalias', label: '🩴 Sandalias' },
{ id: 'Zapatillas', label: '👟 Zapatillas' }, { id: 'Zapatillas', label: '👟 Zapatillas' },
{ id: 'Chanclas', label: '🩴 Chanclas & Slides' },
{ id: 'Bolsos', label: '👜 Bolsos' },
{ id: 'Maletines', label: '🎒 Maletines & Morrales' },
{ id: 'Carrieles', label: '👝 Carrieles & Bandoleras' },
{ id: 'Gorras', label: '🧢 Gorras' }, { id: 'Gorras', label: '🧢 Gorras' },
{ id: 'Bolsos', label: '👜 Bolsos' },
{ id: 'Carrieles', label: '👝 Carrieles' },
{ id: 'Maletines', label: '🎒 Maletines' },
{ id: 'Ropa', label: '👕 Ropa' },
{ id: 'Accesorios', label: '🕶️ Accesorios' },
{ id: 'Nike', label: '⚡ Nike' }, { id: 'Nike', label: '⚡ Nike' },
{ id: 'Adidas', label: '🛹 Adidas' }, { id: 'Adidas', label: '🛹 Adidas' },
{ id: 'Jordan', label: '🏀 Jordan' }, { id: 'Jordan', label: '🏀 Jordan' },

View File

@ -433,32 +433,32 @@ export function extractSupplierPrice(text: string): number {
*/ */
export interface CategoryDetection { export interface CategoryDetection {
category: string; category: string;
categoryKey: 'zapatillas' | 'chanclas' | 'bolsos' | 'maletines' | 'carrieles' | 'gorras' | 'ropa' | 'accesorios'; categoryKey: 'zapatillas' | 'sandalias' | 'chanclas' | 'bolsos' | 'maletines' | 'carrieles' | 'gorras' | 'ropa' | 'accesorios';
icon: string; icon: string;
confidence: number; confidence: number;
} }
export const OFFICIAL_CATEGORIES: { id: CategoryDetection['categoryKey']; label: string; icon: string }[] = [ export const OFFICIAL_CATEGORIES: { id: CategoryDetection['categoryKey']; label: string; icon: string }[] = [
{ id: 'zapatillas', label: 'Zapatillas', icon: '👟' }, { id: 'zapatillas', label: 'Zapatillas', icon: '👟' },
{ id: 'chanclas', label: 'Chanclas / Sandalias', icon: '🩴' }, { id: 'sandalias', label: 'Sandalias', icon: '🩴' },
{ id: 'bolsos', label: 'Bolsos / Carteras', icon: '👜' }, { id: 'gorras', label: 'Gorras', icon: '🧢' },
{ id: 'maletines', label: 'Maletines / Morrales', icon: '🎒' }, { id: 'bolsos', label: 'Bolsos', icon: '👜' },
{ id: 'carrieles', label: 'Carrieles / Bandoleras', icon: '👝' }, { id: 'carrieles', label: 'Carrieles', icon: '👝' },
{ id: 'gorras', label: 'Gorras / Caps', icon: '🧢' }, { id: 'maletines', label: 'Maletines', icon: '🎒' },
{ id: 'ropa', label: 'Ropa / Streetwear', icon: '👕' }, { id: 'ropa', label: 'Ropa', icon: '👕' },
{ id: 'accesorios', label: 'Accesorios', icon: '🕶️' }, { id: 'accesorios', label: 'Accesorios', icon: '🕶️' },
]; ];
export function detectCategory(text: string, title?: string): CategoryDetection { export function detectCategory(text: string, title?: string): CategoryDetection {
const full = `${title || ''} ${text || ''}`.toLowerCase(); const full = `${title || ''} ${text || ''}`.toLowerCase();
// 1. Chanclas / Sandalias / Slides / Crocs // 1. Sandalias / Chanclas / Slides / Crocs
if ( if (
/\b(chancl[as]|sandali[as]|slide[s]?|croc[s]?|pantufl[as]|ojot[as]|adilette|chola[s]|chinela[s]|huarache[s]|alparagatas?|flip[\s-]*flops?|chancleta[s]?|chancletero)\b/i.test( /\b(sandali[as]|chancl[as]|slide[s]?|croc[s]?|pantufl[as]|ojot[as]|adilette|chola[s]|chinela[s]|huarache[s]|alparagatas?|flip[\s-]*flops?|chancleta[s]?|chancletero)\b/i.test(
full full
) )
) { ) {
return { category: 'Chanclas', categoryKey: 'chanclas', icon: '🩴', confidence: 0.98 }; return { category: 'Sandalias', categoryKey: 'sandalias', icon: '🩴', confidence: 0.98 };
} }
// 2. Carrieles / Bandoleras / Riñoneras / Crossbody / Pecheras / Canguros // 2. Carrieles / Bandoleras / Riñoneras / Crossbody / Pecheras / Canguros

View File

@ -5,9 +5,171 @@ import { formatCOP } from './pricing-engine';
const DOMAIN = process.env.NEXT_PUBLIC_APP_URL || 'https://drip-zone.online'; const DOMAIN = process.env.NEXT_PUBLIC_APP_URL || 'https://drip-zone.online';
/**
* Definición Oficial de Colecciones para el Catálogo de WhatsApp Business y Meta Commerce
*/
export interface WhatsAppCatalogCollection {
id: string;
name: string;
icon: string;
productType: string;
googleCategory: string;
googleCategoryId: string;
description: string;
}
export const WHATSAPP_COLLECTIONS: Record<string, WhatsAppCatalogCollection> = {
sandalias: {
id: 'sandalias',
name: 'Sandalias',
icon: '🩴',
productType: 'Calzado > Sandalias y Chanclas',
googleCategory: 'Apparel & Accessories > Shoes > Sandals',
googleCategoryId: '188',
description: 'Sandalias, chanclas, slides y calzado abierto de descanso y estilo veraniego.',
},
zapatillas: {
id: 'zapatillas',
name: 'Zapatillas',
icon: '👟',
productType: 'Calzado > Sneakers y Zapatillas',
googleCategory: 'Apparel & Accessories > Shoes',
googleCategoryId: '187',
description: 'Sneakers urbanos, zapatillas deportivas y calzado casual en horma nacional.',
},
gorras: {
id: 'gorras',
name: 'Gorras',
icon: '🧢',
productType: 'Accesorios > Gorras y Sombreros',
googleCategory: 'Apparel & Accessories > Clothing Accessories > Hats',
googleCategoryId: '173',
description: 'Gorras snapback, caps curvas, viseras y sombreros streetwear.',
},
bolsos: {
id: 'bolsos',
name: 'Bolsos',
icon: '👜',
productType: 'Accesorios > Bolsos y Carteras',
googleCategory: 'Apparel & Accessories > Handbags, Wallets & Cases > Handbags',
googleCategoryId: '6552',
description: 'Bolsos, carteras, tote bags y bolsas de mano exclusivas.',
},
carrieles: {
id: 'carrieles',
name: 'Carrieles',
icon: '👝',
productType: 'Accesorios > Carrieles y Bandoleras',
googleCategory: 'Apparel & Accessories > Handbags, Wallets & Cases > Fanny Packs',
googleCategoryId: '100',
description: 'Carrieles, bandoleras, pecheras, riñoneras y bolsos cruzados crossbody.',
},
maletines: {
id: 'maletines',
name: 'Maletines',
icon: '🎒',
productType: 'Equipaje > Maletines y Morrales',
googleCategory: 'Luggage & Bags > Backpacks',
googleCategoryId: '54',
description: 'Morrales escolares y ejecutivos, maletines deportivos, mochilas y tulas.',
},
ropa: {
id: 'ropa',
name: 'Ropa',
icon: '👕',
productType: 'Moda > Ropa y Streetwear',
googleCategory: 'Apparel & Accessories > Clothing',
googleCategoryId: '1604',
description: 'Camisetas, buzos, hoodies, pantalonetas, sudaderas y conjuntos.',
},
accesorios: {
id: 'accesorios',
name: 'Accesorios',
icon: '🕶️',
productType: 'Accesorios > Complementos',
googleCategory: 'Apparel & Accessories > Clothing Accessories',
googleCategoryId: '167',
description: 'Billeteras, cinturones, medias, gafas y accesorios de moda urbana.',
},
};
export const OFFICIAL_COLLECTION_NAMES = Object.values(WHATSAPP_COLLECTIONS).map((c) => c.name);
/**
* Resuelve la Colección exacta del producto para agruparlo en WhatsApp Catalog
*/
export function resolveProductCollection(product: Partial<Product>): WhatsAppCatalogCollection {
const text = `${product.name || ''} ${product.modelName || ''} ${product.description || ''} ${product.category || ''}`.toLowerCase();
// 1. Sandalias / Chanclas / Slides / Crocs
if (
/\b(sandali[as]?|chancl[as]?|slide[s]?|croc[s]?|pantufl[as]?|ojot[as]?|adilette|chola[s]?|chinela[s]?|huarache[s]?|flip[\s-]*flops?|chancleta[s]?)\b/i.test(
text
) || (product.category && /chancla|sandalia/i.test(product.category))
) {
return WHATSAPP_COLLECTIONS.sandalias;
}
// 2. Gorras / Sombreros / Caps
if (
/\b(gorra[s]?|gorro[s]?|cap[s]?|snapback|bucket[\s-]*hat|beanie|sombrero[s]?|visera[s]?)\b/i.test(
text
) || (product.category && /gorra/i.test(product.category))
) {
return WHATSAPP_COLLECTIONS.gorras;
}
// 3. Bolsos / Carteras
if (
/\b(bolso[s]?|cartera[s]?|tote([\s-]*bag)?|clutch|satchel|baguette|monedero[s]?|shopping[\s-]*bag|handbag[s]?)\b/i.test(
text
) || (product.category && /bolso|cartera/i.test(product.category))
) {
return WHATSAPP_COLLECTIONS.bolsos;
}
// 4. Carrieles / Bandoleras / Pecheras / Riñoneras / Crossbody
if (
/\b(carriel(es)?|bandolera[s]?|ri[ñn]onera[s]?|pechera[s]?|crossbody|waist[\s-]*bag|fanny[\s-]*pack|chest[\s-]*bag|canguro[s]?|mariconera[s]?|shoulder[\s-]*bag|koala[s]?|manbag)\b/i.test(
text
) || (product.category && /carriel|bandolera|pechera|ri[ñn]onera/i.test(product.category))
) {
return WHATSAPP_COLLECTIONS.carrieles;
}
// 5. Maletines / Morrales / Mochilas
if (
/\b(malet[ií]n(es)?|morral(es)?|mochila[s]?|backpack[s]?|tula[s]?|maleta[s]?|bolso\s*deportivo|duffle|gym[\s-]*bag)\b/i.test(
text
) || (product.category && /malet|morral|mochila/i.test(product.category))
) {
return WHATSAPP_COLLECTIONS.maletines;
}
// 6. Ropa / Textil
if (
/\b(camiset[as]|camibuso[s]?|buzo[s]?|hoodie[s]?|chaqueta[s]?|sudadera[s]?|pantalon(es)?|short[s]?|jogger[s]?|polo[s]?|conjunto[s]?|bermuda[s]?|overol[es]?|ropa|streetwear)\b/i.test(
text
) || (product.category && /ropa|textil|streetwear/i.test(product.category))
) {
return WHATSAPP_COLLECTIONS.ropa;
}
// 7. Accesorios varios
if (
/\b(billetera[s]?|cintur[oó]n(es)?|correa[s]?|reloj(es)?|gafa[s]?|medias?|calcet[ií]n(es)?)\b/i.test(
text
) || (product.category && /accesorio/i.test(product.category))
) {
return WHATSAPP_COLLECTIONS.accesorios;
}
// 8. Por defecto: Zapatillas (Sneakers / Tenis)
return WHATSAPP_COLLECTIONS.zapatillas;
}
/** /**
* Resuelve la URL absoluta pública y segura de la imagen del producto * Resuelve la URL absoluta pública y segura de la imagen del producto
* para que Meta y WhatsApp puedan descargarla e indexarla en alta resolución.
*/ */
export function resolveAbsoluteImageUrl(imagePath?: string): string { export function resolveAbsoluteImageUrl(imagePath?: string): string {
if (!imagePath) { if (!imagePath) {
@ -21,13 +183,8 @@ export function resolveAbsoluteImageUrl(imagePath?: string): string {
} }
/** /**
* Formatea los datos del producto con las especificaciones exactas requeridas por * Formatea los datos del producto con las especificaciones de Meta Commerce & WhatsApp Catalog
* el Catálogo de WhatsApp Business y Meta Commerce Manager: * agrupándolo en su correspondiente Colección mediante custom_label_0 y product_type.
* 1. Imagen en alta resolución
* 2. Valor de venta en COP (precio retail)
* 3. Descripción completa y persuasiva
* 4. Tallas disponibles detalladas (horma nacional)
* 5. Estado de disponibilidad activo / inactivo
*/ */
export function formatProductForWhatsAppCatalog(product: Product) { export function formatProductForWhatsAppCatalog(product: Product) {
const brand = product.brand || 'Sneakers'; const brand = product.brand || 'Sneakers';
@ -48,9 +205,13 @@ export function formatProductForWhatsAppCatalog(product: Product) {
const directCheckoutUrl = `${DOMAIN}/checkout?productId=${encodeURIComponent(product.id)}&source=whatsapp`; const directCheckoutUrl = `${DOMAIN}/checkout?productId=${encodeURIComponent(product.id)}&source=whatsapp`;
// Descripción optimizada para WhatsApp Catalog incluyendo tallas, enlace web/app y facilidades de pago // Resolver Colección Oficial en WhatsApp
const collection = resolveProductCollection(product);
// Descripción optimizada para WhatsApp Catalog con mención a la Colección
const catalogDescription = ( const catalogDescription = (
`${product.description || `Zapatillas ${title} exclusivas en horma nacional de alta calidad.`}\n\n` + `Colección: ${collection.icon} *${collection.name} DripZone*\n` +
`${product.description || `${collection.name} ${title} exclusivas en horma nacional de alta calidad.`}\n\n` +
`📏 Tallas disponibles: ${sizesList} (Horma Nacional)\n` + `📏 Tallas disponibles: ${sizesList} (Horma Nacional)\n` +
`💎 Precio oficial: ${formattedPrice} COP\n` + `💎 Precio oficial: ${formattedPrice} COP\n` +
`🚚 Pago Contraentrega disponible en toda Colombia\n` + `🚚 Pago Contraentrega disponible en toda Colombia\n` +
@ -78,29 +239,50 @@ export function formatProductForWhatsAppCatalog(product: Product) {
additional_image_links: additionalImages, additional_image_links: additionalImages,
sizes: sizesList, sizes: sizesList,
color: product.colorName || 'Color Original', color: product.colorName || 'Color Original',
category: product.category || 'Zapatillas', category: product.category || collection.name,
collection: collection.name,
collectionId: collection.id,
collectionIcon: collection.icon,
product_type: collection.productType,
google_product_category: collection.googleCategory,
google_product_category_id: collection.googleCategoryId,
// custom_label_0 es el identificador oficial de Meta Commerce para Sets y Colecciones de WhatsApp
custom_label_0: collection.name,
custom_label_1: 'Pago Contraentrega Nacional',
custom_label_2: `Tallas: ${sizesList}`,
custom_label_3: `${collection.name} - ${brand}`,
custom_label_4: isAvailable ? 'Disponible' : 'Agotado',
isActive: product.isActive !== false, isActive: product.isActive !== false,
}; };
} }
/** /**
* Genera el Feed XML en formato estándar Google Merchant / Facebook Commerce RSS * Genera el Feed XML en formato Google / Meta Merchant RSS 2.0.
* compatible con Meta Commerce Manager para sincronización programada del catálogo de WhatsApp. * Permite filtrar por una Colección específica si se requiere (ej. Sandalias, Zapatillas, Gorras).
* Si includeInactive es false (default), solo exporta productos activos para que solo
* los calzados activos aparezcan disponibles para compra en WhatsApp.
*/ */
export function generateWhatsAppCatalogXml( export function generateWhatsAppCatalogXml(
products: Product[], products: Product[],
storeSettings?: StoreSettings, storeSettings?: StoreSettings,
includeInactive = false includeInactive = false,
filterCollection?: string
): string { ): string {
const storeName = storeSettings?.storeName || 'DripZone'; const storeName = storeSettings?.storeName || 'DripZone';
const storeDescription = storeSettings?.slogan || 'Catálogo Oficial de Sneakers y Calzado Urbano'; const storeDescription = filterCollection
? `Catálogo Oficial de WhatsApp - Colección ${filterCollection}`
: (storeSettings?.slogan || 'Catálogo Oficial de WhatsApp Business por Colecciones');
const itemsToInclude = includeInactive let itemsToInclude = includeInactive
? products ? products
: products.filter((p) => p.isActive !== false); : products.filter((p) => p.isActive !== false);
if (filterCollection && filterCollection.toLowerCase() !== 'all') {
const target = filterCollection.toLowerCase();
itemsToInclude = itemsToInclude.filter((p) => {
const col = resolveProductCollection(p);
return col.name.toLowerCase() === target || col.id.toLowerCase() === target;
});
}
const itemsXml = itemsToInclude const itemsXml = itemsToInclude
.map((product) => { .map((product) => {
const item = formatProductForWhatsAppCatalog(product); const item = formatProductForWhatsAppCatalog(product);
@ -121,10 +303,13 @@ ${additionalImagesXml}
<g:availability>${item.availability === 'in stock' ? 'in_stock' : 'out_of_stock'}</g:availability> <g:availability>${item.availability === 'in stock' ? 'in_stock' : 'out_of_stock'}</g:availability>
<g:price>${item.priceAmount} COP</g:price> <g:price>${item.priceAmount} COP</g:price>
<g:size><![CDATA[${item.sizes}]]></g:size> <g:size><![CDATA[${item.sizes}]]></g:size>
<g:product_type><![CDATA[${item.category}]]></g:product_type> <g:product_type><![CDATA[${item.product_type}]]></g:product_type>
<g:custom_label_0><![CDATA[Pago Contraentrega]]></g:custom_label_0> <g:google_product_category><![CDATA[${item.google_product_category}]]></g:google_product_category>
<g:custom_label_1><![CDATA[Tallas: ${item.sizes}]]></g:custom_label_1> <g:custom_label_0><![CDATA[${item.custom_label_0}]]></g:custom_label_0>
<g:custom_label_2><![CDATA[${item.isActive ? 'Activo' : 'Inactivo'}]]></g:custom_label_2> <g:custom_label_1><![CDATA[${item.custom_label_1}]]></g:custom_label_1>
<g:custom_label_2><![CDATA[${item.custom_label_2}]]></g:custom_label_2>
<g:custom_label_3><![CDATA[${item.custom_label_3}]]></g:custom_label_3>
<g:custom_label_4><![CDATA[${item.custom_label_4}]]></g:custom_label_4>
</item>`; </item>`;
}) })
.join(''); .join('');
@ -132,7 +317,7 @@ ${additionalImagesXml}
return `<?xml version="1.0" encoding="UTF-8"?> return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0"> <rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">
<channel> <channel>
<title><![CDATA[${storeName} - Catálogo Oficial de WhatsApp]]></title> <title><![CDATA[${storeName} - Catálogo de WhatsApp${filterCollection ? ` (${filterCollection})` : ''}]]></title>
<link><![CDATA[${DOMAIN}]]></link> <link><![CDATA[${DOMAIN}]]></link>
<description><![CDATA[${storeDescription}]]></description> <description><![CDATA[${storeDescription}]]></description>
${itemsXml} ${itemsXml}
@ -141,9 +326,14 @@ ${additionalImagesXml}
} }
/** /**
* Genera el archivo CSV para importación directa en Meta Commerce Manager / WhatsApp Catalog. * Genera el archivo CSV para importación directa en Meta Commerce Manager / WhatsApp Catalog
* con columnas completas de colecciones y categorización.
*/ */
export function generateWhatsAppCatalogCsv(products: Product[], includeInactive = false): string { export function generateWhatsAppCatalogCsv(
products: Product[],
includeInactive = false,
filterCollection?: string
): string {
const headers = [ const headers = [
'id', 'id',
'title', 'title',
@ -156,12 +346,27 @@ export function generateWhatsAppCatalogCsv(products: Product[], includeInactive
'additional_image_link', 'additional_image_link',
'brand', 'brand',
'size', 'size',
'product_type',
'google_product_category',
'custom_label_0',
'custom_label_1',
'custom_label_2',
'custom_label_3',
'custom_label_4',
]; ];
const itemsToInclude = includeInactive let itemsToInclude = includeInactive
? products ? products
: products.filter((p) => p.isActive !== false); : products.filter((p) => p.isActive !== false);
if (filterCollection && filterCollection.toLowerCase() !== 'all') {
const target = filterCollection.toLowerCase();
itemsToInclude = itemsToInclude.filter((p) => {
const col = resolveProductCollection(p);
return col.name.toLowerCase() === target || col.id.toLowerCase() === target;
});
}
const rows = itemsToInclude.map((product) => { const rows = itemsToInclude.map((product) => {
const item = formatProductForWhatsAppCatalog(product); const item = formatProductForWhatsAppCatalog(product);
return [ return [
@ -176,6 +381,13 @@ export function generateWhatsAppCatalogCsv(products: Product[], includeInactive
`"${item.additional_image_links.join(',')}"`, `"${item.additional_image_links.join(',')}"`,
`"${item.brand.replace(/"/g, '""')}"`, `"${item.brand.replace(/"/g, '""')}"`,
`"${item.sizes.replace(/"/g, '""')}"`, `"${item.sizes.replace(/"/g, '""')}"`,
`"${item.product_type.replace(/"/g, '""')}"`,
`"${item.google_product_category.replace(/"/g, '""')}"`,
`"${item.custom_label_0.replace(/"/g, '""')}"`,
`"${item.custom_label_1.replace(/"/g, '""')}"`,
`"${item.custom_label_2.replace(/"/g, '""')}"`,
`"${item.custom_label_3.replace(/"/g, '""')}"`,
`"${item.custom_label_4.replace(/"/g, '""')}"`,
].join(','); ].join(',');
}); });
@ -184,8 +396,7 @@ export function generateWhatsAppCatalogCsv(products: Product[], includeInactive
/** /**
* Registra o actualiza en tiempo real un producto en Meta Commerce Manager * Registra o actualiza en tiempo real un producto en Meta Commerce Manager
* para que aparezca o se actualice inmediatamente en el catálogo de WhatsApp. * enviando sus atributos de colección (custom_label_0, product_type, google_product_category).
* Si el producto está inactivo (isActive: false), se envía availability: 'out of stock'.
*/ */
export async function syncProductToMetaCatalogApi( export async function syncProductToMetaCatalogApi(
product: Product, product: Product,
@ -195,12 +406,10 @@ export async function syncProductToMetaCatalogApi(
const catalogId = settings?.metaCatalogId || process.env.META_CATALOG_ID; const catalogId = settings?.metaCatalogId || process.env.META_CATALOG_ID;
const accessToken = settings?.metaAccessToken || process.env.META_ACCESS_TOKEN; const accessToken = settings?.metaAccessToken || process.env.META_ACCESS_TOKEN;
// Si no se han configurado credenciales directas de Meta Graph API,
// el feed continuo XML/CSV sigue activo como mecanismo principal de sincronización.
if (!catalogId || !accessToken) { if (!catalogId || !accessToken) {
return { return {
success: true, success: true,
error: 'Feed XML/CSV activo. Para sincronización instantánea vía API ingresa el Meta Catalog ID y Token.', error: 'Feed XML/CSV activo con soporte de colecciones.',
}; };
} }
@ -225,6 +434,13 @@ export async function syncProductToMetaCatalogApi(
additional_image_urls: item.additional_image_links, additional_image_urls: item.additional_image_links,
brand: item.brand, brand: item.brand,
size: item.sizes, size: item.sizes,
product_type: item.product_type,
google_product_category: item.google_product_category,
custom_label_0: item.custom_label_0,
custom_label_1: item.custom_label_1,
custom_label_2: item.custom_label_2,
custom_label_3: item.custom_label_3,
custom_label_4: item.custom_label_4,
}, },
}, },
], ],
@ -248,10 +464,69 @@ export async function syncProductToMetaCatalogApi(
} }
} }
/**
* Crea o verifica automáticamente los Conjuntos de Productos (Colecciones)
* en Meta Commerce Manager a través de Meta Graph API.
*/
export async function syncMetaCatalogProductSets(
catalogId: string,
accessToken: string
): Promise<{
success: boolean;
results: { collection: string; status: 'created' | 'exists' | 'error'; error?: string }[];
}> {
if (!catalogId || !accessToken) {
return {
success: false,
results: [],
};
}
const results: { collection: string; status: 'created' | 'exists' | 'error'; error?: string }[] = [];
for (const collection of Object.values(WHATSAPP_COLLECTIONS)) {
try {
// Intentar crear el Product Set basado en custom_label_0
const endpoint = `https://graph.facebook.com/v19.0/${catalogId}/product_sets`;
const payload = {
name: collection.name,
filter: JSON.stringify({
custom_label_0: { eq: collection.name },
}),
};
const response = await axios.post(endpoint, payload, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
timeout: 15000,
});
if (response.data && response.data.id) {
results.push({ collection: collection.name, status: 'created' });
} else {
results.push({ collection: collection.name, status: 'exists' });
}
} catch (e: any) {
const errMsg = e?.response?.data?.error?.message || e.message;
if (errMsg.includes('already exists') || errMsg.includes('duplicate')) {
results.push({ collection: collection.name, status: 'exists' });
} else {
results.push({ collection: collection.name, status: 'error', error: errMsg });
}
}
}
return {
success: results.every((r) => r.status === 'created' || r.status === 'exists'),
results,
};
}
/** /**
* Función principal disparada tras la creación, publicación o actualización de cualquier * Función principal disparada tras la creación, publicación o actualización de cualquier
* producto en la app móvil y web. Garantiza que el producto se registre o actualice * producto en la app móvil y web. Garantiza que el producto se registre en su Colección en WhatsApp.
* en el Catálogo de WhatsApp de DripZone.
*/ */
export async function registerProductInWhatsAppCatalog( export async function registerProductInWhatsAppCatalog(
product: Product product: Product
@ -266,11 +541,12 @@ export async function registerProductInWhatsAppCatalog(
dataService.updateProduct(product.id, { dataService.updateProduct(product.id, {
whatsappCatalogSynced: true, whatsappCatalogSynced: true,
whatsappCatalogSyncAt: new Date().toISOString(), whatsappCatalogSyncAt: new Date().toISOString(),
category: catalogItem.collection, // Asegurar persistencia de la colección normalizada
} as any); } as any);
} }
console.log( console.log(
`📦 [WhatsApp Catalog] Producto ${catalogItem.isActive ? 'ACTIVO' : 'INACTIVO'} sincronizado para WhatsApp: "${catalogItem.title}" (${catalogItem.price}) | Disponibilidad: ${catalogItem.availability} | Tallas: ${catalogItem.sizes}` `📦 [WhatsApp Catalog] Colección "${catalogItem.collectionIcon} ${catalogItem.collection}": "${catalogItem.title}" (${catalogItem.price}) | Disponibilidad: ${catalogItem.availability} | Tallas: ${catalogItem.sizes}`
); );
return { return {

32
scripts/build_apk.py Normal file
View File

@ -0,0 +1,32 @@
import subprocess
import os
import shutil
android_dir = r"c:\Users\Carlos\dev\almeira-web\dripzone-app\android"
jbr_dir = r"C:\Program Files\Android\Android Studio\jbr"
env = os.environ.copy()
env["JAVA_HOME"] = jbr_dir
print("Iniciando compilación de APK con Gradle...")
result = subprocess.run(
["cmd.exe", "/c", "gradlew.bat", "assembleDebug"],
cwd=android_dir,
env=env,
capture_output=True,
text=True
)
print("Exit code:", result.returncode)
if result.returncode == 0:
print("¡Compilación exitosa!")
src_apk = os.path.join(android_dir, "app", "build", "outputs", "apk", "debug", "app-debug.apk")
dst_apk1 = r"c:\Users\Carlos\dev\almeira-web\dripzone-app\public\DripZone.apk"
dst_apk2 = r"c:\Users\Carlos\dev\almeira-web\DripZone_Actualizado.apk"
if os.path.exists(src_apk):
shutil.copy2(src_apk, dst_apk1)
shutil.copy2(src_apk, dst_apk2)
size_mb = os.path.getsize(dst_apk1) / (1024 * 1024)
print(f"APK copiado a {dst_apk1} ({size_mb:.2f} MB)")
else:
print("Error compilación:\n", result.stderr[-1000:] if result.stderr else result.stdout[-1000:])

View File

@ -0,0 +1,31 @@
import urllib.request
import json
import ssl
import sys
import io
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request("https://drip-zone.online/api/products?all=true")
with urllib.request.urlopen(req, context=ctx, timeout=15) as res:
data = json.loads(res.read().decode('utf-8'))
products = data.get('products', [])
print(f"Total productos obtenidos: {len(products)}")
categories = {}
for p in products:
cat = p.get('category') or 'Sin categoría'
categories[cat] = categories.get(cat, 0) + 1
print("\nDistribución actual por categoría:")
for cat, count in categories.items():
print(f" - {cat}: {count} productos")
print("\nPrimeros 5 productos con nombre y categoría:")
for p in products[:5]:
print(f" * [{p.get('category')}] {p.get('name')} (Brand: {p.get('brand')})")

View File

@ -0,0 +1,16 @@
import paramiko
import sys
import io
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect('161.97.93.156', username='carlos', password='Innova1978+*?', timeout=20)
stdin, stdout, stderr = client.exec_command('docker logs --tail 100 api-i5mzip2g7cmyvc56n9ewahbb')
print("Evolution API logs:\n", stdout.read().decode('utf-8', errors='replace'))
client.close()

View File

@ -0,0 +1,23 @@
import urllib.request
import json
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
EVOLUTION_API_URL = "https://evolution.almeira.com.co"
EVOLUTION_API_KEY = "EE49029EEB85-4568-8F81-C9FDFC6E2943"
INSTANCE_NAME = "AutomataAlmeira"
TARGET_PHONE = "573167455877"
# Check instance state
try:
req = urllib.request.Request(
f"{EVOLUTION_API_URL}/instance/connectionState/{INSTANCE_NAME}",
headers={"apikey": EVOLUTION_API_KEY}
)
with urllib.request.urlopen(req, context=ctx, timeout=10) as r:
print("AutomataAlmeira connectionState:", r.read().decode('utf-8'))
except Exception as e:
print("Error connectionState:", e)

28
scripts/check_status.py Normal file
View File

@ -0,0 +1,28 @@
import paramiko
import urllib.request
import json
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect('161.97.93.156', username='carlos', password='Innova1978+*?', timeout=20)
stdin, stdout, stderr = client.exec_command('docker ps --filter name=bfmnoga3 --format "{{.ID}} {{.Names}} {{.Status}}"')
print("Docker container:", stdout.read().decode('utf-8').strip())
stdin, stdout, stderr = client.exec_command('docker exec dripzone-production ls -lh /app/public/DripZone.apk 2>/dev/null || docker exec $(docker ps -q --filter name=bfmnoga3 | head -n1) ls -lh /app/public/DripZone.apk 2>/dev/null')
print("APK in container:", stdout.read().decode('utf-8').strip())
client.close()
# Test live APK URL
try:
req = urllib.request.Request("https://drip-zone.online/DripZone.apk", method="HEAD")
with urllib.request.urlopen(req, context=ctx, timeout=10) as r:
print(f"Live URL https://drip-zone.online/DripZone.apk -> HTTP {r.status}, length: {r.headers.get('Content-Length')}")
except Exception as e:
print("Live URL check error:", e)

21
scripts/check_vps_logs.py Normal file
View File

@ -0,0 +1,21 @@
import paramiko
import sys
import io
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect('161.97.93.156', username='carlos', password='Innova1978+*?', timeout=20)
print("=== LOGS RECIENTES DE EVOLUTION API ===")
stdin, stdout, stderr = client.exec_command('docker logs --tail 30 $(docker ps -q --filter "name=evolution" | head -n 1)')
print(stdout.read().decode('utf-8', errors='replace'))
print("=== LOGS RECIENTES DE DRIPZONE ===")
stdin, stdout, stderr = client.exec_command('docker logs --tail 40 $(docker ps -q --filter "name=bfmnoga3" | head -n 1)')
print(stdout.read().decode('utf-8', errors='replace'))
client.close()

View File

@ -0,0 +1,148 @@
import paramiko
import os
import urllib.request
import json
import ssl
import sys
import io
import time
import subprocess
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
HOST = "161.97.93.156"
USER = "carlos"
PASSWORD = "Innova1978+*?"
REMOTE_BASE = "/home/carlos/apps/dripzone-app"
LOCAL_APK = r"c:\Users\Carlos\dev\almeira-web\dripzone-app\public\DripZone.apk"
REMOTE_APK = f"{REMOTE_BASE}/public/DripZone.apk"
EVOLUTION_API_URL = "https://evolution.almeira.com.co"
EVOLUTION_API_KEY = "EE49029EEB85-4568-8F81-C9FDFC6E2943"
INSTANCE_NAME = "AutomataAlmeira"
TARGET_PHONE = "573167455877"
APK_URL = "https://drip-zone.online/DripZone.apk"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def main():
print("[1/5] Verificando APK local...")
if not os.path.exists(LOCAL_APK):
print(f" [ERROR] El archivo {LOCAL_APK} no existe.")
sys.exit(1)
size_mb = os.path.getsize(LOCAL_APK) / (1024 * 1024)
print(f" -> APK local verificado: {LOCAL_APK} ({size_mb:.2f} MB)")
print("[2/5] Guardando cambios y pusheando a Gitea...")
try:
subprocess.run(["git", "add", "."], cwd=r"c:\Users\Carlos\dev\almeira-web", check=False)
msg = "feat: colecciones WhatsApp catalog (Sandalias, Zapatillas, Gorras, etc) + imagen al inicio de ficha de edicion"
subprocess.run(["git", "commit", "-m", msg], cwd=r"c:\Users\Carlos\dev\almeira-web", check=False)
push_res = subprocess.run(["git", "push", "origin", "main"], cwd=r"c:\Users\Carlos\dev\almeira-web", capture_output=True, text=True)
print(" -> Git push:", push_res.stdout.strip() or push_res.stderr.strip())
except Exception as e:
print(" [WARN Git]:", e)
print("[3/5] Subiendo APK a VPS 161.97.93.156 vía SFTP...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30)
sftp = ssh.open_sftp()
try:
sftp.mkdir(f"{REMOTE_BASE}/public")
except:
pass
sftp.put(LOCAL_APK, REMOTE_APK)
sftp.close()
print(" [OK] Archivo APK subido por SFTP.")
print("[4/5] Copiando APK dentro de los contenedores Docker en VPS...")
cmd = f"""
docker cp {REMOTE_APK} dripzone-production:/app/public/DripZone.apk || true
docker cp {REMOTE_APK} dripzone-production:/app/public/dripzone.apk || true
docker cp {REMOTE_APK} dripzone-production:/app/DripZone.apk || true
for c in $(docker ps -q --filter "name=bfmnoga3"); do
docker cp {REMOTE_APK} $c:/app/public/DripZone.apk || true
docker cp {REMOTE_APK} $c:/app/public/dripzone.apk || true
docker cp {REMOTE_APK} $c:/app/DripZone.apk || true
done
docker exec dripzone-production ls -lh /app/public/DripZone.apk || true
"""
stdin, stdout, stderr = ssh.exec_command(cmd)
out = stdout.read().decode('utf-8', errors='replace')
print(" -> Salida Docker:\n", out.strip())
ssh.close()
time.sleep(2)
try:
req = urllib.request.Request(APK_URL, method='HEAD')
with urllib.request.urlopen(req, context=ctx, timeout=15) as res:
live_size_mb = int(res.headers.get('Content-Length', 0)) / (1024 * 1024)
print(f" [OK] APK online verificado en {APK_URL} (HTTP {res.status}, {live_size_mb:.2f} MB)")
except Exception as e:
print(f" [WARN] Verificación HEAD APK: {e}")
print("[5/5] Enviando reporte y APK al WhatsApp de Carlos (+57 316 745 5877)...")
message_text = (
"🏷️ *DripZone - Colecciones en Catálogo de WhatsApp & Ficha de Edición con Imagen*\n\n"
"Hola Carlos, se han implementado y desplegado las mejoras solicitadas:\n\n"
"📂 *1. Colecciones Oficiales en el Catálogo de WhatsApp (Meta Commerce):*\n"
"• Los productos ahora se diferencian y agrupan automáticamente en colecciones oficiales:\n"
" - 🩴 *Sandalias* (sandalias, chanclas, slides, crocs)\n"
" - 👟 *Zapatillas* (sneakers, tenis, running, urbano)\n"
" - 🧢 *Gorras* (caps, snapback, sombreros)\n"
" - 👜 *Bolsos* (carteras, tote bags, bolsos de mano)\n"
" - 👝 *Carrieles* (bandoleras, pecheras, riñoneras, crossbody)\n"
" - 🎒 *Maletines* (morrales, mochilas, backpacks, tulas)\n"
" - 👕 *Ropa* (camisetas, buzos, hoodies, streetwear)\n"
" - 🕶️ *Accesorios* (cinturones, billeteras, gafas, relojes)\n"
"• Cada producto lleva su etiqueta de colección (`custom_label_0`), taxonomía de tienda (`product_type`) y categoría oficial Google/Meta.\n"
"• Soporte para feeds XML y CSV segmentados por colección (ej. `/api/catalog/feed.xml?collection=Sandalias`).\n"
"• Nuevo endpoint y botón de sincronización de Product Sets para Meta Graph API (`/api/catalog/collections/sync`).\n\n"
"🖼️ *2. Ficha de Edición con Imagen al Inicio:*\n"
"• En el panel de productos (`/admin/productos`), al editar cualquier publicación, ahora se muestra inmediatamente la foto en alta resolución al inicio de la ficha.\n"
"• Incluye selector de miniaturas si el producto tiene varias fotos y lista desplegable con las colecciones oficiales para reasignación rápida.\n\n"
f"⬇️ *Descarga directa del APK actualizado:* {APK_URL}\n\n"
"Adjunto el APK actualizado con las colecciones y la nueva interfaz."
)
req_text = urllib.request.Request(
f"{EVOLUTION_API_URL}/message/sendText/{INSTANCE_NAME}",
data=json.dumps({"number": TARGET_PHONE, "text": message_text, "delay": 500}).encode("utf-8"),
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req_text, context=ctx, timeout=30) as resp:
print(" [OK] Mensaje de texto enviado a WhatsApp (Status:", resp.status, ")")
except Exception as e:
print(" [Error texto WhatsApp]:", e)
req_media = urllib.request.Request(
f"{EVOLUTION_API_URL}/message/sendMedia/{INSTANCE_NAME}",
data=json.dumps({
"number": TARGET_PHONE,
"mediatype": "document",
"mimetype": "application/vnd.android.package-archive",
"fileName": "DripZone-v2.2-Collections.apk",
"caption": "📲 *DripZone Native v2.2 - Colecciones de Catálogo & Ficha con Foto*",
"media": APK_URL
}).encode("utf-8"),
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req_media, context=ctx, timeout=60) as resp:
print(" [OK] Documento APK enviado a WhatsApp (Status:", resp.status, ")")
except Exception as e:
print(" [Aviso envío documento APK directo]:", e)
print("\n✅ [DESPLIEGUE Y NOTIFICACIÓN COMPLETADOS CON ÉXITO]")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,98 @@
import urllib.request
import json
import ssl
import sys
import io
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
print("=================================================================")
print("🧪 VERIFICACIÓN FINAL DEL SISTEMA - DRIPZONE & WHATSAPP CATALOG")
print("=================================================================")
# 1. Feed XML de Catálogo WhatsApp
print("\n[1] Verificando Feed XML de Catálogo WhatsApp (Meta Commerce)...")
try:
req = urllib.request.Request(
"https://drip-zone.online/api/catalog/feed.xml",
headers={"User-Agent": "facebookexternalhit/1.1"}
)
with urllib.request.urlopen(req, context=ctx, timeout=15) as res:
content = res.read().decode('utf-8', errors='replace')
print(f" -> HTTP Status: {res.status}")
print(f" -> Content-Type: {res.headers.get('Content-Type')}")
item_count = content.count("<item>")
print(f" -> Total productos en XML: {item_count}")
if "<rss" in content and "<channel>" in content:
print(" -> Estructura XML: VÁLIDA (RSS 2.0 / Google & Meta Merchant Feed)")
first_item = content.split("<item>")[1].split("</item>")[0] if item_count > 0 else ""
if first_item:
lines = [l.strip() for l in first_item.split("\n") if l.strip()][:6]
print(" -> Muestra primer producto:\n " + "\n ".join(lines))
except Exception as e:
print(f" [ERROR] {e}")
# 2. Feed CSV de Catálogo WhatsApp
print("\n[2] Verificando Feed CSV de Catálogo WhatsApp...")
try:
req = urllib.request.Request("https://drip-zone.online/api/catalog/feed.csv")
with urllib.request.urlopen(req, context=ctx, timeout=15) as res:
content = res.read().decode('utf-8', errors='replace')
print(f" -> HTTP Status: {res.status}")
csv_lines = [l for l in content.split("\n") if l.strip()]
print(f" -> Total filas en CSV (incluyendo header): {len(csv_lines)}")
print(f" -> Header CSV: {csv_lines[0] if csv_lines else 'Vacío'}")
except Exception as e:
print(f" [ERROR] {e}")
# 3. Webhook de WhatsApp en DripZone
print("\n[3] Verificando Webhook en DripZone...")
try:
req = urllib.request.Request(
"https://drip-zone.online/api/webhook/whatsapp",
data=json.dumps({"test": True, "source": "final_verification"}).encode('utf-8'),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, context=ctx, timeout=15) as res:
print(f" -> POST /api/webhook/whatsapp: HTTP {res.status}")
print(f" -> Respuesta: {res.read().decode('utf-8')}")
except Exception as e:
print(f" [ERROR] {e}")
# 4. APK Público
print("\n[4] Verificando disponibilidad del APK...")
try:
req = urllib.request.Request("https://drip-zone.online/DripZone.apk", method="HEAD")
with urllib.request.urlopen(req, context=ctx, timeout=15) as res:
size_mb = int(res.headers.get('Content-Length', 0)) / (1024 * 1024)
print(f" -> URL: https://drip-zone.online/DripZone.apk")
print(f" -> HTTP Status: {res.status} (Tamaño: {size_mb:.2f} MB)")
except Exception as e:
print(f" [ERROR] {e}")
# 5. Webhooks en Evolution API
print("\n[5] Verificando configuración de Webhooks en Evolution API...")
for inst, key in [("AutomataAlmeira", "EE49029EEB85-4568-8F81-C9FDFC6E2943"), ("DripZone", "83B5BD230796-4324-AE73-3508F69E279D")]:
try:
req = urllib.request.Request(
f"https://evolution.almeira.com.co/webhook/find/{inst}",
headers={"apikey": key}
)
with urllib.request.urlopen(req, context=ctx, timeout=15) as res:
data = json.loads(res.read().decode('utf-8'))
print(f" -> Instancia {inst}:")
print(f" Enabled: {data.get('enabled')}")
print(f" Webhook URL: {data.get('url')}")
print(f" Eventos: {data.get('events')}")
except Exception as e:
print(f" [ERROR en {inst}]: {e}")
print("\n=================================================================")
print("✅ TODAS LAS PRUEBAS COMPLETADAS")
print("=================================================================")

View File

@ -0,0 +1,75 @@
import urllib.request
import json
import ssl
import sys
import io
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
APPWRITE_ENDPOINT = "https://appwrite.almeira.com.co/v1"
PROJECT_ID = "69b84df5003bd8957c5c"
DATABASE_ID = "sneakers_multistore_db"
COLLECTION_ID = "products"
API_KEY = "standard_cce3cdb866ea6433888d40cedd24dc45987372308fdc3dd0697de08648c0e5c7e04b02c3bcee271403fd656b00a5820f04c08b51631284a6e8a3582d8aed2aa33234f3c29cc777e9b162eb54810b789251b0d006a9a3b081ebc2da53b9baa94614b124b3508ad849fd9a4187cc6094f506d4b57f409b69d9562a74df594f3ec3"
headers = {
"X-Appwrite-Project": PROJECT_ID,
"X-Appwrite-Key": API_KEY,
"Content-Type": "application/json",
}
print("1. Obteniendo productos de Appwrite...")
url = f"{APPWRITE_ENDPOINT}/databases/{DATABASE_ID}/collections/{COLLECTION_ID}/documents?limit=100"
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, context=ctx, timeout=20) as res:
data = json.loads(res.read().decode('utf-8'))
docs = data.get('documents', [])
print(f" -> Total documentos encontrados: {len(docs)}")
def resolve_collection(doc):
name = (doc.get('name') or '').lower()
cat = (doc.get('category') or '').lower()
desc = (doc.get('description') or '').lower()
full = f"{name} {cat} {desc}"
if any(w in full for w in ['sandali', 'chancl', 'slide', 'croc', 'pantufl', 'ojot', 'adilette']):
return 'Sandalias'
if any(w in full for w in ['gorra', 'cap', 'snapback', 'bucket', 'sombrero']):
return 'Gorras'
if any(w in full for w in ['bolso', 'cartera', 'tote', 'handbag']):
return 'Bolsos'
if any(w in full for w in ['carriel', 'bandolera', 'riñonera', 'rinonera', 'pechera', 'crossbody', 'canguro']):
return 'Carrieles'
if any(w in full for w in ['malet', 'morral', 'mochila', 'backpack', 'tula']):
return 'Maletines'
if any(w in full for w in ['camiseta', 'buzo', 'hoodie', 'chaqueta', 'pantalon', 'short', 'ropa', 'streetwear']):
return 'Ropa'
if any(w in full for w in ['billetera', 'cinturon', 'correa', 'reloj', 'gafas']):
return 'Accesorios'
return 'Zapatillas'
updated_count = 0
for doc in docs:
doc_id = doc['$id']
current_cat = doc.get('category') or ''
target_cat = resolve_collection(doc)
if current_cat != target_cat:
update_url = f"{APPWRITE_ENDPOINT}/databases/{DATABASE_ID}/collections/{COLLECTION_ID}/documents/{doc_id}"
payload = json.dumps({"data": {"category": target_cat}}).encode('utf-8')
update_req = urllib.request.Request(update_url, data=payload, headers=headers, method='PATCH')
try:
with urllib.request.urlopen(update_req, context=ctx, timeout=10) as patch_res:
print(f" [OK] {doc.get('name')[:35]}: '{current_cat}' -> '{target_cat}'")
updated_count += 1
except Exception as e:
print(f" [ERROR en {doc_id}]: {e}")
print(f"\n✅ Total productos actualizados en Appwrite: {updated_count}")