feat: agrupacion manual de modelos, validacion previa antes de publicar y precios variables por talla en UI

This commit is contained in:
AI Bot 2026-08-28 23:30:13 -05:00
parent 93d0a590a7
commit f2ac7876f7
36 changed files with 4266 additions and 693 deletions

View File

@ -28,10 +28,12 @@ import {
import { SupplierPost, Product, MonitoredWhatsAppGroup } from '@/types';
import { formatCOP } from '@/lib/pricing-engine';
import { BRANDS, BrandId } from '@/config/brands';
import { normalizeModelKey } from '@/lib/model-grouper';
export default function SupplierFeedPage() {
const [posts, setPosts] = useState<SupplierPost[]>([]);
const [monitoredGroups, setMonitoredGroups] = useState<MonitoredWhatsAppGroup[]>([]);
const [existingProducts, setExistingProducts] = useState<Product[]>([]);
const [selectedGroupFilter, setSelectedGroupFilter] = useState<string>('all');
const [tabStatus, setTabStatus] = useState<'pending' | 'published' | 'discarded'>('pending');
const [isLoading, setIsLoading] = useState(false);
@ -45,13 +47,15 @@ export default function SupplierFeedPage() {
const [editingPost, setEditingPost] = useState<SupplierPost | null>(null);
const [editName, setEditName] = useState('');
const [editBrand, setEditBrand] = useState('');
const [editColorName, setEditColorName] = useState('');
const [editPrice, setEditPrice] = useState(120000);
const [editSizes, setEditSizes] = useState('');
const [mergeWithExisting, setMergeWithExisting] = useState(true);
// Target brand selector for publishing
const [targetBrand, setTargetBrand] = useState<BrandId | 'all'>('almeira');
// Cargar grupos monitoreados y posts iniciales en vivo
// Cargar grupos monitoreados, productos existentes y posts en vivo
const loadInitialData = async () => {
setIsLoading(true);
try {
@ -62,7 +66,14 @@ export default function SupplierFeedPage() {
setMonitoredGroups(grpData.groups);
}
// 2. Posts en vivo desde los grupos de WhatsApp
// 2. Productos existentes en el catálogo
const prodRes = await fetch('/api/products?all=true');
const prodData = await prodRes.json();
if (prodData.success && Array.isArray(prodData.products)) {
setExistingProducts(prodData.products);
}
// 3. Posts en vivo desde los grupos de WhatsApp
const feedRes = await fetch('/api/whatsapp-feed?fetchLive=true&limit=50');
const feedData = await feedRes.json();
if (feedData.success && Array.isArray(feedData.posts)) {
@ -136,7 +147,7 @@ export default function SupplierFeedPage() {
// Publicar un producto a la tienda
const handlePublishPost = async (
post: SupplierPost,
customData?: { name?: string; brand?: string; retailPrice?: number; sizes?: string[] }
customData?: { name?: string; brand?: string; colorName?: string; retailPrice?: number; sizes?: string[] }
) => {
try {
const res = await fetch('/api/whatsapp-feed', {
@ -146,6 +157,7 @@ export default function SupplierFeedPage() {
action: 'publish',
post,
customName: customData?.name,
customColorName: customData?.colorName,
customRetailPrice: customData?.retailPrice,
customSizes: customData?.sizes,
}),
@ -251,13 +263,17 @@ export default function SupplierFeedPage() {
const handleOpenEdit = (post: SupplierPost) => {
setEditingPost(post);
setEditName(post.extractedProduct?.name || 'Zapatilla Urbana');
setEditBrand(post.extractedProduct?.brand || 'Nike');
const prodName = post.extractedProduct?.name || 'Zapatilla Urbana';
const prodBrand = post.extractedProduct?.brand || 'Nike';
setEditName(prodName);
setEditBrand(prodBrand);
setEditColorName(post.extractedProduct?.colorName || '');
setEditPrice(
post.extractedProduct?.suggestedRetailPrice ||
(post.extractedProduct?.supplierPrice ? post.extractedProduct.supplierPrice + 35000 : 125000)
);
setEditSizes(post.extractedProduct?.sizes?.join(', ') || '38, 39, 40, 41, 42, 43');
setMergeWithExisting(true);
};
return (
@ -308,7 +324,7 @@ export default function SupplierFeedPage() {
</div>
<button
onClick={() => setActionMessage(null)}
className="text-white hover:text-purple-300 font-bold px-2 cursor-pointer"
className="text-purple-400 hover:text-white font-bold"
>
✕
</button>
@ -619,96 +635,168 @@ export default function SupplierFeedPage() {
)}
{/* 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>
{editingPost && (() => {
const matchingProduct = existingProducts.find(
(p) => normalizeModelKey(p.modelName || p.name, p.brand) === normalizeModelKey(editName, editBrand)
);
<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"
/>
return (
<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 y Validar antes de Publicar</span>
</h3>
<button
onClick={() => setEditingPost(null)}
className="text-neutral-400 hover:text-white font-bold"
>
✕
</button>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Pre-validación de Modelo Existente */}
{matchingProduct && (
<div className="bg-purple-950/40 border border-purple-500/50 rounded-2xl p-3.5 space-y-2.5">
<div className="flex items-center gap-2 text-purple-300 font-bold text-xs">
<Sparkles className="w-4 h-4 text-purple-400" />
<span>¡Modelo Existente Detectado en la Tienda!</span>
</div>
<div className="flex items-center justify-between bg-neutral-950 p-2.5 rounded-xl border border-neutral-800">
<div className="flex items-center gap-2.5">
<img
src={matchingProduct.images[0]}
className="w-10 h-10 rounded-lg object-cover bg-neutral-900 border border-neutral-800"
alt=""
/>
<div>
<p className="font-black text-white text-xs">{matchingProduct.name}</p>
<p className="text-[10px] text-neutral-400">
{matchingProduct.colors?.length || 1} variantes de color existentes
</p>
</div>
</div>
<span className="text-[10px] bg-purple-600/30 text-purple-300 border border-purple-500/40 font-bold px-2 py-0.5 rounded-md">
Coincidencia
</span>
</div>
<div className="flex flex-col gap-1.5 text-xs">
<label className="flex items-center gap-2 text-neutral-200 cursor-pointer font-semibold">
<input
type="radio"
name="mergeOption"
checked={mergeWithExisting}
onChange={() => setMergeWithExisting(true)}
className="accent-purple-600"
/>
<span>Agregar como nueva variante de color a "{matchingProduct.name}"</span>
</label>
<label className="flex items-center gap-2 text-neutral-400 hover:text-neutral-200 cursor-pointer">
<input
type="radio"
name="mergeOption"
checked={!mergeWithExisting}
onChange={() => setMergeWithExisting(false)}
className="accent-purple-600"
/>
<span>Publicar como un modelo nuevo independiente</span>
</label>
</div>
</div>
)}
<div className="space-y-3.5 text-xs">
<div>
<label className="font-bold text-neutral-300 block mb-1">Marca:</label>
<label className="font-bold text-neutral-300 block mb-1">Nombre Comercial del Modelo:</label>
<input
type="text"
value={editBrand}
onChange={(e) => setEditBrand(e.target.value)}
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>
{matchingProduct && mergeWithExisting && (
<div>
<label className="font-bold text-purple-400 block mb-1">Nombre de este Estilo / Colorway:</label>
<input
type="text"
value={editColorName}
onChange={(e) => setEditColorName(e.target.value)}
placeholder="Ej: Blanco / Negro, Gum Edition, Rosa Pastel"
className="w-full bg-neutral-950 border border-purple-500/50 rounded-xl px-3 py-2 text-white font-semibold 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>
<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 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: !mergeWithExisting && matchingProduct ? `${editName} (${editColorName || 'Variante'})` : editName,
colorName: editColorName,
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"
>
{matchingProduct && mergeWithExisting ? 'Fusionar y Publicar en App' : 'Guardar y Publicar en App'}
</button>
</div>
</div>
<div className="flex items-center gap-2.5 pt-3 border-t border-neutral-800">
<button
onClick={() => setEditingPost(null)}
className="flex-1 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold text-xs py-3 rounded-xl cursor-pointer"
>
Cancelar
</button>
<button
onClick={() => {
const sizesArray = editSizes
.split(',')
.map((s) => s.trim())
.filter(Boolean);
handlePublishPost(editingPost, {
name: editName,
brand: editBrand,
retailPrice: editPrice,
sizes: sizesArray,
});
}}
className="flex-1 bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:opacity-95 text-white font-black text-xs py-3 rounded-xl shadow-lg shadow-purple-600/30 cursor-pointer"
>
Guardar y Publicar en la App
</button>
</div>
</div>
</div>
)}
);
})()}
</div>
);
}

View File

@ -1,10 +1,11 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck } from 'lucide-react';
import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck, Trash2, Edit, Wand2, ArrowRight, Layers, Palette, CheckSquare, Square } from 'lucide-react';
import { Product, Supplier } from '@/types';
import { INITIAL_PRODUCTS, INITIAL_SUPPLIERS } from '@/lib/data-service';
import { formatCOP } from '@/lib/pricing-engine';
import { GroupProductsModal } from '@/components/admin/GroupProductsModal';
export default function AdminProductsPage() {
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
@ -13,6 +14,10 @@ export default function AdminProductsPage() {
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [supplierFilter, setSupplierFilter] = useState<string>('all');
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAiModalOpen, setIsAiModalOpen] = useState(false);
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
const [selectedProductIds, setSelectedProductIds] = useState<string[]>([]);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
// New product form
@ -28,6 +33,21 @@ export default function AdminProductsPage() {
const [category, setCategory] = useState('Sneakers / Urbano');
const [isActiveNew, setIsActiveNew] = useState(true);
// Edit product form
const [editName, setEditName] = useState('');
const [editBrand, setEditBrand] = useState('');
const [editSupplierId, setEditSupplierId] = useState('');
const [editSupplierPrice, setEditSupplierPrice] = useState('');
const [editRetailPrice, setEditRetailPrice] = useState('');
const [editSizesInput, setEditSizesInput] = useState('');
const [editImageUrl, setEditImageUrl] = useState('');
const [editCategory, setEditCategory] = useState('');
// 1-to-1 AI processor form
const [rawText, setRawText] = useState('');
const [rawImageUrl, setRawImageUrl] = useState('');
const [aiExtracted, setAiExtracted] = useState<Partial<Product> | null>(null);
useEffect(() => {
fetch('/api/products?all=true')
.then((r) => r.json())
@ -77,6 +97,25 @@ export default function AdminProductsPage() {
window.open(`https://wa.me/${phone}?text=${text}`, '_blank');
};
const handleDeleteProduct = async (product: Product) => {
if (!window.confirm(`¿Estás seguro de eliminar permanentemente la publicación "${product.name}"?`)) {
return;
}
try {
const res = await fetch(`/api/products?id=${product.id}`, {
method: 'DELETE',
});
const data = await res.json();
if (data.success) {
setProducts((prev) => prev.filter((p) => p.id !== product.id));
setToastMessage(`🗑️ Publicación "${product.name}" eliminada permanentemente del catálogo.`);
}
} catch (e) {
console.error('Error eliminando producto:', e);
}
};
const handleCreateProduct = async (e: React.FormEvent) => {
e.preventDefault();
const sizes = sizesInput
@ -119,6 +158,142 @@ export default function AdminProductsPage() {
}
};
const handleOpenEdit = (product: Product) => {
setEditingProduct(product);
setEditName(product.name);
setEditBrand(product.brand);
setEditSupplierId(product.supplierId || suppliers[0].id);
setEditSupplierPrice(product.supplierPrice.toString());
setEditRetailPrice(product.retailPrice.toString());
setEditSizesInput(product.sizes.join(', '));
setEditImageUrl(product.images[0] || '');
setEditCategory(product.category || 'Sneakers / Urbano');
};
const handleSaveEdit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingProduct) return;
const sizes = editSizesInput.split(',').map((s) => s.trim()).filter(Boolean);
const chosenSupplier = suppliers.find((s) => s.id === editSupplierId) || suppliers[0];
const updates = {
name: editName,
brand: editBrand,
supplierPrice: parseInt(editSupplierPrice, 10) || editingProduct.supplierPrice,
retailPrice: parseInt(editRetailPrice, 10) || editingProduct.retailPrice,
originalPrice: Math.round((parseInt(editRetailPrice, 10) || editingProduct.retailPrice) * 1.35),
sizes,
images: [editImageUrl || editingProduct.images[0]],
category: editCategory,
supplierId: chosenSupplier.id,
supplierName: chosenSupplier.name,
supplierPhone: chosenSupplier.phone,
};
setProducts((prev) => prev.map((p) => (p.id === editingProduct.id ? { ...p, ...updates } : p)));
try {
const res = await fetch('/api/products', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: editingProduct.id, updates }),
});
const data = await res.json();
if (data.success) {
setEditingProduct(null);
setToastMessage(`✅ Publicación "${editName}" actualizada con éxito.`);
}
} catch (e) {
console.error(e);
}
};
// 1-to-1 AI Processor logic
const handleProcessSinglePostWithAI = () => {
if (!rawText.trim()) return;
// Extracción inteligente
const cleanTitle = rawText
.split('\n')[0]
.replace(/🔥|👟|💥|⚡|DISPONIBLE|LLEGANDO|HOY|A BODEGA/gi, '')
.trim();
// Detectar marca
let detectedBrand = 'Nike';
if (/adidas/i.test(rawText)) detectedBrand = 'Adidas';
if (/jordan/i.test(rawText)) detectedBrand = 'Jordan';
if (/new balance/i.test(rawText)) detectedBrand = 'New Balance';
if (/puma/i.test(rawText)) detectedBrand = 'Puma';
if (/vans/i.test(rawText)) detectedBrand = 'Vans';
// Detectar precio
const priceMatch = rawText.match(/\$?\s*(\d{2,3})[.,]?(\d{3})/);
let cost = 90000;
if (priceMatch) {
cost = parseInt(priceMatch[1] + priceMatch[2], 10);
}
// Calcular precio venta con 35% de margen
const retail = Math.round(cost * 1.35 / 1000) * 1000;
// Detectar tallas
const sizes = ['37', '38', '39', '40', '41', '42', '43'];
setAiExtracted({
name: cleanTitle || 'Zapatilla Urbana Exclusiva',
brand: detectedBrand,
supplierPrice: cost,
retailPrice: retail,
sizes,
images: [rawImageUrl || 'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
category: 'Sneakers / Urbano',
description: 'Zapatillas calidad 1.1 garantizada para entrega inmediata.',
isActive: true,
inStock: true,
originBadge: 'Bodega Central',
supplierName: 'DIFUSION JOVIC STORE',
supplierPhone: '573134848203',
});
};
const handlePublishAiProduct = async () => {
if (!aiExtracted) return;
const newProd: Product = {
id: `snk-${Date.now()}`,
name: aiExtracted.name || 'Zapatilla',
brand: aiExtracted.brand || 'Nike',
supplierPrice: aiExtracted.supplierPrice || 90000,
retailPrice: aiExtracted.retailPrice || 125000,
originalPrice: Math.round((aiExtracted.retailPrice || 125000) * 1.35),
sizes: aiExtracted.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: aiExtracted.images || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: aiExtracted.description || 'Zapatillas urbanas',
category: aiExtracted.category || 'Sneakers / Urbano',
inStock: true,
isActive: true,
originBadge: 'Bodega Central',
supplierName: aiExtracted.supplierName || 'DIFUSION JOVIC STORE',
supplierPhone: aiExtracted.supplierPhone || '573134848203',
};
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newProd),
});
const data = await res.json();
if (data.success) {
setProducts((prev) => [data.product, ...prev]);
setIsAiModalOpen(false);
setRawText('');
setRawImageUrl('');
setAiExtracted(null);
setToastMessage(`🚀 ¡Publicación individual "${newProd.name}" procesada y publicada en la tienda!`);
}
};
const filtered = products.filter((p) => {
const matchesSearch =
p.name.toLowerCase().includes(search.toLowerCase()) ||
@ -132,34 +307,41 @@ export default function AdminProductsPage() {
return true;
});
const activeCount = products.filter((p) => p.isActive !== false).length;
const inactiveCount = products.filter((p) => p.isActive === false).length;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-black text-white flex items-center gap-2">
<Package className="w-6 h-6 text-cyan-400" />
<span>Catálogo General de Zapatillas & Proveedores ({products.length})</span>
<Package className="w-6 h-6 text-purple-400" />
<span>Catálogo de Publicaciones ({products.length})</span>
</h1>
<p className="text-xs text-slate-400 mt-1">
Visualiza cada modelo con su <strong>proveedor asignado</strong>, costo mayorista y activa/inactiva publicaciones para la tienda.
<p className="text-xs text-neutral-400 mt-1">
Gestión completa (CRUD): Crea, edita precios/tallas, activa/oculta o elimina publicaciones de tu catálogo.
</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
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.5 rounded-xl flex items-center space-x-1.5 shadow-[0_0_15px_rgba(6,182,212,0.3)] cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Agregar Zapatilla con Proveedor</span>
</button>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setIsAiModalOpen(true)}
className="bg-purple-950/80 hover:bg-purple-900 border border-purple-500/50 text-purple-300 font-bold text-xs px-3.5 py-2.5 rounded-xl flex items-center space-x-1.5 shadow-md cursor-pointer transition-all"
>
<Wand2 className="w-4 h-4 text-fuchsia-400" />
<span>Procesar 1 a 1 con IA</span>
</button>
<button
onClick={() => setIsModalOpen(true)}
className="bg-gradient-to-r from-purple-600 to-fuchsia-500 hover:from-purple-500 hover:to-fuchsia-400 text-white font-bold text-xs px-4 py-2.5 rounded-xl flex items-center space-x-1.5 shadow-[0_0_15px_rgba(168,85,247,0.3)] cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>+ Crear Zapatilla Manual</span>
</button>
</div>
</div>
{toastMessage && (
<div className="bg-cyan-950/80 border border-cyan-500 text-cyan-300 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-md">
<div className="bg-purple-950/80 border border-purple-500 text-purple-300 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-md">
<span>{toastMessage}</span>
<button onClick={() => setToastMessage(null)} className="font-bold text-white cursor-pointer ml-3">
✕
@ -169,12 +351,11 @@ export default function AdminProductsPage() {
{/* Filters & Search Toolbar */}
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3">
{/* Status filter pills */}
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl overflow-x-auto">
<div className="flex items-center space-x-1.5 bg-neutral-900 border border-neutral-800 p-1 rounded-2xl overflow-x-auto">
<button
onClick={() => setStatusFilter('all')}
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer ${
statusFilter === 'all' ? 'bg-white text-slate-950 shadow' : 'text-slate-400 hover:text-white'
statusFilter === 'all' ? 'bg-white text-neutral-950 shadow' : 'text-neutral-400 hover:text-white'
}`}
>
Todos ({products.length})
@ -183,58 +364,108 @@ export default function AdminProductsPage() {
onClick={() => setStatusFilter('active')}
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
statusFilter === 'active'
? 'bg-emerald-500/30 border border-emerald-500/50 text-emerald-300 shadow'
: 'text-slate-400 hover:text-emerald-400'
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow'
: 'text-neutral-400 hover:text-emerald-300'
}`}
>
<span className="w-2 h-2 rounded-full bg-emerald-400" />
<span>Activos ({activeCount})</span>
<Eye className="w-3.5 h-3.5" />
<span>Activas</span>
</button>
<button
onClick={() => setStatusFilter('inactive')}
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
statusFilter === 'inactive'
? 'bg-amber-500/30 border border-amber-500/50 text-amber-300 shadow'
: 'text-slate-400 hover:text-amber-400'
? 'bg-neutral-800 text-white border border-neutral-700 shadow'
: 'text-neutral-400 hover:text-white'
}`}
>
<span className="w-2 h-2 rounded-full bg-amber-400" />
<span>Inactivos ({inactiveCount})</span>
<EyeOff className="w-3.5 h-3.5" />
<span>Ocultas</span>
</button>
</div>
{/* Search */}
<div className="max-w-xs w-full">
<input
type="text"
placeholder="Filtrar por modelo, marca o proveedor..."
placeholder="Buscar por modelo, marca o proveedor..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-cyan-400"
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400"
/>
</div>
</div>
{/* Floating Bulk Grouping Toolbar */}
{selectedProductIds.length >= 2 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-neutral-900/95 border-2 border-purple-500 rounded-3xl p-3 px-5 shadow-2xl backdrop-blur-md flex items-center gap-4 animate-in slide-in-from-bottom-5">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-xl bg-purple-600 text-white flex items-center justify-center font-black text-xs">
{selectedProductIds.length}
</div>
<div>
<p className="text-white font-black text-xs leading-tight">
{selectedProductIds.length} Zapatillas Seleccionadas
</p>
<p className="text-[10px] text-neutral-400">
Puedes fusionarlas en 1 solo modelo con múltiples colorways.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedProductIds([])}
className="text-xs text-neutral-400 hover:text-white px-3 py-2 rounded-xl bg-neutral-800 hover:bg-neutral-700 cursor-pointer font-bold"
>
Deseleccionar
</button>
<button
onClick={() => setIsGroupModalOpen(true)}
className="bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:opacity-95 text-white font-black text-xs px-4 py-2 rounded-xl shadow-lg shadow-purple-600/30 flex items-center gap-1.5 cursor-pointer"
>
<Layers className="w-4 h-4" />
<span>Agrupar como Variantes de 1 Modelo</span>
</button>
</div>
</div>
)}
{/* Products Table */}
<div className="bg-slate-900 border border-slate-800 rounded-3xl overflow-hidden shadow-xl">
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl overflow-hidden shadow-xl">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-slate-300">
<thead className="bg-slate-950 text-slate-400 font-bold uppercase text-[10px] border-b border-slate-800">
<table className="w-full text-left text-xs text-neutral-300">
<thead className="bg-neutral-950 text-neutral-400 font-bold uppercase text-[10px] border-b border-neutral-800">
<tr>
<th className="py-3.5 px-4">Estado Publicación</th>
<th className="py-3.5 px-3 text-center w-10">
<input
type="checkbox"
checked={filtered.length > 0 && selectedProductIds.length === filtered.length}
onChange={(e) => {
if (e.target.checked) {
setSelectedProductIds(filtered.map((p) => p.id));
} else {
setSelectedProductIds([]);
}
}}
className="w-4 h-4 rounded-md accent-purple-600 cursor-pointer"
title="Seleccionar todas"
/>
</th>
<th className="py-3.5 px-4">Estado</th>
<th className="py-3.5 px-4">Producto</th>
<th className="py-3.5 px-4">Proveedor Asignado (Interno)</th>
<th className="py-3.5 px-4">Proveedor Asignado</th>
<th className="py-3.5 px-4">Marca</th>
<th className="py-3.5 px-4">Costo Base</th>
<th className="py-3.5 px-4">Precio Venta</th>
<th className="py-3.5 px-4">Margen Ganancia</th>
<th className="py-3.5 px-4">Tallas</th>
<th className="py-3.5 px-4 text-center">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
<tbody className="divide-y divide-neutral-800/60">
{filtered.length === 0 ? (
<tr>
<td colSpan={8} className="text-center py-8 text-slate-500">
<td colSpan={10} className="text-center py-8 text-neutral-500">
No hay zapatillas que coincidan con el filtro seleccionado.
</td>
</tr>
@ -244,24 +475,42 @@ export default function AdminProductsPage() {
const margin = prod.retailPrice - prod.supplierPrice;
const suppName = prod.supplierName || 'DIFUSION JOVIC STORE';
const suppPhone = prod.supplierPhone || '573134848203';
const isSelected = selectedProductIds.includes(prod.id);
const colorwaysCount = prod.colors?.length || 1;
return (
<tr
key={prod.id}
className={`hover:bg-slate-800/50 transition-colors ${
!isActive ? 'opacity-65 bg-slate-950/40' : ''
}`}
className={`hover:bg-neutral-800/50 transition-colors ${
isSelected ? 'bg-purple-950/25 border-l-2 border-l-purple-500' : ''
} ${!isActive ? 'opacity-65 bg-neutral-950/40' : ''}`}
>
{/* Estado Toggle Button */}
{/* Checkbox */}
<td className="py-3.5 px-3 text-center">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
if (e.target.checked) {
setSelectedProductIds((prev) => [...prev, prod.id]);
} else {
setSelectedProductIds((prev) => prev.filter((id) => id !== prod.id));
}
}}
className="w-4 h-4 rounded-md accent-purple-600 cursor-pointer"
/>
</td>
{/* Toggle status */}
<td className="py-3.5 px-4">
<button
onClick={() => handleToggleProductStatus(prod)}
className={`text-[11px] font-bold px-3 py-1.5 rounded-xl border flex items-center gap-1.5 transition-all cursor-pointer ${
className={`text-[11px] font-bold px-2.5 py-1 rounded-xl border flex items-center gap-1.5 transition-all cursor-pointer ${
isActive
? 'bg-emerald-500/20 border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/30'
: 'bg-slate-950 border-slate-700 text-slate-400 hover:text-white'
: 'bg-neutral-950 border-neutral-700 text-neutral-400 hover:text-white'
}`}
title="Haz clic para activar o desactivar esta publicación en la tienda"
title="Activar / Ocultar en la tienda"
>
{isActive ? (
<>
@ -270,30 +519,45 @@ export default function AdminProductsPage() {
</>
) : (
<>
<EyeOff className="w-3.5 h-3.5 text-slate-400" />
<span>Inactivo (Oculto)</span>
<EyeOff className="w-3.5 h-3.5 text-neutral-400" />
<span>Oculto</span>
</>
)}
</button>
</td>
{/* Product Name & Image */}
{/* Image & Title */}
<td className="py-3.5 px-4 flex items-center space-x-3">
<img
src={prod.images[0]}
alt=""
className="w-12 h-12 rounded-xl object-cover bg-slate-950 border border-slate-800 shrink-0"
/>
<div className="relative">
<img
src={prod.images[0]}
alt=""
className="w-12 h-12 rounded-xl object-cover bg-neutral-950 border border-neutral-800 shrink-0"
/>
{colorwaysCount > 1 && (
<span className="absolute -top-1 -right-1 bg-purple-600 text-white text-[9px] font-black px-1.5 py-0.2 rounded-full border border-neutral-900 shadow">
{colorwaysCount}
</span>
)}
</div>
<div>
<p className="font-bold text-white text-xs leading-snug">{prod.name}</p>
<span className="text-[10px] text-slate-400">{prod.category}</span>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-neutral-400">{prod.category}</span>
{colorwaysCount > 1 && (
<span className="text-[10px] text-purple-400 bg-purple-500/10 border border-purple-500/30 px-1.5 py-0.2 rounded font-bold flex items-center gap-0.5">
<Palette className="w-2.5 h-2.5" />
{colorwaysCount} Colores
</span>
)}
</div>
</div>
</td>
{/* Supplier Column */}
{/* Supplier */}
<td className="py-3.5 px-4">
<div className="space-y-1">
<span className="bg-slate-950 text-cyan-300 font-bold px-2 py-0.5 rounded border border-slate-800 text-[11px] block w-fit">
<span className="bg-neutral-950 text-purple-300 font-bold px-2 py-0.5 rounded border border-neutral-800 text-[11px] block w-fit">
🏢 {suppName}
</span>
<button
@ -306,12 +570,12 @@ export default function AdminProductsPage() {
</div>
</td>
<td className="py-3.5 px-4 font-bold text-cyan-400">{prod.brand}</td>
<td className="py-3.5 px-4 font-bold text-purple-400">{prod.brand}</td>
<td className="py-3.5 px-4 font-mono">{formatCOP(prod.supplierPrice)}</td>
<td className="py-3.5 px-4 font-mono font-bold text-white">{formatCOP(prod.retailPrice)}</td>
<td className="py-3.5 px-4">
<span className="bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 font-bold px-2 py-0.5 rounded font-mono">
<span className="bg-purple-500/20 text-purple-300 border border-purple-500/30 font-bold px-2 py-0.5 rounded font-mono">
+{formatCOP(margin)}
</span>
</td>
@ -319,12 +583,32 @@ export default function AdminProductsPage() {
<td className="py-3.5 px-4">
<div className="flex gap-1 flex-wrap max-w-xs">
{prod.sizes.map((s) => (
<span key={s} className="bg-slate-950 text-slate-300 text-[10px] px-1.5 py-0.5 rounded border border-slate-800 font-bold">
<span key={s} className="bg-neutral-950 text-neutral-300 text-[10px] px-1.5 py-0.5 rounded border border-neutral-800 font-bold">
{s}
</span>
))}
</div>
</td>
{/* Action buttons (Edit & Delete) */}
<td className="py-3.5 px-4 text-center">
<div className="flex items-center justify-center space-x-1.5">
<button
onClick={() => handleOpenEdit(prod)}
className="bg-neutral-950 hover:bg-purple-950/50 border border-neutral-800 hover:border-purple-500/50 text-neutral-400 hover:text-purple-300 p-2 rounded-xl transition-all cursor-pointer"
title="Editar publicación (Precios, Tallas, Proveedor)"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteProduct(prod)}
className="bg-neutral-950 hover:bg-red-950/50 border border-neutral-800 hover:border-red-500/50 text-neutral-400 hover:text-red-400 p-2 rounded-xl transition-all cursor-pointer"
title="Eliminar permanentemente del catálogo"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})
@ -334,32 +618,123 @@ export default function AdminProductsPage() {
</div>
</div>
{/* Modal Add Product */}
{/* Modal de Agrupación Manual de Productos */}
<GroupProductsModal
isOpen={isGroupModalOpen}
onClose={() => setIsGroupModalOpen(false)}
selectedProducts={products.filter((p) => selectedProductIds.includes(p.id))}
onGroupSuccess={(groupedProduct) => {
setProducts((prev) => [
groupedProduct,
...prev.filter((p) => !selectedProductIds.includes(p.id) && p.id !== groupedProduct.id),
]);
setSelectedProductIds([]);
setToastMessage(`¡Se unificaron ${selectedProductIds.length} productos con éxito en "${groupedProduct.name}"!`);
}}
/>
{/* Modal 1: Process Single Message with AI (1 a 1) */}
{isAiModalOpen && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
<h3 className="font-bold text-white text-base flex items-center gap-2">
<Wand2 className="w-5 h-5 text-fuchsia-400" />
<span>Procesar Publicación 1 a 1 con IA</span>
</h3>
<button onClick={() => setIsAiModalOpen(false)} className="text-neutral-400 hover:text-white cursor-pointer">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-3 text-xs">
<div>
<label className="block text-neutral-300 font-bold mb-1">
Pega el Texto del Proveedor / Grupo de WhatsApp *
</label>
<textarea
rows={4}
placeholder="Ej: 🔥 LLEGANDO HOY 🔥&#10;Nike Air Jordan 4 Retro White Cement&#10;Tallas: 38 a 43&#10;Mayorista: $95.000 c/u"
value={rawText}
onChange={(e) => setRawText(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl p-3 text-white outline-hidden focus:border-purple-400 font-mono text-xs"
/>
</div>
<div>
<label className="block text-neutral-300 font-bold mb-1">URL de la Foto / Imagen</label>
<input
type="text"
placeholder="https://images.unsplash.com/..."
value={rawImageUrl}
onChange={(e) => setRawImageUrl(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-400"
/>
</div>
<button
type="button"
onClick={handleProcessSinglePostWithAI}
className="w-full bg-purple-600 hover:bg-purple-500 text-white font-bold py-2.5 rounded-xl shadow-md flex items-center justify-center gap-2 cursor-pointer transition-all"
>
<Sparkles className="w-4 h-4 text-fuchsia-300" />
<span>Extraer Título, Tallas & Calcular Margen 35% con IA</span>
</button>
{aiExtracted && (
<div className="bg-neutral-950 border border-purple-500/40 rounded-2xl p-4 space-y-2 animate-in zoom-in-95">
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold text-purple-400">✨ Resultado Extraído por IA</span>
<span className="bg-emerald-500/20 text-emerald-300 font-bold px-2 py-0.5 rounded text-[10px]">Listo para Publicar</span>
</div>
<p className="text-white font-bold text-sm">{aiExtracted.name}</p>
<div className="grid grid-cols-2 gap-2 text-[11px] text-neutral-300">
<p><strong>Marca:</strong> {aiExtracted.brand}</p>
<p><strong>Tallas:</strong> {aiExtracted.sizes?.join(', ')}</p>
<p><strong>Costo Mayorista:</strong> {formatCOP(aiExtracted.supplierPrice || 0)}</p>
<p className="text-emerald-400"><strong>Precio Venta (35%):</strong> {formatCOP(aiExtracted.retailPrice || 0)}</p>
</div>
<button
type="button"
onClick={handlePublishAiProduct}
className="w-full bg-gradient-to-r from-emerald-600 to-teal-500 hover:from-emerald-500 hover:to-teal-400 text-white font-black py-2.5 rounded-xl shadow-lg flex items-center justify-center gap-2 cursor-pointer mt-2"
>
<span>Publicar Inmediatamente en la Tienda</span>
<ArrowRight className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
</div>
)}
{/* Modal 2: Add Product Manually */}
{isModalOpen && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
<h3 className="font-bold text-white text-base">Registrar Nueva Zapatilla</h3>
<form onSubmit={handleCreateProduct} className="space-y-3 text-xs">
<div>
<label className="block text-slate-400 font-bold mb-1">Nombre del Modelo *</label>
<label className="block text-neutral-400 font-bold mb-1">Nombre del Modelo *</label>
<input
type="text"
required
placeholder="Ej. Nike Air Jordan 1 High Chicago"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
{/* Supplier Selection */}
<div>
<label className="block text-cyan-400 font-bold mb-1">Proveedor Asignado (Interno) *</label>
<label className="block text-purple-400 font-bold mb-1">Proveedor Asignado (Interno) *</label>
<select
value={selectedSupplierId}
onChange={(e) => setSelectedSupplierId(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden font-semibold"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden font-semibold"
>
{suppliers.map((s) => (
<option key={s.id} value={s.id}>
@ -367,18 +742,15 @@ export default function AdminProductsPage() {
</option>
))}
</select>
<p className="text-[10px] text-slate-500 mt-0.5">
Este proveedor recibirá la notificación para separar el par cuando se haga un pedido.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-slate-400 font-bold mb-1">Marca</label>
<label className="block text-neutral-400 font-bold mb-1">Marca</label>
<select
value={brand}
onChange={(e) => setBrand(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
>
<option value="Nike">Nike</option>
<option value="Jordan">Jordan</option>
@ -389,81 +761,73 @@ export default function AdminProductsPage() {
</div>
<div>
<label className="block text-slate-400 font-bold mb-1">Categoría</label>
<label className="block text-neutral-400 font-bold mb-1">Categoría</label>
<input
type="text"
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-slate-400 font-bold mb-1">Costo Base Mayorista (COP)</label>
<label className="block text-neutral-400 font-bold mb-1">Costo Proveedor ($ COP) *</label>
<input
type="number"
required
value={supplierPrice}
onChange={(e) => setSupplierPrice(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div>
<label className="block text-slate-400 font-bold mb-1">Precio Venta Sugerido (COP)</label>
<label className="block text-neutral-400 font-bold mb-1">Precio Venta Público ($ COP) *</label>
<input
type="number"
required
value={retailPrice}
onChange={(e) => setRetailPrice(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden font-bold text-purple-400"
/>
</div>
</div>
<div>
<label className="block text-slate-400 font-bold mb-1">Tallas Disponibles (separadas por coma)</label>
<label className="block text-neutral-400 font-bold mb-1">Tallas Disponibles (separadas por coma)</label>
<input
type="text"
required
value={sizesInput}
onChange={(e) => setSizesInput(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div>
<label className="block text-slate-400 font-bold mb-1">URL de Imagen Principal</label>
<label className="block text-neutral-400 font-bold mb-1">URL Imagen del Producto</label>
<input
type="url"
type="text"
required
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div className="pt-2">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={isActiveNew}
onChange={(e) => setIsActiveNew(e.target.checked)}
className="rounded text-cyan-500 focus:ring-0"
/>
<span className="text-slate-200 font-bold">Publicar inmediatamente en la tienda (Activo)</span>
</label>
</div>
<div className="flex justify-end space-x-2 pt-3 border-t border-slate-800">
<div className="flex justify-end space-x-2 pt-3 border-t border-neutral-800">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="bg-slate-800 hover:bg-slate-700 text-slate-300 font-bold py-2 px-4 rounded-xl cursor-pointer"
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold py-2 px-4 rounded-xl cursor-pointer"
>
Cancelar
</button>
<button
type="submit"
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold py-2 px-4 rounded-xl shadow-md cursor-pointer"
className="bg-gradient-to-r from-purple-600 to-fuchsia-500 hover:from-purple-500 hover:to-fuchsia-400 text-white font-bold py-2 px-4 rounded-xl shadow-md cursor-pointer"
>
Guardar Zapatilla
</button>
@ -472,6 +836,135 @@ export default function AdminProductsPage() {
</div>
</div>
)}
{/* Modal 3: Edit Existing Product (PUT) */}
{editingProduct && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
<h3 className="font-bold text-white text-base flex items-center gap-2">
<Edit className="w-5 h-5 text-purple-400" />
<span>Editar Publicación: {editingProduct.name}</span>
</h3>
<button onClick={() => setEditingProduct(null)} className="text-neutral-400 hover:text-white cursor-pointer">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSaveEdit} className="space-y-3 text-xs">
<div>
<label className="block text-neutral-400 font-bold mb-1">Nombre del Modelo *</label>
<input
type="text"
required
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 outline-hidden focus:border-purple-400"
/>
</div>
<div>
<label className="block text-purple-400 font-bold mb-1">Proveedor Asignado *</label>
<select
value={editSupplierId}
onChange={(e) => setEditSupplierId(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden font-semibold"
>
{suppliers.map((s) => (
<option key={s.id} value={s.id}>
{s.name} — WhatsApp: {s.phone}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-neutral-400 font-bold 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-400"
/>
</div>
<div>
<label className="block text-neutral-400 font-bold mb-1">Categoría</label>
<input
type="text"
value={editCategory}
onChange={(e) => setEditCategory(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-400"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-neutral-400 font-bold mb-1">Costo Proveedor ($ COP) *</label>
<input
type="number"
required
value={editSupplierPrice}
onChange={(e) => setEditSupplierPrice(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div>
<label className="block text-neutral-400 font-bold mb-1">Precio Venta Público ($ COP) *</label>
<input
type="number"
required
value={editRetailPrice}
onChange={(e) => setEditRetailPrice(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden font-bold text-purple-400"
/>
</div>
</div>
<div>
<label className="block text-neutral-400 font-bold mb-1">Tallas Disponibles</label>
<input
type="text"
required
value={editSizesInput}
onChange={(e) => setEditSizesInput(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div>
<label className="block text-neutral-400 font-bold mb-1">URL Imagen</label>
<input
type="text"
required
value={editImageUrl}
onChange={(e) => setEditImageUrl(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div className="flex justify-end space-x-2 pt-3 border-t border-neutral-800">
<button
type="button"
onClick={() => setEditingProduct(null)}
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold py-2 px-4 rounded-xl cursor-pointer"
>
Cancelar
</button>
<button
type="submit"
className="bg-gradient-to-r from-purple-600 to-fuchsia-500 hover:from-purple-500 hover:to-fuchsia-400 text-white font-bold py-2 px-4 rounded-xl shadow-md cursor-pointer"
>
Guardar Cambios
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -17,6 +17,85 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const body = await request.json();
// ACCIÓN MANUAL: Agrupar múltiples productos como variantes de un solo modelo
if (body.action === 'group-products') {
const { targetProductId, productIdsToMerge, customModelName, customBrand } = body;
const allProducts = dataService.getProducts();
const target = allProducts.find((p) => p.id === targetProductId);
if (!target) {
return NextResponse.json({ success: false, error: 'Producto objetivo no encontrado' }, { status: 404 });
}
const colors =
target.colors && target.colors.length > 0
? [...target.colors]
: [
{
id: `color-${target.id}-1`,
colorName: target.colorName || 'Estilo Principal',
image: target.images[0],
images: target.images,
sizes: target.sizes,
inStock: target.inStock !== false,
price: target.retailPrice,
sizePrices: target.sizePrices,
},
];
for (const mergeId of productIdsToMerge || []) {
if (mergeId === targetProductId) continue;
const toMerge = allProducts.find((p) => p.id === mergeId);
if (!toMerge) continue;
const mergeColors =
toMerge.colors && toMerge.colors.length > 0
? toMerge.colors
: [
{
id: `color-${toMerge.id}`,
colorName: toMerge.colorName || toMerge.name || `Estilo #${colors.length + 1}`,
image: toMerge.images[0],
images: toMerge.images,
sizes: toMerge.sizes,
inStock: toMerge.inStock !== false,
price: toMerge.retailPrice,
sizePrices: toMerge.sizePrices,
},
];
mergeColors.forEach((mc) => {
if (!colors.some((c) => c.image === mc.image)) {
colors.push(mc);
}
});
target.images = Array.from(new Set([...target.images, ...toMerge.images]));
target.sizes = Array.from(new Set([...target.sizes, ...toMerge.sizes])).sort(
(a, b) => parseInt(a, 10) - parseInt(b, 10)
);
// Eliminar el producto que fue fusionado
dataService.deleteProduct(toMerge.id);
}
target.colors = colors;
if (customModelName) {
target.name = customModelName;
target.modelName = customModelName;
}
if (customBrand) {
target.brand = customBrand;
}
dataService.updateProduct(target.id, target);
return NextResponse.json({
success: true,
product: target,
message: `¡Se agruparon ${productIdsToMerge.length} productos con éxito en "${target.name}"!`,
});
}
const proof = getProductSocialProof(body);
const newProduct = dataService.addProduct({
...body,

View File

@ -203,7 +203,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const body = await request.json();
const { action, post, posts, customRetailPrice, customName, customSizes } = body;
const { action, post, posts, customRetailPrice, customName, customColorName, customSizes } = body;
// 1. PUBLICAR UN PRODUCTO A LA TIENDA (APP MÓVIL Y WEB)
if (action === 'publish' && post) {
@ -220,6 +220,7 @@ export async function POST(request: Request) {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName,
modelName: prodName,
colorName: customColorName || post.extractedProduct?.colorName,
brand: prodBrand,
supplierPrice: post.extractedProduct?.supplierPrice || 75000,
retailPrice: retailPrice,

View File

@ -0,0 +1,263 @@
'use client';
import React, { useState } from 'react';
import { Layers, X, Check, Palette, Sparkles, ArrowRight, ShieldCheck } from 'lucide-react';
import { Product, ProductColorVariant } from '@/types';
import { formatCOP } from '@/lib/pricing-engine';
interface GroupProductsModalProps {
isOpen: boolean;
onClose: () => void;
selectedProducts: Product[];
onGroupSuccess: (groupedProduct: Product) => void;
}
export const GroupProductsModal: React.FC<GroupProductsModalProps> = ({
isOpen,
onClose,
selectedProducts,
onGroupSuccess,
}) => {
if (!isOpen || selectedProducts.length < 2) return null;
// Elegir el producto principal por defecto (el primero seleccionado)
const [masterId, setMasterId] = useState<string>(selectedProducts[0].id);
const masterProduct = selectedProducts.find((p) => p.id === masterId) || selectedProducts[0];
const [masterName, setMasterName] = useState(masterProduct.modelName || masterProduct.name);
const [masterBrand, setMasterBrand] = useState(masterProduct.brand);
const [masterPrice, setMasterPrice] = useState(masterProduct.retailPrice);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Nombres y precios personalizados para cada variante de color
const [variantConfigs, setVariantConfigs] = useState<
Record<string, { colorName: string; price: number; sizes: string }>
>(() => {
const initial: Record<string, { colorName: string; price: number; sizes: string }> = {};
selectedProducts.forEach((p, idx) => {
initial[p.id] = {
colorName: p.colorName || p.name.replace(p.brand, '').trim() || `Estilo #${idx + 1}`,
price: p.retailPrice,
sizes: p.sizes.join(', '),
};
});
return initial;
});
const handleVariantChange = (
id: string,
field: 'colorName' | 'price' | 'sizes',
value: string | number
) => {
setVariantConfigs((prev) => ({
...prev,
[id]: {
...prev[id],
[field]: value,
},
}));
};
const handleConfirmGroup = async () => {
setIsSubmitting(true);
setErrorMessage(null);
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'group-products',
targetProductId: masterId,
productIdsToMerge: selectedProducts.map((p) => p.id),
customModelName: masterName,
customBrand: masterBrand,
}),
});
const data = await res.json();
if (data.success && data.product) {
onGroupSuccess(data.product);
onClose();
} else {
setErrorMessage(data.error || 'Error al agrupar productos');
}
} catch (err: any) {
setErrorMessage(err.message || 'Error de conexión');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 bg-black/85 backdrop-blur-md flex items-center justify-center p-4 overflow-y-auto animate-in fade-in">
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-2xl w-full p-6 space-y-5 shadow-2xl my-auto">
{/* Header */}
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-xl bg-purple-500/20 text-purple-400 flex items-center justify-center">
<Layers className="w-5 h-5" />
</div>
<div>
<h3 className="font-black text-white text-base">
Agrupar {selectedProducts.length} Zapatillas como Variantes de un Modelo
</h3>
<p className="text-neutral-400 text-xs">
Se unificarán en 1 sola publicación con selector interactivo de colorways.
</p>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white flex items-center justify-center cursor-pointer transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{errorMessage && (
<div className="bg-red-950/40 border border-red-500/40 rounded-xl p-3 text-red-300 text-xs">
{errorMessage}
</div>
)}
{/* Modelo Principal */}
<div className="bg-neutral-950 p-4 rounded-2xl border border-neutral-800 space-y-3">
<span className="text-[11px] font-black text-purple-400 uppercase tracking-wider block">
1. Datos Generales de la Publicación Unificada
</span>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
<div className="sm:col-span-2">
<label className="font-bold text-neutral-300 block mb-1">Nombre del Modelo Base:</label>
<input
type="text"
value={masterName}
onChange={(e) => setMasterName(e.target.value)}
placeholder="Ej: Adidas Samba OG"
className="w-full bg-neutral-900 border border-neutral-700 rounded-xl px-3 py-2 text-white font-bold outline-hidden focus:border-purple-500"
/>
</div>
<div>
<label className="font-bold text-neutral-300 block mb-1">Marca:</label>
<input
type="text"
value={masterBrand}
onChange={(e) => setMasterBrand(e.target.value)}
className="w-full bg-neutral-900 border border-neutral-700 rounded-xl px-3 py-2 text-white font-bold outline-hidden focus:border-purple-500"
/>
</div>
</div>
</div>
{/* Lista de Variantes / Estilos */}
<div className="space-y-2.5">
<span className="text-[11px] font-black text-purple-400 uppercase tracking-wider block">
2. Configuración de Cada Variante de Color ({selectedProducts.length})
</span>
<div className="max-h-64 overflow-y-auto space-y-2.5 pr-1">
{selectedProducts.map((prod, idx) => {
const isMaster = prod.id === masterId;
const cfg = variantConfigs[prod.id] || {
colorName: `Estilo #${idx + 1}`,
price: prod.retailPrice,
sizes: prod.sizes.join(', '),
};
return (
<div
key={prod.id}
className={`p-3 rounded-2xl border transition-all flex flex-col sm:flex-row items-center gap-3 ${
isMaster
? 'bg-purple-950/20 border-purple-500/60 ring-1 ring-purple-500/40'
: 'bg-neutral-950/60 border-neutral-800 hover:border-neutral-700'
}`}
>
<img
src={prod.images[0]}
alt=""
className="w-14 h-14 rounded-xl object-cover bg-neutral-900 border border-neutral-700 shrink-0"
/>
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-2 w-full text-xs">
<div>
<label className="text-[10px] text-neutral-400 font-bold block mb-0.5">
Nombre del Estilo/Color:
</label>
<input
type="text"
value={cfg.colorName}
onChange={(e) => handleVariantChange(prod.id, 'colorName', e.target.value)}
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-2 py-1.5 text-white font-semibold outline-hidden focus:border-purple-500 text-xs"
/>
</div>
<div>
<label className="text-[10px] text-emerald-400 font-bold block mb-0.5">
Precio Venta:
</label>
<input
type="number"
value={cfg.price}
onChange={(e) =>
handleVariantChange(prod.id, 'price', Number(e.target.value))
}
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-2 py-1.5 text-emerald-300 font-mono font-bold outline-hidden focus:border-emerald-500 text-xs"
/>
</div>
<div>
<label className="text-[10px] text-neutral-400 font-bold block mb-0.5">
Tallas:
</label>
<input
type="text"
value={cfg.sizes}
onChange={(e) => handleVariantChange(prod.id, 'sizes', e.target.value)}
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-2 py-1.5 text-white outline-hidden focus:border-purple-500 text-xs"
/>
</div>
</div>
<button
type="button"
onClick={() => setMasterId(prod.id)}
className={`text-[10px] font-bold px-2.5 py-1.5 rounded-lg border shrink-0 cursor-pointer ${
isMaster
? 'bg-purple-600 text-white border-purple-400'
: 'bg-neutral-900 text-neutral-400 border-neutral-700 hover:text-white'
}`}
>
{isMaster ? '★ Principal' : 'Hacer Principal'}
</button>
</div>
);
})}
</div>
</div>
{/* Footer */}
<div className="flex items-center gap-3 pt-3 border-t border-neutral-800">
<button
onClick={onClose}
disabled={isSubmitting}
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={handleConfirmGroup}
disabled={isSubmitting}
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 flex items-center justify-center gap-2"
>
<Check className="w-4 h-4" />
<span>{isSubmitting ? 'Agrupando modelos...' : 'Confirmar y Fusionar en 1 Modelo'}</span>
</button>
</div>
</div>
</div>
);
};

View File

@ -3,7 +3,7 @@
import React, { useState } from 'react';
import { Star, Zap, Truck, ShoppingBag, Layers, Palette, Sparkles } from 'lucide-react';
import { Product } from '@/types';
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
import { formatCOP, calculateSeparationBreakdown, getProductPriceRange } from '@/lib/pricing-engine';
import { useBrand } from '@/context/BrandContext';
import { getProductSocialProof } from '@/lib/social-proof';
@ -21,6 +21,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
const [activeImage, setActiveImage] = useState<string>(product.images[0]);
const [activeColorName, setActiveColorName] = useState<string>(product.colorName || '');
const socialProof = getProductSocialProof(product);
const priceRange = getProductPriceRange(product);
const discountPercent = product.originalPrice
? Math.round(((product.originalPrice - product.retailPrice) / product.originalPrice) * 100)
@ -145,7 +146,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
<div className="pt-2 border-t border-slate-800">
<div className="flex items-baseline space-x-2">
<span className={`text-lg sm:text-xl font-black text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>
{formatCOP(product.retailPrice)}
{priceRange.hasVariation ? `Desde ${formatCOP(priceRange.minPrice)}` : formatCOP(product.retailPrice)}
</span>
{product.originalPrice && (
<span className="text-xs text-slate-500 line-through font-semibold">
@ -155,7 +156,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
</div>
<p className="text-[11px] font-semibold bg-white/5 px-2 py-1 rounded-md mt-1.5 border border-white/10 text-slate-300">
⚡ Separa con <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y cancelas <strong className="text-white">{formatCOP(separation.balanceDue)}</strong> al recibir.
⚡ Separa con <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y cancelas al recibir.
</p>
{/* Quick Action Button */}

View File

@ -17,7 +17,7 @@ import {
ArrowRight,
} from 'lucide-react';
import { Product, City, ProductColorVariant } from '@/types';
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
import { formatCOP, calculateSeparationBreakdown, getProductEffectivePrice } from '@/lib/pricing-engine';
import { useBrand } from '@/context/BrandContext';
import { getProductSocialProof } from '@/lib/social-proof';
@ -75,8 +75,10 @@ export const ProductModal: React.FC<ProductModalProps> = ({
const isSizeInStock = availableSizes.includes(selectedSize);
// Precio dinámico efectivo según colorway y talla seleccionada
const currentPrice = getProductEffectivePrice(product, selectedColor, selectedSize);
const socialProof = getProductSocialProof(product);
const separation = calculateSeparationBreakdown(product.retailPrice);
const separation = calculateSeparationBreakdown(currentPrice);
const handleAdd = () => {
if (!isSizeInStock) return;
@ -84,6 +86,7 @@ export const ProductModal: React.FC<ProductModalProps> = ({
{
...product,
name: `${product.modelName || product.name} (${selectedColor.colorName})`,
retailPrice: currentPrice,
images: selectedColor.images || [selectedImage],
},
selectedSize
@ -102,7 +105,7 @@ export const ProductModal: React.FC<ProductModalProps> = ({
👟 *${product.modelName || product.name}*
🎨 Color: *${selectedColor.colorName}*
📏 Talla: *${selectedSize}* (Horma Nacional)
💎 Precio: *${formatCOP(product.retailPrice)} COP*
💎 Precio: *${formatCOP(currentPrice)} COP*
🚚 Entrega en: *${selectedCity.name}* (Pago Contraentrega)
⚡ Deseo separar con $10.000 COP y cancelar el saldo en mi casa.
@ -225,7 +228,7 @@ export const ProductModal: React.FC<ProductModalProps> = ({
<div className="bg-neutral-950/80 border border-neutral-800 rounded-2xl p-3.5 space-y-2">
<div className="flex items-baseline space-x-3">
<span className={`text-2xl sm:text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>
{formatCOP(product.retailPrice)}
{formatCOP(currentPrice)}
</span>
{product.originalPrice && (
<span className="text-sm text-neutral-500 line-through font-bold">
@ -313,12 +316,14 @@ export const ProductModal: React.FC<ProductModalProps> = ({
{displaySizes.map((size) => {
const inStock = availableSizes.includes(size);
const isSelected = selectedSize === size;
const sizePrice = getProductEffectivePrice(product, selectedColor, size);
const hasCustomSizePrice = sizePrice !== product.retailPrice;
return (
<button
key={size}
onClick={() => setSelectedSize(size)}
className={`relative py-2.5 rounded-xl text-xs font-black transition-all cursor-pointer flex flex-col items-center justify-center ${
className={`relative py-2 rounded-xl text-xs font-black transition-all cursor-pointer flex flex-col items-center justify-center ${
isSelected
? inStock
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-lg scale-105 border border-white/30`
@ -329,8 +334,8 @@ export const ProductModal: React.FC<ProductModalProps> = ({
}`}
>
<span>{size}</span>
<span className="text-[8px] font-mono leading-none mt-0.5">
{inStock ? 'Listo' : 'Pedir'}
<span className="text-[8px] font-mono leading-none mt-0.5 opacity-80">
{hasCustomSizePrice ? `$${Math.round(sizePrice / 1000)}k` : inStock ? 'Listo' : 'Pedir'}
</span>
</button>
);

View File

@ -19,46 +19,23 @@ export function formatCOP(amount: number): string {
return '$' + Math.round(amount).toLocaleString('es-CO');
}
/**
* Redondea cualquier valor hacia arriba al siguiente múltiplo de $5.000 COP
* Ejemplos:
* 132.000 -> 135.000
* 121.500 -> 125.000
* 130.000 -> 130.000
* 130.001 -> 135.000
*/
export function roundUpToNearest5000(amount: number): number {
if (!amount || amount <= 0) return 0;
return Math.ceil(amount / ROUND_STEP_COP) * ROUND_STEP_COP;
}
/**
* Calcula el precio de venta sugerido asegurando el % mínimo de ganancia
* y redondeando al $5.000 superior para proteger el margen.
*/
export function calculateRetailPrice(
supplierPrice: number,
minMarginPercent: number = DEFAULT_MIN_PROFIT_MARGIN_PERCENT,
minMarginCop: number = DEFAULT_MIN_PROFIT_COP
): number {
if (!supplierPrice || supplierPrice <= 0) return 0;
// 1. Ganancia calculada por porcentaje
const priceByPercent = supplierPrice * (1 + minMarginPercent / 100);
// 2. Ganancia calculada por monto fijo mínimo (para calzado de costo bajo)
const priceByFixed = supplierPrice + minMarginCop;
// 3. Tomamos el precio más seguro para nunca perforar el margen mínimo
const baseTargetPrice = Math.max(priceByPercent, priceByFixed);
// 4. Redondeo automático al 5.000 siguiente
return roundUpToNearest5000(baseTargetPrice);
}
/**
* Obtiene el desglose financiero exacto de la publicación
*/
export function getPricingBreakdown(supplierCost: number, minMarginPercent: number = DEFAULT_MIN_PROFIT_MARGIN_PERCENT) {
const retailPrice = calculateRetailPrice(supplierCost, minMarginPercent);
const grossProfit = retailPrice - supplierCost;
@ -135,3 +112,66 @@ export function generateSalesCopy(params: {
📲 ¡Pídelas antes de que se agote tu talla!
👉 Pedir directo por WhatsApp: ${waLink}`;
}
/**
* Obtiene el precio exacto de venta de un producto según el estilo/color y la talla seleccionada.
*/
export function getProductEffectivePrice(
product: {
retailPrice: number;
sizePrices?: Record<string, number>;
},
selectedColor?: {
price?: number;
sizePrices?: Record<string, number>;
} | null,
selectedSize?: string | null
): number {
if (selectedColor && selectedSize && selectedColor.sizePrices && selectedColor.sizePrices[selectedSize]) {
return selectedColor.sizePrices[selectedSize];
}
if (selectedSize && product.sizePrices && product.sizePrices[selectedSize]) {
return product.sizePrices[selectedSize];
}
if (selectedColor && selectedColor.price && selectedColor.price > 0) {
return selectedColor.price;
}
return product.retailPrice || 120000;
}
/**
* Obtiene el rango de precios si varían según la talla o colorway
*/
export function getProductPriceRange(product: {
retailPrice: number;
sizePrices?: Record<string, number>;
colors?: Array<{ price?: number; sizePrices?: Record<string, number> }>;
}): { minPrice: number; maxPrice: number; hasVariation: boolean } {
const prices: number[] = [product.retailPrice || 120000];
if (product.sizePrices) {
Object.values(product.sizePrices).forEach((p) => {
if (typeof p === 'number' && p > 0) prices.push(p);
});
}
if (Array.isArray(product.colors)) {
product.colors.forEach((c) => {
if (c.price && c.price > 0) prices.push(c.price);
if (c.sizePrices) {
Object.values(c.sizePrices).forEach((p) => {
if (typeof p === 'number' && p > 0) prices.push(p);
});
}
});
}
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
return {
minPrice,
maxPrice,
hasVariation: minPrice !== maxPrice,
};
}

View File

@ -40,6 +40,8 @@ export interface ProductColorVariant {
images?: string[];
sizes: string[];
inStock?: boolean;
price?: number; // Precio específico de esta variante
sizePrices?: Record<string, number>; // Precios específicos por talla (ej: { "36": 110000, "42": 125000 })
}
export interface Product {
@ -53,6 +55,7 @@ export interface Product {
originalPrice?: number;
sizes: string[];
allSizes?: string[];
sizePrices?: Record<string, number>; // Precios variables por talla a nivel general
images: string[];
colors?: ProductColorVariant[];
description: string;
@ -149,6 +152,7 @@ export interface SupplierPost {
export interface ExtractedProductData {
name: string;
brand: string;
colorName?: string;
sizes: string[];
supplierPrice: number;
suggestedRetailPrice: number;

View File

@ -28,10 +28,12 @@ import {
import { SupplierPost, Product, MonitoredWhatsAppGroup } from '@/types';
import { formatCOP } from '@/lib/pricing-engine';
import { BRANDS, BrandId } from '@/config/brands';
import { normalizeModelKey } from '@/lib/model-grouper';
export default function SupplierFeedPage() {
const [posts, setPosts] = useState<SupplierPost[]>([]);
const [monitoredGroups, setMonitoredGroups] = useState<MonitoredWhatsAppGroup[]>([]);
const [existingProducts, setExistingProducts] = useState<Product[]>([]);
const [selectedGroupFilter, setSelectedGroupFilter] = useState<string>('all');
const [tabStatus, setTabStatus] = useState<'pending' | 'published' | 'discarded'>('pending');
const [isLoading, setIsLoading] = useState(false);
@ -45,13 +47,15 @@ export default function SupplierFeedPage() {
const [editingPost, setEditingPost] = useState<SupplierPost | null>(null);
const [editName, setEditName] = useState('');
const [editBrand, setEditBrand] = useState('');
const [editColorName, setEditColorName] = useState('');
const [editPrice, setEditPrice] = useState(120000);
const [editSizes, setEditSizes] = useState('');
const [mergeWithExisting, setMergeWithExisting] = useState(true);
// Target brand selector for publishing
const [targetBrand, setTargetBrand] = useState<BrandId | 'all'>('almeira');
// Cargar grupos monitoreados y posts iniciales en vivo
// Cargar grupos monitoreados, productos existentes y posts en vivo
const loadInitialData = async () => {
setIsLoading(true);
try {
@ -62,7 +66,14 @@ export default function SupplierFeedPage() {
setMonitoredGroups(grpData.groups);
}
// 2. Posts en vivo desde los grupos de WhatsApp
// 2. Productos existentes en el catálogo
const prodRes = await fetch('/api/products?all=true');
const prodData = await prodRes.json();
if (prodData.success && Array.isArray(prodData.products)) {
setExistingProducts(prodData.products);
}
// 3. Posts en vivo desde los grupos de WhatsApp
const feedRes = await fetch('/api/whatsapp-feed?fetchLive=true&limit=50');
const feedData = await feedRes.json();
if (feedData.success && Array.isArray(feedData.posts)) {
@ -136,7 +147,7 @@ export default function SupplierFeedPage() {
// Publicar un producto a la tienda
const handlePublishPost = async (
post: SupplierPost,
customData?: { name?: string; brand?: string; retailPrice?: number; sizes?: string[] }
customData?: { name?: string; brand?: string; colorName?: string; retailPrice?: number; sizes?: string[] }
) => {
try {
const res = await fetch('/api/whatsapp-feed', {
@ -146,6 +157,7 @@ export default function SupplierFeedPage() {
action: 'publish',
post,
customName: customData?.name,
customColorName: customData?.colorName,
customRetailPrice: customData?.retailPrice,
customSizes: customData?.sizes,
}),
@ -251,13 +263,17 @@ export default function SupplierFeedPage() {
const handleOpenEdit = (post: SupplierPost) => {
setEditingPost(post);
setEditName(post.extractedProduct?.name || 'Zapatilla Urbana');
setEditBrand(post.extractedProduct?.brand || 'Nike');
const prodName = post.extractedProduct?.name || 'Zapatilla Urbana';
const prodBrand = post.extractedProduct?.brand || 'Nike';
setEditName(prodName);
setEditBrand(prodBrand);
setEditColorName(post.extractedProduct?.colorName || '');
setEditPrice(
post.extractedProduct?.suggestedRetailPrice ||
(post.extractedProduct?.supplierPrice ? post.extractedProduct.supplierPrice + 35000 : 125000)
);
setEditSizes(post.extractedProduct?.sizes?.join(', ') || '38, 39, 40, 41, 42, 43');
setMergeWithExisting(true);
};
return (
@ -308,7 +324,7 @@ export default function SupplierFeedPage() {
</div>
<button
onClick={() => setActionMessage(null)}
className="text-white hover:text-purple-300 font-bold px-2 cursor-pointer"
className="text-purple-400 hover:text-white font-bold"
>
✕
</button>
@ -619,96 +635,168 @@ export default function SupplierFeedPage() {
)}
{/* 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>
{editingPost && (() => {
const matchingProduct = existingProducts.find(
(p) => normalizeModelKey(p.modelName || p.name, p.brand) === normalizeModelKey(editName, editBrand)
);
<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"
/>
return (
<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 y Validar antes de Publicar</span>
</h3>
<button
onClick={() => setEditingPost(null)}
className="text-neutral-400 hover:text-white font-bold"
>
✕
</button>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Pre-validación de Modelo Existente */}
{matchingProduct && (
<div className="bg-purple-950/40 border border-purple-500/50 rounded-2xl p-3.5 space-y-2.5">
<div className="flex items-center gap-2 text-purple-300 font-bold text-xs">
<Sparkles className="w-4 h-4 text-purple-400" />
<span>¡Modelo Existente Detectado en la Tienda!</span>
</div>
<div className="flex items-center justify-between bg-neutral-950 p-2.5 rounded-xl border border-neutral-800">
<div className="flex items-center gap-2.5">
<img
src={matchingProduct.images[0]}
className="w-10 h-10 rounded-lg object-cover bg-neutral-900 border border-neutral-800"
alt=""
/>
<div>
<p className="font-black text-white text-xs">{matchingProduct.name}</p>
<p className="text-[10px] text-neutral-400">
{matchingProduct.colors?.length || 1} variantes de color existentes
</p>
</div>
</div>
<span className="text-[10px] bg-purple-600/30 text-purple-300 border border-purple-500/40 font-bold px-2 py-0.5 rounded-md">
Coincidencia
</span>
</div>
<div className="flex flex-col gap-1.5 text-xs">
<label className="flex items-center gap-2 text-neutral-200 cursor-pointer font-semibold">
<input
type="radio"
name="mergeOption"
checked={mergeWithExisting}
onChange={() => setMergeWithExisting(true)}
className="accent-purple-600"
/>
<span>Agregar como nueva variante de color a "{matchingProduct.name}"</span>
</label>
<label className="flex items-center gap-2 text-neutral-400 hover:text-neutral-200 cursor-pointer">
<input
type="radio"
name="mergeOption"
checked={!mergeWithExisting}
onChange={() => setMergeWithExisting(false)}
className="accent-purple-600"
/>
<span>Publicar como un modelo nuevo independiente</span>
</label>
</div>
</div>
)}
<div className="space-y-3.5 text-xs">
<div>
<label className="font-bold text-neutral-300 block mb-1">Marca:</label>
<label className="font-bold text-neutral-300 block mb-1">Nombre Comercial del Modelo:</label>
<input
type="text"
value={editBrand}
onChange={(e) => setEditBrand(e.target.value)}
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>
{matchingProduct && mergeWithExisting && (
<div>
<label className="font-bold text-purple-400 block mb-1">Nombre de este Estilo / Colorway:</label>
<input
type="text"
value={editColorName}
onChange={(e) => setEditColorName(e.target.value)}
placeholder="Ej: Blanco / Negro, Gum Edition, Rosa Pastel"
className="w-full bg-neutral-950 border border-purple-500/50 rounded-xl px-3 py-2 text-white font-semibold 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>
<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 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: !mergeWithExisting && matchingProduct ? `${editName} (${editColorName || 'Variante'})` : editName,
colorName: editColorName,
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"
>
{matchingProduct && mergeWithExisting ? 'Fusionar y Publicar en App' : 'Guardar y Publicar en App'}
</button>
</div>
</div>
<div className="flex items-center gap-2.5 pt-3 border-t border-neutral-800">
<button
onClick={() => setEditingPost(null)}
className="flex-1 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold text-xs py-3 rounded-xl cursor-pointer"
>
Cancelar
</button>
<button
onClick={() => {
const sizesArray = editSizes
.split(',')
.map((s) => s.trim())
.filter(Boolean);
handlePublishPost(editingPost, {
name: editName,
brand: editBrand,
retailPrice: editPrice,
sizes: sizesArray,
});
}}
className="flex-1 bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:opacity-95 text-white font-black text-xs py-3 rounded-xl shadow-lg shadow-purple-600/30 cursor-pointer"
>
Guardar y Publicar en la App
</button>
</div>
</div>
</div>
)}
);
})()}
</div>
);
}

View File

@ -1,10 +1,11 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck, Trash2, Edit, Wand2, ArrowRight } from 'lucide-react';
import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck, Trash2, Edit, Wand2, ArrowRight, Layers, Palette, CheckSquare, Square } from 'lucide-react';
import { Product, Supplier } from '@/types';
import { INITIAL_PRODUCTS, INITIAL_SUPPLIERS } from '@/lib/data-service';
import { formatCOP } from '@/lib/pricing-engine';
import { GroupProductsModal } from '@/components/admin/GroupProductsModal';
export default function AdminProductsPage() {
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
@ -14,6 +15,8 @@ export default function AdminProductsPage() {
const [supplierFilter, setSupplierFilter] = useState<string>('all');
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAiModalOpen, setIsAiModalOpen] = useState(false);
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
const [selectedProductIds, setSelectedProductIds] = useState<string[]>([]);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
@ -392,12 +395,62 @@ export default function AdminProductsPage() {
</div>
</div>
{/* Floating Bulk Grouping Toolbar */}
{selectedProductIds.length >= 2 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-neutral-900/95 border-2 border-purple-500 rounded-3xl p-3 px-5 shadow-2xl backdrop-blur-md flex items-center gap-4 animate-in slide-in-from-bottom-5">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-xl bg-purple-600 text-white flex items-center justify-center font-black text-xs">
{selectedProductIds.length}
</div>
<div>
<p className="text-white font-black text-xs leading-tight">
{selectedProductIds.length} Zapatillas Seleccionadas
</p>
<p className="text-[10px] text-neutral-400">
Puedes fusionarlas en 1 solo modelo con múltiples colorways.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedProductIds([])}
className="text-xs text-neutral-400 hover:text-white px-3 py-2 rounded-xl bg-neutral-800 hover:bg-neutral-700 cursor-pointer font-bold"
>
Deseleccionar
</button>
<button
onClick={() => setIsGroupModalOpen(true)}
className="bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:opacity-95 text-white font-black text-xs px-4 py-2 rounded-xl shadow-lg shadow-purple-600/30 flex items-center gap-1.5 cursor-pointer"
>
<Layers className="w-4 h-4" />
<span>Agrupar como Variantes de 1 Modelo</span>
</button>
</div>
</div>
)}
{/* Products Table */}
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl overflow-hidden shadow-xl">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-neutral-300">
<thead className="bg-neutral-950 text-neutral-400 font-bold uppercase text-[10px] border-b border-neutral-800">
<tr>
<th className="py-3.5 px-3 text-center w-10">
<input
type="checkbox"
checked={filtered.length > 0 && selectedProductIds.length === filtered.length}
onChange={(e) => {
if (e.target.checked) {
setSelectedProductIds(filtered.map((p) => p.id));
} else {
setSelectedProductIds([]);
}
}}
className="w-4 h-4 rounded-md accent-purple-600 cursor-pointer"
title="Seleccionar todas"
/>
</th>
<th className="py-3.5 px-4">Estado</th>
<th className="py-3.5 px-4">Producto</th>
<th className="py-3.5 px-4">Proveedor Asignado</th>
@ -412,7 +465,7 @@ export default function AdminProductsPage() {
<tbody className="divide-y divide-neutral-800/60">
{filtered.length === 0 ? (
<tr>
<td colSpan={9} className="text-center py-8 text-neutral-500">
<td colSpan={10} className="text-center py-8 text-neutral-500">
No hay zapatillas que coincidan con el filtro seleccionado.
</td>
</tr>
@ -422,14 +475,32 @@ export default function AdminProductsPage() {
const margin = prod.retailPrice - prod.supplierPrice;
const suppName = prod.supplierName || 'DIFUSION JOVIC STORE';
const suppPhone = prod.supplierPhone || '573134848203';
const isSelected = selectedProductIds.includes(prod.id);
const colorwaysCount = prod.colors?.length || 1;
return (
<tr
key={prod.id}
className={`hover:bg-neutral-800/50 transition-colors ${
!isActive ? 'opacity-65 bg-neutral-950/40' : ''
}`}
isSelected ? 'bg-purple-950/25 border-l-2 border-l-purple-500' : ''
} ${!isActive ? 'opacity-65 bg-neutral-950/40' : ''}`}
>
{/* Checkbox */}
<td className="py-3.5 px-3 text-center">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
if (e.target.checked) {
setSelectedProductIds((prev) => [...prev, prod.id]);
} else {
setSelectedProductIds((prev) => prev.filter((id) => id !== prod.id));
}
}}
className="w-4 h-4 rounded-md accent-purple-600 cursor-pointer"
/>
</td>
{/* Toggle status */}
<td className="py-3.5 px-4">
<button
@ -457,14 +528,29 @@ export default function AdminProductsPage() {
{/* Image & Title */}
<td className="py-3.5 px-4 flex items-center space-x-3">
<img
src={prod.images[0]}
alt=""
className="w-12 h-12 rounded-xl object-cover bg-neutral-950 border border-neutral-800 shrink-0"
/>
<div className="relative">
<img
src={prod.images[0]}
alt=""
className="w-12 h-12 rounded-xl object-cover bg-neutral-950 border border-neutral-800 shrink-0"
/>
{colorwaysCount > 1 && (
<span className="absolute -top-1 -right-1 bg-purple-600 text-white text-[9px] font-black px-1.5 py-0.2 rounded-full border border-neutral-900 shadow">
{colorwaysCount}
</span>
)}
</div>
<div>
<p className="font-bold text-white text-xs leading-snug">{prod.name}</p>
<span className="text-[10px] text-neutral-400">{prod.category}</span>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-neutral-400">{prod.category}</span>
{colorwaysCount > 1 && (
<span className="text-[10px] text-purple-400 bg-purple-500/10 border border-purple-500/30 px-1.5 py-0.2 rounded font-bold flex items-center gap-0.5">
<Palette className="w-2.5 h-2.5" />
{colorwaysCount} Colores
</span>
)}
</div>
</div>
</td>
@ -532,6 +618,21 @@ export default function AdminProductsPage() {
</div>
</div>
{/* Modal de Agrupación Manual de Productos */}
<GroupProductsModal
isOpen={isGroupModalOpen}
onClose={() => setIsGroupModalOpen(false)}
selectedProducts={products.filter((p) => selectedProductIds.includes(p.id))}
onGroupSuccess={(groupedProduct) => {
setProducts((prev) => [
groupedProduct,
...prev.filter((p) => !selectedProductIds.includes(p.id) && p.id !== groupedProduct.id),
]);
setSelectedProductIds([]);
setToastMessage(`¡Se unificaron ${selectedProductIds.length} productos con éxito en "${groupedProduct.name}"!`);
}}
/>
{/* Modal 1: Process Single Message with AI (1 a 1) */}
{isAiModalOpen && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">

View File

@ -17,6 +17,85 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const body = await request.json();
// ACCIÓN MANUAL: Agrupar múltiples productos como variantes de un solo modelo
if (body.action === 'group-products') {
const { targetProductId, productIdsToMerge, customModelName, customBrand } = body;
const allProducts = dataService.getProducts();
const target = allProducts.find((p) => p.id === targetProductId);
if (!target) {
return NextResponse.json({ success: false, error: 'Producto objetivo no encontrado' }, { status: 404 });
}
const colors =
target.colors && target.colors.length > 0
? [...target.colors]
: [
{
id: `color-${target.id}-1`,
colorName: target.colorName || 'Estilo Principal',
image: target.images[0],
images: target.images,
sizes: target.sizes,
inStock: target.inStock !== false,
price: target.retailPrice,
sizePrices: target.sizePrices,
},
];
for (const mergeId of productIdsToMerge || []) {
if (mergeId === targetProductId) continue;
const toMerge = allProducts.find((p) => p.id === mergeId);
if (!toMerge) continue;
const mergeColors =
toMerge.colors && toMerge.colors.length > 0
? toMerge.colors
: [
{
id: `color-${toMerge.id}`,
colorName: toMerge.colorName || toMerge.name || `Estilo #${colors.length + 1}`,
image: toMerge.images[0],
images: toMerge.images,
sizes: toMerge.sizes,
inStock: toMerge.inStock !== false,
price: toMerge.retailPrice,
sizePrices: toMerge.sizePrices,
},
];
mergeColors.forEach((mc) => {
if (!colors.some((c) => c.image === mc.image)) {
colors.push(mc);
}
});
target.images = Array.from(new Set([...target.images, ...toMerge.images]));
target.sizes = Array.from(new Set([...target.sizes, ...toMerge.sizes])).sort(
(a, b) => parseInt(a, 10) - parseInt(b, 10)
);
// Eliminar el producto que fue fusionado
dataService.deleteProduct(toMerge.id);
}
target.colors = colors;
if (customModelName) {
target.name = customModelName;
target.modelName = customModelName;
}
if (customBrand) {
target.brand = customBrand;
}
dataService.updateProduct(target.id, target);
return NextResponse.json({
success: true,
product: target,
message: `¡Se agruparon ${productIdsToMerge.length} productos con éxito en "${target.name}"!`,
});
}
const proof = getProductSocialProof(body);
const newProduct = dataService.addProduct({
...body,

View File

@ -203,7 +203,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const body = await request.json();
const { action, post, posts, customRetailPrice, customName, customSizes } = body;
const { action, post, posts, customRetailPrice, customName, customColorName, customSizes } = body;
// 1. PUBLICAR UN PRODUCTO A LA TIENDA (APP MÓVIL Y WEB)
if (action === 'publish' && post) {
@ -220,6 +220,7 @@ export async function POST(request: Request) {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName,
modelName: prodName,
colorName: customColorName || post.extractedProduct?.colorName,
brand: prodBrand,
supplierPrice: post.extractedProduct?.supplierPrice || 75000,
retailPrice: retailPrice,

View File

@ -0,0 +1,263 @@
'use client';
import React, { useState } from 'react';
import { Layers, X, Check, Palette, Sparkles, ArrowRight, ShieldCheck } from 'lucide-react';
import { Product, ProductColorVariant } from '@/types';
import { formatCOP } from '@/lib/pricing-engine';
interface GroupProductsModalProps {
isOpen: boolean;
onClose: () => void;
selectedProducts: Product[];
onGroupSuccess: (groupedProduct: Product) => void;
}
export const GroupProductsModal: React.FC<GroupProductsModalProps> = ({
isOpen,
onClose,
selectedProducts,
onGroupSuccess,
}) => {
if (!isOpen || selectedProducts.length < 2) return null;
// Elegir el producto principal por defecto (el primero seleccionado)
const [masterId, setMasterId] = useState<string>(selectedProducts[0].id);
const masterProduct = selectedProducts.find((p) => p.id === masterId) || selectedProducts[0];
const [masterName, setMasterName] = useState(masterProduct.modelName || masterProduct.name);
const [masterBrand, setMasterBrand] = useState(masterProduct.brand);
const [masterPrice, setMasterPrice] = useState(masterProduct.retailPrice);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Nombres y precios personalizados para cada variante de color
const [variantConfigs, setVariantConfigs] = useState<
Record<string, { colorName: string; price: number; sizes: string }>
>(() => {
const initial: Record<string, { colorName: string; price: number; sizes: string }> = {};
selectedProducts.forEach((p, idx) => {
initial[p.id] = {
colorName: p.colorName || p.name.replace(p.brand, '').trim() || `Estilo #${idx + 1}`,
price: p.retailPrice,
sizes: p.sizes.join(', '),
};
});
return initial;
});
const handleVariantChange = (
id: string,
field: 'colorName' | 'price' | 'sizes',
value: string | number
) => {
setVariantConfigs((prev) => ({
...prev,
[id]: {
...prev[id],
[field]: value,
},
}));
};
const handleConfirmGroup = async () => {
setIsSubmitting(true);
setErrorMessage(null);
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'group-products',
targetProductId: masterId,
productIdsToMerge: selectedProducts.map((p) => p.id),
customModelName: masterName,
customBrand: masterBrand,
}),
});
const data = await res.json();
if (data.success && data.product) {
onGroupSuccess(data.product);
onClose();
} else {
setErrorMessage(data.error || 'Error al agrupar productos');
}
} catch (err: any) {
setErrorMessage(err.message || 'Error de conexión');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 bg-black/85 backdrop-blur-md flex items-center justify-center p-4 overflow-y-auto animate-in fade-in">
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-2xl w-full p-6 space-y-5 shadow-2xl my-auto">
{/* Header */}
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-xl bg-purple-500/20 text-purple-400 flex items-center justify-center">
<Layers className="w-5 h-5" />
</div>
<div>
<h3 className="font-black text-white text-base">
Agrupar {selectedProducts.length} Zapatillas como Variantes de un Modelo
</h3>
<p className="text-neutral-400 text-xs">
Se unificarán en 1 sola publicación con selector interactivo de colorways.
</p>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white flex items-center justify-center cursor-pointer transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{errorMessage && (
<div className="bg-red-950/40 border border-red-500/40 rounded-xl p-3 text-red-300 text-xs">
{errorMessage}
</div>
)}
{/* Modelo Principal */}
<div className="bg-neutral-950 p-4 rounded-2xl border border-neutral-800 space-y-3">
<span className="text-[11px] font-black text-purple-400 uppercase tracking-wider block">
1. Datos Generales de la Publicación Unificada
</span>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
<div className="sm:col-span-2">
<label className="font-bold text-neutral-300 block mb-1">Nombre del Modelo Base:</label>
<input
type="text"
value={masterName}
onChange={(e) => setMasterName(e.target.value)}
placeholder="Ej: Adidas Samba OG"
className="w-full bg-neutral-900 border border-neutral-700 rounded-xl px-3 py-2 text-white font-bold outline-hidden focus:border-purple-500"
/>
</div>
<div>
<label className="font-bold text-neutral-300 block mb-1">Marca:</label>
<input
type="text"
value={masterBrand}
onChange={(e) => setMasterBrand(e.target.value)}
className="w-full bg-neutral-900 border border-neutral-700 rounded-xl px-3 py-2 text-white font-bold outline-hidden focus:border-purple-500"
/>
</div>
</div>
</div>
{/* Lista de Variantes / Estilos */}
<div className="space-y-2.5">
<span className="text-[11px] font-black text-purple-400 uppercase tracking-wider block">
2. Configuración de Cada Variante de Color ({selectedProducts.length})
</span>
<div className="max-h-64 overflow-y-auto space-y-2.5 pr-1">
{selectedProducts.map((prod, idx) => {
const isMaster = prod.id === masterId;
const cfg = variantConfigs[prod.id] || {
colorName: `Estilo #${idx + 1}`,
price: prod.retailPrice,
sizes: prod.sizes.join(', '),
};
return (
<div
key={prod.id}
className={`p-3 rounded-2xl border transition-all flex flex-col sm:flex-row items-center gap-3 ${
isMaster
? 'bg-purple-950/20 border-purple-500/60 ring-1 ring-purple-500/40'
: 'bg-neutral-950/60 border-neutral-800 hover:border-neutral-700'
}`}
>
<img
src={prod.images[0]}
alt=""
className="w-14 h-14 rounded-xl object-cover bg-neutral-900 border border-neutral-700 shrink-0"
/>
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-2 w-full text-xs">
<div>
<label className="text-[10px] text-neutral-400 font-bold block mb-0.5">
Nombre del Estilo/Color:
</label>
<input
type="text"
value={cfg.colorName}
onChange={(e) => handleVariantChange(prod.id, 'colorName', e.target.value)}
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-2 py-1.5 text-white font-semibold outline-hidden focus:border-purple-500 text-xs"
/>
</div>
<div>
<label className="text-[10px] text-emerald-400 font-bold block mb-0.5">
Precio Venta:
</label>
<input
type="number"
value={cfg.price}
onChange={(e) =>
handleVariantChange(prod.id, 'price', Number(e.target.value))
}
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-2 py-1.5 text-emerald-300 font-mono font-bold outline-hidden focus:border-emerald-500 text-xs"
/>
</div>
<div>
<label className="text-[10px] text-neutral-400 font-bold block mb-0.5">
Tallas:
</label>
<input
type="text"
value={cfg.sizes}
onChange={(e) => handleVariantChange(prod.id, 'sizes', e.target.value)}
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-2 py-1.5 text-white outline-hidden focus:border-purple-500 text-xs"
/>
</div>
</div>
<button
type="button"
onClick={() => setMasterId(prod.id)}
className={`text-[10px] font-bold px-2.5 py-1.5 rounded-lg border shrink-0 cursor-pointer ${
isMaster
? 'bg-purple-600 text-white border-purple-400'
: 'bg-neutral-900 text-neutral-400 border-neutral-700 hover:text-white'
}`}
>
{isMaster ? '★ Principal' : 'Hacer Principal'}
</button>
</div>
);
})}
</div>
</div>
{/* Footer */}
<div className="flex items-center gap-3 pt-3 border-t border-neutral-800">
<button
onClick={onClose}
disabled={isSubmitting}
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={handleConfirmGroup}
disabled={isSubmitting}
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 flex items-center justify-center gap-2"
>
<Check className="w-4 h-4" />
<span>{isSubmitting ? 'Agrupando modelos...' : 'Confirmar y Fusionar en 1 Modelo'}</span>
</button>
</div>
</div>
</div>
);
};

View File

@ -3,7 +3,7 @@
import React, { useState } from 'react';
import { Star, Zap, Truck, ShoppingBag, Layers, Palette, Sparkles } from 'lucide-react';
import { Product } from '@/types';
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
import { formatCOP, calculateSeparationBreakdown, getProductPriceRange } from '@/lib/pricing-engine';
import { useBrand } from '@/context/BrandContext';
import { getProductSocialProof } from '@/lib/social-proof';
@ -21,6 +21,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
const [activeImage, setActiveImage] = useState<string>(product.images[0]);
const [activeColorName, setActiveColorName] = useState<string>(product.colorName || '');
const socialProof = getProductSocialProof(product);
const priceRange = getProductPriceRange(product);
const discountPercent = product.originalPrice
? Math.round(((product.originalPrice - product.retailPrice) / product.originalPrice) * 100)
@ -145,7 +146,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
<div className="pt-2 border-t border-slate-800">
<div className="flex items-baseline space-x-2">
<span className={`text-lg sm:text-xl font-black text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>
{formatCOP(product.retailPrice)}
{priceRange.hasVariation ? `Desde ${formatCOP(priceRange.minPrice)}` : formatCOP(product.retailPrice)}
</span>
{product.originalPrice && (
<span className="text-xs text-slate-500 line-through font-semibold">
@ -155,7 +156,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
</div>
<p className="text-[11px] font-semibold bg-white/5 px-2 py-1 rounded-md mt-1.5 border border-white/10 text-slate-300">
⚡ Separa con <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y cancelas <strong className="text-white">{formatCOP(separation.balanceDue)}</strong> al recibir.
⚡ Separa con <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y cancelas al recibir.
</p>
{/* Quick Action Button */}

View File

@ -17,7 +17,7 @@ import {
ArrowRight,
} from 'lucide-react';
import { Product, City, ProductColorVariant } from '@/types';
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
import { formatCOP, calculateSeparationBreakdown, getProductEffectivePrice } from '@/lib/pricing-engine';
import { useBrand } from '@/context/BrandContext';
import { getProductSocialProof } from '@/lib/social-proof';
@ -75,8 +75,10 @@ export const ProductModal: React.FC<ProductModalProps> = ({
const isSizeInStock = availableSizes.includes(selectedSize);
// Precio dinámico efectivo según colorway y talla seleccionada
const currentPrice = getProductEffectivePrice(product, selectedColor, selectedSize);
const socialProof = getProductSocialProof(product);
const separation = calculateSeparationBreakdown(product.retailPrice);
const separation = calculateSeparationBreakdown(currentPrice);
const handleAdd = () => {
if (!isSizeInStock) return;
@ -84,6 +86,7 @@ export const ProductModal: React.FC<ProductModalProps> = ({
{
...product,
name: `${product.modelName || product.name} (${selectedColor.colorName})`,
retailPrice: currentPrice,
images: selectedColor.images || [selectedImage],
},
selectedSize
@ -102,7 +105,7 @@ export const ProductModal: React.FC<ProductModalProps> = ({
👟 *${product.modelName || product.name}*
🎨 Color: *${selectedColor.colorName}*
📏 Talla: *${selectedSize}* (Horma Nacional)
💎 Precio: *${formatCOP(product.retailPrice)} COP*
💎 Precio: *${formatCOP(currentPrice)} COP*
🚚 Entrega en: *${selectedCity.name}* (Pago Contraentrega)
⚡ Deseo separar con $10.000 COP y cancelar el saldo en mi casa.
@ -225,7 +228,7 @@ export const ProductModal: React.FC<ProductModalProps> = ({
<div className="bg-neutral-950/80 border border-neutral-800 rounded-2xl p-3.5 space-y-2">
<div className="flex items-baseline space-x-3">
<span className={`text-2xl sm:text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>
{formatCOP(product.retailPrice)}
{formatCOP(currentPrice)}
</span>
{product.originalPrice && (
<span className="text-sm text-neutral-500 line-through font-bold">
@ -313,12 +316,14 @@ export const ProductModal: React.FC<ProductModalProps> = ({
{displaySizes.map((size) => {
const inStock = availableSizes.includes(size);
const isSelected = selectedSize === size;
const sizePrice = getProductEffectivePrice(product, selectedColor, size);
const hasCustomSizePrice = sizePrice !== product.retailPrice;
return (
<button
key={size}
onClick={() => setSelectedSize(size)}
className={`relative py-2.5 rounded-xl text-xs font-black transition-all cursor-pointer flex flex-col items-center justify-center ${
className={`relative py-2 rounded-xl text-xs font-black transition-all cursor-pointer flex flex-col items-center justify-center ${
isSelected
? inStock
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-lg scale-105 border border-white/30`
@ -329,8 +334,8 @@ export const ProductModal: React.FC<ProductModalProps> = ({
}`}
>
<span>{size}</span>
<span className="text-[8px] font-mono leading-none mt-0.5">
{inStock ? 'Listo' : 'Pedir'}
<span className="text-[8px] font-mono leading-none mt-0.5 opacity-80">
{hasCustomSizePrice ? `$${Math.round(sizePrice / 1000)}k` : inStock ? 'Listo' : 'Pedir'}
</span>
</button>
);

View File

@ -112,3 +112,66 @@ export function generateSalesCopy(params: {
📲 ¡Pídelas antes de que se agote tu talla!
👉 Pedir directo por WhatsApp: ${waLink}`;
}
/**
* Obtiene el precio exacto de venta de un producto según el estilo/color y la talla seleccionada.
*/
export function getProductEffectivePrice(
product: {
retailPrice: number;
sizePrices?: Record<string, number>;
},
selectedColor?: {
price?: number;
sizePrices?: Record<string, number>;
} | null,
selectedSize?: string | null
): number {
if (selectedColor && selectedSize && selectedColor.sizePrices && selectedColor.sizePrices[selectedSize]) {
return selectedColor.sizePrices[selectedSize];
}
if (selectedSize && product.sizePrices && product.sizePrices[selectedSize]) {
return product.sizePrices[selectedSize];
}
if (selectedColor && selectedColor.price && selectedColor.price > 0) {
return selectedColor.price;
}
return product.retailPrice || 120000;
}
/**
* Obtiene el rango de precios si varían según la talla o colorway
*/
export function getProductPriceRange(product: {
retailPrice: number;
sizePrices?: Record<string, number>;
colors?: Array<{ price?: number; sizePrices?: Record<string, number> }>;
}): { minPrice: number; maxPrice: number; hasVariation: boolean } {
const prices: number[] = [product.retailPrice || 120000];
if (product.sizePrices) {
Object.values(product.sizePrices).forEach((p) => {
if (typeof p === 'number' && p > 0) prices.push(p);
});
}
if (Array.isArray(product.colors)) {
product.colors.forEach((c) => {
if (c.price && c.price > 0) prices.push(c.price);
if (c.sizePrices) {
Object.values(c.sizePrices).forEach((p) => {
if (typeof p === 'number' && p > 0) prices.push(p);
});
}
});
}
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
return {
minPrice,
maxPrice,
hasVariation: minPrice !== maxPrice,
};
}

View File

@ -40,6 +40,8 @@ export interface ProductColorVariant {
images?: string[];
sizes: string[];
inStock?: boolean;
price?: number; // Precio específico de esta variante
sizePrices?: Record<string, number>; // Precios específicos por talla (ej: { "36": 110000, "42": 125000 })
}
export interface Product {
@ -53,6 +55,7 @@ export interface Product {
originalPrice?: number;
sizes: string[];
allSizes?: string[];
sizePrices?: Record<string, number>; // Precios variables por talla a nivel general
images: string[];
colors?: ProductColorVariant[];
description: string;
@ -149,6 +152,7 @@ export interface SupplierPost {
export interface ExtractedProductData {
name: string;
brand: string;
colorName?: string;
sizes: string[];
supplierPrice: number;
suggestedRetailPrice: number;

View File

@ -28,10 +28,12 @@ import {
import { SupplierPost, Product, MonitoredWhatsAppGroup } from '@/types';
import { formatCOP } from '@/lib/pricing-engine';
import { BRANDS, BrandId } from '@/config/brands';
import { normalizeModelKey } from '@/lib/model-grouper';
export default function SupplierFeedPage() {
const [posts, setPosts] = useState<SupplierPost[]>([]);
const [monitoredGroups, setMonitoredGroups] = useState<MonitoredWhatsAppGroup[]>([]);
const [existingProducts, setExistingProducts] = useState<Product[]>([]);
const [selectedGroupFilter, setSelectedGroupFilter] = useState<string>('all');
const [tabStatus, setTabStatus] = useState<'pending' | 'published' | 'discarded'>('pending');
const [isLoading, setIsLoading] = useState(false);
@ -45,13 +47,15 @@ export default function SupplierFeedPage() {
const [editingPost, setEditingPost] = useState<SupplierPost | null>(null);
const [editName, setEditName] = useState('');
const [editBrand, setEditBrand] = useState('');
const [editColorName, setEditColorName] = useState('');
const [editPrice, setEditPrice] = useState(120000);
const [editSizes, setEditSizes] = useState('');
const [mergeWithExisting, setMergeWithExisting] = useState(true);
// Target brand selector for publishing
const [targetBrand, setTargetBrand] = useState<BrandId | 'all'>('almeira');
// Cargar grupos monitoreados y posts iniciales en vivo
// Cargar grupos monitoreados, productos existentes y posts en vivo
const loadInitialData = async () => {
setIsLoading(true);
try {
@ -62,7 +66,14 @@ export default function SupplierFeedPage() {
setMonitoredGroups(grpData.groups);
}
// 2. Posts en vivo desde los grupos de WhatsApp
// 2. Productos existentes en el catálogo
const prodRes = await fetch('/api/products?all=true');
const prodData = await prodRes.json();
if (prodData.success && Array.isArray(prodData.products)) {
setExistingProducts(prodData.products);
}
// 3. Posts en vivo desde los grupos de WhatsApp
const feedRes = await fetch('/api/whatsapp-feed?fetchLive=true&limit=50');
const feedData = await feedRes.json();
if (feedData.success && Array.isArray(feedData.posts)) {
@ -136,7 +147,7 @@ export default function SupplierFeedPage() {
// Publicar un producto a la tienda
const handlePublishPost = async (
post: SupplierPost,
customData?: { name?: string; brand?: string; retailPrice?: number; sizes?: string[] }
customData?: { name?: string; brand?: string; colorName?: string; retailPrice?: number; sizes?: string[] }
) => {
try {
const res = await fetch('/api/whatsapp-feed', {
@ -146,6 +157,7 @@ export default function SupplierFeedPage() {
action: 'publish',
post,
customName: customData?.name,
customColorName: customData?.colorName,
customRetailPrice: customData?.retailPrice,
customSizes: customData?.sizes,
}),
@ -251,13 +263,17 @@ export default function SupplierFeedPage() {
const handleOpenEdit = (post: SupplierPost) => {
setEditingPost(post);
setEditName(post.extractedProduct?.name || 'Zapatilla Urbana');
setEditBrand(post.extractedProduct?.brand || 'Nike');
const prodName = post.extractedProduct?.name || 'Zapatilla Urbana';
const prodBrand = post.extractedProduct?.brand || 'Nike';
setEditName(prodName);
setEditBrand(prodBrand);
setEditColorName(post.extractedProduct?.colorName || '');
setEditPrice(
post.extractedProduct?.suggestedRetailPrice ||
(post.extractedProduct?.supplierPrice ? post.extractedProduct.supplierPrice + 35000 : 125000)
);
setEditSizes(post.extractedProduct?.sizes?.join(', ') || '38, 39, 40, 41, 42, 43');
setMergeWithExisting(true);
};
return (
@ -308,7 +324,7 @@ export default function SupplierFeedPage() {
</div>
<button
onClick={() => setActionMessage(null)}
className="text-white hover:text-purple-300 font-bold px-2 cursor-pointer"
className="text-purple-400 hover:text-white font-bold"
>
✕
</button>
@ -619,96 +635,168 @@ export default function SupplierFeedPage() {
)}
{/* 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>
{editingPost && (() => {
const matchingProduct = existingProducts.find(
(p) => normalizeModelKey(p.modelName || p.name, p.brand) === normalizeModelKey(editName, editBrand)
);
<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"
/>
return (
<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 y Validar antes de Publicar</span>
</h3>
<button
onClick={() => setEditingPost(null)}
className="text-neutral-400 hover:text-white font-bold"
>
✕
</button>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Pre-validación de Modelo Existente */}
{matchingProduct && (
<div className="bg-purple-950/40 border border-purple-500/50 rounded-2xl p-3.5 space-y-2.5">
<div className="flex items-center gap-2 text-purple-300 font-bold text-xs">
<Sparkles className="w-4 h-4 text-purple-400" />
<span>¡Modelo Existente Detectado en la Tienda!</span>
</div>
<div className="flex items-center justify-between bg-neutral-950 p-2.5 rounded-xl border border-neutral-800">
<div className="flex items-center gap-2.5">
<img
src={matchingProduct.images[0]}
className="w-10 h-10 rounded-lg object-cover bg-neutral-900 border border-neutral-800"
alt=""
/>
<div>
<p className="font-black text-white text-xs">{matchingProduct.name}</p>
<p className="text-[10px] text-neutral-400">
{matchingProduct.colors?.length || 1} variantes de color existentes
</p>
</div>
</div>
<span className="text-[10px] bg-purple-600/30 text-purple-300 border border-purple-500/40 font-bold px-2 py-0.5 rounded-md">
Coincidencia
</span>
</div>
<div className="flex flex-col gap-1.5 text-xs">
<label className="flex items-center gap-2 text-neutral-200 cursor-pointer font-semibold">
<input
type="radio"
name="mergeOption"
checked={mergeWithExisting}
onChange={() => setMergeWithExisting(true)}
className="accent-purple-600"
/>
<span>Agregar como nueva variante de color a "{matchingProduct.name}"</span>
</label>
<label className="flex items-center gap-2 text-neutral-400 hover:text-neutral-200 cursor-pointer">
<input
type="radio"
name="mergeOption"
checked={!mergeWithExisting}
onChange={() => setMergeWithExisting(false)}
className="accent-purple-600"
/>
<span>Publicar como un modelo nuevo independiente</span>
</label>
</div>
</div>
)}
<div className="space-y-3.5 text-xs">
<div>
<label className="font-bold text-neutral-300 block mb-1">Marca:</label>
<label className="font-bold text-neutral-300 block mb-1">Nombre Comercial del Modelo:</label>
<input
type="text"
value={editBrand}
onChange={(e) => setEditBrand(e.target.value)}
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>
{matchingProduct && mergeWithExisting && (
<div>
<label className="font-bold text-purple-400 block mb-1">Nombre de este Estilo / Colorway:</label>
<input
type="text"
value={editColorName}
onChange={(e) => setEditColorName(e.target.value)}
placeholder="Ej: Blanco / Negro, Gum Edition, Rosa Pastel"
className="w-full bg-neutral-950 border border-purple-500/50 rounded-xl px-3 py-2 text-white font-semibold 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>
<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 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: !mergeWithExisting && matchingProduct ? `${editName} (${editColorName || 'Variante'})` : editName,
colorName: editColorName,
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"
>
{matchingProduct && mergeWithExisting ? 'Fusionar y Publicar en App' : 'Guardar y Publicar en App'}
</button>
</div>
</div>
<div className="flex items-center gap-2.5 pt-3 border-t border-neutral-800">
<button
onClick={() => setEditingPost(null)}
className="flex-1 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold text-xs py-3 rounded-xl cursor-pointer"
>
Cancelar
</button>
<button
onClick={() => {
const sizesArray = editSizes
.split(',')
.map((s) => s.trim())
.filter(Boolean);
handlePublishPost(editingPost, {
name: editName,
brand: editBrand,
retailPrice: editPrice,
sizes: sizesArray,
});
}}
className="flex-1 bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:opacity-95 text-white font-black text-xs py-3 rounded-xl shadow-lg shadow-purple-600/30 cursor-pointer"
>
Guardar y Publicar en la App
</button>
</div>
</div>
</div>
)}
);
})()}
</div>
);
}

View File

@ -1,10 +1,11 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck } from 'lucide-react';
import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck, Trash2, Edit, Wand2, ArrowRight, Layers, Palette, CheckSquare, Square } from 'lucide-react';
import { Product, Supplier } from '@/types';
import { INITIAL_PRODUCTS, INITIAL_SUPPLIERS } from '@/lib/data-service';
import { formatCOP } from '@/lib/pricing-engine';
import { GroupProductsModal } from '@/components/admin/GroupProductsModal';
export default function AdminProductsPage() {
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
@ -13,6 +14,10 @@ export default function AdminProductsPage() {
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [supplierFilter, setSupplierFilter] = useState<string>('all');
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAiModalOpen, setIsAiModalOpen] = useState(false);
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
const [selectedProductIds, setSelectedProductIds] = useState<string[]>([]);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
// New product form
@ -28,6 +33,21 @@ export default function AdminProductsPage() {
const [category, setCategory] = useState('Sneakers / Urbano');
const [isActiveNew, setIsActiveNew] = useState(true);
// Edit product form
const [editName, setEditName] = useState('');
const [editBrand, setEditBrand] = useState('');
const [editSupplierId, setEditSupplierId] = useState('');
const [editSupplierPrice, setEditSupplierPrice] = useState('');
const [editRetailPrice, setEditRetailPrice] = useState('');
const [editSizesInput, setEditSizesInput] = useState('');
const [editImageUrl, setEditImageUrl] = useState('');
const [editCategory, setEditCategory] = useState('');
// 1-to-1 AI processor form
const [rawText, setRawText] = useState('');
const [rawImageUrl, setRawImageUrl] = useState('');
const [aiExtracted, setAiExtracted] = useState<Partial<Product> | null>(null);
useEffect(() => {
fetch('/api/products?all=true')
.then((r) => r.json())
@ -77,6 +97,25 @@ export default function AdminProductsPage() {
window.open(`https://wa.me/${phone}?text=${text}`, '_blank');
};
const handleDeleteProduct = async (product: Product) => {
if (!window.confirm(`¿Estás seguro de eliminar permanentemente la publicación "${product.name}"?`)) {
return;
}
try {
const res = await fetch(`/api/products?id=${product.id}`, {
method: 'DELETE',
});
const data = await res.json();
if (data.success) {
setProducts((prev) => prev.filter((p) => p.id !== product.id));
setToastMessage(`🗑️ Publicación "${product.name}" eliminada permanentemente del catálogo.`);
}
} catch (e) {
console.error('Error eliminando producto:', e);
}
};
const handleCreateProduct = async (e: React.FormEvent) => {
e.preventDefault();
const sizes = sizesInput
@ -119,6 +158,142 @@ export default function AdminProductsPage() {
}
};
const handleOpenEdit = (product: Product) => {
setEditingProduct(product);
setEditName(product.name);
setEditBrand(product.brand);
setEditSupplierId(product.supplierId || suppliers[0].id);
setEditSupplierPrice(product.supplierPrice.toString());
setEditRetailPrice(product.retailPrice.toString());
setEditSizesInput(product.sizes.join(', '));
setEditImageUrl(product.images[0] || '');
setEditCategory(product.category || 'Sneakers / Urbano');
};
const handleSaveEdit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingProduct) return;
const sizes = editSizesInput.split(',').map((s) => s.trim()).filter(Boolean);
const chosenSupplier = suppliers.find((s) => s.id === editSupplierId) || suppliers[0];
const updates = {
name: editName,
brand: editBrand,
supplierPrice: parseInt(editSupplierPrice, 10) || editingProduct.supplierPrice,
retailPrice: parseInt(editRetailPrice, 10) || editingProduct.retailPrice,
originalPrice: Math.round((parseInt(editRetailPrice, 10) || editingProduct.retailPrice) * 1.35),
sizes,
images: [editImageUrl || editingProduct.images[0]],
category: editCategory,
supplierId: chosenSupplier.id,
supplierName: chosenSupplier.name,
supplierPhone: chosenSupplier.phone,
};
setProducts((prev) => prev.map((p) => (p.id === editingProduct.id ? { ...p, ...updates } : p)));
try {
const res = await fetch('/api/products', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: editingProduct.id, updates }),
});
const data = await res.json();
if (data.success) {
setEditingProduct(null);
setToastMessage(`✅ Publicación "${editName}" actualizada con éxito.`);
}
} catch (e) {
console.error(e);
}
};
// 1-to-1 AI Processor logic
const handleProcessSinglePostWithAI = () => {
if (!rawText.trim()) return;
// Extracción inteligente
const cleanTitle = rawText
.split('\n')[0]
.replace(/🔥|👟|💥|⚡|DISPONIBLE|LLEGANDO|HOY|A BODEGA/gi, '')
.trim();
// Detectar marca
let detectedBrand = 'Nike';
if (/adidas/i.test(rawText)) detectedBrand = 'Adidas';
if (/jordan/i.test(rawText)) detectedBrand = 'Jordan';
if (/new balance/i.test(rawText)) detectedBrand = 'New Balance';
if (/puma/i.test(rawText)) detectedBrand = 'Puma';
if (/vans/i.test(rawText)) detectedBrand = 'Vans';
// Detectar precio
const priceMatch = rawText.match(/\$?\s*(\d{2,3})[.,]?(\d{3})/);
let cost = 90000;
if (priceMatch) {
cost = parseInt(priceMatch[1] + priceMatch[2], 10);
}
// Calcular precio venta con 35% de margen
const retail = Math.round(cost * 1.35 / 1000) * 1000;
// Detectar tallas
const sizes = ['37', '38', '39', '40', '41', '42', '43'];
setAiExtracted({
name: cleanTitle || 'Zapatilla Urbana Exclusiva',
brand: detectedBrand,
supplierPrice: cost,
retailPrice: retail,
sizes,
images: [rawImageUrl || 'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
category: 'Sneakers / Urbano',
description: 'Zapatillas calidad 1.1 garantizada para entrega inmediata.',
isActive: true,
inStock: true,
originBadge: 'Bodega Central',
supplierName: 'DIFUSION JOVIC STORE',
supplierPhone: '573134848203',
});
};
const handlePublishAiProduct = async () => {
if (!aiExtracted) return;
const newProd: Product = {
id: `snk-${Date.now()}`,
name: aiExtracted.name || 'Zapatilla',
brand: aiExtracted.brand || 'Nike',
supplierPrice: aiExtracted.supplierPrice || 90000,
retailPrice: aiExtracted.retailPrice || 125000,
originalPrice: Math.round((aiExtracted.retailPrice || 125000) * 1.35),
sizes: aiExtracted.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: aiExtracted.images || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: aiExtracted.description || 'Zapatillas urbanas',
category: aiExtracted.category || 'Sneakers / Urbano',
inStock: true,
isActive: true,
originBadge: 'Bodega Central',
supplierName: aiExtracted.supplierName || 'DIFUSION JOVIC STORE',
supplierPhone: aiExtracted.supplierPhone || '573134848203',
};
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newProd),
});
const data = await res.json();
if (data.success) {
setProducts((prev) => [data.product, ...prev]);
setIsAiModalOpen(false);
setRawText('');
setRawImageUrl('');
setAiExtracted(null);
setToastMessage(`🚀 ¡Publicación individual "${newProd.name}" procesada y publicada en la tienda!`);
}
};
const filtered = products.filter((p) => {
const matchesSearch =
p.name.toLowerCase().includes(search.toLowerCase()) ||
@ -132,34 +307,41 @@ export default function AdminProductsPage() {
return true;
});
const activeCount = products.filter((p) => p.isActive !== false).length;
const inactiveCount = products.filter((p) => p.isActive === false).length;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-black text-white flex items-center gap-2">
<Package className="w-6 h-6 text-orange-500" />
<span>Catálogo General de Zapatillas & Proveedores ({products.length})</span>
<Package className="w-6 h-6 text-purple-400" />
<span>Catálogo de Publicaciones ({products.length})</span>
</h1>
<p className="text-xs text-gray-400 mt-1">
Visualiza cada modelo con su <strong>proveedor asignado</strong>, costo mayorista y activa/inactiva publicaciones para la tienda.
<p className="text-xs text-neutral-400 mt-1">
Gestión completa (CRUD): Crea, edita precios/tallas, activa/oculta o elimina publicaciones de tu catálogo.
</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="bg-gradient-to-r from-red-600 to-orange-500 hover:from-red-500 hover:to-orange-400 text-white font-bold text-xs px-4 py-2.5 rounded-xl flex items-center space-x-1.5 shadow-[0_0_15px_rgba(239,68,68,0.3)] cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Agregar Zapatilla con Proveedor</span>
</button>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setIsAiModalOpen(true)}
className="bg-purple-950/80 hover:bg-purple-900 border border-purple-500/50 text-purple-300 font-bold text-xs px-3.5 py-2.5 rounded-xl flex items-center space-x-1.5 shadow-md cursor-pointer transition-all"
>
<Wand2 className="w-4 h-4 text-fuchsia-400" />
<span>Procesar 1 a 1 con IA</span>
</button>
<button
onClick={() => setIsModalOpen(true)}
className="bg-gradient-to-r from-purple-600 to-fuchsia-500 hover:from-purple-500 hover:to-fuchsia-400 text-white font-bold text-xs px-4 py-2.5 rounded-xl flex items-center space-x-1.5 shadow-[0_0_15px_rgba(168,85,247,0.3)] cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>+ Crear Zapatilla Manual</span>
</button>
</div>
</div>
{toastMessage && (
<div className="bg-orange-950/80 border border-orange-500 text-orange-300 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-md">
<div className="bg-purple-950/80 border border-purple-500 text-purple-300 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-md">
<span>{toastMessage}</span>
<button onClick={() => setToastMessage(null)} className="font-bold text-white cursor-pointer ml-3">
✕
@ -169,12 +351,11 @@ export default function AdminProductsPage() {
{/* Filters & Search Toolbar */}
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3">
{/* Status filter pills */}
<div className="flex items-center space-x-1.5 bg-gray-900 border border-gray-800 p-1 rounded-2xl overflow-x-auto">
<div className="flex items-center space-x-1.5 bg-neutral-900 border border-neutral-800 p-1 rounded-2xl overflow-x-auto">
<button
onClick={() => setStatusFilter('all')}
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer ${
statusFilter === 'all' ? 'bg-white text-gray-950 shadow' : 'text-gray-400 hover:text-white'
statusFilter === 'all' ? 'bg-white text-neutral-950 shadow' : 'text-neutral-400 hover:text-white'
}`}
>
Todos ({products.length})
@ -183,58 +364,108 @@ export default function AdminProductsPage() {
onClick={() => setStatusFilter('active')}
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
statusFilter === 'active'
? 'bg-emerald-500/30 border border-emerald-500/50 text-emerald-300 shadow'
: 'text-gray-400 hover:text-emerald-400'
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow'
: 'text-neutral-400 hover:text-emerald-300'
}`}
>
<span className="w-2 h-2 rounded-full bg-emerald-400" />
<span>Activos ({activeCount})</span>
<Eye className="w-3.5 h-3.5" />
<span>Activas</span>
</button>
<button
onClick={() => setStatusFilter('inactive')}
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
statusFilter === 'inactive'
? 'bg-amber-500/30 border border-amber-500/50 text-amber-300 shadow'
: 'text-gray-400 hover:text-amber-400'
? 'bg-neutral-800 text-white border border-neutral-700 shadow'
: 'text-neutral-400 hover:text-white'
}`}
>
<span className="w-2 h-2 rounded-full bg-amber-400" />
<span>Inactivos ({inactiveCount})</span>
<EyeOff className="w-3.5 h-3.5" />
<span>Ocultas</span>
</button>
</div>
{/* Search */}
<div className="max-w-xs w-full">
<input
type="text"
placeholder="Filtrar por modelo, marca o proveedor..."
placeholder="Buscar por modelo, marca o proveedor..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-gray-900 border border-gray-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-orange-500"
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400"
/>
</div>
</div>
{/* Floating Bulk Grouping Toolbar */}
{selectedProductIds.length >= 2 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-neutral-900/95 border-2 border-purple-500 rounded-3xl p-3 px-5 shadow-2xl backdrop-blur-md flex items-center gap-4 animate-in slide-in-from-bottom-5">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-xl bg-purple-600 text-white flex items-center justify-center font-black text-xs">
{selectedProductIds.length}
</div>
<div>
<p className="text-white font-black text-xs leading-tight">
{selectedProductIds.length} Zapatillas Seleccionadas
</p>
<p className="text-[10px] text-neutral-400">
Puedes fusionarlas en 1 solo modelo con múltiples colorways.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedProductIds([])}
className="text-xs text-neutral-400 hover:text-white px-3 py-2 rounded-xl bg-neutral-800 hover:bg-neutral-700 cursor-pointer font-bold"
>
Deseleccionar
</button>
<button
onClick={() => setIsGroupModalOpen(true)}
className="bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:opacity-95 text-white font-black text-xs px-4 py-2 rounded-xl shadow-lg shadow-purple-600/30 flex items-center gap-1.5 cursor-pointer"
>
<Layers className="w-4 h-4" />
<span>Agrupar como Variantes de 1 Modelo</span>
</button>
</div>
</div>
)}
{/* Products Table */}
<div className="bg-gray-900 border border-gray-800 rounded-3xl overflow-hidden shadow-xl">
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl overflow-hidden shadow-xl">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-gray-300">
<thead className="bg-gray-950 text-gray-400 font-bold uppercase text-[10px] border-b border-gray-800">
<table className="w-full text-left text-xs text-neutral-300">
<thead className="bg-neutral-950 text-neutral-400 font-bold uppercase text-[10px] border-b border-neutral-800">
<tr>
<th className="py-3.5 px-4">Estado Publicación</th>
<th className="py-3.5 px-3 text-center w-10">
<input
type="checkbox"
checked={filtered.length > 0 && selectedProductIds.length === filtered.length}
onChange={(e) => {
if (e.target.checked) {
setSelectedProductIds(filtered.map((p) => p.id));
} else {
setSelectedProductIds([]);
}
}}
className="w-4 h-4 rounded-md accent-purple-600 cursor-pointer"
title="Seleccionar todas"
/>
</th>
<th className="py-3.5 px-4">Estado</th>
<th className="py-3.5 px-4">Producto</th>
<th className="py-3.5 px-4">Proveedor Asignado (Interno)</th>
<th className="py-3.5 px-4">Proveedor Asignado</th>
<th className="py-3.5 px-4">Marca</th>
<th className="py-3.5 px-4">Costo Base</th>
<th className="py-3.5 px-4">Precio Venta</th>
<th className="py-3.5 px-4">Margen Ganancia</th>
<th className="py-3.5 px-4">Tallas</th>
<th className="py-3.5 px-4 text-center">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800/60">
<tbody className="divide-y divide-neutral-800/60">
{filtered.length === 0 ? (
<tr>
<td colSpan={8} className="text-center py-8 text-gray-500">
<td colSpan={10} className="text-center py-8 text-neutral-500">
No hay zapatillas que coincidan con el filtro seleccionado.
</td>
</tr>
@ -244,24 +475,42 @@ export default function AdminProductsPage() {
const margin = prod.retailPrice - prod.supplierPrice;
const suppName = prod.supplierName || 'DIFUSION JOVIC STORE';
const suppPhone = prod.supplierPhone || '573134848203';
const isSelected = selectedProductIds.includes(prod.id);
const colorwaysCount = prod.colors?.length || 1;
return (
<tr
key={prod.id}
className={`hover:bg-gray-800/50 transition-colors ${
!isActive ? 'opacity-65 bg-gray-950/40' : ''
}`}
className={`hover:bg-neutral-800/50 transition-colors ${
isSelected ? 'bg-purple-950/25 border-l-2 border-l-purple-500' : ''
} ${!isActive ? 'opacity-65 bg-neutral-950/40' : ''}`}
>
{/* Estado Toggle Button */}
{/* Checkbox */}
<td className="py-3.5 px-3 text-center">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
if (e.target.checked) {
setSelectedProductIds((prev) => [...prev, prod.id]);
} else {
setSelectedProductIds((prev) => prev.filter((id) => id !== prod.id));
}
}}
className="w-4 h-4 rounded-md accent-purple-600 cursor-pointer"
/>
</td>
{/* Toggle status */}
<td className="py-3.5 px-4">
<button
onClick={() => handleToggleProductStatus(prod)}
className={`text-[11px] font-bold px-3 py-1.5 rounded-xl border flex items-center gap-1.5 transition-all cursor-pointer ${
className={`text-[11px] font-bold px-2.5 py-1 rounded-xl border flex items-center gap-1.5 transition-all cursor-pointer ${
isActive
? 'bg-emerald-500/20 border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/30'
: 'bg-gray-950 border-gray-700 text-gray-400 hover:text-white'
: 'bg-neutral-950 border-neutral-700 text-neutral-400 hover:text-white'
}`}
title="Haz clic para activar o desactivar esta publicación en la tienda"
title="Activar / Ocultar en la tienda"
>
{isActive ? (
<>
@ -270,30 +519,45 @@ export default function AdminProductsPage() {
</>
) : (
<>
<EyeOff className="w-3.5 h-3.5 text-gray-400" />
<span>Inactivo (Oculto)</span>
<EyeOff className="w-3.5 h-3.5 text-neutral-400" />
<span>Oculto</span>
</>
)}
</button>
</td>
{/* Product Name & Image */}
{/* Image & Title */}
<td className="py-3.5 px-4 flex items-center space-x-3">
<img
src={prod.images[0]}
alt=""
className="w-12 h-12 rounded-xl object-cover bg-gray-950 border border-gray-800 shrink-0"
/>
<div className="relative">
<img
src={prod.images[0]}
alt=""
className="w-12 h-12 rounded-xl object-cover bg-neutral-950 border border-neutral-800 shrink-0"
/>
{colorwaysCount > 1 && (
<span className="absolute -top-1 -right-1 bg-purple-600 text-white text-[9px] font-black px-1.5 py-0.2 rounded-full border border-neutral-900 shadow">
{colorwaysCount}
</span>
)}
</div>
<div>
<p className="font-bold text-white text-xs leading-snug">{prod.name}</p>
<span className="text-[10px] text-gray-400">{prod.category}</span>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-neutral-400">{prod.category}</span>
{colorwaysCount > 1 && (
<span className="text-[10px] text-purple-400 bg-purple-500/10 border border-purple-500/30 px-1.5 py-0.2 rounded font-bold flex items-center gap-0.5">
<Palette className="w-2.5 h-2.5" />
{colorwaysCount} Colores
</span>
)}
</div>
</div>
</td>
{/* Supplier Column */}
{/* Supplier */}
<td className="py-3.5 px-4">
<div className="space-y-1">
<span className="bg-gray-950 text-orange-400 font-bold px-2 py-0.5 rounded border border-gray-800 text-[11px] block w-fit">
<span className="bg-neutral-950 text-purple-300 font-bold px-2 py-0.5 rounded border border-neutral-800 text-[11px] block w-fit">
🏢 {suppName}
</span>
<button
@ -306,12 +570,12 @@ export default function AdminProductsPage() {
</div>
</td>
<td className="py-3.5 px-4 font-bold text-orange-400">{prod.brand}</td>
<td className="py-3.5 px-4 font-bold text-purple-400">{prod.brand}</td>
<td className="py-3.5 px-4 font-mono">{formatCOP(prod.supplierPrice)}</td>
<td className="py-3.5 px-4 font-mono font-bold text-white">{formatCOP(prod.retailPrice)}</td>
<td className="py-3.5 px-4">
<span className="bg-orange-500/20 text-orange-300 border border-orange-500/30 font-bold px-2 py-0.5 rounded font-mono">
<span className="bg-purple-500/20 text-purple-300 border border-purple-500/30 font-bold px-2 py-0.5 rounded font-mono">
+{formatCOP(margin)}
</span>
</td>
@ -319,12 +583,32 @@ export default function AdminProductsPage() {
<td className="py-3.5 px-4">
<div className="flex gap-1 flex-wrap max-w-xs">
{prod.sizes.map((s) => (
<span key={s} className="bg-gray-950 text-gray-300 text-[10px] px-1.5 py-0.5 rounded border border-gray-800 font-bold">
<span key={s} className="bg-neutral-950 text-neutral-300 text-[10px] px-1.5 py-0.5 rounded border border-neutral-800 font-bold">
{s}
</span>
))}
</div>
</td>
{/* Action buttons (Edit & Delete) */}
<td className="py-3.5 px-4 text-center">
<div className="flex items-center justify-center space-x-1.5">
<button
onClick={() => handleOpenEdit(prod)}
className="bg-neutral-950 hover:bg-purple-950/50 border border-neutral-800 hover:border-purple-500/50 text-neutral-400 hover:text-purple-300 p-2 rounded-xl transition-all cursor-pointer"
title="Editar publicación (Precios, Tallas, Proveedor)"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteProduct(prod)}
className="bg-neutral-950 hover:bg-red-950/50 border border-neutral-800 hover:border-red-500/50 text-neutral-400 hover:text-red-400 p-2 rounded-xl transition-all cursor-pointer"
title="Eliminar permanentemente del catálogo"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})
@ -334,32 +618,123 @@ export default function AdminProductsPage() {
</div>
</div>
{/* Modal Add Product */}
{/* Modal de Agrupación Manual de Productos */}
<GroupProductsModal
isOpen={isGroupModalOpen}
onClose={() => setIsGroupModalOpen(false)}
selectedProducts={products.filter((p) => selectedProductIds.includes(p.id))}
onGroupSuccess={(groupedProduct) => {
setProducts((prev) => [
groupedProduct,
...prev.filter((p) => !selectedProductIds.includes(p.id) && p.id !== groupedProduct.id),
]);
setSelectedProductIds([]);
setToastMessage(`¡Se unificaron ${selectedProductIds.length} productos con éxito en "${groupedProduct.name}"!`);
}}
/>
{/* Modal 1: Process Single Message with AI (1 a 1) */}
{isAiModalOpen && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
<h3 className="font-bold text-white text-base flex items-center gap-2">
<Wand2 className="w-5 h-5 text-fuchsia-400" />
<span>Procesar Publicación 1 a 1 con IA</span>
</h3>
<button onClick={() => setIsAiModalOpen(false)} className="text-neutral-400 hover:text-white cursor-pointer">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-3 text-xs">
<div>
<label className="block text-neutral-300 font-bold mb-1">
Pega el Texto del Proveedor / Grupo de WhatsApp *
</label>
<textarea
rows={4}
placeholder="Ej: 🔥 LLEGANDO HOY 🔥&#10;Nike Air Jordan 4 Retro White Cement&#10;Tallas: 38 a 43&#10;Mayorista: $95.000 c/u"
value={rawText}
onChange={(e) => setRawText(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl p-3 text-white outline-hidden focus:border-purple-400 font-mono text-xs"
/>
</div>
<div>
<label className="block text-neutral-300 font-bold mb-1">URL de la Foto / Imagen</label>
<input
type="text"
placeholder="https://images.unsplash.com/..."
value={rawImageUrl}
onChange={(e) => setRawImageUrl(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-400"
/>
</div>
<button
type="button"
onClick={handleProcessSinglePostWithAI}
className="w-full bg-purple-600 hover:bg-purple-500 text-white font-bold py-2.5 rounded-xl shadow-md flex items-center justify-center gap-2 cursor-pointer transition-all"
>
<Sparkles className="w-4 h-4 text-fuchsia-300" />
<span>Extraer Título, Tallas & Calcular Margen 35% con IA</span>
</button>
{aiExtracted && (
<div className="bg-neutral-950 border border-purple-500/40 rounded-2xl p-4 space-y-2 animate-in zoom-in-95">
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold text-purple-400">✨ Resultado Extraído por IA</span>
<span className="bg-emerald-500/20 text-emerald-300 font-bold px-2 py-0.5 rounded text-[10px]">Listo para Publicar</span>
</div>
<p className="text-white font-bold text-sm">{aiExtracted.name}</p>
<div className="grid grid-cols-2 gap-2 text-[11px] text-neutral-300">
<p><strong>Marca:</strong> {aiExtracted.brand}</p>
<p><strong>Tallas:</strong> {aiExtracted.sizes?.join(', ')}</p>
<p><strong>Costo Mayorista:</strong> {formatCOP(aiExtracted.supplierPrice || 0)}</p>
<p className="text-emerald-400"><strong>Precio Venta (35%):</strong> {formatCOP(aiExtracted.retailPrice || 0)}</p>
</div>
<button
type="button"
onClick={handlePublishAiProduct}
className="w-full bg-gradient-to-r from-emerald-600 to-teal-500 hover:from-emerald-500 hover:to-teal-400 text-white font-black py-2.5 rounded-xl shadow-lg flex items-center justify-center gap-2 cursor-pointer mt-2"
>
<span>Publicar Inmediatamente en la Tienda</span>
<ArrowRight className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
</div>
)}
{/* Modal 2: Add Product Manually */}
{isModalOpen && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-gray-900 border border-gray-800 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
<h3 className="font-bold text-white text-base">Registrar Nueva Zapatilla</h3>
<form onSubmit={handleCreateProduct} className="space-y-3 text-xs">
<div>
<label className="block text-gray-400 font-bold mb-1">Nombre del Modelo *</label>
<label className="block text-neutral-400 font-bold mb-1">Nombre del Modelo *</label>
<input
type="text"
required
placeholder="Ej. Nike Air Jordan 1 High Chicago"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-950 border border-gray-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
{/* Supplier Selection */}
<div>
<label className="block text-orange-400 font-bold mb-1">Proveedor Asignado (Interno) *</label>
<label className="block text-purple-400 font-bold mb-1">Proveedor Asignado (Interno) *</label>
<select
value={selectedSupplierId}
onChange={(e) => setSelectedSupplierId(e.target.value)}
className="w-full bg-gray-950 border border-gray-800 rounded-xl px-3 py-2 text-white outline-hidden font-semibold"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden font-semibold"
>
{suppliers.map((s) => (
<option key={s.id} value={s.id}>
@ -367,18 +742,15 @@ export default function AdminProductsPage() {
</option>
))}
</select>
<p className="text-[10px] text-gray-500 mt-0.5">
Este proveedor recibirá la notificación para separar el par cuando se haga un pedido.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-gray-400 font-bold mb-1">Marca</label>
<label className="block text-neutral-400 font-bold mb-1">Marca</label>
<select
value={brand}
onChange={(e) => setBrand(e.target.value)}
className="w-full bg-gray-950 border border-gray-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
>
<option value="Nike">Nike</option>
<option value="Jordan">Jordan</option>
@ -389,81 +761,73 @@ export default function AdminProductsPage() {
</div>
<div>
<label className="block text-gray-400 font-bold mb-1">Categoría</label>
<label className="block text-neutral-400 font-bold mb-1">Categoría</label>
<input
type="text"
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-gray-950 border border-gray-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-gray-400 font-bold mb-1">Costo Base Mayorista (COP)</label>
<label className="block text-neutral-400 font-bold mb-1">Costo Proveedor ($ COP) *</label>
<input
type="number"
required
value={supplierPrice}
onChange={(e) => setSupplierPrice(e.target.value)}
className="w-full bg-gray-950 border border-gray-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div>
<label className="block text-gray-400 font-bold mb-1">Precio Venta Sugerido (COP)</label>
<label className="block text-neutral-400 font-bold mb-1">Precio Venta Público ($ COP) *</label>
<input
type="number"
required
value={retailPrice}
onChange={(e) => setRetailPrice(e.target.value)}
className="w-full bg-gray-950 border border-gray-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden font-bold text-purple-400"
/>
</div>
</div>
<div>
<label className="block text-gray-400 font-bold mb-1">Tallas Disponibles (separadas por coma)</label>
<label className="block text-neutral-400 font-bold mb-1">Tallas Disponibles (separadas por coma)</label>
<input
type="text"
required
value={sizesInput}
onChange={(e) => setSizesInput(e.target.value)}
className="w-full bg-gray-950 border border-gray-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div>
<label className="block text-gray-400 font-bold mb-1">URL de Imagen Principal</label>
<label className="block text-neutral-400 font-bold mb-1">URL Imagen del Producto</label>
<input
type="url"
type="text"
required
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
className="w-full bg-gray-950 border border-gray-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div className="pt-2">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={isActiveNew}
onChange={(e) => setIsActiveNew(e.target.checked)}
className="rounded text-orange-500 focus:ring-0"
/>
<span className="text-gray-200 font-bold">Publicar inmediatamente en la tienda (Activo)</span>
</label>
</div>
<div className="flex justify-end space-x-2 pt-3 border-t border-gray-800">
<div className="flex justify-end space-x-2 pt-3 border-t border-neutral-800">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="bg-gray-800 hover:bg-gray-700 text-gray-300 font-bold py-2 px-4 rounded-xl cursor-pointer"
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold py-2 px-4 rounded-xl cursor-pointer"
>
Cancelar
</button>
<button
type="submit"
className="bg-gradient-to-r from-red-600 to-orange-500 hover:from-red-500 hover:to-orange-400 text-white font-bold py-2 px-4 rounded-xl shadow-md cursor-pointer"
className="bg-gradient-to-r from-purple-600 to-fuchsia-500 hover:from-purple-500 hover:to-fuchsia-400 text-white font-bold py-2 px-4 rounded-xl shadow-md cursor-pointer"
>
Guardar Zapatilla
</button>
@ -472,6 +836,135 @@ export default function AdminProductsPage() {
</div>
</div>
)}
{/* Modal 3: Edit Existing Product (PUT) */}
{editingProduct && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
<h3 className="font-bold text-white text-base flex items-center gap-2">
<Edit className="w-5 h-5 text-purple-400" />
<span>Editar Publicación: {editingProduct.name}</span>
</h3>
<button onClick={() => setEditingProduct(null)} className="text-neutral-400 hover:text-white cursor-pointer">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSaveEdit} className="space-y-3 text-xs">
<div>
<label className="block text-neutral-400 font-bold mb-1">Nombre del Modelo *</label>
<input
type="text"
required
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 outline-hidden focus:border-purple-400"
/>
</div>
<div>
<label className="block text-purple-400 font-bold mb-1">Proveedor Asignado *</label>
<select
value={editSupplierId}
onChange={(e) => setEditSupplierId(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden font-semibold"
>
{suppliers.map((s) => (
<option key={s.id} value={s.id}>
{s.name} — WhatsApp: {s.phone}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-neutral-400 font-bold 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-400"
/>
</div>
<div>
<label className="block text-neutral-400 font-bold mb-1">Categoría</label>
<input
type="text"
value={editCategory}
onChange={(e) => setEditCategory(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-400"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-neutral-400 font-bold mb-1">Costo Proveedor ($ COP) *</label>
<input
type="number"
required
value={editSupplierPrice}
onChange={(e) => setEditSupplierPrice(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div>
<label className="block text-neutral-400 font-bold mb-1">Precio Venta Público ($ COP) *</label>
<input
type="number"
required
value={editRetailPrice}
onChange={(e) => setEditRetailPrice(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden font-bold text-purple-400"
/>
</div>
</div>
<div>
<label className="block text-neutral-400 font-bold mb-1">Tallas Disponibles</label>
<input
type="text"
required
value={editSizesInput}
onChange={(e) => setEditSizesInput(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div>
<label className="block text-neutral-400 font-bold mb-1">URL Imagen</label>
<input
type="text"
required
value={editImageUrl}
onChange={(e) => setEditImageUrl(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div className="flex justify-end space-x-2 pt-3 border-t border-neutral-800">
<button
type="button"
onClick={() => setEditingProduct(null)}
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold py-2 px-4 rounded-xl cursor-pointer"
>
Cancelar
</button>
<button
type="submit"
className="bg-gradient-to-r from-purple-600 to-fuchsia-500 hover:from-purple-500 hover:to-fuchsia-400 text-white font-bold py-2 px-4 rounded-xl shadow-md cursor-pointer"
>
Guardar Cambios
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -17,6 +17,85 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const body = await request.json();
// ACCIÓN MANUAL: Agrupar múltiples productos como variantes de un solo modelo
if (body.action === 'group-products') {
const { targetProductId, productIdsToMerge, customModelName, customBrand } = body;
const allProducts = dataService.getProducts();
const target = allProducts.find((p) => p.id === targetProductId);
if (!target) {
return NextResponse.json({ success: false, error: 'Producto objetivo no encontrado' }, { status: 404 });
}
const colors =
target.colors && target.colors.length > 0
? [...target.colors]
: [
{
id: `color-${target.id}-1`,
colorName: target.colorName || 'Estilo Principal',
image: target.images[0],
images: target.images,
sizes: target.sizes,
inStock: target.inStock !== false,
price: target.retailPrice,
sizePrices: target.sizePrices,
},
];
for (const mergeId of productIdsToMerge || []) {
if (mergeId === targetProductId) continue;
const toMerge = allProducts.find((p) => p.id === mergeId);
if (!toMerge) continue;
const mergeColors =
toMerge.colors && toMerge.colors.length > 0
? toMerge.colors
: [
{
id: `color-${toMerge.id}`,
colorName: toMerge.colorName || toMerge.name || `Estilo #${colors.length + 1}`,
image: toMerge.images[0],
images: toMerge.images,
sizes: toMerge.sizes,
inStock: toMerge.inStock !== false,
price: toMerge.retailPrice,
sizePrices: toMerge.sizePrices,
},
];
mergeColors.forEach((mc) => {
if (!colors.some((c) => c.image === mc.image)) {
colors.push(mc);
}
});
target.images = Array.from(new Set([...target.images, ...toMerge.images]));
target.sizes = Array.from(new Set([...target.sizes, ...toMerge.sizes])).sort(
(a, b) => parseInt(a, 10) - parseInt(b, 10)
);
// Eliminar el producto que fue fusionado
dataService.deleteProduct(toMerge.id);
}
target.colors = colors;
if (customModelName) {
target.name = customModelName;
target.modelName = customModelName;
}
if (customBrand) {
target.brand = customBrand;
}
dataService.updateProduct(target.id, target);
return NextResponse.json({
success: true,
product: target,
message: `¡Se agruparon ${productIdsToMerge.length} productos con éxito en "${target.name}"!`,
});
}
const proof = getProductSocialProof(body);
const newProduct = dataService.addProduct({
...body,

View File

@ -203,7 +203,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const body = await request.json();
const { action, post, posts, customRetailPrice, customName, customSizes } = body;
const { action, post, posts, customRetailPrice, customName, customColorName, customSizes } = body;
// 1. PUBLICAR UN PRODUCTO A LA TIENDA (APP MÓVIL Y WEB)
if (action === 'publish' && post) {
@ -220,6 +220,7 @@ export async function POST(request: Request) {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName,
modelName: prodName,
colorName: customColorName || post.extractedProduct?.colorName,
brand: prodBrand,
supplierPrice: post.extractedProduct?.supplierPrice || 75000,
retailPrice: retailPrice,

View File

@ -0,0 +1,263 @@
'use client';
import React, { useState } from 'react';
import { Layers, X, Check, Palette, Sparkles, ArrowRight, ShieldCheck } from 'lucide-react';
import { Product, ProductColorVariant } from '@/types';
import { formatCOP } from '@/lib/pricing-engine';
interface GroupProductsModalProps {
isOpen: boolean;
onClose: () => void;
selectedProducts: Product[];
onGroupSuccess: (groupedProduct: Product) => void;
}
export const GroupProductsModal: React.FC<GroupProductsModalProps> = ({
isOpen,
onClose,
selectedProducts,
onGroupSuccess,
}) => {
if (!isOpen || selectedProducts.length < 2) return null;
// Elegir el producto principal por defecto (el primero seleccionado)
const [masterId, setMasterId] = useState<string>(selectedProducts[0].id);
const masterProduct = selectedProducts.find((p) => p.id === masterId) || selectedProducts[0];
const [masterName, setMasterName] = useState(masterProduct.modelName || masterProduct.name);
const [masterBrand, setMasterBrand] = useState(masterProduct.brand);
const [masterPrice, setMasterPrice] = useState(masterProduct.retailPrice);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Nombres y precios personalizados para cada variante de color
const [variantConfigs, setVariantConfigs] = useState<
Record<string, { colorName: string; price: number; sizes: string }>
>(() => {
const initial: Record<string, { colorName: string; price: number; sizes: string }> = {};
selectedProducts.forEach((p, idx) => {
initial[p.id] = {
colorName: p.colorName || p.name.replace(p.brand, '').trim() || `Estilo #${idx + 1}`,
price: p.retailPrice,
sizes: p.sizes.join(', '),
};
});
return initial;
});
const handleVariantChange = (
id: string,
field: 'colorName' | 'price' | 'sizes',
value: string | number
) => {
setVariantConfigs((prev) => ({
...prev,
[id]: {
...prev[id],
[field]: value,
},
}));
};
const handleConfirmGroup = async () => {
setIsSubmitting(true);
setErrorMessage(null);
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'group-products',
targetProductId: masterId,
productIdsToMerge: selectedProducts.map((p) => p.id),
customModelName: masterName,
customBrand: masterBrand,
}),
});
const data = await res.json();
if (data.success && data.product) {
onGroupSuccess(data.product);
onClose();
} else {
setErrorMessage(data.error || 'Error al agrupar productos');
}
} catch (err: any) {
setErrorMessage(err.message || 'Error de conexión');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 bg-black/85 backdrop-blur-md flex items-center justify-center p-4 overflow-y-auto animate-in fade-in">
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-2xl w-full p-6 space-y-5 shadow-2xl my-auto">
{/* Header */}
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-xl bg-purple-500/20 text-purple-400 flex items-center justify-center">
<Layers className="w-5 h-5" />
</div>
<div>
<h3 className="font-black text-white text-base">
Agrupar {selectedProducts.length} Zapatillas como Variantes de un Modelo
</h3>
<p className="text-neutral-400 text-xs">
Se unificarán en 1 sola publicación con selector interactivo de colorways.
</p>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white flex items-center justify-center cursor-pointer transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{errorMessage && (
<div className="bg-red-950/40 border border-red-500/40 rounded-xl p-3 text-red-300 text-xs">
{errorMessage}
</div>
)}
{/* Modelo Principal */}
<div className="bg-neutral-950 p-4 rounded-2xl border border-neutral-800 space-y-3">
<span className="text-[11px] font-black text-purple-400 uppercase tracking-wider block">
1. Datos Generales de la Publicación Unificada
</span>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
<div className="sm:col-span-2">
<label className="font-bold text-neutral-300 block mb-1">Nombre del Modelo Base:</label>
<input
type="text"
value={masterName}
onChange={(e) => setMasterName(e.target.value)}
placeholder="Ej: Adidas Samba OG"
className="w-full bg-neutral-900 border border-neutral-700 rounded-xl px-3 py-2 text-white font-bold outline-hidden focus:border-purple-500"
/>
</div>
<div>
<label className="font-bold text-neutral-300 block mb-1">Marca:</label>
<input
type="text"
value={masterBrand}
onChange={(e) => setMasterBrand(e.target.value)}
className="w-full bg-neutral-900 border border-neutral-700 rounded-xl px-3 py-2 text-white font-bold outline-hidden focus:border-purple-500"
/>
</div>
</div>
</div>
{/* Lista de Variantes / Estilos */}
<div className="space-y-2.5">
<span className="text-[11px] font-black text-purple-400 uppercase tracking-wider block">
2. Configuración de Cada Variante de Color ({selectedProducts.length})
</span>
<div className="max-h-64 overflow-y-auto space-y-2.5 pr-1">
{selectedProducts.map((prod, idx) => {
const isMaster = prod.id === masterId;
const cfg = variantConfigs[prod.id] || {
colorName: `Estilo #${idx + 1}`,
price: prod.retailPrice,
sizes: prod.sizes.join(', '),
};
return (
<div
key={prod.id}
className={`p-3 rounded-2xl border transition-all flex flex-col sm:flex-row items-center gap-3 ${
isMaster
? 'bg-purple-950/20 border-purple-500/60 ring-1 ring-purple-500/40'
: 'bg-neutral-950/60 border-neutral-800 hover:border-neutral-700'
}`}
>
<img
src={prod.images[0]}
alt=""
className="w-14 h-14 rounded-xl object-cover bg-neutral-900 border border-neutral-700 shrink-0"
/>
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-2 w-full text-xs">
<div>
<label className="text-[10px] text-neutral-400 font-bold block mb-0.5">
Nombre del Estilo/Color:
</label>
<input
type="text"
value={cfg.colorName}
onChange={(e) => handleVariantChange(prod.id, 'colorName', e.target.value)}
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-2 py-1.5 text-white font-semibold outline-hidden focus:border-purple-500 text-xs"
/>
</div>
<div>
<label className="text-[10px] text-emerald-400 font-bold block mb-0.5">
Precio Venta:
</label>
<input
type="number"
value={cfg.price}
onChange={(e) =>
handleVariantChange(prod.id, 'price', Number(e.target.value))
}
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-2 py-1.5 text-emerald-300 font-mono font-bold outline-hidden focus:border-emerald-500 text-xs"
/>
</div>
<div>
<label className="text-[10px] text-neutral-400 font-bold block mb-0.5">
Tallas:
</label>
<input
type="text"
value={cfg.sizes}
onChange={(e) => handleVariantChange(prod.id, 'sizes', e.target.value)}
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-2 py-1.5 text-white outline-hidden focus:border-purple-500 text-xs"
/>
</div>
</div>
<button
type="button"
onClick={() => setMasterId(prod.id)}
className={`text-[10px] font-bold px-2.5 py-1.5 rounded-lg border shrink-0 cursor-pointer ${
isMaster
? 'bg-purple-600 text-white border-purple-400'
: 'bg-neutral-900 text-neutral-400 border-neutral-700 hover:text-white'
}`}
>
{isMaster ? '★ Principal' : 'Hacer Principal'}
</button>
</div>
);
})}
</div>
</div>
{/* Footer */}
<div className="flex items-center gap-3 pt-3 border-t border-neutral-800">
<button
onClick={onClose}
disabled={isSubmitting}
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={handleConfirmGroup}
disabled={isSubmitting}
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 flex items-center justify-center gap-2"
>
<Check className="w-4 h-4" />
<span>{isSubmitting ? 'Agrupando modelos...' : 'Confirmar y Fusionar en 1 Modelo'}</span>
</button>
</div>
</div>
</div>
);
};

View File

@ -3,7 +3,7 @@
import React, { useState } from 'react';
import { Star, Zap, Truck, ShoppingBag, Layers, Palette, Sparkles } from 'lucide-react';
import { Product } from '@/types';
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
import { formatCOP, calculateSeparationBreakdown, getProductPriceRange } from '@/lib/pricing-engine';
import { useBrand } from '@/context/BrandContext';
import { getProductSocialProof } from '@/lib/social-proof';
@ -21,6 +21,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
const [activeImage, setActiveImage] = useState<string>(product.images[0]);
const [activeColorName, setActiveColorName] = useState<string>(product.colorName || '');
const socialProof = getProductSocialProof(product);
const priceRange = getProductPriceRange(product);
const discountPercent = product.originalPrice
? Math.round(((product.originalPrice - product.retailPrice) / product.originalPrice) * 100)
@ -145,7 +146,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
<div className="pt-2 border-t border-slate-800">
<div className="flex items-baseline space-x-2">
<span className={`text-lg sm:text-xl font-black text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>
{formatCOP(product.retailPrice)}
{priceRange.hasVariation ? `Desde ${formatCOP(priceRange.minPrice)}` : formatCOP(product.retailPrice)}
</span>
{product.originalPrice && (
<span className="text-xs text-slate-500 line-through font-semibold">
@ -155,7 +156,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
</div>
<p className="text-[11px] font-semibold bg-white/5 px-2 py-1 rounded-md mt-1.5 border border-white/10 text-slate-300">
⚡ Separa con <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y cancelas <strong className="text-white">{formatCOP(separation.balanceDue)}</strong> al recibir.
⚡ Separa con <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y cancelas al recibir.
</p>
{/* Quick Action Button */}

View File

@ -17,7 +17,7 @@ import {
ArrowRight,
} from 'lucide-react';
import { Product, City, ProductColorVariant } from '@/types';
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
import { formatCOP, calculateSeparationBreakdown, getProductEffectivePrice } from '@/lib/pricing-engine';
import { useBrand } from '@/context/BrandContext';
import { getProductSocialProof } from '@/lib/social-proof';
@ -75,8 +75,10 @@ export const ProductModal: React.FC<ProductModalProps> = ({
const isSizeInStock = availableSizes.includes(selectedSize);
// Precio dinámico efectivo según colorway y talla seleccionada
const currentPrice = getProductEffectivePrice(product, selectedColor, selectedSize);
const socialProof = getProductSocialProof(product);
const separation = calculateSeparationBreakdown(product.retailPrice);
const separation = calculateSeparationBreakdown(currentPrice);
const handleAdd = () => {
if (!isSizeInStock) return;
@ -84,6 +86,7 @@ export const ProductModal: React.FC<ProductModalProps> = ({
{
...product,
name: `${product.modelName || product.name} (${selectedColor.colorName})`,
retailPrice: currentPrice,
images: selectedColor.images || [selectedImage],
},
selectedSize
@ -102,7 +105,7 @@ export const ProductModal: React.FC<ProductModalProps> = ({
👟 *${product.modelName || product.name}*
🎨 Color: *${selectedColor.colorName}*
📏 Talla: *${selectedSize}* (Horma Nacional)
💎 Precio: *${formatCOP(product.retailPrice)} COP*
💎 Precio: *${formatCOP(currentPrice)} COP*
🚚 Entrega en: *${selectedCity.name}* (Pago Contraentrega)
⚡ Deseo separar con $10.000 COP y cancelar el saldo en mi casa.
@ -225,7 +228,7 @@ export const ProductModal: React.FC<ProductModalProps> = ({
<div className="bg-neutral-950/80 border border-neutral-800 rounded-2xl p-3.5 space-y-2">
<div className="flex items-baseline space-x-3">
<span className={`text-2xl sm:text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>
{formatCOP(product.retailPrice)}
{formatCOP(currentPrice)}
</span>
{product.originalPrice && (
<span className="text-sm text-neutral-500 line-through font-bold">
@ -313,12 +316,14 @@ export const ProductModal: React.FC<ProductModalProps> = ({
{displaySizes.map((size) => {
const inStock = availableSizes.includes(size);
const isSelected = selectedSize === size;
const sizePrice = getProductEffectivePrice(product, selectedColor, size);
const hasCustomSizePrice = sizePrice !== product.retailPrice;
return (
<button
key={size}
onClick={() => setSelectedSize(size)}
className={`relative py-2.5 rounded-xl text-xs font-black transition-all cursor-pointer flex flex-col items-center justify-center ${
className={`relative py-2 rounded-xl text-xs font-black transition-all cursor-pointer flex flex-col items-center justify-center ${
isSelected
? inStock
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-lg scale-105 border border-white/30`
@ -329,8 +334,8 @@ export const ProductModal: React.FC<ProductModalProps> = ({
}`}
>
<span>{size}</span>
<span className="text-[8px] font-mono leading-none mt-0.5">
{inStock ? 'Listo' : 'Pedir'}
<span className="text-[8px] font-mono leading-none mt-0.5 opacity-80">
{hasCustomSizePrice ? `$${Math.round(sizePrice / 1000)}k` : inStock ? 'Listo' : 'Pedir'}
</span>
</button>
);

View File

@ -112,3 +112,66 @@ export function generateSalesCopy(params: {
📲 ¡Pídelas antes de que se agote tu talla!
👉 Pedir directo por WhatsApp: ${waLink}`;
}
/**
* Obtiene el precio exacto de venta de un producto según el estilo/color y la talla seleccionada.
*/
export function getProductEffectivePrice(
product: {
retailPrice: number;
sizePrices?: Record<string, number>;
},
selectedColor?: {
price?: number;
sizePrices?: Record<string, number>;
} | null,
selectedSize?: string | null
): number {
if (selectedColor && selectedSize && selectedColor.sizePrices && selectedColor.sizePrices[selectedSize]) {
return selectedColor.sizePrices[selectedSize];
}
if (selectedSize && product.sizePrices && product.sizePrices[selectedSize]) {
return product.sizePrices[selectedSize];
}
if (selectedColor && selectedColor.price && selectedColor.price > 0) {
return selectedColor.price;
}
return product.retailPrice || 120000;
}
/**
* Obtiene el rango de precios si varían según la talla o colorway
*/
export function getProductPriceRange(product: {
retailPrice: number;
sizePrices?: Record<string, number>;
colors?: Array<{ price?: number; sizePrices?: Record<string, number> }>;
}): { minPrice: number; maxPrice: number; hasVariation: boolean } {
const prices: number[] = [product.retailPrice || 120000];
if (product.sizePrices) {
Object.values(product.sizePrices).forEach((p) => {
if (typeof p === 'number' && p > 0) prices.push(p);
});
}
if (Array.isArray(product.colors)) {
product.colors.forEach((c) => {
if (c.price && c.price > 0) prices.push(c.price);
if (c.sizePrices) {
Object.values(c.sizePrices).forEach((p) => {
if (typeof p === 'number' && p > 0) prices.push(p);
});
}
});
}
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
return {
minPrice,
maxPrice,
hasVariation: minPrice !== maxPrice,
};
}

View File

@ -40,6 +40,8 @@ export interface ProductColorVariant {
images?: string[];
sizes: string[];
inStock?: boolean;
price?: number; // Precio específico de esta variante
sizePrices?: Record<string, number>; // Precios específicos por talla (ej: { "36": 110000, "42": 125000 })
}
export interface Product {
@ -53,6 +55,7 @@ export interface Product {
originalPrice?: number;
sizes: string[];
allSizes?: string[];
sizePrices?: Record<string, number>; // Precios variables por talla a nivel general
images: string[];
colors?: ProductColorVariant[];
description: string;
@ -149,6 +152,7 @@ export interface SupplierPost {
export interface ExtractedProductData {
name: string;
brand: string;
colorName?: string;
sizes: string[];
supplierPrice: number;
suggestedRetailPrice: number;

View File

@ -28,10 +28,12 @@ import {
import { SupplierPost, Product, MonitoredWhatsAppGroup } from '@/types';
import { formatCOP } from '@/lib/pricing-engine';
import { BRANDS, BrandId } from '@/config/brands';
import { normalizeModelKey } from '@/lib/model-grouper';
export default function SupplierFeedPage() {
const [posts, setPosts] = useState<SupplierPost[]>([]);
const [monitoredGroups, setMonitoredGroups] = useState<MonitoredWhatsAppGroup[]>([]);
const [existingProducts, setExistingProducts] = useState<Product[]>([]);
const [selectedGroupFilter, setSelectedGroupFilter] = useState<string>('all');
const [tabStatus, setTabStatus] = useState<'pending' | 'published' | 'discarded'>('pending');
const [isLoading, setIsLoading] = useState(false);
@ -45,13 +47,15 @@ export default function SupplierFeedPage() {
const [editingPost, setEditingPost] = useState<SupplierPost | null>(null);
const [editName, setEditName] = useState('');
const [editBrand, setEditBrand] = useState('');
const [editColorName, setEditColorName] = useState('');
const [editPrice, setEditPrice] = useState(120000);
const [editSizes, setEditSizes] = useState('');
const [mergeWithExisting, setMergeWithExisting] = useState(true);
// Target brand selector for publishing
const [targetBrand, setTargetBrand] = useState<BrandId | 'all'>('almeira');
// Cargar grupos monitoreados y posts iniciales en vivo
// Cargar grupos monitoreados, productos existentes y posts en vivo
const loadInitialData = async () => {
setIsLoading(true);
try {
@ -62,7 +66,14 @@ export default function SupplierFeedPage() {
setMonitoredGroups(grpData.groups);
}
// 2. Posts en vivo desde los grupos de WhatsApp
// 2. Productos existentes en el catálogo
const prodRes = await fetch('/api/products?all=true');
const prodData = await prodRes.json();
if (prodData.success && Array.isArray(prodData.products)) {
setExistingProducts(prodData.products);
}
// 3. Posts en vivo desde los grupos de WhatsApp
const feedRes = await fetch('/api/whatsapp-feed?fetchLive=true&limit=50');
const feedData = await feedRes.json();
if (feedData.success && Array.isArray(feedData.posts)) {
@ -136,7 +147,7 @@ export default function SupplierFeedPage() {
// Publicar un producto a la tienda
const handlePublishPost = async (
post: SupplierPost,
customData?: { name?: string; brand?: string; retailPrice?: number; sizes?: string[] }
customData?: { name?: string; brand?: string; colorName?: string; retailPrice?: number; sizes?: string[] }
) => {
try {
const res = await fetch('/api/whatsapp-feed', {
@ -146,6 +157,7 @@ export default function SupplierFeedPage() {
action: 'publish',
post,
customName: customData?.name,
customColorName: customData?.colorName,
customRetailPrice: customData?.retailPrice,
customSizes: customData?.sizes,
}),
@ -251,13 +263,17 @@ export default function SupplierFeedPage() {
const handleOpenEdit = (post: SupplierPost) => {
setEditingPost(post);
setEditName(post.extractedProduct?.name || 'Zapatilla Urbana');
setEditBrand(post.extractedProduct?.brand || 'Nike');
const prodName = post.extractedProduct?.name || 'Zapatilla Urbana';
const prodBrand = post.extractedProduct?.brand || 'Nike';
setEditName(prodName);
setEditBrand(prodBrand);
setEditColorName(post.extractedProduct?.colorName || '');
setEditPrice(
post.extractedProduct?.suggestedRetailPrice ||
(post.extractedProduct?.supplierPrice ? post.extractedProduct.supplierPrice + 35000 : 125000)
);
setEditSizes(post.extractedProduct?.sizes?.join(', ') || '38, 39, 40, 41, 42, 43');
setMergeWithExisting(true);
};
return (
@ -308,7 +324,7 @@ export default function SupplierFeedPage() {
</div>
<button
onClick={() => setActionMessage(null)}
className="text-white hover:text-purple-300 font-bold px-2 cursor-pointer"
className="text-purple-400 hover:text-white font-bold"
>
✕
</button>
@ -619,96 +635,168 @@ export default function SupplierFeedPage() {
)}
{/* 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>
{editingPost && (() => {
const matchingProduct = existingProducts.find(
(p) => normalizeModelKey(p.modelName || p.name, p.brand) === normalizeModelKey(editName, editBrand)
);
<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"
/>
return (
<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 y Validar antes de Publicar</span>
</h3>
<button
onClick={() => setEditingPost(null)}
className="text-neutral-400 hover:text-white font-bold"
>
✕
</button>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Pre-validación de Modelo Existente */}
{matchingProduct && (
<div className="bg-purple-950/40 border border-purple-500/50 rounded-2xl p-3.5 space-y-2.5">
<div className="flex items-center gap-2 text-purple-300 font-bold text-xs">
<Sparkles className="w-4 h-4 text-purple-400" />
<span>¡Modelo Existente Detectado en la Tienda!</span>
</div>
<div className="flex items-center justify-between bg-neutral-950 p-2.5 rounded-xl border border-neutral-800">
<div className="flex items-center gap-2.5">
<img
src={matchingProduct.images[0]}
className="w-10 h-10 rounded-lg object-cover bg-neutral-900 border border-neutral-800"
alt=""
/>
<div>
<p className="font-black text-white text-xs">{matchingProduct.name}</p>
<p className="text-[10px] text-neutral-400">
{matchingProduct.colors?.length || 1} variantes de color existentes
</p>
</div>
</div>
<span className="text-[10px] bg-purple-600/30 text-purple-300 border border-purple-500/40 font-bold px-2 py-0.5 rounded-md">
Coincidencia
</span>
</div>
<div className="flex flex-col gap-1.5 text-xs">
<label className="flex items-center gap-2 text-neutral-200 cursor-pointer font-semibold">
<input
type="radio"
name="mergeOption"
checked={mergeWithExisting}
onChange={() => setMergeWithExisting(true)}
className="accent-purple-600"
/>
<span>Agregar como nueva variante de color a "{matchingProduct.name}"</span>
</label>
<label className="flex items-center gap-2 text-neutral-400 hover:text-neutral-200 cursor-pointer">
<input
type="radio"
name="mergeOption"
checked={!mergeWithExisting}
onChange={() => setMergeWithExisting(false)}
className="accent-purple-600"
/>
<span>Publicar como un modelo nuevo independiente</span>
</label>
</div>
</div>
)}
<div className="space-y-3.5 text-xs">
<div>
<label className="font-bold text-neutral-300 block mb-1">Marca:</label>
<label className="font-bold text-neutral-300 block mb-1">Nombre Comercial del Modelo:</label>
<input
type="text"
value={editBrand}
onChange={(e) => setEditBrand(e.target.value)}
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>
{matchingProduct && mergeWithExisting && (
<div>
<label className="font-bold text-purple-400 block mb-1">Nombre de este Estilo / Colorway:</label>
<input
type="text"
value={editColorName}
onChange={(e) => setEditColorName(e.target.value)}
placeholder="Ej: Blanco / Negro, Gum Edition, Rosa Pastel"
className="w-full bg-neutral-950 border border-purple-500/50 rounded-xl px-3 py-2 text-white font-semibold 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>
<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 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: !mergeWithExisting && matchingProduct ? `${editName} (${editColorName || 'Variante'})` : editName,
colorName: editColorName,
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"
>
{matchingProduct && mergeWithExisting ? 'Fusionar y Publicar en App' : 'Guardar y Publicar en App'}
</button>
</div>
</div>
<div className="flex items-center gap-2.5 pt-3 border-t border-neutral-800">
<button
onClick={() => setEditingPost(null)}
className="flex-1 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold text-xs py-3 rounded-xl cursor-pointer"
>
Cancelar
</button>
<button
onClick={() => {
const sizesArray = editSizes
.split(',')
.map((s) => s.trim())
.filter(Boolean);
handlePublishPost(editingPost, {
name: editName,
brand: editBrand,
retailPrice: editPrice,
sizes: sizesArray,
});
}}
className="flex-1 bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:opacity-95 text-white font-black text-xs py-3 rounded-xl shadow-lg shadow-purple-600/30 cursor-pointer"
>
Guardar y Publicar en la App
</button>
</div>
</div>
</div>
)}
);
})()}
</div>
);
}

View File

@ -1,10 +1,11 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck } from 'lucide-react';
import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck, Trash2, Edit, Wand2, ArrowRight, Layers, Palette, CheckSquare, Square } from 'lucide-react';
import { Product, Supplier } from '@/types';
import { INITIAL_PRODUCTS, INITIAL_SUPPLIERS } from '@/lib/data-service';
import { formatCOP } from '@/lib/pricing-engine';
import { GroupProductsModal } from '@/components/admin/GroupProductsModal';
export default function AdminProductsPage() {
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
@ -13,6 +14,10 @@ export default function AdminProductsPage() {
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [supplierFilter, setSupplierFilter] = useState<string>('all');
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAiModalOpen, setIsAiModalOpen] = useState(false);
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
const [selectedProductIds, setSelectedProductIds] = useState<string[]>([]);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
// New product form
@ -28,6 +33,21 @@ export default function AdminProductsPage() {
const [category, setCategory] = useState('Sneakers / Urbano');
const [isActiveNew, setIsActiveNew] = useState(true);
// Edit product form
const [editName, setEditName] = useState('');
const [editBrand, setEditBrand] = useState('');
const [editSupplierId, setEditSupplierId] = useState('');
const [editSupplierPrice, setEditSupplierPrice] = useState('');
const [editRetailPrice, setEditRetailPrice] = useState('');
const [editSizesInput, setEditSizesInput] = useState('');
const [editImageUrl, setEditImageUrl] = useState('');
const [editCategory, setEditCategory] = useState('');
// 1-to-1 AI processor form
const [rawText, setRawText] = useState('');
const [rawImageUrl, setRawImageUrl] = useState('');
const [aiExtracted, setAiExtracted] = useState<Partial<Product> | null>(null);
useEffect(() => {
fetch('/api/products?all=true')
.then((r) => r.json())
@ -77,6 +97,25 @@ export default function AdminProductsPage() {
window.open(`https://wa.me/${phone}?text=${text}`, '_blank');
};
const handleDeleteProduct = async (product: Product) => {
if (!window.confirm(`¿Estás seguro de eliminar permanentemente la publicación "${product.name}"?`)) {
return;
}
try {
const res = await fetch(`/api/products?id=${product.id}`, {
method: 'DELETE',
});
const data = await res.json();
if (data.success) {
setProducts((prev) => prev.filter((p) => p.id !== product.id));
setToastMessage(`🗑️ Publicación "${product.name}" eliminada permanentemente del catálogo.`);
}
} catch (e) {
console.error('Error eliminando producto:', e);
}
};
const handleCreateProduct = async (e: React.FormEvent) => {
e.preventDefault();
const sizes = sizesInput
@ -119,6 +158,142 @@ export default function AdminProductsPage() {
}
};
const handleOpenEdit = (product: Product) => {
setEditingProduct(product);
setEditName(product.name);
setEditBrand(product.brand);
setEditSupplierId(product.supplierId || suppliers[0].id);
setEditSupplierPrice(product.supplierPrice.toString());
setEditRetailPrice(product.retailPrice.toString());
setEditSizesInput(product.sizes.join(', '));
setEditImageUrl(product.images[0] || '');
setEditCategory(product.category || 'Sneakers / Urbano');
};
const handleSaveEdit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingProduct) return;
const sizes = editSizesInput.split(',').map((s) => s.trim()).filter(Boolean);
const chosenSupplier = suppliers.find((s) => s.id === editSupplierId) || suppliers[0];
const updates = {
name: editName,
brand: editBrand,
supplierPrice: parseInt(editSupplierPrice, 10) || editingProduct.supplierPrice,
retailPrice: parseInt(editRetailPrice, 10) || editingProduct.retailPrice,
originalPrice: Math.round((parseInt(editRetailPrice, 10) || editingProduct.retailPrice) * 1.35),
sizes,
images: [editImageUrl || editingProduct.images[0]],
category: editCategory,
supplierId: chosenSupplier.id,
supplierName: chosenSupplier.name,
supplierPhone: chosenSupplier.phone,
};
setProducts((prev) => prev.map((p) => (p.id === editingProduct.id ? { ...p, ...updates } : p)));
try {
const res = await fetch('/api/products', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: editingProduct.id, updates }),
});
const data = await res.json();
if (data.success) {
setEditingProduct(null);
setToastMessage(`✅ Publicación "${editName}" actualizada con éxito.`);
}
} catch (e) {
console.error(e);
}
};
// 1-to-1 AI Processor logic
const handleProcessSinglePostWithAI = () => {
if (!rawText.trim()) return;
// Extracción inteligente
const cleanTitle = rawText
.split('\n')[0]
.replace(/🔥|👟|💥|⚡|DISPONIBLE|LLEGANDO|HOY|A BODEGA/gi, '')
.trim();
// Detectar marca
let detectedBrand = 'Nike';
if (/adidas/i.test(rawText)) detectedBrand = 'Adidas';
if (/jordan/i.test(rawText)) detectedBrand = 'Jordan';
if (/new balance/i.test(rawText)) detectedBrand = 'New Balance';
if (/puma/i.test(rawText)) detectedBrand = 'Puma';
if (/vans/i.test(rawText)) detectedBrand = 'Vans';
// Detectar precio
const priceMatch = rawText.match(/\$?\s*(\d{2,3})[.,]?(\d{3})/);
let cost = 90000;
if (priceMatch) {
cost = parseInt(priceMatch[1] + priceMatch[2], 10);
}
// Calcular precio venta con 35% de margen
const retail = Math.round(cost * 1.35 / 1000) * 1000;
// Detectar tallas
const sizes = ['37', '38', '39', '40', '41', '42', '43'];
setAiExtracted({
name: cleanTitle || 'Zapatilla Urbana Exclusiva',
brand: detectedBrand,
supplierPrice: cost,
retailPrice: retail,
sizes,
images: [rawImageUrl || 'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
category: 'Sneakers / Urbano',
description: 'Zapatillas calidad 1.1 garantizada para entrega inmediata.',
isActive: true,
inStock: true,
originBadge: 'Bodega Central',
supplierName: 'DIFUSION JOVIC STORE',
supplierPhone: '573134848203',
});
};
const handlePublishAiProduct = async () => {
if (!aiExtracted) return;
const newProd: Product = {
id: `snk-${Date.now()}`,
name: aiExtracted.name || 'Zapatilla',
brand: aiExtracted.brand || 'Nike',
supplierPrice: aiExtracted.supplierPrice || 90000,
retailPrice: aiExtracted.retailPrice || 125000,
originalPrice: Math.round((aiExtracted.retailPrice || 125000) * 1.35),
sizes: aiExtracted.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: aiExtracted.images || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: aiExtracted.description || 'Zapatillas urbanas',
category: aiExtracted.category || 'Sneakers / Urbano',
inStock: true,
isActive: true,
originBadge: 'Bodega Central',
supplierName: aiExtracted.supplierName || 'DIFUSION JOVIC STORE',
supplierPhone: aiExtracted.supplierPhone || '573134848203',
};
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newProd),
});
const data = await res.json();
if (data.success) {
setProducts((prev) => [data.product, ...prev]);
setIsAiModalOpen(false);
setRawText('');
setRawImageUrl('');
setAiExtracted(null);
setToastMessage(`🚀 ¡Publicación individual "${newProd.name}" procesada y publicada en la tienda!`);
}
};
const filtered = products.filter((p) => {
const matchesSearch =
p.name.toLowerCase().includes(search.toLowerCase()) ||
@ -132,34 +307,41 @@ export default function AdminProductsPage() {
return true;
});
const activeCount = products.filter((p) => p.isActive !== false).length;
const inactiveCount = products.filter((p) => p.isActive === false).length;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-black text-white flex items-center gap-2">
<Package className="w-6 h-6 text-emerald-400" />
<span>Catálogo General de Zapatillas & Proveedores ({products.length})</span>
<Package className="w-6 h-6 text-purple-400" />
<span>Catálogo de Publicaciones ({products.length})</span>
</h1>
<p className="text-xs text-slate-400 mt-1">
Visualiza cada modelo con su <strong>proveedor asignado</strong>, costo mayorista y activa/inactiva publicaciones para la tienda.
<p className="text-xs text-neutral-400 mt-1">
Gestión completa (CRUD): Crea, edita precios/tallas, activa/oculta o elimina publicaciones de tu catálogo.
</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="bg-gradient-to-r from-emerald-600 to-teal-500 hover:from-emerald-500 hover:to-teal-400 text-white font-bold text-xs px-4 py-2.5 rounded-xl flex items-center space-x-1.5 shadow-[0_0_15px_rgba(16,185,129,0.3)] cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Agregar Zapatilla con Proveedor</span>
</button>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setIsAiModalOpen(true)}
className="bg-purple-950/80 hover:bg-purple-900 border border-purple-500/50 text-purple-300 font-bold text-xs px-3.5 py-2.5 rounded-xl flex items-center space-x-1.5 shadow-md cursor-pointer transition-all"
>
<Wand2 className="w-4 h-4 text-fuchsia-400" />
<span>Procesar 1 a 1 con IA</span>
</button>
<button
onClick={() => setIsModalOpen(true)}
className="bg-gradient-to-r from-purple-600 to-fuchsia-500 hover:from-purple-500 hover:to-fuchsia-400 text-white font-bold text-xs px-4 py-2.5 rounded-xl flex items-center space-x-1.5 shadow-[0_0_15px_rgba(168,85,247,0.3)] cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>+ Crear Zapatilla Manual</span>
</button>
</div>
</div>
{toastMessage && (
<div className="bg-emerald-950/80 border border-emerald-500 text-emerald-300 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-md">
<div className="bg-purple-950/80 border border-purple-500 text-purple-300 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-md">
<span>{toastMessage}</span>
<button onClick={() => setToastMessage(null)} className="font-bold text-white cursor-pointer ml-3">
✕
@ -169,12 +351,11 @@ export default function AdminProductsPage() {
{/* Filters & Search Toolbar */}
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3">
{/* Status filter pills */}
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl overflow-x-auto">
<div className="flex items-center space-x-1.5 bg-neutral-900 border border-neutral-800 p-1 rounded-2xl overflow-x-auto">
<button
onClick={() => setStatusFilter('all')}
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer ${
statusFilter === 'all' ? 'bg-white text-slate-950 shadow' : 'text-slate-400 hover:text-white'
statusFilter === 'all' ? 'bg-white text-neutral-950 shadow' : 'text-neutral-400 hover:text-white'
}`}
>
Todos ({products.length})
@ -183,58 +364,108 @@ export default function AdminProductsPage() {
onClick={() => setStatusFilter('active')}
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
statusFilter === 'active'
? 'bg-emerald-500/30 border border-emerald-500/50 text-emerald-300 shadow'
: 'text-slate-400 hover:text-emerald-400'
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow'
: 'text-neutral-400 hover:text-emerald-300'
}`}
>
<span className="w-2 h-2 rounded-full bg-emerald-400" />
<span>Activos ({activeCount})</span>
<Eye className="w-3.5 h-3.5" />
<span>Activas</span>
</button>
<button
onClick={() => setStatusFilter('inactive')}
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
statusFilter === 'inactive'
? 'bg-amber-500/30 border border-amber-500/50 text-amber-300 shadow'
: 'text-slate-400 hover:text-amber-400'
? 'bg-neutral-800 text-white border border-neutral-700 shadow'
: 'text-neutral-400 hover:text-white'
}`}
>
<span className="w-2 h-2 rounded-full bg-amber-400" />
<span>Inactivos ({inactiveCount})</span>
<EyeOff className="w-3.5 h-3.5" />
<span>Ocultas</span>
</button>
</div>
{/* Search */}
<div className="max-w-xs w-full">
<input
type="text"
placeholder="Filtrar por modelo, marca o proveedor..."
placeholder="Buscar por modelo, marca o proveedor..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-emerald-400"
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400"
/>
</div>
</div>
{/* Floating Bulk Grouping Toolbar */}
{selectedProductIds.length >= 2 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-neutral-900/95 border-2 border-purple-500 rounded-3xl p-3 px-5 shadow-2xl backdrop-blur-md flex items-center gap-4 animate-in slide-in-from-bottom-5">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-xl bg-purple-600 text-white flex items-center justify-center font-black text-xs">
{selectedProductIds.length}
</div>
<div>
<p className="text-white font-black text-xs leading-tight">
{selectedProductIds.length} Zapatillas Seleccionadas
</p>
<p className="text-[10px] text-neutral-400">
Puedes fusionarlas en 1 solo modelo con múltiples colorways.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedProductIds([])}
className="text-xs text-neutral-400 hover:text-white px-3 py-2 rounded-xl bg-neutral-800 hover:bg-neutral-700 cursor-pointer font-bold"
>
Deseleccionar
</button>
<button
onClick={() => setIsGroupModalOpen(true)}
className="bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:opacity-95 text-white font-black text-xs px-4 py-2 rounded-xl shadow-lg shadow-purple-600/30 flex items-center gap-1.5 cursor-pointer"
>
<Layers className="w-4 h-4" />
<span>Agrupar como Variantes de 1 Modelo</span>
</button>
</div>
</div>
)}
{/* Products Table */}
<div className="bg-slate-900 border border-slate-800 rounded-3xl overflow-hidden shadow-xl">
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl overflow-hidden shadow-xl">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-slate-300">
<thead className="bg-slate-950 text-slate-400 font-bold uppercase text-[10px] border-b border-slate-800">
<table className="w-full text-left text-xs text-neutral-300">
<thead className="bg-neutral-950 text-neutral-400 font-bold uppercase text-[10px] border-b border-neutral-800">
<tr>
<th className="py-3.5 px-4">Estado Publicación</th>
<th className="py-3.5 px-3 text-center w-10">
<input
type="checkbox"
checked={filtered.length > 0 && selectedProductIds.length === filtered.length}
onChange={(e) => {
if (e.target.checked) {
setSelectedProductIds(filtered.map((p) => p.id));
} else {
setSelectedProductIds([]);
}
}}
className="w-4 h-4 rounded-md accent-purple-600 cursor-pointer"
title="Seleccionar todas"
/>
</th>
<th className="py-3.5 px-4">Estado</th>
<th className="py-3.5 px-4">Producto</th>
<th className="py-3.5 px-4">Proveedor Asignado (Interno)</th>
<th className="py-3.5 px-4">Proveedor Asignado</th>
<th className="py-3.5 px-4">Marca</th>
<th className="py-3.5 px-4">Costo Base</th>
<th className="py-3.5 px-4">Precio Venta</th>
<th className="py-3.5 px-4">Margen Ganancia</th>
<th className="py-3.5 px-4">Tallas</th>
<th className="py-3.5 px-4 text-center">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
<tbody className="divide-y divide-neutral-800/60">
{filtered.length === 0 ? (
<tr>
<td colSpan={8} className="text-center py-8 text-slate-500">
<td colSpan={10} className="text-center py-8 text-neutral-500">
No hay zapatillas que coincidan con el filtro seleccionado.
</td>
</tr>
@ -244,24 +475,42 @@ export default function AdminProductsPage() {
const margin = prod.retailPrice - prod.supplierPrice;
const suppName = prod.supplierName || 'DIFUSION JOVIC STORE';
const suppPhone = prod.supplierPhone || '573134848203';
const isSelected = selectedProductIds.includes(prod.id);
const colorwaysCount = prod.colors?.length || 1;
return (
<tr
key={prod.id}
className={`hover:bg-slate-800/50 transition-colors ${
!isActive ? 'opacity-65 bg-slate-950/40' : ''
}`}
className={`hover:bg-neutral-800/50 transition-colors ${
isSelected ? 'bg-purple-950/25 border-l-2 border-l-purple-500' : ''
} ${!isActive ? 'opacity-65 bg-neutral-950/40' : ''}`}
>
{/* Estado Toggle Button */}
{/* Checkbox */}
<td className="py-3.5 px-3 text-center">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
if (e.target.checked) {
setSelectedProductIds((prev) => [...prev, prod.id]);
} else {
setSelectedProductIds((prev) => prev.filter((id) => id !== prod.id));
}
}}
className="w-4 h-4 rounded-md accent-purple-600 cursor-pointer"
/>
</td>
{/* Toggle status */}
<td className="py-3.5 px-4">
<button
onClick={() => handleToggleProductStatus(prod)}
className={`text-[11px] font-bold px-3 py-1.5 rounded-xl border flex items-center gap-1.5 transition-all cursor-pointer ${
className={`text-[11px] font-bold px-2.5 py-1 rounded-xl border flex items-center gap-1.5 transition-all cursor-pointer ${
isActive
? 'bg-emerald-500/20 border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/30'
: 'bg-slate-950 border-slate-700 text-slate-400 hover:text-white'
: 'bg-neutral-950 border-neutral-700 text-neutral-400 hover:text-white'
}`}
title="Haz clic para activar o desactivar esta publicación en la tienda"
title="Activar / Ocultar en la tienda"
>
{isActive ? (
<>
@ -270,30 +519,45 @@ export default function AdminProductsPage() {
</>
) : (
<>
<EyeOff className="w-3.5 h-3.5 text-slate-400" />
<span>Inactivo (Oculto)</span>
<EyeOff className="w-3.5 h-3.5 text-neutral-400" />
<span>Oculto</span>
</>
)}
</button>
</td>
{/* Product Name & Image */}
{/* Image & Title */}
<td className="py-3.5 px-4 flex items-center space-x-3">
<img
src={prod.images[0]}
alt=""
className="w-12 h-12 rounded-xl object-cover bg-slate-950 border border-slate-800 shrink-0"
/>
<div className="relative">
<img
src={prod.images[0]}
alt=""
className="w-12 h-12 rounded-xl object-cover bg-neutral-950 border border-neutral-800 shrink-0"
/>
{colorwaysCount > 1 && (
<span className="absolute -top-1 -right-1 bg-purple-600 text-white text-[9px] font-black px-1.5 py-0.2 rounded-full border border-neutral-900 shadow">
{colorwaysCount}
</span>
)}
</div>
<div>
<p className="font-bold text-white text-xs leading-snug">{prod.name}</p>
<span className="text-[10px] text-slate-400">{prod.category}</span>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-neutral-400">{prod.category}</span>
{colorwaysCount > 1 && (
<span className="text-[10px] text-purple-400 bg-purple-500/10 border border-purple-500/30 px-1.5 py-0.2 rounded font-bold flex items-center gap-0.5">
<Palette className="w-2.5 h-2.5" />
{colorwaysCount} Colores
</span>
)}
</div>
</div>
</td>
{/* Supplier Column */}
{/* Supplier */}
<td className="py-3.5 px-4">
<div className="space-y-1">
<span className="bg-slate-950 text-emerald-300 font-bold px-2 py-0.5 rounded border border-slate-800 text-[11px] block w-fit">
<span className="bg-neutral-950 text-purple-300 font-bold px-2 py-0.5 rounded border border-neutral-800 text-[11px] block w-fit">
🏢 {suppName}
</span>
<button
@ -306,12 +570,12 @@ export default function AdminProductsPage() {
</div>
</td>
<td className="py-3.5 px-4 font-bold text-emerald-400">{prod.brand}</td>
<td className="py-3.5 px-4 font-bold text-purple-400">{prod.brand}</td>
<td className="py-3.5 px-4 font-mono">{formatCOP(prod.supplierPrice)}</td>
<td className="py-3.5 px-4 font-mono font-bold text-white">{formatCOP(prod.retailPrice)}</td>
<td className="py-3.5 px-4">
<span className="bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-bold px-2 py-0.5 rounded font-mono">
<span className="bg-purple-500/20 text-purple-300 border border-purple-500/30 font-bold px-2 py-0.5 rounded font-mono">
+{formatCOP(margin)}
</span>
</td>
@ -319,12 +583,32 @@ export default function AdminProductsPage() {
<td className="py-3.5 px-4">
<div className="flex gap-1 flex-wrap max-w-xs">
{prod.sizes.map((s) => (
<span key={s} className="bg-slate-950 text-slate-300 text-[10px] px-1.5 py-0.5 rounded border border-slate-800 font-bold">
<span key={s} className="bg-neutral-950 text-neutral-300 text-[10px] px-1.5 py-0.5 rounded border border-neutral-800 font-bold">
{s}
</span>
))}
</div>
</td>
{/* Action buttons (Edit & Delete) */}
<td className="py-3.5 px-4 text-center">
<div className="flex items-center justify-center space-x-1.5">
<button
onClick={() => handleOpenEdit(prod)}
className="bg-neutral-950 hover:bg-purple-950/50 border border-neutral-800 hover:border-purple-500/50 text-neutral-400 hover:text-purple-300 p-2 rounded-xl transition-all cursor-pointer"
title="Editar publicación (Precios, Tallas, Proveedor)"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteProduct(prod)}
className="bg-neutral-950 hover:bg-red-950/50 border border-neutral-800 hover:border-red-500/50 text-neutral-400 hover:text-red-400 p-2 rounded-xl transition-all cursor-pointer"
title="Eliminar permanentemente del catálogo"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})
@ -334,32 +618,123 @@ export default function AdminProductsPage() {
</div>
</div>
{/* Modal Add Product */}
{/* Modal de Agrupación Manual de Productos */}
<GroupProductsModal
isOpen={isGroupModalOpen}
onClose={() => setIsGroupModalOpen(false)}
selectedProducts={products.filter((p) => selectedProductIds.includes(p.id))}
onGroupSuccess={(groupedProduct) => {
setProducts((prev) => [
groupedProduct,
...prev.filter((p) => !selectedProductIds.includes(p.id) && p.id !== groupedProduct.id),
]);
setSelectedProductIds([]);
setToastMessage(`¡Se unificaron ${selectedProductIds.length} productos con éxito en "${groupedProduct.name}"!`);
}}
/>
{/* Modal 1: Process Single Message with AI (1 a 1) */}
{isAiModalOpen && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
<h3 className="font-bold text-white text-base flex items-center gap-2">
<Wand2 className="w-5 h-5 text-fuchsia-400" />
<span>Procesar Publicación 1 a 1 con IA</span>
</h3>
<button onClick={() => setIsAiModalOpen(false)} className="text-neutral-400 hover:text-white cursor-pointer">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-3 text-xs">
<div>
<label className="block text-neutral-300 font-bold mb-1">
Pega el Texto del Proveedor / Grupo de WhatsApp *
</label>
<textarea
rows={4}
placeholder="Ej: 🔥 LLEGANDO HOY 🔥&#10;Nike Air Jordan 4 Retro White Cement&#10;Tallas: 38 a 43&#10;Mayorista: $95.000 c/u"
value={rawText}
onChange={(e) => setRawText(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl p-3 text-white outline-hidden focus:border-purple-400 font-mono text-xs"
/>
</div>
<div>
<label className="block text-neutral-300 font-bold mb-1">URL de la Foto / Imagen</label>
<input
type="text"
placeholder="https://images.unsplash.com/..."
value={rawImageUrl}
onChange={(e) => setRawImageUrl(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-400"
/>
</div>
<button
type="button"
onClick={handleProcessSinglePostWithAI}
className="w-full bg-purple-600 hover:bg-purple-500 text-white font-bold py-2.5 rounded-xl shadow-md flex items-center justify-center gap-2 cursor-pointer transition-all"
>
<Sparkles className="w-4 h-4 text-fuchsia-300" />
<span>Extraer Título, Tallas & Calcular Margen 35% con IA</span>
</button>
{aiExtracted && (
<div className="bg-neutral-950 border border-purple-500/40 rounded-2xl p-4 space-y-2 animate-in zoom-in-95">
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold text-purple-400">✨ Resultado Extraído por IA</span>
<span className="bg-emerald-500/20 text-emerald-300 font-bold px-2 py-0.5 rounded text-[10px]">Listo para Publicar</span>
</div>
<p className="text-white font-bold text-sm">{aiExtracted.name}</p>
<div className="grid grid-cols-2 gap-2 text-[11px] text-neutral-300">
<p><strong>Marca:</strong> {aiExtracted.brand}</p>
<p><strong>Tallas:</strong> {aiExtracted.sizes?.join(', ')}</p>
<p><strong>Costo Mayorista:</strong> {formatCOP(aiExtracted.supplierPrice || 0)}</p>
<p className="text-emerald-400"><strong>Precio Venta (35%):</strong> {formatCOP(aiExtracted.retailPrice || 0)}</p>
</div>
<button
type="button"
onClick={handlePublishAiProduct}
className="w-full bg-gradient-to-r from-emerald-600 to-teal-500 hover:from-emerald-500 hover:to-teal-400 text-white font-black py-2.5 rounded-xl shadow-lg flex items-center justify-center gap-2 cursor-pointer mt-2"
>
<span>Publicar Inmediatamente en la Tienda</span>
<ArrowRight className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
</div>
)}
{/* Modal 2: Add Product Manually */}
{isModalOpen && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
<h3 className="font-bold text-white text-base">Registrar Nueva Zapatilla</h3>
<form onSubmit={handleCreateProduct} className="space-y-3 text-xs">
<div>
<label className="block text-slate-400 font-bold mb-1">Nombre del Modelo *</label>
<label className="block text-neutral-400 font-bold mb-1">Nombre del Modelo *</label>
<input
type="text"
required
placeholder="Ej. Nike Air Jordan 1 High Chicago"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
{/* Supplier Selection */}
<div>
<label className="block text-emerald-400 font-bold mb-1">Proveedor Asignado (Interno) *</label>
<label className="block text-purple-400 font-bold mb-1">Proveedor Asignado (Interno) *</label>
<select
value={selectedSupplierId}
onChange={(e) => setSelectedSupplierId(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden font-semibold"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden font-semibold"
>
{suppliers.map((s) => (
<option key={s.id} value={s.id}>
@ -367,18 +742,15 @@ export default function AdminProductsPage() {
</option>
))}
</select>
<p className="text-[10px] text-slate-500 mt-0.5">
Este proveedor recibirá la notificación para separar el par cuando se haga un pedido.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-slate-400 font-bold mb-1">Marca</label>
<label className="block text-neutral-400 font-bold mb-1">Marca</label>
<select
value={brand}
onChange={(e) => setBrand(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
>
<option value="Nike">Nike</option>
<option value="Jordan">Jordan</option>
@ -389,81 +761,73 @@ export default function AdminProductsPage() {
</div>
<div>
<label className="block text-slate-400 font-bold mb-1">Categoría</label>
<label className="block text-neutral-400 font-bold mb-1">Categoría</label>
<input
type="text"
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-slate-400 font-bold mb-1">Costo Base Mayorista (COP)</label>
<label className="block text-neutral-400 font-bold mb-1">Costo Proveedor ($ COP) *</label>
<input
type="number"
required
value={supplierPrice}
onChange={(e) => setSupplierPrice(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div>
<label className="block text-slate-400 font-bold mb-1">Precio Venta Sugerido (COP)</label>
<label className="block text-neutral-400 font-bold mb-1">Precio Venta Público ($ COP) *</label>
<input
type="number"
required
value={retailPrice}
onChange={(e) => setRetailPrice(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden font-bold text-purple-400"
/>
</div>
</div>
<div>
<label className="block text-slate-400 font-bold mb-1">Tallas Disponibles (separadas por coma)</label>
<label className="block text-neutral-400 font-bold mb-1">Tallas Disponibles (separadas por coma)</label>
<input
type="text"
required
value={sizesInput}
onChange={(e) => setSizesInput(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div>
<label className="block text-slate-400 font-bold mb-1">URL de Imagen Principal</label>
<label className="block text-neutral-400 font-bold mb-1">URL Imagen del Producto</label>
<input
type="url"
type="text"
required
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div className="pt-2">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={isActiveNew}
onChange={(e) => setIsActiveNew(e.target.checked)}
className="rounded text-emerald-500 focus:ring-0"
/>
<span className="text-slate-200 font-bold">Publicar inmediatamente en la tienda (Activo)</span>
</label>
</div>
<div className="flex justify-end space-x-2 pt-3 border-t border-slate-800">
<div className="flex justify-end space-x-2 pt-3 border-t border-neutral-800">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="bg-slate-800 hover:bg-slate-700 text-slate-300 font-bold py-2 px-4 rounded-xl cursor-pointer"
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold py-2 px-4 rounded-xl cursor-pointer"
>
Cancelar
</button>
<button
type="submit"
className="bg-gradient-to-r from-emerald-600 to-teal-500 hover:from-emerald-500 hover:to-teal-400 text-white font-bold py-2 px-4 rounded-xl shadow-md cursor-pointer"
className="bg-gradient-to-r from-purple-600 to-fuchsia-500 hover:from-purple-500 hover:to-fuchsia-400 text-white font-bold py-2 px-4 rounded-xl shadow-md cursor-pointer"
>
Guardar Zapatilla
</button>
@ -472,6 +836,135 @@ export default function AdminProductsPage() {
</div>
</div>
)}
{/* Modal 3: Edit Existing Product (PUT) */}
{editingProduct && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
<h3 className="font-bold text-white text-base flex items-center gap-2">
<Edit className="w-5 h-5 text-purple-400" />
<span>Editar Publicación: {editingProduct.name}</span>
</h3>
<button onClick={() => setEditingProduct(null)} className="text-neutral-400 hover:text-white cursor-pointer">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSaveEdit} className="space-y-3 text-xs">
<div>
<label className="block text-neutral-400 font-bold mb-1">Nombre del Modelo *</label>
<input
type="text"
required
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 outline-hidden focus:border-purple-400"
/>
</div>
<div>
<label className="block text-purple-400 font-bold mb-1">Proveedor Asignado *</label>
<select
value={editSupplierId}
onChange={(e) => setEditSupplierId(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden font-semibold"
>
{suppliers.map((s) => (
<option key={s.id} value={s.id}>
{s.name} — WhatsApp: {s.phone}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-neutral-400 font-bold 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-400"
/>
</div>
<div>
<label className="block text-neutral-400 font-bold mb-1">Categoría</label>
<input
type="text"
value={editCategory}
onChange={(e) => setEditCategory(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-400"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-neutral-400 font-bold mb-1">Costo Proveedor ($ COP) *</label>
<input
type="number"
required
value={editSupplierPrice}
onChange={(e) => setEditSupplierPrice(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div>
<label className="block text-neutral-400 font-bold mb-1">Precio Venta Público ($ COP) *</label>
<input
type="number"
required
value={editRetailPrice}
onChange={(e) => setEditRetailPrice(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden font-bold text-purple-400"
/>
</div>
</div>
<div>
<label className="block text-neutral-400 font-bold mb-1">Tallas Disponibles</label>
<input
type="text"
required
value={editSizesInput}
onChange={(e) => setEditSizesInput(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div>
<label className="block text-neutral-400 font-bold mb-1">URL Imagen</label>
<input
type="text"
required
value={editImageUrl}
onChange={(e) => setEditImageUrl(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden"
/>
</div>
<div className="flex justify-end space-x-2 pt-3 border-t border-neutral-800">
<button
type="button"
onClick={() => setEditingProduct(null)}
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold py-2 px-4 rounded-xl cursor-pointer"
>
Cancelar
</button>
<button
type="submit"
className="bg-gradient-to-r from-purple-600 to-fuchsia-500 hover:from-purple-500 hover:to-fuchsia-400 text-white font-bold py-2 px-4 rounded-xl shadow-md cursor-pointer"
>
Guardar Cambios
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -17,6 +17,85 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const body = await request.json();
// ACCIÓN MANUAL: Agrupar múltiples productos como variantes de un solo modelo
if (body.action === 'group-products') {
const { targetProductId, productIdsToMerge, customModelName, customBrand } = body;
const allProducts = dataService.getProducts();
const target = allProducts.find((p) => p.id === targetProductId);
if (!target) {
return NextResponse.json({ success: false, error: 'Producto objetivo no encontrado' }, { status: 404 });
}
const colors =
target.colors && target.colors.length > 0
? [...target.colors]
: [
{
id: `color-${target.id}-1`,
colorName: target.colorName || 'Estilo Principal',
image: target.images[0],
images: target.images,
sizes: target.sizes,
inStock: target.inStock !== false,
price: target.retailPrice,
sizePrices: target.sizePrices,
},
];
for (const mergeId of productIdsToMerge || []) {
if (mergeId === targetProductId) continue;
const toMerge = allProducts.find((p) => p.id === mergeId);
if (!toMerge) continue;
const mergeColors =
toMerge.colors && toMerge.colors.length > 0
? toMerge.colors
: [
{
id: `color-${toMerge.id}`,
colorName: toMerge.colorName || toMerge.name || `Estilo #${colors.length + 1}`,
image: toMerge.images[0],
images: toMerge.images,
sizes: toMerge.sizes,
inStock: toMerge.inStock !== false,
price: toMerge.retailPrice,
sizePrices: toMerge.sizePrices,
},
];
mergeColors.forEach((mc) => {
if (!colors.some((c) => c.image === mc.image)) {
colors.push(mc);
}
});
target.images = Array.from(new Set([...target.images, ...toMerge.images]));
target.sizes = Array.from(new Set([...target.sizes, ...toMerge.sizes])).sort(
(a, b) => parseInt(a, 10) - parseInt(b, 10)
);
// Eliminar el producto que fue fusionado
dataService.deleteProduct(toMerge.id);
}
target.colors = colors;
if (customModelName) {
target.name = customModelName;
target.modelName = customModelName;
}
if (customBrand) {
target.brand = customBrand;
}
dataService.updateProduct(target.id, target);
return NextResponse.json({
success: true,
product: target,
message: `¡Se agruparon ${productIdsToMerge.length} productos con éxito en "${target.name}"!`,
});
}
const proof = getProductSocialProof(body);
const newProduct = dataService.addProduct({
...body,

View File

@ -203,7 +203,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const body = await request.json();
const { action, post, posts, customRetailPrice, customName, customSizes } = body;
const { action, post, posts, customRetailPrice, customName, customColorName, customSizes } = body;
// 1. PUBLICAR UN PRODUCTO A LA TIENDA (APP MÓVIL Y WEB)
if (action === 'publish' && post) {
@ -220,6 +220,7 @@ export async function POST(request: Request) {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName,
modelName: prodName,
colorName: customColorName || post.extractedProduct?.colorName,
brand: prodBrand,
supplierPrice: post.extractedProduct?.supplierPrice || 75000,
retailPrice: retailPrice,

View File

@ -0,0 +1,263 @@
'use client';
import React, { useState } from 'react';
import { Layers, X, Check, Palette, Sparkles, ArrowRight, ShieldCheck } from 'lucide-react';
import { Product, ProductColorVariant } from '@/types';
import { formatCOP } from '@/lib/pricing-engine';
interface GroupProductsModalProps {
isOpen: boolean;
onClose: () => void;
selectedProducts: Product[];
onGroupSuccess: (groupedProduct: Product) => void;
}
export const GroupProductsModal: React.FC<GroupProductsModalProps> = ({
isOpen,
onClose,
selectedProducts,
onGroupSuccess,
}) => {
if (!isOpen || selectedProducts.length < 2) return null;
// Elegir el producto principal por defecto (el primero seleccionado)
const [masterId, setMasterId] = useState<string>(selectedProducts[0].id);
const masterProduct = selectedProducts.find((p) => p.id === masterId) || selectedProducts[0];
const [masterName, setMasterName] = useState(masterProduct.modelName || masterProduct.name);
const [masterBrand, setMasterBrand] = useState(masterProduct.brand);
const [masterPrice, setMasterPrice] = useState(masterProduct.retailPrice);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Nombres y precios personalizados para cada variante de color
const [variantConfigs, setVariantConfigs] = useState<
Record<string, { colorName: string; price: number; sizes: string }>
>(() => {
const initial: Record<string, { colorName: string; price: number; sizes: string }> = {};
selectedProducts.forEach((p, idx) => {
initial[p.id] = {
colorName: p.colorName || p.name.replace(p.brand, '').trim() || `Estilo #${idx + 1}`,
price: p.retailPrice,
sizes: p.sizes.join(', '),
};
});
return initial;
});
const handleVariantChange = (
id: string,
field: 'colorName' | 'price' | 'sizes',
value: string | number
) => {
setVariantConfigs((prev) => ({
...prev,
[id]: {
...prev[id],
[field]: value,
},
}));
};
const handleConfirmGroup = async () => {
setIsSubmitting(true);
setErrorMessage(null);
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'group-products',
targetProductId: masterId,
productIdsToMerge: selectedProducts.map((p) => p.id),
customModelName: masterName,
customBrand: masterBrand,
}),
});
const data = await res.json();
if (data.success && data.product) {
onGroupSuccess(data.product);
onClose();
} else {
setErrorMessage(data.error || 'Error al agrupar productos');
}
} catch (err: any) {
setErrorMessage(err.message || 'Error de conexión');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 bg-black/85 backdrop-blur-md flex items-center justify-center p-4 overflow-y-auto animate-in fade-in">
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-2xl w-full p-6 space-y-5 shadow-2xl my-auto">
{/* Header */}
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-xl bg-purple-500/20 text-purple-400 flex items-center justify-center">
<Layers className="w-5 h-5" />
</div>
<div>
<h3 className="font-black text-white text-base">
Agrupar {selectedProducts.length} Zapatillas como Variantes de un Modelo
</h3>
<p className="text-neutral-400 text-xs">
Se unificarán en 1 sola publicación con selector interactivo de colorways.
</p>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white flex items-center justify-center cursor-pointer transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{errorMessage && (
<div className="bg-red-950/40 border border-red-500/40 rounded-xl p-3 text-red-300 text-xs">
{errorMessage}
</div>
)}
{/* Modelo Principal */}
<div className="bg-neutral-950 p-4 rounded-2xl border border-neutral-800 space-y-3">
<span className="text-[11px] font-black text-purple-400 uppercase tracking-wider block">
1. Datos Generales de la Publicación Unificada
</span>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
<div className="sm:col-span-2">
<label className="font-bold text-neutral-300 block mb-1">Nombre del Modelo Base:</label>
<input
type="text"
value={masterName}
onChange={(e) => setMasterName(e.target.value)}
placeholder="Ej: Adidas Samba OG"
className="w-full bg-neutral-900 border border-neutral-700 rounded-xl px-3 py-2 text-white font-bold outline-hidden focus:border-purple-500"
/>
</div>
<div>
<label className="font-bold text-neutral-300 block mb-1">Marca:</label>
<input
type="text"
value={masterBrand}
onChange={(e) => setMasterBrand(e.target.value)}
className="w-full bg-neutral-900 border border-neutral-700 rounded-xl px-3 py-2 text-white font-bold outline-hidden focus:border-purple-500"
/>
</div>
</div>
</div>
{/* Lista de Variantes / Estilos */}
<div className="space-y-2.5">
<span className="text-[11px] font-black text-purple-400 uppercase tracking-wider block">
2. Configuración de Cada Variante de Color ({selectedProducts.length})
</span>
<div className="max-h-64 overflow-y-auto space-y-2.5 pr-1">
{selectedProducts.map((prod, idx) => {
const isMaster = prod.id === masterId;
const cfg = variantConfigs[prod.id] || {
colorName: `Estilo #${idx + 1}`,
price: prod.retailPrice,
sizes: prod.sizes.join(', '),
};
return (
<div
key={prod.id}
className={`p-3 rounded-2xl border transition-all flex flex-col sm:flex-row items-center gap-3 ${
isMaster
? 'bg-purple-950/20 border-purple-500/60 ring-1 ring-purple-500/40'
: 'bg-neutral-950/60 border-neutral-800 hover:border-neutral-700'
}`}
>
<img
src={prod.images[0]}
alt=""
className="w-14 h-14 rounded-xl object-cover bg-neutral-900 border border-neutral-700 shrink-0"
/>
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-2 w-full text-xs">
<div>
<label className="text-[10px] text-neutral-400 font-bold block mb-0.5">
Nombre del Estilo/Color:
</label>
<input
type="text"
value={cfg.colorName}
onChange={(e) => handleVariantChange(prod.id, 'colorName', e.target.value)}
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-2 py-1.5 text-white font-semibold outline-hidden focus:border-purple-500 text-xs"
/>
</div>
<div>
<label className="text-[10px] text-emerald-400 font-bold block mb-0.5">
Precio Venta:
</label>
<input
type="number"
value={cfg.price}
onChange={(e) =>
handleVariantChange(prod.id, 'price', Number(e.target.value))
}
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-2 py-1.5 text-emerald-300 font-mono font-bold outline-hidden focus:border-emerald-500 text-xs"
/>
</div>
<div>
<label className="text-[10px] text-neutral-400 font-bold block mb-0.5">
Tallas:
</label>
<input
type="text"
value={cfg.sizes}
onChange={(e) => handleVariantChange(prod.id, 'sizes', e.target.value)}
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-2 py-1.5 text-white outline-hidden focus:border-purple-500 text-xs"
/>
</div>
</div>
<button
type="button"
onClick={() => setMasterId(prod.id)}
className={`text-[10px] font-bold px-2.5 py-1.5 rounded-lg border shrink-0 cursor-pointer ${
isMaster
? 'bg-purple-600 text-white border-purple-400'
: 'bg-neutral-900 text-neutral-400 border-neutral-700 hover:text-white'
}`}
>
{isMaster ? '★ Principal' : 'Hacer Principal'}
</button>
</div>
);
})}
</div>
</div>
{/* Footer */}
<div className="flex items-center gap-3 pt-3 border-t border-neutral-800">
<button
onClick={onClose}
disabled={isSubmitting}
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={handleConfirmGroup}
disabled={isSubmitting}
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 flex items-center justify-center gap-2"
>
<Check className="w-4 h-4" />
<span>{isSubmitting ? 'Agrupando modelos...' : 'Confirmar y Fusionar en 1 Modelo'}</span>
</button>
</div>
</div>
</div>
);
};

View File

@ -3,7 +3,7 @@
import React, { useState } from 'react';
import { Star, Zap, Truck, ShoppingBag, Layers, Palette, Sparkles } from 'lucide-react';
import { Product } from '@/types';
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
import { formatCOP, calculateSeparationBreakdown, getProductPriceRange } from '@/lib/pricing-engine';
import { useBrand } from '@/context/BrandContext';
import { getProductSocialProof } from '@/lib/social-proof';
@ -21,6 +21,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
const [activeImage, setActiveImage] = useState<string>(product.images[0]);
const [activeColorName, setActiveColorName] = useState<string>(product.colorName || '');
const socialProof = getProductSocialProof(product);
const priceRange = getProductPriceRange(product);
const discountPercent = product.originalPrice
? Math.round(((product.originalPrice - product.retailPrice) / product.originalPrice) * 100)
@ -145,7 +146,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
<div className="pt-2 border-t border-slate-800">
<div className="flex items-baseline space-x-2">
<span className={`text-lg sm:text-xl font-black text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>
{formatCOP(product.retailPrice)}
{priceRange.hasVariation ? `Desde ${formatCOP(priceRange.minPrice)}` : formatCOP(product.retailPrice)}
</span>
{product.originalPrice && (
<span className="text-xs text-slate-500 line-through font-semibold">
@ -155,7 +156,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
</div>
<p className="text-[11px] font-semibold bg-white/5 px-2 py-1 rounded-md mt-1.5 border border-white/10 text-slate-300">
⚡ Separa con <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y cancelas <strong className="text-white">{formatCOP(separation.balanceDue)}</strong> al recibir.
⚡ Separa con <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y cancelas al recibir.
</p>
{/* Quick Action Button */}

View File

@ -17,7 +17,7 @@ import {
ArrowRight,
} from 'lucide-react';
import { Product, City, ProductColorVariant } from '@/types';
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
import { formatCOP, calculateSeparationBreakdown, getProductEffectivePrice } from '@/lib/pricing-engine';
import { useBrand } from '@/context/BrandContext';
import { getProductSocialProof } from '@/lib/social-proof';
@ -75,8 +75,10 @@ export const ProductModal: React.FC<ProductModalProps> = ({
const isSizeInStock = availableSizes.includes(selectedSize);
// Precio dinámico efectivo según colorway y talla seleccionada
const currentPrice = getProductEffectivePrice(product, selectedColor, selectedSize);
const socialProof = getProductSocialProof(product);
const separation = calculateSeparationBreakdown(product.retailPrice);
const separation = calculateSeparationBreakdown(currentPrice);
const handleAdd = () => {
if (!isSizeInStock) return;
@ -84,6 +86,7 @@ export const ProductModal: React.FC<ProductModalProps> = ({
{
...product,
name: `${product.modelName || product.name} (${selectedColor.colorName})`,
retailPrice: currentPrice,
images: selectedColor.images || [selectedImage],
},
selectedSize
@ -102,7 +105,7 @@ export const ProductModal: React.FC<ProductModalProps> = ({
👟 *${product.modelName || product.name}*
🎨 Color: *${selectedColor.colorName}*
📏 Talla: *${selectedSize}* (Horma Nacional)
💎 Precio: *${formatCOP(product.retailPrice)} COP*
💎 Precio: *${formatCOP(currentPrice)} COP*
🚚 Entrega en: *${selectedCity.name}* (Pago Contraentrega)
⚡ Deseo separar con $10.000 COP y cancelar el saldo en mi casa.
@ -225,7 +228,7 @@ export const ProductModal: React.FC<ProductModalProps> = ({
<div className="bg-neutral-950/80 border border-neutral-800 rounded-2xl p-3.5 space-y-2">
<div className="flex items-baseline space-x-3">
<span className={`text-2xl sm:text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>
{formatCOP(product.retailPrice)}
{formatCOP(currentPrice)}
</span>
{product.originalPrice && (
<span className="text-sm text-neutral-500 line-through font-bold">
@ -313,12 +316,14 @@ export const ProductModal: React.FC<ProductModalProps> = ({
{displaySizes.map((size) => {
const inStock = availableSizes.includes(size);
const isSelected = selectedSize === size;
const sizePrice = getProductEffectivePrice(product, selectedColor, size);
const hasCustomSizePrice = sizePrice !== product.retailPrice;
return (
<button
key={size}
onClick={() => setSelectedSize(size)}
className={`relative py-2.5 rounded-xl text-xs font-black transition-all cursor-pointer flex flex-col items-center justify-center ${
className={`relative py-2 rounded-xl text-xs font-black transition-all cursor-pointer flex flex-col items-center justify-center ${
isSelected
? inStock
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-lg scale-105 border border-white/30`
@ -329,8 +334,8 @@ export const ProductModal: React.FC<ProductModalProps> = ({
}`}
>
<span>{size}</span>
<span className="text-[8px] font-mono leading-none mt-0.5">
{inStock ? 'Listo' : 'Pedir'}
<span className="text-[8px] font-mono leading-none mt-0.5 opacity-80">
{hasCustomSizePrice ? `$${Math.round(sizePrice / 1000)}k` : inStock ? 'Listo' : 'Pedir'}
</span>
</button>
);

View File

@ -112,3 +112,66 @@ export function generateSalesCopy(params: {
📲 ¡Pídelas antes de que se agote tu talla!
👉 Pedir directo por WhatsApp: ${waLink}`;
}
/**
* Obtiene el precio exacto de venta de un producto según el estilo/color y la talla seleccionada.
*/
export function getProductEffectivePrice(
product: {
retailPrice: number;
sizePrices?: Record<string, number>;
},
selectedColor?: {
price?: number;
sizePrices?: Record<string, number>;
} | null,
selectedSize?: string | null
): number {
if (selectedColor && selectedSize && selectedColor.sizePrices && selectedColor.sizePrices[selectedSize]) {
return selectedColor.sizePrices[selectedSize];
}
if (selectedSize && product.sizePrices && product.sizePrices[selectedSize]) {
return product.sizePrices[selectedSize];
}
if (selectedColor && selectedColor.price && selectedColor.price > 0) {
return selectedColor.price;
}
return product.retailPrice || 120000;
}
/**
* Obtiene el rango de precios si varían según la talla o colorway
*/
export function getProductPriceRange(product: {
retailPrice: number;
sizePrices?: Record<string, number>;
colors?: Array<{ price?: number; sizePrices?: Record<string, number> }>;
}): { minPrice: number; maxPrice: number; hasVariation: boolean } {
const prices: number[] = [product.retailPrice || 120000];
if (product.sizePrices) {
Object.values(product.sizePrices).forEach((p) => {
if (typeof p === 'number' && p > 0) prices.push(p);
});
}
if (Array.isArray(product.colors)) {
product.colors.forEach((c) => {
if (c.price && c.price > 0) prices.push(c.price);
if (c.sizePrices) {
Object.values(c.sizePrices).forEach((p) => {
if (typeof p === 'number' && p > 0) prices.push(p);
});
}
});
}
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
return {
minPrice,
maxPrice,
hasVariation: minPrice !== maxPrice,
};
}

View File

@ -40,6 +40,8 @@ export interface ProductColorVariant {
images?: string[];
sizes: string[];
inStock?: boolean;
price?: number; // Precio específico de esta variante
sizePrices?: Record<string, number>; // Precios específicos por talla (ej: { "36": 110000, "42": 125000 })
}
export interface Product {
@ -53,6 +55,7 @@ export interface Product {
originalPrice?: number;
sizes: string[];
allSizes?: string[];
sizePrices?: Record<string, number>; // Precios variables por talla a nivel general
images: string[];
colors?: ProductColorVariant[];
description: string;
@ -149,6 +152,7 @@ export interface SupplierPost {
export interface ExtractedProductData {
name: string;
brand: string;
colorName?: string;
sizes: string[];
supplierPrice: number;
suggestedRetailPrice: number;