diff --git a/__pycache__/deploy_dripzone_vps.cpython-313.pyc b/__pycache__/deploy_dripzone_vps.cpython-313.pyc index 317467c..8169a5a 100644 Binary files a/__pycache__/deploy_dripzone_vps.cpython-313.pyc and b/__pycache__/deploy_dripzone_vps.cpython-313.pyc differ diff --git a/dripzone-app/src/app/admin/catalogo-whatsapp/page.tsx b/dripzone-app/src/app/admin/catalogo-whatsapp/page.tsx index a087f82..ebbe451 100644 --- a/dripzone-app/src/app/admin/catalogo-whatsapp/page.tsx +++ b/dripzone-app/src/app/admin/catalogo-whatsapp/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import Image from 'next/image'; import { MessageCircle, @@ -9,7 +9,6 @@ import { Check, ExternalLink, ShieldCheck, - AlertCircle, CheckCircle2, Package, Layers, @@ -23,11 +22,17 @@ import { Tag, Link2, Download, - Send, + FolderSync, + ChevronDown, } from 'lucide-react'; import { Product, StoreSettings } from '@/types'; import { formatCOP } from '@/lib/pricing-engine'; import { WhatsAppOrderLinkModal } from '@/components/admin/WhatsAppOrderLinkModal'; +import { + WHATSAPP_COLLECTIONS, + OFFICIAL_COLLECTION_NAMES, + resolveProductCollection, +} from '@/lib/whatsapp-catalog'; export default function AdminWhatsAppCatalogPage() { const [products, setProducts] = useState([]); @@ -35,9 +40,10 @@ export default function AdminWhatsAppCatalogPage() { const [isLoading, setIsLoading] = useState(true); const [isSyncing, setIsSyncing] = useState(false); const [syncMessage, setSyncMessage] = useState(null); - const [copiedFeed, setCopiedFeed] = useState<'xml' | 'csv' | null>(null); + const [copiedFeed, setCopiedFeed] = useState(null); const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all'); + const [selectedCollection, setSelectedCollection] = useState('all'); const [savingSettings, setSavingSettings] = useState(false); // Modal para Generación de Enlaces de Pedido WhatsApp para Asesores @@ -85,9 +91,9 @@ export default function AdminWhatsAppCatalogPage() { fetchData(); }, []); - const handleCopyUrl = (type: 'xml' | 'csv', url: string) => { + const handleCopyUrl = (key: string, url: string) => { navigator.clipboard.writeText(url); - setCopiedFeed(type); + setCopiedFeed(key); 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 newStatus = product.isActive === false ? true : false; @@ -197,15 +281,38 @@ export default function AdminWhatsAppCatalogPage() { const activeProducts = 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 = {}; + 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 col = resolveProductCollection(p); + const matchesSearch = p.name.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; - if (statusFilter === 'inactive') return matchesSearch && p.isActive === false; - return matchesSearch; + const matchesStatus = + statusFilter === 'all' + ? true + : statusFilter === 'active' + ? p.isActive !== false + : p.isActive === false; + + const matchesCollection = + selectedCollection === 'all' ? true : col.name === selectedCollection; + + return matchesSearch && matchesStatus && matchesCollection; }); return ( @@ -218,31 +325,41 @@ export default function AdminWhatsAppCatalogPage() {

- Catálogo Oficial de WhatsApp Business + Catálogo de WhatsApp Business & Colecciones

- 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).

-
+
+ +
@@ -255,15 +372,14 @@ export default function AdminWhatsAppCatalogPage() {

- Flujo Comercial & Procesamiento de Pedidos DripZone + Colecciones y Flujo Comercial en WhatsApp Business - Oficial + Meta Commerce

- El catálogo de WhatsApp permite a los usuarios consultar modelos y comunicarse para asesoría. - Todos los pedidos y pagos ($10.000 anticipo Bold + contraentrega) se procesan a través de la web o app móvil para asegurar factura y seguimiento. - Usa el botón "Cerrar Pedido por WhatsApp" en cada producto para enviar al cliente su enlace directo con talla lista. + Las colecciones permiten a tus clientes explorar fácilmente tu catálogo en WhatsApp por secciones: Sandalias, Zapatillas, Gorras, Bolsos, etc. + Cada producto se sincroniza con su etiqueta oficial de colección (custom_label_0) y taxonomía Google/Meta.

@@ -277,7 +393,7 @@ export default function AdminWhatsAppCatalogPage() { )} - {/* Métricas y Estado */} + {/* Métricas y Estado General */}
@@ -285,7 +401,7 @@ export default function AdminWhatsAppCatalogPage() {
{products.length}
-
Total Modelos
+
Total Productos
@@ -295,17 +411,17 @@ export default function AdminWhatsAppCatalogPage() {
{activeProducts.length}
-
Activos en WhatsApp & App
+
Activos en WhatsApp
- +
-
{inactiveProducts.length}
-
Inactivos / Ocultos
+
{OFFICIAL_COLLECTION_NAMES.length}
+
Colecciones Oficiales
@@ -316,11 +432,82 @@ export default function AdminWhatsAppCatalogPage() {
Línea Oficial DripZone
+57 310 639 0296
-
Instancia Evolution: DripZone
+
Instancia: DripZone / Automata
+ {/* SECCIÓN DE COLECCIONES DE WHATSAPP: PESTAÑAS Y CONTROL DE CATEGORÍAS */} +
+
+
+

+ + Colecciones del Catálogo de WhatsApp +

+

+ Filtra por colección para auditar o reasignar productos. Cada producto se sincroniza automáticamente con su colección en Meta Commerce. +

+
+ +
+ +
+
+ + {/* Pestañas de Colecciones */} +
+ + + {Object.values(WHATSAPP_COLLECTIONS).map((col) => { + const isSelected = selectedCollection === col.name; + const count = collectionCounts[col.name] || 0; + + return ( + + ); + })} +
+
+ {/* Sección 1: Enlaces del Feed Automático (Meta Commerce Manager) */}
@@ -330,7 +517,7 @@ export default function AdminWhatsAppCatalogPage() { Orígenes de Datos para Meta Commerce & WhatsApp Catalog

- 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 custom_label_0.

@@ -341,7 +528,7 @@ export default function AdminWhatsAppCatalogPage() {

- 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 (custom_label_0), categoría Google y taxonomía de tienda.

@@ -375,7 +562,7 @@ export default function AdminWhatsAppCatalogPage() {

- 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 custom_label_0 con el nombre exacto de la colección.

+ + {/* Enlaces de Feed por Colección Específica */} +
+
+ + Feeds XML Segmentados por Colección Individual (Opcional): +
+
+ {Object.values(WHATSAPP_COLLECTIONS).map((c) => { + const url = `${domain}/api/catalog/feed.xml?collection=${encodeURIComponent(c.name)}`; + const count = collectionCounts[c.name] || 0; + return ( + + ); + })} +
+
- {/* 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 */}

- Productos en Vitrina y Control de Disponibilidad + + {selectedCollection === 'all' + ? 'Todos los Productos en Catálogo' + : `Colección: ${selectedCollection}`} +

- 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.

@@ -497,7 +718,7 @@ export default function AdminWhatsAppCatalogPage() { type="text" value={search} 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" /> @@ -546,6 +767,7 @@ export default function AdminWhatsAppCatalogPage() { const sizesList = Array.isArray(product.sizes) && product.sizes.length > 0 ? product.sizes.join(', ') : '37 - 43'; + const colInfo = resolveProductCollection(product); return (
- {/* Header de la tarjeta */} + {/* Header de la tarjeta con Colección destacada */}
{/* Imagen HD */}
@@ -608,6 +830,27 @@ export default function AdminWhatsAppCatalogPage() {
+ {/* Badge de Colección Oficial WhatsApp */} +
+ + + Colección WhatsApp: + +
+ +
+
+ {/* Tallas disponibles */}
@@ -620,8 +863,8 @@ export default function AdminWhatsAppCatalogPage() {
{/* Descripción de la publicación */} -
- {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.'}
{/* Botón para Asesor Comercial: Enlace Directo para WhatsApp */} @@ -663,7 +906,7 @@ export default function AdminWhatsAppCatalogPage() {
+ {/* VISTA PREVIA DE LA IMAGEN AL INICIO DE LA FICHA */} +
+
+ +
+
+
+ + {editBrand || editingProduct.brand || 'Sneakers'} + + + {editingProduct.isActive !== false ? '🟢 En Vitrina' : '⚪ Oculto'} + +
+
+ {editingProduct.name} +
+
+ Colección: + {editCategory || editingProduct.category || 'Zapatillas'} +
+ {editingProduct.images && editingProduct.images.length > 1 && ( +
+ {editingProduct.images.slice(0, 5).map((img, idx) => ( + + ))} +
+ )} +
+
+
@@ -1332,13 +1377,25 @@ export default function AdminProductsPage() {
- + 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" /> + +
diff --git a/dripzone-app/src/app/api/catalog/collections/sync/route.ts b/dripzone-app/src/app/api/catalog/collections/sync/route.ts new file mode 100644 index 0000000..77bd6d1 --- /dev/null +++ b/dripzone-app/src/app/api/catalog/collections/sync/route.ts @@ -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 = {}; + 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 } + ); + } +} diff --git a/dripzone-app/src/app/api/catalog/feed.csv/route.ts b/dripzone-app/src/app/api/catalog/feed.csv/route.ts index 72b68c1..07551c8 100644 --- a/dripzone-app/src/app/api/catalog/feed.csv/route.ts +++ b/dripzone-app/src/app/api/catalog/feed.csv/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { dataService } from '@/lib/data-service'; import { generateWhatsAppCatalogCsv } from '@/lib/whatsapp-catalog'; import { getProductsFromAppwrite } from '@/lib/server-appwrite'; @@ -6,8 +6,11 @@ import { getProductsFromAppwrite } from '@/lib/server-appwrite'; export const dynamic = 'force-dynamic'; export const revalidate = 0; -export async function GET() { +export async function GET(req: NextRequest) { try { + const { searchParams } = new URL(req.url); + const filterCollection = searchParams.get('collection') || searchParams.get('category') || undefined; + let products = dataService.getProducts(); // 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); } - const csv = generateWhatsAppCatalogCsv(products); + const csv = generateWhatsAppCatalogCsv(products, false, filterCollection); return new Response(csv, { status: 200, diff --git a/dripzone-app/src/app/api/catalog/feed.xml/route.ts b/dripzone-app/src/app/api/catalog/feed.xml/route.ts index 0d71c7d..a63df17 100644 --- a/dripzone-app/src/app/api/catalog/feed.xml/route.ts +++ b/dripzone-app/src/app/api/catalog/feed.xml/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { dataService } from '@/lib/data-service'; import { generateWhatsAppCatalogXml } from '@/lib/whatsapp-catalog'; import { getProductsFromAppwrite } from '@/lib/server-appwrite'; @@ -6,8 +6,11 @@ import { getProductsFromAppwrite } from '@/lib/server-appwrite'; export const dynamic = 'force-dynamic'; export const revalidate = 0; -export async function GET() { +export async function GET(req: NextRequest) { try { + const { searchParams } = new URL(req.url); + const filterCollection = searchParams.get('collection') || searchParams.get('category') || undefined; + let products = dataService.getProducts(); // Sincronizar siempre con Appwrite para garantizar catálogo en tiempo real @@ -27,7 +30,7 @@ export async function GET() { } const settings = dataService.getSettings(); - const xml = generateWhatsAppCatalogXml(products, settings); + const xml = generateWhatsAppCatalogXml(products, settings, false, filterCollection); return new Response(xml, { status: 200, diff --git a/dripzone-app/src/app/api/webhook/whatsapp/route.ts b/dripzone-app/src/app/api/webhook/whatsapp/route.ts index 376d2aa..68473da 100644 --- a/dripzone-app/src/app/api/webhook/whatsapp/route.ts +++ b/dripzone-app/src/app/api/webhook/whatsapp/route.ts @@ -20,7 +20,7 @@ import { getProductsFromAppwrite, } from '@/lib/server-appwrite'; import { Product, SupplierPost } from '@/types'; -import { registerProductInWhatsAppCatalog } from '@/lib/whatsapp-catalog'; +import { registerProductInWhatsAppCatalog, resolveProductCollection } from '@/lib/whatsapp-catalog'; import { formatCOP } from '@/lib/pricing-engine'; // 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 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({ name: prodName, brand: prodBrand, - category: extracted.category || 'Zapatillas', + category: finalCategory, retailPrice, supplierPrice, forbiddenTerms: forbiddenContext, @@ -431,7 +439,7 @@ export async function POST(request: Request) { : ['37', '38', '39', '40', '41', '42', '43'], images: [relativeImageUrl], description: prodDescription, - category: extracted.category || 'Zapatillas', + category: finalCategory, inStock: true, isActive: true, rating: proof.rating, diff --git a/dripzone-app/src/app/page.tsx b/dripzone-app/src/app/page.tsx index fd2e59b..477c5a7 100644 --- a/dripzone-app/src/app/page.tsx +++ b/dripzone-app/src/app/page.tsx @@ -89,29 +89,39 @@ export default function StoreHomePage() { if (catLow === 'zapatillas') { return ( prodCat.includes('zapatilla') || + prodCat.includes('sneaker') || + prodCat.includes('tenis') || (!prodCat.includes('chancla') && - !prodCat.includes('slide') && + !prodCat.includes('sandalia') && !prodCat.includes('bolso') && !prodCat.includes('malet') && !prodCat.includes('morral') && !prodCat.includes('carriel') && !prodCat.includes('gorra') && + !prodCat.includes('ropa') && + !prodCat.includes('accesorio') && !prodName.includes('chancla') && + !prodName.includes('sandalia') && + !prodName.includes('gorra') && !prodName.includes('morral') && !prodName.includes('carriel')) ); } - if (catLow === 'chanclas') { + if (catLow === 'sandalias' || catLow === 'chanclas') { return ( + prodCat.includes('sandalia') || prodCat.includes('chancla') || prodCat.includes('slide') || - prodCat.includes('sandalia') || prodCat.includes('croc') || + prodName.includes('sandalia') || prodName.includes('chancla') || prodName.includes('slide') || prodName.includes('croc') ); } + if (catLow === 'gorras') { + return prodCat.includes('gorra') || prodCat.includes('cap') || prodName.includes('gorra'); + } if (catLow === 'bolsos') { return ( prodCat.includes('bolso') || @@ -142,8 +152,31 @@ export default function StoreHomePage() { prodName.includes('bandolera') ); } - if (catLow === 'gorras') { - return prodCat.includes('gorra') || prodCat.includes('cap') || prodName.includes('gorra'); + if (catLow === 'ropa') { + 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 diff --git a/dripzone-app/src/components/store/CategoryPills.tsx b/dripzone-app/src/components/store/CategoryPills.tsx index 2a14883..1083260 100644 --- a/dripzone-app/src/components/store/CategoryPills.tsx +++ b/dripzone-app/src/components/store/CategoryPills.tsx @@ -10,12 +10,14 @@ interface CategoryPillsProps { const CATEGORIES = [ { id: 'all', label: '🔥 Todo el Catálogo' }, + { id: 'Sandalias', label: '🩴 Sandalias' }, { 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: 'Bolsos', label: '👜 Bolsos' }, + { id: 'Carrieles', label: '👝 Carrieles' }, + { id: 'Maletines', label: '🎒 Maletines' }, + { id: 'Ropa', label: '👕 Ropa' }, + { id: 'Accesorios', label: '🕶️ Accesorios' }, { id: 'Nike', label: '⚡ Nike' }, { id: 'Adidas', label: '🛹 Adidas' }, { id: 'Jordan', label: '🏀 Jordan' }, diff --git a/dripzone-app/src/lib/gemini-parser.ts b/dripzone-app/src/lib/gemini-parser.ts index 2aee1f4..b4f7c5e 100644 --- a/dripzone-app/src/lib/gemini-parser.ts +++ b/dripzone-app/src/lib/gemini-parser.ts @@ -433,32 +433,32 @@ export function extractSupplierPrice(text: string): number { */ export interface CategoryDetection { category: string; - categoryKey: 'zapatillas' | 'chanclas' | 'bolsos' | 'maletines' | 'carrieles' | 'gorras' | 'ropa' | 'accesorios'; + categoryKey: 'zapatillas' | 'sandalias' | 'chanclas' | 'bolsos' | 'maletines' | 'carrieles' | 'gorras' | 'ropa' | 'accesorios'; icon: string; confidence: number; } export const OFFICIAL_CATEGORIES: { id: CategoryDetection['categoryKey']; label: string; icon: string }[] = [ { id: 'zapatillas', label: 'Zapatillas', icon: '👟' }, - { id: 'chanclas', label: 'Chanclas / Sandalias', icon: '🩴' }, - { id: 'bolsos', label: 'Bolsos / Carteras', icon: '👜' }, - { id: 'maletines', label: 'Maletines / Morrales', icon: '🎒' }, - { id: 'carrieles', label: 'Carrieles / Bandoleras', icon: '👝' }, - { id: 'gorras', label: 'Gorras / Caps', icon: '🧢' }, - { id: 'ropa', label: 'Ropa / Streetwear', icon: '👕' }, + { id: 'sandalias', label: 'Sandalias', icon: '🩴' }, + { id: 'gorras', label: 'Gorras', icon: '🧢' }, + { id: 'bolsos', label: 'Bolsos', icon: '👜' }, + { id: 'carrieles', label: 'Carrieles', icon: '👝' }, + { id: 'maletines', label: 'Maletines', icon: '🎒' }, + { id: 'ropa', label: 'Ropa', icon: '👕' }, { id: 'accesorios', label: 'Accesorios', icon: '🕶️' }, ]; export function detectCategory(text: string, title?: string): CategoryDetection { const full = `${title || ''} ${text || ''}`.toLowerCase(); - // 1. Chanclas / Sandalias / Slides / Crocs + // 1. Sandalias / Chanclas / Slides / Crocs 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 ) ) { - 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 diff --git a/dripzone-app/src/lib/whatsapp-catalog.ts b/dripzone-app/src/lib/whatsapp-catalog.ts index 28586c7..861ba5c 100644 --- a/dripzone-app/src/lib/whatsapp-catalog.ts +++ b/dripzone-app/src/lib/whatsapp-catalog.ts @@ -5,9 +5,171 @@ import { formatCOP } from './pricing-engine'; 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 = { + 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): 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 - * para que Meta y WhatsApp puedan descargarla e indexarla en alta resolución. */ export function resolveAbsoluteImageUrl(imagePath?: string): string { if (!imagePath) { @@ -21,13 +183,8 @@ export function resolveAbsoluteImageUrl(imagePath?: string): string { } /** - * Formatea los datos del producto con las especificaciones exactas requeridas por - * el Catálogo de WhatsApp Business y Meta Commerce Manager: - * 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 + * Formatea los datos del producto con las especificaciones de Meta Commerce & WhatsApp Catalog + * agrupándolo en su correspondiente Colección mediante custom_label_0 y product_type. */ export function formatProductForWhatsAppCatalog(product: Product) { const brand = product.brand || 'Sneakers'; @@ -48,9 +205,13 @@ export function formatProductForWhatsAppCatalog(product: Product) { 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 = ( - `${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` + `💎 Precio oficial: ${formattedPrice} COP\n` + `🚚 Pago Contraentrega disponible en toda Colombia\n` + @@ -78,29 +239,50 @@ export function formatProductForWhatsAppCatalog(product: Product) { additional_image_links: additionalImages, sizes: sizesList, 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, }; } /** - * Genera el Feed XML en formato estándar Google Merchant / Facebook Commerce RSS - * compatible con Meta Commerce Manager para sincronización programada del catálogo de WhatsApp. - * Si includeInactive es false (default), solo exporta productos activos para que solo - * los calzados activos aparezcan disponibles para compra en WhatsApp. + * Genera el Feed XML en formato Google / Meta Merchant RSS 2.0. + * Permite filtrar por una Colección específica si se requiere (ej. Sandalias, Zapatillas, Gorras). */ export function generateWhatsAppCatalogXml( products: Product[], storeSettings?: StoreSettings, - includeInactive = false + includeInactive = false, + filterCollection?: string ): string { 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.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 .map((product) => { const item = formatProductForWhatsAppCatalog(product); @@ -121,10 +303,13 @@ ${additionalImagesXml} ${item.availability === 'in stock' ? 'in_stock' : 'out_of_stock'} ${item.priceAmount} COP - - - - + + + + + + + `; }) .join(''); @@ -132,7 +317,7 @@ ${additionalImagesXml} return ` - <![CDATA[${storeName} - Catálogo Oficial de WhatsApp]]> + <![CDATA[${storeName} - Catálogo de WhatsApp${filterCollection ? ` (${filterCollection})` : ''}]]> ${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 = [ 'id', 'title', @@ -156,12 +346,27 @@ export function generateWhatsAppCatalogCsv(products: Product[], includeInactive 'additional_image_link', 'brand', '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.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 item = formatProductForWhatsAppCatalog(product); return [ @@ -176,6 +381,13 @@ export function generateWhatsAppCatalogCsv(products: Product[], includeInactive `"${item.additional_image_links.join(',')}"`, `"${item.brand.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(','); }); @@ -184,8 +396,7 @@ export function generateWhatsAppCatalogCsv(products: Product[], includeInactive /** * 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. - * Si el producto está inactivo (isActive: false), se envía availability: 'out of stock'. + * enviando sus atributos de colección (custom_label_0, product_type, google_product_category). */ export async function syncProductToMetaCatalogApi( product: Product, @@ -195,12 +406,10 @@ export async function syncProductToMetaCatalogApi( const catalogId = settings?.metaCatalogId || process.env.META_CATALOG_ID; 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) { return { 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, brand: item.brand, 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 - * producto en la app móvil y web. Garantiza que el producto se registre o actualice - * en el Catálogo de WhatsApp de DripZone. + * producto en la app móvil y web. Garantiza que el producto se registre en su Colección en WhatsApp. */ export async function registerProductInWhatsAppCatalog( product: Product @@ -266,11 +541,12 @@ export async function registerProductInWhatsAppCatalog( dataService.updateProduct(product.id, { whatsappCatalogSynced: true, whatsappCatalogSyncAt: new Date().toISOString(), + category: catalogItem.collection, // Asegurar persistencia de la colección normalizada } as any); } 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 { diff --git a/scripts/build_apk.py b/scripts/build_apk.py new file mode 100644 index 0000000..99d30a3 --- /dev/null +++ b/scripts/build_apk.py @@ -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:]) diff --git a/scripts/check_categories.py b/scripts/check_categories.py new file mode 100644 index 0000000..6cadbec --- /dev/null +++ b/scripts/check_categories.py @@ -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')})") diff --git a/scripts/check_evolution_container.py b/scripts/check_evolution_container.py new file mode 100644 index 0000000..ab9cfe0 --- /dev/null +++ b/scripts/check_evolution_container.py @@ -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() diff --git a/scripts/check_evolution_state.py b/scripts/check_evolution_state.py new file mode 100644 index 0000000..2d61078 --- /dev/null +++ b/scripts/check_evolution_state.py @@ -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) diff --git a/scripts/check_status.py b/scripts/check_status.py new file mode 100644 index 0000000..1fb82e3 --- /dev/null +++ b/scripts/check_status.py @@ -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) diff --git a/scripts/check_vps_logs.py b/scripts/check_vps_logs.py new file mode 100644 index 0000000..c3150fd --- /dev/null +++ b/scripts/check_vps_logs.py @@ -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() diff --git a/scripts/deploy_collections_update.py b/scripts/deploy_collections_update.py new file mode 100644 index 0000000..b9b8e0f --- /dev/null +++ b/scripts/deploy_collections_update.py @@ -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() diff --git a/scripts/final_system_check.py b/scripts/final_system_check.py new file mode 100644 index 0000000..41aabdb --- /dev/null +++ b/scripts/final_system_check.py @@ -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("") + print(f" -> Total productos en XML: {item_count}") + if "" in content: + print(" -> Estructura XML: VÁLIDA (RSS 2.0 / Google & Meta Merchant Feed)") + first_item = content.split("")[1].split("")[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("=================================================================") diff --git a/scripts/migrate_collections_appwrite.py b/scripts/migrate_collections_appwrite.py new file mode 100644 index 0000000..9a03684 --- /dev/null +++ b/scripts/migrate_collections_appwrite.py @@ -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}")