feat: agrupacion manual de modelos, validacion previa antes de publicar y precios variables por talla en UI
This commit is contained in:
parent
93d0a590a7
commit
f2ac7876f7
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 🔥 Nike Air Jordan 4 Retro White Cement Tallas: 38 a 43 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
263
almeira-store-app/src/components/admin/GroupProductsModal.tsx
Normal file
263
almeira-store-app/src/components/admin/GroupProductsModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
263
dripzone-app/src/components/admin/GroupProductsModal.tsx
Normal file
263
dripzone-app/src/components/admin/GroupProductsModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 🔥 Nike Air Jordan 4 Retro White Cement Tallas: 38 a 43 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
263
sneaky-app/src/components/admin/GroupProductsModal.tsx
Normal file
263
sneaky-app/src/components/admin/GroupProductsModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 🔥 Nike Air Jordan 4 Retro White Cement Tallas: 38 a 43 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
263
zapply-app/src/components/admin/GroupProductsModal.tsx
Normal file
263
zapply-app/src/components/admin/GroupProductsModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user