feat: sincronizacion en vivo de whatsapp feed, seleccion masiva de calzados, proteccion de boton admin por roles y catalogo limpio

This commit is contained in:
AI Bot 2026-08-28 10:59:38 -05:00
parent 9b74396685
commit 5b0c675431
31 changed files with 3834 additions and 3374 deletions

View File

@ -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<SupplierPost[]>(INITIAL_SUPPLIER_POSTS);
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
const [isProcessing, setIsProcessing] = useState(false);
const [posts, setPosts] = useState<SupplierPost[]>([]);
const [monitoredGroups, setMonitoredGroups] = useState<MonitoredWhatsAppGroup[]>([]);
const [selectedGroupFilter, setSelectedGroupFilter] = useState<string>('all');
const [tabStatus, setTabStatus] = useState<'pending' | 'published' | 'discarded'>('pending');
const [isLoading, setIsLoading] = useState(false);
const [isSyncingLive, setIsSyncingLive] = useState(false);
const [actionMessage, setActionMessage] = useState<string | null>(null);
// Multi-selection state
const [selectedPostIds, setSelectedPostIds] = useState<Set<string>>(new Set());
// Edit Modal State
const [editingPost, setEditingPost] = useState<SupplierPost | null>(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<BrandId | 'all'>('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 (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-black text-white flex items-center gap-2">
<Radio className="w-6 h-6 text-cyan-400 animate-pulse" />
<span>Feed de Entrada & Publicador Multi-Marca</span>
</h1>
<p className="text-xs text-slate-400 mt-1">
Recepción automática con IA, prevención de duplicados y distribución hacia las 4 marcas (<strong>Almeira Store</strong>, <strong>Sneaky</strong>, <strong>Zapply</strong>, <strong>DripZone</strong>).
</p>
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-xl bg-cyan-500/20 text-cyan-400 flex items-center justify-center border border-cyan-500/30 shadow-[0_0_15px_rgba(6,182,212,0.3)]">
<Radio className="w-5 h-5 animate-pulse" />
</div>
<div>
<h1 className="text-2xl font-black text-white">Feed de Mensajes de WhatsApp • Almeira Store</h1>
<p className="text-xs text-slate-400">
Visualiza los mensajes de tus grupos de proveedores y selecciona los calzados que deseas publicar en la app móvil.
</p>
</div>
</div>
</div>
{/* Target Brand Selector Pill Bar */}
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl">
<span className="text-[10px] font-bold text-slate-400 pl-2 pr-1 flex items-center gap-1">
<Layers className="w-3 h-3" /> Destino:
</span>
<button
onClick={() => setTargetBrand('all')}
className={`text-xs font-bold px-2.5 py-1 rounded-xl transition-all cursor-pointer ${
targetBrand === 'all' ? 'bg-white text-slate-950 shadow-sm' : 'text-slate-400 hover:text-white'
}`}
{/* Action Controls */}
<div className="flex items-center gap-2.5 flex-wrap">
<Link
href="/admin/grupos-whatsapp"
className="bg-slate-900 hover:bg-slate-800 border border-slate-700 text-slate-300 font-bold text-xs px-3.5 py-2.5 rounded-xl flex items-center gap-1.5 transition-all"
>
4 Marcas
<MessageSquare className="w-4 h-4 text-cyan-400" />
<span>Gestionar Grupos</span>
</Link>
<button
onClick={handleSyncLive}
disabled={isSyncingLive}
className="bg-gradient-to-r from-cyan-600 to-blue-600 hover:opacity-95 text-white font-black text-xs px-4 py-2.5 rounded-xl flex items-center gap-2 shadow-lg shadow-cyan-600/30 cursor-pointer transition-all active:scale-95 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isSyncingLive ? 'animate-spin' : ''}`} />
<span>{isSyncingLive ? 'Consultando Grupos...' : 'Sincronizar WhatsApp en Vivo'}</span>
</button>
{Object.values(BRANDS).map((b) => (
<button
key={b.id}
onClick={() => setTargetBrand(b.id as BrandId)}
className={`text-xs font-bold px-2.5 py-1 rounded-xl transition-all cursor-pointer ${
targetBrand === b.id ? `bg-gradient-to-r ${b.colors.primaryGradient} text-white shadow-sm` : 'text-slate-400 hover:text-white'
}`}
>
{b.shortName}
</button>
))}
</div>
</div>
{/* Toast feedback */}
{actionMessage && (
<div className="bg-cyan-950/80 border border-cyan-500 text-cyan-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-lg">
<span>{actionMessage}</span>
<button onClick={() => setActionMessage(null)} className="font-bold text-white cursor-pointer">
<div className="bg-cyan-950/80 border border-cyan-500/50 text-cyan-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-xl">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-cyan-400" />
<span>{actionMessage}</span>
</div>
<button
onClick={() => setActionMessage(null)}
className="text-white hover:text-cyan-300 font-bold px-2 cursor-pointer"
>
✕
</button>
</div>
)}
{/* Live Simulator for Test Messages */}
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4">
<div className="flex items-center space-x-2 text-cyan-400 font-bold text-xs uppercase tracking-wider">
<Sparkles className="w-4 h-4 text-cyan-400" />
<span>Simulador de Recepción en Tiempo Real</span>
{/* Filter & Tabs Bar */}
<div className="bg-slate-900/90 border border-slate-800 rounded-3xl p-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-4 backdrop-blur-xl">
{/* Status Tabs */}
<div className="flex items-center gap-1 bg-slate-950 p-1 rounded-2xl border border-slate-800 text-xs font-bold">
<button
onClick={() => setTabStatus('pending')}
className={`px-3.5 py-2 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
tabStatus === 'pending'
? 'bg-cyan-600 text-white shadow'
: 'text-slate-400 hover:text-white'
}`}
>
<span>📥 Pendientes</span>
<span className="bg-cyan-950 text-cyan-300 text-[10px] px-1.5 py-0.5 rounded-full font-mono">
{pendingCount}
</span>
</button>
<button
onClick={() => setTabStatus('published')}
className={`px-3.5 py-2 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
tabStatus === 'published'
? 'bg-emerald-600 text-white shadow'
: 'text-slate-400 hover:text-white'
}`}
>
<span>✅ Publicados en Tienda</span>
<span className="bg-emerald-950 text-emerald-300 text-[10px] px-1.5 py-0.5 rounded-full font-mono">
{publishedCount}
</span>
</button>
<button
onClick={() => setTabStatus('discarded')}
className={`px-3.5 py-2 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
tabStatus === 'discarded'
? 'bg-slate-800 text-white shadow'
: 'text-slate-400 hover:text-white'
}`}
>
<span>🗑️ Descartados</span>
<span className="bg-slate-900 text-slate-400 text-[10px] px-1.5 py-0.5 rounded-full font-mono">
{discardedCount}
</span>
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-3">
<div className="md:col-span-4">
<label className="block text-[11px] font-bold text-slate-400 mb-1">Canal de Entrada:</label>
<select
value={testGroup}
onChange={(e) => setTestGroup(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
>
<option value="Canal Mayorista A">Canal Mayorista A</option>
<option value="Canal Mayorista B">Canal Mayorista B</option>
</select>
</div>
<div className="md:col-span-8">
<label className="block text-[11px] font-bold text-slate-400 mb-1">Texto del Mensaje de WhatsApp:</label>
<div className="flex gap-2">
<input
type="text"
value={testText}
onChange={(e) => 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"
/>
<button
onClick={handleSimulateIncoming}
disabled={isProcessing}
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-xs px-4 py-2 rounded-xl flex items-center space-x-1.5 shrink-0 transition-all cursor-pointer shadow-md"
>
<Send className="w-3.5 h-3.5" />
<span>Simular Recepción</span>
</button>
</div>
</div>
{/* Group Selector Filter */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-cyan-400 shrink-0" />
<select
value={selectedGroupFilter}
onChange={(e) => setSelectedGroupFilter(e.target.value)}
className="bg-slate-950 border border-slate-800 text-white text-xs font-semibold rounded-xl px-3 py-2 outline-hidden cursor-pointer"
>
<option value="all">Todos los Grupos de WhatsApp</option>
{monitoredGroups.map((g) => (
<option key={g.id || g.jid} value={g.jid}>
{g.name}
</option>
))}
</select>
</div>
</div>
{/* Intercepted Posts Feed */}
<div className="space-y-4">
<h2 className="font-bold text-white text-base">Mensajes Procesados Recientemente</h2>
{/* Multi-Selection Bulk Actions Bar */}
{selectedPostIds.size > 0 && tabStatus === 'pending' && (
<div className="bg-gradient-to-r from-cyan-950/90 to-slate-900 border border-cyan-500/40 rounded-2xl p-3.5 flex items-center justify-between animate-in fade-in shadow-2xl">
<div className="flex items-center gap-2.5">
<span className="bg-cyan-600 text-white text-xs font-black px-2.5 py-1 rounded-lg">
{selectedPostIds.size} seleccionados
</span>
<span className="text-xs text-slate-300 font-semibold hidden sm:inline">
Listos para transferir al catálogo de la tienda
</span>
</div>
<div className="grid grid-cols-1 gap-4">
{posts.map((post) => {
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedPostIds(new Set())}
className="text-slate-400 hover:text-white text-xs font-bold px-3 py-1.5 rounded-xl cursor-pointer"
>
Desmarcar
</button>
<button
onClick={handleBulkPublish}
className="bg-gradient-to-r from-cyan-600 to-blue-600 hover:opacity-95 text-white text-xs font-black px-4 py-2 rounded-xl flex items-center gap-1.5 shadow-lg shadow-cyan-600/30 cursor-pointer transition-all active:scale-95"
>
<Plus className="w-4 h-4" />
<span>Publicar Seleccionados ({selectedPostIds.size})</span>
</button>
</div>
</div>
)}
{/* Posts List / Grid */}
{filteredPosts.length === 0 ? (
<div className="bg-slate-900/60 rounded-3xl p-12 text-center border border-slate-800 space-y-4">
<div className="w-16 h-16 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 text-cyan-400 flex items-center justify-center mx-auto">
<MessageSquare className="w-8 h-8" />
</div>
<div className="space-y-1">
<h3 className="font-black text-white text-base">No hay mensajes en esta bandeja</h3>
<p className="text-xs text-slate-400 max-w-md mx-auto">
Haz clic en <strong>"Sincronizar WhatsApp en Vivo"</strong> para extraer automáticamente las ofertas recientes de tus grupos seleccionados.
</p>
</div>
<button
onClick={handleSyncLive}
disabled={isSyncingLive}
className="bg-cyan-600 hover:bg-cyan-500 text-white font-bold text-xs px-5 py-2.5 rounded-xl inline-flex items-center gap-2 cursor-pointer shadow-md"
>
<RefreshCw className={`w-4 h-4 ${isSyncingLive ? 'animate-spin' : ''}`} />
<span>Escanear Grupos Ahora</span>
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{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 (
<div
key={post.id}
className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4 hover:border-slate-700 transition-all"
className={`bg-slate-900 border rounded-3xl p-5 space-y-4 transition-all ${
isSelected
? 'border-cyan-500 shadow-[0_0_20px_rgba(6,182,212,0.2)]'
: isPublished
? 'border-emerald-500/30 bg-emerald-950/10'
: 'border-slate-800 hover:border-slate-700'
}`}
>
{/* Header of post */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800 pb-3">
<div className="flex items-center space-x-2">
<span className="bg-slate-800 text-slate-200 text-xs font-black px-2.5 py-1 rounded-full border border-slate-700">
{post.channelName || 'Canal Mayorista'}
</span>
<span className="text-xs text-slate-400">{post.receivedAt}</span>
{/* Header info */}
<div className="flex items-center justify-between gap-2 border-b border-slate-800 pb-3">
<div className="flex items-center gap-2">
{tabStatus === 'pending' && (
<button
onClick={() => handleToggleSelect(post.id)}
className="text-cyan-400 hover:text-cyan-300 cursor-pointer"
>
{isSelected ? (
<CheckSquare className="w-5 h-5 fill-cyan-600 text-white" />
) : (
<Square className="w-5 h-5 text-slate-600" />
)}
</button>
)}
<div>
<span className="bg-cyan-950 text-cyan-300 text-[10px] font-black px-2 py-0.5 rounded-full border border-cyan-500/30 inline-block">
{post.channelName || 'Grupo WhatsApp'}
</span>
<span className="text-[10px] text-slate-500 ml-2 font-mono">{post.receivedAt}</span>
</div>
</div>
{/* Anti-Duplicate Status Badge */}
{duplicateCheck && (
<div
className={`text-xs font-bold px-3 py-1 rounded-full flex items-center gap-1.5 ${
duplicateCheck.isDuplicate
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
}`}
>
{duplicateCheck.isDuplicate ? (
<>
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
<span>Duplicado Detectado ({Math.round(duplicateCheck.similarityScore * 100)}% Similitud)</span>
</>
) : (
<>
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
<span>Producto Nuevo (0% Duplicados)</span>
</>
)}
</div>
)}
<div>
{isPublished ? (
<span className="bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 text-[10px] font-bold px-2.5 py-0.5 rounded-full flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Publicado en Tienda
</span>
) : isDiscarded ? (
<span className="bg-slate-800 text-slate-400 text-[10px] font-bold px-2.5 py-0.5 rounded-full">
Descartado
</span>
) : (
<span className="bg-amber-500/20 text-amber-300 border border-amber-500/40 text-[10px] font-bold px-2.5 py-0.5 rounded-full flex items-center gap-1">
<Sparkles className="w-3 h-3" /> Pendiente de Aprobación
</span>
)}
</div>
</div>
{/* Content Grid */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
{/* Left: Raw WhatsApp Message (5 cols) */}
<div className="md:col-span-5 bg-slate-950 rounded-2xl p-4 space-y-2 border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400">Mensaje de Entrada:</span>
<p className="text-xs text-slate-300 font-mono whitespace-pre-line leading-relaxed">
{post.rawMessage}
</p>
{post.rawImages.length > 0 && (
<div className="pt-2">
{/* Content Grid (Photo + AI extracted info) */}
<div className="grid grid-cols-1 sm:grid-cols-12 gap-4">
{/* Sneaker Image & Raw text */}
<div className="sm:col-span-5 space-y-2">
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-slate-950 border border-slate-800 flex items-center justify-center group">
{post.rawImages && post.rawImages[0] ? (
<img
src={post.rawImages[0]}
alt=""
className="w-24 h-24 rounded-xl object-cover bg-slate-900 border border-slate-700"
alt={extracted?.name || 'Zapatilla'}
className="w-full h-full object-cover group-hover:scale-105 transition-all"
/>
</div>
)}
) : (
<div className="text-slate-600 text-xs">Sin foto</div>
)}
</div>
<div className="bg-slate-950 rounded-xl p-2.5 border border-slate-800/80">
<p className="text-[11px] text-slate-400 font-mono whitespace-pre-line line-clamp-3">
{post.rawMessage}
</p>
</div>
</div>
{/* Right: AI Extracted & Pricing Calculation (7 cols) */}
<div className="md:col-span-7 space-y-3">
{extracted ? (
<div className="bg-slate-950 rounded-2xl p-4 border border-slate-800 space-y-3">
<div className="flex items-start justify-between">
<div>
<span className="text-[10px] font-bold text-slate-400 uppercase">
{extracted.brand} • {extracted.category}
</span>
<h3 className="text-base font-black text-white">{extracted.name}</h3>
</div>
<span className="text-xs font-mono bg-purple-500/20 text-purple-300 border border-purple-500/30 px-2 py-0.5 rounded font-bold">
IA: {Math.round(extracted.confidence * 100)}% certeza
</span>
</div>
{/* AI Extraction & Product Info */}
<div className="sm:col-span-7 space-y-3 flex flex-col justify-between">
<div className="space-y-2">
<div>
<span className="text-[10px] font-black uppercase text-cyan-400 tracking-wider">
{extracted?.brand || 'Nike'} • {extracted?.category || 'Sneakers'}
</span>
<h3 className="text-base font-black text-white leading-tight">
{extracted?.name || 'Zapatilla Urbana'}
</h3>
</div>
{/* Pricing breakdown */}
<div className="grid grid-cols-2 gap-3 bg-slate-900 p-3 rounded-xl border border-slate-800">
<div>
<span className="text-[10px] text-slate-400 block font-semibold">Costo Base:</span>
<span className="text-sm font-bold text-slate-300 font-mono">
{formatCOP(extracted.supplierPrice)}
</span>
</div>
<div>
<span className="text-[10px] text-emerald-400 block font-bold">Precio Venta (+ Margen):</span>
<span className="text-base font-black text-emerald-300 font-mono">
{formatCOP(extracted.suggestedRetailPrice)}
</span>
</div>
</div>
{/* Sizes */}
{/* Pricing breakdown */}
<div className="bg-slate-950 p-3 rounded-2xl border border-slate-800 grid grid-cols-2 gap-2">
<div>
<span className="text-[10px] text-slate-400 font-semibold uppercase block mb-1">
Tallas extraídas:
<span className="text-[10px] text-slate-400 block font-bold">Costo Proveedor:</span>
<span className="text-xs font-bold text-slate-300 font-mono">
{formatCOP(extracted?.supplierPrice || 90000)}
</span>
<div className="flex gap-1 flex-wrap">
{extracted.sizes.map((s) => (
<span key={s} className="bg-slate-900 text-white font-bold text-xs px-2 py-0.5 rounded border border-slate-800">
{s}
</span>
))}
</div>
</div>
{/* Duplicate warning note */}
{duplicateCheck?.isDuplicate && (
<div className="bg-amber-950/40 border border-amber-800/60 p-2.5 rounded-xl text-xs text-amber-200">
{duplicateCheck.message}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t border-slate-800">
<button
onClick={() => handlePublishFacebook(post)}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-bold text-xs py-2.5 px-3 rounded-xl flex items-center justify-center space-x-1.5 transition-all cursor-pointer shadow-sm"
>
<Share2 className="w-3.5 h-3.5" />
<span>Publicar en Facebook</span>
</button>
<button
onClick={() => handleAddToStore(post)}
className="flex-1 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-xs py-2.5 px-3 rounded-xl flex items-center justify-center space-x-1.5 transition-all cursor-pointer shadow-md"
>
<Plus className="w-3.5 h-3.5" />
<span>Publicar a la Tienda</span>
</button>
<div>
<span className="text-[10px] text-emerald-400 block font-bold">Venta Sugerida:</span>
<span className="text-sm font-black text-emerald-300 font-mono">
{formatCOP(extracted?.suggestedRetailPrice || 125000)}
</span>
</div>
</div>
) : (
<p className="text-xs text-slate-500">Extrayendo datos...</p>
)}
{/* Sizes badge pills */}
<div>
<span className="text-[10px] font-bold text-slate-400 uppercase block mb-1">
Tallas Disponibles:
</span>
<div className="flex gap-1 flex-wrap">
{extracted?.sizes && extracted.sizes.length > 0 ? (
extracted.sizes.map((s) => (
<span
key={s}
className="bg-slate-950 text-white font-bold text-[11px] px-2 py-0.5 rounded-lg border border-slate-800"
>
{s}
</span>
))
) : (
<span className="text-xs text-slate-500">37 a 43</span>
)}
</div>
</div>
</div>
{/* Card Actions */}
<div className="pt-2 border-t border-slate-800 flex items-center gap-2">
{!isPublished ? (
<>
<button
onClick={() => handlePublishPost(post)}
className="flex-1 bg-gradient-to-r from-cyan-600 to-blue-600 hover:opacity-95 text-white font-black text-xs py-2.5 px-3 rounded-xl flex items-center justify-center gap-1.5 shadow-md shadow-cyan-600/30 cursor-pointer transition-all active:scale-95"
>
<Plus className="w-3.5 h-3.5" />
<span>Publicar en la App</span>
</button>
<button
onClick={() => handleOpenEdit(post)}
title="Editar antes de publicar"
className="bg-slate-800 hover:bg-slate-700 text-slate-300 p-2.5 rounded-xl cursor-pointer transition-all"
>
<Edit3 className="w-4 h-4" />
</button>
<button
onClick={() => handleDiscardPost(post)}
title="Descartar"
className="bg-slate-800 hover:bg-red-950/40 text-slate-400 hover:text-red-400 p-2.5 rounded-xl cursor-pointer transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</>
) : (
<div className="w-full flex items-center justify-between text-xs">
<span className="text-emerald-400 font-bold flex items-center gap-1">
<CheckCircle2 className="w-4 h-4" /> Disponible para clientes
</span>
<Link
href="/"
target="_blank"
className="text-cyan-400 hover:underline font-bold flex items-center gap-1"
>
<span>Ver en Tienda</span>
<ArrowUpRight className="w-3 h-3" />
</Link>
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Edit Modal Before Publishing */}
{editingPost && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-slate-900 border border-cyan-500/40 rounded-3xl max-w-lg w-full p-6 space-y-5 shadow-2xl animate-in zoom-in-95">
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
<h3 className="font-black text-white text-base flex items-center gap-2">
<Edit3 className="w-5 h-5 text-cyan-400" />
<span>Personalizar antes de Publicar</span>
</h3>
<button
onClick={() => setEditingPost(null)}
className="text-slate-400 hover:text-white font-bold"
>
✕
</button>
</div>
<div className="space-y-3.5 text-xs">
<div>
<label className="font-bold text-slate-300 block mb-1">Nombre Comercial del Modelo:</label>
<input
type="text"
value={editName}
onChange={(e) => 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"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="font-bold text-slate-300 block mb-1">Marca:</label>
<input
type="text"
value={editBrand}
onChange={(e) => 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"
/>
</div>
<div>
<label className="font-bold text-emerald-400 block mb-1">Precio de Venta (COP):</label>
<input
type="number"
value={editPrice}
onChange={(e) => setEditPrice(Number(e.target.value))}
className="w-full bg-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"
/>
</div>
</div>
<div>
<label className="font-bold text-slate-300 block mb-1">Tallas (separadas por coma):</label>
<input
type="text"
value={editSizes}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex items-center gap-2.5 pt-3 border-t border-slate-800">
<button
onClick={() => setEditingPost(null)}
className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 font-bold text-xs py-3 rounded-xl cursor-pointer"
>
Cancelar
</button>
<button
onClick={() => {
const sizesArray = editSizes
.split(',')
.map((s) => s.trim())
.filter(Boolean);
handlePublishPost(editingPost, {
name: editName,
brand: editBrand,
retailPrice: editPrice,
sizes: sizesArray,
});
}}
className="flex-1 bg-gradient-to-r from-cyan-600 to-blue-600 hover:opacity-95 text-white font-black text-xs py-3 rounded-xl shadow-lg shadow-cyan-600/30 cursor-pointer"
>
Guardar y Publicar en la App
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -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 (
<div className="min-h-screen bg-slate-950 text-white flex flex-col items-center justify-center p-4">
<div className="max-w-md w-full bg-slate-900/90 border border-cyan-500/30 rounded-3xl p-8 text-center space-y-6 shadow-2xl backdrop-blur-xl">
<div className="w-16 h-16 rounded-2xl bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 flex items-center justify-center mx-auto shadow-inner">
<ShieldAlert className="w-8 h-8" />
</div>
<div className="space-y-2">
<h2 className="text-xl font-black tracking-tight text-white">Acceso Restringido para Administradores</h2>
<p className="text-xs text-slate-400 leading-relaxed">
Este panel de control es de uso exclusivo para el equipo administrativo y operadores logísticos autorizados.
</p>
</div>
<div className="space-y-3 pt-2">
<button
onClick={openAuthModal}
className="w-full bg-gradient-to-r from-cyan-500 to-blue-600 hover:opacity-95 text-white font-black text-xs py-3.5 px-4 rounded-xl flex items-center justify-center gap-2 shadow-lg shadow-cyan-600/30 cursor-pointer transition-all active:scale-95"
>
<LogIn className="w-4 h-4" />
<span>Ingresar como Administrador</span>
</button>
<Link
href="/"
className="w-full bg-slate-800 hover:bg-slate-700 text-slate-300 font-bold text-xs py-3 px-4 rounded-xl flex items-center justify-center gap-2 transition-all block"
>
<ArrowLeft className="w-4 h-4" />
<span>Volver a la Tienda</span>
</Link>
</div>
</div>
<AuthModal />
</div>
);
}
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col md:flex-row font-sans">
{/* Sidebar */}
@ -96,6 +140,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<main className="flex-1 p-4 sm:p-8 overflow-y-auto bg-slate-950">
{children}
</main>
<AuthModal />
</div>
);
}

View File

@ -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<string, string>();
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 });
}
}

View File

@ -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<Product | null>(null);
const [isCartOpen, setIsCartOpen] = useState(false);
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
const [isInstallModalOpen, setIsInstallModalOpen] = useState(false);
const [cartItems, setCartItems] = useState<CartItem[]>([]);
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 */}
<MobileBottomDock
cartCount={cartCount}
onOpenCart={() => setIsCartOpen(true)}
onOpenInstallModal={() => setIsInstallModalOpen(true)}
/>
<MobileInstallBanner
isOpen={isInstallModalOpen}
onClose={() => setIsInstallModalOpen(false)}
/>
{/* User Auth Modal */}

View File

@ -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<MobileBottomDockProps> = ({
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<MobileBottomDockProps> = ({
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 (
<div className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-slate-950/90 backdrop-blur-xl border-t border-slate-800/80 px-3 py-2">
<nav
aria-label="Navegación principal móvil"
className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-slate-950/90 backdrop-blur-xl border-t border-slate-800/80 px-3 py-2"
>
<div className="flex items-center justify-around">
{/* 1. Inicio */}
<Link
href="/"
onClick={() => setActiveTab('home')}
className={`flex flex-col items-center gap-0.5 cursor-pointer transition-all ${
activeTab === 'home' ? 'text-white' : 'text-slate-400 hover:text-slate-200'
isHomeActive ? 'text-white' : 'text-slate-400 hover:text-slate-200'
}`}
>
<Home className={`w-5 h-5 ${activeTab === 'home' ? 'stroke-[2.5]' : ''}`} />
<Home className={`w-5 h-5 ${isHomeActive ? 'stroke-[2.5] text-white' : ''}`} />
<span className="text-[10px] font-bold">Inicio</span>
</Link>
@ -109,12 +76,14 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
setActiveTab('cart');
onOpenCart();
}}
className="relative flex flex-col items-center gap-0.5 cursor-pointer text-slate-400 hover:text-white"
className="relative flex flex-col items-center gap-0.5 cursor-pointer text-slate-400 hover:text-white transition-all"
>
<div className="relative">
<ShoppingBag className="w-5 h-5 text-white" />
{cartCount > 0 && (
<span className={`absolute -top-1.5 -right-2 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-black text-[9px] w-4 h-4 rounded-full flex items-center justify-center shadow-md animate-pulse`}>
<span
className={`absolute -top-1.5 -right-2 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-black text-[9px] w-4 h-4 rounded-full flex items-center justify-center shadow-md animate-pulse`}
>
{cartCount}
</span>
)}
@ -125,34 +94,38 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
{/* 4. WhatsApp Asesoría */}
<button
onClick={handleWhatsApp}
className="flex flex-col items-center gap-0.5 cursor-pointer text-emerald-400 hover:text-emerald-300"
className="flex flex-col items-center gap-0.5 cursor-pointer text-emerald-400 hover:text-emerald-300 transition-all"
>
<MessageCircle className="w-5 h-5 fill-emerald-500/20" />
<span className="text-[10px] font-bold">WhatsApp</span>
</button>
{/* 5. App Móvil (solo en Web) vs Perfil/Admin (si ya está en la App) */}
{!isInsideApp ? (
<button
onClick={onOpenInstallModal}
className="flex flex-col items-center gap-0.5 cursor-pointer text-cyan-400 hover:text-cyan-300"
>
<div className="relative">
<Download className="w-5 h-5 animate-bounce" />
</div>
<span className="text-[10px] font-bold">App Móvil</span>
</button>
) : (
{/* 5. Solo Administradores: Acceso Admin | Clientes / Visitantes: Mi Cuenta / Ingresar */}
{isAdmin ? (
<Link
href="/admin"
className="flex flex-col items-center gap-0.5 cursor-pointer text-slate-400 hover:text-white"
onClick={() => setActiveTab('admin')}
className={`flex flex-col items-center gap-0.5 cursor-pointer transition-all ${
isAdminActive || activeTab === 'admin' ? 'text-cyan-300' : 'text-slate-400 hover:text-slate-200'
}`}
>
<User className="w-5 h-5" />
<Shield className={`w-5 h-5 ${isAdminActive ? 'stroke-[2.5] text-cyan-400' : 'text-cyan-400'}`} />
<span className="text-[10px] font-bold">Admin</span>
</Link>
) : (
<button
onClick={() => {
setActiveTab('profile');
openAuthModal();
}}
className="flex flex-col items-center gap-0.5 cursor-pointer text-slate-400 hover:text-white transition-all"
>
<User className="w-5 h-5" />
<span className="text-[10px] font-bold">{user ? 'Mi Cuenta' : 'Ingresar'}</span>
</button>
)}
</div>
</div>
</nav>
);
};

View File

@ -122,15 +122,17 @@ export const Navbar: React.FC<NavbarProps> = ({
)}
</button>
{/* Admin Link */}
<Link
href="/admin"
className="hidden sm:flex items-center space-x-1 text-xs font-semibold text-slate-300 hover:text-cyan-300 bg-slate-900/80 hover:bg-slate-800 px-3 py-2 rounded-full border border-slate-700 transition-all"
title="Panel de Administración"
>
<Store className="w-4 h-4 text-cyan-400" />
<span className="hidden lg:inline">Admin</span>
</Link>
{/* Admin Link - Solo para Administradores */}
{user && (user.role === 'admin' || user.role === 'operator') && (
<Link
href="/admin"
className="hidden sm:flex items-center space-x-1 text-xs font-semibold text-slate-300 hover:text-cyan-300 bg-slate-900/80 hover:bg-slate-800 px-3 py-2 rounded-full border border-slate-700 transition-all"
title="Panel de Administración"
>
<Store className="w-4 h-4 text-cyan-400" />
<span className="hidden lg:inline">Admin</span>
</Link>
)}
{/* Cart Button */}
<button

View File

@ -115,12 +115,13 @@ export interface Order {
export interface SupplierPost {
id: string;
channelName: string;
channelJid?: string;
supplierName?: string;
supplierPhone?: string;
rawMessage: string;
rawImages: string[];
extractedProduct?: ExtractedProductData;
status: 'pending_review' | 'published_facebook' | 'published_store' | 'ignored';
status: 'pending_review' | 'published_facebook' | 'published_store' | 'published' | 'discarded' | 'ignored';
receivedAt: string;
}

View File

@ -1,365 +1,688 @@
'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,
Eye,
} 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<SupplierPost[]>(INITIAL_SUPPLIER_POSTS);
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
const [isProcessing, setIsProcessing] = useState(false);
const [posts, setPosts] = useState<SupplierPost[]>([]);
const [monitoredGroups, setMonitoredGroups] = useState<MonitoredWhatsAppGroup[]>([]);
const [selectedGroupFilter, setSelectedGroupFilter] = useState<string>('all');
const [tabStatus, setTabStatus] = useState<'pending' | 'published' | 'discarded'>('pending');
const [isLoading, setIsLoading] = useState(false);
const [isSyncingLive, setIsSyncingLive] = useState(false);
const [actionMessage, setActionMessage] = useState<string | null>(null);
// Multi-selection state
const [selectedPostIds, setSelectedPostIds] = useState<Set<string>>(new Set());
// Edit Modal State
const [editingPost, setEditingPost] = useState<SupplierPost | null>(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<BrandId | 'all'>('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.');
// 1. Grupos
const grpRes = await fetch('/api/whatsapp-groups');
const grpData = await grpRes.json();
if (grpData.success && Array.isArray(grpData.groups)) {
setMonitoredGroups(grpData.groups);
}
// 2. Posts almacenados
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);
}
};
// Manejo de Selección
const handleToggleSelect = (id: string) => {
setSelectedPostIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
// Filtrado de posts por estado y grupo
const filteredPosts = posts.filter((p) => {
// Estado
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;
// Grupo
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-black text-white flex items-center gap-2">
<Radio className="w-6 h-6 text-cyan-400 animate-pulse" />
<span>Feed de Entrada & Publicador Multi-Marca</span>
</h1>
<p className="text-xs text-slate-400 mt-1">
Recepción automática con IA, prevención de duplicados y distribución hacia las 4 marcas (<strong>Almeira Store</strong>, <strong>Sneaky</strong>, <strong>Zapply</strong>, <strong>DripZone</strong>).
</p>
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-xl bg-purple-500/20 text-purple-400 flex items-center justify-center border border-purple-500/30 shadow-[0_0_15px_rgba(168,85,247,0.3)]">
<Radio className="w-5 h-5 animate-pulse" />
</div>
<div>
<h1 className="text-2xl font-black text-white">Feed de Mensajes de WhatsApp</h1>
<p className="text-xs text-neutral-400">
Visualiza los mensajes de tus grupos de proveedores y selecciona los calzados que deseas publicar en la app móvil.
</p>
</div>
</div>
</div>
{/* Target Brand Selector Pill Bar */}
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl">
<span className="text-[10px] font-bold text-slate-400 pl-2 pr-1 flex items-center gap-1">
<Layers className="w-3 h-3" /> Destino:
</span>
<button
onClick={() => setTargetBrand('all')}
className={`text-xs font-bold px-2.5 py-1 rounded-xl transition-all cursor-pointer ${
targetBrand === 'all' ? 'bg-white text-slate-950 shadow-sm' : 'text-slate-400 hover:text-white'
}`}
{/* Action Controls */}
<div className="flex items-center gap-2.5 flex-wrap">
<Link
href="/admin/grupos-whatsapp"
className="bg-neutral-900 hover:bg-neutral-800 border border-neutral-700 text-neutral-300 font-bold text-xs px-3.5 py-2.5 rounded-xl flex items-center gap-1.5 transition-all"
>
4 Marcas
<MessageSquare className="w-4 h-4 text-purple-400" />
<span>Gestionar Grupos</span>
</Link>
<button
onClick={handleSyncLive}
disabled={isSyncingLive}
className="bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:opacity-95 text-white font-black text-xs px-4 py-2.5 rounded-xl flex items-center gap-2 shadow-lg shadow-purple-600/30 cursor-pointer transition-all active:scale-95 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isSyncingLive ? 'animate-spin' : ''}`} />
<span>{isSyncingLive ? 'Consultando Grupos...' : 'Sincronizar WhatsApp en Vivo'}</span>
</button>
{Object.values(BRANDS).map((b) => (
<button
key={b.id}
onClick={() => setTargetBrand(b.id as BrandId)}
className={`text-xs font-bold px-2.5 py-1 rounded-xl transition-all cursor-pointer ${
targetBrand === b.id ? `bg-gradient-to-r ${b.colors.primaryGradient} text-white shadow-sm` : 'text-slate-400 hover:text-white'
}`}
>
{b.shortName}
</button>
))}
</div>
</div>
{/* Toast feedback */}
{actionMessage && (
<div className="bg-cyan-950/80 border border-cyan-500 text-cyan-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-lg">
<span>{actionMessage}</span>
<button onClick={() => setActionMessage(null)} className="font-bold text-white cursor-pointer">
<div className="bg-purple-950/80 border border-purple-500/50 text-purple-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-xl">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-purple-400" />
<span>{actionMessage}</span>
</div>
<button
onClick={() => setActionMessage(null)}
className="text-white hover:text-purple-300 font-bold px-2 cursor-pointer"
>
✕
</button>
</div>
)}
{/* Live Simulator for Test Messages */}
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4">
<div className="flex items-center space-x-2 text-cyan-400 font-bold text-xs uppercase tracking-wider">
<Sparkles className="w-4 h-4 text-cyan-400" />
<span>Simulador de Recepción en Tiempo Real</span>
{/* Filter & Tabs Bar */}
<div className="bg-neutral-900/90 border border-neutral-800 rounded-3xl p-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-4 backdrop-blur-xl">
{/* Status Tabs */}
<div className="flex items-center gap-1 bg-neutral-950 p-1 rounded-2xl border border-neutral-800 text-xs font-bold">
<button
onClick={() => setTabStatus('pending')}
className={`px-3.5 py-2 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
tabStatus === 'pending'
? 'bg-purple-600 text-white shadow'
: 'text-neutral-400 hover:text-white'
}`}
>
<span>📥 Pendientes</span>
<span className="bg-purple-950 text-purple-300 text-[10px] px-1.5 py-0.5 rounded-full font-mono">
{pendingCount}
</span>
</button>
<button
onClick={() => setTabStatus('published')}
className={`px-3.5 py-2 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
tabStatus === 'published'
? 'bg-emerald-600 text-white shadow'
: 'text-neutral-400 hover:text-white'
}`}
>
<span>✅ Publicados en Tienda</span>
<span className="bg-emerald-950 text-emerald-300 text-[10px] px-1.5 py-0.5 rounded-full font-mono">
{publishedCount}
</span>
</button>
<button
onClick={() => setTabStatus('discarded')}
className={`px-3.5 py-2 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
tabStatus === 'discarded'
? 'bg-neutral-800 text-white shadow'
: 'text-neutral-400 hover:text-white'
}`}
>
<span>🗑️ Descartados</span>
<span className="bg-neutral-900 text-neutral-400 text-[10px] px-1.5 py-0.5 rounded-full font-mono">
{discardedCount}
</span>
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-3">
<div className="md:col-span-4">
<label className="block text-[11px] font-bold text-slate-400 mb-1">Canal de Entrada:</label>
<select
value={testGroup}
onChange={(e) => setTestGroup(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
>
<option value="Canal Mayorista A">Canal Mayorista A</option>
<option value="Canal Mayorista B">Canal Mayorista B</option>
</select>
</div>
<div className="md:col-span-8">
<label className="block text-[11px] font-bold text-slate-400 mb-1">Texto del Mensaje de WhatsApp:</label>
<div className="flex gap-2">
<input
type="text"
value={testText}
onChange={(e) => 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"
/>
<button
onClick={handleSimulateIncoming}
disabled={isProcessing}
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-xs px-4 py-2 rounded-xl flex items-center space-x-1.5 shrink-0 transition-all cursor-pointer shadow-md"
>
<Send className="w-3.5 h-3.5" />
<span>Simular Recepción</span>
</button>
</div>
</div>
{/* Group Selector Filter */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-purple-400 shrink-0" />
<select
value={selectedGroupFilter}
onChange={(e) => setSelectedGroupFilter(e.target.value)}
className="bg-neutral-950 border border-neutral-800 text-white text-xs font-semibold rounded-xl px-3 py-2 outline-hidden cursor-pointer"
>
<option value="all">Todos los Grupos de WhatsApp</option>
{monitoredGroups.map((g) => (
<option key={g.id || g.jid} value={g.jid}>
{g.name}
</option>
))}
</select>
</div>
</div>
{/* Intercepted Posts Feed */}
<div className="space-y-4">
<h2 className="font-bold text-white text-base">Mensajes Procesados Recientemente</h2>
{/* Multi-Selection Bulk Actions Bar */}
{selectedPostIds.size > 0 && tabStatus === 'pending' && (
<div className="bg-gradient-to-r from-purple-950/90 to-neutral-900 border border-purple-500/40 rounded-2xl p-3.5 flex items-center justify-between animate-in fade-in shadow-2xl">
<div className="flex items-center gap-2.5">
<span className="bg-purple-600 text-white text-xs font-black px-2.5 py-1 rounded-lg">
{selectedPostIds.size} seleccionados
</span>
<span className="text-xs text-neutral-300 font-semibold hidden sm:inline">
Listos para transferir al catálogo de la tienda
</span>
</div>
<div className="grid grid-cols-1 gap-4">
{posts.map((post) => {
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedPostIds(new Set())}
className="text-neutral-400 hover:text-white text-xs font-bold px-3 py-1.5 rounded-xl cursor-pointer"
>
Desmarcar
</button>
<button
onClick={handleBulkPublish}
className="bg-gradient-to-r from-purple-600 to-fuchsia-500 hover:opacity-95 text-white text-xs font-black px-4 py-2 rounded-xl flex items-center gap-1.5 shadow-lg shadow-purple-600/30 cursor-pointer transition-all active:scale-95"
>
<Plus className="w-4 h-4" />
<span>Publicar Seleccionados ({selectedPostIds.size})</span>
</button>
</div>
</div>
)}
{/* Posts List / Grid */}
{filteredPosts.length === 0 ? (
<div className="bg-neutral-900/60 rounded-3xl p-12 text-center border border-neutral-800 space-y-4">
<div className="w-16 h-16 rounded-2xl bg-purple-500/10 border border-purple-500/20 text-purple-400 flex items-center justify-center mx-auto">
<MessageSquare className="w-8 h-8" />
</div>
<div className="space-y-1">
<h3 className="font-black text-white text-base">No hay mensajes en esta bandeja</h3>
<p className="text-xs text-neutral-400 max-w-md mx-auto">
Haz clic en <strong>"Sincronizar WhatsApp en Vivo"</strong> para extraer automáticamente las ofertas recientes de tus grupos seleccionados.
</p>
</div>
<button
onClick={handleSyncLive}
disabled={isSyncingLive}
className="bg-purple-600 hover:bg-purple-500 text-white font-bold text-xs px-5 py-2.5 rounded-xl inline-flex items-center gap-2 cursor-pointer shadow-md"
>
<RefreshCw className={`w-4 h-4 ${isSyncingLive ? 'animate-spin' : ''}`} />
<span>Escanear Grupos Ahora</span>
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{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 (
<div
key={post.id}
className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4 hover:border-slate-700 transition-all"
className={`bg-neutral-900 border rounded-3xl p-5 space-y-4 transition-all ${
isSelected
? 'border-purple-500 shadow-[0_0_20px_rgba(168,85,247,0.2)]'
: isPublished
? 'border-emerald-500/30 bg-emerald-950/10'
: 'border-neutral-800 hover:border-neutral-700'
}`}
>
{/* Header of post */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800 pb-3">
<div className="flex items-center space-x-2">
<span className="bg-slate-800 text-slate-200 text-xs font-black px-2.5 py-1 rounded-full border border-slate-700">
{post.channelName || 'Canal Mayorista'}
</span>
<span className="text-xs text-slate-400">{post.receivedAt}</span>
{/* Header info */}
<div className="flex items-center justify-between gap-2 border-b border-neutral-800 pb-3">
<div className="flex items-center gap-2">
{tabStatus === 'pending' && (
<button
onClick={() => handleToggleSelect(post.id)}
className="text-purple-400 hover:text-purple-300 cursor-pointer"
>
{isSelected ? (
<CheckSquare className="w-5 h-5 fill-purple-600 text-white" />
) : (
<Square className="w-5 h-5 text-neutral-600" />
)}
</button>
)}
<div>
<span className="bg-purple-950 text-purple-300 text-[10px] font-black px-2 py-0.5 rounded-full border border-purple-500/30 inline-block">
{post.channelName || 'Grupo WhatsApp'}
</span>
<span className="text-[10px] text-neutral-500 ml-2 font-mono">{post.receivedAt}</span>
</div>
</div>
{/* Anti-Duplicate Status Badge */}
{duplicateCheck && (
<div
className={`text-xs font-bold px-3 py-1 rounded-full flex items-center gap-1.5 ${
duplicateCheck.isDuplicate
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
}`}
>
{duplicateCheck.isDuplicate ? (
<>
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
<span>Duplicado Detectado ({Math.round(duplicateCheck.similarityScore * 100)}% Similitud)</span>
</>
) : (
<>
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
<span>Producto Nuevo (0% Duplicados)</span>
</>
)}
</div>
)}
<div>
{isPublished ? (
<span className="bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 text-[10px] font-bold px-2.5 py-0.5 rounded-full flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Publicado en Tienda
</span>
) : isDiscarded ? (
<span className="bg-neutral-800 text-neutral-400 text-[10px] font-bold px-2.5 py-0.5 rounded-full">
Descartado
</span>
) : (
<span className="bg-amber-500/20 text-amber-300 border border-amber-500/40 text-[10px] font-bold px-2.5 py-0.5 rounded-full flex items-center gap-1">
<Sparkles className="w-3 h-3" /> Pendiente de Aprobación
</span>
)}
</div>
</div>
{/* Content Grid */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
{/* Left: Raw WhatsApp Message (5 cols) */}
<div className="md:col-span-5 bg-slate-950 rounded-2xl p-4 space-y-2 border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400">Mensaje de Entrada:</span>
<p className="text-xs text-slate-300 font-mono whitespace-pre-line leading-relaxed">
{post.rawMessage}
</p>
{post.rawImages.length > 0 && (
<div className="pt-2">
{/* Content Grid (Photo + AI extracted info) */}
<div className="grid grid-cols-1 sm:grid-cols-12 gap-4">
{/* Sneaker Image & Raw text */}
<div className="sm:col-span-5 space-y-2">
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-neutral-950 border border-neutral-800 flex items-center justify-center group">
{post.rawImages && post.rawImages[0] ? (
<img
src={post.rawImages[0]}
alt=""
className="w-24 h-24 rounded-xl object-cover bg-slate-900 border border-slate-700"
alt={extracted?.name || 'Zapatilla'}
className="w-full h-full object-cover group-hover:scale-105 transition-all"
/>
</div>
)}
) : (
<div className="text-neutral-600 text-xs">Sin foto</div>
)}
</div>
{/* Raw WhatsApp Message snippet */}
<div className="bg-neutral-950 rounded-xl p-2.5 border border-neutral-800/80">
<p className="text-[11px] text-neutral-400 font-mono whitespace-pre-line line-clamp-3">
{post.rawMessage}
</p>
</div>
</div>
{/* Right: AI Extracted & Pricing Calculation (7 cols) */}
<div className="md:col-span-7 space-y-3">
{extracted ? (
<div className="bg-slate-950 rounded-2xl p-4 border border-slate-800 space-y-3">
<div className="flex items-start justify-between">
<div>
<span className="text-[10px] font-bold text-slate-400 uppercase">
{extracted.brand} • {extracted.category}
</span>
<h3 className="text-base font-black text-white">{extracted.name}</h3>
</div>
<span className="text-xs font-mono bg-purple-500/20 text-purple-300 border border-purple-500/30 px-2 py-0.5 rounded font-bold">
IA: {Math.round(extracted.confidence * 100)}% certeza
</span>
</div>
{/* AI Extraction & Product Info */}
<div className="sm:col-span-7 space-y-3 flex flex-col justify-between">
<div className="space-y-2">
<div>
<span className="text-[10px] font-black uppercase text-purple-400 tracking-wider">
{extracted?.brand || 'Nike'} • {extracted?.category || 'Sneakers'}
</span>
<h3 className="text-base font-black text-white leading-tight">
{extracted?.name || 'Zapatilla Urbana'}
</h3>
</div>
{/* Pricing breakdown */}
<div className="grid grid-cols-2 gap-3 bg-slate-900 p-3 rounded-xl border border-slate-800">
<div>
<span className="text-[10px] text-slate-400 block font-semibold">Costo Base:</span>
<span className="text-sm font-bold text-slate-300 font-mono">
{formatCOP(extracted.supplierPrice)}
</span>
</div>
<div>
<span className="text-[10px] text-emerald-400 block font-bold">Precio Venta (+ Margen):</span>
<span className="text-base font-black text-emerald-300 font-mono">
{formatCOP(extracted.suggestedRetailPrice)}
</span>
</div>
</div>
{/* Sizes */}
{/* Pricing breakdown */}
<div className="bg-neutral-950 p-3 rounded-2xl border border-neutral-800 grid grid-cols-2 gap-2">
<div>
<span className="text-[10px] text-slate-400 font-semibold uppercase block mb-1">
Tallas extraídas:
<span className="text-[10px] text-neutral-400 block font-bold">Costo Proveedor:</span>
<span className="text-xs font-bold text-neutral-300 font-mono">
{formatCOP(extracted?.supplierPrice || 90000)}
</span>
<div className="flex gap-1 flex-wrap">
{extracted.sizes.map((s) => (
<span key={s} className="bg-slate-900 text-white font-bold text-xs px-2 py-0.5 rounded border border-slate-800">
{s}
</span>
))}
</div>
</div>
{/* Duplicate warning note */}
{duplicateCheck?.isDuplicate && (
<div className="bg-amber-950/40 border border-amber-800/60 p-2.5 rounded-xl text-xs text-amber-200">
{duplicateCheck.message}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t border-slate-800">
<button
onClick={() => handlePublishFacebook(post)}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-bold text-xs py-2.5 px-3 rounded-xl flex items-center justify-center space-x-1.5 transition-all cursor-pointer shadow-sm"
>
<Share2 className="w-3.5 h-3.5" />
<span>Publicar en Facebook</span>
</button>
<button
onClick={() => handleAddToStore(post)}
className="flex-1 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-xs py-2.5 px-3 rounded-xl flex items-center justify-center space-x-1.5 transition-all cursor-pointer shadow-md"
>
<Plus className="w-3.5 h-3.5" />
<span>Publicar a la Tienda</span>
</button>
<div>
<span className="text-[10px] text-emerald-400 block font-bold">Venta Sugerida:</span>
<span className="text-sm font-black text-emerald-300 font-mono">
{formatCOP(extracted?.suggestedRetailPrice || 125000)}
</span>
</div>
</div>
) : (
<p className="text-xs text-slate-500">Extrayendo datos...</p>
)}
{/* Sizes badge pills */}
<div>
<span className="text-[10px] font-bold text-neutral-400 uppercase block mb-1">
Tallas Disponibles:
</span>
<div className="flex gap-1 flex-wrap">
{extracted?.sizes && extracted.sizes.length > 0 ? (
extracted.sizes.map((s) => (
<span
key={s}
className="bg-neutral-950 text-white font-bold text-[11px] px-2 py-0.5 rounded-lg border border-neutral-800"
>
{s}
</span>
))
) : (
<span className="text-xs text-neutral-500">37 a 43</span>
)}
</div>
</div>
</div>
{/* Card Actions */}
<div className="pt-2 border-t border-neutral-800 flex items-center gap-2">
{!isPublished ? (
<>
<button
onClick={() => handlePublishPost(post)}
className="flex-1 bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:opacity-95 text-white font-black text-xs py-2.5 px-3 rounded-xl flex items-center justify-center gap-1.5 shadow-md shadow-purple-600/30 cursor-pointer transition-all active:scale-95"
>
<Plus className="w-3.5 h-3.5" />
<span>Publicar en la App</span>
</button>
<button
onClick={() => handleOpenEdit(post)}
title="Editar antes de publicar"
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-300 p-2.5 rounded-xl cursor-pointer transition-all"
>
<Edit3 className="w-4 h-4" />
</button>
<button
onClick={() => handleDiscardPost(post)}
title="Descartar"
className="bg-neutral-800 hover:bg-red-950/40 text-neutral-400 hover:text-red-400 p-2.5 rounded-xl cursor-pointer transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</>
) : (
<div className="w-full flex items-center justify-between text-xs">
<span className="text-emerald-400 font-bold flex items-center gap-1">
<CheckCircle2 className="w-4 h-4" /> Disponible para clientes
</span>
<Link
href="/"
target="_blank"
className="text-purple-400 hover:underline font-bold flex items-center gap-1"
>
<span>Ver en Tienda</span>
<ArrowUpRight className="w-3 h-3" />
</Link>
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Edit Modal Before Publishing */}
{editingPost && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-lg w-full p-6 space-y-5 shadow-2xl animate-in zoom-in-95">
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
<h3 className="font-black text-white text-base flex items-center gap-2">
<Edit3 className="w-5 h-5 text-purple-400" />
<span>Personalizar antes de Publicar</span>
</h3>
<button
onClick={() => setEditingPost(null)}
className="text-neutral-400 hover:text-white font-bold"
>
✕
</button>
</div>
<div className="space-y-3.5 text-xs">
<div>
<label className="font-bold text-neutral-300 block mb-1">Nombre Comercial del Modelo:</label>
<input
type="text"
value={editName}
onChange={(e) => 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"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="font-bold text-neutral-300 block mb-1">Marca:</label>
<input
type="text"
value={editBrand}
onChange={(e) => setEditBrand(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-500"
/>
</div>
<div>
<label className="font-bold text-emerald-400 block mb-1">Precio de Venta (COP):</label>
<input
type="number"
value={editPrice}
onChange={(e) => setEditPrice(Number(e.target.value))}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-emerald-300 font-black font-mono outline-hidden focus:border-emerald-500"
/>
</div>
</div>
<div>
<label className="font-bold text-neutral-300 block mb-1">Tallas (separadas por coma):</label>
<input
type="text"
value={editSizes}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex items-center gap-2.5 pt-3 border-t border-neutral-800">
<button
onClick={() => setEditingPost(null)}
className="flex-1 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold text-xs py-3 rounded-xl cursor-pointer"
>
Cancelar
</button>
<button
onClick={() => {
const sizesArray = editSizes
.split(',')
.map((s) => s.trim())
.filter(Boolean);
handlePublishPost(editingPost, {
name: editName,
brand: editBrand,
retailPrice: editPrice,
sizes: sizesArray,
});
}}
className="flex-1 bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:opacity-95 text-white font-black text-xs py-3 rounded-xl shadow-lg shadow-purple-600/30 cursor-pointer"
>
Guardar y Publicar en la App
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -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 (
<div className="min-h-screen bg-neutral-950 text-white flex flex-col items-center justify-center p-4">
<div className="max-w-md w-full bg-neutral-900/90 border border-purple-500/30 rounded-3xl p-8 text-center space-y-6 shadow-2xl backdrop-blur-xl">
<div className="w-16 h-16 rounded-2xl bg-purple-500/10 border border-purple-500/30 text-purple-400 flex items-center justify-center mx-auto shadow-inner">
<ShieldAlert className="w-8 h-8" />
</div>
<div className="space-y-2">
<h2 className="text-xl font-black tracking-tight text-white">Acceso Restringido para Administradores</h2>
<p className="text-xs text-neutral-400 leading-relaxed">
Este panel de control es de uso exclusivo para el equipo administrativo y operadores logísticos autorizados.
</p>
</div>
<div className="space-y-3 pt-2">
<button
onClick={openAuthModal}
className="w-full bg-gradient-to-r from-purple-600 to-fuchsia-500 hover:opacity-95 text-white font-black text-xs py-3.5 px-4 rounded-xl flex items-center justify-center gap-2 shadow-lg shadow-purple-600/30 cursor-pointer transition-all active:scale-95"
>
<LogIn className="w-4 h-4" />
<span>Ingresar como Administrador</span>
</button>
<Link
href="/"
className="w-full bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold text-xs py-3 px-4 rounded-xl flex items-center justify-center gap-2 transition-all block"
>
<ArrowLeft className="w-4 h-4" />
<span>Volver a la Tienda</span>
</Link>
</div>
</div>
<AuthModal />
</div>
);
}
return (
<div className="min-h-screen bg-neutral-950 text-neutral-100 flex flex-col md:flex-row font-sans">
{/* Sidebar */}
@ -104,6 +148,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<main className="flex-1 p-4 sm:p-8 overflow-y-auto bg-neutral-950">
{children}
</main>
<AuthModal />
</div>
);
}

View File

@ -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<string, string>();
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 });
}
}

View File

@ -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<Order | null>(null);

View File

@ -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<Product[]>(INITIAL_PRODUCTS);
const [products, setProducts] = useState<Product[]>([]);
const [cities, setCities] = useState<City[]>(INITIAL_CITIES);
const [selectedCity, setSelectedCity] = useState<City>(INITIAL_CITIES[0]); // Cali
const [activeCategory, setActiveCategory] = useState<string>('all');
@ -28,7 +27,6 @@ export default function StoreHomePage() {
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [isCartOpen, setIsCartOpen] = useState(false);
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
const [isInstallModalOpen, setIsInstallModalOpen] = useState(false);
const [cartItems, setCartItems] = useState<CartItem[]>([]);
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() {
</div>
{/* Product Grid */}
{filteredProducts.length === 0 ? (
<div className="bg-slate-900/80 rounded-3xl p-12 text-center border border-slate-800 space-y-3">
<div className="w-16 h-16 rounded-full bg-white/10 text-white flex items-center justify-center mx-auto">
<RefreshCw className="w-8 h-8 animate-spin" />
{products.length === 0 ? (
<div className="bg-neutral-900/60 rounded-3xl p-10 sm:p-14 text-center border border-purple-500/20 space-y-4 max-w-xl mx-auto shadow-2xl backdrop-blur-xl animate-in fade-in">
<div className="w-16 h-16 rounded-2xl bg-purple-500/10 border border-purple-500/30 text-purple-400 flex items-center justify-center mx-auto shadow-inner">
<Zap className="w-8 h-8 animate-pulse" />
</div>
<div className="space-y-1.5">
<h3 className="font-black text-white text-lg tracking-tight">Catálogo en Actualización</h3>
<p className="text-xs text-neutral-400 leading-relaxed">
Estamos sincronizando los nuevos drops de calzado en vivo desde nuestras bodegas mayoristas asociadas.
</p>
</div>
<a
href={`https://wa.me/${currentBrand.whatsappNumber}?text=${encodeURIComponent(
`Hola ${currentBrand.name}! 👋 Quiero consultar la disponibilidad de zapatillas para despacho contraentrega.`
)}`}
target="_blank"
rel="noopener noreferrer"
className={`inline-flex items-center gap-2 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-bold text-xs px-6 py-3 rounded-full cursor-pointer shadow-lg hover:scale-105 active:scale-95 transition-all`}
>
<span>Consultar Disponibilidad por WhatsApp</span>
</a>
</div>
) : filteredProducts.length === 0 ? (
<div className="bg-neutral-900/80 rounded-3xl p-12 text-center border border-neutral-800 space-y-3">
<div className="w-14 h-14 rounded-2xl bg-white/5 text-neutral-400 flex items-center justify-center mx-auto">
<RefreshCw className="w-6 h-6" />
</div>
<h4 className="font-bold text-white text-base">No encontramos zapatillas con ese criterio</h4>
<p className="text-xs text-slate-400">Intenta buscar otra marca o restablecer los filtros.</p>
<p className="text-xs text-neutral-400">Intenta buscar otra marca o restablecer los filtros.</p>
<button
onClick={() => {
setActiveCategory('all');
setSearchQuery('');
}}
className={`bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-bold text-xs px-4 py-2 rounded-full cursor-pointer shadow-md`}
className={`bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-bold text-xs px-5 py-2.5 rounded-full cursor-pointer shadow-md`}
>
Ver Todo el Catálogo
</button>
@ -265,16 +285,10 @@ export default function StoreHomePage() {
onSelectCity={setSelectedCity}
/>
{/* Mobile App Dock & Install Banner */}
{/* Mobile App Dock */}
<MobileBottomDock
cartCount={cartCount}
onOpenCart={() => setIsCartOpen(true)}
onOpenInstallModal={() => setIsInstallModalOpen(true)}
/>
<MobileInstallBanner
isOpen={isInstallModalOpen}
onClose={() => setIsInstallModalOpen(false)}
/>
{/* User Auth Modal */}

View File

@ -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<MobileBottomDockProps> = ({
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<MobileBottomDockProps> = ({
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 (
<div className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-neutral-950/90 backdrop-blur-xl border-t border-neutral-800/80 px-3 py-2">
<nav
aria-label="Navegación principal móvil"
className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-neutral-950/90 backdrop-blur-xl border-t border-neutral-800/80 px-3 py-2"
>
<div className="flex items-center justify-around">
{/* 1. Inicio */}
<Link
href="/"
onClick={() => setActiveTab('home')}
className={`flex flex-col items-center gap-0.5 cursor-pointer transition-all ${
activeTab === 'home' ? 'text-white' : 'text-neutral-400 hover:text-neutral-200'
isHomeActive ? 'text-white' : 'text-neutral-400 hover:text-neutral-200'
}`}
>
<Home className={`w-5 h-5 ${activeTab === 'home' ? 'stroke-[2.5]' : ''}`} />
<Home className={`w-5 h-5 ${isHomeActive ? 'stroke-[2.5] text-white' : ''}`} />
<span className="text-[10px] font-bold">Inicio</span>
</Link>
{/* 2. Buscar */}
<button
onClick={() => {
setActiveTab('search');
@ -101,17 +70,20 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
<span className="text-[10px] font-bold">Buscar</span>
</button>
{/* 3. Carrito */}
<button
onClick={() => {
setActiveTab('cart');
onOpenCart();
}}
className="relative flex flex-col items-center gap-0.5 cursor-pointer text-neutral-400 hover:text-white"
className="relative flex flex-col items-center gap-0.5 cursor-pointer text-neutral-400 hover:text-white transition-all"
>
<div className="relative">
<ShoppingBag className="w-5 h-5 text-white" />
{cartCount > 0 && (
<span className={`absolute -top-1.5 -right-2 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-black text-[9px] w-4 h-4 rounded-full flex items-center justify-center shadow-md animate-pulse`}>
<span
className={`absolute -top-1.5 -right-2 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-black text-[9px] w-4 h-4 rounded-full flex items-center justify-center shadow-md animate-pulse`}
>
{cartCount}
</span>
)}
@ -119,34 +91,40 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
<span className="text-[10px] font-bold text-white">Carrito</span>
</button>
{/* 4. WhatsApp */}
<button
onClick={handleWhatsApp}
className="flex flex-col items-center gap-0.5 cursor-pointer text-emerald-400 hover:text-emerald-300"
className="flex flex-col items-center gap-0.5 cursor-pointer text-emerald-400 hover:text-emerald-300 transition-all"
>
<MessageCircle className="w-5 h-5 fill-emerald-500/20" />
<span className="text-[10px] font-bold">WhatsApp</span>
</button>
{!isInsideApp ? (
<button
onClick={onOpenInstallModal}
className="flex flex-col items-center gap-0.5 cursor-pointer text-purple-400 hover:text-purple-300"
>
<div className="relative">
<Download className="w-5 h-5 animate-bounce" />
</div>
<span className="text-[10px] font-bold">App Móvil</span>
</button>
) : (
{/* 5. Solo Administradores: Acceso Admin | Clientes / Visitantes: Mi Cuenta / Ingresar */}
{isAdmin ? (
<Link
href="/admin"
className="flex flex-col items-center gap-0.5 cursor-pointer text-neutral-400 hover:text-white"
onClick={() => setActiveTab('admin')}
className={`flex flex-col items-center gap-0.5 cursor-pointer transition-all ${
isAdminActive || activeTab === 'admin' ? 'text-purple-300' : 'text-neutral-400 hover:text-neutral-200'
}`}
>
<User className="w-5 h-5" />
<Shield className={`w-5 h-5 ${isAdminActive ? 'stroke-[2.5] text-purple-400' : 'text-purple-400'}`} />
<span className="text-[10px] font-bold">Admin</span>
</Link>
) : (
<button
onClick={() => {
setActiveTab('profile');
openAuthModal();
}}
className="flex flex-col items-center gap-0.5 cursor-pointer text-neutral-400 hover:text-white transition-all"
>
<User className="w-5 h-5" />
<span className="text-[10px] font-bold">{user ? 'Mi Cuenta' : 'Ingresar'}</span>
</button>
)}
</div>
</div>
</nav>
);
};

View File

@ -126,15 +126,17 @@ export const Navbar: React.FC<NavbarProps> = ({
)}
</button>
{/* Admin Link */}
<Link
href="/admin"
className="hidden sm:flex items-center space-x-1 text-xs font-semibold text-neutral-300 hover:text-purple-400 bg-neutral-900/80 hover:bg-neutral-800 px-3 py-2 rounded-full border border-neutral-700 transition-all"
title="Panel de Administración"
>
<Store className="w-4 h-4 text-purple-400" />
<span className="hidden lg:inline">Admin</span>
</Link>
{/* Admin Link - Solo para Administradores */}
{user && (user.role === 'admin' || user.role === 'operator') && (
<Link
href="/admin"
className="hidden sm:flex items-center space-x-1 text-xs font-semibold text-neutral-300 hover:text-purple-400 bg-neutral-900/80 hover:bg-neutral-800 px-3 py-2 rounded-full border border-neutral-700 transition-all"
title="Panel de Administración"
>
<Store className="w-4 h-4 text-purple-400" />
<span className="hidden lg:inline">Admin</span>
</Link>
)}
{/* Cart Button */}
<button

File diff suppressed because it is too large Load Diff

View File

@ -115,12 +115,13 @@ export interface Order {
export interface SupplierPost {
id: string;
channelName: string;
channelJid?: string;
supplierName?: string;
supplierPhone?: string;
rawMessage: string;
rawImages: string[];
extractedProduct?: ExtractedProductData;
status: 'pending_review' | 'published_facebook' | 'published_store' | 'ignored';
status: 'pending_review' | 'published_facebook' | 'published_store' | 'published' | 'discarded' | 'ignored';
receivedAt: string;
}

View File

@ -22,7 +22,7 @@ async function sendApkToCarlos() {
const apkBuffer = fs.readFileSync(apkPath);
const base64Data = apkBuffer.toString('base64');
const caption = `🚀 *DripZone Sneakers - APK Oficial Actualizado*\n\n📅 *Generado:* ${new Date().toLocaleString('es-CO', { timeZone: 'America/Bogota' })}\n📦 *Tamaño:* ${sizeMB} MB\n\n✨ *Novedades de esta versión:*\n✅ Gestión y módulo completo de Proveedores\n✅ Edición, actualización y eliminación de pedidos en tiempo real\n✅ Notificaciones automáticas de pedidos por WhatsApp (Evolution API v2)\n✅ Autenticación biométrica con Huella / Passkey y 2FA OTP\n✅ Splash Screen vertical en alta definición y Dock interactivo optimizado\n\n📲 *Instalación:* Toca este archivo para instalar/actualizar la app en tu teléfono Android.`;
const caption = `🚀 *DripZone Sneakers - APK Oficial Actualizado*\n\n📅 *Generado:* ${new Date().toLocaleString('es-CO', { timeZone: 'America/Bogota' })}\n📦 *Tamaño:* ${sizeMB} MB\n\n✨ *Novedades de esta versión:*\n🛡️ *Seguridad de Roles:* Botón Admin 100% oculto para clientes regulares; visible únicamente para administradores autenticados.\n🧹 *Catálogo Limpio en Vivo:* Eliminados productos mock iniciales. Si no hay productos publicados, muestra estado de actualización y drops.\n📥 *Feed WhatsApp & Selección:* Sincronización en vivo con grupos de WhatsApp, extracción por IA de marcas/tallas/precios y publicación selectiva a la app.\n✅ *Gestión Completa:* Proveedores, pedidos contraentrega, pagos Bold/Nequi y logística directa.\n\n📲 *Instalación:* Descarga y abre este archivo APK para actualizar la aplicación en tu dispositivo Android.`;
console.log(`📤 Enviando APK vía Evolution API a WhatsApp (+${TARGET_PHONE})...`);

View File

@ -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<SupplierPost[]>(INITIAL_SUPPLIER_POSTS);
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
const [isProcessing, setIsProcessing] = useState(false);
const [posts, setPosts] = useState<SupplierPost[]>([]);
const [monitoredGroups, setMonitoredGroups] = useState<MonitoredWhatsAppGroup[]>([]);
const [selectedGroupFilter, setSelectedGroupFilter] = useState<string>('all');
const [tabStatus, setTabStatus] = useState<'pending' | 'published' | 'discarded'>('pending');
const [isLoading, setIsLoading] = useState(false);
const [isSyncingLive, setIsSyncingLive] = useState(false);
const [actionMessage, setActionMessage] = useState<string | null>(null);
// Multi-selection state
const [selectedPostIds, setSelectedPostIds] = useState<Set<string>>(new Set());
// Edit Modal State
const [editingPost, setEditingPost] = useState<SupplierPost | null>(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<BrandId | 'all'>('almeira');
const [targetBrand, setTargetBrand] = useState<BrandId | 'all'>('sneaky');
// 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 (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-black text-white flex items-center gap-2">
<Radio className="w-6 h-6 text-cyan-400 animate-pulse" />
<span>Feed de Entrada & Publicador Multi-Marca</span>
</h1>
<p className="text-xs text-slate-400 mt-1">
Recepción automática con IA, prevención de duplicados y distribución hacia las 4 marcas (<strong>Almeira Store</strong>, <strong>Sneaky</strong>, <strong>Zapply</strong>, <strong>DripZone</strong>).
</p>
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-xl bg-orange-500/20 text-orange-400 flex items-center justify-center border border-orange-500/30 shadow-[0_0_15px_rgba(249,115,22,0.3)]">
<Radio className="w-5 h-5 animate-pulse" />
</div>
<div>
<h1 className="text-2xl font-black text-white">Feed de Mensajes de WhatsApp • Sneaky</h1>
<p className="text-xs text-zinc-400">
Visualiza los mensajes de tus grupos de proveedores y selecciona los calzados que deseas publicar en la app móvil.
</p>
</div>
</div>
</div>
{/* Target Brand Selector Pill Bar */}
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl">
<span className="text-[10px] font-bold text-slate-400 pl-2 pr-1 flex items-center gap-1">
<Layers className="w-3 h-3" /> Destino:
</span>
<button
onClick={() => setTargetBrand('all')}
className={`text-xs font-bold px-2.5 py-1 rounded-xl transition-all cursor-pointer ${
targetBrand === 'all' ? 'bg-white text-slate-950 shadow-sm' : 'text-slate-400 hover:text-white'
}`}
{/* Action Controls */}
<div className="flex items-center gap-2.5 flex-wrap">
<Link
href="/admin/grupos-whatsapp"
className="bg-zinc-900 hover:bg-zinc-800 border border-zinc-700 text-zinc-300 font-bold text-xs px-3.5 py-2.5 rounded-xl flex items-center gap-1.5 transition-all"
>
4 Marcas
<MessageSquare className="w-4 h-4 text-orange-400" />
<span>Gestionar Grupos</span>
</Link>
<button
onClick={handleSyncLive}
disabled={isSyncingLive}
className="bg-gradient-to-r from-red-600 to-orange-500 hover:opacity-95 text-white font-black text-xs px-4 py-2.5 rounded-xl flex items-center gap-2 shadow-lg shadow-orange-600/30 cursor-pointer transition-all active:scale-95 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isSyncingLive ? 'animate-spin' : ''}`} />
<span>{isSyncingLive ? 'Consultando Grupos...' : 'Sincronizar WhatsApp en Vivo'}</span>
</button>
{Object.values(BRANDS).map((b) => (
<button
key={b.id}
onClick={() => setTargetBrand(b.id as BrandId)}
className={`text-xs font-bold px-2.5 py-1 rounded-xl transition-all cursor-pointer ${
targetBrand === b.id ? `bg-gradient-to-r ${b.colors.primaryGradient} text-white shadow-sm` : 'text-slate-400 hover:text-white'
}`}
>
{b.shortName}
</button>
))}
</div>
</div>
{/* Toast feedback */}
{actionMessage && (
<div className="bg-cyan-950/80 border border-cyan-500 text-cyan-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-lg">
<span>{actionMessage}</span>
<button onClick={() => setActionMessage(null)} className="font-bold text-white cursor-pointer">
<div className="bg-orange-950/80 border border-orange-500/50 text-orange-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-xl">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-orange-400" />
<span>{actionMessage}</span>
</div>
<button
onClick={() => setActionMessage(null)}
className="text-white hover:text-orange-300 font-bold px-2 cursor-pointer"
>
✕
</button>
</div>
)}
{/* Live Simulator for Test Messages */}
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4">
<div className="flex items-center space-x-2 text-cyan-400 font-bold text-xs uppercase tracking-wider">
<Sparkles className="w-4 h-4 text-cyan-400" />
<span>Simulador de Recepción en Tiempo Real</span>
{/* Filter & Tabs Bar */}
<div className="bg-zinc-900/90 border border-zinc-800 rounded-3xl p-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-4 backdrop-blur-xl">
{/* Status Tabs */}
<div className="flex items-center gap-1 bg-zinc-950 p-1 rounded-2xl border border-zinc-800 text-xs font-bold">
<button
onClick={() => setTabStatus('pending')}
className={`px-3.5 py-2 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
tabStatus === 'pending'
? 'bg-orange-600 text-white shadow'
: 'text-zinc-400 hover:text-white'
}`}
>
<span>📥 Pendientes</span>
<span className="bg-orange-950 text-orange-300 text-[10px] px-1.5 py-0.5 rounded-full font-mono">
{pendingCount}
</span>
</button>
<button
onClick={() => setTabStatus('published')}
className={`px-3.5 py-2 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
tabStatus === 'published'
? 'bg-emerald-600 text-white shadow'
: 'text-zinc-400 hover:text-white'
}`}
>
<span>✅ Publicados en Tienda</span>
<span className="bg-emerald-950 text-emerald-300 text-[10px] px-1.5 py-0.5 rounded-full font-mono">
{publishedCount}
</span>
</button>
<button
onClick={() => setTabStatus('discarded')}
className={`px-3.5 py-2 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
tabStatus === 'discarded'
? 'bg-zinc-800 text-white shadow'
: 'text-zinc-400 hover:text-white'
}`}
>
<span>🗑️ Descartados</span>
<span className="bg-zinc-900 text-zinc-400 text-[10px] px-1.5 py-0.5 rounded-full font-mono">
{discardedCount}
</span>
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-3">
<div className="md:col-span-4">
<label className="block text-[11px] font-bold text-slate-400 mb-1">Canal de Entrada:</label>
<select
value={testGroup}
onChange={(e) => setTestGroup(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
>
<option value="Canal Mayorista A">Canal Mayorista A</option>
<option value="Canal Mayorista B">Canal Mayorista B</option>
</select>
</div>
<div className="md:col-span-8">
<label className="block text-[11px] font-bold text-slate-400 mb-1">Texto del Mensaje de WhatsApp:</label>
<div className="flex gap-2">
<input
type="text"
value={testText}
onChange={(e) => 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"
/>
<button
onClick={handleSimulateIncoming}
disabled={isProcessing}
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-xs px-4 py-2 rounded-xl flex items-center space-x-1.5 shrink-0 transition-all cursor-pointer shadow-md"
>
<Send className="w-3.5 h-3.5" />
<span>Simular Recepción</span>
</button>
</div>
</div>
{/* Group Selector Filter */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-orange-400 shrink-0" />
<select
value={selectedGroupFilter}
onChange={(e) => setSelectedGroupFilter(e.target.value)}
className="bg-zinc-950 border border-zinc-800 text-white text-xs font-semibold rounded-xl px-3 py-2 outline-hidden cursor-pointer"
>
<option value="all">Todos los Grupos de WhatsApp</option>
{monitoredGroups.map((g) => (
<option key={g.id || g.jid} value={g.jid}>
{g.name}
</option>
))}
</select>
</div>
</div>
{/* Intercepted Posts Feed */}
<div className="space-y-4">
<h2 className="font-bold text-white text-base">Mensajes Procesados Recientemente</h2>
{/* Multi-Selection Bulk Actions Bar */}
{selectedPostIds.size > 0 && tabStatus === 'pending' && (
<div className="bg-gradient-to-r from-orange-950/90 to-zinc-900 border border-orange-500/40 rounded-2xl p-3.5 flex items-center justify-between animate-in fade-in shadow-2xl">
<div className="flex items-center gap-2.5">
<span className="bg-orange-600 text-white text-xs font-black px-2.5 py-1 rounded-lg">
{selectedPostIds.size} seleccionados
</span>
<span className="text-xs text-zinc-300 font-semibold hidden sm:inline">
Listos para transferir al catálogo de la tienda
</span>
</div>
<div className="grid grid-cols-1 gap-4">
{posts.map((post) => {
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedPostIds(new Set())}
className="text-zinc-400 hover:text-white text-xs font-bold px-3 py-1.5 rounded-xl cursor-pointer"
>
Desmarcar
</button>
<button
onClick={handleBulkPublish}
className="bg-gradient-to-r from-red-600 to-orange-500 hover:opacity-95 text-white text-xs font-black px-4 py-2 rounded-xl flex items-center gap-1.5 shadow-lg shadow-orange-600/30 cursor-pointer transition-all active:scale-95"
>
<Plus className="w-4 h-4" />
<span>Publicar Seleccionados ({selectedPostIds.size})</span>
</button>
</div>
</div>
)}
{/* Posts List / Grid */}
{filteredPosts.length === 0 ? (
<div className="bg-zinc-900/60 rounded-3xl p-12 text-center border border-zinc-800 space-y-4">
<div className="w-16 h-16 rounded-2xl bg-orange-500/10 border border-orange-500/20 text-orange-400 flex items-center justify-center mx-auto">
<MessageSquare className="w-8 h-8" />
</div>
<div className="space-y-1">
<h3 className="font-black text-white text-base">No hay mensajes en esta bandeja</h3>
<p className="text-xs text-zinc-400 max-w-md mx-auto">
Haz clic en <strong>"Sincronizar WhatsApp en Vivo"</strong> para extraer automáticamente las ofertas recientes de tus grupos seleccionados.
</p>
</div>
<button
onClick={handleSyncLive}
disabled={isSyncingLive}
className="bg-orange-600 hover:bg-orange-500 text-white font-bold text-xs px-5 py-2.5 rounded-xl inline-flex items-center gap-2 cursor-pointer shadow-md"
>
<RefreshCw className={`w-4 h-4 ${isSyncingLive ? 'animate-spin' : ''}`} />
<span>Escanear Grupos Ahora</span>
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{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 (
<div
key={post.id}
className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4 hover:border-slate-700 transition-all"
className={`bg-zinc-900 border rounded-3xl p-5 space-y-4 transition-all ${
isSelected
? 'border-orange-500 shadow-[0_0_20px_rgba(249,115,22,0.2)]'
: isPublished
? 'border-emerald-500/30 bg-emerald-950/10'
: 'border-zinc-800 hover:border-zinc-700'
}`}
>
{/* Header of post */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800 pb-3">
<div className="flex items-center space-x-2">
<span className="bg-slate-800 text-slate-200 text-xs font-black px-2.5 py-1 rounded-full border border-slate-700">
{post.channelName || 'Canal Mayorista'}
</span>
<span className="text-xs text-slate-400">{post.receivedAt}</span>
{/* Header info */}
<div className="flex items-center justify-between gap-2 border-b border-zinc-800 pb-3">
<div className="flex items-center gap-2">
{tabStatus === 'pending' && (
<button
onClick={() => handleToggleSelect(post.id)}
className="text-orange-400 hover:text-orange-300 cursor-pointer"
>
{isSelected ? (
<CheckSquare className="w-5 h-5 fill-orange-600 text-white" />
) : (
<Square className="w-5 h-5 text-zinc-600" />
)}
</button>
)}
<div>
<span className="bg-orange-950 text-orange-300 text-[10px] font-black px-2 py-0.5 rounded-full border border-orange-500/30 inline-block">
{post.channelName || 'Grupo WhatsApp'}
</span>
<span className="text-[10px] text-zinc-500 ml-2 font-mono">{post.receivedAt}</span>
</div>
</div>
{/* Anti-Duplicate Status Badge */}
{duplicateCheck && (
<div
className={`text-xs font-bold px-3 py-1 rounded-full flex items-center gap-1.5 ${
duplicateCheck.isDuplicate
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
}`}
>
{duplicateCheck.isDuplicate ? (
<>
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
<span>Duplicado Detectado ({Math.round(duplicateCheck.similarityScore * 100)}% Similitud)</span>
</>
) : (
<>
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
<span>Producto Nuevo (0% Duplicados)</span>
</>
)}
</div>
)}
<div>
{isPublished ? (
<span className="bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 text-[10px] font-bold px-2.5 py-0.5 rounded-full flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Publicado en Tienda
</span>
) : isDiscarded ? (
<span className="bg-zinc-800 text-zinc-400 text-[10px] font-bold px-2.5 py-0.5 rounded-full">
Descartado
</span>
) : (
<span className="bg-amber-500/20 text-amber-300 border border-amber-500/40 text-[10px] font-bold px-2.5 py-0.5 rounded-full flex items-center gap-1">
<Sparkles className="w-3 h-3" /> Pendiente de Aprobación
</span>
)}
</div>
</div>
{/* Content Grid */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
{/* Left: Raw WhatsApp Message (5 cols) */}
<div className="md:col-span-5 bg-slate-950 rounded-2xl p-4 space-y-2 border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400">Mensaje de Entrada:</span>
<p className="text-xs text-slate-300 font-mono whitespace-pre-line leading-relaxed">
{post.rawMessage}
</p>
{post.rawImages.length > 0 && (
<div className="pt-2">
{/* Content Grid (Photo + AI extracted info) */}
<div className="grid grid-cols-1 sm:grid-cols-12 gap-4">
{/* Sneaker Image & Raw text */}
<div className="sm:col-span-5 space-y-2">
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-zinc-950 border border-zinc-800 flex items-center justify-center group">
{post.rawImages && post.rawImages[0] ? (
<img
src={post.rawImages[0]}
alt=""
className="w-24 h-24 rounded-xl object-cover bg-slate-900 border border-slate-700"
alt={extracted?.name || 'Zapatilla'}
className="w-full h-full object-cover group-hover:scale-105 transition-all"
/>
</div>
)}
) : (
<div className="text-zinc-600 text-xs">Sin foto</div>
)}
</div>
<div className="bg-zinc-950 rounded-xl p-2.5 border border-zinc-800/80">
<p className="text-[11px] text-zinc-400 font-mono whitespace-pre-line line-clamp-3">
{post.rawMessage}
</p>
</div>
</div>
{/* Right: AI Extracted & Pricing Calculation (7 cols) */}
<div className="md:col-span-7 space-y-3">
{extracted ? (
<div className="bg-slate-950 rounded-2xl p-4 border border-slate-800 space-y-3">
<div className="flex items-start justify-between">
<div>
<span className="text-[10px] font-bold text-slate-400 uppercase">
{extracted.brand} • {extracted.category}
</span>
<h3 className="text-base font-black text-white">{extracted.name}</h3>
</div>
<span className="text-xs font-mono bg-purple-500/20 text-purple-300 border border-purple-500/30 px-2 py-0.5 rounded font-bold">
IA: {Math.round(extracted.confidence * 100)}% certeza
</span>
</div>
{/* AI Extraction & Product Info */}
<div className="sm:col-span-7 space-y-3 flex flex-col justify-between">
<div className="space-y-2">
<div>
<span className="text-[10px] font-black uppercase text-orange-400 tracking-wider">
{extracted?.brand || 'Nike'} • {extracted?.category || 'Sneakers'}
</span>
<h3 className="text-base font-black text-white leading-tight">
{extracted?.name || 'Zapatilla Urbana'}
</h3>
</div>
{/* Pricing breakdown */}
<div className="grid grid-cols-2 gap-3 bg-slate-900 p-3 rounded-xl border border-slate-800">
<div>
<span className="text-[10px] text-slate-400 block font-semibold">Costo Base:</span>
<span className="text-sm font-bold text-slate-300 font-mono">
{formatCOP(extracted.supplierPrice)}
</span>
</div>
<div>
<span className="text-[10px] text-emerald-400 block font-bold">Precio Venta (+ Margen):</span>
<span className="text-base font-black text-emerald-300 font-mono">
{formatCOP(extracted.suggestedRetailPrice)}
</span>
</div>
</div>
{/* Sizes */}
{/* Pricing breakdown */}
<div className="bg-zinc-950 p-3 rounded-2xl border border-zinc-800 grid grid-cols-2 gap-2">
<div>
<span className="text-[10px] text-slate-400 font-semibold uppercase block mb-1">
Tallas extraídas:
<span className="text-[10px] text-zinc-400 block font-bold">Costo Proveedor:</span>
<span className="text-xs font-bold text-zinc-300 font-mono">
{formatCOP(extracted?.supplierPrice || 90000)}
</span>
<div className="flex gap-1 flex-wrap">
{extracted.sizes.map((s) => (
<span key={s} className="bg-slate-900 text-white font-bold text-xs px-2 py-0.5 rounded border border-slate-800">
{s}
</span>
))}
</div>
</div>
{/* Duplicate warning note */}
{duplicateCheck?.isDuplicate && (
<div className="bg-amber-950/40 border border-amber-800/60 p-2.5 rounded-xl text-xs text-amber-200">
{duplicateCheck.message}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t border-slate-800">
<button
onClick={() => handlePublishFacebook(post)}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-bold text-xs py-2.5 px-3 rounded-xl flex items-center justify-center space-x-1.5 transition-all cursor-pointer shadow-sm"
>
<Share2 className="w-3.5 h-3.5" />
<span>Publicar en Facebook</span>
</button>
<button
onClick={() => handleAddToStore(post)}
className="flex-1 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-xs py-2.5 px-3 rounded-xl flex items-center justify-center space-x-1.5 transition-all cursor-pointer shadow-md"
>
<Plus className="w-3.5 h-3.5" />
<span>Publicar a la Tienda</span>
</button>
<div>
<span className="text-[10px] text-emerald-400 block font-bold">Venta Sugerida:</span>
<span className="text-sm font-black text-emerald-300 font-mono">
{formatCOP(extracted?.suggestedRetailPrice || 125000)}
</span>
</div>
</div>
) : (
<p className="text-xs text-slate-500">Extrayendo datos...</p>
)}
{/* Sizes badge pills */}
<div>
<span className="text-[10px] font-bold text-zinc-400 uppercase block mb-1">
Tallas Disponibles:
</span>
<div className="flex gap-1 flex-wrap">
{extracted?.sizes && extracted.sizes.length > 0 ? (
extracted.sizes.map((s) => (
<span
key={s}
className="bg-zinc-950 text-white font-bold text-[11px] px-2 py-0.5 rounded-lg border border-zinc-800"
>
{s}
</span>
))
) : (
<span className="text-xs text-zinc-500">37 a 43</span>
)}
</div>
</div>
</div>
{/* Card Actions */}
<div className="pt-2 border-t border-zinc-800 flex items-center gap-2">
{!isPublished ? (
<>
<button
onClick={() => handlePublishPost(post)}
className="flex-1 bg-gradient-to-r from-red-600 to-orange-500 hover:opacity-95 text-white font-black text-xs py-2.5 px-3 rounded-xl flex items-center justify-center gap-1.5 shadow-md shadow-orange-600/30 cursor-pointer transition-all active:scale-95"
>
<Plus className="w-3.5 h-3.5" />
<span>Publicar en la App</span>
</button>
<button
onClick={() => handleOpenEdit(post)}
title="Editar antes de publicar"
className="bg-zinc-800 hover:bg-zinc-700 text-zinc-300 p-2.5 rounded-xl cursor-pointer transition-all"
>
<Edit3 className="w-4 h-4" />
</button>
<button
onClick={() => handleDiscardPost(post)}
title="Descartar"
className="bg-zinc-800 hover:bg-red-950/40 text-zinc-400 hover:text-red-400 p-2.5 rounded-xl cursor-pointer transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</>
) : (
<div className="w-full flex items-center justify-between text-xs">
<span className="text-emerald-400 font-bold flex items-center gap-1">
<CheckCircle2 className="w-4 h-4" /> Disponible para clientes
</span>
<Link
href="/"
target="_blank"
className="text-orange-400 hover:underline font-bold flex items-center gap-1"
>
<span>Ver en Tienda</span>
<ArrowUpRight className="w-3 h-3" />
</Link>
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Edit Modal Before Publishing */}
{editingPost && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-zinc-900 border border-orange-500/40 rounded-3xl max-w-lg w-full p-6 space-y-5 shadow-2xl animate-in zoom-in-95">
<div className="flex items-center justify-between border-b border-zinc-800 pb-3">
<h3 className="font-black text-white text-base flex items-center gap-2">
<Edit3 className="w-5 h-5 text-orange-400" />
<span>Personalizar antes de Publicar</span>
</h3>
<button
onClick={() => setEditingPost(null)}
className="text-zinc-400 hover:text-white font-bold"
>
✕
</button>
</div>
<div className="space-y-3.5 text-xs">
<div>
<label className="font-bold text-zinc-300 block mb-1">Nombre Comercial del Modelo:</label>
<input
type="text"
value={editName}
onChange={(e) => 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"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="font-bold text-zinc-300 block mb-1">Marca:</label>
<input
type="text"
value={editBrand}
onChange={(e) => 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"
/>
</div>
<div>
<label className="font-bold text-emerald-400 block mb-1">Precio de Venta (COP):</label>
<input
type="number"
value={editPrice}
onChange={(e) => setEditPrice(Number(e.target.value))}
className="w-full bg-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"
/>
</div>
</div>
<div>
<label className="font-bold text-zinc-300 block mb-1">Tallas (separadas por coma):</label>
<input
type="text"
value={editSizes}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex items-center gap-2.5 pt-3 border-t border-zinc-800">
<button
onClick={() => setEditingPost(null)}
className="flex-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 font-bold text-xs py-3 rounded-xl cursor-pointer"
>
Cancelar
</button>
<button
onClick={() => {
const sizesArray = editSizes
.split(',')
.map((s) => s.trim())
.filter(Boolean);
handlePublishPost(editingPost, {
name: editName,
brand: editBrand,
retailPrice: editPrice,
sizes: sizesArray,
});
}}
className="flex-1 bg-gradient-to-r from-red-600 to-orange-500 hover:opacity-95 text-white font-black text-xs py-3 rounded-xl shadow-lg shadow-orange-600/30 cursor-pointer"
>
Guardar y Publicar en la App
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -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 (
<div className="min-h-screen bg-zinc-950 text-white flex flex-col items-center justify-center p-4">
<div className="max-w-md w-full bg-zinc-900/90 border border-orange-500/30 rounded-3xl p-8 text-center space-y-6 shadow-2xl backdrop-blur-xl">
<div className="w-16 h-16 rounded-2xl bg-orange-500/10 border border-orange-500/30 text-orange-400 flex items-center justify-center mx-auto shadow-inner">
<ShieldAlert className="w-8 h-8" />
</div>
<div className="space-y-2">
<h2 className="text-xl font-black tracking-tight text-white">Acceso Restringido para Administradores</h2>
<p className="text-xs text-zinc-400 leading-relaxed">
Este panel de control es de uso exclusivo para el equipo administrativo y operadores logísticos autorizados.
</p>
</div>
<div className="space-y-3 pt-2">
<button
onClick={openAuthModal}
className="w-full bg-gradient-to-r from-red-600 to-orange-500 hover:opacity-95 text-white font-black text-xs py-3.5 px-4 rounded-xl flex items-center justify-center gap-2 shadow-lg shadow-orange-600/30 cursor-pointer transition-all active:scale-95"
>
<LogIn className="w-4 h-4" />
<span>Ingresar como Administrador</span>
</button>
<Link
href="/"
className="w-full bg-zinc-800 hover:bg-zinc-700 text-zinc-300 font-bold text-xs py-3 px-4 rounded-xl flex items-center justify-center gap-2 transition-all block"
>
<ArrowLeft className="w-4 h-4" />
<span>Volver a la Tienda</span>
</Link>
</div>
</div>
<AuthModal />
</div>
);
}
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col md:flex-row font-sans">
{/* Sidebar */}
@ -96,6 +140,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<main className="flex-1 p-4 sm:p-8 overflow-y-auto bg-zinc-950">
{children}
</main>
<AuthModal />
</div>
);
}

View File

@ -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<string, string>();
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 });
}
}

View File

@ -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<Product | null>(null);
const [isCartOpen, setIsCartOpen] = useState(false);
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
const [isInstallModalOpen, setIsInstallModalOpen] = useState(false);
const [cartItems, setCartItems] = useState<CartItem[]>([]);
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 (
<div className={`min-h-screen flex flex-col font-sans transition-colors duration-500 ${currentBrand.colors.bodyBg}`}>
{/* Navigation Header */}
<Navbar
cartCount={totalCartCount}
cartCount={cartCount}
onOpenCart={() => setIsCartOpen(true)}
selectedCity={selectedCity}
onOpenCitySelector={() => setIsCityModalOpen(true)}
@ -121,12 +116,9 @@ export default function StoreHomePage() {
onSearchChange={setSearchQuery}
/>
{/* Main Container */}
<main className="max-w-7xl mx-auto px-4 py-6 flex-1 w-full space-y-6">
{/* Flash Deals Hero Banner */}
<FlashDealsBanner />
{/* Value Props Strip */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-slate-900/60 backdrop-blur-md p-3.5 rounded-2xl border border-slate-800 shadow-sm flex items-center space-x-3">
<div className={`w-10 h-10 rounded-xl bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white flex items-center justify-center shrink-0 shadow-sm`}>
@ -169,7 +161,6 @@ export default function StoreHomePage() {
</div>
</div>
{/* Category & Brand Filters */}
<div className="pt-2">
<div className="flex items-center justify-between mb-3">
<h3 className="font-black text-white text-lg flex items-center gap-2">
@ -187,7 +178,6 @@ export default function StoreHomePage() {
/>
</div>
{/* Product Grid */}
{filteredProducts.length === 0 ? (
<div className="bg-slate-900/80 rounded-3xl p-12 text-center border border-slate-800 space-y-3">
<div className="w-16 h-16 rounded-full bg-white/10 text-white flex items-center justify-center mx-auto">
@ -219,7 +209,6 @@ export default function StoreHomePage() {
)}
</main>
{/* Footer */}
<footer className="bg-black/70 text-slate-400 mt-12 py-8 border-t border-white/5">
<div className="max-w-7xl mx-auto px-4 flex flex-col md:flex-row items-center justify-between gap-4 text-xs">
<div className="flex items-center space-x-2.5">
@ -236,7 +225,6 @@ export default function StoreHomePage() {
</div>
</footer>
{/* Modals & Drawers */}
<ProductModal
product={selectedProduct}
onClose={() => setSelectedProduct(null)}
@ -265,19 +253,11 @@ export default function StoreHomePage() {
onSelectCity={setSelectedCity}
/>
{/* Mobile App Dock & Install Banner */}
<MobileBottomDock
cartCount={cartCount}
onOpenCart={() => setIsCartOpen(true)}
onOpenInstallModal={() => setIsInstallModalOpen(true)}
/>
<MobileInstallBanner
isOpen={isInstallModalOpen}
onClose={() => setIsInstallModalOpen(false)}
/>
{/* User Auth Modal */}
<AuthModal />
</div>
);

View File

@ -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<MobileBottomDockProps> = ({
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<MobileBottomDockProps> = ({
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 (
<div className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-zinc-950/90 backdrop-blur-xl border-t border-zinc-800/80 px-3 py-2">
<nav
aria-label="Navegación principal móvil"
className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-zinc-950/90 backdrop-blur-xl border-t border-zinc-800/80 px-3 py-2"
>
<div className="flex items-center justify-around">
{/* 1. Inicio */}
<Link
href="/"
onClick={() => setActiveTab('home')}
className={`flex flex-col items-center gap-0.5 cursor-pointer transition-all ${
activeTab === 'home' ? 'text-white' : 'text-zinc-400 hover:text-zinc-200'
isHomeActive ? 'text-white' : 'text-zinc-400 hover:text-zinc-200'
}`}
>
<Home className={`w-5 h-5 ${activeTab === 'home' ? 'stroke-[2.5]' : ''}`} />
<Home className={`w-5 h-5 ${isHomeActive ? 'stroke-[2.5] text-white' : ''}`} />
<span className="text-[10px] font-bold">Inicio</span>
</Link>
@ -109,12 +76,14 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
setActiveTab('cart');
onOpenCart();
}}
className="relative flex flex-col items-center gap-0.5 cursor-pointer text-zinc-400 hover:text-white"
className="relative flex flex-col items-center gap-0.5 cursor-pointer text-zinc-400 hover:text-white transition-all"
>
<div className="relative">
<ShoppingBag className="w-5 h-5 text-white" />
{cartCount > 0 && (
<span className={`absolute -top-1.5 -right-2 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-black text-[9px] w-4 h-4 rounded-full flex items-center justify-center shadow-md animate-pulse`}>
<span
className={`absolute -top-1.5 -right-2 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-black text-[9px] w-4 h-4 rounded-full flex items-center justify-center shadow-md animate-pulse`}
>
{cartCount}
</span>
)}
@ -125,33 +94,37 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
{/* 4. WhatsApp Asesoría */}
<button
onClick={handleWhatsApp}
className="flex flex-col items-center gap-0.5 cursor-pointer text-emerald-400 hover:text-emerald-300"
className="flex flex-col items-center gap-0.5 cursor-pointer text-emerald-400 hover:text-emerald-300 transition-all"
>
<MessageCircle className="w-5 h-5 fill-emerald-500/20" />
<span className="text-[10px] font-bold">WhatsApp</span>
</button>
{/* 5. App Móvil (solo en Web) vs Perfil/Admin (si ya está en la App) */}
{!isInsideApp ? (
<button
onClick={onOpenInstallModal}
className="flex flex-col items-center gap-0.5 cursor-pointer text-orange-400 hover:text-orange-300"
>
<div className="relative">
<Download className="w-5 h-5 animate-bounce" />
</div>
<span className="text-[10px] font-bold">App Móvil</span>
</button>
) : (
{/* 5. Solo Administradores: Acceso Admin | Clientes / Visitantes: Mi Cuenta / Ingresar */}
{isAdmin ? (
<Link
href="/admin"
className="flex flex-col items-center gap-0.5 cursor-pointer text-zinc-400 hover:text-white"
onClick={() => setActiveTab('admin')}
className={`flex flex-col items-center gap-0.5 cursor-pointer transition-all ${
isAdminActive || activeTab === 'admin' ? 'text-orange-300' : 'text-zinc-400 hover:text-zinc-200'
}`}
>
<User className="w-5 h-5" />
<Shield className={`w-5 h-5 ${isAdminActive ? 'stroke-[2.5] text-orange-400' : 'text-orange-400'}`} />
<span className="text-[10px] font-bold">Admin</span>
</Link>
) : (
<button
onClick={() => {
setActiveTab('profile');
openAuthModal();
}}
className="flex flex-col items-center gap-0.5 cursor-pointer text-zinc-400 hover:text-white transition-all"
>
<User className="w-5 h-5" />
<span className="text-[10px] font-bold">{user ? 'Mi Cuenta' : 'Ingresar'}</span>
</button>
)}
</div>
</div>
</nav>
);
};

View File

@ -122,15 +122,17 @@ export const Navbar: React.FC<NavbarProps> = ({
)}
</button>
{/* Admin Link */}
<Link
href="/admin"
className="hidden sm:flex items-center space-x-1 text-xs font-semibold text-gray-300 hover:text-orange-400 bg-zinc-900/80 hover:bg-zinc-800 px-3 py-2 rounded-full border border-zinc-700 transition-all"
title="Panel de Administración"
>
<Store className="w-4 h-4 text-orange-400" />
<span className="hidden lg:inline">Admin</span>
</Link>
{/* Admin Link - Solo para Administradores */}
{user && (user.role === 'admin' || user.role === 'operator') && (
<Link
href="/admin"
className="hidden sm:flex items-center space-x-1 text-xs font-semibold text-gray-300 hover:text-orange-400 bg-zinc-900/80 hover:bg-zinc-800 px-3 py-2 rounded-full border border-zinc-700 transition-all"
title="Panel de Administración"
>
<Store className="w-4 h-4 text-orange-400" />
<span className="hidden lg:inline">Admin</span>
</Link>
)}
{/* Cart Button */}
<button

View File

@ -115,12 +115,13 @@ export interface Order {
export interface SupplierPost {
id: string;
channelName: string;
channelJid?: string;
supplierName?: string;
supplierPhone?: string;
rawMessage: string;
rawImages: string[];
extractedProduct?: ExtractedProductData;
status: 'pending_review' | 'published_facebook' | 'published_store' | 'ignored';
status: 'pending_review' | 'published_facebook' | 'published_store' | 'published' | 'discarded' | 'ignored';
receivedAt: string;
}

View File

@ -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<SupplierPost[]>(INITIAL_SUPPLIER_POSTS);
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
const [isProcessing, setIsProcessing] = useState(false);
const [posts, setPosts] = useState<SupplierPost[]>([]);
const [monitoredGroups, setMonitoredGroups] = useState<MonitoredWhatsAppGroup[]>([]);
const [selectedGroupFilter, setSelectedGroupFilter] = useState<string>('all');
const [tabStatus, setTabStatus] = useState<'pending' | 'published' | 'discarded'>('pending');
const [isLoading, setIsLoading] = useState(false);
const [isSyncingLive, setIsSyncingLive] = useState(false);
const [actionMessage, setActionMessage] = useState<string | null>(null);
// Multi-selection state
const [selectedPostIds, setSelectedPostIds] = useState<Set<string>>(new Set());
// Edit Modal State
const [editingPost, setEditingPost] = useState<SupplierPost | null>(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<BrandId | 'all'>('almeira');
const [targetBrand, setTargetBrand] = useState<BrandId | 'all'>('zapply');
// 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 (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-black text-white flex items-center gap-2">
<Radio className="w-6 h-6 text-cyan-400 animate-pulse" />
<span>Feed de Entrada & Publicador Multi-Marca</span>
</h1>
<p className="text-xs text-slate-400 mt-1">
Recepción automática con IA, prevención de duplicados y distribución hacia las 4 marcas (<strong>Almeira Store</strong>, <strong>Sneaky</strong>, <strong>Zapply</strong>, <strong>DripZone</strong>).
</p>
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-xl bg-emerald-500/20 text-emerald-400 flex items-center justify-center border border-emerald-500/30 shadow-[0_0_15px_rgba(16,185,129,0.3)]">
<Radio className="w-5 h-5 animate-pulse" />
</div>
<div>
<h1 className="text-2xl font-black text-white">Feed de Mensajes de WhatsApp • Zapply</h1>
<p className="text-xs text-slate-400">
Visualiza los mensajes de tus grupos de proveedores y selecciona los calzados que deseas publicar en la app móvil.
</p>
</div>
</div>
</div>
{/* Target Brand Selector Pill Bar */}
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl">
<span className="text-[10px] font-bold text-slate-400 pl-2 pr-1 flex items-center gap-1">
<Layers className="w-3 h-3" /> Destino:
</span>
<button
onClick={() => setTargetBrand('all')}
className={`text-xs font-bold px-2.5 py-1 rounded-xl transition-all cursor-pointer ${
targetBrand === 'all' ? 'bg-white text-slate-950 shadow-sm' : 'text-slate-400 hover:text-white'
}`}
{/* Action Controls */}
<div className="flex items-center gap-2.5 flex-wrap">
<Link
href="/admin/grupos-whatsapp"
className="bg-slate-900 hover:bg-slate-800 border border-slate-700 text-slate-300 font-bold text-xs px-3.5 py-2.5 rounded-xl flex items-center gap-1.5 transition-all"
>
4 Marcas
<MessageSquare className="w-4 h-4 text-emerald-400" />
<span>Gestionar Grupos</span>
</Link>
<button
onClick={handleSyncLive}
disabled={isSyncingLive}
className="bg-gradient-to-r from-emerald-600 to-teal-500 hover:opacity-95 text-white font-black text-xs px-4 py-2.5 rounded-xl flex items-center gap-2 shadow-lg shadow-emerald-600/30 cursor-pointer transition-all active:scale-95 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isSyncingLive ? 'animate-spin' : ''}`} />
<span>{isSyncingLive ? 'Consultando Grupos...' : 'Sincronizar WhatsApp en Vivo'}</span>
</button>
{Object.values(BRANDS).map((b) => (
<button
key={b.id}
onClick={() => setTargetBrand(b.id as BrandId)}
className={`text-xs font-bold px-2.5 py-1 rounded-xl transition-all cursor-pointer ${
targetBrand === b.id ? `bg-gradient-to-r ${b.colors.primaryGradient} text-white shadow-sm` : 'text-slate-400 hover:text-white'
}`}
>
{b.shortName}
</button>
))}
</div>
</div>
{/* Toast feedback */}
{actionMessage && (
<div className="bg-cyan-950/80 border border-cyan-500 text-cyan-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-lg">
<span>{actionMessage}</span>
<button onClick={() => setActionMessage(null)} className="font-bold text-white cursor-pointer">
<div className="bg-emerald-950/80 border border-emerald-500/50 text-emerald-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-xl">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-emerald-400" />
<span>{actionMessage}</span>
</div>
<button
onClick={() => setActionMessage(null)}
className="text-white hover:text-emerald-300 font-bold px-2 cursor-pointer"
>
✕
</button>
</div>
)}
{/* Live Simulator for Test Messages */}
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4">
<div className="flex items-center space-x-2 text-cyan-400 font-bold text-xs uppercase tracking-wider">
<Sparkles className="w-4 h-4 text-cyan-400" />
<span>Simulador de Recepción en Tiempo Real</span>
{/* Filter & Tabs Bar */}
<div className="bg-slate-900/90 border border-slate-800 rounded-3xl p-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-4 backdrop-blur-xl">
{/* Status Tabs */}
<div className="flex items-center gap-1 bg-slate-950 p-1 rounded-2xl border border-slate-800 text-xs font-bold">
<button
onClick={() => setTabStatus('pending')}
className={`px-3.5 py-2 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
tabStatus === 'pending'
? 'bg-emerald-600 text-white shadow'
: 'text-slate-400 hover:text-white'
}`}
>
<span>📥 Pendientes</span>
<span className="bg-emerald-950 text-emerald-300 text-[10px] px-1.5 py-0.5 rounded-full font-mono">
{pendingCount}
</span>
</button>
<button
onClick={() => setTabStatus('published')}
className={`px-3.5 py-2 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
tabStatus === 'published'
? 'bg-emerald-600 text-white shadow'
: 'text-slate-400 hover:text-white'
}`}
>
<span>✅ Publicados en Tienda</span>
<span className="bg-emerald-950 text-emerald-300 text-[10px] px-1.5 py-0.5 rounded-full font-mono">
{publishedCount}
</span>
</button>
<button
onClick={() => setTabStatus('discarded')}
className={`px-3.5 py-2 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
tabStatus === 'discarded'
? 'bg-slate-800 text-white shadow'
: 'text-slate-400 hover:text-white'
}`}
>
<span>🗑️ Descartados</span>
<span className="bg-slate-900 text-slate-400 text-[10px] px-1.5 py-0.5 rounded-full font-mono">
{discardedCount}
</span>
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-3">
<div className="md:col-span-4">
<label className="block text-[11px] font-bold text-slate-400 mb-1">Canal de Entrada:</label>
<select
value={testGroup}
onChange={(e) => setTestGroup(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
>
<option value="Canal Mayorista A">Canal Mayorista A</option>
<option value="Canal Mayorista B">Canal Mayorista B</option>
</select>
</div>
<div className="md:col-span-8">
<label className="block text-[11px] font-bold text-slate-400 mb-1">Texto del Mensaje de WhatsApp:</label>
<div className="flex gap-2">
<input
type="text"
value={testText}
onChange={(e) => 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"
/>
<button
onClick={handleSimulateIncoming}
disabled={isProcessing}
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-xs px-4 py-2 rounded-xl flex items-center space-x-1.5 shrink-0 transition-all cursor-pointer shadow-md"
>
<Send className="w-3.5 h-3.5" />
<span>Simular Recepción</span>
</button>
</div>
</div>
{/* Group Selector Filter */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-emerald-400 shrink-0" />
<select
value={selectedGroupFilter}
onChange={(e) => setSelectedGroupFilter(e.target.value)}
className="bg-slate-950 border border-slate-800 text-white text-xs font-semibold rounded-xl px-3 py-2 outline-hidden cursor-pointer"
>
<option value="all">Todos los Grupos de WhatsApp</option>
{monitoredGroups.map((g) => (
<option key={g.id || g.jid} value={g.jid}>
{g.name}
</option>
))}
</select>
</div>
</div>
{/* Intercepted Posts Feed */}
<div className="space-y-4">
<h2 className="font-bold text-white text-base">Mensajes Procesados Recientemente</h2>
{/* Multi-Selection Bulk Actions Bar */}
{selectedPostIds.size > 0 && tabStatus === 'pending' && (
<div className="bg-gradient-to-r from-emerald-950/90 to-slate-900 border border-emerald-500/40 rounded-2xl p-3.5 flex items-center justify-between animate-in fade-in shadow-2xl">
<div className="flex items-center gap-2.5">
<span className="bg-emerald-600 text-white text-xs font-black px-2.5 py-1 rounded-lg">
{selectedPostIds.size} seleccionados
</span>
<span className="text-xs text-slate-300 font-semibold hidden sm:inline">
Listos para transferir al catálogo de la tienda
</span>
</div>
<div className="grid grid-cols-1 gap-4">
{posts.map((post) => {
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedPostIds(new Set())}
className="text-slate-400 hover:text-white text-xs font-bold px-3 py-1.5 rounded-xl cursor-pointer"
>
Desmarcar
</button>
<button
onClick={handleBulkPublish}
className="bg-gradient-to-r from-emerald-600 to-teal-500 hover:opacity-95 text-white text-xs font-black px-4 py-2 rounded-xl flex items-center gap-1.5 shadow-lg shadow-emerald-600/30 cursor-pointer transition-all active:scale-95"
>
<Plus className="w-4 h-4" />
<span>Publicar Seleccionados ({selectedPostIds.size})</span>
</button>
</div>
</div>
)}
{/* Posts List / Grid */}
{filteredPosts.length === 0 ? (
<div className="bg-slate-900/60 rounded-3xl p-12 text-center border border-slate-800 space-y-4">
<div className="w-16 h-16 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center mx-auto">
<MessageSquare className="w-8 h-8" />
</div>
<div className="space-y-1">
<h3 className="font-black text-white text-base">No hay mensajes en esta bandeja</h3>
<p className="text-xs text-slate-400 max-w-md mx-auto">
Haz clic en <strong>"Sincronizar WhatsApp en Vivo"</strong> para extraer automáticamente las ofertas recientes de tus grupos seleccionados.
</p>
</div>
<button
onClick={handleSyncLive}
disabled={isSyncingLive}
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs px-5 py-2.5 rounded-xl inline-flex items-center gap-2 cursor-pointer shadow-md"
>
<RefreshCw className={`w-4 h-4 ${isSyncingLive ? 'animate-spin' : ''}`} />
<span>Escanear Grupos Ahora</span>
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{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 (
<div
key={post.id}
className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4 hover:border-slate-700 transition-all"
className={`bg-slate-900 border rounded-3xl p-5 space-y-4 transition-all ${
isSelected
? 'border-emerald-500 shadow-[0_0_20px_rgba(16,185,129,0.2)]'
: isPublished
? 'border-emerald-500/30 bg-emerald-950/10'
: 'border-slate-800 hover:border-slate-700'
}`}
>
{/* Header of post */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800 pb-3">
<div className="flex items-center space-x-2">
<span className="bg-slate-800 text-slate-200 text-xs font-black px-2.5 py-1 rounded-full border border-slate-700">
{post.channelName || 'Canal Mayorista'}
</span>
<span className="text-xs text-slate-400">{post.receivedAt}</span>
{/* Header info */}
<div className="flex items-center justify-between gap-2 border-b border-slate-800 pb-3">
<div className="flex items-center gap-2">
{tabStatus === 'pending' && (
<button
onClick={() => handleToggleSelect(post.id)}
className="text-emerald-400 hover:text-emerald-300 cursor-pointer"
>
{isSelected ? (
<CheckSquare className="w-5 h-5 fill-emerald-600 text-white" />
) : (
<Square className="w-5 h-5 text-slate-600" />
)}
</button>
)}
<div>
<span className="bg-emerald-950 text-emerald-300 text-[10px] font-black px-2 py-0.5 rounded-full border border-emerald-500/30 inline-block">
{post.channelName || 'Grupo WhatsApp'}
</span>
<span className="text-[10px] text-slate-500 ml-2 font-mono">{post.receivedAt}</span>
</div>
</div>
{/* Anti-Duplicate Status Badge */}
{duplicateCheck && (
<div
className={`text-xs font-bold px-3 py-1 rounded-full flex items-center gap-1.5 ${
duplicateCheck.isDuplicate
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
}`}
>
{duplicateCheck.isDuplicate ? (
<>
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
<span>Duplicado Detectado ({Math.round(duplicateCheck.similarityScore * 100)}% Similitud)</span>
</>
) : (
<>
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
<span>Producto Nuevo (0% Duplicados)</span>
</>
)}
</div>
)}
<div>
{isPublished ? (
<span className="bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 text-[10px] font-bold px-2.5 py-0.5 rounded-full flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Publicado en Tienda
</span>
) : isDiscarded ? (
<span className="bg-slate-800 text-slate-400 text-[10px] font-bold px-2.5 py-0.5 rounded-full">
Descartado
</span>
) : (
<span className="bg-amber-500/20 text-amber-300 border border-amber-500/40 text-[10px] font-bold px-2.5 py-0.5 rounded-full flex items-center gap-1">
<Sparkles className="w-3 h-3" /> Pendiente de Aprobación
</span>
)}
</div>
</div>
{/* Content Grid */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
{/* Left: Raw WhatsApp Message (5 cols) */}
<div className="md:col-span-5 bg-slate-950 rounded-2xl p-4 space-y-2 border border-slate-800">
<span className="text-[10px] uppercase font-bold text-slate-400">Mensaje de Entrada:</span>
<p className="text-xs text-slate-300 font-mono whitespace-pre-line leading-relaxed">
{post.rawMessage}
</p>
{post.rawImages.length > 0 && (
<div className="pt-2">
{/* Content Grid (Photo + AI extracted info) */}
<div className="grid grid-cols-1 sm:grid-cols-12 gap-4">
{/* Sneaker Image & Raw text */}
<div className="sm:col-span-5 space-y-2">
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-slate-950 border border-slate-800 flex items-center justify-center group">
{post.rawImages && post.rawImages[0] ? (
<img
src={post.rawImages[0]}
alt=""
className="w-24 h-24 rounded-xl object-cover bg-slate-900 border border-slate-700"
alt={extracted?.name || 'Zapatilla'}
className="w-full h-full object-cover group-hover:scale-105 transition-all"
/>
</div>
)}
) : (
<div className="text-slate-600 text-xs">Sin foto</div>
)}
</div>
<div className="bg-slate-950 rounded-xl p-2.5 border border-slate-800/80">
<p className="text-[11px] text-slate-400 font-mono whitespace-pre-line line-clamp-3">
{post.rawMessage}
</p>
</div>
</div>
{/* Right: AI Extracted & Pricing Calculation (7 cols) */}
<div className="md:col-span-7 space-y-3">
{extracted ? (
<div className="bg-slate-950 rounded-2xl p-4 border border-slate-800 space-y-3">
<div className="flex items-start justify-between">
<div>
<span className="text-[10px] font-bold text-slate-400 uppercase">
{extracted.brand} • {extracted.category}
</span>
<h3 className="text-base font-black text-white">{extracted.name}</h3>
</div>
<span className="text-xs font-mono bg-purple-500/20 text-purple-300 border border-purple-500/30 px-2 py-0.5 rounded font-bold">
IA: {Math.round(extracted.confidence * 100)}% certeza
</span>
</div>
{/* AI Extraction & Product Info */}
<div className="sm:col-span-7 space-y-3 flex flex-col justify-between">
<div className="space-y-2">
<div>
<span className="text-[10px] font-black uppercase text-emerald-400 tracking-wider">
{extracted?.brand || 'Nike'} • {extracted?.category || 'Sneakers'}
</span>
<h3 className="text-base font-black text-white leading-tight">
{extracted?.name || 'Zapatilla Urbana'}
</h3>
</div>
{/* Pricing breakdown */}
<div className="grid grid-cols-2 gap-3 bg-slate-900 p-3 rounded-xl border border-slate-800">
<div>
<span className="text-[10px] text-slate-400 block font-semibold">Costo Base:</span>
<span className="text-sm font-bold text-slate-300 font-mono">
{formatCOP(extracted.supplierPrice)}
</span>
</div>
<div>
<span className="text-[10px] text-emerald-400 block font-bold">Precio Venta (+ Margen):</span>
<span className="text-base font-black text-emerald-300 font-mono">
{formatCOP(extracted.suggestedRetailPrice)}
</span>
</div>
</div>
{/* Sizes */}
{/* Pricing breakdown */}
<div className="bg-slate-950 p-3 rounded-2xl border border-slate-800 grid grid-cols-2 gap-2">
<div>
<span className="text-[10px] text-slate-400 font-semibold uppercase block mb-1">
Tallas extraídas:
<span className="text-[10px] text-slate-400 block font-bold">Costo Proveedor:</span>
<span className="text-xs font-bold text-slate-300 font-mono">
{formatCOP(extracted?.supplierPrice || 90000)}
</span>
<div className="flex gap-1 flex-wrap">
{extracted.sizes.map((s) => (
<span key={s} className="bg-slate-900 text-white font-bold text-xs px-2 py-0.5 rounded border border-slate-800">
{s}
</span>
))}
</div>
</div>
{/* Duplicate warning note */}
{duplicateCheck?.isDuplicate && (
<div className="bg-amber-950/40 border border-amber-800/60 p-2.5 rounded-xl text-xs text-amber-200">
{duplicateCheck.message}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t border-slate-800">
<button
onClick={() => handlePublishFacebook(post)}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-bold text-xs py-2.5 px-3 rounded-xl flex items-center justify-center space-x-1.5 transition-all cursor-pointer shadow-sm"
>
<Share2 className="w-3.5 h-3.5" />
<span>Publicar en Facebook</span>
</button>
<button
onClick={() => handleAddToStore(post)}
className="flex-1 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-xs py-2.5 px-3 rounded-xl flex items-center justify-center space-x-1.5 transition-all cursor-pointer shadow-md"
>
<Plus className="w-3.5 h-3.5" />
<span>Publicar a la Tienda</span>
</button>
<div>
<span className="text-[10px] text-emerald-400 block font-bold">Venta Sugerida:</span>
<span className="text-sm font-black text-emerald-300 font-mono">
{formatCOP(extracted?.suggestedRetailPrice || 125000)}
</span>
</div>
</div>
) : (
<p className="text-xs text-slate-500">Extrayendo datos...</p>
)}
{/* Sizes badge pills */}
<div>
<span className="text-[10px] font-bold text-slate-400 uppercase block mb-1">
Tallas Disponibles:
</span>
<div className="flex gap-1 flex-wrap">
{extracted?.sizes && extracted.sizes.length > 0 ? (
extracted.sizes.map((s) => (
<span
key={s}
className="bg-slate-950 text-white font-bold text-[11px] px-2 py-0.5 rounded-lg border border-slate-800"
>
{s}
</span>
))
) : (
<span className="text-xs text-slate-500">37 a 43</span>
)}
</div>
</div>
</div>
{/* Card Actions */}
<div className="pt-2 border-t border-slate-800 flex items-center gap-2">
{!isPublished ? (
<>
<button
onClick={() => handlePublishPost(post)}
className="flex-1 bg-gradient-to-r from-emerald-600 to-teal-500 hover:opacity-95 text-white font-black text-xs py-2.5 px-3 rounded-xl flex items-center justify-center gap-1.5 shadow-md shadow-emerald-600/30 cursor-pointer transition-all active:scale-95"
>
<Plus className="w-3.5 h-3.5" />
<span>Publicar en la App</span>
</button>
<button
onClick={() => handleOpenEdit(post)}
title="Editar antes de publicar"
className="bg-slate-800 hover:bg-slate-700 text-slate-300 p-2.5 rounded-xl cursor-pointer transition-all"
>
<Edit3 className="w-4 h-4" />
</button>
<button
onClick={() => handleDiscardPost(post)}
title="Descartar"
className="bg-slate-800 hover:bg-red-950/40 text-slate-400 hover:text-red-400 p-2.5 rounded-xl cursor-pointer transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</>
) : (
<div className="w-full flex items-center justify-between text-xs">
<span className="text-emerald-400 font-bold flex items-center gap-1">
<CheckCircle2 className="w-4 h-4" /> Disponible para clientes
</span>
<Link
href="/"
target="_blank"
className="text-emerald-400 hover:underline font-bold flex items-center gap-1"
>
<span>Ver en Tienda</span>
<ArrowUpRight className="w-3 h-3" />
</Link>
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Edit Modal Before Publishing */}
{editingPost && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-slate-900 border border-emerald-500/40 rounded-3xl max-w-lg w-full p-6 space-y-5 shadow-2xl animate-in zoom-in-95">
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
<h3 className="font-black text-white text-base flex items-center gap-2">
<Edit3 className="w-5 h-5 text-emerald-400" />
<span>Personalizar antes de Publicar</span>
</h3>
<button
onClick={() => setEditingPost(null)}
className="text-slate-400 hover:text-white font-bold"
>
✕
</button>
</div>
<div className="space-y-3.5 text-xs">
<div>
<label className="font-bold text-slate-300 block mb-1">Nombre Comercial del Modelo:</label>
<input
type="text"
value={editName}
onChange={(e) => 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"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="font-bold text-slate-300 block mb-1">Marca:</label>
<input
type="text"
value={editBrand}
onChange={(e) => 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"
/>
</div>
<div>
<label className="font-bold text-emerald-400 block mb-1">Precio de Venta (COP):</label>
<input
type="number"
value={editPrice}
onChange={(e) => setEditPrice(Number(e.target.value))}
className="w-full bg-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"
/>
</div>
</div>
<div>
<label className="font-bold text-slate-300 block mb-1">Tallas (separadas por coma):</label>
<input
type="text"
value={editSizes}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex items-center gap-2.5 pt-3 border-t border-slate-800">
<button
onClick={() => setEditingPost(null)}
className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 font-bold text-xs py-3 rounded-xl cursor-pointer"
>
Cancelar
</button>
<button
onClick={() => {
const sizesArray = editSizes
.split(',')
.map((s) => s.trim())
.filter(Boolean);
handlePublishPost(editingPost, {
name: editName,
brand: editBrand,
retailPrice: editPrice,
sizes: sizesArray,
});
}}
className="flex-1 bg-gradient-to-r from-emerald-600 to-teal-500 hover:opacity-95 text-white font-black text-xs py-3 rounded-xl shadow-lg shadow-emerald-600/30 cursor-pointer"
>
Guardar y Publicar en la App
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -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 (
<div className="min-h-screen bg-slate-950 text-white flex flex-col items-center justify-center p-4">
<div className="max-w-md w-full bg-slate-900/90 border border-emerald-500/30 rounded-3xl p-8 text-center space-y-6 shadow-2xl backdrop-blur-xl">
<div className="w-16 h-16 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 flex items-center justify-center mx-auto shadow-inner">
<ShieldAlert className="w-8 h-8" />
</div>
<div className="space-y-2">
<h2 className="text-xl font-black tracking-tight text-white">Acceso Restringido para Administradores</h2>
<p className="text-xs text-slate-400 leading-relaxed">
Este panel de control es de uso exclusivo para el equipo administrativo y operadores logísticos autorizados.
</p>
</div>
<div className="space-y-3 pt-2">
<button
onClick={openAuthModal}
className="w-full bg-gradient-to-r from-emerald-600 to-teal-500 hover:opacity-95 text-white font-black text-xs py-3.5 px-4 rounded-xl flex items-center justify-center gap-2 shadow-lg shadow-emerald-600/30 cursor-pointer transition-all active:scale-95"
>
<LogIn className="w-4 h-4" />
<span>Ingresar como Administrador</span>
</button>
<Link
href="/"
className="w-full bg-slate-800 hover:bg-slate-700 text-slate-300 font-bold text-xs py-3 px-4 rounded-xl flex items-center justify-center gap-2 transition-all block"
>
<ArrowLeft className="w-4 h-4" />
<span>Volver a la Tienda</span>
</Link>
</div>
</div>
<AuthModal />
</div>
);
}
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col md:flex-row font-sans">
{/* Sidebar */}
@ -96,6 +140,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<main className="flex-1 p-4 sm:p-8 overflow-y-auto bg-slate-950">
{children}
</main>
<AuthModal />
</div>
);
}

View File

@ -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<string, string>();
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 });
}
}

View File

@ -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<Product | null>(null);
const [isCartOpen, setIsCartOpen] = useState(false);
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
const [isInstallModalOpen, setIsInstallModalOpen] = useState(false);
const [cartItems, setCartItems] = useState<CartItem[]>([]);
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 (
<div className={`min-h-screen flex flex-col font-sans transition-colors duration-500 ${currentBrand.colors.bodyBg}`}>
{/* Navigation Header */}
<Navbar
cartCount={totalCartCount}
cartCount={cartCount}
onOpenCart={() => 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 */}
<MobileBottomDock
cartCount={cartCount}
onOpenCart={() => setIsCartOpen(true)}
onOpenInstallModal={() => setIsInstallModalOpen(true)}
/>
<MobileInstallBanner
isOpen={isInstallModalOpen}
onClose={() => setIsInstallModalOpen(false)}
/>
{/* User Auth Modal */}

View File

@ -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<MobileBottomDockProps> = ({
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<MobileBottomDockProps> = ({
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 (
<div className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-slate-950/90 backdrop-blur-xl border-t border-slate-800/80 px-3 py-2">
<nav
aria-label="Navegación principal móvil"
className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-slate-950/90 backdrop-blur-xl border-t border-slate-800/80 px-3 py-2"
>
<div className="flex items-center justify-around">
{/* 1. Inicio */}
<Link
href="/"
onClick={() => setActiveTab('home')}
className={`flex flex-col items-center gap-0.5 cursor-pointer transition-all ${
activeTab === 'home' ? 'text-white' : 'text-slate-400 hover:text-slate-200'
isHomeActive ? 'text-white' : 'text-slate-400 hover:text-slate-200'
}`}
>
<Home className={`w-5 h-5 ${activeTab === 'home' ? 'stroke-[2.5]' : ''}`} />
<Home className={`w-5 h-5 ${isHomeActive ? 'stroke-[2.5] text-white' : ''}`} />
<span className="text-[10px] font-bold">Inicio</span>
</Link>
{/* 2. Buscar */}
<button
onClick={() => {
setActiveTab('search');
@ -101,17 +70,20 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
<span className="text-[10px] font-bold">Buscar</span>
</button>
{/* 3. Carrito */}
<button
onClick={() => {
setActiveTab('cart');
onOpenCart();
}}
className="relative flex flex-col items-center gap-0.5 cursor-pointer text-slate-400 hover:text-white"
className="relative flex flex-col items-center gap-0.5 cursor-pointer text-slate-400 hover:text-white transition-all"
>
<div className="relative">
<ShoppingBag className="w-5 h-5 text-white" />
{cartCount > 0 && (
<span className={`absolute -top-1.5 -right-2 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-black text-[9px] w-4 h-4 rounded-full flex items-center justify-center shadow-md animate-pulse`}>
<span
className={`absolute -top-1.5 -right-2 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-black text-[9px] w-4 h-4 rounded-full flex items-center justify-center shadow-md animate-pulse`}
>
{cartCount}
</span>
)}
@ -119,34 +91,40 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
<span className="text-[10px] font-bold text-white">Carrito</span>
</button>
{/* 4. WhatsApp */}
<button
onClick={handleWhatsApp}
className="flex flex-col items-center gap-0.5 cursor-pointer text-emerald-400 hover:text-emerald-300"
className="flex flex-col items-center gap-0.5 cursor-pointer text-emerald-400 hover:text-emerald-300 transition-all"
>
<MessageCircle className="w-5 h-5 fill-emerald-500/20" />
<span className="text-[10px] font-bold">WhatsApp</span>
</button>
{!isInsideApp ? (
<button
onClick={onOpenInstallModal}
className="flex flex-col items-center gap-0.5 cursor-pointer text-emerald-400 hover:text-emerald-300"
>
<div className="relative">
<Download className="w-5 h-5 animate-bounce" />
</div>
<span className="text-[10px] font-bold">App Móvil</span>
</button>
) : (
{/* 5. Solo Administradores: Acceso Admin | Clientes / Visitantes: Mi Cuenta / Ingresar */}
{isAdmin ? (
<Link
href="/admin"
className="flex flex-col items-center gap-0.5 cursor-pointer text-slate-400 hover:text-white"
onClick={() => setActiveTab('admin')}
className={`flex flex-col items-center gap-0.5 cursor-pointer transition-all ${
isAdminActive || activeTab === 'admin' ? 'text-emerald-300' : 'text-slate-400 hover:text-slate-200'
}`}
>
<User className="w-5 h-5" />
<Shield className={`w-5 h-5 ${isAdminActive ? 'stroke-[2.5] text-emerald-400' : 'text-emerald-400'}`} />
<span className="text-[10px] font-bold">Admin</span>
</Link>
) : (
<button
onClick={() => {
setActiveTab('profile');
openAuthModal();
}}
className="flex flex-col items-center gap-0.5 cursor-pointer text-slate-400 hover:text-white transition-all"
>
<User className="w-5 h-5" />
<span className="text-[10px] font-bold">{user ? 'Mi Cuenta' : 'Ingresar'}</span>
</button>
)}
</div>
</div>
</nav>
);
};

View File

@ -122,15 +122,17 @@ export const Navbar: React.FC<NavbarProps> = ({
)}
</button>
{/* Admin Link */}
<Link
href="/admin"
className="hidden sm:flex items-center space-x-1 text-xs font-semibold text-slate-300 hover:text-emerald-400 bg-slate-900/80 hover:bg-slate-800 px-3 py-2 rounded-full border border-slate-700 transition-all"
title="Panel de Administración"
>
<Store className="w-4 h-4 text-emerald-400" />
<span className="hidden lg:inline">Admin</span>
</Link>
{/* Admin Link - Solo para Administradores */}
{user && (user.role === 'admin' || user.role === 'operator') && (
<Link
href="/admin"
className="hidden sm:flex items-center space-x-1 text-xs font-semibold text-slate-300 hover:text-emerald-400 bg-slate-900/80 hover:bg-slate-800 px-3 py-2 rounded-full border border-slate-700 transition-all"
title="Panel de Administración"
>
<Store className="w-4 h-4 text-emerald-400" />
<span className="hidden lg:inline">Admin</span>
</Link>
)}
{/* Cart Button */}
<button

View File

@ -115,12 +115,13 @@ export interface Order {
export interface SupplierPost {
id: string;
channelName: string;
channelJid?: string;
supplierName?: string;
supplierPhone?: string;
rawMessage: string;
rawImages: string[];
extractedProduct?: ExtractedProductData;
status: 'pending_review' | 'published_facebook' | 'published_store' | 'ignored';
status: 'pending_review' | 'published_facebook' | 'published_store' | 'published' | 'discarded' | 'ignored';
receivedAt: string;
}