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:
parent
9b74396685
commit
5b0c675431
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
247
almeira-store-app/src/app/api/whatsapp-feed/route.ts
Normal file
247
almeira-store-app/src/app/api/whatsapp-feed/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
255
dripzone-app/src/app/api/whatsapp-feed/route.ts
Normal file
255
dripzone-app/src/app/api/whatsapp-feed/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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})...`);
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
247
sneaky-app/src/app/api/whatsapp-feed/route.ts
Normal file
247
sneaky-app/src/app/api/whatsapp-feed/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
247
zapply-app/src/app/api/whatsapp-feed/route.ts
Normal file
247
zapply-app/src/app/api/whatsapp-feed/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user