From 5b0c675431d74d9bfe659ad51ded98baeb2f21bf Mon Sep 17 00:00:00 2001 From: AI Bot Date: Fri, 28 Aug 2026 10:59:38 -0500 Subject: [PATCH] feat: sincronizacion en vivo de whatsapp feed, seleccion masiva de calzados, proteccion de boton admin por roles y catalogo limpio --- .../src/app/admin/feed-whatsapp/page.tsx | 849 +++++--- almeira-store-app/src/app/admin/layout.tsx | 48 +- .../src/app/api/whatsapp-feed/route.ts | 247 +++ almeira-store-app/src/app/page.tsx | 10 +- .../components/mobile/MobileBottomDock.tsx | 109 +- .../src/components/store/Navbar.tsx | 20 +- almeira-store-app/src/types/index.ts | 3 +- .../src/app/admin/feed-whatsapp/page.tsx | 857 +++++--- dripzone-app/src/app/admin/layout.tsx | 48 +- .../src/app/api/whatsapp-feed/route.ts | 255 +++ dripzone-app/src/app/checkout/page.tsx | 26 +- dripzone-app/src/app/page.tsx | 50 +- .../components/mobile/MobileBottomDock.tsx | 112 +- dripzone-app/src/components/store/Navbar.tsx | 20 +- dripzone-app/src/lib/data-service.ts | 1922 +---------------- dripzone-app/src/types/index.ts | 3 +- scripts/send_apk_to_whatsapp.js | 2 +- .../src/app/admin/feed-whatsapp/page.tsx | 851 +++++--- sneaky-app/src/app/admin/layout.tsx | 48 +- sneaky-app/src/app/api/whatsapp-feed/route.ts | 247 +++ sneaky-app/src/app/page.tsx | 54 +- .../components/mobile/MobileBottomDock.tsx | 109 +- sneaky-app/src/components/store/Navbar.tsx | 20 +- sneaky-app/src/types/index.ts | 3 +- .../src/app/admin/feed-whatsapp/page.tsx | 851 +++++--- zapply-app/src/app/admin/layout.tsx | 48 +- zapply-app/src/app/api/whatsapp-feed/route.ts | 247 +++ zapply-app/src/app/page.tsx | 14 +- .../components/mobile/MobileBottomDock.tsx | 112 +- zapply-app/src/components/store/Navbar.tsx | 20 +- zapply-app/src/types/index.ts | 3 +- 31 files changed, 3834 insertions(+), 3374 deletions(-) create mode 100644 almeira-store-app/src/app/api/whatsapp-feed/route.ts create mode 100644 dripzone-app/src/app/api/whatsapp-feed/route.ts create mode 100644 sneaky-app/src/app/api/whatsapp-feed/route.ts create mode 100644 zapply-app/src/app/api/whatsapp-feed/route.ts diff --git a/almeira-store-app/src/app/admin/feed-whatsapp/page.tsx b/almeira-store-app/src/app/admin/feed-whatsapp/page.tsx index a1866cb..bd23608 100644 --- a/almeira-store-app/src/app/admin/feed-whatsapp/page.tsx +++ b/almeira-store-app/src/app/admin/feed-whatsapp/page.tsx @@ -1,365 +1,680 @@ 'use client'; -import React, { useState } from 'react'; -import { Radio, Sparkles, Share2, AlertTriangle, ShieldCheck, Plus, Send, Layers } from 'lucide-react'; -import { SupplierPost, Product } from '@/types'; -import { INITIAL_SUPPLIER_POSTS, INITIAL_PRODUCTS } from '@/lib/data-service'; -import { checkProductDuplicate } from '@/lib/deduplication-engine'; +import React, { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { + Radio, + Sparkles, + RefreshCw, + Search, + CheckCircle2, + XCircle, + Plus, + Send, + Layers, + ShoppingBag, + ExternalLink, + MessageSquare, + ShieldCheck, + Zap, + Filter, + CheckSquare, + Square, + Edit3, + Trash2, + ArrowUpRight, +} from 'lucide-react'; +import { SupplierPost, Product, MonitoredWhatsAppGroup } from '@/types'; import { formatCOP } from '@/lib/pricing-engine'; import { BRANDS, BrandId } from '@/config/brands'; export default function SupplierFeedPage() { - const [posts, setPosts] = useState(INITIAL_SUPPLIER_POSTS); - const [products, setProducts] = useState(INITIAL_PRODUCTS); - const [isProcessing, setIsProcessing] = useState(false); + const [posts, setPosts] = useState([]); + const [monitoredGroups, setMonitoredGroups] = useState([]); + const [selectedGroupFilter, setSelectedGroupFilter] = useState('all'); + const [tabStatus, setTabStatus] = useState<'pending' | 'published' | 'discarded'>('pending'); + const [isLoading, setIsLoading] = useState(false); + const [isSyncingLive, setIsSyncingLive] = useState(false); const [actionMessage, setActionMessage] = useState(null); + // Multi-selection state + const [selectedPostIds, setSelectedPostIds] = useState>(new Set()); + + // Edit Modal State + const [editingPost, setEditingPost] = useState(null); + const [editName, setEditName] = useState(''); + const [editBrand, setEditBrand] = useState(''); + const [editPrice, setEditPrice] = useState(120000); + const [editSizes, setEditSizes] = useState(''); + // Target brand selector for publishing const [targetBrand, setTargetBrand] = useState('almeira'); - // Simulación de nuevo mensaje entrante de grupo para probar en vivo - const [testText, setTestText] = useState( - '🔥 LLEGANDO A BODEGA HOY 🔥\nNike Air Max 90 White Black\nTallas: 38 a 43\nMayorista: $90.000 c/u\nEnvío inmediato contraentrega' - ); - const [testGroup, setTestGroup] = useState('Canal Mayorista A'); - - const handleSimulateIncoming = async () => { - setIsProcessing(true); + // Cargar grupos monitoreados y posts iniciales + const loadInitialData = async () => { + setIsLoading(true); try { - const res = await fetch('/api/webhook/whatsapp', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - text: testText, - groupInfo: { subject: testGroup }, - }), - }); - const data = await res.json(); - if (data.success && data.post) { - setPosts((prev) => [data.post, ...prev]); - setActionMessage('✅ Nuevo mensaje de WhatsApp procesado con IA.'); + const grpRes = await fetch('/api/whatsapp-groups'); + const grpData = await grpRes.json(); + if (grpData.success && Array.isArray(grpData.groups)) { + setMonitoredGroups(grpData.groups); + } + + const feedRes = await fetch('/api/whatsapp-feed'); + const feedData = await feedRes.json(); + if (feedData.success && Array.isArray(feedData.posts)) { + setPosts(feedData.posts); } } catch (err) { - console.error(err); + console.error('Error cargando feed:', err); } finally { - setIsProcessing(false); + setIsLoading(false); } }; - const handlePublishFacebook = async (post: SupplierPost) => { - if (!post.extractedProduct) return; - setIsProcessing(true); + useEffect(() => { + loadInitialData(); + }, []); + + // Sincronizar mensajes en vivo desde Evolution API + const handleSyncLive = async () => { + setIsSyncingLive(true); + setActionMessage(null); try { - const brandName = targetBrand === 'all' ? 'Todas las Tiendas' : BRANDS[targetBrand].name; - const res = await fetch('/api/publish/facebook', { + const url = + selectedGroupFilter !== 'all' + ? `/api/whatsapp-feed?fetchLive=true&groupJid=${encodeURIComponent(selectedGroupFilter)}` + : '/api/whatsapp-feed?fetchLive=true&limit=40'; + + const res = await fetch(url); + const data = await res.json(); + if (data.success && Array.isArray(data.posts)) { + setPosts(data.posts); + setActionMessage( + `✅ ¡Sincronización completa! Se escanearon ${data.totalFound || data.posts.length} mensajes recientes de WhatsApp.` + ); + } else { + setActionMessage('⚠️ No se encontraron mensajes nuevos en los grupos seleccionados.'); + } + } catch (err: any) { + console.error(err); + setActionMessage('❌ Error conectando con Evolution API para traer mensajes.'); + } finally { + setIsSyncingLive(false); + } + }; + + // Publicar un producto a la tienda + const handlePublishPost = async ( + post: SupplierPost, + customData?: { name?: string; brand?: string; retailPrice?: number; sizes?: string[] } + ) => { + try { + const res = await fetch('/api/whatsapp-feed', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - productName: post.extractedProduct.name, - brand: post.extractedProduct.brand, - retailPrice: post.extractedProduct.suggestedRetailPrice, - sizes: post.extractedProduct.sizes, - images: post.rawImages, + action: 'publish', + post, + customName: customData?.name, + customRetailPrice: customData?.retailPrice, + customSizes: customData?.sizes, }), }); const data = await res.json(); if (data.success) { - setActionMessage(`🚀 ¡Publicado en Facebook bajo la marca "${brandName}"! (ID: ${data.postId})`); + setPosts((prev) => + prev.map((p) => (p.id === post.id ? { ...p, status: 'published' } : p)) + ); + setActionMessage(`🚀 ¡"${data.product.name}" ha sido publicado en la App Móvil y Web!`); + if (editingPost?.id === post.id) setEditingPost(null); } } catch (e) { console.error(e); - } finally { - setIsProcessing(false); + setActionMessage('❌ Error al publicar producto.'); } }; - const handleAddToStore = async (post: SupplierPost) => { - if (!post.extractedProduct) return; - const brandLabel = targetBrand === 'all' ? 'Multi-Marca' : BRANDS[targetBrand].shortName; - const newProduct: Product = { - id: `snk-${Date.now()}`, - name: post.extractedProduct.name, - brand: post.extractedProduct.brand, - supplierPrice: post.extractedProduct.supplierPrice, - retailPrice: post.extractedProduct.suggestedRetailPrice, - originalPrice: Math.round(post.extractedProduct.suggestedRetailPrice * 1.35), - sizes: post.extractedProduct.sizes, - images: post.rawImages, - description: `Zapatillas calidad premium garantizada para entrega inmediata.`, - category: post.extractedProduct.category, - inStock: true, - isActive: true, - originBadge: `Bodega Central`, - supplierName: post.supplierName || post.channelName || 'DIFUSION JOVIC STORE', - supplierPhone: post.supplierPhone || '573134848203', - }; + // Publicación masiva de los seleccionados + const handleBulkPublish = async () => { + const selectedPosts = posts.filter( + (p) => selectedPostIds.has(p.id) && p.status !== 'published' + ); + if (selectedPosts.length === 0) return; + setIsLoading(true); try { - const res = await fetch('/api/products', { + const res = await fetch('/api/whatsapp-feed', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(newProduct), + body: JSON.stringify({ + action: 'bulk-publish', + posts: selectedPosts, + }), }); const data = await res.json(); if (data.success) { - setProducts((prev) => [data.product, ...prev]); - setActionMessage(`✨ "${newProduct.name}" habilitado en el catálogo para ${brandLabel}.`); + setPosts((prev) => + prev.map((p) => (selectedPostIds.has(p.id) ? { ...p, status: 'published' } : p)) + ); + setSelectedPostIds(new Set()); + setActionMessage( + `🎉 ¡Éxito! Se han publicado ${data.products?.length || selectedPosts.length} zapatillas en la App Móvil y Web.` + ); } } catch (e) { console.error(e); + setActionMessage('❌ Error en publicación masiva.'); + } finally { + setIsLoading(false); } }; + // Descartar mensaje + const handleDiscardPost = async (post: SupplierPost) => { + try { + await fetch('/api/whatsapp-feed', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'discard', post }), + }); + setPosts((prev) => + prev.map((p) => (p.id === post.id ? { ...p, status: 'discarded' } : p)) + ); + setSelectedPostIds((prev) => { + const next = new Set(prev); + next.delete(post.id); + return next; + }); + } catch (e) { + console.error(e); + } + }; + + const handleToggleSelect = (id: string) => { + setSelectedPostIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const filteredPosts = posts.filter((p) => { + if (tabStatus === 'pending' && p.status === 'published') return false; + if (tabStatus === 'pending' && p.status === 'discarded') return false; + if (tabStatus === 'published' && p.status !== 'published') return false; + if (tabStatus === 'discarded' && p.status !== 'discarded') return false; + + if (selectedGroupFilter !== 'all') { + return (p as any).channelJid === selectedGroupFilter || p.channelName === selectedGroupFilter; + } + return true; + }); + + const pendingCount = posts.filter((p) => p.status !== 'published' && p.status !== 'discarded').length; + const publishedCount = posts.filter((p) => p.status === 'published').length; + const discardedCount = posts.filter((p) => p.status === 'discarded').length; + + const handleOpenEdit = (post: SupplierPost) => { + setEditingPost(post); + setEditName(post.extractedProduct?.name || 'Zapatilla Urbana'); + setEditBrand(post.extractedProduct?.brand || 'Nike'); + setEditPrice( + post.extractedProduct?.suggestedRetailPrice || + (post.extractedProduct?.supplierPrice ? post.extractedProduct.supplierPrice + 35000 : 125000) + ); + setEditSizes(post.extractedProduct?.sizes?.join(', ') || '38, 39, 40, 41, 42, 43'); + }; + return (
{/* Header */} -
+
-

- - Feed de Entrada & Publicador Multi-Marca -

-

- Recepción automática con IA, prevención de duplicados y distribución hacia las 4 marcas (Almeira Store, Sneaky, Zapply, DripZone). -

+
+
+ +
+
+

Feed de Mensajes de WhatsApp • Almeira Store

+

+ Visualiza los mensajes de tus grupos de proveedores y selecciona los calzados que deseas publicar en la app móvil. +

+
+
- {/* Target Brand Selector Pill Bar */} -
- - Destino: - - - {Object.values(BRANDS).map((b) => ( - - ))}
+ {/* Toast feedback */} {actionMessage && ( -
- {actionMessage} -
)} - {/* Live Simulator for Test Messages */} -
-
- - Simulador de Recepción en Tiempo Real + {/* Filter & Tabs Bar */} +
+ {/* Status Tabs */} +
+ + + + +
-
-
- - -
- -
- -
- setTestText(e.target.value)} - className="flex-1 bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden" - /> - -
-
+ {/* Group Selector Filter */} +
+ +
- {/* Intercepted Posts Feed */} -
-

Mensajes Procesados Recientemente

+ {/* Multi-Selection Bulk Actions Bar */} + {selectedPostIds.size > 0 && tabStatus === 'pending' && ( +
+
+ + {selectedPostIds.size} seleccionados + + + Listos para transferir al catálogo de la tienda + +
-
- {posts.map((post) => { +
+ + + +
+
+ )} + + {/* Posts List / Grid */} + {filteredPosts.length === 0 ? ( +
+
+ +
+
+

No hay mensajes en esta bandeja

+

+ Haz clic en "Sincronizar WhatsApp en Vivo" para extraer automáticamente las ofertas recientes de tus grupos seleccionados. +

+
+ +
+ ) : ( +
+ {filteredPosts.map((post) => { const extracted = post.extractedProduct; - const duplicateCheck = extracted - ? checkProductDuplicate( - { - name: extracted.name, - brand: extracted.brand, - sizes: extracted.sizes, - supplierPrice: extracted.supplierPrice, - }, - products - ) - : null; + const isSelected = selectedPostIds.has(post.id); + const isPublished = post.status === 'published'; + const isDiscarded = post.status === 'discarded'; return (
- {/* Header of post */} -
-
- - {post.channelName || 'Canal Mayorista'} - - {post.receivedAt} + {/* Header info */} +
+
+ {tabStatus === 'pending' && ( + + )} + +
+ + {post.channelName || 'Grupo WhatsApp'} + + {post.receivedAt} +
- {/* Anti-Duplicate Status Badge */} - {duplicateCheck && ( -
- {duplicateCheck.isDuplicate ? ( - <> - - Duplicado Detectado ({Math.round(duplicateCheck.similarityScore * 100)}% Similitud) - - ) : ( - <> - - Producto Nuevo (0% Duplicados) - - )} -
- )} +
+ {isPublished ? ( + + Publicado en Tienda + + ) : isDiscarded ? ( + + Descartado + + ) : ( + + Pendiente de Aprobación + + )} +
- {/* Content Grid */} -
- {/* Left: Raw WhatsApp Message (5 cols) */} -
- Mensaje de Entrada: -

- {post.rawMessage} -

- {post.rawImages.length > 0 && ( -
+ {/* Content Grid (Photo + AI extracted info) */} +
+ {/* Sneaker Image & Raw text */} +
+
+ {post.rawImages && post.rawImages[0] ? ( -
- )} + ) : ( +
Sin foto
+ )} +
+ +
+

+ {post.rawMessage} +

+
- {/* Right: AI Extracted & Pricing Calculation (7 cols) */} -
- {extracted ? ( -
-
-
- - {extracted.brand} • {extracted.category} - -

{extracted.name}

-
- - IA: {Math.round(extracted.confidence * 100)}% certeza - -
+ {/* AI Extraction & Product Info */} +
+
+
+ + {extracted?.brand || 'Nike'} • {extracted?.category || 'Sneakers'} + +

+ {extracted?.name || 'Zapatilla Urbana'} +

+
- {/* Pricing breakdown */} -
-
- Costo Base: - - {formatCOP(extracted.supplierPrice)} - -
-
- Precio Venta (+ Margen): - - {formatCOP(extracted.suggestedRetailPrice)} - -
-
- - {/* Sizes */} + {/* Pricing breakdown */} +
- - Tallas extraídas: + Costo Proveedor: + + {formatCOP(extracted?.supplierPrice || 90000)} -
- {extracted.sizes.map((s) => ( - - {s} - - ))} -
- - {/* Duplicate warning note */} - {duplicateCheck?.isDuplicate && ( -
- {duplicateCheck.message} -
- )} - - {/* Actions */} -
- - - +
+ Venta Sugerida: + + {formatCOP(extracted?.suggestedRetailPrice || 125000)} +
- ) : ( -

Extrayendo datos...

- )} + + {/* Sizes badge pills */} +
+ + Tallas Disponibles: + +
+ {extracted?.sizes && extracted.sizes.length > 0 ? ( + extracted.sizes.map((s) => ( + + {s} + + )) + ) : ( + 37 a 43 + )} +
+
+
+ + {/* Card Actions */} +
+ {!isPublished ? ( + <> + + + + + + + ) : ( +
+ + Disponible para clientes + + + Ver en Tienda + + +
+ )} +
); })}
-
+ )} + + {/* Edit Modal Before Publishing */} + {editingPost && ( +
+
+
+

+ + Personalizar antes de Publicar +

+ +
+ +
+
+ + setEditName(e.target.value)} + className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white font-bold outline-hidden focus:border-cyan-500" + /> +
+ +
+
+ + setEditBrand(e.target.value)} + className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-cyan-500" + /> +
+ +
+ + setEditPrice(Number(e.target.value))} + className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-emerald-300 font-black font-mono outline-hidden focus:border-emerald-500" + /> +
+
+ +
+ + setEditSizes(e.target.value)} + placeholder="38, 39, 40, 41, 42, 43" + className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-cyan-500" + /> +
+
+ +
+ + + +
+
+
+ )}
); } diff --git a/almeira-store-app/src/app/admin/layout.tsx b/almeira-store-app/src/app/admin/layout.tsx index ff0624d..9509f86 100644 --- a/almeira-store-app/src/app/admin/layout.tsx +++ b/almeira-store-app/src/app/admin/layout.tsx @@ -11,15 +11,20 @@ import { CreditCard, ShoppingBag, ArrowLeft, - Sparkles, Store, Users, MessageSquare, Building2, + ShieldAlert, + LogIn, } from 'lucide-react'; +import { useAuth } from '@/context/AuthContext'; +import { AuthModal } from '@/components/auth/AuthModal'; export default function AdminLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + const { user, openAuthModal } = useAuth(); + const isAdmin = Boolean(user && (user.role === 'admin' || user.role === 'operator')); const NAV_ITEMS = [ { href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard }, @@ -34,6 +39,45 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) { href: '/admin/pedidos', label: 'Pedidos & Logística', icon: ShoppingBag }, ]; + if (!isAdmin) { + return ( +
+
+
+ +
+ +
+

Acceso Restringido para Administradores

+

+ Este panel de control es de uso exclusivo para el equipo administrativo y operadores logísticos autorizados. +

+
+ +
+ + + + + Volver a la Tienda + +
+
+ + +
+ ); + } + return (
{/* Sidebar */} @@ -96,6 +140,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{children}
+ +
); } diff --git a/almeira-store-app/src/app/api/whatsapp-feed/route.ts b/almeira-store-app/src/app/api/whatsapp-feed/route.ts new file mode 100644 index 0000000..8dbf018 --- /dev/null +++ b/almeira-store-app/src/app/api/whatsapp-feed/route.ts @@ -0,0 +1,247 @@ +import { NextResponse } from 'next/server'; +import { dataService } from '@/lib/data-service'; +import { parseSupplierMessage } from '@/lib/gemini-parser'; +import { SupplierPost, MonitoredWhatsAppGroup } from '@/types'; + +const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; +const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943'; +const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const fetchLive = searchParams.get('fetchLive') === 'true'; + const limit = parseInt(searchParams.get('limit') || '30', 10); + const filterJid = searchParams.get('groupJid'); + + // 1. Obtener grupos monitoreados locales + const monitoredGroups: MonitoredWhatsAppGroup[] = dataService.getMonitoredGroups(); + const monitoredJids = new Set( + monitoredGroups.filter((g) => g.isMonitored).map((g) => g.jid) + ); + + // Si no se pide en vivo, retornar lo almacenado localmente + if (!fetchLive) { + let posts = dataService.getSupplierPosts(); + if (filterJid) { + posts = posts.filter((p) => (p as any).channelJid === filterJid || p.channelName?.includes(filterJid)); + } + return NextResponse.json({ + success: true, + posts, + monitoredCount: monitoredJids.size, + }); + } + + // 2. Consultar Evolution API para obtener los mensajes recientes de los grupos + const msgUrl = `${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_INSTANCE_NAME}?page=1&limit=${limit}`; + const msgRes = await fetch(msgUrl, { + method: 'POST', + headers: { + apikey: EVOLUTION_API_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(filterJid ? { where: { key: { remoteJid: filterJid } } } : {}), + }); + + const msgData = await msgRes.json(); + const records = msgData.messages?.records || msgData.records || (Array.isArray(msgData) ? msgData : []); + + // 3. Mapear grupos para resolver nombres + const groupsMap = new Map(); + monitoredGroups.forEach((g) => groupsMap.set(g.jid, g.name)); + + if (groupsMap.size === 0) { + try { + const grpRes = await fetch( + `${EVOLUTION_API_URL}/group/fetchAllGroups/${EVOLUTION_INSTANCE_NAME}?getParticipants=false`, + { headers: { apikey: EVOLUTION_API_KEY } } + ); + const grpData = await grpRes.json(); + if (Array.isArray(grpData)) { + grpData.forEach((g: any) => { + const jid = g.id || g.jid; + const name = g.subject || g.name || 'Grupo WhatsApp'; + groupsMap.set(jid, name); + }); + } + } catch (e) {} + } + + const processedPosts: SupplierPost[] = []; + + for (const record of records) { + const remoteJid = record.key?.remoteJid || ''; + if (!remoteJid.includes('@g.us')) continue; + + if (monitoredJids.size > 0 && !filterJid && !monitoredJids.has(remoteJid)) { + continue; + } + + const text = + record.message?.conversation || + record.message?.extendedTextMessage?.text || + record.message?.imageMessage?.caption || + record.message?.videoMessage?.caption || + ''; + + const groupName = groupsMap.get(remoteJid) || record.pushName || 'Grupo Mayorista'; + const hasImage = Boolean(record.message?.imageMessage); + + if (!text && !hasImage) continue; + + let imageUrls: string[] = []; + + if (hasImage) { + try { + const mediaUrl = `${EVOLUTION_API_URL}/chat/getBase64FromMediaMessage/${EVOLUTION_INSTANCE_NAME}`; + const mediaRes = await fetch(mediaUrl, { + method: 'POST', + headers: { + apikey: EVOLUTION_API_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ message: record, convertToMp4: false }), + }); + const mediaData = await mediaRes.json(); + if (mediaData.base64) { + const mime = mediaData.mimetype || 'image/jpeg'; + imageUrls.push(`data:${mime};base64,${mediaData.base64}`); + } + } catch (mediaErr) { + console.warn('Error descargando imagen de WhatsApp:', mediaErr); + } + } + + if (imageUrls.length === 0) { + imageUrls.push('https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'); + } + + const extracted = await parseSupplierMessage(text || groupName); + + const timestamp = record.messageTimestamp + ? new Date(record.messageTimestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : 'Reciente'; + + const post: SupplierPost = { + id: `post-${record.key?.id || Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + channelName: groupName, + channelJid: remoteJid, + rawMessage: text || `[Foto de Calzado enviada en ${groupName}]`, + rawImages: imageUrls, + extractedProduct: extracted, + status: 'pending_review', + receivedAt: timestamp, + supplierName: groupName, + supplierPhone: record.key?.participant?.replace(/\D/g, '') || '573000000000', + }; + + processedPosts.push(post); + } + + if (processedPosts.length > 0) { + dataService.saveSupplierPosts(processedPosts); + } + + return NextResponse.json({ + success: true, + isLive: true, + totalFound: processedPosts.length, + posts: dataService.getSupplierPosts(), + }); + } catch (error: any) { + console.error('Error en /api/whatsapp-feed:', error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { action, post, posts, customRetailPrice, customName, customSizes } = body; + + // 1. PUBLICAR UN PRODUCTO A LA TIENDA (APP MÓVIL Y WEB) + if (action === 'publish' && post) { + const retailPrice = + customRetailPrice || + post.extractedProduct?.suggestedRetailPrice || + (post.extractedProduct?.supplierPrice ? post.extractedProduct.supplierPrice + 35000 : 125000); + + const newProduct = { + id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + name: customName || post.extractedProduct?.name || 'Zapatillas Urbanas', + brand: post.extractedProduct?.brand || 'Nike', + supplierPrice: post.extractedProduct?.supplierPrice || 90000, + retailPrice: retailPrice, + originalPrice: Math.round(retailPrice * 1.35), + sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], + images: Array.isArray(post.rawImages) && post.rawImages.length > 0 ? post.rawImages : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], + description: `Calzado garantizado de alta calidad para despacho inmediato en 24 horas y pago contraentrega.`, + category: post.extractedProduct?.category || 'Calzado / Sneakers', + inStock: true, + isActive: true, + originBadge: `Bodega ${post.channelName || 'Cali'}`, + supplierName: post.supplierName || post.channelName || 'Proveedor Mayorista', + supplierPhone: post.supplierPhone || '573167455877', + }; + + dataService.addProduct(newProduct); + dataService.updateSupplierPostStatus(post.id, 'published'); + + return NextResponse.json({ + success: true, + message: '¡Producto publicado exitosamente en la App Móvil y Web!', + product: newProduct, + }); + } + + // 2. PUBLICACIÓN MASIVA (BULK) + if (action === 'bulk-publish' && Array.isArray(posts)) { + const createdProducts = []; + for (const p of posts) { + const retailPrice = + p.extractedProduct?.suggestedRetailPrice || + (p.extractedProduct?.supplierPrice ? p.extractedProduct.supplierPrice + 35000 : 125000); + + const newProd = { + id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + name: p.extractedProduct?.name || 'Zapatillas Urbanas', + brand: p.extractedProduct?.brand || 'Nike', + supplierPrice: p.extractedProduct?.supplierPrice || 90000, + retailPrice, + originalPrice: Math.round(retailPrice * 1.35), + sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], + images: p.rawImages || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], + description: `Calzado garantizado para despacho inmediato en 24 horas con pago contraentrega.`, + category: p.extractedProduct?.category || 'Calzado / Sneakers', + inStock: true, + isActive: true, + originBadge: `Bodega ${p.channelName || 'Cali'}`, + supplierName: p.supplierName || p.channelName || 'Proveedor Mayorista', + supplierPhone: p.supplierPhone || '573167455877', + }; + + dataService.addProduct(newProd); + dataService.updateSupplierPostStatus(p.id, 'published'); + createdProducts.push(newProd); + } + + return NextResponse.json({ + success: true, + message: `¡Se publicaron ${createdProducts.length} productos en la App Móvil y Web!`, + products: createdProducts, + }); + } + + // 3. DESCARTAR POST + if (action === 'discard' && post?.id) { + dataService.updateSupplierPostStatus(post.id, 'discarded'); + return NextResponse.json({ success: true, message: 'Mensaje descartado' }); + } + + return NextResponse.json({ success: false, error: 'Acción no válida' }, { status: 400 }); + } catch (err: any) { + console.error('Error en POST /api/whatsapp-feed:', err); + return NextResponse.json({ success: false, error: err.message }, { status: 500 }); + } +} diff --git a/almeira-store-app/src/app/page.tsx b/almeira-store-app/src/app/page.tsx index e4b60ff..5b27077 100644 --- a/almeira-store-app/src/app/page.tsx +++ b/almeira-store-app/src/app/page.tsx @@ -9,7 +9,6 @@ import { ProductModal } from '@/components/store/ProductModal'; import { CartDrawer } from '@/components/store/CartDrawer'; import { CitySelectorModal } from '@/components/store/CitySelectorModal'; import { MobileBottomDock } from '@/components/mobile/MobileBottomDock'; -import { MobileInstallBanner } from '@/components/mobile/MobileInstallBanner'; import { AuthModal } from '@/components/auth/AuthModal'; import { Product, City, CartItem } from '@/types'; import { INITIAL_PRODUCTS, INITIAL_CITIES } from '@/lib/data-service'; @@ -28,7 +27,6 @@ export default function StoreHomePage() { const [selectedProduct, setSelectedProduct] = useState(null); const [isCartOpen, setIsCartOpen] = useState(false); const [isCityModalOpen, setIsCityModalOpen] = useState(false); - const [isInstallModalOpen, setIsInstallModalOpen] = useState(false); const [cartItems, setCartItems] = useState([]); const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0); @@ -265,16 +263,10 @@ export default function StoreHomePage() { onSelectCity={setSelectedCity} /> - {/* Mobile App Dock & Install Banner */} + {/* Mobile App Dock */} setIsCartOpen(true)} - onOpenInstallModal={() => setIsInstallModalOpen(true)} - /> - - setIsInstallModalOpen(false)} /> {/* User Auth Modal */} diff --git a/almeira-store-app/src/components/mobile/MobileBottomDock.tsx b/almeira-store-app/src/components/mobile/MobileBottomDock.tsx index 06fe92e..a85e0fe 100644 --- a/almeira-store-app/src/components/mobile/MobileBottomDock.tsx +++ b/almeira-store-app/src/components/mobile/MobileBottomDock.tsx @@ -1,68 +1,28 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import Link from 'next/link'; -import { Home, Search, ShoppingBag, MessageCircle, Download, User } from 'lucide-react'; +import { usePathname } from 'next/navigation'; +import { Home, Search, ShoppingBag, MessageCircle, User, Shield } from 'lucide-react'; import { useBrand } from '@/context/BrandContext'; +import { useAuth } from '@/context/AuthContext'; interface MobileBottomDockProps { cartCount: number; onOpenCart: () => void; onOpenSearch?: () => void; - onOpenInstallModal: () => void; + onOpenInstallModal?: () => void; } export const MobileBottomDock: React.FC = ({ cartCount, onOpenCart, onOpenSearch, - onOpenInstallModal, }) => { const { currentBrand } = useBrand(); + const { user, openAuthModal } = useAuth(); + const pathname = usePathname(); const [activeTab, setActiveTab] = useState('home'); - const [isInsideApp, setIsInsideApp] = useState(false); - - useEffect(() => { - const detectApp = () => { - if (typeof window === 'undefined') return false; - - // 1. Capacitor bridge object o plataforma nativa - const isCapacitor = - typeof (window as any).Capacitor !== 'undefined' || - !!(window as any).Capacitor?.isNativePlatform?.() || - (window as any).Capacitor?.getPlatform?.() === 'android' || - (window as any).Capacitor?.getPlatform?.() === 'ios'; - - // 2. Modo Standalone (PWA instalada o contenedor Android) - const isStandalone = - window.matchMedia?.('(display-mode: standalone)')?.matches || - (window.navigator as any)?.standalone === true || - document.referrer.includes('android-app://'); - - // 3. UserAgent personalizado o WebView embebida - const ua = (navigator.userAgent || '').toLowerCase(); - const isAppUA = - ua.includes('capacitor') || - ua.includes('capacitorapp') || - ua.includes('almeira') || - ua.includes('; wv') || - ua.includes('version/4.0'); - - return isCapacitor || isStandalone || isAppUA; - }; - - if (detectApp()) { - setIsInsideApp(true); - } - - const timer = setTimeout(() => { - if (detectApp()) { - setIsInsideApp(true); - } - }, 300); - - return () => clearTimeout(timer); - }, []); const handleWhatsApp = () => { const text = encodeURIComponent( @@ -71,18 +31,25 @@ export const MobileBottomDock: React.FC = ({ window.open(`https://wa.me/${currentBrand.whatsappNumber}?text=${text}`, '_blank'); }; + const isAdmin = Boolean(user && (user.role === 'admin' || user.role === 'operator')); + const isAdminActive = pathname?.startsWith('/admin'); + const isHomeActive = pathname === '/' && activeTab === 'home'; + return ( -
+ ); }; diff --git a/almeira-store-app/src/components/store/Navbar.tsx b/almeira-store-app/src/components/store/Navbar.tsx index 4412286..bec0527 100644 --- a/almeira-store-app/src/components/store/Navbar.tsx +++ b/almeira-store-app/src/components/store/Navbar.tsx @@ -122,15 +122,17 @@ export const Navbar: React.FC = ({ )} - {/* Admin Link */} - - - Admin - + {/* Admin Link - Solo para Administradores */} + {user && (user.role === 'admin' || user.role === 'operator') && ( + + + Admin + + )} {/* Cart Button */} - {Object.values(BRANDS).map((b) => ( - - ))}
+ {/* Toast feedback */} {actionMessage && ( -
- {actionMessage} -
)} - {/* Live Simulator for Test Messages */} -
-
- - Simulador de Recepción en Tiempo Real + {/* Filter & Tabs Bar */} +
+ {/* Status Tabs */} +
+ + + + +
-
-
- - -
- -
- -
- setTestText(e.target.value)} - className="flex-1 bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden" - /> - -
-
+ {/* Group Selector Filter */} +
+ +
- {/* Intercepted Posts Feed */} -
-

