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';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Radio, Sparkles, Share2, AlertTriangle, ShieldCheck, Plus, Send, Layers } from 'lucide-react';
|
import Link from 'next/link';
|
||||||
import { SupplierPost, Product } from '@/types';
|
import {
|
||||||
import { INITIAL_SUPPLIER_POSTS, INITIAL_PRODUCTS } from '@/lib/data-service';
|
Radio,
|
||||||
import { checkProductDuplicate } from '@/lib/deduplication-engine';
|
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 { formatCOP } from '@/lib/pricing-engine';
|
||||||
import { BRANDS, BrandId } from '@/config/brands';
|
import { BRANDS, BrandId } from '@/config/brands';
|
||||||
|
|
||||||
export default function SupplierFeedPage() {
|
export default function SupplierFeedPage() {
|
||||||
const [posts, setPosts] = useState<SupplierPost[]>(INITIAL_SUPPLIER_POSTS);
|
const [posts, setPosts] = useState<SupplierPost[]>([]);
|
||||||
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
|
const [monitoredGroups, setMonitoredGroups] = useState<MonitoredWhatsAppGroup[]>([]);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
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);
|
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
|
// Target brand selector for publishing
|
||||||
const [targetBrand, setTargetBrand] = useState<BrandId | 'all'>('almeira');
|
const [targetBrand, setTargetBrand] = useState<BrandId | 'all'>('almeira');
|
||||||
|
|
||||||
// Simulación de nuevo mensaje entrante de grupo para probar en vivo
|
// Cargar grupos monitoreados y posts iniciales
|
||||||
const [testText, setTestText] = useState(
|
const loadInitialData = async () => {
|
||||||
'🔥 LLEGANDO A BODEGA HOY 🔥\nNike Air Max 90 White Black\nTallas: 38 a 43\nMayorista: $90.000 c/u\nEnvío inmediato contraentrega'
|
setIsLoading(true);
|
||||||
);
|
|
||||||
const [testGroup, setTestGroup] = useState('Canal Mayorista A');
|
|
||||||
|
|
||||||
const handleSimulateIncoming = async () => {
|
|
||||||
setIsProcessing(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/webhook/whatsapp', {
|
const grpRes = await fetch('/api/whatsapp-groups');
|
||||||
method: 'POST',
|
const grpData = await grpRes.json();
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (grpData.success && Array.isArray(grpData.groups)) {
|
||||||
body: JSON.stringify({
|
setMonitoredGroups(grpData.groups);
|
||||||
text: testText,
|
}
|
||||||
groupInfo: { subject: testGroup },
|
|
||||||
}),
|
const feedRes = await fetch('/api/whatsapp-feed');
|
||||||
});
|
const feedData = await feedRes.json();
|
||||||
const data = await res.json();
|
if (feedData.success && Array.isArray(feedData.posts)) {
|
||||||
if (data.success && data.post) {
|
setPosts(feedData.posts);
|
||||||
setPosts((prev) => [data.post, ...prev]);
|
|
||||||
setActionMessage('✅ Nuevo mensaje de WhatsApp procesado con IA.');
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error('Error cargando feed:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePublishFacebook = async (post: SupplierPost) => {
|
useEffect(() => {
|
||||||
if (!post.extractedProduct) return;
|
loadInitialData();
|
||||||
setIsProcessing(true);
|
}, []);
|
||||||
|
|
||||||
|
// Sincronizar mensajes en vivo desde Evolution API
|
||||||
|
const handleSyncLive = async () => {
|
||||||
|
setIsSyncingLive(true);
|
||||||
|
setActionMessage(null);
|
||||||
try {
|
try {
|
||||||
const brandName = targetBrand === 'all' ? 'Todas las Tiendas' : BRANDS[targetBrand].name;
|
const url =
|
||||||
const res = await fetch('/api/publish/facebook', {
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
productName: post.extractedProduct.name,
|
action: 'publish',
|
||||||
brand: post.extractedProduct.brand,
|
post,
|
||||||
retailPrice: post.extractedProduct.suggestedRetailPrice,
|
customName: customData?.name,
|
||||||
sizes: post.extractedProduct.sizes,
|
customRetailPrice: customData?.retailPrice,
|
||||||
images: post.rawImages,
|
customSizes: customData?.sizes,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
setActionMessage('❌ Error al publicar producto.');
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddToStore = async (post: SupplierPost) => {
|
// Publicación masiva de los seleccionados
|
||||||
if (!post.extractedProduct) return;
|
const handleBulkPublish = async () => {
|
||||||
const brandLabel = targetBrand === 'all' ? 'Multi-Marca' : BRANDS[targetBrand].shortName;
|
const selectedPosts = posts.filter(
|
||||||
const newProduct: Product = {
|
(p) => selectedPostIds.has(p.id) && p.status !== 'published'
|
||||||
id: `snk-${Date.now()}`,
|
);
|
||||||
name: post.extractedProduct.name,
|
if (selectedPosts.length === 0) return;
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/products', {
|
const res = await fetch('/api/whatsapp-feed', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(newProduct),
|
body: JSON.stringify({
|
||||||
|
action: 'bulk-publish',
|
||||||
|
posts: selectedPosts,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setProducts((prev) => [data.product, ...prev]);
|
setPosts((prev) =>
|
||||||
setActionMessage(`✨ "${newProduct.name}" habilitado en el catálogo para ${brandLabel}.`);
|
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) {
|
} catch (e) {
|
||||||
console.error(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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* 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>
|
<div>
|
||||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Radio className="w-6 h-6 text-cyan-400 animate-pulse" />
|
<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)]">
|
||||||
<span>Feed de Entrada & Publicador Multi-Marca</span>
|
<Radio className="w-5 h-5 animate-pulse" />
|
||||||
</h1>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
<div>
|
||||||
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>).
|
<h1 className="text-2xl font-black text-white">Feed de Mensajes de WhatsApp • Almeira Store</h1>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Target Brand Selector Pill Bar */}
|
{/* Action Controls */}
|
||||||
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl">
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
<span className="text-[10px] font-bold text-slate-400 pl-2 pr-1 flex items-center gap-1">
|
<Link
|
||||||
<Layers className="w-3 h-3" /> Destino:
|
href="/admin/grupos-whatsapp"
|
||||||
</span>
|
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"
|
||||||
<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'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Toast feedback */}
|
||||||
{actionMessage && (
|
{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">
|
<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">
|
||||||
<span>{actionMessage}</span>
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={() => setActionMessage(null)} className="font-bold text-white cursor-pointer">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Live Simulator for Test Messages */}
|
{/* Filter & Tabs Bar */}
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4">
|
<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">
|
||||||
<div className="flex items-center space-x-2 text-cyan-400 font-bold text-xs uppercase tracking-wider">
|
{/* Status Tabs */}
|
||||||
<Sparkles className="w-4 h-4 text-cyan-400" />
|
<div className="flex items-center gap-1 bg-slate-950 p-1 rounded-2xl border border-slate-800 text-xs font-bold">
|
||||||
<span>Simulador de Recepción en Tiempo Real</span>
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-3">
|
{/* Group Selector Filter */}
|
||||||
<div className="md:col-span-4">
|
<div className="flex items-center gap-2">
|
||||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Canal de Entrada:</label>
|
<Filter className="w-4 h-4 text-cyan-400 shrink-0" />
|
||||||
<select
|
<select
|
||||||
value={testGroup}
|
value={selectedGroupFilter}
|
||||||
onChange={(e) => setTestGroup(e.target.value)}
|
onChange={(e) => setSelectedGroupFilter(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"
|
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="Canal Mayorista A">Canal Mayorista A</option>
|
<option value="all">Todos los Grupos de WhatsApp</option>
|
||||||
<option value="Canal Mayorista B">Canal Mayorista B</option>
|
{monitoredGroups.map((g) => (
|
||||||
</select>
|
<option key={g.id || g.jid} value={g.jid}>
|
||||||
</div>
|
{g.name}
|
||||||
|
</option>
|
||||||
<div className="md:col-span-8">
|
))}
|
||||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Texto del Mensaje de WhatsApp:</label>
|
</select>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Intercepted Posts Feed */}
|
{/* Multi-Selection Bulk Actions Bar */}
|
||||||
<div className="space-y-4">
|
{selectedPostIds.size > 0 && tabStatus === 'pending' && (
|
||||||
<h2 className="font-bold text-white text-base">Mensajes Procesados Recientemente</h2>
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
{posts.map((post) => {
|
<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 extracted = post.extractedProduct;
|
||||||
const duplicateCheck = extracted
|
const isSelected = selectedPostIds.has(post.id);
|
||||||
? checkProductDuplicate(
|
const isPublished = post.status === 'published';
|
||||||
{
|
const isDiscarded = post.status === 'discarded';
|
||||||
name: extracted.name,
|
|
||||||
brand: extracted.brand,
|
|
||||||
sizes: extracted.sizes,
|
|
||||||
supplierPrice: extracted.supplierPrice,
|
|
||||||
},
|
|
||||||
products
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={post.id}
|
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 */}
|
{/* Header info */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800 pb-3">
|
<div className="flex items-center justify-between gap-2 border-b border-slate-800 pb-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="bg-slate-800 text-slate-200 text-xs font-black px-2.5 py-1 rounded-full border border-slate-700">
|
{tabStatus === 'pending' && (
|
||||||
{post.channelName || 'Canal Mayorista'}
|
<button
|
||||||
</span>
|
onClick={() => handleToggleSelect(post.id)}
|
||||||
<span className="text-xs text-slate-400">{post.receivedAt}</span>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Anti-Duplicate Status Badge */}
|
<div>
|
||||||
{duplicateCheck && (
|
{isPublished ? (
|
||||||
<div
|
<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">
|
||||||
className={`text-xs font-bold px-3 py-1 rounded-full flex items-center gap-1.5 ${
|
<CheckCircle2 className="w-3 h-3" /> Publicado en Tienda
|
||||||
duplicateCheck.isDuplicate
|
</span>
|
||||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
|
) : isDiscarded ? (
|
||||||
: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
|
<span className="bg-slate-800 text-slate-400 text-[10px] font-bold px-2.5 py-0.5 rounded-full">
|
||||||
}`}
|
Descartado
|
||||||
>
|
</span>
|
||||||
{duplicateCheck.isDuplicate ? (
|
) : (
|
||||||
<>
|
<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">
|
||||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
|
<Sparkles className="w-3 h-3" /> Pendiente de Aprobación
|
||||||
<span>Duplicado Detectado ({Math.round(duplicateCheck.similarityScore * 100)}% Similitud)</span>
|
</span>
|
||||||
</>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<>
|
|
||||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
|
||||||
<span>Producto Nuevo (0% Duplicados)</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Grid */}
|
{/* Content Grid (Photo + AI extracted info) */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
|
<div className="grid grid-cols-1 sm:grid-cols-12 gap-4">
|
||||||
{/* Left: Raw WhatsApp Message (5 cols) */}
|
{/* Sneaker Image & Raw text */}
|
||||||
<div className="md:col-span-5 bg-slate-950 rounded-2xl p-4 space-y-2 border border-slate-800">
|
<div className="sm:col-span-5 space-y-2">
|
||||||
<span className="text-[10px] uppercase font-bold text-slate-400">Mensaje de Entrada:</span>
|
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-slate-950 border border-slate-800 flex items-center justify-center group">
|
||||||
<p className="text-xs text-slate-300 font-mono whitespace-pre-line leading-relaxed">
|
{post.rawImages && post.rawImages[0] ? (
|
||||||
{post.rawMessage}
|
|
||||||
</p>
|
|
||||||
{post.rawImages.length > 0 && (
|
|
||||||
<div className="pt-2">
|
|
||||||
<img
|
<img
|
||||||
src={post.rawImages[0]}
|
src={post.rawImages[0]}
|
||||||
alt=""
|
alt={extracted?.name || 'Zapatilla'}
|
||||||
className="w-24 h-24 rounded-xl object-cover bg-slate-900 border border-slate-700"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Right: AI Extracted & Pricing Calculation (7 cols) */}
|
{/* AI Extraction & Product Info */}
|
||||||
<div className="md:col-span-7 space-y-3">
|
<div className="sm:col-span-7 space-y-3 flex flex-col justify-between">
|
||||||
{extracted ? (
|
<div className="space-y-2">
|
||||||
<div className="bg-slate-950 rounded-2xl p-4 border border-slate-800 space-y-3">
|
<div>
|
||||||
<div className="flex items-start justify-between">
|
<span className="text-[10px] font-black uppercase text-cyan-400 tracking-wider">
|
||||||
<div>
|
{extracted?.brand || 'Nike'} • {extracted?.category || 'Sneakers'}
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase">
|
</span>
|
||||||
{extracted.brand} • {extracted.category}
|
<h3 className="text-base font-black text-white leading-tight">
|
||||||
</span>
|
{extracted?.name || 'Zapatilla Urbana'}
|
||||||
<h3 className="text-base font-black text-white">{extracted.name}</h3>
|
</h3>
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
{/* Pricing breakdown */}
|
{/* Pricing breakdown */}
|
||||||
<div className="grid grid-cols-2 gap-3 bg-slate-900 p-3 rounded-xl border border-slate-800">
|
<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 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 */}
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[10px] text-slate-400 font-semibold uppercase block mb-1">
|
<span className="text-[10px] text-slate-400 block font-bold">Costo Proveedor:</span>
|
||||||
Tallas extraídas:
|
<span className="text-xs font-bold text-slate-300 font-mono">
|
||||||
|
{formatCOP(extracted?.supplierPrice || 90000)}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
<div>
|
||||||
{/* Duplicate warning note */}
|
<span className="text-[10px] text-emerald-400 block font-bold">Venta Sugerida:</span>
|
||||||
{duplicateCheck?.isDuplicate && (
|
<span className="text-sm font-black text-emerald-300 font-mono">
|
||||||
<div className="bg-amber-950/40 border border-amber-800/60 p-2.5 rounded-xl text-xs text-amber-200">
|
{formatCOP(extracted?.suggestedRetailPrice || 125000)}
|
||||||
{duplicateCheck.message}
|
</span>
|
||||||
</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>
|
</div>
|
||||||
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,15 +11,20 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Sparkles,
|
|
||||||
Store,
|
Store,
|
||||||
Users,
|
Users,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Building2,
|
Building2,
|
||||||
|
ShieldAlert,
|
||||||
|
LogIn,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { AuthModal } from '@/components/auth/AuthModal';
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { user, openAuthModal } = useAuth();
|
||||||
|
const isAdmin = Boolean(user && (user.role === 'admin' || user.role === 'operator'));
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard },
|
{ 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 },
|
{ 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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col md:flex-row font-sans">
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col md:flex-row font-sans">
|
||||||
{/* Sidebar */}
|
{/* 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">
|
<main className="flex-1 p-4 sm:p-8 overflow-y-auto bg-slate-950">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<AuthModal />
|
||||||
</div>
|
</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 { CartDrawer } from '@/components/store/CartDrawer';
|
||||||
import { CitySelectorModal } from '@/components/store/CitySelectorModal';
|
import { CitySelectorModal } from '@/components/store/CitySelectorModal';
|
||||||
import { MobileBottomDock } from '@/components/mobile/MobileBottomDock';
|
import { MobileBottomDock } from '@/components/mobile/MobileBottomDock';
|
||||||
import { MobileInstallBanner } from '@/components/mobile/MobileInstallBanner';
|
|
||||||
import { AuthModal } from '@/components/auth/AuthModal';
|
import { AuthModal } from '@/components/auth/AuthModal';
|
||||||
import { Product, City, CartItem } from '@/types';
|
import { Product, City, CartItem } from '@/types';
|
||||||
import { INITIAL_PRODUCTS, INITIAL_CITIES } from '@/lib/data-service';
|
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 [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||||
const [isCartOpen, setIsCartOpen] = useState(false);
|
const [isCartOpen, setIsCartOpen] = useState(false);
|
||||||
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
||||||
const [isInstallModalOpen, setIsInstallModalOpen] = useState(false);
|
|
||||||
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
||||||
|
|
||||||
const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
||||||
@ -265,16 +263,10 @@ export default function StoreHomePage() {
|
|||||||
onSelectCity={setSelectedCity}
|
onSelectCity={setSelectedCity}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Mobile App Dock & Install Banner */}
|
{/* Mobile App Dock */}
|
||||||
<MobileBottomDock
|
<MobileBottomDock
|
||||||
cartCount={cartCount}
|
cartCount={cartCount}
|
||||||
onOpenCart={() => setIsCartOpen(true)}
|
onOpenCart={() => setIsCartOpen(true)}
|
||||||
onOpenInstallModal={() => setIsInstallModalOpen(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MobileInstallBanner
|
|
||||||
isOpen={isInstallModalOpen}
|
|
||||||
onClose={() => setIsInstallModalOpen(false)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* User Auth Modal */}
|
{/* User Auth Modal */}
|
||||||
|
|||||||
@ -1,68 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Link from 'next/link';
|
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 { useBrand } from '@/context/BrandContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
|
||||||
interface MobileBottomDockProps {
|
interface MobileBottomDockProps {
|
||||||
cartCount: number;
|
cartCount: number;
|
||||||
onOpenCart: () => void;
|
onOpenCart: () => void;
|
||||||
onOpenSearch?: () => void;
|
onOpenSearch?: () => void;
|
||||||
onOpenInstallModal: () => void;
|
onOpenInstallModal?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||||
cartCount,
|
cartCount,
|
||||||
onOpenCart,
|
onOpenCart,
|
||||||
onOpenSearch,
|
onOpenSearch,
|
||||||
onOpenInstallModal,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { currentBrand } = useBrand();
|
const { currentBrand } = useBrand();
|
||||||
|
const { user, openAuthModal } = useAuth();
|
||||||
|
const pathname = usePathname();
|
||||||
const [activeTab, setActiveTab] = useState('home');
|
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 handleWhatsApp = () => {
|
||||||
const text = encodeURIComponent(
|
const text = encodeURIComponent(
|
||||||
@ -71,18 +31,25 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
|||||||
window.open(`https://wa.me/${currentBrand.whatsappNumber}?text=${text}`, '_blank');
|
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 (
|
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">
|
<div className="flex items-center justify-around">
|
||||||
{/* 1. Inicio */}
|
{/* 1. Inicio */}
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
onClick={() => setActiveTab('home')}
|
onClick={() => setActiveTab('home')}
|
||||||
className={`flex flex-col items-center gap-0.5 cursor-pointer transition-all ${
|
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>
|
<span className="text-[10px] font-bold">Inicio</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@ -109,12 +76,14 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
|||||||
setActiveTab('cart');
|
setActiveTab('cart');
|
||||||
onOpenCart();
|
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">
|
<div className="relative">
|
||||||
<ShoppingBag className="w-5 h-5 text-white" />
|
<ShoppingBag className="w-5 h-5 text-white" />
|
||||||
{cartCount > 0 && (
|
{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}
|
{cartCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -125,34 +94,38 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
|||||||
{/* 4. WhatsApp Asesoría */}
|
{/* 4. WhatsApp Asesoría */}
|
||||||
<button
|
<button
|
||||||
onClick={handleWhatsApp}
|
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" />
|
<MessageCircle className="w-5 h-5 fill-emerald-500/20" />
|
||||||
<span className="text-[10px] font-bold">WhatsApp</span>
|
<span className="text-[10px] font-bold">WhatsApp</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 5. App Móvil (solo en Web) vs Perfil/Admin (si ya está en la App) */}
|
{/* 5. Solo Administradores: Acceso Admin | Clientes / Visitantes: Mi Cuenta / Ingresar */}
|
||||||
{!isInsideApp ? (
|
{isAdmin ? (
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
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>
|
<span className="text-[10px] font-bold">Admin</span>
|
||||||
</Link>
|
</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>
|
||||||
</div>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -122,15 +122,17 @@ export const Navbar: React.FC<NavbarProps> = ({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Admin Link */}
|
{/* Admin Link - Solo para Administradores */}
|
||||||
<Link
|
{user && (user.role === 'admin' || user.role === 'operator') && (
|
||||||
href="/admin"
|
<Link
|
||||||
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"
|
href="/admin"
|
||||||
title="Panel de Administración"
|
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>
|
<Store className="w-4 h-4 text-cyan-400" />
|
||||||
</Link>
|
<span className="hidden lg:inline">Admin</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Cart Button */}
|
{/* Cart Button */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -115,12 +115,13 @@ export interface Order {
|
|||||||
export interface SupplierPost {
|
export interface SupplierPost {
|
||||||
id: string;
|
id: string;
|
||||||
channelName: string;
|
channelName: string;
|
||||||
|
channelJid?: string;
|
||||||
supplierName?: string;
|
supplierName?: string;
|
||||||
supplierPhone?: string;
|
supplierPhone?: string;
|
||||||
rawMessage: string;
|
rawMessage: string;
|
||||||
rawImages: string[];
|
rawImages: string[];
|
||||||
extractedProduct?: ExtractedProductData;
|
extractedProduct?: ExtractedProductData;
|
||||||
status: 'pending_review' | 'published_facebook' | 'published_store' | 'ignored';
|
status: 'pending_review' | 'published_facebook' | 'published_store' | 'published' | 'discarded' | 'ignored';
|
||||||
receivedAt: string;
|
receivedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,365 +1,688 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Radio, Sparkles, Share2, AlertTriangle, ShieldCheck, Plus, Send, Layers } from 'lucide-react';
|
import Link from 'next/link';
|
||||||
import { SupplierPost, Product } from '@/types';
|
import {
|
||||||
import { INITIAL_SUPPLIER_POSTS, INITIAL_PRODUCTS } from '@/lib/data-service';
|
Radio,
|
||||||
import { checkProductDuplicate } from '@/lib/deduplication-engine';
|
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 { formatCOP } from '@/lib/pricing-engine';
|
||||||
import { BRANDS, BrandId } from '@/config/brands';
|
import { BRANDS, BrandId } from '@/config/brands';
|
||||||
|
|
||||||
export default function SupplierFeedPage() {
|
export default function SupplierFeedPage() {
|
||||||
const [posts, setPosts] = useState<SupplierPost[]>(INITIAL_SUPPLIER_POSTS);
|
const [posts, setPosts] = useState<SupplierPost[]>([]);
|
||||||
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
|
const [monitoredGroups, setMonitoredGroups] = useState<MonitoredWhatsAppGroup[]>([]);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
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);
|
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
|
// Target brand selector for publishing
|
||||||
const [targetBrand, setTargetBrand] = useState<BrandId | 'all'>('almeira');
|
const [targetBrand, setTargetBrand] = useState<BrandId | 'all'>('almeira');
|
||||||
|
|
||||||
// Simulación de nuevo mensaje entrante de grupo para probar en vivo
|
// Cargar grupos monitoreados y posts iniciales
|
||||||
const [testText, setTestText] = useState(
|
const loadInitialData = async () => {
|
||||||
'🔥 LLEGANDO A BODEGA HOY 🔥\nNike Air Max 90 White Black\nTallas: 38 a 43\nMayorista: $90.000 c/u\nEnvío inmediato contraentrega'
|
setIsLoading(true);
|
||||||
);
|
|
||||||
const [testGroup, setTestGroup] = useState('Canal Mayorista A');
|
|
||||||
|
|
||||||
const handleSimulateIncoming = async () => {
|
|
||||||
setIsProcessing(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/webhook/whatsapp', {
|
// 1. Grupos
|
||||||
method: 'POST',
|
const grpRes = await fetch('/api/whatsapp-groups');
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const grpData = await grpRes.json();
|
||||||
body: JSON.stringify({
|
if (grpData.success && Array.isArray(grpData.groups)) {
|
||||||
text: testText,
|
setMonitoredGroups(grpData.groups);
|
||||||
groupInfo: { subject: testGroup },
|
}
|
||||||
}),
|
|
||||||
});
|
// 2. Posts almacenados
|
||||||
const data = await res.json();
|
const feedRes = await fetch('/api/whatsapp-feed');
|
||||||
if (data.success && data.post) {
|
const feedData = await feedRes.json();
|
||||||
setPosts((prev) => [data.post, ...prev]);
|
if (feedData.success && Array.isArray(feedData.posts)) {
|
||||||
setActionMessage('✅ Nuevo mensaje de WhatsApp procesado con IA.');
|
setPosts(feedData.posts);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error('Error cargando feed:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePublishFacebook = async (post: SupplierPost) => {
|
useEffect(() => {
|
||||||
if (!post.extractedProduct) return;
|
loadInitialData();
|
||||||
setIsProcessing(true);
|
}, []);
|
||||||
|
|
||||||
|
// Sincronizar mensajes en vivo desde Evolution API
|
||||||
|
const handleSyncLive = async () => {
|
||||||
|
setIsSyncingLive(true);
|
||||||
|
setActionMessage(null);
|
||||||
try {
|
try {
|
||||||
const brandName = targetBrand === 'all' ? 'Todas las Tiendas' : BRANDS[targetBrand].name;
|
const url =
|
||||||
const res = await fetch('/api/publish/facebook', {
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
productName: post.extractedProduct.name,
|
action: 'publish',
|
||||||
brand: post.extractedProduct.brand,
|
post,
|
||||||
retailPrice: post.extractedProduct.suggestedRetailPrice,
|
customName: customData?.name,
|
||||||
sizes: post.extractedProduct.sizes,
|
customRetailPrice: customData?.retailPrice,
|
||||||
images: post.rawImages,
|
customSizes: customData?.sizes,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
setActionMessage('❌ Error al publicar producto.');
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddToStore = async (post: SupplierPost) => {
|
// Publicación masiva de los seleccionados
|
||||||
if (!post.extractedProduct) return;
|
const handleBulkPublish = async () => {
|
||||||
const brandLabel = targetBrand === 'all' ? 'Multi-Marca' : BRANDS[targetBrand].shortName;
|
const selectedPosts = posts.filter(
|
||||||
const newProduct: Product = {
|
(p) => selectedPostIds.has(p.id) && p.status !== 'published'
|
||||||
id: `snk-${Date.now()}`,
|
);
|
||||||
name: post.extractedProduct.name,
|
if (selectedPosts.length === 0) return;
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/products', {
|
const res = await fetch('/api/whatsapp-feed', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(newProduct),
|
body: JSON.stringify({
|
||||||
|
action: 'bulk-publish',
|
||||||
|
posts: selectedPosts,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setProducts((prev) => [data.product, ...prev]);
|
setPosts((prev) =>
|
||||||
setActionMessage(`✨ "${newProduct.name}" habilitado en el catálogo para ${brandLabel}.`);
|
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) {
|
} catch (e) {
|
||||||
console.error(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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* 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>
|
<div>
|
||||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Radio className="w-6 h-6 text-cyan-400 animate-pulse" />
|
<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)]">
|
||||||
<span>Feed de Entrada & Publicador Multi-Marca</span>
|
<Radio className="w-5 h-5 animate-pulse" />
|
||||||
</h1>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
<div>
|
||||||
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>).
|
<h1 className="text-2xl font-black text-white">Feed de Mensajes de WhatsApp</h1>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Target Brand Selector Pill Bar */}
|
{/* Action Controls */}
|
||||||
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl">
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
<span className="text-[10px] font-bold text-slate-400 pl-2 pr-1 flex items-center gap-1">
|
<Link
|
||||||
<Layers className="w-3 h-3" /> Destino:
|
href="/admin/grupos-whatsapp"
|
||||||
</span>
|
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"
|
||||||
<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'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Toast feedback */}
|
||||||
{actionMessage && (
|
{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">
|
<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">
|
||||||
<span>{actionMessage}</span>
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={() => setActionMessage(null)} className="font-bold text-white cursor-pointer">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Live Simulator for Test Messages */}
|
{/* Filter & Tabs Bar */}
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4">
|
<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">
|
||||||
<div className="flex items-center space-x-2 text-cyan-400 font-bold text-xs uppercase tracking-wider">
|
{/* Status Tabs */}
|
||||||
<Sparkles className="w-4 h-4 text-cyan-400" />
|
<div className="flex items-center gap-1 bg-neutral-950 p-1 rounded-2xl border border-neutral-800 text-xs font-bold">
|
||||||
<span>Simulador de Recepción en Tiempo Real</span>
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-3">
|
{/* Group Selector Filter */}
|
||||||
<div className="md:col-span-4">
|
<div className="flex items-center gap-2">
|
||||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Canal de Entrada:</label>
|
<Filter className="w-4 h-4 text-purple-400 shrink-0" />
|
||||||
<select
|
<select
|
||||||
value={testGroup}
|
value={selectedGroupFilter}
|
||||||
onChange={(e) => setTestGroup(e.target.value)}
|
onChange={(e) => setSelectedGroupFilter(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"
|
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="Canal Mayorista A">Canal Mayorista A</option>
|
<option value="all">Todos los Grupos de WhatsApp</option>
|
||||||
<option value="Canal Mayorista B">Canal Mayorista B</option>
|
{monitoredGroups.map((g) => (
|
||||||
</select>
|
<option key={g.id || g.jid} value={g.jid}>
|
||||||
</div>
|
{g.name}
|
||||||
|
</option>
|
||||||
<div className="md:col-span-8">
|
))}
|
||||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Texto del Mensaje de WhatsApp:</label>
|
</select>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Intercepted Posts Feed */}
|
{/* Multi-Selection Bulk Actions Bar */}
|
||||||
<div className="space-y-4">
|
{selectedPostIds.size > 0 && tabStatus === 'pending' && (
|
||||||
<h2 className="font-bold text-white text-base">Mensajes Procesados Recientemente</h2>
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
{posts.map((post) => {
|
<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 extracted = post.extractedProduct;
|
||||||
const duplicateCheck = extracted
|
const isSelected = selectedPostIds.has(post.id);
|
||||||
? checkProductDuplicate(
|
const isPublished = post.status === 'published';
|
||||||
{
|
const isDiscarded = post.status === 'discarded';
|
||||||
name: extracted.name,
|
|
||||||
brand: extracted.brand,
|
|
||||||
sizes: extracted.sizes,
|
|
||||||
supplierPrice: extracted.supplierPrice,
|
|
||||||
},
|
|
||||||
products
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={post.id}
|
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 */}
|
{/* Header info */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800 pb-3">
|
<div className="flex items-center justify-between gap-2 border-b border-neutral-800 pb-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="bg-slate-800 text-slate-200 text-xs font-black px-2.5 py-1 rounded-full border border-slate-700">
|
{tabStatus === 'pending' && (
|
||||||
{post.channelName || 'Canal Mayorista'}
|
<button
|
||||||
</span>
|
onClick={() => handleToggleSelect(post.id)}
|
||||||
<span className="text-xs text-slate-400">{post.receivedAt}</span>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Anti-Duplicate Status Badge */}
|
<div>
|
||||||
{duplicateCheck && (
|
{isPublished ? (
|
||||||
<div
|
<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">
|
||||||
className={`text-xs font-bold px-3 py-1 rounded-full flex items-center gap-1.5 ${
|
<CheckCircle2 className="w-3 h-3" /> Publicado en Tienda
|
||||||
duplicateCheck.isDuplicate
|
</span>
|
||||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
|
) : isDiscarded ? (
|
||||||
: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
|
<span className="bg-neutral-800 text-neutral-400 text-[10px] font-bold px-2.5 py-0.5 rounded-full">
|
||||||
}`}
|
Descartado
|
||||||
>
|
</span>
|
||||||
{duplicateCheck.isDuplicate ? (
|
) : (
|
||||||
<>
|
<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">
|
||||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
|
<Sparkles className="w-3 h-3" /> Pendiente de Aprobación
|
||||||
<span>Duplicado Detectado ({Math.round(duplicateCheck.similarityScore * 100)}% Similitud)</span>
|
</span>
|
||||||
</>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<>
|
|
||||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
|
||||||
<span>Producto Nuevo (0% Duplicados)</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Grid */}
|
{/* Content Grid (Photo + AI extracted info) */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
|
<div className="grid grid-cols-1 sm:grid-cols-12 gap-4">
|
||||||
{/* Left: Raw WhatsApp Message (5 cols) */}
|
{/* Sneaker Image & Raw text */}
|
||||||
<div className="md:col-span-5 bg-slate-950 rounded-2xl p-4 space-y-2 border border-slate-800">
|
<div className="sm:col-span-5 space-y-2">
|
||||||
<span className="text-[10px] uppercase font-bold text-slate-400">Mensaje de Entrada:</span>
|
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-neutral-950 border border-neutral-800 flex items-center justify-center group">
|
||||||
<p className="text-xs text-slate-300 font-mono whitespace-pre-line leading-relaxed">
|
{post.rawImages && post.rawImages[0] ? (
|
||||||
{post.rawMessage}
|
|
||||||
</p>
|
|
||||||
{post.rawImages.length > 0 && (
|
|
||||||
<div className="pt-2">
|
|
||||||
<img
|
<img
|
||||||
src={post.rawImages[0]}
|
src={post.rawImages[0]}
|
||||||
alt=""
|
alt={extracted?.name || 'Zapatilla'}
|
||||||
className="w-24 h-24 rounded-xl object-cover bg-slate-900 border border-slate-700"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Right: AI Extracted & Pricing Calculation (7 cols) */}
|
{/* AI Extraction & Product Info */}
|
||||||
<div className="md:col-span-7 space-y-3">
|
<div className="sm:col-span-7 space-y-3 flex flex-col justify-between">
|
||||||
{extracted ? (
|
<div className="space-y-2">
|
||||||
<div className="bg-slate-950 rounded-2xl p-4 border border-slate-800 space-y-3">
|
<div>
|
||||||
<div className="flex items-start justify-between">
|
<span className="text-[10px] font-black uppercase text-purple-400 tracking-wider">
|
||||||
<div>
|
{extracted?.brand || 'Nike'} • {extracted?.category || 'Sneakers'}
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase">
|
</span>
|
||||||
{extracted.brand} • {extracted.category}
|
<h3 className="text-base font-black text-white leading-tight">
|
||||||
</span>
|
{extracted?.name || 'Zapatilla Urbana'}
|
||||||
<h3 className="text-base font-black text-white">{extracted.name}</h3>
|
</h3>
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
{/* Pricing breakdown */}
|
{/* Pricing breakdown */}
|
||||||
<div className="grid grid-cols-2 gap-3 bg-slate-900 p-3 rounded-xl border border-slate-800">
|
<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 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 */}
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[10px] text-slate-400 font-semibold uppercase block mb-1">
|
<span className="text-[10px] text-neutral-400 block font-bold">Costo Proveedor:</span>
|
||||||
Tallas extraídas:
|
<span className="text-xs font-bold text-neutral-300 font-mono">
|
||||||
|
{formatCOP(extracted?.supplierPrice || 90000)}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
<div>
|
||||||
{/* Duplicate warning note */}
|
<span className="text-[10px] text-emerald-400 block font-bold">Venta Sugerida:</span>
|
||||||
{duplicateCheck?.isDuplicate && (
|
<span className="text-sm font-black text-emerald-300 font-mono">
|
||||||
<div className="bg-amber-950/40 border border-amber-800/60 p-2.5 rounded-xl text-xs text-amber-200">
|
{formatCOP(extracted?.suggestedRetailPrice || 125000)}
|
||||||
{duplicateCheck.message}
|
</span>
|
||||||
</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>
|
</div>
|
||||||
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,15 +12,20 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Sparkles,
|
|
||||||
Store,
|
Store,
|
||||||
Users,
|
Users,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Building2,
|
Building2,
|
||||||
|
ShieldAlert,
|
||||||
|
LogIn,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { AuthModal } from '@/components/auth/AuthModal';
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { user, openAuthModal } = useAuth();
|
||||||
|
const isAdmin = Boolean(user && (user.role === 'admin' || user.role === 'operator'));
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard },
|
{ 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 },
|
{ 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 (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-950 text-neutral-100 flex flex-col md:flex-row font-sans">
|
<div className="min-h-screen bg-neutral-950 text-neutral-100 flex flex-col md:flex-row font-sans">
|
||||||
{/* Sidebar */}
|
{/* 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">
|
<main className="flex-1 p-4 sm:p-8 overflow-y-auto bg-neutral-950">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<AuthModal />
|
||||||
</div>
|
</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;
|
quantity: number;
|
||||||
price: number;
|
price: number;
|
||||||
image: string;
|
image: string;
|
||||||
}>>([
|
}>>(
|
||||||
{
|
INITIAL_PRODUCTS.length > 0
|
||||||
productId: INITIAL_PRODUCTS[0].id,
|
? [
|
||||||
productName: INITIAL_PRODUCTS[0].name,
|
{
|
||||||
brand: INITIAL_PRODUCTS[0].brand,
|
productId: INITIAL_PRODUCTS[0].id,
|
||||||
size: '39',
|
productName: INITIAL_PRODUCTS[0].name,
|
||||||
quantity: 1,
|
brand: INITIAL_PRODUCTS[0].brand,
|
||||||
price: INITIAL_PRODUCTS[0].retailPrice,
|
size: '39',
|
||||||
image: INITIAL_PRODUCTS[0].images[0],
|
quantity: 1,
|
||||||
},
|
price: INITIAL_PRODUCTS[0].retailPrice,
|
||||||
]);
|
image: INITIAL_PRODUCTS[0].images[0],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [orderCompleted, setOrderCompleted] = useState<Order | null>(null);
|
const [orderCompleted, setOrderCompleted] = useState<Order | null>(null);
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import { ProductModal } from '@/components/store/ProductModal';
|
|||||||
import { CartDrawer } from '@/components/store/CartDrawer';
|
import { CartDrawer } from '@/components/store/CartDrawer';
|
||||||
import { CitySelectorModal } from '@/components/store/CitySelectorModal';
|
import { CitySelectorModal } from '@/components/store/CitySelectorModal';
|
||||||
import { MobileBottomDock } from '@/components/mobile/MobileBottomDock';
|
import { MobileBottomDock } from '@/components/mobile/MobileBottomDock';
|
||||||
import { MobileInstallBanner } from '@/components/mobile/MobileInstallBanner';
|
|
||||||
import { AuthModal } from '@/components/auth/AuthModal';
|
import { AuthModal } from '@/components/auth/AuthModal';
|
||||||
import { Product, City, CartItem } from '@/types';
|
import { Product, City, CartItem } from '@/types';
|
||||||
import { INITIAL_PRODUCTS, INITIAL_CITIES } from '@/lib/data-service';
|
import { INITIAL_PRODUCTS, INITIAL_CITIES } from '@/lib/data-service';
|
||||||
@ -18,7 +17,7 @@ import { useBrand } from '@/context/BrandContext';
|
|||||||
|
|
||||||
export default function StoreHomePage() {
|
export default function StoreHomePage() {
|
||||||
const { currentBrand } = useBrand();
|
const { currentBrand } = useBrand();
|
||||||
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [cities, setCities] = useState<City[]>(INITIAL_CITIES);
|
const [cities, setCities] = useState<City[]>(INITIAL_CITIES);
|
||||||
const [selectedCity, setSelectedCity] = useState<City>(INITIAL_CITIES[0]); // Cali
|
const [selectedCity, setSelectedCity] = useState<City>(INITIAL_CITIES[0]); // Cali
|
||||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||||
@ -28,7 +27,6 @@ export default function StoreHomePage() {
|
|||||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||||
const [isCartOpen, setIsCartOpen] = useState(false);
|
const [isCartOpen, setIsCartOpen] = useState(false);
|
||||||
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
||||||
const [isInstallModalOpen, setIsInstallModalOpen] = useState(false);
|
|
||||||
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
||||||
|
|
||||||
const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
||||||
@ -37,11 +35,11 @@ export default function StoreHomePage() {
|
|||||||
fetch('/api/products')
|
fetch('/api/products')
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.success && data.products?.length > 0) {
|
if (data.success && Array.isArray(data.products)) {
|
||||||
setProducts(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')
|
fetch('/api/cities')
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
@ -188,19 +186,41 @@ export default function StoreHomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Product Grid */}
|
{/* Product Grid */}
|
||||||
{filteredProducts.length === 0 ? (
|
{products.length === 0 ? (
|
||||||
<div className="bg-slate-900/80 rounded-3xl p-12 text-center border border-slate-800 space-y-3">
|
<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-full bg-white/10 text-white flex items-center justify-center mx-auto">
|
<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">
|
||||||
<RefreshCw className="w-8 h-8 animate-spin" />
|
<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>
|
</div>
|
||||||
<h4 className="font-bold text-white text-base">No encontramos zapatillas con ese criterio</h4>
|
<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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveCategory('all');
|
setActiveCategory('all');
|
||||||
setSearchQuery('');
|
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
|
Ver Todo el Catálogo
|
||||||
</button>
|
</button>
|
||||||
@ -265,16 +285,10 @@ export default function StoreHomePage() {
|
|||||||
onSelectCity={setSelectedCity}
|
onSelectCity={setSelectedCity}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Mobile App Dock & Install Banner */}
|
{/* Mobile App Dock */}
|
||||||
<MobileBottomDock
|
<MobileBottomDock
|
||||||
cartCount={cartCount}
|
cartCount={cartCount}
|
||||||
onOpenCart={() => setIsCartOpen(true)}
|
onOpenCart={() => setIsCartOpen(true)}
|
||||||
onOpenInstallModal={() => setIsInstallModalOpen(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MobileInstallBanner
|
|
||||||
isOpen={isInstallModalOpen}
|
|
||||||
onClose={() => setIsInstallModalOpen(false)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* User Auth Modal */}
|
{/* User Auth Modal */}
|
||||||
|
|||||||
@ -1,68 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Link from 'next/link';
|
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 { useBrand } from '@/context/BrandContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
|
||||||
interface MobileBottomDockProps {
|
interface MobileBottomDockProps {
|
||||||
cartCount: number;
|
cartCount: number;
|
||||||
onOpenCart: () => void;
|
onOpenCart: () => void;
|
||||||
onOpenSearch?: () => void;
|
onOpenSearch?: () => void;
|
||||||
onOpenInstallModal: () => void;
|
onOpenInstallModal?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||||
cartCount,
|
cartCount,
|
||||||
onOpenCart,
|
onOpenCart,
|
||||||
onOpenSearch,
|
onOpenSearch,
|
||||||
onOpenInstallModal,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { currentBrand } = useBrand();
|
const { currentBrand } = useBrand();
|
||||||
|
const { user, openAuthModal } = useAuth();
|
||||||
|
const pathname = usePathname();
|
||||||
const [activeTab, setActiveTab] = useState('home');
|
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 handleWhatsApp = () => {
|
||||||
const text = encodeURIComponent(
|
const text = encodeURIComponent(
|
||||||
@ -71,20 +31,29 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
|||||||
window.open(`https://wa.me/${currentBrand.whatsappNumber}?text=${text}`, '_blank');
|
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 (
|
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">
|
<div className="flex items-center justify-around">
|
||||||
|
{/* 1. Inicio */}
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
onClick={() => setActiveTab('home')}
|
onClick={() => setActiveTab('home')}
|
||||||
className={`flex flex-col items-center gap-0.5 cursor-pointer transition-all ${
|
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>
|
<span className="text-[10px] font-bold">Inicio</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* 2. Buscar */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveTab('search');
|
setActiveTab('search');
|
||||||
@ -101,17 +70,20 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
|||||||
<span className="text-[10px] font-bold">Buscar</span>
|
<span className="text-[10px] font-bold">Buscar</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 3. Carrito */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveTab('cart');
|
setActiveTab('cart');
|
||||||
onOpenCart();
|
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">
|
<div className="relative">
|
||||||
<ShoppingBag className="w-5 h-5 text-white" />
|
<ShoppingBag className="w-5 h-5 text-white" />
|
||||||
{cartCount > 0 && (
|
{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}
|
{cartCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -119,34 +91,40 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
|||||||
<span className="text-[10px] font-bold text-white">Carrito</span>
|
<span className="text-[10px] font-bold text-white">Carrito</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 4. WhatsApp */}
|
||||||
<button
|
<button
|
||||||
onClick={handleWhatsApp}
|
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" />
|
<MessageCircle className="w-5 h-5 fill-emerald-500/20" />
|
||||||
<span className="text-[10px] font-bold">WhatsApp</span>
|
<span className="text-[10px] font-bold">WhatsApp</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!isInsideApp ? (
|
{/* 5. Solo Administradores: Acceso Admin | Clientes / Visitantes: Mi Cuenta / Ingresar */}
|
||||||
<button
|
{isAdmin ? (
|
||||||
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>
|
|
||||||
) : (
|
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
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>
|
<span className="text-[10px] font-bold">Admin</span>
|
||||||
</Link>
|
</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>
|
||||||
</div>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -126,15 +126,17 @@ export const Navbar: React.FC<NavbarProps> = ({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Admin Link */}
|
{/* Admin Link - Solo para Administradores */}
|
||||||
<Link
|
{user && (user.role === 'admin' || user.role === 'operator') && (
|
||||||
href="/admin"
|
<Link
|
||||||
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"
|
href="/admin"
|
||||||
title="Panel de Administración"
|
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>
|
<Store className="w-4 h-4 text-purple-400" />
|
||||||
</Link>
|
<span className="hidden lg:inline">Admin</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Cart Button */}
|
{/* Cart Button */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -115,12 +115,13 @@ export interface Order {
|
|||||||
export interface SupplierPost {
|
export interface SupplierPost {
|
||||||
id: string;
|
id: string;
|
||||||
channelName: string;
|
channelName: string;
|
||||||
|
channelJid?: string;
|
||||||
supplierName?: string;
|
supplierName?: string;
|
||||||
supplierPhone?: string;
|
supplierPhone?: string;
|
||||||
rawMessage: string;
|
rawMessage: string;
|
||||||
rawImages: string[];
|
rawImages: string[];
|
||||||
extractedProduct?: ExtractedProductData;
|
extractedProduct?: ExtractedProductData;
|
||||||
status: 'pending_review' | 'published_facebook' | 'published_store' | 'ignored';
|
status: 'pending_review' | 'published_facebook' | 'published_store' | 'published' | 'discarded' | 'ignored';
|
||||||
receivedAt: string;
|
receivedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,7 @@ async function sendApkToCarlos() {
|
|||||||
const apkBuffer = fs.readFileSync(apkPath);
|
const apkBuffer = fs.readFileSync(apkPath);
|
||||||
const base64Data = apkBuffer.toString('base64');
|
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})...`);
|
console.log(`📤 Enviando APK vía Evolution API a WhatsApp (+${TARGET_PHONE})...`);
|
||||||
|
|
||||||
|
|||||||
@ -1,365 +1,680 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Radio, Sparkles, Share2, AlertTriangle, ShieldCheck, Plus, Send, Layers } from 'lucide-react';
|
import Link from 'next/link';
|
||||||
import { SupplierPost, Product } from '@/types';
|
import {
|
||||||
import { INITIAL_SUPPLIER_POSTS, INITIAL_PRODUCTS } from '@/lib/data-service';
|
Radio,
|
||||||
import { checkProductDuplicate } from '@/lib/deduplication-engine';
|
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 { formatCOP } from '@/lib/pricing-engine';
|
||||||
import { BRANDS, BrandId } from '@/config/brands';
|
import { BRANDS, BrandId } from '@/config/brands';
|
||||||
|
|
||||||
export default function SupplierFeedPage() {
|
export default function SupplierFeedPage() {
|
||||||
const [posts, setPosts] = useState<SupplierPost[]>(INITIAL_SUPPLIER_POSTS);
|
const [posts, setPosts] = useState<SupplierPost[]>([]);
|
||||||
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
|
const [monitoredGroups, setMonitoredGroups] = useState<MonitoredWhatsAppGroup[]>([]);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
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);
|
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
|
// 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
|
// Cargar grupos monitoreados y posts iniciales
|
||||||
const [testText, setTestText] = useState(
|
const loadInitialData = async () => {
|
||||||
'🔥 LLEGANDO A BODEGA HOY 🔥\nNike Air Max 90 White Black\nTallas: 38 a 43\nMayorista: $90.000 c/u\nEnvío inmediato contraentrega'
|
setIsLoading(true);
|
||||||
);
|
|
||||||
const [testGroup, setTestGroup] = useState('Canal Mayorista A');
|
|
||||||
|
|
||||||
const handleSimulateIncoming = async () => {
|
|
||||||
setIsProcessing(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/webhook/whatsapp', {
|
const grpRes = await fetch('/api/whatsapp-groups');
|
||||||
method: 'POST',
|
const grpData = await grpRes.json();
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (grpData.success && Array.isArray(grpData.groups)) {
|
||||||
body: JSON.stringify({
|
setMonitoredGroups(grpData.groups);
|
||||||
text: testText,
|
}
|
||||||
groupInfo: { subject: testGroup },
|
|
||||||
}),
|
const feedRes = await fetch('/api/whatsapp-feed');
|
||||||
});
|
const feedData = await feedRes.json();
|
||||||
const data = await res.json();
|
if (feedData.success && Array.isArray(feedData.posts)) {
|
||||||
if (data.success && data.post) {
|
setPosts(feedData.posts);
|
||||||
setPosts((prev) => [data.post, ...prev]);
|
|
||||||
setActionMessage('✅ Nuevo mensaje de WhatsApp procesado con IA.');
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error('Error cargando feed:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePublishFacebook = async (post: SupplierPost) => {
|
useEffect(() => {
|
||||||
if (!post.extractedProduct) return;
|
loadInitialData();
|
||||||
setIsProcessing(true);
|
}, []);
|
||||||
|
|
||||||
|
// Sincronizar mensajes en vivo desde Evolution API
|
||||||
|
const handleSyncLive = async () => {
|
||||||
|
setIsSyncingLive(true);
|
||||||
|
setActionMessage(null);
|
||||||
try {
|
try {
|
||||||
const brandName = targetBrand === 'all' ? 'Todas las Tiendas' : BRANDS[targetBrand].name;
|
const url =
|
||||||
const res = await fetch('/api/publish/facebook', {
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
productName: post.extractedProduct.name,
|
action: 'publish',
|
||||||
brand: post.extractedProduct.brand,
|
post,
|
||||||
retailPrice: post.extractedProduct.suggestedRetailPrice,
|
customName: customData?.name,
|
||||||
sizes: post.extractedProduct.sizes,
|
customRetailPrice: customData?.retailPrice,
|
||||||
images: post.rawImages,
|
customSizes: customData?.sizes,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
setActionMessage('❌ Error al publicar producto.');
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddToStore = async (post: SupplierPost) => {
|
// Publicación masiva de los seleccionados
|
||||||
if (!post.extractedProduct) return;
|
const handleBulkPublish = async () => {
|
||||||
const brandLabel = targetBrand === 'all' ? 'Multi-Marca' : BRANDS[targetBrand].shortName;
|
const selectedPosts = posts.filter(
|
||||||
const newProduct: Product = {
|
(p) => selectedPostIds.has(p.id) && p.status !== 'published'
|
||||||
id: `snk-${Date.now()}`,
|
);
|
||||||
name: post.extractedProduct.name,
|
if (selectedPosts.length === 0) return;
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/products', {
|
const res = await fetch('/api/whatsapp-feed', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(newProduct),
|
body: JSON.stringify({
|
||||||
|
action: 'bulk-publish',
|
||||||
|
posts: selectedPosts,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setProducts((prev) => [data.product, ...prev]);
|
setPosts((prev) =>
|
||||||
setActionMessage(`✨ "${newProduct.name}" habilitado en el catálogo para ${brandLabel}.`);
|
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) {
|
} catch (e) {
|
||||||
console.error(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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* 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>
|
<div>
|
||||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Radio className="w-6 h-6 text-cyan-400 animate-pulse" />
|
<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)]">
|
||||||
<span>Feed de Entrada & Publicador Multi-Marca</span>
|
<Radio className="w-5 h-5 animate-pulse" />
|
||||||
</h1>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
<div>
|
||||||
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>).
|
<h1 className="text-2xl font-black text-white">Feed de Mensajes de WhatsApp • Sneaky</h1>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Target Brand Selector Pill Bar */}
|
{/* Action Controls */}
|
||||||
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl">
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
<span className="text-[10px] font-bold text-slate-400 pl-2 pr-1 flex items-center gap-1">
|
<Link
|
||||||
<Layers className="w-3 h-3" /> Destino:
|
href="/admin/grupos-whatsapp"
|
||||||
</span>
|
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"
|
||||||
<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'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Toast feedback */}
|
||||||
{actionMessage && (
|
{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">
|
<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">
|
||||||
<span>{actionMessage}</span>
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={() => setActionMessage(null)} className="font-bold text-white cursor-pointer">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Live Simulator for Test Messages */}
|
{/* Filter & Tabs Bar */}
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4">
|
<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">
|
||||||
<div className="flex items-center space-x-2 text-cyan-400 font-bold text-xs uppercase tracking-wider">
|
{/* Status Tabs */}
|
||||||
<Sparkles className="w-4 h-4 text-cyan-400" />
|
<div className="flex items-center gap-1 bg-zinc-950 p-1 rounded-2xl border border-zinc-800 text-xs font-bold">
|
||||||
<span>Simulador de Recepción en Tiempo Real</span>
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-3">
|
{/* Group Selector Filter */}
|
||||||
<div className="md:col-span-4">
|
<div className="flex items-center gap-2">
|
||||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Canal de Entrada:</label>
|
<Filter className="w-4 h-4 text-orange-400 shrink-0" />
|
||||||
<select
|
<select
|
||||||
value={testGroup}
|
value={selectedGroupFilter}
|
||||||
onChange={(e) => setTestGroup(e.target.value)}
|
onChange={(e) => setSelectedGroupFilter(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"
|
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="Canal Mayorista A">Canal Mayorista A</option>
|
<option value="all">Todos los Grupos de WhatsApp</option>
|
||||||
<option value="Canal Mayorista B">Canal Mayorista B</option>
|
{monitoredGroups.map((g) => (
|
||||||
</select>
|
<option key={g.id || g.jid} value={g.jid}>
|
||||||
</div>
|
{g.name}
|
||||||
|
</option>
|
||||||
<div className="md:col-span-8">
|
))}
|
||||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Texto del Mensaje de WhatsApp:</label>
|
</select>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Intercepted Posts Feed */}
|
{/* Multi-Selection Bulk Actions Bar */}
|
||||||
<div className="space-y-4">
|
{selectedPostIds.size > 0 && tabStatus === 'pending' && (
|
||||||
<h2 className="font-bold text-white text-base">Mensajes Procesados Recientemente</h2>
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
{posts.map((post) => {
|
<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 extracted = post.extractedProduct;
|
||||||
const duplicateCheck = extracted
|
const isSelected = selectedPostIds.has(post.id);
|
||||||
? checkProductDuplicate(
|
const isPublished = post.status === 'published';
|
||||||
{
|
const isDiscarded = post.status === 'discarded';
|
||||||
name: extracted.name,
|
|
||||||
brand: extracted.brand,
|
|
||||||
sizes: extracted.sizes,
|
|
||||||
supplierPrice: extracted.supplierPrice,
|
|
||||||
},
|
|
||||||
products
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={post.id}
|
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 */}
|
{/* Header info */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800 pb-3">
|
<div className="flex items-center justify-between gap-2 border-b border-zinc-800 pb-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="bg-slate-800 text-slate-200 text-xs font-black px-2.5 py-1 rounded-full border border-slate-700">
|
{tabStatus === 'pending' && (
|
||||||
{post.channelName || 'Canal Mayorista'}
|
<button
|
||||||
</span>
|
onClick={() => handleToggleSelect(post.id)}
|
||||||
<span className="text-xs text-slate-400">{post.receivedAt}</span>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Anti-Duplicate Status Badge */}
|
<div>
|
||||||
{duplicateCheck && (
|
{isPublished ? (
|
||||||
<div
|
<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">
|
||||||
className={`text-xs font-bold px-3 py-1 rounded-full flex items-center gap-1.5 ${
|
<CheckCircle2 className="w-3 h-3" /> Publicado en Tienda
|
||||||
duplicateCheck.isDuplicate
|
</span>
|
||||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
|
) : isDiscarded ? (
|
||||||
: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
|
<span className="bg-zinc-800 text-zinc-400 text-[10px] font-bold px-2.5 py-0.5 rounded-full">
|
||||||
}`}
|
Descartado
|
||||||
>
|
</span>
|
||||||
{duplicateCheck.isDuplicate ? (
|
) : (
|
||||||
<>
|
<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">
|
||||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
|
<Sparkles className="w-3 h-3" /> Pendiente de Aprobación
|
||||||
<span>Duplicado Detectado ({Math.round(duplicateCheck.similarityScore * 100)}% Similitud)</span>
|
</span>
|
||||||
</>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<>
|
|
||||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
|
||||||
<span>Producto Nuevo (0% Duplicados)</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Grid */}
|
{/* Content Grid (Photo + AI extracted info) */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
|
<div className="grid grid-cols-1 sm:grid-cols-12 gap-4">
|
||||||
{/* Left: Raw WhatsApp Message (5 cols) */}
|
{/* Sneaker Image & Raw text */}
|
||||||
<div className="md:col-span-5 bg-slate-950 rounded-2xl p-4 space-y-2 border border-slate-800">
|
<div className="sm:col-span-5 space-y-2">
|
||||||
<span className="text-[10px] uppercase font-bold text-slate-400">Mensaje de Entrada:</span>
|
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-zinc-950 border border-zinc-800 flex items-center justify-center group">
|
||||||
<p className="text-xs text-slate-300 font-mono whitespace-pre-line leading-relaxed">
|
{post.rawImages && post.rawImages[0] ? (
|
||||||
{post.rawMessage}
|
|
||||||
</p>
|
|
||||||
{post.rawImages.length > 0 && (
|
|
||||||
<div className="pt-2">
|
|
||||||
<img
|
<img
|
||||||
src={post.rawImages[0]}
|
src={post.rawImages[0]}
|
||||||
alt=""
|
alt={extracted?.name || 'Zapatilla'}
|
||||||
className="w-24 h-24 rounded-xl object-cover bg-slate-900 border border-slate-700"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Right: AI Extracted & Pricing Calculation (7 cols) */}
|
{/* AI Extraction & Product Info */}
|
||||||
<div className="md:col-span-7 space-y-3">
|
<div className="sm:col-span-7 space-y-3 flex flex-col justify-between">
|
||||||
{extracted ? (
|
<div className="space-y-2">
|
||||||
<div className="bg-slate-950 rounded-2xl p-4 border border-slate-800 space-y-3">
|
<div>
|
||||||
<div className="flex items-start justify-between">
|
<span className="text-[10px] font-black uppercase text-orange-400 tracking-wider">
|
||||||
<div>
|
{extracted?.brand || 'Nike'} • {extracted?.category || 'Sneakers'}
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase">
|
</span>
|
||||||
{extracted.brand} • {extracted.category}
|
<h3 className="text-base font-black text-white leading-tight">
|
||||||
</span>
|
{extracted?.name || 'Zapatilla Urbana'}
|
||||||
<h3 className="text-base font-black text-white">{extracted.name}</h3>
|
</h3>
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
{/* Pricing breakdown */}
|
{/* Pricing breakdown */}
|
||||||
<div className="grid grid-cols-2 gap-3 bg-slate-900 p-3 rounded-xl border border-slate-800">
|
<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 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 */}
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[10px] text-slate-400 font-semibold uppercase block mb-1">
|
<span className="text-[10px] text-zinc-400 block font-bold">Costo Proveedor:</span>
|
||||||
Tallas extraídas:
|
<span className="text-xs font-bold text-zinc-300 font-mono">
|
||||||
|
{formatCOP(extracted?.supplierPrice || 90000)}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
<div>
|
||||||
{/* Duplicate warning note */}
|
<span className="text-[10px] text-emerald-400 block font-bold">Venta Sugerida:</span>
|
||||||
{duplicateCheck?.isDuplicate && (
|
<span className="text-sm font-black text-emerald-300 font-mono">
|
||||||
<div className="bg-amber-950/40 border border-amber-800/60 p-2.5 rounded-xl text-xs text-amber-200">
|
{formatCOP(extracted?.suggestedRetailPrice || 125000)}
|
||||||
{duplicateCheck.message}
|
</span>
|
||||||
</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>
|
</div>
|
||||||
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,15 +11,20 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Sparkles,
|
|
||||||
Store,
|
Store,
|
||||||
Users,
|
Users,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Building2,
|
Building2,
|
||||||
|
ShieldAlert,
|
||||||
|
LogIn,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { AuthModal } from '@/components/auth/AuthModal';
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { user, openAuthModal } = useAuth();
|
||||||
|
const isAdmin = Boolean(user && (user.role === 'admin' || user.role === 'operator'));
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard },
|
{ 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 },
|
{ 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 (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col md:flex-row font-sans">
|
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col md:flex-row font-sans">
|
||||||
{/* Sidebar */}
|
{/* 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">
|
<main className="flex-1 p-4 sm:p-8 overflow-y-auto bg-zinc-950">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<AuthModal />
|
||||||
</div>
|
</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 { CartDrawer } from '@/components/store/CartDrawer';
|
||||||
import { CitySelectorModal } from '@/components/store/CitySelectorModal';
|
import { CitySelectorModal } from '@/components/store/CitySelectorModal';
|
||||||
import { MobileBottomDock } from '@/components/mobile/MobileBottomDock';
|
import { MobileBottomDock } from '@/components/mobile/MobileBottomDock';
|
||||||
import { MobileInstallBanner } from '@/components/mobile/MobileInstallBanner';
|
|
||||||
import { AuthModal } from '@/components/auth/AuthModal';
|
import { AuthModal } from '@/components/auth/AuthModal';
|
||||||
import { Product, City, CartItem } from '@/types';
|
import { Product, City, CartItem } from '@/types';
|
||||||
import { INITIAL_PRODUCTS, INITIAL_CITIES } from '@/lib/data-service';
|
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 [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||||
const [isCartOpen, setIsCartOpen] = useState(false);
|
const [isCartOpen, setIsCartOpen] = useState(false);
|
||||||
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
||||||
const [isInstallModalOpen, setIsInstallModalOpen] = useState(false);
|
|
||||||
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
||||||
|
|
||||||
const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
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));
|
.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) => {
|
const handleAddToCart = (product: Product, size: string) => {
|
||||||
setCartItems((prev) => {
|
setCartItems((prev) => {
|
||||||
const existingIdx = prev.findIndex(
|
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 (
|
return (
|
||||||
<div className={`min-h-screen flex flex-col font-sans transition-colors duration-500 ${currentBrand.colors.bodyBg}`}>
|
<div className={`min-h-screen flex flex-col font-sans transition-colors duration-500 ${currentBrand.colors.bodyBg}`}>
|
||||||
{/* Navigation Header */}
|
|
||||||
<Navbar
|
<Navbar
|
||||||
cartCount={totalCartCount}
|
cartCount={cartCount}
|
||||||
onOpenCart={() => setIsCartOpen(true)}
|
onOpenCart={() => setIsCartOpen(true)}
|
||||||
selectedCity={selectedCity}
|
selectedCity={selectedCity}
|
||||||
onOpenCitySelector={() => setIsCityModalOpen(true)}
|
onOpenCitySelector={() => setIsCityModalOpen(true)}
|
||||||
@ -121,12 +116,9 @@ export default function StoreHomePage() {
|
|||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Container */}
|
|
||||||
<main className="max-w-7xl mx-auto px-4 py-6 flex-1 w-full space-y-6">
|
<main className="max-w-7xl mx-auto px-4 py-6 flex-1 w-full space-y-6">
|
||||||
{/* Flash Deals Hero Banner */}
|
|
||||||
<FlashDealsBanner />
|
<FlashDealsBanner />
|
||||||
|
|
||||||
{/* Value Props Strip */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<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="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`}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category & Brand Filters */}
|
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="font-black text-white text-lg flex items-center gap-2">
|
<h3 className="font-black text-white text-lg flex items-center gap-2">
|
||||||
@ -187,7 +178,6 @@ export default function StoreHomePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Product Grid */}
|
|
||||||
{filteredProducts.length === 0 ? (
|
{filteredProducts.length === 0 ? (
|
||||||
<div className="bg-slate-900/80 rounded-3xl p-12 text-center border border-slate-800 space-y-3">
|
<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">
|
<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>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="bg-black/70 text-slate-400 mt-12 py-8 border-t border-white/5">
|
<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="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">
|
<div className="flex items-center space-x-2.5">
|
||||||
@ -236,7 +225,6 @@ export default function StoreHomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{/* Modals & Drawers */}
|
|
||||||
<ProductModal
|
<ProductModal
|
||||||
product={selectedProduct}
|
product={selectedProduct}
|
||||||
onClose={() => setSelectedProduct(null)}
|
onClose={() => setSelectedProduct(null)}
|
||||||
@ -265,19 +253,11 @@ export default function StoreHomePage() {
|
|||||||
onSelectCity={setSelectedCity}
|
onSelectCity={setSelectedCity}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Mobile App Dock & Install Banner */}
|
|
||||||
<MobileBottomDock
|
<MobileBottomDock
|
||||||
cartCount={cartCount}
|
cartCount={cartCount}
|
||||||
onOpenCart={() => setIsCartOpen(true)}
|
onOpenCart={() => setIsCartOpen(true)}
|
||||||
onOpenInstallModal={() => setIsInstallModalOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MobileInstallBanner
|
|
||||||
isOpen={isInstallModalOpen}
|
|
||||||
onClose={() => setIsInstallModalOpen(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* User Auth Modal */}
|
|
||||||
<AuthModal />
|
<AuthModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,68 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Link from 'next/link';
|
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 { useBrand } from '@/context/BrandContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
|
||||||
interface MobileBottomDockProps {
|
interface MobileBottomDockProps {
|
||||||
cartCount: number;
|
cartCount: number;
|
||||||
onOpenCart: () => void;
|
onOpenCart: () => void;
|
||||||
onOpenSearch?: () => void;
|
onOpenSearch?: () => void;
|
||||||
onOpenInstallModal: () => void;
|
onOpenInstallModal?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||||
cartCount,
|
cartCount,
|
||||||
onOpenCart,
|
onOpenCart,
|
||||||
onOpenSearch,
|
onOpenSearch,
|
||||||
onOpenInstallModal,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { currentBrand } = useBrand();
|
const { currentBrand } = useBrand();
|
||||||
|
const { user, openAuthModal } = useAuth();
|
||||||
|
const pathname = usePathname();
|
||||||
const [activeTab, setActiveTab] = useState('home');
|
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 handleWhatsApp = () => {
|
||||||
const text = encodeURIComponent(
|
const text = encodeURIComponent(
|
||||||
@ -71,18 +31,25 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
|||||||
window.open(`https://wa.me/${currentBrand.whatsappNumber}?text=${text}`, '_blank');
|
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 (
|
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">
|
<div className="flex items-center justify-around">
|
||||||
{/* 1. Inicio */}
|
{/* 1. Inicio */}
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
onClick={() => setActiveTab('home')}
|
onClick={() => setActiveTab('home')}
|
||||||
className={`flex flex-col items-center gap-0.5 cursor-pointer transition-all ${
|
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>
|
<span className="text-[10px] font-bold">Inicio</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@ -109,12 +76,14 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
|||||||
setActiveTab('cart');
|
setActiveTab('cart');
|
||||||
onOpenCart();
|
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">
|
<div className="relative">
|
||||||
<ShoppingBag className="w-5 h-5 text-white" />
|
<ShoppingBag className="w-5 h-5 text-white" />
|
||||||
{cartCount > 0 && (
|
{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}
|
{cartCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -125,33 +94,37 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
|||||||
{/* 4. WhatsApp Asesoría */}
|
{/* 4. WhatsApp Asesoría */}
|
||||||
<button
|
<button
|
||||||
onClick={handleWhatsApp}
|
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" />
|
<MessageCircle className="w-5 h-5 fill-emerald-500/20" />
|
||||||
<span className="text-[10px] font-bold">WhatsApp</span>
|
<span className="text-[10px] font-bold">WhatsApp</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 5. App Móvil (solo en Web) vs Perfil/Admin (si ya está en la App) */}
|
{/* 5. Solo Administradores: Acceso Admin | Clientes / Visitantes: Mi Cuenta / Ingresar */}
|
||||||
{!isInsideApp ? (
|
{isAdmin ? (
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
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>
|
<span className="text-[10px] font-bold">Admin</span>
|
||||||
</Link>
|
</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>
|
||||||
</div>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -122,15 +122,17 @@ export const Navbar: React.FC<NavbarProps> = ({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Admin Link */}
|
{/* Admin Link - Solo para Administradores */}
|
||||||
<Link
|
{user && (user.role === 'admin' || user.role === 'operator') && (
|
||||||
href="/admin"
|
<Link
|
||||||
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"
|
href="/admin"
|
||||||
title="Panel de Administración"
|
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>
|
<Store className="w-4 h-4 text-orange-400" />
|
||||||
</Link>
|
<span className="hidden lg:inline">Admin</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Cart Button */}
|
{/* Cart Button */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -115,12 +115,13 @@ export interface Order {
|
|||||||
export interface SupplierPost {
|
export interface SupplierPost {
|
||||||
id: string;
|
id: string;
|
||||||
channelName: string;
|
channelName: string;
|
||||||
|
channelJid?: string;
|
||||||
supplierName?: string;
|
supplierName?: string;
|
||||||
supplierPhone?: string;
|
supplierPhone?: string;
|
||||||
rawMessage: string;
|
rawMessage: string;
|
||||||
rawImages: string[];
|
rawImages: string[];
|
||||||
extractedProduct?: ExtractedProductData;
|
extractedProduct?: ExtractedProductData;
|
||||||
status: 'pending_review' | 'published_facebook' | 'published_store' | 'ignored';
|
status: 'pending_review' | 'published_facebook' | 'published_store' | 'published' | 'discarded' | 'ignored';
|
||||||
receivedAt: string;
|
receivedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,365 +1,680 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Radio, Sparkles, Share2, AlertTriangle, ShieldCheck, Plus, Send, Layers } from 'lucide-react';
|
import Link from 'next/link';
|
||||||
import { SupplierPost, Product } from '@/types';
|
import {
|
||||||
import { INITIAL_SUPPLIER_POSTS, INITIAL_PRODUCTS } from '@/lib/data-service';
|
Radio,
|
||||||
import { checkProductDuplicate } from '@/lib/deduplication-engine';
|
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 { formatCOP } from '@/lib/pricing-engine';
|
||||||
import { BRANDS, BrandId } from '@/config/brands';
|
import { BRANDS, BrandId } from '@/config/brands';
|
||||||
|
|
||||||
export default function SupplierFeedPage() {
|
export default function SupplierFeedPage() {
|
||||||
const [posts, setPosts] = useState<SupplierPost[]>(INITIAL_SUPPLIER_POSTS);
|
const [posts, setPosts] = useState<SupplierPost[]>([]);
|
||||||
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
|
const [monitoredGroups, setMonitoredGroups] = useState<MonitoredWhatsAppGroup[]>([]);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
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);
|
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
|
// 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
|
// Cargar grupos monitoreados y posts iniciales
|
||||||
const [testText, setTestText] = useState(
|
const loadInitialData = async () => {
|
||||||
'🔥 LLEGANDO A BODEGA HOY 🔥\nNike Air Max 90 White Black\nTallas: 38 a 43\nMayorista: $90.000 c/u\nEnvío inmediato contraentrega'
|
setIsLoading(true);
|
||||||
);
|
|
||||||
const [testGroup, setTestGroup] = useState('Canal Mayorista A');
|
|
||||||
|
|
||||||
const handleSimulateIncoming = async () => {
|
|
||||||
setIsProcessing(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/webhook/whatsapp', {
|
const grpRes = await fetch('/api/whatsapp-groups');
|
||||||
method: 'POST',
|
const grpData = await grpRes.json();
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (grpData.success && Array.isArray(grpData.groups)) {
|
||||||
body: JSON.stringify({
|
setMonitoredGroups(grpData.groups);
|
||||||
text: testText,
|
}
|
||||||
groupInfo: { subject: testGroup },
|
|
||||||
}),
|
const feedRes = await fetch('/api/whatsapp-feed');
|
||||||
});
|
const feedData = await feedRes.json();
|
||||||
const data = await res.json();
|
if (feedData.success && Array.isArray(feedData.posts)) {
|
||||||
if (data.success && data.post) {
|
setPosts(feedData.posts);
|
||||||
setPosts((prev) => [data.post, ...prev]);
|
|
||||||
setActionMessage('✅ Nuevo mensaje de WhatsApp procesado con IA.');
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error('Error cargando feed:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePublishFacebook = async (post: SupplierPost) => {
|
useEffect(() => {
|
||||||
if (!post.extractedProduct) return;
|
loadInitialData();
|
||||||
setIsProcessing(true);
|
}, []);
|
||||||
|
|
||||||
|
// Sincronizar mensajes en vivo desde Evolution API
|
||||||
|
const handleSyncLive = async () => {
|
||||||
|
setIsSyncingLive(true);
|
||||||
|
setActionMessage(null);
|
||||||
try {
|
try {
|
||||||
const brandName = targetBrand === 'all' ? 'Todas las Tiendas' : BRANDS[targetBrand].name;
|
const url =
|
||||||
const res = await fetch('/api/publish/facebook', {
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
productName: post.extractedProduct.name,
|
action: 'publish',
|
||||||
brand: post.extractedProduct.brand,
|
post,
|
||||||
retailPrice: post.extractedProduct.suggestedRetailPrice,
|
customName: customData?.name,
|
||||||
sizes: post.extractedProduct.sizes,
|
customRetailPrice: customData?.retailPrice,
|
||||||
images: post.rawImages,
|
customSizes: customData?.sizes,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
setActionMessage('❌ Error al publicar producto.');
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddToStore = async (post: SupplierPost) => {
|
// Publicación masiva de los seleccionados
|
||||||
if (!post.extractedProduct) return;
|
const handleBulkPublish = async () => {
|
||||||
const brandLabel = targetBrand === 'all' ? 'Multi-Marca' : BRANDS[targetBrand].shortName;
|
const selectedPosts = posts.filter(
|
||||||
const newProduct: Product = {
|
(p) => selectedPostIds.has(p.id) && p.status !== 'published'
|
||||||
id: `snk-${Date.now()}`,
|
);
|
||||||
name: post.extractedProduct.name,
|
if (selectedPosts.length === 0) return;
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/products', {
|
const res = await fetch('/api/whatsapp-feed', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(newProduct),
|
body: JSON.stringify({
|
||||||
|
action: 'bulk-publish',
|
||||||
|
posts: selectedPosts,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setProducts((prev) => [data.product, ...prev]);
|
setPosts((prev) =>
|
||||||
setActionMessage(`✨ "${newProduct.name}" habilitado en el catálogo para ${brandLabel}.`);
|
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) {
|
} catch (e) {
|
||||||
console.error(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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* 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>
|
<div>
|
||||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Radio className="w-6 h-6 text-cyan-400 animate-pulse" />
|
<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)]">
|
||||||
<span>Feed de Entrada & Publicador Multi-Marca</span>
|
<Radio className="w-5 h-5 animate-pulse" />
|
||||||
</h1>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
<div>
|
||||||
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>).
|
<h1 className="text-2xl font-black text-white">Feed de Mensajes de WhatsApp • Zapply</h1>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Target Brand Selector Pill Bar */}
|
{/* Action Controls */}
|
||||||
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl">
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
<span className="text-[10px] font-bold text-slate-400 pl-2 pr-1 flex items-center gap-1">
|
<Link
|
||||||
<Layers className="w-3 h-3" /> Destino:
|
href="/admin/grupos-whatsapp"
|
||||||
</span>
|
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"
|
||||||
<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'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Toast feedback */}
|
||||||
{actionMessage && (
|
{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">
|
<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">
|
||||||
<span>{actionMessage}</span>
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={() => setActionMessage(null)} className="font-bold text-white cursor-pointer">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Live Simulator for Test Messages */}
|
{/* Filter & Tabs Bar */}
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4">
|
<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">
|
||||||
<div className="flex items-center space-x-2 text-cyan-400 font-bold text-xs uppercase tracking-wider">
|
{/* Status Tabs */}
|
||||||
<Sparkles className="w-4 h-4 text-cyan-400" />
|
<div className="flex items-center gap-1 bg-slate-950 p-1 rounded-2xl border border-slate-800 text-xs font-bold">
|
||||||
<span>Simulador de Recepción en Tiempo Real</span>
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-3">
|
{/* Group Selector Filter */}
|
||||||
<div className="md:col-span-4">
|
<div className="flex items-center gap-2">
|
||||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Canal de Entrada:</label>
|
<Filter className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||||
<select
|
<select
|
||||||
value={testGroup}
|
value={selectedGroupFilter}
|
||||||
onChange={(e) => setTestGroup(e.target.value)}
|
onChange={(e) => setSelectedGroupFilter(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"
|
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="Canal Mayorista A">Canal Mayorista A</option>
|
<option value="all">Todos los Grupos de WhatsApp</option>
|
||||||
<option value="Canal Mayorista B">Canal Mayorista B</option>
|
{monitoredGroups.map((g) => (
|
||||||
</select>
|
<option key={g.id || g.jid} value={g.jid}>
|
||||||
</div>
|
{g.name}
|
||||||
|
</option>
|
||||||
<div className="md:col-span-8">
|
))}
|
||||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Texto del Mensaje de WhatsApp:</label>
|
</select>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Intercepted Posts Feed */}
|
{/* Multi-Selection Bulk Actions Bar */}
|
||||||
<div className="space-y-4">
|
{selectedPostIds.size > 0 && tabStatus === 'pending' && (
|
||||||
<h2 className="font-bold text-white text-base">Mensajes Procesados Recientemente</h2>
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
{posts.map((post) => {
|
<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 extracted = post.extractedProduct;
|
||||||
const duplicateCheck = extracted
|
const isSelected = selectedPostIds.has(post.id);
|
||||||
? checkProductDuplicate(
|
const isPublished = post.status === 'published';
|
||||||
{
|
const isDiscarded = post.status === 'discarded';
|
||||||
name: extracted.name,
|
|
||||||
brand: extracted.brand,
|
|
||||||
sizes: extracted.sizes,
|
|
||||||
supplierPrice: extracted.supplierPrice,
|
|
||||||
},
|
|
||||||
products
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={post.id}
|
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 */}
|
{/* Header info */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800 pb-3">
|
<div className="flex items-center justify-between gap-2 border-b border-slate-800 pb-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="bg-slate-800 text-slate-200 text-xs font-black px-2.5 py-1 rounded-full border border-slate-700">
|
{tabStatus === 'pending' && (
|
||||||
{post.channelName || 'Canal Mayorista'}
|
<button
|
||||||
</span>
|
onClick={() => handleToggleSelect(post.id)}
|
||||||
<span className="text-xs text-slate-400">{post.receivedAt}</span>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Anti-Duplicate Status Badge */}
|
<div>
|
||||||
{duplicateCheck && (
|
{isPublished ? (
|
||||||
<div
|
<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">
|
||||||
className={`text-xs font-bold px-3 py-1 rounded-full flex items-center gap-1.5 ${
|
<CheckCircle2 className="w-3 h-3" /> Publicado en Tienda
|
||||||
duplicateCheck.isDuplicate
|
</span>
|
||||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
|
) : isDiscarded ? (
|
||||||
: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
|
<span className="bg-slate-800 text-slate-400 text-[10px] font-bold px-2.5 py-0.5 rounded-full">
|
||||||
}`}
|
Descartado
|
||||||
>
|
</span>
|
||||||
{duplicateCheck.isDuplicate ? (
|
) : (
|
||||||
<>
|
<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">
|
||||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
|
<Sparkles className="w-3 h-3" /> Pendiente de Aprobación
|
||||||
<span>Duplicado Detectado ({Math.round(duplicateCheck.similarityScore * 100)}% Similitud)</span>
|
</span>
|
||||||
</>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<>
|
|
||||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
|
||||||
<span>Producto Nuevo (0% Duplicados)</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Grid */}
|
{/* Content Grid (Photo + AI extracted info) */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
|
<div className="grid grid-cols-1 sm:grid-cols-12 gap-4">
|
||||||
{/* Left: Raw WhatsApp Message (5 cols) */}
|
{/* Sneaker Image & Raw text */}
|
||||||
<div className="md:col-span-5 bg-slate-950 rounded-2xl p-4 space-y-2 border border-slate-800">
|
<div className="sm:col-span-5 space-y-2">
|
||||||
<span className="text-[10px] uppercase font-bold text-slate-400">Mensaje de Entrada:</span>
|
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-slate-950 border border-slate-800 flex items-center justify-center group">
|
||||||
<p className="text-xs text-slate-300 font-mono whitespace-pre-line leading-relaxed">
|
{post.rawImages && post.rawImages[0] ? (
|
||||||
{post.rawMessage}
|
|
||||||
</p>
|
|
||||||
{post.rawImages.length > 0 && (
|
|
||||||
<div className="pt-2">
|
|
||||||
<img
|
<img
|
||||||
src={post.rawImages[0]}
|
src={post.rawImages[0]}
|
||||||
alt=""
|
alt={extracted?.name || 'Zapatilla'}
|
||||||
className="w-24 h-24 rounded-xl object-cover bg-slate-900 border border-slate-700"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Right: AI Extracted & Pricing Calculation (7 cols) */}
|
{/* AI Extraction & Product Info */}
|
||||||
<div className="md:col-span-7 space-y-3">
|
<div className="sm:col-span-7 space-y-3 flex flex-col justify-between">
|
||||||
{extracted ? (
|
<div className="space-y-2">
|
||||||
<div className="bg-slate-950 rounded-2xl p-4 border border-slate-800 space-y-3">
|
<div>
|
||||||
<div className="flex items-start justify-between">
|
<span className="text-[10px] font-black uppercase text-emerald-400 tracking-wider">
|
||||||
<div>
|
{extracted?.brand || 'Nike'} • {extracted?.category || 'Sneakers'}
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase">
|
</span>
|
||||||
{extracted.brand} • {extracted.category}
|
<h3 className="text-base font-black text-white leading-tight">
|
||||||
</span>
|
{extracted?.name || 'Zapatilla Urbana'}
|
||||||
<h3 className="text-base font-black text-white">{extracted.name}</h3>
|
</h3>
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
{/* Pricing breakdown */}
|
{/* Pricing breakdown */}
|
||||||
<div className="grid grid-cols-2 gap-3 bg-slate-900 p-3 rounded-xl border border-slate-800">
|
<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 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 */}
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[10px] text-slate-400 font-semibold uppercase block mb-1">
|
<span className="text-[10px] text-slate-400 block font-bold">Costo Proveedor:</span>
|
||||||
Tallas extraídas:
|
<span className="text-xs font-bold text-slate-300 font-mono">
|
||||||
|
{formatCOP(extracted?.supplierPrice || 90000)}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
<div>
|
||||||
{/* Duplicate warning note */}
|
<span className="text-[10px] text-emerald-400 block font-bold">Venta Sugerida:</span>
|
||||||
{duplicateCheck?.isDuplicate && (
|
<span className="text-sm font-black text-emerald-300 font-mono">
|
||||||
<div className="bg-amber-950/40 border border-amber-800/60 p-2.5 rounded-xl text-xs text-amber-200">
|
{formatCOP(extracted?.suggestedRetailPrice || 125000)}
|
||||||
{duplicateCheck.message}
|
</span>
|
||||||
</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>
|
</div>
|
||||||
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,15 +11,20 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Sparkles,
|
|
||||||
Store,
|
Store,
|
||||||
Users,
|
Users,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Building2,
|
Building2,
|
||||||
|
ShieldAlert,
|
||||||
|
LogIn,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { AuthModal } from '@/components/auth/AuthModal';
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { user, openAuthModal } = useAuth();
|
||||||
|
const isAdmin = Boolean(user && (user.role === 'admin' || user.role === 'operator'));
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard },
|
{ 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 },
|
{ 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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col md:flex-row font-sans">
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col md:flex-row font-sans">
|
||||||
{/* Sidebar */}
|
{/* 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">
|
<main className="flex-1 p-4 sm:p-8 overflow-y-auto bg-slate-950">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<AuthModal />
|
||||||
</div>
|
</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 { CartDrawer } from '@/components/store/CartDrawer';
|
||||||
import { CitySelectorModal } from '@/components/store/CitySelectorModal';
|
import { CitySelectorModal } from '@/components/store/CitySelectorModal';
|
||||||
import { MobileBottomDock } from '@/components/mobile/MobileBottomDock';
|
import { MobileBottomDock } from '@/components/mobile/MobileBottomDock';
|
||||||
import { MobileInstallBanner } from '@/components/mobile/MobileInstallBanner';
|
|
||||||
import { AuthModal } from '@/components/auth/AuthModal';
|
import { AuthModal } from '@/components/auth/AuthModal';
|
||||||
import { Product, City, CartItem } from '@/types';
|
import { Product, City, CartItem } from '@/types';
|
||||||
import { INITIAL_PRODUCTS, INITIAL_CITIES } from '@/lib/data-service';
|
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 [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||||
const [isCartOpen, setIsCartOpen] = useState(false);
|
const [isCartOpen, setIsCartOpen] = useState(false);
|
||||||
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
||||||
const [isInstallModalOpen, setIsInstallModalOpen] = useState(false);
|
|
||||||
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
||||||
|
|
||||||
const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
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 (
|
return (
|
||||||
<div className={`min-h-screen flex flex-col font-sans transition-colors duration-500 ${currentBrand.colors.bodyBg}`}>
|
<div className={`min-h-screen flex flex-col font-sans transition-colors duration-500 ${currentBrand.colors.bodyBg}`}>
|
||||||
{/* Navigation Header */}
|
{/* Navigation Header */}
|
||||||
<Navbar
|
<Navbar
|
||||||
cartCount={totalCartCount}
|
cartCount={cartCount}
|
||||||
onOpenCart={() => setIsCartOpen(true)}
|
onOpenCart={() => setIsCartOpen(true)}
|
||||||
selectedCity={selectedCity}
|
selectedCity={selectedCity}
|
||||||
onOpenCitySelector={() => setIsCityModalOpen(true)}
|
onOpenCitySelector={() => setIsCityModalOpen(true)}
|
||||||
@ -265,16 +261,10 @@ export default function StoreHomePage() {
|
|||||||
onSelectCity={setSelectedCity}
|
onSelectCity={setSelectedCity}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Mobile App Dock & Install Banner */}
|
{/* Mobile App Dock */}
|
||||||
<MobileBottomDock
|
<MobileBottomDock
|
||||||
cartCount={cartCount}
|
cartCount={cartCount}
|
||||||
onOpenCart={() => setIsCartOpen(true)}
|
onOpenCart={() => setIsCartOpen(true)}
|
||||||
onOpenInstallModal={() => setIsInstallModalOpen(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MobileInstallBanner
|
|
||||||
isOpen={isInstallModalOpen}
|
|
||||||
onClose={() => setIsInstallModalOpen(false)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* User Auth Modal */}
|
{/* User Auth Modal */}
|
||||||
|
|||||||
@ -1,68 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Link from 'next/link';
|
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 { useBrand } from '@/context/BrandContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
|
||||||
interface MobileBottomDockProps {
|
interface MobileBottomDockProps {
|
||||||
cartCount: number;
|
cartCount: number;
|
||||||
onOpenCart: () => void;
|
onOpenCart: () => void;
|
||||||
onOpenSearch?: () => void;
|
onOpenSearch?: () => void;
|
||||||
onOpenInstallModal: () => void;
|
onOpenInstallModal?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||||
cartCount,
|
cartCount,
|
||||||
onOpenCart,
|
onOpenCart,
|
||||||
onOpenSearch,
|
onOpenSearch,
|
||||||
onOpenInstallModal,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { currentBrand } = useBrand();
|
const { currentBrand } = useBrand();
|
||||||
|
const { user, openAuthModal } = useAuth();
|
||||||
|
const pathname = usePathname();
|
||||||
const [activeTab, setActiveTab] = useState('home');
|
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 handleWhatsApp = () => {
|
||||||
const text = encodeURIComponent(
|
const text = encodeURIComponent(
|
||||||
@ -71,20 +31,29 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
|||||||
window.open(`https://wa.me/${currentBrand.whatsappNumber}?text=${text}`, '_blank');
|
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 (
|
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">
|
<div className="flex items-center justify-around">
|
||||||
|
{/* 1. Inicio */}
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
onClick={() => setActiveTab('home')}
|
onClick={() => setActiveTab('home')}
|
||||||
className={`flex flex-col items-center gap-0.5 cursor-pointer transition-all ${
|
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>
|
<span className="text-[10px] font-bold">Inicio</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* 2. Buscar */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveTab('search');
|
setActiveTab('search');
|
||||||
@ -101,17 +70,20 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
|||||||
<span className="text-[10px] font-bold">Buscar</span>
|
<span className="text-[10px] font-bold">Buscar</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 3. Carrito */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveTab('cart');
|
setActiveTab('cart');
|
||||||
onOpenCart();
|
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">
|
<div className="relative">
|
||||||
<ShoppingBag className="w-5 h-5 text-white" />
|
<ShoppingBag className="w-5 h-5 text-white" />
|
||||||
{cartCount > 0 && (
|
{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}
|
{cartCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -119,34 +91,40 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
|||||||
<span className="text-[10px] font-bold text-white">Carrito</span>
|
<span className="text-[10px] font-bold text-white">Carrito</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 4. WhatsApp */}
|
||||||
<button
|
<button
|
||||||
onClick={handleWhatsApp}
|
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" />
|
<MessageCircle className="w-5 h-5 fill-emerald-500/20" />
|
||||||
<span className="text-[10px] font-bold">WhatsApp</span>
|
<span className="text-[10px] font-bold">WhatsApp</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!isInsideApp ? (
|
{/* 5. Solo Administradores: Acceso Admin | Clientes / Visitantes: Mi Cuenta / Ingresar */}
|
||||||
<button
|
{isAdmin ? (
|
||||||
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>
|
|
||||||
) : (
|
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
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>
|
<span className="text-[10px] font-bold">Admin</span>
|
||||||
</Link>
|
</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>
|
||||||
</div>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -122,15 +122,17 @@ export const Navbar: React.FC<NavbarProps> = ({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Admin Link */}
|
{/* Admin Link - Solo para Administradores */}
|
||||||
<Link
|
{user && (user.role === 'admin' || user.role === 'operator') && (
|
||||||
href="/admin"
|
<Link
|
||||||
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"
|
href="/admin"
|
||||||
title="Panel de Administración"
|
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>
|
<Store className="w-4 h-4 text-emerald-400" />
|
||||||
</Link>
|
<span className="hidden lg:inline">Admin</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Cart Button */}
|
{/* Cart Button */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -115,12 +115,13 @@ export interface Order {
|
|||||||
export interface SupplierPost {
|
export interface SupplierPost {
|
||||||
id: string;
|
id: string;
|
||||||
channelName: string;
|
channelName: string;
|
||||||
|
channelJid?: string;
|
||||||
supplierName?: string;
|
supplierName?: string;
|
||||||
supplierPhone?: string;
|
supplierPhone?: string;
|
||||||
rawMessage: string;
|
rawMessage: string;
|
||||||
rawImages: string[];
|
rawImages: string[];
|
||||||
extractedProduct?: ExtractedProductData;
|
extractedProduct?: ExtractedProductData;
|
||||||
status: 'pending_review' | 'published_facebook' | 'published_store' | 'ignored';
|
status: 'pending_review' | 'published_facebook' | 'published_store' | 'published' | 'discarded' | 'ignored';
|
||||||
receivedAt: string;
|
receivedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user