From f2ac7876f7d3c045a3a9fb613e98306bc4317dfd Mon Sep 17 00:00:00 2001 From: AI Bot Date: Fri, 28 Aug 2026 23:30:13 -0500 Subject: [PATCH] feat: agrupacion manual de modelos, validacion previa antes de publicar y precios variables por talla en UI --- .../src/app/admin/feed-whatsapp/page.tsx | 252 ++++--- .../src/app/admin/productos/page.tsx | 685 +++++++++++++++--- .../src/app/api/products/route.ts | 79 ++ .../src/app/api/whatsapp-feed/route.ts | 3 +- .../components/admin/GroupProductsModal.tsx | 263 +++++++ .../src/components/store/ProductCard.tsx | 7 +- .../src/components/store/ProductModal.tsx | 19 +- almeira-store-app/src/lib/pricing-engine.ts | 86 ++- almeira-store-app/src/types/index.ts | 4 + .../src/app/admin/feed-whatsapp/page.tsx | 252 ++++--- dripzone-app/src/app/admin/productos/page.tsx | 121 +++- dripzone-app/src/app/api/products/route.ts | 79 ++ .../src/app/api/whatsapp-feed/route.ts | 3 +- .../components/admin/GroupProductsModal.tsx | 263 +++++++ .../src/components/store/ProductCard.tsx | 7 +- .../src/components/store/ProductModal.tsx | 19 +- dripzone-app/src/lib/pricing-engine.ts | 63 ++ dripzone-app/src/types/index.ts | 4 + .../src/app/admin/feed-whatsapp/page.tsx | 252 ++++--- sneaky-app/src/app/admin/productos/page.tsx | 685 +++++++++++++++--- sneaky-app/src/app/api/products/route.ts | 79 ++ sneaky-app/src/app/api/whatsapp-feed/route.ts | 3 +- .../components/admin/GroupProductsModal.tsx | 263 +++++++ .../src/components/store/ProductCard.tsx | 7 +- .../src/components/store/ProductModal.tsx | 19 +- sneaky-app/src/lib/pricing-engine.ts | 63 ++ sneaky-app/src/types/index.ts | 4 + .../src/app/admin/feed-whatsapp/page.tsx | 252 ++++--- zapply-app/src/app/admin/productos/page.tsx | 685 +++++++++++++++--- zapply-app/src/app/api/products/route.ts | 79 ++ zapply-app/src/app/api/whatsapp-feed/route.ts | 3 +- .../components/admin/GroupProductsModal.tsx | 263 +++++++ .../src/components/store/ProductCard.tsx | 7 +- .../src/components/store/ProductModal.tsx | 19 +- zapply-app/src/lib/pricing-engine.ts | 63 ++ zapply-app/src/types/index.ts | 4 + 36 files changed, 4266 insertions(+), 693 deletions(-) create mode 100644 almeira-store-app/src/components/admin/GroupProductsModal.tsx create mode 100644 dripzone-app/src/components/admin/GroupProductsModal.tsx create mode 100644 sneaky-app/src/components/admin/GroupProductsModal.tsx create mode 100644 zapply-app/src/components/admin/GroupProductsModal.tsx diff --git a/almeira-store-app/src/app/admin/feed-whatsapp/page.tsx b/almeira-store-app/src/app/admin/feed-whatsapp/page.tsx index 84a18c8..f6dd82b 100644 --- a/almeira-store-app/src/app/admin/feed-whatsapp/page.tsx +++ b/almeira-store-app/src/app/admin/feed-whatsapp/page.tsx @@ -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([]); const [monitoredGroups, setMonitoredGroups] = useState([]); + const [existingProducts, setExistingProducts] = useState([]); const [selectedGroupFilter, setSelectedGroupFilter] = useState('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(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('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() { @@ -619,96 +635,168 @@ export default function SupplierFeedPage() { )} {/* Edit Modal Before Publishing */} - {editingPost && ( -
-
-
-

- - Personalizar antes de Publicar -

- -
+ {editingPost && (() => { + const matchingProduct = existingProducts.find( + (p) => normalizeModelKey(p.modelName || p.name, p.brand) === normalizeModelKey(editName, editBrand) + ); -
-
- - 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 ( +
+
+
+

+ + Personalizar y Validar antes de Publicar +

+
-
+ {/* Pre-validación de Modelo Existente */} + {matchingProduct && ( +
+
+ + ¡Modelo Existente Detectado en la Tienda! +
+ +
+
+ +
+

{matchingProduct.name}

+

+ {matchingProduct.colors?.length || 1} variantes de color existentes +

+
+
+ + Coincidencia + +
+ +
+ + +
+
+ )} + +
- + 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" + /> +
+ + {matchingProduct && mergeWithExisting && ( +
+ + 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" + /> +
+ )} + +
+
+ + setEditBrand(e.target.value)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-500" + /> +
+ +
+ + setEditPrice(Number(e.target.value))} + className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-emerald-300 font-black font-mono outline-hidden focus:border-emerald-500" + /> +
+
+ +
+ + setEditSizes(e.target.value)} + placeholder="38, 39, 40, 41, 42, 43" className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-500" />
- -
- - setEditPrice(Number(e.target.value))} - className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-emerald-300 font-black font-mono outline-hidden focus:border-emerald-500" - /> -
-
- - setEditSizes(e.target.value)} - placeholder="38, 39, 40, 41, 42, 43" - className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-500" - /> +
+ + +
- -
- - - -
-
- )} + ); + })()}
); } diff --git a/almeira-store-app/src/app/admin/productos/page.tsx b/almeira-store-app/src/app/admin/productos/page.tsx index 8aefc4a..ef2a800 100644 --- a/almeira-store-app/src/app/admin/productos/page.tsx +++ b/almeira-store-app/src/app/admin/productos/page.tsx @@ -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(INITIAL_PRODUCTS); @@ -13,6 +14,10 @@ export default function AdminProductsPage() { const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all'); const [supplierFilter, setSupplierFilter] = useState('all'); const [isModalOpen, setIsModalOpen] = useState(false); + const [isAiModalOpen, setIsAiModalOpen] = useState(false); + const [isGroupModalOpen, setIsGroupModalOpen] = useState(false); + const [selectedProductIds, setSelectedProductIds] = useState([]); + const [editingProduct, setEditingProduct] = useState(null); const [toastMessage, setToastMessage] = useState(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 | 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 (
{/* Header */}

- - Catálogo General de Zapatillas & Proveedores ({products.length}) + + Catálogo de Publicaciones ({products.length})

-

- Visualiza cada modelo con su proveedor asignado, costo mayorista y activa/inactiva publicaciones para la tienda. +

+ Gestión completa (CRUD): Crea, edita precios/tallas, activa/oculta o elimina publicaciones de tu catálogo.

- +
+ + + +
{toastMessage && ( -
+
{toastMessage}
- {/* Search */}
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" />
+ {/* Floating Bulk Grouping Toolbar */} + {selectedProductIds.length >= 2 && ( +
+
+
+ {selectedProductIds.length} +
+
+

+ {selectedProductIds.length} Zapatillas Seleccionadas +

+

+ Puedes fusionarlas en 1 solo modelo con múltiples colorways. +

+
+
+ +
+ + +
+
+ )} + {/* Products Table */} -
+
- - +
+ - + + - + + - + {filtered.length === 0 ? ( - @@ -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 ( - {/* Estado Toggle Button */} + {/* Checkbox */} + + + {/* Toggle status */} - {/* Product Name & Image */} + {/* Image & Title */} - {/* Supplier Column */} + {/* Supplier */} - + @@ -319,12 +583,32 @@ export default function AdminProductsPage() { + + {/* Action buttons (Edit & Delete) */} + ); }) @@ -334,32 +618,123 @@ export default function AdminProductsPage() { - {/* Modal Add Product */} + {/* Modal de Agrupación Manual de Productos */} + 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 && ( +
+
+
+

+ + Procesar Publicación 1 a 1 con IA +

+ +
+ +
+
+ +
Estado Publicación + 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" + /> + Estado ProductoProveedor Asignado (Interno)Proveedor Asignado Marca Costo Base Precio Venta Margen Ganancia TallasAcciones
+ No hay zapatillas que coincidan con el filtro seleccionado.
+ { + 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" + /> + - +
+ + {colorwaysCount > 1 && ( + + {colorwaysCount} + + )} +

{prod.name}

- {prod.category} +
+ {prod.category} + {colorwaysCount > 1 && ( + + + {colorwaysCount} Colores + + )} +
- + 🏢 {suppName}
{prod.brand}{prod.brand} {formatCOP(prod.supplierPrice)} {formatCOP(prod.retailPrice)} - + +{formatCOP(margin)}
{prod.sizes.map((s) => ( - + {s} ))}
+
+ + +
+