Mensajes Procesados Recientemente

+ {/* Multi-Selection Bulk Actions Bar */} + {selectedPostIds.size > 0 && tabStatus === 'pending' && ( +
+
+ + {selectedPostIds.size} seleccionados + + + Listos para transferir al catálogo de la tienda + +
-
- {posts.map((post) => { +
+ + + +
+
+ )} + + {/* Posts List / Grid */} + {filteredPosts.length === 0 ? ( +
+
+ +
+
+

No hay mensajes en esta bandeja

+

+ Haz clic en "Sincronizar WhatsApp en Vivo" para extraer automáticamente las ofertas recientes de tus grupos seleccionados. +

+
+ +
+ ) : ( +
+ {filteredPosts.map((post) => { const extracted = post.extractedProduct; - const duplicateCheck = extracted - ? checkProductDuplicate( - { - name: extracted.name, - brand: extracted.brand, - sizes: extracted.sizes, - supplierPrice: extracted.supplierPrice, - }, - products - ) - : null; + const isSelected = selectedPostIds.has(post.id); + const isPublished = post.status === 'published'; + const isDiscarded = post.status === 'discarded'; return (
- {/* Header of post */} -
-
- - {post.channelName || 'Canal Mayorista'} - - {post.receivedAt} + {/* Header info */} +
+
+ {tabStatus === 'pending' && ( + + )} + +
+ + {post.channelName || 'Grupo WhatsApp'} + + {post.receivedAt} +
- {/* Anti-Duplicate Status Badge */} - {duplicateCheck && ( -
- {duplicateCheck.isDuplicate ? ( - <> - - Duplicado Detectado ({Math.round(duplicateCheck.similarityScore * 100)}% Similitud) - - ) : ( - <> - - Producto Nuevo (0% Duplicados) - - )} -
- )} +
+ {isPublished ? ( + + Publicado en Tienda + + ) : isDiscarded ? ( + + Descartado + + ) : ( + + Pendiente de Aprobación + + )} +
- {/* Content Grid */} -
- {/* Left: Raw WhatsApp Message (5 cols) */} -
- Mensaje de Entrada: -

- {post.rawMessage} -

- {post.rawImages.length > 0 && ( -
+ {/* Content Grid (Photo + AI extracted info) */} +
+ {/* Sneaker Image & Raw text */} +
+
+ {post.rawImages && post.rawImages[0] ? ( -
- )} + ) : ( +
Sin foto
+ )} +
+ + {/* Raw WhatsApp Message snippet */} +
+

+ {post.rawMessage} +

+
- {/* Right: AI Extracted & Pricing Calculation (7 cols) */} -
- {extracted ? ( -
-
-
- - {extracted.brand} • {extracted.category} - -

{extracted.name}

-
- - IA: {Math.round(extracted.confidence * 100)}% certeza - -
+ {/* AI Extraction & Product Info */} +
+
+
+ + {extracted?.brand || 'Nike'} • {extracted?.category || 'Sneakers'} + +

+ {extracted?.name || 'Zapatilla Urbana'} +

+
- {/* Pricing breakdown */} -
-
- Costo Base: - - {formatCOP(extracted.supplierPrice)} - -
-
- Precio Venta (+ Margen): - - {formatCOP(extracted.suggestedRetailPrice)} - -
-
- - {/* Sizes */} + {/* Pricing breakdown */} +
- - Tallas extraídas: + Costo Proveedor: + + {formatCOP(extracted?.supplierPrice || 90000)} -
- {extracted.sizes.map((s) => ( - - {s} - - ))} -
- - {/* Duplicate warning note */} - {duplicateCheck?.isDuplicate && ( -
- {duplicateCheck.message} -
- )} - - {/* Actions */} -
- - - +
+ Venta Sugerida: + + {formatCOP(extracted?.suggestedRetailPrice || 125000)} +
- ) : ( -

Extrayendo datos...

- )} + + {/* Sizes badge pills */} +
+ + Tallas Disponibles: + +
+ {extracted?.sizes && extracted.sizes.length > 0 ? ( + extracted.sizes.map((s) => ( + + {s} + + )) + ) : ( + 37 a 43 + )} +
+
+
+ + {/* Card Actions */} +
+ {!isPublished ? ( + <> + + + + + + + ) : ( +
+ + Disponible para clientes + + + Ver en Tienda + + +
+ )} +
); })}
-
+ )} + + {/* Edit Modal Before Publishing */} + {editingPost && ( +
+
+
+

+ + Personalizar antes de Publicar +

+ +
+ +
+
+ + setEditName(e.target.value)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white font-bold outline-hidden focus:border-purple-500" + /> +
+ +
+
+ + setEditBrand(e.target.value)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-500" + /> +
+ +
+ + setEditPrice(Number(e.target.value))} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-emerald-300 font-black font-mono outline-hidden focus:border-emerald-500" + /> +
+
+ +
+ + setEditSizes(e.target.value)} + placeholder="38, 39, 40, 41, 42, 43" + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-500" + /> +
+
+ +
+ + + +
+
+
+ )}
); } diff --git a/dripzone-app/src/app/admin/layout.tsx b/dripzone-app/src/app/admin/layout.tsx index 020ce8f..8cce830 100644 --- a/dripzone-app/src/app/admin/layout.tsx +++ b/dripzone-app/src/app/admin/layout.tsx @@ -12,15 +12,20 @@ import { CreditCard, ShoppingBag, ArrowLeft, - Sparkles, Store, Users, MessageSquare, Building2, + ShieldAlert, + LogIn, } from 'lucide-react'; +import { useAuth } from '@/context/AuthContext'; +import { AuthModal } from '@/components/auth/AuthModal'; export default function AdminLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + const { user, openAuthModal } = useAuth(); + const isAdmin = Boolean(user && (user.role === 'admin' || user.role === 'operator')); const NAV_ITEMS = [ { href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard }, @@ -35,6 +40,45 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) { href: '/admin/pedidos', label: 'Pedidos & Logística', icon: ShoppingBag }, ]; + if (!isAdmin) { + return ( +
+
+
+ +
+ +
+

Acceso Restringido para Administradores

+

+ Este panel de control es de uso exclusivo para el equipo administrativo y operadores logísticos autorizados. +

+
+ +
+ + + + + Volver a la Tienda + +
+
+ + +
+ ); + } + return (
{/* Sidebar */} @@ -104,6 +148,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{children}
+ +
); } diff --git a/dripzone-app/src/app/api/whatsapp-feed/route.ts b/dripzone-app/src/app/api/whatsapp-feed/route.ts new file mode 100644 index 0000000..9821f9f --- /dev/null +++ b/dripzone-app/src/app/api/whatsapp-feed/route.ts @@ -0,0 +1,255 @@ +import { NextResponse } from 'next/server'; +import { dataService } from '@/lib/data-service'; +import { parseSupplierMessage } from '@/lib/gemini-parser'; +import { SupplierPost, MonitoredWhatsAppGroup } from '@/types'; + +const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; +const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943'; +const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const fetchLive = searchParams.get('fetchLive') === 'true'; + const limit = parseInt(searchParams.get('limit') || '30', 10); + const filterJid = searchParams.get('groupJid'); + + // 1. Obtener grupos monitoreados locales + const monitoredGroups: MonitoredWhatsAppGroup[] = dataService.getMonitoredGroups(); + const monitoredJids = new Set( + monitoredGroups.filter((g) => g.isMonitored).map((g) => g.jid) + ); + + // Si no se pide en vivo, retornar lo almacenado localmente + if (!fetchLive) { + let posts = dataService.getSupplierPosts(); + if (filterJid) { + posts = posts.filter((p) => (p as any).channelJid === filterJid || p.channelName?.includes(filterJid)); + } + return NextResponse.json({ + success: true, + posts, + monitoredCount: monitoredJids.size, + }); + } + + // 2. Consultar Evolution API para obtener los mensajes recientes de los grupos + const msgUrl = `${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_INSTANCE_NAME}?page=1&limit=${limit}`; + const msgRes = await fetch(msgUrl, { + method: 'POST', + headers: { + apikey: EVOLUTION_API_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(filterJid ? { where: { key: { remoteJid: filterJid } } } : {}), + }); + + const msgData = await msgRes.json(); + const records = msgData.messages?.records || msgData.records || (Array.isArray(msgData) ? msgData : []); + + // 3. Mapear grupos para resolver nombres + const groupsMap = new Map(); + monitoredGroups.forEach((g) => groupsMap.set(g.jid, g.name)); + + // Si faltan nombres, intentar traerlos de Evolution + if (groupsMap.size === 0) { + try { + const grpRes = await fetch( + `${EVOLUTION_API_URL}/group/fetchAllGroups/${EVOLUTION_INSTANCE_NAME}?getParticipants=false`, + { headers: { apikey: EVOLUTION_API_KEY } } + ); + const grpData = await grpRes.json(); + if (Array.isArray(grpData)) { + grpData.forEach((g: any) => { + const jid = g.id || g.jid; + const name = g.subject || g.name || 'Grupo WhatsApp'; + groupsMap.set(jid, name); + }); + } + } catch (e) {} + } + + const processedPosts: SupplierPost[] = []; + + for (const record of records) { + const remoteJid = record.key?.remoteJid || ''; + // Filtrar solo grupos de WhatsApp (@g.us) + if (!remoteJid.includes('@g.us')) continue; + + // Si hay grupos monitoreados configurados y no se pasó un filtro específico, respetar monitoreo + if (monitoredJids.size > 0 && !filterJid && !monitoredJids.has(remoteJid)) { + // Si el grupo no está monitoreado, omitir + continue; + } + + const text = + record.message?.conversation || + record.message?.extendedTextMessage?.text || + record.message?.imageMessage?.caption || + record.message?.videoMessage?.caption || + ''; + + const groupName = groupsMap.get(remoteJid) || record.pushName || 'Grupo Mayorista'; + const hasImage = Boolean(record.message?.imageMessage); + + // Si no tiene texto ni imagen, omitir + if (!text && !hasImage) continue; + + let imageUrls: string[] = []; + + // Descargar imagen en Base64 desde Evolution API si está presente + if (hasImage) { + try { + const mediaUrl = `${EVOLUTION_API_URL}/chat/getBase64FromMediaMessage/${EVOLUTION_INSTANCE_NAME}`; + const mediaRes = await fetch(mediaUrl, { + method: 'POST', + headers: { + apikey: EVOLUTION_API_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ message: record, convertToMp4: false }), + }); + const mediaData = await mediaRes.json(); + if (mediaData.base64) { + const mime = mediaData.mimetype || 'image/jpeg'; + imageUrls.push(`data:${mime};base64,${mediaData.base64}`); + } + } catch (mediaErr) { + console.warn('Error descargando imagen de WhatsApp:', mediaErr); + } + } + + if (imageUrls.length === 0) { + imageUrls.push('https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'); + } + + // Parsear oferta de calzado con IA + const extracted = await parseSupplierMessage(text || groupName); + + const timestamp = record.messageTimestamp + ? new Date(record.messageTimestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : 'Reciente'; + + const post: SupplierPost = { + id: `post-${record.key?.id || Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + channelName: groupName, + channelJid: remoteJid, + rawMessage: text || `[Foto de Calzado enviada en ${groupName}]`, + rawImages: imageUrls, + extractedProduct: extracted, + status: 'pending_review', + receivedAt: timestamp, + supplierName: groupName, + supplierPhone: record.key?.participant?.replace(/\D/g, '') || '573000000000', + }; + + processedPosts.push(post); + } + + // Guardar en el servicio local los nuevos posts + if (processedPosts.length > 0) { + dataService.saveSupplierPosts(processedPosts); + } + + return NextResponse.json({ + success: true, + isLive: true, + totalFound: processedPosts.length, + posts: dataService.getSupplierPosts(), + }); + } catch (error: any) { + console.error('Error en /api/whatsapp-feed:', error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { action, post, posts, customRetailPrice, customName, customSizes } = body; + + // 1. PUBLICAR UN PRODUCTO A LA TIENDA (APP MÓVIL Y WEB) + if (action === 'publish' && post) { + const retailPrice = + customRetailPrice || + post.extractedProduct?.suggestedRetailPrice || + (post.extractedProduct?.supplierPrice ? post.extractedProduct.supplierPrice + 35000 : 125000); + + const newProduct = { + id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + name: customName || post.extractedProduct?.name || 'Zapatillas Urbanas', + brand: post.extractedProduct?.brand || 'Nike', + supplierPrice: post.extractedProduct?.supplierPrice || 90000, + retailPrice: retailPrice, + originalPrice: Math.round(retailPrice * 1.35), + sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], + images: Array.isArray(post.rawImages) && post.rawImages.length > 0 ? post.rawImages : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], + description: `Calzado garantizado de alta calidad para despacho inmediato en 24 horas y pago contraentrega.`, + category: post.extractedProduct?.category || 'Calzado / Sneakers', + inStock: true, + isActive: true, + originBadge: `Bodega ${post.channelName || 'Cali'}`, + supplierName: post.supplierName || post.channelName || 'Proveedor Mayorista', + supplierPhone: post.supplierPhone || '573167455877', + }; + + dataService.addProduct(newProduct); + dataService.updateSupplierPostStatus(post.id, 'published'); + + return NextResponse.json({ + success: true, + message: '¡Producto publicado exitosamente en la App Móvil y Web!', + product: newProduct, + }); + } + + // 2. PUBLICACIÓN MASIVA (BULK) + if (action === 'bulk-publish' && Array.isArray(posts)) { + const createdProducts = []; + for (const p of posts) { + const retailPrice = + p.extractedProduct?.suggestedRetailPrice || + (p.extractedProduct?.supplierPrice ? p.extractedProduct.supplierPrice + 35000 : 125000); + + const newProd = { + id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + name: p.extractedProduct?.name || 'Zapatillas Urbanas', + brand: p.extractedProduct?.brand || 'Nike', + supplierPrice: p.extractedProduct?.supplierPrice || 90000, + retailPrice, + originalPrice: Math.round(retailPrice * 1.35), + sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], + images: p.rawImages || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], + description: `Calzado garantizado para despacho inmediato en 24 horas con pago contraentrega.`, + category: p.extractedProduct?.category || 'Calzado / Sneakers', + inStock: true, + isActive: true, + originBadge: `Bodega ${p.channelName || 'Cali'}`, + supplierName: p.supplierName || p.channelName || 'Proveedor Mayorista', + supplierPhone: p.supplierPhone || '573167455877', + }; + + dataService.addProduct(newProd); + dataService.updateSupplierPostStatus(p.id, 'published'); + createdProducts.push(newProd); + } + + return NextResponse.json({ + success: true, + message: `¡Se publicaron ${createdProducts.length} productos en la App Móvil y Web!`, + products: createdProducts, + }); + } + + // 3. DESCARTAR POST + if (action === 'discard' && post?.id) { + dataService.updateSupplierPostStatus(post.id, 'discarded'); + return NextResponse.json({ success: true, message: 'Mensaje descartado' }); + } + + return NextResponse.json({ success: false, error: 'Acción no válida' }, { status: 400 }); + } catch (err: any) { + console.error('Error en POST /api/whatsapp-feed:', err); + return NextResponse.json({ success: false, error: err.message }, { status: 500 }); + } +} diff --git a/dripzone-app/src/app/checkout/page.tsx b/dripzone-app/src/app/checkout/page.tsx index 45c7980..872f8b0 100644 --- a/dripzone-app/src/app/checkout/page.tsx +++ b/dripzone-app/src/app/checkout/page.tsx @@ -32,17 +32,21 @@ export default function CheckoutPage() { quantity: number; price: number; image: string; - }>>([ - { - productId: INITIAL_PRODUCTS[0].id, - productName: INITIAL_PRODUCTS[0].name, - brand: INITIAL_PRODUCTS[0].brand, - size: '39', - quantity: 1, - price: INITIAL_PRODUCTS[0].retailPrice, - image: INITIAL_PRODUCTS[0].images[0], - }, - ]); + }>>( + INITIAL_PRODUCTS.length > 0 + ? [ + { + productId: INITIAL_PRODUCTS[0].id, + productName: INITIAL_PRODUCTS[0].name, + brand: INITIAL_PRODUCTS[0].brand, + size: '39', + quantity: 1, + price: INITIAL_PRODUCTS[0].retailPrice, + image: INITIAL_PRODUCTS[0].images[0], + }, + ] + : [] + ); const [isSubmitting, setIsSubmitting] = useState(false); const [orderCompleted, setOrderCompleted] = useState(null); diff --git a/dripzone-app/src/app/page.tsx b/dripzone-app/src/app/page.tsx index e4b60ff..8944ecf 100644 --- a/dripzone-app/src/app/page.tsx +++ b/dripzone-app/src/app/page.tsx @@ -9,7 +9,6 @@ import { ProductModal } from '@/components/store/ProductModal'; import { CartDrawer } from '@/components/store/CartDrawer'; import { CitySelectorModal } from '@/components/store/CitySelectorModal'; import { MobileBottomDock } from '@/components/mobile/MobileBottomDock'; -import { MobileInstallBanner } from '@/components/mobile/MobileInstallBanner'; import { AuthModal } from '@/components/auth/AuthModal'; import { Product, City, CartItem } from '@/types'; import { INITIAL_PRODUCTS, INITIAL_CITIES } from '@/lib/data-service'; @@ -18,7 +17,7 @@ import { useBrand } from '@/context/BrandContext'; export default function StoreHomePage() { const { currentBrand } = useBrand(); - const [products, setProducts] = useState(INITIAL_PRODUCTS); + const [products, setProducts] = useState([]); const [cities, setCities] = useState(INITIAL_CITIES); const [selectedCity, setSelectedCity] = useState(INITIAL_CITIES[0]); // Cali const [activeCategory, setActiveCategory] = useState('all'); @@ -28,7 +27,6 @@ export default function StoreHomePage() { const [selectedProduct, setSelectedProduct] = useState(null); const [isCartOpen, setIsCartOpen] = useState(false); const [isCityModalOpen, setIsCityModalOpen] = useState(false); - const [isInstallModalOpen, setIsInstallModalOpen] = useState(false); const [cartItems, setCartItems] = useState([]); const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0); @@ -37,11 +35,11 @@ export default function StoreHomePage() { fetch('/api/products') .then((res) => res.json()) .then((data) => { - if (data.success && data.products?.length > 0) { + if (data.success && Array.isArray(data.products)) { setProducts(data.products); } }) - .catch((e) => console.log('Usando catálogo inicial:', e)); + .catch((e) => console.log('Error cargando productos:', e)); fetch('/api/cities') .then((res) => res.json()) @@ -188,19 +186,41 @@ export default function StoreHomePage() {
{/* Product Grid */} - {filteredProducts.length === 0 ? ( -
-
- + {products.length === 0 ? ( +
+
+ +
+
+

Catálogo en Actualización

+

+ Estamos sincronizando los nuevos drops de calzado en vivo desde nuestras bodegas mayoristas asociadas. +

+
+ + Consultar Disponibilidad por WhatsApp + +
+ ) : filteredProducts.length === 0 ? ( +
+
+

No encontramos zapatillas con ese criterio

-

Intenta buscar otra marca o restablecer los filtros.

+

Intenta buscar otra marca o restablecer los filtros.

@@ -265,16 +285,10 @@ export default function StoreHomePage() { onSelectCity={setSelectedCity} /> - {/* Mobile App Dock & Install Banner */} + {/* Mobile App Dock */} setIsCartOpen(true)} - onOpenInstallModal={() => setIsInstallModalOpen(true)} - /> - - setIsInstallModalOpen(false)} /> {/* User Auth Modal */} diff --git a/dripzone-app/src/components/mobile/MobileBottomDock.tsx b/dripzone-app/src/components/mobile/MobileBottomDock.tsx index df55b75..7b7fc5b 100644 --- a/dripzone-app/src/components/mobile/MobileBottomDock.tsx +++ b/dripzone-app/src/components/mobile/MobileBottomDock.tsx @@ -1,68 +1,28 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import Link from 'next/link'; -import { Home, Search, ShoppingBag, MessageCircle, Download, User } from 'lucide-react'; +import { usePathname } from 'next/navigation'; +import { Home, Search, ShoppingBag, MessageCircle, User, Shield } from 'lucide-react'; import { useBrand } from '@/context/BrandContext'; +import { useAuth } from '@/context/AuthContext'; interface MobileBottomDockProps { cartCount: number; onOpenCart: () => void; onOpenSearch?: () => void; - onOpenInstallModal: () => void; + onOpenInstallModal?: () => void; } export const MobileBottomDock: React.FC = ({ cartCount, onOpenCart, onOpenSearch, - onOpenInstallModal, }) => { const { currentBrand } = useBrand(); + const { user, openAuthModal } = useAuth(); + const pathname = usePathname(); const [activeTab, setActiveTab] = useState('home'); - const [isInsideApp, setIsInsideApp] = useState(false); - - useEffect(() => { - const detectApp = () => { - if (typeof window === 'undefined') return false; - - // 1. Capacitor bridge object o plataforma nativa - const isCapacitor = - typeof (window as any).Capacitor !== 'undefined' || - !!(window as any).Capacitor?.isNativePlatform?.() || - (window as any).Capacitor?.getPlatform?.() === 'android' || - (window as any).Capacitor?.getPlatform?.() === 'ios'; - - // 2. Modo Standalone (PWA instalada o contenedor Android) - const isStandalone = - window.matchMedia?.('(display-mode: standalone)')?.matches || - (window.navigator as any)?.standalone === true || - document.referrer.includes('android-app://'); - - // 3. UserAgent personalizado o WebView embebida - const ua = (navigator.userAgent || '').toLowerCase(); - const isAppUA = - ua.includes('capacitor') || - ua.includes('capacitorapp') || - ua.includes('dripzone') || - ua.includes('; wv') || - ua.includes('version/4.0'); - - return isCapacitor || isStandalone || isAppUA; - }; - - if (detectApp()) { - setIsInsideApp(true); - } - - const timer = setTimeout(() => { - if (detectApp()) { - setIsInsideApp(true); - } - }, 300); - - return () => clearTimeout(timer); - }, []); const handleWhatsApp = () => { const text = encodeURIComponent( @@ -71,20 +31,29 @@ export const MobileBottomDock: React.FC = ({ window.open(`https://wa.me/${currentBrand.whatsappNumber}?text=${text}`, '_blank'); }; + const isAdmin = Boolean(user && (user.role === 'admin' || user.role === 'operator')); + const isAdminActive = pathname?.startsWith('/admin'); + const isHomeActive = pathname === '/' && activeTab === 'home'; + return ( -
+
+ ); }; diff --git a/dripzone-app/src/components/store/Navbar.tsx b/dripzone-app/src/components/store/Navbar.tsx index cea49f0..c4b5f18 100644 --- a/dripzone-app/src/components/store/Navbar.tsx +++ b/dripzone-app/src/components/store/Navbar.tsx @@ -126,15 +126,17 @@ export const Navbar: React.FC = ({ )} - {/* Admin Link */} - - - Admin - + {/* Admin Link - Solo para Administradores */} + {user && (user.role === 'admin' || user.role === 'operator') && ( + + + Admin + + )} {/* Cart Button */} - {Object.values(BRANDS).map((b) => ( - - ))}
+ {/* Toast feedback */} {actionMessage && ( -
- {actionMessage} -
)} - {/* Live Simulator for Test Messages */} -
-
- - Simulador de Recepción en Tiempo Real + {/* Filter & Tabs Bar */} +
+ {/* Status Tabs */} +
+ + + + +
-
-
- - -
- -
- -
- setTestText(e.target.value)} - className="flex-1 bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden" - /> - -
-
+ {/* Group Selector Filter */} +
+ +
- {/* Intercepted Posts Feed */} -
-

Mensajes Procesados Recientemente

+ {/* Multi-Selection Bulk Actions Bar */} + {selectedPostIds.size > 0 && tabStatus === 'pending' && ( +
+
+ + {selectedPostIds.size} seleccionados + + + Listos para transferir al catálogo de la tienda + +
-
- {posts.map((post) => { +
+ + + +
+
+ )} + + {/* Posts List / Grid */} + {filteredPosts.length === 0 ? ( +
+
+ +
+
+

No hay mensajes en esta bandeja

+

+ Haz clic en "Sincronizar WhatsApp en Vivo" para extraer automáticamente las ofertas recientes de tus grupos seleccionados. +

+
+ +
+ ) : ( +
+ {filteredPosts.map((post) => { const extracted = post.extractedProduct; - const duplicateCheck = extracted - ? checkProductDuplicate( - { - name: extracted.name, - brand: extracted.brand, - sizes: extracted.sizes, - supplierPrice: extracted.supplierPrice, - }, - products - ) - : null; + const isSelected = selectedPostIds.has(post.id); + const isPublished = post.status === 'published'; + const isDiscarded = post.status === 'discarded'; return (
- {/* Header of post */} -
-
- - {post.channelName || 'Canal Mayorista'} - - {post.receivedAt} + {/* Header info */} +
+
+ {tabStatus === 'pending' && ( + + )} + +
+ + {post.channelName || 'Grupo WhatsApp'} + + {post.receivedAt} +
- {/* Anti-Duplicate Status Badge */} - {duplicateCheck && ( -
- {duplicateCheck.isDuplicate ? ( - <> - - Duplicado Detectado ({Math.round(duplicateCheck.similarityScore * 100)}% Similitud) - - ) : ( - <> - - Producto Nuevo (0% Duplicados) - - )} -
- )} +
+ {isPublished ? ( + + Publicado en Tienda + + ) : isDiscarded ? ( + + Descartado + + ) : ( + + Pendiente de Aprobación + + )} +
- {/* Content Grid */} -
- {/* Left: Raw WhatsApp Message (5 cols) */} -
- Mensaje de Entrada: -

- {post.rawMessage} -

- {post.rawImages.length > 0 && ( -
+ {/* Content Grid (Photo + AI extracted info) */} +
+ {/* Sneaker Image & Raw text */} +
+
+ {post.rawImages && post.rawImages[0] ? ( -
- )} + ) : ( +
Sin foto
+ )} +
+ +
+

+ {post.rawMessage} +

+
- {/* Right: AI Extracted & Pricing Calculation (7 cols) */} -
- {extracted ? ( -
-
-
- - {extracted.brand} • {extracted.category} - -

{extracted.name}

-
- - IA: {Math.round(extracted.confidence * 100)}% certeza - -
+ {/* AI Extraction & Product Info */} +
+
+
+ + {extracted?.brand || 'Nike'} • {extracted?.category || 'Sneakers'} + +

+ {extracted?.name || 'Zapatilla Urbana'} +

+
- {/* Pricing breakdown */} -
-
- Costo Base: - - {formatCOP(extracted.supplierPrice)} - -
-
- Precio Venta (+ Margen): - - {formatCOP(extracted.suggestedRetailPrice)} - -
-
- - {/* Sizes */} + {/* Pricing breakdown */} +
- - Tallas extraídas: + Costo Proveedor: + + {formatCOP(extracted?.supplierPrice || 90000)} -
- {extracted.sizes.map((s) => ( - - {s} - - ))} -
- - {/* Duplicate warning note */} - {duplicateCheck?.isDuplicate && ( -
- {duplicateCheck.message} -
- )} - - {/* Actions */} -
- - - +
+ Venta Sugerida: + + {formatCOP(extracted?.suggestedRetailPrice || 125000)} +
- ) : ( -

Extrayendo datos...

- )} + + {/* Sizes badge pills */} +
+ + Tallas Disponibles: + +
+ {extracted?.sizes && extracted.sizes.length > 0 ? ( + extracted.sizes.map((s) => ( + + {s} + + )) + ) : ( + 37 a 43 + )} +
+
+
+ + {/* Card Actions */} +
+ {!isPublished ? ( + <> + + + + + + + ) : ( +
+ + Disponible para clientes + + + Ver en Tienda + + +
+ )} +
); })}
-
+ )} + + {/* Edit Modal Before Publishing */} + {editingPost && ( +
+
+
+

+ + Personalizar antes de Publicar +

+ +
+ +
+
+ + setEditName(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3 py-2 text-white font-bold outline-hidden focus:border-orange-500" + /> +
+ +
+
+ + setEditBrand(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-orange-500" + /> +
+ +
+ + setEditPrice(Number(e.target.value))} + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3 py-2 text-emerald-300 font-black font-mono outline-hidden focus:border-emerald-500" + /> +
+
+ +
+ + setEditSizes(e.target.value)} + placeholder="38, 39, 40, 41, 42, 43" + className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-orange-500" + /> +
+
+ +
+ + + +
+
+
+ )}
); } diff --git a/sneaky-app/src/app/admin/layout.tsx b/sneaky-app/src/app/admin/layout.tsx index 81cfdd3..3403170 100644 --- a/sneaky-app/src/app/admin/layout.tsx +++ b/sneaky-app/src/app/admin/layout.tsx @@ -11,15 +11,20 @@ import { CreditCard, ShoppingBag, ArrowLeft, - Sparkles, Store, Users, MessageSquare, Building2, + ShieldAlert, + LogIn, } from 'lucide-react'; +import { useAuth } from '@/context/AuthContext'; +import { AuthModal } from '@/components/auth/AuthModal'; export default function AdminLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + const { user, openAuthModal } = useAuth(); + const isAdmin = Boolean(user && (user.role === 'admin' || user.role === 'operator')); const NAV_ITEMS = [ { href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard }, @@ -34,6 +39,45 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) { href: '/admin/pedidos', label: 'Pedidos & Logística', icon: ShoppingBag }, ]; + if (!isAdmin) { + return ( +
+
+
+ +
+ +
+

Acceso Restringido para Administradores

+

+ Este panel de control es de uso exclusivo para el equipo administrativo y operadores logísticos autorizados. +

+
+ +
+ + + + + Volver a la Tienda + +
+
+ + +
+ ); + } + return (
{/* Sidebar */} @@ -96,6 +140,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{children}
+ +
); } diff --git a/sneaky-app/src/app/api/whatsapp-feed/route.ts b/sneaky-app/src/app/api/whatsapp-feed/route.ts new file mode 100644 index 0000000..8dbf018 --- /dev/null +++ b/sneaky-app/src/app/api/whatsapp-feed/route.ts @@ -0,0 +1,247 @@ +import { NextResponse } from 'next/server'; +import { dataService } from '@/lib/data-service'; +import { parseSupplierMessage } from '@/lib/gemini-parser'; +import { SupplierPost, MonitoredWhatsAppGroup } from '@/types'; + +const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; +const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943'; +const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const fetchLive = searchParams.get('fetchLive') === 'true'; + const limit = parseInt(searchParams.get('limit') || '30', 10); + const filterJid = searchParams.get('groupJid'); + + // 1. Obtener grupos monitoreados locales + const monitoredGroups: MonitoredWhatsAppGroup[] = dataService.getMonitoredGroups(); + const monitoredJids = new Set( + monitoredGroups.filter((g) => g.isMonitored).map((g) => g.jid) + ); + + // Si no se pide en vivo, retornar lo almacenado localmente + if (!fetchLive) { + let posts = dataService.getSupplierPosts(); + if (filterJid) { + posts = posts.filter((p) => (p as any).channelJid === filterJid || p.channelName?.includes(filterJid)); + } + return NextResponse.json({ + success: true, + posts, + monitoredCount: monitoredJids.size, + }); + } + + // 2. Consultar Evolution API para obtener los mensajes recientes de los grupos + const msgUrl = `${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_INSTANCE_NAME}?page=1&limit=${limit}`; + const msgRes = await fetch(msgUrl, { + method: 'POST', + headers: { + apikey: EVOLUTION_API_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(filterJid ? { where: { key: { remoteJid: filterJid } } } : {}), + }); + + const msgData = await msgRes.json(); + const records = msgData.messages?.records || msgData.records || (Array.isArray(msgData) ? msgData : []); + + // 3. Mapear grupos para resolver nombres + const groupsMap = new Map(); + monitoredGroups.forEach((g) => groupsMap.set(g.jid, g.name)); + + if (groupsMap.size === 0) { + try { + const grpRes = await fetch( + `${EVOLUTION_API_URL}/group/fetchAllGroups/${EVOLUTION_INSTANCE_NAME}?getParticipants=false`, + { headers: { apikey: EVOLUTION_API_KEY } } + ); + const grpData = await grpRes.json(); + if (Array.isArray(grpData)) { + grpData.forEach((g: any) => { + const jid = g.id || g.jid; + const name = g.subject || g.name || 'Grupo WhatsApp'; + groupsMap.set(jid, name); + }); + } + } catch (e) {} + } + + const processedPosts: SupplierPost[] = []; + + for (const record of records) { + const remoteJid = record.key?.remoteJid || ''; + if (!remoteJid.includes('@g.us')) continue; + + if (monitoredJids.size > 0 && !filterJid && !monitoredJids.has(remoteJid)) { + continue; + } + + const text = + record.message?.conversation || + record.message?.extendedTextMessage?.text || + record.message?.imageMessage?.caption || + record.message?.videoMessage?.caption || + ''; + + const groupName = groupsMap.get(remoteJid) || record.pushName || 'Grupo Mayorista'; + const hasImage = Boolean(record.message?.imageMessage); + + if (!text && !hasImage) continue; + + let imageUrls: string[] = []; + + if (hasImage) { + try { + const mediaUrl = `${EVOLUTION_API_URL}/chat/getBase64FromMediaMessage/${EVOLUTION_INSTANCE_NAME}`; + const mediaRes = await fetch(mediaUrl, { + method: 'POST', + headers: { + apikey: EVOLUTION_API_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ message: record, convertToMp4: false }), + }); + const mediaData = await mediaRes.json(); + if (mediaData.base64) { + const mime = mediaData.mimetype || 'image/jpeg'; + imageUrls.push(`data:${mime};base64,${mediaData.base64}`); + } + } catch (mediaErr) { + console.warn('Error descargando imagen de WhatsApp:', mediaErr); + } + } + + if (imageUrls.length === 0) { + imageUrls.push('https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'); + } + + const extracted = await parseSupplierMessage(text || groupName); + + const timestamp = record.messageTimestamp + ? new Date(record.messageTimestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : 'Reciente'; + + const post: SupplierPost = { + id: `post-${record.key?.id || Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + channelName: groupName, + channelJid: remoteJid, + rawMessage: text || `[Foto de Calzado enviada en ${groupName}]`, + rawImages: imageUrls, + extractedProduct: extracted, + status: 'pending_review', + receivedAt: timestamp, + supplierName: groupName, + supplierPhone: record.key?.participant?.replace(/\D/g, '') || '573000000000', + }; + + processedPosts.push(post); + } + + if (processedPosts.length > 0) { + dataService.saveSupplierPosts(processedPosts); + } + + return NextResponse.json({ + success: true, + isLive: true, + totalFound: processedPosts.length, + posts: dataService.getSupplierPosts(), + }); + } catch (error: any) { + console.error('Error en /api/whatsapp-feed:', error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { action, post, posts, customRetailPrice, customName, customSizes } = body; + + // 1. PUBLICAR UN PRODUCTO A LA TIENDA (APP MÓVIL Y WEB) + if (action === 'publish' && post) { + const retailPrice = + customRetailPrice || + post.extractedProduct?.suggestedRetailPrice || + (post.extractedProduct?.supplierPrice ? post.extractedProduct.supplierPrice + 35000 : 125000); + + const newProduct = { + id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + name: customName || post.extractedProduct?.name || 'Zapatillas Urbanas', + brand: post.extractedProduct?.brand || 'Nike', + supplierPrice: post.extractedProduct?.supplierPrice || 90000, + retailPrice: retailPrice, + originalPrice: Math.round(retailPrice * 1.35), + sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], + images: Array.isArray(post.rawImages) && post.rawImages.length > 0 ? post.rawImages : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], + description: `Calzado garantizado de alta calidad para despacho inmediato en 24 horas y pago contraentrega.`, + category: post.extractedProduct?.category || 'Calzado / Sneakers', + inStock: true, + isActive: true, + originBadge: `Bodega ${post.channelName || 'Cali'}`, + supplierName: post.supplierName || post.channelName || 'Proveedor Mayorista', + supplierPhone: post.supplierPhone || '573167455877', + }; + + dataService.addProduct(newProduct); + dataService.updateSupplierPostStatus(post.id, 'published'); + + return NextResponse.json({ + success: true, + message: '¡Producto publicado exitosamente en la App Móvil y Web!', + product: newProduct, + }); + } + + // 2. PUBLICACIÓN MASIVA (BULK) + if (action === 'bulk-publish' && Array.isArray(posts)) { + const createdProducts = []; + for (const p of posts) { + const retailPrice = + p.extractedProduct?.suggestedRetailPrice || + (p.extractedProduct?.supplierPrice ? p.extractedProduct.supplierPrice + 35000 : 125000); + + const newProd = { + id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + name: p.extractedProduct?.name || 'Zapatillas Urbanas', + brand: p.extractedProduct?.brand || 'Nike', + supplierPrice: p.extractedProduct?.supplierPrice || 90000, + retailPrice, + originalPrice: Math.round(retailPrice * 1.35), + sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], + images: p.rawImages || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], + description: `Calzado garantizado para despacho inmediato en 24 horas con pago contraentrega.`, + category: p.extractedProduct?.category || 'Calzado / Sneakers', + inStock: true, + isActive: true, + originBadge: `Bodega ${p.channelName || 'Cali'}`, + supplierName: p.supplierName || p.channelName || 'Proveedor Mayorista', + supplierPhone: p.supplierPhone || '573167455877', + }; + + dataService.addProduct(newProd); + dataService.updateSupplierPostStatus(p.id, 'published'); + createdProducts.push(newProd); + } + + return NextResponse.json({ + success: true, + message: `¡Se publicaron ${createdProducts.length} productos en la App Móvil y Web!`, + products: createdProducts, + }); + } + + // 3. DESCARTAR POST + if (action === 'discard' && post?.id) { + dataService.updateSupplierPostStatus(post.id, 'discarded'); + return NextResponse.json({ success: true, message: 'Mensaje descartado' }); + } + + return NextResponse.json({ success: false, error: 'Acción no válida' }, { status: 400 }); + } catch (err: any) { + console.error('Error en POST /api/whatsapp-feed:', err); + return NextResponse.json({ success: false, error: err.message }, { status: 500 }); + } +} diff --git a/sneaky-app/src/app/page.tsx b/sneaky-app/src/app/page.tsx index e4b60ff..2655213 100644 --- a/sneaky-app/src/app/page.tsx +++ b/sneaky-app/src/app/page.tsx @@ -9,7 +9,6 @@ import { ProductModal } from '@/components/store/ProductModal'; import { CartDrawer } from '@/components/store/CartDrawer'; import { CitySelectorModal } from '@/components/store/CitySelectorModal'; import { MobileBottomDock } from '@/components/mobile/MobileBottomDock'; -import { MobileInstallBanner } from '@/components/mobile/MobileInstallBanner'; import { AuthModal } from '@/components/auth/AuthModal'; import { Product, City, CartItem } from '@/types'; import { INITIAL_PRODUCTS, INITIAL_CITIES } from '@/lib/data-service'; @@ -28,7 +27,6 @@ export default function StoreHomePage() { const [selectedProduct, setSelectedProduct] = useState(null); const [isCartOpen, setIsCartOpen] = useState(false); const [isCityModalOpen, setIsCityModalOpen] = useState(false); - const [isInstallModalOpen, setIsInstallModalOpen] = useState(false); const [cartItems, setCartItems] = useState([]); const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0); @@ -55,23 +53,6 @@ export default function StoreHomePage() { .catch((e) => console.log('Usando ciudades iniciales:', e)); }, []); - const filteredProducts = products.filter((p) => { - if (searchQuery.trim()) { - const q = searchQuery.toLowerCase(); - const matchName = p.name.toLowerCase().includes(q); - const matchBrand = p.brand.toLowerCase().includes(q); - const matchSize = p.sizes.some((s) => s.includes(q)); - if (!matchName && !matchBrand && !matchSize) return false; - } - - if (activeCategory === 'flash') return p.isFlashDeal; - if (activeCategory === 'under120') return p.retailPrice <= 120000; - if (activeCategory !== 'all') { - return p.brand.toLowerCase() === activeCategory.toLowerCase(); - } - return true; - }); - const handleAddToCart = (product: Product, size: string) => { setCartItems((prev) => { const existingIdx = prev.findIndex( @@ -107,13 +88,27 @@ export default function StoreHomePage() { ); }; - const totalCartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0); + const filteredProducts = products.filter((p) => { + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + const matchName = p.name.toLowerCase().includes(q); + const matchBrand = p.brand.toLowerCase().includes(q); + const matchSize = p.sizes.some((s) => s.includes(q)); + if (!matchName && !matchBrand && !matchSize) return false; + } + + if (activeCategory === 'flash') return p.isFlashDeal; + if (activeCategory === 'under120') return p.retailPrice <= 120000; + if (activeCategory !== 'all') { + return p.brand.toLowerCase() === activeCategory.toLowerCase(); + } + return true; + }); return (
- {/* Navigation Header */} setIsCartOpen(true)} selectedCity={selectedCity} onOpenCitySelector={() => setIsCityModalOpen(true)} @@ -121,12 +116,9 @@ export default function StoreHomePage() { onSearchChange={setSearchQuery} /> - {/* Main Container */}
- {/* Flash Deals Hero Banner */} - {/* Value Props Strip */}
@@ -169,7 +161,6 @@ export default function StoreHomePage() {
- {/* Category & Brand Filters */}

@@ -187,7 +178,6 @@ export default function StoreHomePage() { />

- {/* Product Grid */} {filteredProducts.length === 0 ? (
@@ -219,7 +209,6 @@ export default function StoreHomePage() { )}
- {/* Footer */}
@@ -236,7 +225,6 @@ export default function StoreHomePage() {
- {/* Modals & Drawers */} setSelectedProduct(null)} @@ -265,19 +253,11 @@ export default function StoreHomePage() { onSelectCity={setSelectedCity} /> - {/* Mobile App Dock & Install Banner */} setIsCartOpen(true)} - onOpenInstallModal={() => setIsInstallModalOpen(true)} /> - setIsInstallModalOpen(false)} - /> - - {/* User Auth Modal */}
); diff --git a/sneaky-app/src/components/mobile/MobileBottomDock.tsx b/sneaky-app/src/components/mobile/MobileBottomDock.tsx index 20f7032..47281e8 100644 --- a/sneaky-app/src/components/mobile/MobileBottomDock.tsx +++ b/sneaky-app/src/components/mobile/MobileBottomDock.tsx @@ -1,68 +1,28 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import Link from 'next/link'; -import { Home, Search, ShoppingBag, MessageCircle, Download, User } from 'lucide-react'; +import { usePathname } from 'next/navigation'; +import { Home, Search, ShoppingBag, MessageCircle, User, Shield } from 'lucide-react'; import { useBrand } from '@/context/BrandContext'; +import { useAuth } from '@/context/AuthContext'; interface MobileBottomDockProps { cartCount: number; onOpenCart: () => void; onOpenSearch?: () => void; - onOpenInstallModal: () => void; + onOpenInstallModal?: () => void; } export const MobileBottomDock: React.FC = ({ cartCount, onOpenCart, onOpenSearch, - onOpenInstallModal, }) => { const { currentBrand } = useBrand(); + const { user, openAuthModal } = useAuth(); + const pathname = usePathname(); const [activeTab, setActiveTab] = useState('home'); - const [isInsideApp, setIsInsideApp] = useState(false); - - useEffect(() => { - const detectApp = () => { - if (typeof window === 'undefined') return false; - - // 1. Capacitor bridge object o plataforma nativa - const isCapacitor = - typeof (window as any).Capacitor !== 'undefined' || - !!(window as any).Capacitor?.isNativePlatform?.() || - (window as any).Capacitor?.getPlatform?.() === 'android' || - (window as any).Capacitor?.getPlatform?.() === 'ios'; - - // 2. Modo Standalone (PWA instalada o contenedor Android) - const isStandalone = - window.matchMedia?.('(display-mode: standalone)')?.matches || - (window.navigator as any)?.standalone === true || - document.referrer.includes('android-app://'); - - // 3. UserAgent personalizado o WebView embebida - const ua = (navigator.userAgent || '').toLowerCase(); - const isAppUA = - ua.includes('capacitor') || - ua.includes('capacitorapp') || - ua.includes('sneaky') || - ua.includes('; wv') || - ua.includes('version/4.0'); - - return isCapacitor || isStandalone || isAppUA; - }; - - if (detectApp()) { - setIsInsideApp(true); - } - - const timer = setTimeout(() => { - if (detectApp()) { - setIsInsideApp(true); - } - }, 300); - - return () => clearTimeout(timer); - }, []); const handleWhatsApp = () => { const text = encodeURIComponent( @@ -71,18 +31,25 @@ export const MobileBottomDock: React.FC = ({ window.open(`https://wa.me/${currentBrand.whatsappNumber}?text=${text}`, '_blank'); }; + const isAdmin = Boolean(user && (user.role === 'admin' || user.role === 'operator')); + const isAdminActive = pathname?.startsWith('/admin'); + const isHomeActive = pathname === '/' && activeTab === 'home'; + return ( -
+ ); }; diff --git a/sneaky-app/src/components/store/Navbar.tsx b/sneaky-app/src/components/store/Navbar.tsx index 9e1ddd1..34219f9 100644 --- a/sneaky-app/src/components/store/Navbar.tsx +++ b/sneaky-app/src/components/store/Navbar.tsx @@ -122,15 +122,17 @@ export const Navbar: React.FC = ({ )} - {/* Admin Link */} - - - Admin - + {/* Admin Link - Solo para Administradores */} + {user && (user.role === 'admin' || user.role === 'operator') && ( + + + Admin + + )} {/* Cart Button */} - {Object.values(BRANDS).map((b) => ( - - ))}
+ {/* Toast feedback */} {actionMessage && ( -
- {actionMessage} -
)} - {/* Live Simulator for Test Messages */} -
-
- - Simulador de Recepción en Tiempo Real + {/* Filter & Tabs Bar */} +
+ {/* Status Tabs */} +
+ + + + +
-
-
- - -
- -
- -
- setTestText(e.target.value)} - className="flex-1 bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden" - /> - -
-
+ {/* Group Selector Filter */} +
+ +
- {/* Intercepted Posts Feed */} -
-

Mensajes Procesados Recientemente

+ {/* Multi-Selection Bulk Actions Bar */} + {selectedPostIds.size > 0 && tabStatus === 'pending' && ( +
+
+ + {selectedPostIds.size} seleccionados + + + Listos para transferir al catálogo de la tienda + +
-
- {posts.map((post) => { +
+ + + +
+
+ )} + + {/* Posts List / Grid */} + {filteredPosts.length === 0 ? ( +
+
+ +
+
+

No hay mensajes en esta bandeja

+

+ Haz clic en "Sincronizar WhatsApp en Vivo" para extraer automáticamente las ofertas recientes de tus grupos seleccionados. +

+
+ +
+ ) : ( +
+ {filteredPosts.map((post) => { const extracted = post.extractedProduct; - const duplicateCheck = extracted - ? checkProductDuplicate( - { - name: extracted.name, - brand: extracted.brand, - sizes: extracted.sizes, - supplierPrice: extracted.supplierPrice, - }, - products - ) - : null; + const isSelected = selectedPostIds.has(post.id); + const isPublished = post.status === 'published'; + const isDiscarded = post.status === 'discarded'; return (
- {/* Header of post */} -
-
- - {post.channelName || 'Canal Mayorista'} - - {post.receivedAt} + {/* Header info */} +
+
+ {tabStatus === 'pending' && ( + + )} + +
+ + {post.channelName || 'Grupo WhatsApp'} + + {post.receivedAt} +
- {/* Anti-Duplicate Status Badge */} - {duplicateCheck && ( -
- {duplicateCheck.isDuplicate ? ( - <> - - Duplicado Detectado ({Math.round(duplicateCheck.similarityScore * 100)}% Similitud) - - ) : ( - <> - - Producto Nuevo (0% Duplicados) - - )} -
- )} +
+ {isPublished ? ( + + Publicado en Tienda + + ) : isDiscarded ? ( + + Descartado + + ) : ( + + Pendiente de Aprobación + + )} +
- {/* Content Grid */} -
- {/* Left: Raw WhatsApp Message (5 cols) */} -
- Mensaje de Entrada: -

- {post.rawMessage} -

- {post.rawImages.length > 0 && ( -
+ {/* Content Grid (Photo + AI extracted info) */} +
+ {/* Sneaker Image & Raw text */} +
+
+ {post.rawImages && post.rawImages[0] ? ( -
- )} + ) : ( +
Sin foto
+ )} +
+ +
+

+ {post.rawMessage} +

+
- {/* Right: AI Extracted & Pricing Calculation (7 cols) */} -
- {extracted ? ( -
-
-
- - {extracted.brand} • {extracted.category} - -

{extracted.name}

-
- - IA: {Math.round(extracted.confidence * 100)}% certeza - -
+ {/* AI Extraction & Product Info */} +
+
+
+ + {extracted?.brand || 'Nike'} • {extracted?.category || 'Sneakers'} + +

+ {extracted?.name || 'Zapatilla Urbana'} +

+
- {/* Pricing breakdown */} -
-
- Costo Base: - - {formatCOP(extracted.supplierPrice)} - -
-
- Precio Venta (+ Margen): - - {formatCOP(extracted.suggestedRetailPrice)} - -
-
- - {/* Sizes */} + {/* Pricing breakdown */} +
- - Tallas extraídas: + Costo Proveedor: + + {formatCOP(extracted?.supplierPrice || 90000)} -
- {extracted.sizes.map((s) => ( - - {s} - - ))} -
- - {/* Duplicate warning note */} - {duplicateCheck?.isDuplicate && ( -
- {duplicateCheck.message} -
- )} - - {/* Actions */} -
- - - +
+ Venta Sugerida: + + {formatCOP(extracted?.suggestedRetailPrice || 125000)} +
- ) : ( -

Extrayendo datos...

- )} + + {/* Sizes badge pills */} +
+ + Tallas Disponibles: + +
+ {extracted?.sizes && extracted.sizes.length > 0 ? ( + extracted.sizes.map((s) => ( + + {s} + + )) + ) : ( + 37 a 43 + )} +
+
+
+ + {/* Card Actions */} +
+ {!isPublished ? ( + <> + + + + + + + ) : ( +
+ + Disponible para clientes + + + Ver en Tienda + + +
+ )} +
); })}
-
+ )} + + {/* Edit Modal Before Publishing */} + {editingPost && ( +
+
+
+

+ + Personalizar antes de Publicar +

+ +
+ +
+
+ + setEditName(e.target.value)} + className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white font-bold outline-hidden focus:border-emerald-500" + /> +
+ +
+
+ + setEditBrand(e.target.value)} + className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-emerald-500" + /> +
+ +
+ + setEditPrice(Number(e.target.value))} + className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-emerald-300 font-black font-mono outline-hidden focus:border-emerald-500" + /> +
+
+ +
+ + setEditSizes(e.target.value)} + placeholder="38, 39, 40, 41, 42, 43" + className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-emerald-500" + /> +
+
+ +
+ + + +
+
+
+ )}
); } diff --git a/zapply-app/src/app/admin/layout.tsx b/zapply-app/src/app/admin/layout.tsx index fbe48b1..59802fa 100644 --- a/zapply-app/src/app/admin/layout.tsx +++ b/zapply-app/src/app/admin/layout.tsx @@ -11,15 +11,20 @@ import { CreditCard, ShoppingBag, ArrowLeft, - Sparkles, Store, Users, MessageSquare, Building2, + ShieldAlert, + LogIn, } from 'lucide-react'; +import { useAuth } from '@/context/AuthContext'; +import { AuthModal } from '@/components/auth/AuthModal'; export default function AdminLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + const { user, openAuthModal } = useAuth(); + const isAdmin = Boolean(user && (user.role === 'admin' || user.role === 'operator')); const NAV_ITEMS = [ { href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard }, @@ -34,6 +39,45 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) { href: '/admin/pedidos', label: 'Pedidos & Logística', icon: ShoppingBag }, ]; + if (!isAdmin) { + return ( +
+
+
+ +
+ +
+

Acceso Restringido para Administradores

+

+ Este panel de control es de uso exclusivo para el equipo administrativo y operadores logísticos autorizados. +

+
+ +
+ + + + + Volver a la Tienda + +
+
+ + +
+ ); + } + return (
{/* Sidebar */} @@ -96,6 +140,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{children}
+ +
); } diff --git a/zapply-app/src/app/api/whatsapp-feed/route.ts b/zapply-app/src/app/api/whatsapp-feed/route.ts new file mode 100644 index 0000000..8dbf018 --- /dev/null +++ b/zapply-app/src/app/api/whatsapp-feed/route.ts @@ -0,0 +1,247 @@ +import { NextResponse } from 'next/server'; +import { dataService } from '@/lib/data-service'; +import { parseSupplierMessage } from '@/lib/gemini-parser'; +import { SupplierPost, MonitoredWhatsAppGroup } from '@/types'; + +const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; +const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943'; +const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const fetchLive = searchParams.get('fetchLive') === 'true'; + const limit = parseInt(searchParams.get('limit') || '30', 10); + const filterJid = searchParams.get('groupJid'); + + // 1. Obtener grupos monitoreados locales + const monitoredGroups: MonitoredWhatsAppGroup[] = dataService.getMonitoredGroups(); + const monitoredJids = new Set( + monitoredGroups.filter((g) => g.isMonitored).map((g) => g.jid) + ); + + // Si no se pide en vivo, retornar lo almacenado localmente + if (!fetchLive) { + let posts = dataService.getSupplierPosts(); + if (filterJid) { + posts = posts.filter((p) => (p as any).channelJid === filterJid || p.channelName?.includes(filterJid)); + } + return NextResponse.json({ + success: true, + posts, + monitoredCount: monitoredJids.size, + }); + } + + // 2. Consultar Evolution API para obtener los mensajes recientes de los grupos + const msgUrl = `${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_INSTANCE_NAME}?page=1&limit=${limit}`; + const msgRes = await fetch(msgUrl, { + method: 'POST', + headers: { + apikey: EVOLUTION_API_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(filterJid ? { where: { key: { remoteJid: filterJid } } } : {}), + }); + + const msgData = await msgRes.json(); + const records = msgData.messages?.records || msgData.records || (Array.isArray(msgData) ? msgData : []); + + // 3. Mapear grupos para resolver nombres + const groupsMap = new Map(); + monitoredGroups.forEach((g) => groupsMap.set(g.jid, g.name)); + + if (groupsMap.size === 0) { + try { + const grpRes = await fetch( + `${EVOLUTION_API_URL}/group/fetchAllGroups/${EVOLUTION_INSTANCE_NAME}?getParticipants=false`, + { headers: { apikey: EVOLUTION_API_KEY } } + ); + const grpData = await grpRes.json(); + if (Array.isArray(grpData)) { + grpData.forEach((g: any) => { + const jid = g.id || g.jid; + const name = g.subject || g.name || 'Grupo WhatsApp'; + groupsMap.set(jid, name); + }); + } + } catch (e) {} + } + + const processedPosts: SupplierPost[] = []; + + for (const record of records) { + const remoteJid = record.key?.remoteJid || ''; + if (!remoteJid.includes('@g.us')) continue; + + if (monitoredJids.size > 0 && !filterJid && !monitoredJids.has(remoteJid)) { + continue; + } + + const text = + record.message?.conversation || + record.message?.extendedTextMessage?.text || + record.message?.imageMessage?.caption || + record.message?.videoMessage?.caption || + ''; + + const groupName = groupsMap.get(remoteJid) || record.pushName || 'Grupo Mayorista'; + const hasImage = Boolean(record.message?.imageMessage); + + if (!text && !hasImage) continue; + + let imageUrls: string[] = []; + + if (hasImage) { + try { + const mediaUrl = `${EVOLUTION_API_URL}/chat/getBase64FromMediaMessage/${EVOLUTION_INSTANCE_NAME}`; + const mediaRes = await fetch(mediaUrl, { + method: 'POST', + headers: { + apikey: EVOLUTION_API_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ message: record, convertToMp4: false }), + }); + const mediaData = await mediaRes.json(); + if (mediaData.base64) { + const mime = mediaData.mimetype || 'image/jpeg'; + imageUrls.push(`data:${mime};base64,${mediaData.base64}`); + } + } catch (mediaErr) { + console.warn('Error descargando imagen de WhatsApp:', mediaErr); + } + } + + if (imageUrls.length === 0) { + imageUrls.push('https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'); + } + + const extracted = await parseSupplierMessage(text || groupName); + + const timestamp = record.messageTimestamp + ? new Date(record.messageTimestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : 'Reciente'; + + const post: SupplierPost = { + id: `post-${record.key?.id || Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + channelName: groupName, + channelJid: remoteJid, + rawMessage: text || `[Foto de Calzado enviada en ${groupName}]`, + rawImages: imageUrls, + extractedProduct: extracted, + status: 'pending_review', + receivedAt: timestamp, + supplierName: groupName, + supplierPhone: record.key?.participant?.replace(/\D/g, '') || '573000000000', + }; + + processedPosts.push(post); + } + + if (processedPosts.length > 0) { + dataService.saveSupplierPosts(processedPosts); + } + + return NextResponse.json({ + success: true, + isLive: true, + totalFound: processedPosts.length, + posts: dataService.getSupplierPosts(), + }); + } catch (error: any) { + console.error('Error en /api/whatsapp-feed:', error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { action, post, posts, customRetailPrice, customName, customSizes } = body; + + // 1. PUBLICAR UN PRODUCTO A LA TIENDA (APP MÓVIL Y WEB) + if (action === 'publish' && post) { + const retailPrice = + customRetailPrice || + post.extractedProduct?.suggestedRetailPrice || + (post.extractedProduct?.supplierPrice ? post.extractedProduct.supplierPrice + 35000 : 125000); + + const newProduct = { + id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + name: customName || post.extractedProduct?.name || 'Zapatillas Urbanas', + brand: post.extractedProduct?.brand || 'Nike', + supplierPrice: post.extractedProduct?.supplierPrice || 90000, + retailPrice: retailPrice, + originalPrice: Math.round(retailPrice * 1.35), + sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], + images: Array.isArray(post.rawImages) && post.rawImages.length > 0 ? post.rawImages : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], + description: `Calzado garantizado de alta calidad para despacho inmediato en 24 horas y pago contraentrega.`, + category: post.extractedProduct?.category || 'Calzado / Sneakers', + inStock: true, + isActive: true, + originBadge: `Bodega ${post.channelName || 'Cali'}`, + supplierName: post.supplierName || post.channelName || 'Proveedor Mayorista', + supplierPhone: post.supplierPhone || '573167455877', + }; + + dataService.addProduct(newProduct); + dataService.updateSupplierPostStatus(post.id, 'published'); + + return NextResponse.json({ + success: true, + message: '¡Producto publicado exitosamente en la App Móvil y Web!', + product: newProduct, + }); + } + + // 2. PUBLICACIÓN MASIVA (BULK) + if (action === 'bulk-publish' && Array.isArray(posts)) { + const createdProducts = []; + for (const p of posts) { + const retailPrice = + p.extractedProduct?.suggestedRetailPrice || + (p.extractedProduct?.supplierPrice ? p.extractedProduct.supplierPrice + 35000 : 125000); + + const newProd = { + id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + name: p.extractedProduct?.name || 'Zapatillas Urbanas', + brand: p.extractedProduct?.brand || 'Nike', + supplierPrice: p.extractedProduct?.supplierPrice || 90000, + retailPrice, + originalPrice: Math.round(retailPrice * 1.35), + sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], + images: p.rawImages || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], + description: `Calzado garantizado para despacho inmediato en 24 horas con pago contraentrega.`, + category: p.extractedProduct?.category || 'Calzado / Sneakers', + inStock: true, + isActive: true, + originBadge: `Bodega ${p.channelName || 'Cali'}`, + supplierName: p.supplierName || p.channelName || 'Proveedor Mayorista', + supplierPhone: p.supplierPhone || '573167455877', + }; + + dataService.addProduct(newProd); + dataService.updateSupplierPostStatus(p.id, 'published'); + createdProducts.push(newProd); + } + + return NextResponse.json({ + success: true, + message: `¡Se publicaron ${createdProducts.length} productos en la App Móvil y Web!`, + products: createdProducts, + }); + } + + // 3. DESCARTAR POST + if (action === 'discard' && post?.id) { + dataService.updateSupplierPostStatus(post.id, 'discarded'); + return NextResponse.json({ success: true, message: 'Mensaje descartado' }); + } + + return NextResponse.json({ success: false, error: 'Acción no válida' }, { status: 400 }); + } catch (err: any) { + console.error('Error en POST /api/whatsapp-feed:', err); + return NextResponse.json({ success: false, error: err.message }, { status: 500 }); + } +} diff --git a/zapply-app/src/app/page.tsx b/zapply-app/src/app/page.tsx index e4b60ff..e15608d 100644 --- a/zapply-app/src/app/page.tsx +++ b/zapply-app/src/app/page.tsx @@ -9,7 +9,6 @@ import { ProductModal } from '@/components/store/ProductModal'; import { CartDrawer } from '@/components/store/CartDrawer'; import { CitySelectorModal } from '@/components/store/CitySelectorModal'; import { MobileBottomDock } from '@/components/mobile/MobileBottomDock'; -import { MobileInstallBanner } from '@/components/mobile/MobileInstallBanner'; import { AuthModal } from '@/components/auth/AuthModal'; import { Product, City, CartItem } from '@/types'; import { INITIAL_PRODUCTS, INITIAL_CITIES } from '@/lib/data-service'; @@ -28,7 +27,6 @@ export default function StoreHomePage() { const [selectedProduct, setSelectedProduct] = useState(null); const [isCartOpen, setIsCartOpen] = useState(false); const [isCityModalOpen, setIsCityModalOpen] = useState(false); - const [isInstallModalOpen, setIsInstallModalOpen] = useState(false); const [cartItems, setCartItems] = useState([]); const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0); @@ -107,13 +105,11 @@ export default function StoreHomePage() { ); }; - const totalCartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0); - return (
{/* Navigation Header */} setIsCartOpen(true)} selectedCity={selectedCity} onOpenCitySelector={() => setIsCityModalOpen(true)} @@ -265,16 +261,10 @@ export default function StoreHomePage() { onSelectCity={setSelectedCity} /> - {/* Mobile App Dock & Install Banner */} + {/* Mobile App Dock */} setIsCartOpen(true)} - onOpenInstallModal={() => setIsInstallModalOpen(true)} - /> - - setIsInstallModalOpen(false)} /> {/* User Auth Modal */} diff --git a/zapply-app/src/components/mobile/MobileBottomDock.tsx b/zapply-app/src/components/mobile/MobileBottomDock.tsx index b177bd6..b43ebfc 100644 --- a/zapply-app/src/components/mobile/MobileBottomDock.tsx +++ b/zapply-app/src/components/mobile/MobileBottomDock.tsx @@ -1,68 +1,28 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import Link from 'next/link'; -import { Home, Search, ShoppingBag, MessageCircle, Download, User } from 'lucide-react'; +import { usePathname } from 'next/navigation'; +import { Home, Search, ShoppingBag, MessageCircle, User, Shield } from 'lucide-react'; import { useBrand } from '@/context/BrandContext'; +import { useAuth } from '@/context/AuthContext'; interface MobileBottomDockProps { cartCount: number; onOpenCart: () => void; onOpenSearch?: () => void; - onOpenInstallModal: () => void; + onOpenInstallModal?: () => void; } export const MobileBottomDock: React.FC = ({ cartCount, onOpenCart, onOpenSearch, - onOpenInstallModal, }) => { const { currentBrand } = useBrand(); + const { user, openAuthModal } = useAuth(); + const pathname = usePathname(); const [activeTab, setActiveTab] = useState('home'); - const [isInsideApp, setIsInsideApp] = useState(false); - - useEffect(() => { - const detectApp = () => { - if (typeof window === 'undefined') return false; - - // 1. Capacitor bridge object o plataforma nativa - const isCapacitor = - typeof (window as any).Capacitor !== 'undefined' || - !!(window as any).Capacitor?.isNativePlatform?.() || - (window as any).Capacitor?.getPlatform?.() === 'android' || - (window as any).Capacitor?.getPlatform?.() === 'ios'; - - // 2. Modo Standalone (PWA instalada o contenedor Android) - const isStandalone = - window.matchMedia?.('(display-mode: standalone)')?.matches || - (window.navigator as any)?.standalone === true || - document.referrer.includes('android-app://'); - - // 3. UserAgent personalizado o WebView embebida - const ua = (navigator.userAgent || '').toLowerCase(); - const isAppUA = - ua.includes('capacitor') || - ua.includes('capacitorapp') || - ua.includes('zapply') || - ua.includes('; wv') || - ua.includes('version/4.0'); - - return isCapacitor || isStandalone || isAppUA; - }; - - if (detectApp()) { - setIsInsideApp(true); - } - - const timer = setTimeout(() => { - if (detectApp()) { - setIsInsideApp(true); - } - }, 300); - - return () => clearTimeout(timer); - }, []); const handleWhatsApp = () => { const text = encodeURIComponent( @@ -71,20 +31,29 @@ export const MobileBottomDock: React.FC = ({ window.open(`https://wa.me/${currentBrand.whatsappNumber}?text=${text}`, '_blank'); }; + const isAdmin = Boolean(user && (user.role === 'admin' || user.role === 'operator')); + const isAdminActive = pathname?.startsWith('/admin'); + const isHomeActive = pathname === '/' && activeTab === 'home'; + return ( -
+
+ ); }; diff --git a/zapply-app/src/components/store/Navbar.tsx b/zapply-app/src/components/store/Navbar.tsx index 55d13a9..38d18a3 100644 --- a/zapply-app/src/components/store/Navbar.tsx +++ b/zapply-app/src/components/store/Navbar.tsx @@ -122,15 +122,17 @@ export const Navbar: React.FC = ({ )} - {/* Admin Link */} - - - Admin - + {/* Admin Link - Solo para Administradores */} + {user && (user.role === 'admin' || user.role === 'operator') && ( + + + Admin + + )} {/* Cart Button */}