feat: agrupacion automatica por modelo y selector de variantes de color con solicitud de tallas por whatsapp
This commit is contained in:
parent
5b0c675431
commit
582f43059a
@ -32,17 +32,21 @@ export default function CheckoutPage() {
|
||||
quantity: number;
|
||||
price: number;
|
||||
image: string;
|
||||
}>>([
|
||||
{
|
||||
productId: INITIAL_PRODUCTS[0].id,
|
||||
productName: INITIAL_PRODUCTS[0].name,
|
||||
brand: INITIAL_PRODUCTS[0].brand,
|
||||
size: '39',
|
||||
quantity: 1,
|
||||
price: INITIAL_PRODUCTS[0].retailPrice,
|
||||
image: INITIAL_PRODUCTS[0].images[0],
|
||||
},
|
||||
]);
|
||||
}>>(
|
||||
INITIAL_PRODUCTS.length > 0
|
||||
? [
|
||||
{
|
||||
productId: INITIAL_PRODUCTS[0].id,
|
||||
productName: INITIAL_PRODUCTS[0].name,
|
||||
brand: INITIAL_PRODUCTS[0].brand,
|
||||
size: '39',
|
||||
quantity: 1,
|
||||
price: INITIAL_PRODUCTS[0].retailPrice,
|
||||
image: INITIAL_PRODUCTS[0].images[0],
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [orderCompleted, setOrderCompleted] = useState<Order | null>(null);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Navbar } from '@/components/store/Navbar';
|
||||
import { FlashDealsBanner } from '@/components/store/FlashDealsBanner';
|
||||
import { CategoryPills } from '@/components/store/CategoryPills';
|
||||
@ -12,6 +12,7 @@ import { MobileBottomDock } from '@/components/mobile/MobileBottomDock';
|
||||
import { AuthModal } from '@/components/auth/AuthModal';
|
||||
import { Product, City, CartItem } from '@/types';
|
||||
import { INITIAL_PRODUCTS, INITIAL_CITIES } from '@/lib/data-service';
|
||||
import { groupProductsByModel } from '@/lib/product-grouper';
|
||||
import { Sparkles, ShieldCheck, Truck, RefreshCw, Zap } from 'lucide-react';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
|
||||
@ -35,11 +36,11 @@ export default function StoreHomePage() {
|
||||
fetch('/api/products')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success && data.products?.length > 0) {
|
||||
if (data.success && Array.isArray(data.products)) {
|
||||
setProducts(data.products);
|
||||
}
|
||||
})
|
||||
.catch((e) => console.log('Usando catálogo inicial:', e));
|
||||
.catch((e) => console.log('Error cargando productos:', e));
|
||||
|
||||
fetch('/api/cities')
|
||||
.then((res) => res.json())
|
||||
@ -53,13 +54,16 @@ export default function StoreHomePage() {
|
||||
.catch((e) => console.log('Usando ciudades iniciales:', e));
|
||||
}, []);
|
||||
|
||||
const filteredProducts = products.filter((p) => {
|
||||
const groupedProducts = useMemo(() => groupProductsByModel(products), [products]);
|
||||
|
||||
const filteredProducts = groupedProducts.filter((p) => {
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchName = p.name.toLowerCase().includes(q);
|
||||
const matchName = (p.modelName || p.name).toLowerCase().includes(q);
|
||||
const matchBrand = p.brand.toLowerCase().includes(q);
|
||||
const matchColor = p.colors?.some((c) => c.colorName.toLowerCase().includes(q));
|
||||
const matchSize = p.sizes.some((s) => s.includes(q));
|
||||
if (!matchName && !matchBrand && !matchSize) return false;
|
||||
if (!matchName && !matchBrand && !matchSize && !matchColor) return false;
|
||||
}
|
||||
|
||||
if (activeCategory === 'flash') return p.isFlashDeal;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Star, Zap, Truck, ShoppingBag } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { Star, Zap, Truck, ShoppingBag, Palette } from 'lucide-react';
|
||||
import { Product } from '@/types';
|
||||
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
@ -17,37 +17,50 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
onOpenDetail,
|
||||
}) => {
|
||||
const { currentBrand } = useBrand();
|
||||
const [activeImage, setActiveImage] = useState<string>(product.images[0]);
|
||||
|
||||
const discountPercent = product.originalPrice
|
||||
? Math.round(((product.originalPrice - product.retailPrice) / product.originalPrice) * 100)
|
||||
: 28;
|
||||
|
||||
const separation = calculateSeparationBreakdown(product.retailPrice);
|
||||
const colorCount = product.colors?.length || 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onOpenDetail(product)}
|
||||
className={`group rounded-2xl border transition-all duration-300 flex flex-col cursor-pointer overflow-hidden ${currentBrand.colors.cardBg}`}
|
||||
className={`group rounded-3xl border transition-all duration-300 flex flex-col cursor-pointer overflow-hidden ${currentBrand.colors.cardBg} hover:shadow-2xl hover:border-cyan-500/40`}
|
||||
>
|
||||
{/* Image Container */}
|
||||
<div className="relative aspect-square w-full bg-slate-950 overflow-hidden">
|
||||
<img
|
||||
src={product.images[0]}
|
||||
src={activeImage || product.images[0]}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover group-hover:scale-108 transition-transform duration-500"
|
||||
/>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="absolute top-2 left-2 flex flex-col gap-1 z-10">
|
||||
<span className={`bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-[10px] sm:text-xs font-black px-2.5 py-0.5 rounded-md shadow-md uppercase tracking-wider`}>
|
||||
<div className="absolute top-2.5 left-2.5 flex flex-col gap-1 z-10">
|
||||
<span className={`bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-[10px] sm:text-xs font-black px-2.5 py-0.5 rounded-lg shadow-md uppercase tracking-wider`}>
|
||||
-{discountPercent}% OFF
|
||||
</span>
|
||||
{product.isFlashDeal && (
|
||||
<span className="bg-white/90 text-slate-950 text-[10px] font-black px-2 py-0.5 rounded-md shadow-md flex items-center gap-0.5">
|
||||
<span className="bg-white/95 text-slate-950 text-[10px] font-black px-2 py-0.5 rounded-lg shadow-md flex items-center gap-0.5">
|
||||
<Zap className="w-3 h-3 fill-slate-950" /> FLASH
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Colors count indicator badge */}
|
||||
{colorCount > 1 && (
|
||||
<div className="absolute top-2.5 right-2.5 z-10">
|
||||
<span className="bg-slate-950/90 backdrop-blur-md text-cyan-300 border border-cyan-500/40 text-[10px] font-black px-2 py-0.5 rounded-full flex items-center gap-1 shadow-lg">
|
||||
<Palette className="w-3 h-3 text-cyan-400" />
|
||||
<span>{colorCount} Colores</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-2 left-2 right-2 flex items-center justify-between pointer-events-none">
|
||||
<span className="bg-slate-950/80 backdrop-blur-xs text-white border border-white/10 text-[10px] font-semibold px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<Truck className="w-3 h-3 text-white" /> Contraentrega
|
||||
@ -75,9 +88,38 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
|
||||
{/* Product Title */}
|
||||
<h3 className="font-bold text-white text-sm sm:text-base line-clamp-2 transition-colors leading-snug">
|
||||
{product.name}
|
||||
{product.modelName || product.name}
|
||||
</h3>
|
||||
|
||||
{/* Color Thumbnails Preview Swatches if multi-color */}
|
||||
{product.colors && product.colors.length > 1 && (
|
||||
<div className="flex items-center gap-1.5 mt-2 overflow-x-auto pb-1" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="text-[10px] text-slate-500 font-semibold uppercase shrink-0">Colores:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{product.colors.slice(0, 4).map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActiveImage(c.image);
|
||||
}}
|
||||
title={c.colorName}
|
||||
className={`w-6 h-6 rounded-full overflow-hidden border transition-all cursor-pointer ${
|
||||
activeImage === c.image ? 'border-cyan-400 scale-110 shadow-md ring-1 ring-cyan-400' : 'border-slate-700 opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<img src={c.image} alt={c.colorName} className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
{product.colors.length > 4 && (
|
||||
<span className="text-[10px] font-bold text-cyan-400 bg-cyan-500/10 border border-cyan-500/30 rounded-full px-1.5 py-0.5">
|
||||
+{product.colors.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available Sizes Pills */}
|
||||
<div className="flex items-center gap-1 flex-wrap mt-2">
|
||||
<span className="text-[10px] text-slate-500 font-semibold uppercase">Tallas:</span>
|
||||
@ -119,10 +161,10 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
e.stopPropagation();
|
||||
onOpenDetail(product);
|
||||
}}
|
||||
className={`w-full bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-bold text-xs sm:text-sm py-2 px-3 rounded-xl flex items-center justify-center space-x-1.5 shadow-md active:scale-95 transition-all cursor-pointer`}
|
||||
className={`w-full bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-bold text-xs sm:text-sm py-2.5 px-3 rounded-2xl flex items-center justify-center space-x-1.5 shadow-md active:scale-95 transition-all cursor-pointer`}
|
||||
>
|
||||
<ShoppingBag className="w-3.5 h-3.5" />
|
||||
<span>Ver Tallas & Pedir</span>
|
||||
<span>{colorCount > 1 ? `Ver ${colorCount} Colores & Tallas` : 'Ver Tallas & Pedir'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { X, Star, Truck, ShieldCheck, Zap, MessageCircle, ShoppingBag, Check } from 'lucide-react';
|
||||
import { Product, City } from '@/types';
|
||||
import {
|
||||
X,
|
||||
Star,
|
||||
Truck,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
MessageCircle,
|
||||
ShoppingBag,
|
||||
Check,
|
||||
Palette,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { Product, City, ProductColorVariant } from '@/types';
|
||||
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
|
||||
@ -13,6 +26,8 @@ interface ProductModalProps {
|
||||
selectedCity: City;
|
||||
}
|
||||
|
||||
const ALL_STANDARD_SIZES = ['36', '37', '38', '39', '40', '41', '42', '43', '44'];
|
||||
|
||||
export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
product,
|
||||
onClose,
|
||||
@ -22,14 +37,51 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
const { currentBrand } = useBrand();
|
||||
if (!product) return null;
|
||||
|
||||
const [selectedSize, setSelectedSize] = useState<string>(product.sizes[0] || '39');
|
||||
const [selectedImage, setSelectedImage] = useState<string>(product.images[0]);
|
||||
const colorsList: ProductColorVariant[] =
|
||||
product.colors && product.colors.length > 0
|
||||
? product.colors
|
||||
: [
|
||||
{
|
||||
id: 'col-default',
|
||||
colorName: product.colorName || 'Color Principal',
|
||||
image: product.images[0],
|
||||
images: product.images,
|
||||
sizes: product.sizes,
|
||||
inStock: product.inStock,
|
||||
},
|
||||
];
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState<ProductColorVariant>(colorsList[0]);
|
||||
const [selectedImage, setSelectedImage] = useState<string>(colorsList[0]?.image || product.images[0]);
|
||||
const [availableSizes, setAvailableSizes] = useState<string[]>(colorsList[0]?.sizes || product.sizes);
|
||||
const [selectedSize, setSelectedSize] = useState<string>(
|
||||
(colorsList[0]?.sizes && colorsList[0].sizes[0]) || product.sizes[0] || '39'
|
||||
);
|
||||
const [added, setAdded] = useState(false);
|
||||
|
||||
const handleColorChange = (variant: ProductColorVariant) => {
|
||||
setSelectedColor(variant);
|
||||
setSelectedImage(variant.image || (variant.images && variant.images[0]) || product.images[0]);
|
||||
const nextSizes = variant.sizes && variant.sizes.length > 0 ? variant.sizes : product.sizes;
|
||||
setAvailableSizes(nextSizes);
|
||||
if (!nextSizes.includes(selectedSize)) {
|
||||
setSelectedSize(nextSizes[0] || selectedSize);
|
||||
}
|
||||
};
|
||||
|
||||
const isSizeInStock = availableSizes.includes(selectedSize);
|
||||
const separation = calculateSeparationBreakdown(product.retailPrice);
|
||||
|
||||
const handleAdd = () => {
|
||||
onAddToCart(product, selectedSize);
|
||||
if (!isSizeInStock) return;
|
||||
onAddToCart(
|
||||
{
|
||||
...product,
|
||||
name: `${product.modelName || product.name} (${selectedColor.colorName})`,
|
||||
images: selectedColor.images || [selectedImage],
|
||||
},
|
||||
selectedSize
|
||||
);
|
||||
setAdded(true);
|
||||
setTimeout(() => {
|
||||
setAdded(false);
|
||||
@ -38,9 +90,10 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
};
|
||||
|
||||
const handleWhatsAppDirect = () => {
|
||||
const phone = process.env.NEXT_PUBLIC_WHATSAPP_PHONE || '573134848203';
|
||||
const text = `Hola ${currentBrand.name}! Quiero pedir las zapatillas:
|
||||
👟 *${product.name}* (${product.brand})
|
||||
const phone = currentBrand.whatsappNumber || '573167455877';
|
||||
const text = `Hola ${currentBrand.name}! 👋 Quiero pedir las zapatillas:
|
||||
👟 *${product.modelName || product.name}*
|
||||
🎨 Color: *${selectedColor.colorName}*
|
||||
📏 Talla: *${selectedSize}* (Horma Nacional)
|
||||
💎 Precio: *${formatCOP(product.retailPrice)} COP*
|
||||
🚚 Entrega en: *${selectedCity.name}* (Pago Contraentrega)
|
||||
@ -50,78 +103,111 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
window.open(`https://wa.me/${phone}?text=${encodeURIComponent(text)}`, '_blank');
|
||||
};
|
||||
|
||||
const handleWhatsAppRequestSize = (sizeToRequest: string) => {
|
||||
const phone = currentBrand.whatsappNumber || '573167455877';
|
||||
const text = `Hola ${currentBrand.name}! 👋 Estaba revisando la tienda y me gustaron mucho estas zapatillas:
|
||||
👟 *${product.modelName || product.name}*
|
||||
🎨 Color: *${selectedColor.colorName}*
|
||||
📏 Talla que necesito: *${sizeToRequest}* (Actualmente no aparece disponible en stock inmediato)
|
||||
📍 Ciudad de entrega: *${selectedCity.name}*
|
||||
|
||||
¿Es posible conseguir esta talla bajo pedido con sus bodegas aliadas para despacho contraentrega? Quedo muy atento, ¡muchas gracias!`;
|
||||
window.open(`https://wa.me/${phone}?text=${encodeURIComponent(text)}`, '_blank');
|
||||
};
|
||||
|
||||
const displaySizes = Array.from(new Set([...ALL_STANDARD_SIZES, ...product.sizes])).sort(
|
||||
(a, b) => parseInt(a, 10) - parseInt(b, 10)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-md flex items-center justify-center p-3 sm:p-4 overflow-y-auto animate-in fade-in duration-200">
|
||||
<div className={`bg-slate-900 border ${currentBrand.colors.border} text-white rounded-3xl max-w-3xl w-full overflow-hidden shadow-2xl relative my-auto max-h-[92vh] flex flex-col md:flex-row`}>
|
||||
{/* Close Button */}
|
||||
<div className="fixed inset-0 z-50 bg-black/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-4 overflow-y-auto animate-in fade-in duration-200">
|
||||
<div className={`bg-slate-900 border ${currentBrand.colors.border} text-white rounded-3xl max-w-4xl w-full overflow-hidden shadow-2xl relative my-auto max-h-[94vh] flex flex-col md:flex-row`}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-20 w-9 h-9 rounded-full bg-slate-800/90 hover:bg-slate-700 text-slate-300 hover:text-white flex items-center justify-center transition-all cursor-pointer border border-slate-700"
|
||||
className="absolute top-4 right-4 z-20 w-9 h-9 rounded-full bg-slate-800/90 hover:bg-slate-700 text-slate-300 hover:text-white flex items-center justify-center transition-all cursor-pointer border border-slate-700 shadow-md"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Left: Images */}
|
||||
{/* Left: Images & Color Preview */}
|
||||
<div className="md:w-1/2 bg-slate-950 p-4 sm:p-6 flex flex-col justify-between border-b md:border-b-0 md:border-r border-slate-800">
|
||||
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-slate-900 shadow-inner mb-3">
|
||||
<img
|
||||
src={selectedImage}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<span className={`absolute top-3 left-3 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-xs font-black px-2.5 py-1 rounded-lg uppercase shadow`}>
|
||||
-{Math.round(((product.originalPrice || 170000) - product.retailPrice) / (product.originalPrice || 170000) * 100)}% OFF
|
||||
</span>
|
||||
<div>
|
||||
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-slate-900 shadow-inner mb-3 border border-slate-800">
|
||||
<img
|
||||
src={selectedImage}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover transition-all duration-300"
|
||||
/>
|
||||
<span className={`absolute top-3 left-3 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-xs font-black px-2.5 py-1 rounded-lg uppercase shadow`}>
|
||||
-{Math.round(((product.originalPrice || 170000) - product.retailPrice) / (product.originalPrice || 170000) * 100)}% OFF
|
||||
</span>
|
||||
|
||||
<div className="absolute bottom-3 left-3 bg-slate-950/90 backdrop-blur-md px-3 py-1 rounded-full border border-white/20 text-xs font-bold text-white flex items-center gap-1.5 shadow-lg">
|
||||
<Palette className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>{selectedColor.colorName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-400">
|
||||
<span className="font-bold uppercase tracking-wider">Fotos del Colorway:</span>
|
||||
<span>{selectedColor.images?.length || 1} vistas</span>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{(selectedColor.images && selectedColor.images.length > 0 ? selectedColor.images : product.images).map((img, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`w-16 h-16 rounded-xl overflow-hidden border-2 transition-all cursor-pointer shrink-0 ${
|
||||
selectedImage === img ? 'border-cyan-500 scale-105 shadow-md' : 'border-slate-800 opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<img src={img} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnails */}
|
||||
{product.images.length > 1 && (
|
||||
<div className="flex gap-2">
|
||||
{product.images.map((img, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`w-16 h-16 rounded-xl overflow-hidden border-2 transition-all cursor-pointer ${
|
||||
selectedImage === img ? 'border-white scale-105' : 'border-slate-800 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<img src={img} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trust note */}
|
||||
<div className="mt-3 bg-white/5 border border-white/10 rounded-xl p-2.5 text-xs text-slate-300 flex items-center gap-2">
|
||||
<ShieldCheck className={`w-4 h-4 shrink-0 ${selectedCity.firstExchangeFree ? 'text-emerald-400' : 'text-cyan-400'}`} />
|
||||
<span>
|
||||
<div className="mt-4 bg-white/5 border border-white/10 rounded-2xl p-3 text-xs text-slate-300 flex items-center gap-2.5">
|
||||
<ShieldCheck className={`w-5 h-5 shrink-0 ${selectedCity.firstExchangeFree ? 'text-emerald-400' : 'text-cyan-400'}`} />
|
||||
<div className="leading-tight">
|
||||
{selectedCity.firstExchangeFree ? (
|
||||
<><strong>1er Cambio de Talla GRATIS en {selectedCity.name}</strong> • Cambios posteriores: el cliente asume el flete</>
|
||||
<><strong>1er Cambio de Talla GRATIS en {selectedCity.name}</strong> • Cambios posteriores con flete estándar.</>
|
||||
) : (
|
||||
<><strong>Garantía de Cambio en {selectedCity.name}</strong> • El cliente asume los costos de flete</>
|
||||
<><strong>Garantía de Cambio en {selectedCity.name}</strong> • Envío asegurado contraentrega.</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Product Details & Actions */}
|
||||
<div className="md:w-1/2 p-5 sm:p-6 flex flex-col justify-between overflow-y-auto max-h-[60vh] md:max-h-[85vh]">
|
||||
{/* Right: Product Details */}
|
||||
<div className="md:w-1/2 p-5 sm:p-6 flex flex-col justify-between overflow-y-auto max-h-[60vh] md:max-h-[88vh] space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className={`text-xs font-bold uppercase tracking-wider text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>{product.brand}</span>
|
||||
<h2 className="text-xl sm:text-2xl font-black text-white leading-snug">{product.name}</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-xs font-black uppercase tracking-wider text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>
|
||||
{product.brand} • {product.category || 'Sneakers'}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 bg-slate-800 px-2 py-0.5 rounded-full font-mono">
|
||||
Ref: {product.id.slice(0, 10)}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-black text-white leading-snug mt-0.5">
|
||||
{product.modelName || product.name}
|
||||
</h2>
|
||||
<div className="flex items-center space-x-2 text-xs text-slate-400 mt-1">
|
||||
<div className="flex items-center text-amber-400 font-bold">
|
||||
<Star className="w-4 h-4 fill-amber-400 mr-1" />
|
||||
<span>{product.rating || 4.9}</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>+{product.salesCount || 200} pares entregados</span>
|
||||
<span>+{product.salesCount || 200} pares despachados</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Box */}
|
||||
<div className="bg-slate-950/70 border border-white/10 rounded-2xl p-3.5 space-y-2">
|
||||
<div className="bg-slate-950/80 border border-slate-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)}
|
||||
@ -133,38 +219,121 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Separation breakdown banner */}
|
||||
<div className="bg-white/5 rounded-xl p-2.5 border border-white/10 text-xs space-y-1">
|
||||
<p className="font-bold text-white flex items-center gap-1">
|
||||
<Zap className="w-3.5 h-3.5 fill-white" />
|
||||
¡Pago Contraentrega en {selectedCity.name}!
|
||||
</p>
|
||||
<p className="text-slate-300">
|
||||
Separas con solo <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y pagas los <strong className="text-white">{formatCOP(separation.balanceDue)}</strong> al recibir en tu puerta.
|
||||
<p className="text-slate-300 text-[11px] leading-relaxed">
|
||||
Separas con solo <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y pagas los <strong className="text-white">{formatCOP(separation.balanceDue)}</strong> al mensajero cuando llegue a tu casa.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size Selector */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold text-slate-200 uppercase">Selecciona tu Talla (Nacional CO):</span>
|
||||
<span className="text-[11px] text-slate-400 underline">Guía de Tallas</span>
|
||||
{/* Colorways Selector */}
|
||||
{colorsList.length > 0 && (
|
||||
<div className="space-y-2 pt-1 border-t border-slate-800/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-black text-white uppercase flex items-center gap-1.5">
|
||||
<Palette className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>Color Seleccionado:</span>
|
||||
</span>
|
||||
<span className="text-xs font-bold text-cyan-300">
|
||||
{selectedColor.colorName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{colorsList.map((variant) => {
|
||||
const isCurrent = selectedColor.id === variant.id;
|
||||
return (
|
||||
<button
|
||||
key={variant.id}
|
||||
onClick={() => handleColorChange(variant)}
|
||||
className={`flex items-center gap-2 p-2 rounded-xl border text-left transition-all cursor-pointer ${
|
||||
isCurrent
|
||||
? 'bg-cyan-950/40 border-cyan-500 shadow-md ring-1 ring-cyan-500 text-white'
|
||||
: 'bg-slate-950 hover:bg-slate-800/70 border-slate-800 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg overflow-hidden shrink-0 border border-slate-700 bg-slate-900">
|
||||
<img src={variant.image} alt={variant.colorName} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<span className="text-[11px] font-bold leading-tight line-clamp-2">
|
||||
{variant.colorName}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
|
||||
{product.sizes.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setSelectedSize(size)}
|
||||
className={`py-2 rounded-xl text-sm font-black transition-all cursor-pointer ${
|
||||
selectedSize === size
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-md scale-105 border border-white/30`
|
||||
: 'bg-slate-950 hover:bg-slate-800 text-slate-300 border border-slate-800'
|
||||
}`}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Size Selector */}
|
||||
<div className="space-y-2 pt-1 border-t border-slate-800/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-black text-white uppercase">
|
||||
Selecciona tu Talla (CO / Nacional):
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400 font-medium">
|
||||
{isSizeInStock ? (
|
||||
<span className="text-emerald-400 font-bold flex items-center gap-1">
|
||||
<Check className="w-3 h-3" /> Stock Inmediato
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-400 font-bold flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" /> Solicitud Bajo Pedido
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 sm:grid-cols-5 gap-2">
|
||||
{displaySizes.map((size) => {
|
||||
const inStock = availableSizes.includes(size);
|
||||
const isSelected = selectedSize === size;
|
||||
|
||||
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 ${
|
||||
isSelected
|
||||
? inStock
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-lg scale-105 border border-white/30`
|
||||
: 'bg-amber-600 text-white shadow-lg scale-105 border border-amber-300'
|
||||
: inStock
|
||||
? 'bg-slate-950 hover:bg-slate-800 text-white border border-slate-800'
|
||||
: 'bg-slate-950/60 hover:bg-slate-900 text-slate-500 border border-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<span>{size}</span>
|
||||
<span className="text-[8px] font-mono leading-none mt-0.5">
|
||||
{inStock ? 'Listo' : 'Pedir'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Banner: ¿No encuentras tu talla? */}
|
||||
<div className="bg-gradient-to-r from-cyan-950/40 to-slate-950 border border-cyan-500/30 rounded-2xl p-3 flex items-center justify-between gap-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-xl bg-cyan-500/20 text-cyan-400 flex items-center justify-center shrink-0">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-white">¿No encuentras tu talla exacta?</p>
|
||||
<p className="text-[10px] text-slate-400">Verificamos disponibilidad con bodega en 5 minutos.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleWhatsAppRequestSize(selectedSize)}
|
||||
className="bg-cyan-600 hover:bg-cyan-500 text-white font-black text-[10px] px-2.5 py-1.5 rounded-xl shrink-0 cursor-pointer shadow flex items-center gap-1"
|
||||
>
|
||||
<span>Pedir Talla</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -185,35 +354,56 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="pt-4 border-t border-slate-800 flex flex-col gap-2.5 mt-4">
|
||||
<button
|
||||
onClick={handleWhatsAppDirect}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-sm py-3 px-4 rounded-xl flex items-center justify-center space-x-2 shadow-lg shadow-emerald-600/20 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 fill-white" />
|
||||
<span>Pedir por WhatsApp (Contraentrega Talla {selectedSize})</span>
|
||||
</button>
|
||||
{isSizeInStock ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleWhatsAppDirect}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-sm py-3.5 px-4 rounded-2xl flex items-center justify-center space-x-2 shadow-lg shadow-emerald-600/20 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 fill-white" />
|
||||
<span>Pedir por WhatsApp (Contraentrega Talla {selectedSize} • {selectedColor.colorName})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={added}
|
||||
className={`w-full font-bold text-sm py-3 px-4 rounded-xl flex items-center justify-center space-x-2 transition-all cursor-pointer ${
|
||||
added
|
||||
? 'bg-slate-800 text-white border border-white/20'
|
||||
: `bg-gradient-to-r ${currentBrand.colors.primaryGradient} hover:opacity-95 text-white shadow-lg active:scale-95`
|
||||
}`}
|
||||
>
|
||||
{added ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
<span>¡Añadido al Carrito!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
<span>Añadir al Carrito (Talla {selectedSize})</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={added}
|
||||
className={`w-full font-bold text-sm py-3.5 px-4 rounded-2xl flex items-center justify-center space-x-2 transition-all cursor-pointer ${
|
||||
added
|
||||
? 'bg-slate-800 text-white border border-white/20'
|
||||
: `bg-gradient-to-r ${currentBrand.colors.primaryGradient} hover:opacity-95 text-white shadow-lg active:scale-95`
|
||||
}`}
|
||||
>
|
||||
{added ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
<span>¡Añadido al Carrito!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
<span>Añadir al Carrito (Talla {selectedSize})</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-2xl p-3 text-xs text-amber-200 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-amber-400" />
|
||||
<span>
|
||||
La talla <strong>{selectedSize}</strong> en <strong>{selectedColor.colorName}</strong> no tiene despacho inmediato en 24h, pero podemos pedirla a la bodega central para ti.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleWhatsAppRequestSize(selectedSize)}
|
||||
className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 hover:opacity-95 text-white font-black text-sm py-3.5 px-4 rounded-2xl flex items-center justify-center space-x-2 shadow-xl shadow-emerald-600/30 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 fill-white" />
|
||||
<span>Solicitar Talla {selectedSize} Bajo Pedido por WhatsApp</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
100
almeira-store-app/src/lib/product-grouper.ts
Normal file
100
almeira-store-app/src/lib/product-grouper.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { Product, ProductColorVariant } from '@/types';
|
||||
|
||||
export function extractModelAndColor(productName: string, brand: string): { modelName: string; colorName: string } {
|
||||
let clean = productName.trim();
|
||||
|
||||
const colorPatterns = [
|
||||
{ pattern: /(triple white|all white|blanco total|blancas)/i, name: 'Triple White' },
|
||||
{ pattern: /(triple black|all black|negro total|negras)/i, name: 'Triple Black' },
|
||||
{ pattern: /(panda|black & white|black white|blanco y negro)/i, name: 'Panda (Black & White)' },
|
||||
{ pattern: /(military black|negro militar)/i, name: 'Military Black' },
|
||||
{ pattern: /(military blue|azul militar)/i, name: 'Military Blue' },
|
||||
{ pattern: /(white cement|cemento blanco)/i, name: 'White Cement' },
|
||||
{ pattern: /(red cement|cemento rojo)/i, name: 'Red Cement' },
|
||||
{ pattern: /(chicago|lost & found|rojo chicago)/i, name: 'Chicago Red' },
|
||||
{ pattern: /(pine green|verde pino|verde)/i, name: 'Pine Green' },
|
||||
{ pattern: /(photon dust|metallic silver|gris plata)/i, name: 'Photon Dust / Silver' },
|
||||
{ pattern: /(travis scott|olive|baroque brown)/i, name: 'Travis Scott Edition' },
|
||||
{ pattern: /(core black|black white)/i, name: 'Core Black' },
|
||||
{ pattern: /(chalk white|off white|crema|beige)/i, name: 'Chalk White / Cream' },
|
||||
{ pattern: /(blue fusion|royal blue|azul)/i, name: 'Royal Blue' },
|
||||
{ pattern: /(thunder yellow|amarillo thunder)/i, name: 'Thunder Yellow' },
|
||||
{ pattern: /(dama & caballero|dama|caballero)/i, name: 'Edición Unisex' },
|
||||
];
|
||||
|
||||
let detectedColor = 'Color Original';
|
||||
for (const cp of colorPatterns) {
|
||||
if (cp.pattern.test(clean)) {
|
||||
detectedColor = cp.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
modelName: clean,
|
||||
colorName: detectedColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function groupProductsByModel(products: Product[]): Product[] {
|
||||
const modelMap = new Map<string, Product>();
|
||||
|
||||
products.forEach((prod) => {
|
||||
if (prod.colors && prod.colors.length > 1) {
|
||||
modelMap.set(prod.id, prod);
|
||||
return;
|
||||
}
|
||||
|
||||
const { modelName, colorName } = extractModelAndColor(prod.name, prod.brand);
|
||||
const groupKey = prod.modelName || `${prod.brand.toLowerCase()}_${modelName.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
|
||||
if (!modelMap.has(groupKey)) {
|
||||
const primaryVariant: ProductColorVariant = {
|
||||
id: `col-${prod.id}-1`,
|
||||
colorName: prod.colorName || colorName,
|
||||
image: prod.images[0] || '',
|
||||
images: prod.images,
|
||||
sizes: prod.sizes,
|
||||
inStock: prod.inStock,
|
||||
};
|
||||
|
||||
modelMap.set(groupKey, {
|
||||
...prod,
|
||||
modelName: prod.modelName || modelName,
|
||||
colorName: prod.colorName || colorName,
|
||||
colors: [primaryVariant],
|
||||
});
|
||||
} else {
|
||||
const existing = modelMap.get(groupKey)!;
|
||||
const currentColors = existing.colors || [];
|
||||
|
||||
const alreadyHasImage = currentColors.some(
|
||||
(c) => c.image === prod.images[0] || c.colorName.toLowerCase() === (prod.colorName || colorName).toLowerCase()
|
||||
);
|
||||
|
||||
if (!alreadyHasImage) {
|
||||
currentColors.push({
|
||||
id: `col-${prod.id}-${currentColors.length + 1}`,
|
||||
colorName: prod.colorName || colorName,
|
||||
image: prod.images[0] || '',
|
||||
images: prod.images,
|
||||
sizes: prod.sizes,
|
||||
inStock: prod.inStock,
|
||||
});
|
||||
|
||||
const allSizes = Array.from(new Set([...existing.sizes, ...prod.sizes])).sort(
|
||||
(a, b) => parseInt(a, 10) - parseInt(b, 10)
|
||||
);
|
||||
|
||||
modelMap.set(groupKey, {
|
||||
...existing,
|
||||
sizes: allSizes,
|
||||
images: Array.from(new Set([...existing.images, ...prod.images])),
|
||||
colors: currentColors,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(modelMap.values());
|
||||
}
|
||||
@ -26,15 +26,29 @@ export interface User {
|
||||
lastLogin?: string;
|
||||
}
|
||||
|
||||
export interface ProductColorVariant {
|
||||
id: string;
|
||||
colorName: string;
|
||||
colorHex?: string;
|
||||
image: string;
|
||||
images?: string[];
|
||||
sizes: string[];
|
||||
inStock?: boolean;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
modelName?: string;
|
||||
colorName?: string;
|
||||
brand: string;
|
||||
supplierPrice: number;
|
||||
retailPrice: number;
|
||||
originalPrice?: number;
|
||||
sizes: string[];
|
||||
allSizes?: string[];
|
||||
images: string[];
|
||||
colors?: ProductColorVariant[];
|
||||
description: string;
|
||||
category: string;
|
||||
inStock: boolean;
|
||||
|
||||
@ -46,10 +46,12 @@ def deploy():
|
||||
commands = f"""
|
||||
mkdir -p {REMOTE_DIR}
|
||||
tar -xzf {REMOTE_TAR} -C {REMOTE_DIR}
|
||||
rm {REMOTE_TAR}
|
||||
rm -f {REMOTE_TAR}
|
||||
sudo chown -R carlos:carlos /home/carlos/.docker || true
|
||||
chmod -R 777 /home/carlos/.docker || true
|
||||
cd {REMOTE_DIR}
|
||||
docker compose -f docker-compose.prod.yml down --remove-orphans || true
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
DOCKER_BUILDKIT=1 docker compose -f docker-compose.prod.yml up -d --build
|
||||
docker ps | grep dripzone
|
||||
"""
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Navbar } from '@/components/store/Navbar';
|
||||
import { FlashDealsBanner } from '@/components/store/FlashDealsBanner';
|
||||
import { CategoryPills } from '@/components/store/CategoryPills';
|
||||
@ -12,6 +12,7 @@ import { MobileBottomDock } from '@/components/mobile/MobileBottomDock';
|
||||
import { AuthModal } from '@/components/auth/AuthModal';
|
||||
import { Product, City, CartItem } from '@/types';
|
||||
import { INITIAL_PRODUCTS, INITIAL_CITIES } from '@/lib/data-service';
|
||||
import { groupProductsByModel } from '@/lib/product-grouper';
|
||||
import { Sparkles, ShieldCheck, Truck, RefreshCw, Zap } from 'lucide-react';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
|
||||
@ -53,13 +54,16 @@ export default function StoreHomePage() {
|
||||
.catch((e) => console.log('Usando ciudades iniciales:', e));
|
||||
}, []);
|
||||
|
||||
const filteredProducts = products.filter((p) => {
|
||||
const groupedProducts = useMemo(() => groupProductsByModel(products), [products]);
|
||||
|
||||
const filteredProducts = groupedProducts.filter((p) => {
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchName = p.name.toLowerCase().includes(q);
|
||||
const matchName = (p.modelName || p.name).toLowerCase().includes(q);
|
||||
const matchBrand = p.brand.toLowerCase().includes(q);
|
||||
const matchColor = p.colors?.some((c) => c.colorName.toLowerCase().includes(q));
|
||||
const matchSize = p.sizes.some((s) => s.includes(q));
|
||||
if (!matchName && !matchBrand && !matchSize) return false;
|
||||
if (!matchName && !matchBrand && !matchSize && !matchColor) return false;
|
||||
}
|
||||
|
||||
if (activeCategory === 'flash') return p.isFlashDeal;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Star, Zap, Truck, ShoppingBag } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { Star, Zap, Truck, ShoppingBag, Layers, Palette } from 'lucide-react';
|
||||
import { Product } from '@/types';
|
||||
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
@ -17,37 +17,51 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
onOpenDetail,
|
||||
}) => {
|
||||
const { currentBrand } = useBrand();
|
||||
const [activeImage, setActiveImage] = useState<string>(product.images[0]);
|
||||
const [activeColorName, setActiveColorName] = useState<string>(product.colorName || '');
|
||||
|
||||
const discountPercent = product.originalPrice
|
||||
? Math.round(((product.originalPrice - product.retailPrice) / product.originalPrice) * 100)
|
||||
: 28;
|
||||
|
||||
const separation = calculateSeparationBreakdown(product.retailPrice);
|
||||
const colorCount = product.colors?.length || 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onOpenDetail(product)}
|
||||
className={`group rounded-2xl border transition-all duration-300 flex flex-col cursor-pointer overflow-hidden ${currentBrand.colors.cardBg}`}
|
||||
className={`group rounded-3xl border transition-all duration-300 flex flex-col cursor-pointer overflow-hidden ${currentBrand.colors.cardBg} hover:shadow-2xl hover:border-purple-500/40`}
|
||||
>
|
||||
{/* Image Container */}
|
||||
<div className="relative aspect-square w-full bg-slate-950 overflow-hidden">
|
||||
<img
|
||||
src={product.images[0]}
|
||||
src={activeImage || product.images[0]}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover group-hover:scale-108 transition-transform duration-500"
|
||||
/>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="absolute top-2 left-2 flex flex-col gap-1 z-10">
|
||||
<span className={`bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-[10px] sm:text-xs font-black px-2.5 py-0.5 rounded-md shadow-md uppercase tracking-wider`}>
|
||||
<div className="absolute top-2.5 left-2.5 flex flex-col gap-1 z-10">
|
||||
<span className={`bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-[10px] sm:text-xs font-black px-2.5 py-0.5 rounded-lg shadow-md uppercase tracking-wider`}>
|
||||
-{discountPercent}% OFF
|
||||
</span>
|
||||
{product.isFlashDeal && (
|
||||
<span className="bg-white/90 text-slate-950 text-[10px] font-black px-2 py-0.5 rounded-md shadow-md flex items-center gap-0.5">
|
||||
<span className="bg-white/95 text-slate-950 text-[10px] font-black px-2 py-0.5 rounded-lg shadow-md flex items-center gap-0.5">
|
||||
<Zap className="w-3 h-3 fill-slate-950" /> FLASH
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Colors count indicator badge */}
|
||||
{colorCount > 1 && (
|
||||
<div className="absolute top-2.5 right-2.5 z-10">
|
||||
<span className="bg-slate-950/90 backdrop-blur-md text-purple-300 border border-purple-500/40 text-[10px] font-black px-2 py-0.5 rounded-full flex items-center gap-1 shadow-lg">
|
||||
<Palette className="w-3 h-3 text-purple-400" />
|
||||
<span>{colorCount} Colores</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-2 left-2 right-2 flex items-center justify-between pointer-events-none">
|
||||
<span className="bg-slate-950/80 backdrop-blur-xs text-white border border-white/10 text-[10px] font-semibold px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<Truck className="w-3 h-3 text-white" /> Contraentrega
|
||||
@ -75,9 +89,39 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
|
||||
{/* Product Title */}
|
||||
<h3 className="font-bold text-white text-sm sm:text-base line-clamp-2 transition-colors leading-snug">
|
||||
{product.name}
|
||||
{product.modelName || product.name}
|
||||
</h3>
|
||||
|
||||
{/* Color Thumbnails Preview Swatches if multi-color */}
|
||||
{product.colors && product.colors.length > 1 && (
|
||||
<div className="flex items-center gap-1.5 mt-2 overflow-x-auto pb-1" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="text-[10px] text-slate-500 font-semibold uppercase shrink-0">Colores:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{product.colors.slice(0, 4).map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActiveImage(c.image);
|
||||
setActiveColorName(c.colorName);
|
||||
}}
|
||||
title={c.colorName}
|
||||
className={`w-6 h-6 rounded-full overflow-hidden border transition-all cursor-pointer ${
|
||||
activeImage === c.image ? 'border-purple-400 scale-110 shadow-md ring-1 ring-purple-400' : 'border-slate-700 opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<img src={c.image} alt={c.colorName} className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
{product.colors.length > 4 && (
|
||||
<span className="text-[10px] font-bold text-purple-400 bg-purple-500/10 border border-purple-500/30 rounded-full px-1.5 py-0.5">
|
||||
+{product.colors.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available Sizes Pills */}
|
||||
<div className="flex items-center gap-1 flex-wrap mt-2">
|
||||
<span className="text-[10px] text-slate-500 font-semibold uppercase">Tallas:</span>
|
||||
@ -119,10 +163,10 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
e.stopPropagation();
|
||||
onOpenDetail(product);
|
||||
}}
|
||||
className={`w-full bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-bold text-xs sm:text-sm py-2 px-3 rounded-xl flex items-center justify-center space-x-1.5 shadow-md active:scale-95 transition-all cursor-pointer`}
|
||||
className={`w-full bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-bold text-xs sm:text-sm py-2.5 px-3 rounded-2xl flex items-center justify-center space-x-1.5 shadow-md active:scale-95 transition-all cursor-pointer`}
|
||||
>
|
||||
<ShoppingBag className="w-3.5 h-3.5" />
|
||||
<span>Ver Tallas & Pedir</span>
|
||||
<span>{colorCount > 1 ? `Ver ${colorCount} Colores & Tallas` : 'Ver Tallas & Pedir'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { X, Star, Truck, ShieldCheck, Zap, MessageCircle, ShoppingBag, Check } from 'lucide-react';
|
||||
import { Product, City } from '@/types';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Star,
|
||||
Truck,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
MessageCircle,
|
||||
ShoppingBag,
|
||||
Check,
|
||||
Palette,
|
||||
AlertCircle,
|
||||
HelpCircle,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { Product, City, ProductColorVariant } from '@/types';
|
||||
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
|
||||
@ -13,6 +27,8 @@ interface ProductModalProps {
|
||||
selectedCity: City;
|
||||
}
|
||||
|
||||
const ALL_STANDARD_SIZES = ['36', '37', '38', '39', '40', '41', '42', '43', '44'];
|
||||
|
||||
export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
product,
|
||||
onClose,
|
||||
@ -22,14 +38,54 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
const { currentBrand } = useBrand();
|
||||
if (!product) return null;
|
||||
|
||||
const [selectedSize, setSelectedSize] = useState<string>(product.sizes[0] || '39');
|
||||
const [selectedImage, setSelectedImage] = useState<string>(product.images[0]);
|
||||
// Active colorway variant
|
||||
const colorsList: ProductColorVariant[] =
|
||||
product.colors && product.colors.length > 0
|
||||
? product.colors
|
||||
: [
|
||||
{
|
||||
id: 'col-default',
|
||||
colorName: product.colorName || 'Color Principal',
|
||||
image: product.images[0],
|
||||
images: product.images,
|
||||
sizes: product.sizes,
|
||||
inStock: product.inStock,
|
||||
},
|
||||
];
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState<ProductColorVariant>(colorsList[0]);
|
||||
const [selectedImage, setSelectedImage] = useState<string>(colorsList[0]?.image || product.images[0]);
|
||||
const [availableSizes, setAvailableSizes] = useState<string[]>(colorsList[0]?.sizes || product.sizes);
|
||||
const [selectedSize, setSelectedSize] = useState<string>(
|
||||
(colorsList[0]?.sizes && colorsList[0].sizes[0]) || product.sizes[0] || '39'
|
||||
);
|
||||
const [added, setAdded] = useState(false);
|
||||
|
||||
// When color variant changes, update images & available sizes
|
||||
const handleColorChange = (variant: ProductColorVariant) => {
|
||||
setSelectedColor(variant);
|
||||
setSelectedImage(variant.image || (variant.images && variant.images[0]) || product.images[0]);
|
||||
const nextSizes = variant.sizes && variant.sizes.length > 0 ? variant.sizes : product.sizes;
|
||||
setAvailableSizes(nextSizes);
|
||||
if (!nextSizes.includes(selectedSize)) {
|
||||
setSelectedSize(nextSizes[0] || selectedSize);
|
||||
}
|
||||
};
|
||||
|
||||
const isSizeInStock = availableSizes.includes(selectedSize);
|
||||
|
||||
const separation = calculateSeparationBreakdown(product.retailPrice);
|
||||
|
||||
const handleAdd = () => {
|
||||
onAddToCart(product, selectedSize);
|
||||
if (!isSizeInStock) return;
|
||||
onAddToCart(
|
||||
{
|
||||
...product,
|
||||
name: `${product.modelName || product.name} (${selectedColor.colorName})`,
|
||||
images: selectedColor.images || [selectedImage],
|
||||
},
|
||||
selectedSize
|
||||
);
|
||||
setAdded(true);
|
||||
setTimeout(() => {
|
||||
setAdded(false);
|
||||
@ -37,10 +93,12 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
// Pedido normal por WhatsApp
|
||||
const handleWhatsAppDirect = () => {
|
||||
const phone = process.env.NEXT_PUBLIC_WHATSAPP_PHONE || '573134848203';
|
||||
const text = `Hola ${currentBrand.name}! Quiero pedir las zapatillas:
|
||||
👟 *${product.name}* (${product.brand})
|
||||
const phone = currentBrand.whatsappNumber || '573167455877';
|
||||
const text = `Hola ${currentBrand.name}! 👋 Quiero pedir las zapatillas:
|
||||
👟 *${product.modelName || product.name}*
|
||||
🎨 Color: *${selectedColor.colorName}*
|
||||
📏 Talla: *${selectedSize}* (Horma Nacional)
|
||||
💎 Precio: *${formatCOP(product.retailPrice)} COP*
|
||||
🚚 Entrega en: *${selectedCity.name}* (Pago Contraentrega)
|
||||
@ -50,84 +108,124 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
window.open(`https://wa.me/${phone}?text=${encodeURIComponent(text)}`, '_blank');
|
||||
};
|
||||
|
||||
// Solicitud especial de talla no disponible
|
||||
const handleWhatsAppRequestSize = (sizeToRequest: string) => {
|
||||
const phone = currentBrand.whatsappNumber || '573167455877';
|
||||
const text = `Hola ${currentBrand.name}! 👋 Estaba revisando la tienda y me gustaron mucho estas zapatillas:
|
||||
👟 *${product.modelName || product.name}*
|
||||
🎨 Color: *${selectedColor.colorName}*
|
||||
📏 Talla que necesito: *${sizeToRequest}* (Actualmente no aparece disponible en stock inmediato)
|
||||
📍 Ciudad de entrega: *${selectedCity.name}*
|
||||
|
||||
¿Es posible conseguir esta talla bajo pedido con sus bodegas aliadas para despacho contraentrega? Quedo muy atento, ¡muchas gracias!`;
|
||||
window.open(`https://wa.me/${phone}?text=${encodeURIComponent(text)}`, '_blank');
|
||||
};
|
||||
|
||||
// All sizes to show (combination of standard sizes and any custom product sizes)
|
||||
const displaySizes = Array.from(new Set([...ALL_STANDARD_SIZES, ...product.sizes])).sort(
|
||||
(a, b) => parseInt(a, 10) - parseInt(b, 10)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-md flex items-center justify-center p-3 sm:p-4 overflow-y-auto animate-in fade-in duration-200">
|
||||
<div className={`bg-slate-900 border ${currentBrand.colors.border} text-white rounded-3xl max-w-3xl w-full overflow-hidden shadow-2xl relative my-auto max-h-[92vh] flex flex-col md:flex-row`}>
|
||||
<div className="fixed inset-0 z-50 bg-black/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-4 overflow-y-auto animate-in fade-in duration-200">
|
||||
<div className={`bg-neutral-900 border ${currentBrand.colors.border} text-white rounded-3xl max-w-4xl w-full overflow-hidden shadow-2xl relative my-auto max-h-[94vh] flex flex-col md:flex-row`}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-20 w-9 h-9 rounded-full bg-slate-800/90 hover:bg-slate-700 text-slate-300 hover:text-white flex items-center justify-center transition-all cursor-pointer border border-slate-700"
|
||||
className="absolute top-4 right-4 z-20 w-9 h-9 rounded-full bg-neutral-800/90 hover:bg-neutral-700 text-neutral-300 hover:text-white flex items-center justify-center transition-all cursor-pointer border border-neutral-700 shadow-md"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Left: Images */}
|
||||
<div className="md:w-1/2 bg-slate-950 p-4 sm:p-6 flex flex-col justify-between border-b md:border-b-0 md:border-r border-slate-800">
|
||||
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-slate-900 shadow-inner mb-3">
|
||||
<img
|
||||
src={selectedImage}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<span className={`absolute top-3 left-3 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-xs font-black px-2.5 py-1 rounded-lg uppercase shadow`}>
|
||||
-{Math.round(((product.originalPrice || 170000) - product.retailPrice) / (product.originalPrice || 170000) * 100)}% OFF
|
||||
</span>
|
||||
{/* Left: Images & Color Preview */}
|
||||
<div className="md:w-1/2 bg-neutral-950 p-4 sm:p-6 flex flex-col justify-between border-b md:border-b-0 md:border-r border-neutral-800">
|
||||
<div>
|
||||
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-neutral-900 shadow-inner mb-3 border border-neutral-800">
|
||||
<img
|
||||
src={selectedImage}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover transition-all duration-300"
|
||||
/>
|
||||
<span className={`absolute top-3 left-3 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-xs font-black px-2.5 py-1 rounded-lg uppercase shadow`}>
|
||||
-{Math.round(((product.originalPrice || 170000) - product.retailPrice) / (product.originalPrice || 170000) * 100)}% OFF
|
||||
</span>
|
||||
|
||||
{/* Active Color Tag on Image */}
|
||||
<div className="absolute bottom-3 left-3 bg-neutral-950/90 backdrop-blur-md px-3 py-1 rounded-full border border-white/20 text-xs font-bold text-white flex items-center gap-1.5 shadow-lg">
|
||||
<Palette className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>{selectedColor.colorName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gallery / Colorway Thumbnails */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-[11px] text-neutral-400">
|
||||
<span className="font-bold uppercase tracking-wider">Fotos del Colorway:</span>
|
||||
<span>{selectedColor.images?.length || 1} vistas</span>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{(selectedColor.images && selectedColor.images.length > 0 ? selectedColor.images : product.images).map((img, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`w-16 h-16 rounded-xl overflow-hidden border-2 transition-all cursor-pointer shrink-0 ${
|
||||
selectedImage === img ? 'border-purple-500 scale-105 shadow-md' : 'border-neutral-800 opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<img src={img} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnails */}
|
||||
{product.images.length > 1 && (
|
||||
<div className="flex gap-2">
|
||||
{product.images.map((img, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`w-16 h-16 rounded-xl overflow-hidden border-2 transition-all cursor-pointer ${
|
||||
selectedImage === img ? 'border-white scale-105' : 'border-slate-800 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<img src={img} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trust note */}
|
||||
<div className="mt-3 bg-white/5 border border-white/10 rounded-xl p-2.5 text-xs text-slate-300 flex items-center gap-2">
|
||||
<ShieldCheck className={`w-4 h-4 shrink-0 ${selectedCity.firstExchangeFree ? 'text-emerald-400' : 'text-purple-400'}`} />
|
||||
<span>
|
||||
<div className="mt-4 bg-white/5 border border-white/10 rounded-2xl p-3 text-xs text-neutral-300 flex items-center gap-2.5">
|
||||
<ShieldCheck className={`w-5 h-5 shrink-0 ${selectedCity.firstExchangeFree ? 'text-emerald-400' : 'text-purple-400'}`} />
|
||||
<div className="leading-tight">
|
||||
{selectedCity.firstExchangeFree ? (
|
||||
<><strong>1er Cambio de Talla GRATIS en {selectedCity.name}</strong> • Cambios posteriores: el cliente asume el flete</>
|
||||
<><strong>1er Cambio de Talla GRATIS en {selectedCity.name}</strong> • Cambios posteriores con flete estándar.</>
|
||||
) : (
|
||||
<><strong>Garantía de Cambio en {selectedCity.name}</strong> • El cliente asume los costos de flete</>
|
||||
<><strong>Garantía de Cambio en {selectedCity.name}</strong> • Envío asegurado contraentrega.</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Product Details & Actions */}
|
||||
<div className="md:w-1/2 p-5 sm:p-6 flex flex-col justify-between overflow-y-auto max-h-[60vh] md:max-h-[85vh]">
|
||||
{/* Right: Product Details, Colorways, Sizes & WhatsApp Request */}
|
||||
<div className="md:w-1/2 p-5 sm:p-6 flex flex-col justify-between overflow-y-auto max-h-[60vh] md:max-h-[88vh] space-y-4">
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<span className={`text-xs font-bold uppercase tracking-wider text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>{product.brand}</span>
|
||||
<h2 className="text-xl sm:text-2xl font-black text-white leading-snug">{product.name}</h2>
|
||||
<div className="flex items-center space-x-2 text-xs text-slate-400 mt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-xs font-black uppercase tracking-wider text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>
|
||||
{product.brand} • {product.category || 'Sneakers'}
|
||||
</span>
|
||||
<span className="text-[10px] text-neutral-400 bg-neutral-800 px-2 py-0.5 rounded-full font-mono">
|
||||
Ref: {product.id.slice(0, 10)}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-black text-white leading-snug mt-0.5">
|
||||
{product.modelName || product.name}
|
||||
</h2>
|
||||
<div className="flex items-center space-x-2 text-xs text-neutral-400 mt-1">
|
||||
<div className="flex items-center text-amber-400 font-bold">
|
||||
<Star className="w-4 h-4 fill-amber-400 mr-1" />
|
||||
<span>{product.rating || 4.9}</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>+{product.salesCount || 200} pares entregados</span>
|
||||
<span>+{product.salesCount || 200} pares despachados</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Box */}
|
||||
<div className="bg-slate-950/70 border border-white/10 rounded-2xl p-3.5 space-y-2">
|
||||
<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)}
|
||||
</span>
|
||||
{product.originalPrice && (
|
||||
<span className="text-sm text-slate-500 line-through font-bold">
|
||||
<span className="text-sm text-neutral-500 line-through font-bold">
|
||||
{formatCOP(product.originalPrice)}
|
||||
</span>
|
||||
)}
|
||||
@ -139,42 +237,126 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
<Zap className="w-3.5 h-3.5 fill-white" />
|
||||
¡Pago Contraentrega en {selectedCity.name}!
|
||||
</p>
|
||||
<p className="text-slate-300">
|
||||
Separas con solo <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y pagas los <strong className="text-white">{formatCOP(separation.balanceDue)}</strong> al recibir en tu puerta.
|
||||
<p className="text-neutral-300 text-[11px] leading-relaxed">
|
||||
Separas con solo <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y pagas los <strong className="text-white">{formatCOP(separation.balanceDue)}</strong> al mensajero cuando llegue a tu casa.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size Selector */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold text-slate-200 uppercase">Selecciona tu Talla (Nacional CO):</span>
|
||||
<span className="text-[11px] text-slate-400 underline">Guía de Tallas</span>
|
||||
{/* Colorways Selector (Si hay más de 1 color o variante) */}
|
||||
{colorsList.length > 0 && (
|
||||
<div className="space-y-2 pt-1 border-t border-neutral-800/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-black text-white uppercase flex items-center gap-1.5">
|
||||
<Palette className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Color Seleccionado:</span>
|
||||
</span>
|
||||
<span className="text-xs font-bold text-purple-300">
|
||||
{selectedColor.colorName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{colorsList.map((variant) => {
|
||||
const isCurrent = selectedColor.id === variant.id;
|
||||
return (
|
||||
<button
|
||||
key={variant.id}
|
||||
onClick={() => handleColorChange(variant)}
|
||||
className={`flex items-center gap-2 p-2 rounded-xl border text-left transition-all cursor-pointer ${
|
||||
isCurrent
|
||||
? 'bg-purple-950/40 border-purple-500 shadow-md ring-1 ring-purple-500 text-white'
|
||||
: 'bg-neutral-950 hover:bg-neutral-800/70 border-neutral-800 text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg overflow-hidden shrink-0 border border-neutral-700 bg-neutral-900">
|
||||
<img src={variant.image} alt={variant.colorName} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<span className="text-[11px] font-bold leading-tight line-clamp-2">
|
||||
{variant.colorName}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
|
||||
{product.sizes.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setSelectedSize(size)}
|
||||
className={`py-2 rounded-xl text-sm font-black transition-all cursor-pointer ${
|
||||
selectedSize === size
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-md scale-105 border border-white/30`
|
||||
: 'bg-slate-950 hover:bg-slate-800 text-slate-300 border border-slate-800'
|
||||
}`}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Size Selector with Stock & WhatsApp sourcing option */}
|
||||
<div className="space-y-2 pt-1 border-t border-neutral-800/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-black text-white uppercase">
|
||||
Selecciona tu Talla (CO / Nacional):
|
||||
</span>
|
||||
<span className="text-[11px] text-neutral-400 font-medium">
|
||||
{isSizeInStock ? (
|
||||
<span className="text-emerald-400 font-bold flex items-center gap-1">
|
||||
<Check className="w-3 h-3" /> Stock Inmediato
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-400 font-bold flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" /> Solicitud Bajo Pedido
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 sm:grid-cols-5 gap-2">
|
||||
{displaySizes.map((size) => {
|
||||
const inStock = availableSizes.includes(size);
|
||||
const isSelected = selectedSize === size;
|
||||
|
||||
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 ${
|
||||
isSelected
|
||||
? inStock
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-lg scale-105 border border-white/30`
|
||||
: 'bg-amber-600 text-white shadow-lg scale-105 border border-amber-300'
|
||||
: inStock
|
||||
? 'bg-neutral-950 hover:bg-neutral-800 text-white border border-neutral-800'
|
||||
: 'bg-neutral-950/60 hover:bg-neutral-900 text-neutral-500 border border-neutral-800/60'
|
||||
}`}
|
||||
>
|
||||
<span>{size}</span>
|
||||
<span className="text-[8px] font-mono leading-none mt-0.5">
|
||||
{inStock ? 'Listo' : 'Pedir'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Banner: ¿No encuentras tu talla? */}
|
||||
<div className="bg-gradient-to-r from-purple-950/40 to-neutral-950 border border-purple-500/30 rounded-2xl p-3 flex items-center justify-between gap-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-xl bg-purple-500/20 text-purple-400 flex items-center justify-center shrink-0">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-white">¿No encuentras tu talla exacta?</p>
|
||||
<p className="text-[10px] text-neutral-400">Verificamos disponibilidad con bodega en 5 minutos.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleWhatsAppRequestSize(selectedSize)}
|
||||
className="bg-purple-600 hover:bg-purple-500 text-white font-black text-[10px] px-2.5 py-1.5 rounded-xl shrink-0 cursor-pointer shadow flex items-center gap-1"
|
||||
>
|
||||
<span>Pedir Talla</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="text-xs text-slate-400 leading-relaxed border-t border-slate-800 pt-3">
|
||||
<div className="text-xs text-neutral-400 leading-relaxed border-t border-neutral-800 pt-3">
|
||||
<p>{product.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Shipping note */}
|
||||
<div className="flex items-center gap-2 text-xs text-slate-300 bg-slate-950 p-2.5 rounded-xl border border-slate-800">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-300 bg-neutral-950 p-2.5 rounded-xl border border-neutral-800">
|
||||
<Truck className="w-4 h-4 text-white shrink-0" />
|
||||
<div>
|
||||
<strong className="text-white">{selectedCity.name}: </strong>
|
||||
@ -184,36 +366,57 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="pt-4 border-t border-slate-800 flex flex-col gap-2.5 mt-4">
|
||||
<button
|
||||
onClick={handleWhatsAppDirect}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-sm py-3 px-4 rounded-xl flex items-center justify-center space-x-2 shadow-lg shadow-emerald-600/20 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 fill-white" />
|
||||
<span>Pedir por WhatsApp (Contraentrega Talla {selectedSize})</span>
|
||||
</button>
|
||||
<div className="pt-4 border-t border-neutral-800 flex flex-col gap-2.5 mt-4">
|
||||
{isSizeInStock ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleWhatsAppDirect}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-sm py-3.5 px-4 rounded-2xl flex items-center justify-center space-x-2 shadow-lg shadow-emerald-600/20 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 fill-white" />
|
||||
<span>Pedir por WhatsApp (Contraentrega Talla {selectedSize} • {selectedColor.colorName})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={added}
|
||||
className={`w-full font-bold text-sm py-3 px-4 rounded-xl flex items-center justify-center space-x-2 transition-all cursor-pointer ${
|
||||
added
|
||||
? 'bg-slate-800 text-white border border-white/20'
|
||||
: `bg-gradient-to-r ${currentBrand.colors.primaryGradient} hover:opacity-95 text-white shadow-lg active:scale-95`
|
||||
}`}
|
||||
>
|
||||
{added ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
<span>¡Añadido al Carrito!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
<span>Añadir al Carrito (Talla {selectedSize})</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={added}
|
||||
className={`w-full font-bold text-sm py-3.5 px-4 rounded-2xl flex items-center justify-center space-x-2 transition-all cursor-pointer ${
|
||||
added
|
||||
? 'bg-neutral-800 text-white border border-white/20'
|
||||
: `bg-gradient-to-r ${currentBrand.colors.primaryGradient} hover:opacity-95 text-white shadow-lg active:scale-95`
|
||||
}`}
|
||||
>
|
||||
{added ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
<span>¡Añadido al Carrito!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
<span>Añadir al Carrito (Talla {selectedSize})</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-2xl p-3 text-xs text-amber-200 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-amber-400" />
|
||||
<span>
|
||||
La talla <strong>{selectedSize}</strong> en <strong>{selectedColor.colorName}</strong> no tiene despacho inmediato en 24h, pero podemos pedirla a la bodega central para ti.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleWhatsAppRequestSize(selectedSize)}
|
||||
className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 hover:opacity-95 text-white font-black text-sm py-3.5 px-4 rounded-2xl flex items-center justify-center space-x-2 shadow-xl shadow-emerald-600/30 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 fill-white" />
|
||||
<span>Solicitar Talla {selectedSize} Bajo Pedido por WhatsApp</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
115
dripzone-app/src/lib/product-grouper.ts
Normal file
115
dripzone-app/src/lib/product-grouper.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { Product, ProductColorVariant } from '@/types';
|
||||
|
||||
/**
|
||||
* Normaliza y extrae el nombre base del modelo para agrupar zapatillas
|
||||
* Ejemplos:
|
||||
* - "Nike Air Force 1 07 Low Triple White" -> "Nike Air Force 1 07 Low" (Color: Triple White)
|
||||
* - "Nike Dunk Low Retro Panda" -> "Nike Dunk Low Retro" (Color: Panda Black & White)
|
||||
* - "Air Jordan 4 Retro Military Black" -> "Air Jordan 4 Retro" (Color: Military Black)
|
||||
*/
|
||||
export function extractModelAndColor(productName: string, brand: string): { modelName: string; colorName: string } {
|
||||
let clean = productName.trim();
|
||||
|
||||
// Colores conocidos comunes
|
||||
const colorPatterns = [
|
||||
{ pattern: /(triple white|all white|blanco total|blancas)/i, name: 'Triple White' },
|
||||
{ pattern: /(triple black|all black|negro total|negras)/i, name: 'Triple Black' },
|
||||
{ pattern: /(panda|black & white|black white|blanco y negro)/i, name: 'Panda (Black & White)' },
|
||||
{ pattern: /(military black|negro militar)/i, name: 'Military Black' },
|
||||
{ pattern: /(military blue|azul militar)/i, name: 'Military Blue' },
|
||||
{ pattern: /(white cement|cemento blanco)/i, name: 'White Cement' },
|
||||
{ pattern: /(red cement|cemento rojo)/i, name: 'Red Cement' },
|
||||
{ pattern: /(chicago|lost & found|rojo chicago)/i, name: 'Chicago Red' },
|
||||
{ pattern: /(pine green|verde pino|verde)/i, name: 'Pine Green' },
|
||||
{ pattern: /(photon dust|metallic silver|gris plata)/i, name: 'Photon Dust / Silver' },
|
||||
{ pattern: /(travis scott|olive|baroque brown)/i, name: 'Travis Scott Edition' },
|
||||
{ pattern: /(core black|black white)/i, name: 'Core Black' },
|
||||
{ pattern: /(chalk white|off white|crema|beige)/i, name: 'Chalk White / Cream' },
|
||||
{ pattern: /(blue fusion|royal blue|azul)/i, name: 'Royal Blue' },
|
||||
{ pattern: /(thunder yellow|amarillo thunder)/i, name: 'Thunder Yellow' },
|
||||
{ pattern: /(dama & caballero|dama|caballero)/i, name: 'Edición Unisex' },
|
||||
];
|
||||
|
||||
let detectedColor = 'Color Original';
|
||||
for (const cp of colorPatterns) {
|
||||
if (cp.pattern.test(clean)) {
|
||||
detectedColor = cp.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
modelName: clean,
|
||||
colorName: detectedColor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrupa productos por modelo base para que aparezcan como un único ítem con múltiples variantes de color
|
||||
*/
|
||||
export function groupProductsByModel(products: Product[]): Product[] {
|
||||
const modelMap = new Map<string, Product>();
|
||||
|
||||
products.forEach((prod) => {
|
||||
// Si el producto ya tiene variantes de color configuradas explícitamente, usarlo directo
|
||||
if (prod.colors && prod.colors.length > 1) {
|
||||
modelMap.set(prod.id, prod);
|
||||
return;
|
||||
}
|
||||
|
||||
const { modelName, colorName } = extractModelAndColor(prod.name, prod.brand);
|
||||
const groupKey = prod.modelName || `${prod.brand.toLowerCase()}_${modelName.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
|
||||
if (!modelMap.has(groupKey)) {
|
||||
// Primer producto del grupo
|
||||
const primaryVariant: ProductColorVariant = {
|
||||
id: `col-${prod.id}-1`,
|
||||
colorName: prod.colorName || colorName,
|
||||
image: prod.images[0] || '',
|
||||
images: prod.images,
|
||||
sizes: prod.sizes,
|
||||
inStock: prod.inStock,
|
||||
};
|
||||
|
||||
modelMap.set(groupKey, {
|
||||
...prod,
|
||||
modelName: prod.modelName || modelName,
|
||||
colorName: prod.colorName || colorName,
|
||||
colors: [primaryVariant],
|
||||
});
|
||||
} else {
|
||||
// Producto existente: agregar variante de color si la imagen o color es diferente
|
||||
const existing = modelMap.get(groupKey)!;
|
||||
const currentColors = existing.colors || [];
|
||||
|
||||
const alreadyHasImage = currentColors.some(
|
||||
(c) => c.image === prod.images[0] || c.colorName.toLowerCase() === (prod.colorName || colorName).toLowerCase()
|
||||
);
|
||||
|
||||
if (!alreadyHasImage) {
|
||||
currentColors.push({
|
||||
id: `col-${prod.id}-${currentColors.length + 1}`,
|
||||
colorName: prod.colorName || colorName,
|
||||
image: prod.images[0] || '',
|
||||
images: prod.images,
|
||||
sizes: prod.sizes,
|
||||
inStock: prod.inStock,
|
||||
});
|
||||
|
||||
// Combinar tallas únicas
|
||||
const allSizes = Array.from(new Set([...existing.sizes, ...prod.sizes])).sort(
|
||||
(a, b) => parseInt(a, 10) - parseInt(b, 10)
|
||||
);
|
||||
|
||||
modelMap.set(groupKey, {
|
||||
...existing,
|
||||
sizes: allSizes,
|
||||
images: Array.from(new Set([...existing.images, ...prod.images])),
|
||||
colors: currentColors,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(modelMap.values());
|
||||
}
|
||||
@ -26,15 +26,29 @@ export interface User {
|
||||
lastLogin?: string;
|
||||
}
|
||||
|
||||
export interface ProductColorVariant {
|
||||
id: string;
|
||||
colorName: string;
|
||||
colorHex?: string;
|
||||
image: string;
|
||||
images?: string[];
|
||||
sizes: string[];
|
||||
inStock?: boolean;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
modelName?: string;
|
||||
colorName?: string;
|
||||
brand: string;
|
||||
supplierPrice: number;
|
||||
retailPrice: number;
|
||||
originalPrice?: number;
|
||||
sizes: string[];
|
||||
allSizes?: string[];
|
||||
images: string[];
|
||||
colors?: ProductColorVariant[];
|
||||
description: string;
|
||||
category: string;
|
||||
inStock: boolean;
|
||||
|
||||
@ -22,7 +22,7 @@ async function sendApkToCarlos() {
|
||||
const apkBuffer = fs.readFileSync(apkPath);
|
||||
const base64Data = apkBuffer.toString('base64');
|
||||
|
||||
const caption = `🚀 *DripZone Sneakers - APK Oficial Actualizado*\n\n📅 *Generado:* ${new Date().toLocaleString('es-CO', { timeZone: 'America/Bogota' })}\n📦 *Tamaño:* ${sizeMB} MB\n\n✨ *Novedades de esta versión:*\n🛡️ *Seguridad de Roles:* Botón Admin 100% oculto para clientes regulares; visible únicamente para administradores autenticados.\n🧹 *Catálogo Limpio en Vivo:* Eliminados productos mock iniciales. Si no hay productos publicados, muestra estado de actualización y drops.\n📥 *Feed WhatsApp & Selección:* Sincronización en vivo con grupos de WhatsApp, extracción por IA de marcas/tallas/precios y publicación selectiva a la app.\n✅ *Gestión Completa:* Proveedores, pedidos contraentrega, pagos Bold/Nequi y logística directa.\n\n📲 *Instalación:* Descarga y abre este archivo APK para actualizar la aplicación en tu dispositivo Android.`;
|
||||
const caption = `🚀 *DripZone Sneakers - APK Oficial Actualizado*\n\n📅 *Generado:* ${new Date().toLocaleString('es-CO', { timeZone: 'America/Bogota' })}\n📦 *Tamaño:* ${sizeMB} MB\n\n✨ *Novedades de esta versión:*\n🎨 *Agrupación por Modelo y Colores:* Mismo modelo con varios colores se muestra como un solo producto en la tienda, con selector visual de colorways y fotos independientes.\n📏 *Gestión de Tallas & WhatsApp:* Si una talla no está disponible, el cliente puede pulsar *“Solicitar Talla Bajo Pedido por WhatsApp”* con mensaje prellenado.\n🛡️ *Seguridad de Roles:* Botón Admin 100% oculto para clientes regulares; visible únicamente para administradores autenticados.\n📥 *Feed WhatsApp & Selección:* Sincronización en vivo con grupos de WhatsApp, extracción por IA de marcas/tallas/precios y publicación selectiva a la app.\n\n📲 *Instalación:* Descarga y abre este archivo APK para actualizar la aplicación en tu dispositivo Android.`;
|
||||
|
||||
console.log(`📤 Enviando APK vía Evolution API a WhatsApp (+${TARGET_PHONE})...`);
|
||||
|
||||
|
||||
@ -32,17 +32,21 @@ export default function CheckoutPage() {
|
||||
quantity: number;
|
||||
price: number;
|
||||
image: string;
|
||||
}>>([
|
||||
{
|
||||
productId: INITIAL_PRODUCTS[0].id,
|
||||
productName: INITIAL_PRODUCTS[0].name,
|
||||
brand: INITIAL_PRODUCTS[0].brand,
|
||||
size: '39',
|
||||
quantity: 1,
|
||||
price: INITIAL_PRODUCTS[0].retailPrice,
|
||||
image: INITIAL_PRODUCTS[0].images[0],
|
||||
},
|
||||
]);
|
||||
}>>(
|
||||
INITIAL_PRODUCTS.length > 0
|
||||
? [
|
||||
{
|
||||
productId: INITIAL_PRODUCTS[0].id,
|
||||
productName: INITIAL_PRODUCTS[0].name,
|
||||
brand: INITIAL_PRODUCTS[0].brand,
|
||||
size: '39',
|
||||
quantity: 1,
|
||||
price: INITIAL_PRODUCTS[0].retailPrice,
|
||||
image: INITIAL_PRODUCTS[0].images[0],
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [orderCompleted, setOrderCompleted] = useState<Order | null>(null);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Navbar } from '@/components/store/Navbar';
|
||||
import { FlashDealsBanner } from '@/components/store/FlashDealsBanner';
|
||||
import { CategoryPills } from '@/components/store/CategoryPills';
|
||||
@ -12,12 +12,13 @@ import { MobileBottomDock } from '@/components/mobile/MobileBottomDock';
|
||||
import { AuthModal } from '@/components/auth/AuthModal';
|
||||
import { Product, City, CartItem } from '@/types';
|
||||
import { INITIAL_PRODUCTS, INITIAL_CITIES } from '@/lib/data-service';
|
||||
import { groupProductsByModel } from '@/lib/product-grouper';
|
||||
import { Sparkles, ShieldCheck, Truck, RefreshCw, Zap } from 'lucide-react';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
|
||||
export default function StoreHomePage() {
|
||||
const { currentBrand } = useBrand();
|
||||
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [cities, setCities] = useState<City[]>(INITIAL_CITIES);
|
||||
const [selectedCity, setSelectedCity] = useState<City>(INITIAL_CITIES[0]); // Cali
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||
@ -35,11 +36,11 @@ export default function StoreHomePage() {
|
||||
fetch('/api/products')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success && data.products?.length > 0) {
|
||||
if (data.success && Array.isArray(data.products)) {
|
||||
setProducts(data.products);
|
||||
}
|
||||
})
|
||||
.catch((e) => console.log('Usando catálogo inicial:', e));
|
||||
.catch((e) => console.log('Error cargando productos:', e));
|
||||
|
||||
fetch('/api/cities')
|
||||
.then((res) => res.json())
|
||||
@ -53,6 +54,8 @@ export default function StoreHomePage() {
|
||||
.catch((e) => console.log('Usando ciudades iniciales:', e));
|
||||
}, []);
|
||||
|
||||
const groupedProducts = useMemo(() => groupProductsByModel(products), [products]);
|
||||
|
||||
const handleAddToCart = (product: Product, size: string) => {
|
||||
setCartItems((prev) => {
|
||||
const existingIdx = prev.findIndex(
|
||||
@ -88,13 +91,14 @@ export default function StoreHomePage() {
|
||||
);
|
||||
};
|
||||
|
||||
const filteredProducts = products.filter((p) => {
|
||||
const filteredProducts = groupedProducts.filter((p) => {
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchName = p.name.toLowerCase().includes(q);
|
||||
const matchName = (p.modelName || p.name).toLowerCase().includes(q);
|
||||
const matchBrand = p.brand.toLowerCase().includes(q);
|
||||
const matchColor = p.colors?.some((c) => c.colorName.toLowerCase().includes(q));
|
||||
const matchSize = p.sizes.some((s) => s.includes(q));
|
||||
if (!matchName && !matchBrand && !matchSize) return false;
|
||||
if (!matchName && !matchBrand && !matchSize && !matchColor) return false;
|
||||
}
|
||||
|
||||
if (activeCategory === 'flash') return p.isFlashDeal;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Star, Zap, Truck, ShoppingBag } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { Star, Zap, Truck, ShoppingBag, Palette } from 'lucide-react';
|
||||
import { Product } from '@/types';
|
||||
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
@ -17,39 +17,52 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
onOpenDetail,
|
||||
}) => {
|
||||
const { currentBrand } = useBrand();
|
||||
const [activeImage, setActiveImage] = useState<string>(product.images[0]);
|
||||
|
||||
const discountPercent = product.originalPrice
|
||||
? Math.round(((product.originalPrice - product.retailPrice) / product.originalPrice) * 100)
|
||||
: 28;
|
||||
|
||||
const separation = calculateSeparationBreakdown(product.retailPrice);
|
||||
const colorCount = product.colors?.length || 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onOpenDetail(product)}
|
||||
className={`group rounded-2xl border transition-all duration-300 flex flex-col cursor-pointer overflow-hidden ${currentBrand.colors.cardBg}`}
|
||||
className={`group rounded-3xl border transition-all duration-300 flex flex-col cursor-pointer overflow-hidden ${currentBrand.colors.cardBg} hover:shadow-2xl hover:border-orange-500/40`}
|
||||
>
|
||||
{/* Image Container */}
|
||||
<div className="relative aspect-square w-full bg-slate-950 overflow-hidden">
|
||||
<div className="relative aspect-square w-full bg-zinc-950 overflow-hidden">
|
||||
<img
|
||||
src={product.images[0]}
|
||||
src={activeImage || product.images[0]}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover group-hover:scale-108 transition-transform duration-500"
|
||||
/>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="absolute top-2 left-2 flex flex-col gap-1 z-10">
|
||||
<span className={`bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-[10px] sm:text-xs font-black px-2.5 py-0.5 rounded-md shadow-md uppercase tracking-wider`}>
|
||||
<div className="absolute top-2.5 left-2.5 flex flex-col gap-1 z-10">
|
||||
<span className={`bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-[10px] sm:text-xs font-black px-2.5 py-0.5 rounded-lg shadow-md uppercase tracking-wider`}>
|
||||
-{discountPercent}% OFF
|
||||
</span>
|
||||
{product.isFlashDeal && (
|
||||
<span className="bg-white/90 text-slate-950 text-[10px] font-black px-2 py-0.5 rounded-md shadow-md flex items-center gap-0.5">
|
||||
<Zap className="w-3 h-3 fill-slate-950" /> FLASH
|
||||
<span className="bg-white/95 text-zinc-950 text-[10px] font-black px-2 py-0.5 rounded-lg shadow-md flex items-center gap-0.5">
|
||||
<Zap className="w-3 h-3 fill-zinc-950" /> FLASH
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Colors count indicator badge */}
|
||||
{colorCount > 1 && (
|
||||
<div className="absolute top-2.5 right-2.5 z-10">
|
||||
<span className="bg-zinc-950/90 backdrop-blur-md text-orange-300 border border-orange-500/40 text-[10px] font-black px-2 py-0.5 rounded-full flex items-center gap-1 shadow-lg">
|
||||
<Palette className="w-3 h-3 text-orange-400" />
|
||||
<span>{colorCount} Colores</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-2 left-2 right-2 flex items-center justify-between pointer-events-none">
|
||||
<span className="bg-slate-950/80 backdrop-blur-xs text-white border border-white/10 text-[10px] font-semibold px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<span className="bg-zinc-950/80 backdrop-blur-xs text-white border border-white/10 text-[10px] font-semibold px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<Truck className="w-3 h-3 text-white" /> Contraentrega
|
||||
</span>
|
||||
<span className={`bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-[10px] font-bold px-2 py-0.5 rounded-full shadow-xs`}>
|
||||
@ -62,53 +75,82 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
<div className="p-3.5 sm:p-4 flex-1 flex flex-col justify-between space-y-2.5">
|
||||
<div>
|
||||
{/* Rating and Sales */}
|
||||
<div className="flex items-center justify-between text-xs text-slate-400 mb-1">
|
||||
<div className="flex items-center justify-between text-xs text-zinc-400 mb-1">
|
||||
<div className="flex items-center text-amber-400 font-bold">
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 mr-1" />
|
||||
<span>{product.rating || 4.9}</span>
|
||||
<span className="text-slate-500 font-normal ml-1">({product.reviewsCount || 89})</span>
|
||||
<span className="text-zinc-500 font-normal ml-1">({product.reviewsCount || 89})</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-slate-400">
|
||||
<span className="text-[11px] font-medium text-zinc-400">
|
||||
+{product.salesCount || 150} pedidos
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Product Title */}
|
||||
<h3 className="font-bold text-white text-sm sm:text-base line-clamp-2 transition-colors leading-snug">
|
||||
{product.name}
|
||||
{product.modelName || product.name}
|
||||
</h3>
|
||||
|
||||
{/* Color Thumbnails Preview Swatches if multi-color */}
|
||||
{product.colors && product.colors.length > 1 && (
|
||||
<div className="flex items-center gap-1.5 mt-2 overflow-x-auto pb-1" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="text-[10px] text-zinc-500 font-semibold uppercase shrink-0">Colores:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{product.colors.slice(0, 4).map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActiveImage(c.image);
|
||||
}}
|
||||
title={c.colorName}
|
||||
className={`w-6 h-6 rounded-full overflow-hidden border transition-all cursor-pointer ${
|
||||
activeImage === c.image ? 'border-orange-400 scale-110 shadow-md ring-1 ring-orange-400' : 'border-zinc-700 opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<img src={c.image} alt={c.colorName} className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
{product.colors.length > 4 && (
|
||||
<span className="text-[10px] font-bold text-orange-400 bg-orange-500/10 border border-orange-500/30 rounded-full px-1.5 py-0.5">
|
||||
+{product.colors.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available Sizes Pills */}
|
||||
<div className="flex items-center gap-1 flex-wrap mt-2">
|
||||
<span className="text-[10px] text-slate-500 font-semibold uppercase">Tallas:</span>
|
||||
<span className="text-[10px] text-zinc-500 font-semibold uppercase">Tallas:</span>
|
||||
{product.sizes.slice(0, 5).map((size) => (
|
||||
<span
|
||||
key={size}
|
||||
className="text-[10px] font-bold bg-slate-950 text-slate-300 px-1.5 py-0.5 rounded border border-slate-800"
|
||||
className="text-[10px] font-bold bg-zinc-950 text-zinc-300 px-1.5 py-0.5 rounded border border-zinc-800"
|
||||
>
|
||||
{size}
|
||||
</span>
|
||||
))}
|
||||
{product.sizes.length > 5 && (
|
||||
<span className="text-[10px] font-semibold text-slate-500">+{product.sizes.length - 5}</span>
|
||||
<span className="text-[10px] font-semibold text-zinc-500">+{product.sizes.length - 5}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing & Separation terms */}
|
||||
<div className="pt-2 border-t border-slate-800">
|
||||
<div className="pt-2 border-t border-zinc-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)}
|
||||
</span>
|
||||
{product.originalPrice && (
|
||||
<span className="text-xs text-slate-500 line-through font-semibold">
|
||||
<span className="text-xs text-zinc-500 line-through font-semibold">
|
||||
{formatCOP(product.originalPrice)}
|
||||
</span>
|
||||
)}
|
||||
</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">
|
||||
<p className="text-[11px] font-semibold bg-white/5 px-2 py-1 rounded-md mt-1.5 border border-white/10 text-zinc-300">
|
||||
⚡ Separa con <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y cancelas <strong className="text-white">{formatCOP(separation.balanceDue)}</strong> al recibir.
|
||||
</p>
|
||||
|
||||
@ -119,10 +161,10 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
e.stopPropagation();
|
||||
onOpenDetail(product);
|
||||
}}
|
||||
className={`w-full bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-bold text-xs sm:text-sm py-2 px-3 rounded-xl flex items-center justify-center space-x-1.5 shadow-md active:scale-95 transition-all cursor-pointer`}
|
||||
className={`w-full bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-bold text-xs sm:text-sm py-2.5 px-3 rounded-2xl flex items-center justify-center space-x-1.5 shadow-md active:scale-95 transition-all cursor-pointer`}
|
||||
>
|
||||
<ShoppingBag className="w-3.5 h-3.5" />
|
||||
<span>Ver Tallas & Pedir</span>
|
||||
<span>{colorCount > 1 ? `Ver ${colorCount} Colores & Tallas` : 'Ver Tallas & Pedir'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { X, Star, Truck, ShieldCheck, Zap, MessageCircle, ShoppingBag, Check } from 'lucide-react';
|
||||
import { Product, City } from '@/types';
|
||||
import {
|
||||
X,
|
||||
Star,
|
||||
Truck,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
MessageCircle,
|
||||
ShoppingBag,
|
||||
Check,
|
||||
Palette,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { Product, City, ProductColorVariant } from '@/types';
|
||||
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
|
||||
@ -13,6 +26,8 @@ interface ProductModalProps {
|
||||
selectedCity: City;
|
||||
}
|
||||
|
||||
const ALL_STANDARD_SIZES = ['36', '37', '38', '39', '40', '41', '42', '43', '44'];
|
||||
|
||||
export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
product,
|
||||
onClose,
|
||||
@ -22,14 +37,51 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
const { currentBrand } = useBrand();
|
||||
if (!product) return null;
|
||||
|
||||
const [selectedSize, setSelectedSize] = useState<string>(product.sizes[0] || '39');
|
||||
const [selectedImage, setSelectedImage] = useState<string>(product.images[0]);
|
||||
const colorsList: ProductColorVariant[] =
|
||||
product.colors && product.colors.length > 0
|
||||
? product.colors
|
||||
: [
|
||||
{
|
||||
id: 'col-default',
|
||||
colorName: product.colorName || 'Color Principal',
|
||||
image: product.images[0],
|
||||
images: product.images,
|
||||
sizes: product.sizes,
|
||||
inStock: product.inStock,
|
||||
},
|
||||
];
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState<ProductColorVariant>(colorsList[0]);
|
||||
const [selectedImage, setSelectedImage] = useState<string>(colorsList[0]?.image || product.images[0]);
|
||||
const [availableSizes, setAvailableSizes] = useState<string[]>(colorsList[0]?.sizes || product.sizes);
|
||||
const [selectedSize, setSelectedSize] = useState<string>(
|
||||
(colorsList[0]?.sizes && colorsList[0].sizes[0]) || product.sizes[0] || '39'
|
||||
);
|
||||
const [added, setAdded] = useState(false);
|
||||
|
||||
const handleColorChange = (variant: ProductColorVariant) => {
|
||||
setSelectedColor(variant);
|
||||
setSelectedImage(variant.image || (variant.images && variant.images[0]) || product.images[0]);
|
||||
const nextSizes = variant.sizes && variant.sizes.length > 0 ? variant.sizes : product.sizes;
|
||||
setAvailableSizes(nextSizes);
|
||||
if (!nextSizes.includes(selectedSize)) {
|
||||
setSelectedSize(nextSizes[0] || selectedSize);
|
||||
}
|
||||
};
|
||||
|
||||
const isSizeInStock = availableSizes.includes(selectedSize);
|
||||
const separation = calculateSeparationBreakdown(product.retailPrice);
|
||||
|
||||
const handleAdd = () => {
|
||||
onAddToCart(product, selectedSize);
|
||||
if (!isSizeInStock) return;
|
||||
onAddToCart(
|
||||
{
|
||||
...product,
|
||||
name: `${product.modelName || product.name} (${selectedColor.colorName})`,
|
||||
images: selectedColor.images || [selectedImage],
|
||||
},
|
||||
selectedSize
|
||||
);
|
||||
setAdded(true);
|
||||
setTimeout(() => {
|
||||
setAdded(false);
|
||||
@ -38,9 +90,10 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
};
|
||||
|
||||
const handleWhatsAppDirect = () => {
|
||||
const phone = process.env.NEXT_PUBLIC_WHATSAPP_PHONE || '573134848203';
|
||||
const text = `Hola ${currentBrand.name}! Quiero pedir las zapatillas:
|
||||
👟 *${product.name}* (${product.brand})
|
||||
const phone = currentBrand.whatsappNumber || '573167455877';
|
||||
const text = `Hola ${currentBrand.name}! 👋 Quiero pedir las zapatillas:
|
||||
👟 *${product.modelName || product.name}*
|
||||
🎨 Color: *${selectedColor.colorName}*
|
||||
📏 Talla: *${selectedSize}* (Horma Nacional)
|
||||
💎 Precio: *${formatCOP(product.retailPrice)} COP*
|
||||
🚚 Entrega en: *${selectedCity.name}* (Pago Contraentrega)
|
||||
@ -50,131 +103,247 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
window.open(`https://wa.me/${phone}?text=${encodeURIComponent(text)}`, '_blank');
|
||||
};
|
||||
|
||||
const handleWhatsAppRequestSize = (sizeToRequest: string) => {
|
||||
const phone = currentBrand.whatsappNumber || '573167455877';
|
||||
const text = `Hola ${currentBrand.name}! 👋 Estaba revisando la tienda y me gustaron mucho estas zapatillas:
|
||||
👟 *${product.modelName || product.name}*
|
||||
🎨 Color: *${selectedColor.colorName}*
|
||||
📏 Talla que necesito: *${sizeToRequest}* (Actualmente no aparece disponible en stock inmediato)
|
||||
📍 Ciudad de entrega: *${selectedCity.name}*
|
||||
|
||||
¿Es posible conseguir esta talla bajo pedido con sus bodegas aliadas para despacho contraentrega? Quedo muy atento, ¡muchas gracias!`;
|
||||
window.open(`https://wa.me/${phone}?text=${encodeURIComponent(text)}`, '_blank');
|
||||
};
|
||||
|
||||
const displaySizes = Array.from(new Set([...ALL_STANDARD_SIZES, ...product.sizes])).sort(
|
||||
(a, b) => parseInt(a, 10) - parseInt(b, 10)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-md flex items-center justify-center p-3 sm:p-4 overflow-y-auto animate-in fade-in duration-200">
|
||||
<div className={`bg-slate-900 border ${currentBrand.colors.border} text-white rounded-3xl max-w-3xl w-full overflow-hidden shadow-2xl relative my-auto max-h-[92vh] flex flex-col md:flex-row`}>
|
||||
{/* Close Button */}
|
||||
<div className="fixed inset-0 z-50 bg-black/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-4 overflow-y-auto animate-in fade-in duration-200">
|
||||
<div className={`bg-zinc-900 border ${currentBrand.colors.border} text-white rounded-3xl max-w-4xl w-full overflow-hidden shadow-2xl relative my-auto max-h-[94vh] flex flex-col md:flex-row`}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-20 w-9 h-9 rounded-full bg-slate-800/90 hover:bg-slate-700 text-slate-300 hover:text-white flex items-center justify-center transition-all cursor-pointer border border-slate-700"
|
||||
className="absolute top-4 right-4 z-20 w-9 h-9 rounded-full bg-zinc-800/90 hover:bg-zinc-700 text-zinc-300 hover:text-white flex items-center justify-center transition-all cursor-pointer border border-zinc-700 shadow-md"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Left: Images */}
|
||||
<div className="md:w-1/2 bg-slate-950 p-4 sm:p-6 flex flex-col justify-between border-b md:border-b-0 md:border-r border-slate-800">
|
||||
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-slate-900 shadow-inner mb-3">
|
||||
<img
|
||||
src={selectedImage}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<span className={`absolute top-3 left-3 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-xs font-black px-2.5 py-1 rounded-lg uppercase shadow`}>
|
||||
-{Math.round(((product.originalPrice || 170000) - product.retailPrice) / (product.originalPrice || 170000) * 100)}% OFF
|
||||
</span>
|
||||
{/* Left: Images & Color Preview */}
|
||||
<div className="md:w-1/2 bg-zinc-950 p-4 sm:p-6 flex flex-col justify-between border-b md:border-b-0 md:border-r border-zinc-800">
|
||||
<div>
|
||||
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-zinc-900 shadow-inner mb-3 border border-zinc-800">
|
||||
<img
|
||||
src={selectedImage}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover transition-all duration-300"
|
||||
/>
|
||||
<span className={`absolute top-3 left-3 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-xs font-black px-2.5 py-1 rounded-lg uppercase shadow`}>
|
||||
-{Math.round(((product.originalPrice || 170000) - product.retailPrice) / (product.originalPrice || 170000) * 100)}% OFF
|
||||
</span>
|
||||
|
||||
<div className="absolute bottom-3 left-3 bg-zinc-950/90 backdrop-blur-md px-3 py-1 rounded-full border border-white/20 text-xs font-bold text-white flex items-center gap-1.5 shadow-lg">
|
||||
<Palette className="w-3.5 h-3.5 text-orange-400" />
|
||||
<span>{selectedColor.colorName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-[11px] text-zinc-400">
|
||||
<span className="font-bold uppercase tracking-wider">Fotos del Colorway:</span>
|
||||
<span>{selectedColor.images?.length || 1} vistas</span>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{(selectedColor.images && selectedColor.images.length > 0 ? selectedColor.images : product.images).map((img, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`w-16 h-16 rounded-xl overflow-hidden border-2 transition-all cursor-pointer shrink-0 ${
|
||||
selectedImage === img ? 'border-orange-500 scale-105 shadow-md' : 'border-zinc-800 opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<img src={img} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnails */}
|
||||
{product.images.length > 1 && (
|
||||
<div className="flex gap-2">
|
||||
{product.images.map((img, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`w-16 h-16 rounded-xl overflow-hidden border-2 transition-all cursor-pointer ${
|
||||
selectedImage === img ? 'border-white scale-105' : 'border-slate-800 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<img src={img} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trust note */}
|
||||
<div className="mt-3 bg-white/5 border border-white/10 rounded-xl p-2.5 text-xs text-slate-300 flex items-center gap-2">
|
||||
<ShieldCheck className={`w-4 h-4 shrink-0 ${selectedCity.firstExchangeFree ? 'text-emerald-400' : 'text-orange-400'}`} />
|
||||
<span>
|
||||
<div className="mt-4 bg-white/5 border border-white/10 rounded-2xl p-3 text-xs text-zinc-300 flex items-center gap-2.5">
|
||||
<ShieldCheck className={`w-5 h-5 shrink-0 ${selectedCity.firstExchangeFree ? 'text-emerald-400' : 'text-orange-400'}`} />
|
||||
<div className="leading-tight">
|
||||
{selectedCity.firstExchangeFree ? (
|
||||
<><strong>1er Cambio de Talla GRATIS en {selectedCity.name}</strong> • Cambios posteriores: el cliente asume el flete</>
|
||||
<><strong>1er Cambio de Talla GRATIS en {selectedCity.name}</strong> • Cambios posteriores con flete estándar.</>
|
||||
) : (
|
||||
<><strong>Garantía de Cambio en {selectedCity.name}</strong> • El cliente asume los costos de flete</>
|
||||
<><strong>Garantía de Cambio en {selectedCity.name}</strong> • Envío asegurado contraentrega.</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Product Details & Actions */}
|
||||
<div className="md:w-1/2 p-5 sm:p-6 flex flex-col justify-between overflow-y-auto max-h-[60vh] md:max-h-[85vh]">
|
||||
{/* Right: Product Details */}
|
||||
<div className="md:w-1/2 p-5 sm:p-6 flex flex-col justify-between overflow-y-auto max-h-[60vh] md:max-h-[88vh] space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className={`text-xs font-bold uppercase tracking-wider text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>{product.brand}</span>
|
||||
<h2 className="text-xl sm:text-2xl font-black text-white leading-snug">{product.name}</h2>
|
||||
<div className="flex items-center space-x-2 text-xs text-slate-400 mt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-xs font-black uppercase tracking-wider text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>
|
||||
{product.brand} • {product.category || 'Sneakers'}
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-400 bg-zinc-800 px-2 py-0.5 rounded-full font-mono">
|
||||
Ref: {product.id.slice(0, 10)}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-black text-white leading-snug mt-0.5">
|
||||
{product.modelName || product.name}
|
||||
</h2>
|
||||
<div className="flex items-center space-x-2 text-xs text-zinc-400 mt-1">
|
||||
<div className="flex items-center text-amber-400 font-bold">
|
||||
<Star className="w-4 h-4 fill-amber-400 mr-1" />
|
||||
<span>{product.rating || 4.9}</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>+{product.salesCount || 200} pares entregados</span>
|
||||
<span>+{product.salesCount || 200} pares despachados</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Box */}
|
||||
<div className="bg-slate-950/70 border border-white/10 rounded-2xl p-3.5 space-y-2">
|
||||
<div className="bg-zinc-950/80 border border-zinc-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)}
|
||||
</span>
|
||||
{product.originalPrice && (
|
||||
<span className="text-sm text-slate-500 line-through font-bold">
|
||||
<span className="text-sm text-zinc-500 line-through font-bold">
|
||||
{formatCOP(product.originalPrice)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Separation breakdown banner */}
|
||||
<div className="bg-white/5 rounded-xl p-2.5 border border-white/10 text-xs space-y-1">
|
||||
<p className="font-bold text-white flex items-center gap-1">
|
||||
<Zap className="w-3.5 h-3.5 fill-white" />
|
||||
¡Pago Contraentrega en {selectedCity.name}!
|
||||
</p>
|
||||
<p className="text-slate-300">
|
||||
Separas con solo <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y pagas los <strong className="text-white">{formatCOP(separation.balanceDue)}</strong> al recibir en tu puerta.
|
||||
<p className="text-zinc-300 text-[11px] leading-relaxed">
|
||||
Separas con solo <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y pagas los <strong className="text-white">{formatCOP(separation.balanceDue)}</strong> al mensajero cuando llegue a tu casa.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size Selector */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold text-slate-200 uppercase">Selecciona tu Talla (Nacional CO):</span>
|
||||
<span className="text-[11px] text-slate-400 underline">Guía de Tallas</span>
|
||||
{/* Colorways Selector */}
|
||||
{colorsList.length > 0 && (
|
||||
<div className="space-y-2 pt-1 border-t border-zinc-800/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-black text-white uppercase flex items-center gap-1.5">
|
||||
<Palette className="w-3.5 h-3.5 text-orange-400" />
|
||||
<span>Color Seleccionado:</span>
|
||||
</span>
|
||||
<span className="text-xs font-bold text-orange-300">
|
||||
{selectedColor.colorName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{colorsList.map((variant) => {
|
||||
const isCurrent = selectedColor.id === variant.id;
|
||||
return (
|
||||
<button
|
||||
key={variant.id}
|
||||
onClick={() => handleColorChange(variant)}
|
||||
className={`flex items-center gap-2 p-2 rounded-xl border text-left transition-all cursor-pointer ${
|
||||
isCurrent
|
||||
? 'bg-orange-950/40 border-orange-500 shadow-md ring-1 ring-orange-500 text-white'
|
||||
: 'bg-zinc-950 hover:bg-zinc-800/70 border-zinc-800 text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg overflow-hidden shrink-0 border border-zinc-700 bg-zinc-900">
|
||||
<img src={variant.image} alt={variant.colorName} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<span className="text-[11px] font-bold leading-tight line-clamp-2">
|
||||
{variant.colorName}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
|
||||
{product.sizes.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setSelectedSize(size)}
|
||||
className={`py-2 rounded-xl text-sm font-black transition-all cursor-pointer ${
|
||||
selectedSize === size
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-md scale-105 border border-white/30`
|
||||
: 'bg-slate-950 hover:bg-slate-800 text-slate-300 border border-slate-800'
|
||||
}`}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Size Selector */}
|
||||
<div className="space-y-2 pt-1 border-t border-zinc-800/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-black text-white uppercase">
|
||||
Selecciona tu Talla (CO / Nacional):
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400 font-medium">
|
||||
{isSizeInStock ? (
|
||||
<span className="text-emerald-400 font-bold flex items-center gap-1">
|
||||
<Check className="w-3 h-3" /> Stock Inmediato
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-400 font-bold flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" /> Solicitud Bajo Pedido
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 sm:grid-cols-5 gap-2">
|
||||
{displaySizes.map((size) => {
|
||||
const inStock = availableSizes.includes(size);
|
||||
const isSelected = selectedSize === size;
|
||||
|
||||
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 ${
|
||||
isSelected
|
||||
? inStock
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-lg scale-105 border border-white/30`
|
||||
: 'bg-amber-600 text-white shadow-lg scale-105 border border-amber-300'
|
||||
: inStock
|
||||
? 'bg-zinc-950 hover:bg-zinc-800 text-white border border-zinc-800'
|
||||
: 'bg-zinc-950/60 hover:bg-zinc-900 text-zinc-500 border border-zinc-800/60'
|
||||
}`}
|
||||
>
|
||||
<span>{size}</span>
|
||||
<span className="text-[8px] font-mono leading-none mt-0.5">
|
||||
{inStock ? 'Listo' : 'Pedir'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Banner: ¿No encuentras tu talla? */}
|
||||
<div className="bg-gradient-to-r from-orange-950/40 to-zinc-950 border border-orange-500/30 rounded-2xl p-3 flex items-center justify-between gap-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-xl bg-orange-500/20 text-orange-400 flex items-center justify-center shrink-0">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-white">¿No encuentras tu talla exacta?</p>
|
||||
<p className="text-[10px] text-zinc-400">Verificamos disponibilidad con bodega en 5 minutos.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleWhatsAppRequestSize(selectedSize)}
|
||||
className="bg-orange-600 hover:bg-orange-500 text-white font-black text-[10px] px-2.5 py-1.5 rounded-xl shrink-0 cursor-pointer shadow flex items-center gap-1"
|
||||
>
|
||||
<span>Pedir Talla</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="text-xs text-slate-400 leading-relaxed border-t border-slate-800 pt-3">
|
||||
<div className="text-xs text-zinc-400 leading-relaxed border-t border-zinc-800 pt-3">
|
||||
<p>{product.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Shipping note */}
|
||||
<div className="flex items-center gap-2 text-xs text-slate-300 bg-slate-950 p-2.5 rounded-xl border border-slate-800">
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-300 bg-zinc-950 p-2.5 rounded-xl border border-zinc-800">
|
||||
<Truck className="w-4 h-4 text-white shrink-0" />
|
||||
<div>
|
||||
<strong className="text-white">{selectedCity.name}: </strong>
|
||||
@ -184,36 +353,57 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="pt-4 border-t border-slate-800 flex flex-col gap-2.5 mt-4">
|
||||
<button
|
||||
onClick={handleWhatsAppDirect}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-sm py-3 px-4 rounded-xl flex items-center justify-center space-x-2 shadow-lg shadow-emerald-600/20 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 fill-white" />
|
||||
<span>Pedir por WhatsApp (Contraentrega Talla {selectedSize})</span>
|
||||
</button>
|
||||
<div className="pt-4 border-t border-zinc-800 flex flex-col gap-2.5 mt-4">
|
||||
{isSizeInStock ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleWhatsAppDirect}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-sm py-3.5 px-4 rounded-2xl flex items-center justify-center space-x-2 shadow-lg shadow-emerald-600/20 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 fill-white" />
|
||||
<span>Pedir por WhatsApp (Contraentrega Talla {selectedSize} • {selectedColor.colorName})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={added}
|
||||
className={`w-full font-bold text-sm py-3 px-4 rounded-xl flex items-center justify-center space-x-2 transition-all cursor-pointer ${
|
||||
added
|
||||
? 'bg-slate-800 text-white border border-white/20'
|
||||
: `bg-gradient-to-r ${currentBrand.colors.primaryGradient} hover:opacity-95 text-white shadow-lg active:scale-95`
|
||||
}`}
|
||||
>
|
||||
{added ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
<span>¡Añadido al Carrito!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
<span>Añadir al Carrito (Talla {selectedSize})</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={added}
|
||||
className={`w-full font-bold text-sm py-3.5 px-4 rounded-2xl flex items-center justify-center space-x-2 transition-all cursor-pointer ${
|
||||
added
|
||||
? 'bg-zinc-800 text-white border border-white/20'
|
||||
: `bg-gradient-to-r ${currentBrand.colors.primaryGradient} hover:opacity-95 text-white shadow-lg active:scale-95`
|
||||
}`}
|
||||
>
|
||||
{added ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
<span>¡Añadido al Carrito!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
<span>Añadir al Carrito (Talla {selectedSize})</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-2xl p-3 text-xs text-amber-200 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-amber-400" />
|
||||
<span>
|
||||
La talla <strong>{selectedSize}</strong> en <strong>{selectedColor.colorName}</strong> no tiene despacho inmediato en 24h, pero podemos pedirla a la bodega central para ti.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleWhatsAppRequestSize(selectedSize)}
|
||||
className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 hover:opacity-95 text-white font-black text-sm py-3.5 px-4 rounded-2xl flex items-center justify-center space-x-2 shadow-xl shadow-emerald-600/30 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 fill-white" />
|
||||
<span>Solicitar Talla {selectedSize} Bajo Pedido por WhatsApp</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
100
sneaky-app/src/lib/product-grouper.ts
Normal file
100
sneaky-app/src/lib/product-grouper.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { Product, ProductColorVariant } from '@/types';
|
||||
|
||||
export function extractModelAndColor(productName: string, brand: string): { modelName: string; colorName: string } {
|
||||
let clean = productName.trim();
|
||||
|
||||
const colorPatterns = [
|
||||
{ pattern: /(triple white|all white|blanco total|blancas)/i, name: 'Triple White' },
|
||||
{ pattern: /(triple black|all black|negro total|negras)/i, name: 'Triple Black' },
|
||||
{ pattern: /(panda|black & white|black white|blanco y negro)/i, name: 'Panda (Black & White)' },
|
||||
{ pattern: /(military black|negro militar)/i, name: 'Military Black' },
|
||||
{ pattern: /(military blue|azul militar)/i, name: 'Military Blue' },
|
||||
{ pattern: /(white cement|cemento blanco)/i, name: 'White Cement' },
|
||||
{ pattern: /(red cement|cemento rojo)/i, name: 'Red Cement' },
|
||||
{ pattern: /(chicago|lost & found|rojo chicago)/i, name: 'Chicago Red' },
|
||||
{ pattern: /(pine green|verde pino|verde)/i, name: 'Pine Green' },
|
||||
{ pattern: /(photon dust|metallic silver|gris plata)/i, name: 'Photon Dust / Silver' },
|
||||
{ pattern: /(travis scott|olive|baroque brown)/i, name: 'Travis Scott Edition' },
|
||||
{ pattern: /(core black|black white)/i, name: 'Core Black' },
|
||||
{ pattern: /(chalk white|off white|crema|beige)/i, name: 'Chalk White / Cream' },
|
||||
{ pattern: /(blue fusion|royal blue|azul)/i, name: 'Royal Blue' },
|
||||
{ pattern: /(thunder yellow|amarillo thunder)/i, name: 'Thunder Yellow' },
|
||||
{ pattern: /(dama & caballero|dama|caballero)/i, name: 'Edición Unisex' },
|
||||
];
|
||||
|
||||
let detectedColor = 'Color Original';
|
||||
for (const cp of colorPatterns) {
|
||||
if (cp.pattern.test(clean)) {
|
||||
detectedColor = cp.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
modelName: clean,
|
||||
colorName: detectedColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function groupProductsByModel(products: Product[]): Product[] {
|
||||
const modelMap = new Map<string, Product>();
|
||||
|
||||
products.forEach((prod) => {
|
||||
if (prod.colors && prod.colors.length > 1) {
|
||||
modelMap.set(prod.id, prod);
|
||||
return;
|
||||
}
|
||||
|
||||
const { modelName, colorName } = extractModelAndColor(prod.name, prod.brand);
|
||||
const groupKey = prod.modelName || `${prod.brand.toLowerCase()}_${modelName.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
|
||||
if (!modelMap.has(groupKey)) {
|
||||
const primaryVariant: ProductColorVariant = {
|
||||
id: `col-${prod.id}-1`,
|
||||
colorName: prod.colorName || colorName,
|
||||
image: prod.images[0] || '',
|
||||
images: prod.images,
|
||||
sizes: prod.sizes,
|
||||
inStock: prod.inStock,
|
||||
};
|
||||
|
||||
modelMap.set(groupKey, {
|
||||
...prod,
|
||||
modelName: prod.modelName || modelName,
|
||||
colorName: prod.colorName || colorName,
|
||||
colors: [primaryVariant],
|
||||
});
|
||||
} else {
|
||||
const existing = modelMap.get(groupKey)!;
|
||||
const currentColors = existing.colors || [];
|
||||
|
||||
const alreadyHasImage = currentColors.some(
|
||||
(c) => c.image === prod.images[0] || c.colorName.toLowerCase() === (prod.colorName || colorName).toLowerCase()
|
||||
);
|
||||
|
||||
if (!alreadyHasImage) {
|
||||
currentColors.push({
|
||||
id: `col-${prod.id}-${currentColors.length + 1}`,
|
||||
colorName: prod.colorName || colorName,
|
||||
image: prod.images[0] || '',
|
||||
images: prod.images,
|
||||
sizes: prod.sizes,
|
||||
inStock: prod.inStock,
|
||||
});
|
||||
|
||||
const allSizes = Array.from(new Set([...existing.sizes, ...prod.sizes])).sort(
|
||||
(a, b) => parseInt(a, 10) - parseInt(b, 10)
|
||||
);
|
||||
|
||||
modelMap.set(groupKey, {
|
||||
...existing,
|
||||
sizes: allSizes,
|
||||
images: Array.from(new Set([...existing.images, ...prod.images])),
|
||||
colors: currentColors,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(modelMap.values());
|
||||
}
|
||||
@ -26,15 +26,29 @@ export interface User {
|
||||
lastLogin?: string;
|
||||
}
|
||||
|
||||
export interface ProductColorVariant {
|
||||
id: string;
|
||||
colorName: string;
|
||||
colorHex?: string;
|
||||
image: string;
|
||||
images?: string[];
|
||||
sizes: string[];
|
||||
inStock?: boolean;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
modelName?: string;
|
||||
colorName?: string;
|
||||
brand: string;
|
||||
supplierPrice: number;
|
||||
retailPrice: number;
|
||||
originalPrice?: number;
|
||||
sizes: string[];
|
||||
allSizes?: string[];
|
||||
images: string[];
|
||||
colors?: ProductColorVariant[];
|
||||
description: string;
|
||||
category: string;
|
||||
inStock: boolean;
|
||||
|
||||
@ -32,17 +32,21 @@ export default function CheckoutPage() {
|
||||
quantity: number;
|
||||
price: number;
|
||||
image: string;
|
||||
}>>([
|
||||
{
|
||||
productId: INITIAL_PRODUCTS[0].id,
|
||||
productName: INITIAL_PRODUCTS[0].name,
|
||||
brand: INITIAL_PRODUCTS[0].brand,
|
||||
size: '39',
|
||||
quantity: 1,
|
||||
price: INITIAL_PRODUCTS[0].retailPrice,
|
||||
image: INITIAL_PRODUCTS[0].images[0],
|
||||
},
|
||||
]);
|
||||
}>>(
|
||||
INITIAL_PRODUCTS.length > 0
|
||||
? [
|
||||
{
|
||||
productId: INITIAL_PRODUCTS[0].id,
|
||||
productName: INITIAL_PRODUCTS[0].name,
|
||||
brand: INITIAL_PRODUCTS[0].brand,
|
||||
size: '39',
|
||||
quantity: 1,
|
||||
price: INITIAL_PRODUCTS[0].retailPrice,
|
||||
image: INITIAL_PRODUCTS[0].images[0],
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [orderCompleted, setOrderCompleted] = useState<Order | null>(null);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Navbar } from '@/components/store/Navbar';
|
||||
import { FlashDealsBanner } from '@/components/store/FlashDealsBanner';
|
||||
import { CategoryPills } from '@/components/store/CategoryPills';
|
||||
@ -12,12 +12,13 @@ import { MobileBottomDock } from '@/components/mobile/MobileBottomDock';
|
||||
import { AuthModal } from '@/components/auth/AuthModal';
|
||||
import { Product, City, CartItem } from '@/types';
|
||||
import { INITIAL_PRODUCTS, INITIAL_CITIES } from '@/lib/data-service';
|
||||
import { groupProductsByModel } from '@/lib/product-grouper';
|
||||
import { Sparkles, ShieldCheck, Truck, RefreshCw, Zap } from 'lucide-react';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
|
||||
export default function StoreHomePage() {
|
||||
const { currentBrand } = useBrand();
|
||||
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [cities, setCities] = useState<City[]>(INITIAL_CITIES);
|
||||
const [selectedCity, setSelectedCity] = useState<City>(INITIAL_CITIES[0]); // Cali
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||
@ -35,11 +36,11 @@ export default function StoreHomePage() {
|
||||
fetch('/api/products')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success && data.products?.length > 0) {
|
||||
if (data.success && Array.isArray(data.products)) {
|
||||
setProducts(data.products);
|
||||
}
|
||||
})
|
||||
.catch((e) => console.log('Usando catálogo inicial:', e));
|
||||
.catch((e) => console.log('Error cargando productos:', e));
|
||||
|
||||
fetch('/api/cities')
|
||||
.then((res) => res.json())
|
||||
@ -53,13 +54,16 @@ export default function StoreHomePage() {
|
||||
.catch((e) => console.log('Usando ciudades iniciales:', e));
|
||||
}, []);
|
||||
|
||||
const filteredProducts = products.filter((p) => {
|
||||
const groupedProducts = useMemo(() => groupProductsByModel(products), [products]);
|
||||
|
||||
const filteredProducts = groupedProducts.filter((p) => {
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchName = p.name.toLowerCase().includes(q);
|
||||
const matchName = (p.modelName || p.name).toLowerCase().includes(q);
|
||||
const matchBrand = p.brand.toLowerCase().includes(q);
|
||||
const matchColor = p.colors?.some((c) => c.colorName.toLowerCase().includes(q));
|
||||
const matchSize = p.sizes.some((s) => s.includes(q));
|
||||
if (!matchName && !matchBrand && !matchSize) return false;
|
||||
if (!matchName && !matchBrand && !matchSize && !matchColor) return false;
|
||||
}
|
||||
|
||||
if (activeCategory === 'flash') return p.isFlashDeal;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Star, Zap, Truck, ShoppingBag } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { Star, Zap, Truck, ShoppingBag, Palette } from 'lucide-react';
|
||||
import { Product } from '@/types';
|
||||
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
@ -17,37 +17,50 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
onOpenDetail,
|
||||
}) => {
|
||||
const { currentBrand } = useBrand();
|
||||
const [activeImage, setActiveImage] = useState<string>(product.images[0]);
|
||||
|
||||
const discountPercent = product.originalPrice
|
||||
? Math.round(((product.originalPrice - product.retailPrice) / product.originalPrice) * 100)
|
||||
: 28;
|
||||
|
||||
const separation = calculateSeparationBreakdown(product.retailPrice);
|
||||
const colorCount = product.colors?.length || 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onOpenDetail(product)}
|
||||
className={`group rounded-2xl border transition-all duration-300 flex flex-col cursor-pointer overflow-hidden ${currentBrand.colors.cardBg}`}
|
||||
className={`group rounded-3xl border transition-all duration-300 flex flex-col cursor-pointer overflow-hidden ${currentBrand.colors.cardBg} hover:shadow-2xl hover:border-emerald-500/40`}
|
||||
>
|
||||
{/* Image Container */}
|
||||
<div className="relative aspect-square w-full bg-slate-950 overflow-hidden">
|
||||
<img
|
||||
src={product.images[0]}
|
||||
src={activeImage || product.images[0]}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover group-hover:scale-108 transition-transform duration-500"
|
||||
/>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="absolute top-2 left-2 flex flex-col gap-1 z-10">
|
||||
<span className={`bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-[10px] sm:text-xs font-black px-2.5 py-0.5 rounded-md shadow-md uppercase tracking-wider`}>
|
||||
<div className="absolute top-2.5 left-2.5 flex flex-col gap-1 z-10">
|
||||
<span className={`bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-[10px] sm:text-xs font-black px-2.5 py-0.5 rounded-lg shadow-md uppercase tracking-wider`}>
|
||||
-{discountPercent}% OFF
|
||||
</span>
|
||||
{product.isFlashDeal && (
|
||||
<span className="bg-white/90 text-slate-950 text-[10px] font-black px-2 py-0.5 rounded-md shadow-md flex items-center gap-0.5">
|
||||
<span className="bg-white/95 text-slate-950 text-[10px] font-black px-2 py-0.5 rounded-lg shadow-md flex items-center gap-0.5">
|
||||
<Zap className="w-3 h-3 fill-slate-950" /> FLASH
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Colors count indicator badge */}
|
||||
{colorCount > 1 && (
|
||||
<div className="absolute top-2.5 right-2.5 z-10">
|
||||
<span className="bg-slate-950/90 backdrop-blur-md text-emerald-300 border border-emerald-500/40 text-[10px] font-black px-2 py-0.5 rounded-full flex items-center gap-1 shadow-lg">
|
||||
<Palette className="w-3 h-3 text-emerald-400" />
|
||||
<span>{colorCount} Colores</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-2 left-2 right-2 flex items-center justify-between pointer-events-none">
|
||||
<span className="bg-slate-950/80 backdrop-blur-xs text-white border border-white/10 text-[10px] font-semibold px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<Truck className="w-3 h-3 text-white" /> Contraentrega
|
||||
@ -75,9 +88,38 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
|
||||
{/* Product Title */}
|
||||
<h3 className="font-bold text-white text-sm sm:text-base line-clamp-2 transition-colors leading-snug">
|
||||
{product.name}
|
||||
{product.modelName || product.name}
|
||||
</h3>
|
||||
|
||||
{/* Color Thumbnails Preview Swatches if multi-color */}
|
||||
{product.colors && product.colors.length > 1 && (
|
||||
<div className="flex items-center gap-1.5 mt-2 overflow-x-auto pb-1" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="text-[10px] text-slate-500 font-semibold uppercase shrink-0">Colores:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{product.colors.slice(0, 4).map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setActiveImage(c.image);
|
||||
}}
|
||||
title={c.colorName}
|
||||
className={`w-6 h-6 rounded-full overflow-hidden border transition-all cursor-pointer ${
|
||||
activeImage === c.image ? 'border-emerald-400 scale-110 shadow-md ring-1 ring-emerald-400' : 'border-slate-700 opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<img src={c.image} alt={c.colorName} className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
{product.colors.length > 4 && (
|
||||
<span className="text-[10px] font-bold text-emerald-400 bg-emerald-500/10 border border-emerald-500/30 rounded-full px-1.5 py-0.5">
|
||||
+{product.colors.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available Sizes Pills */}
|
||||
<div className="flex items-center gap-1 flex-wrap mt-2">
|
||||
<span className="text-[10px] text-slate-500 font-semibold uppercase">Tallas:</span>
|
||||
@ -119,10 +161,10 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
e.stopPropagation();
|
||||
onOpenDetail(product);
|
||||
}}
|
||||
className={`w-full bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-bold text-xs sm:text-sm py-2 px-3 rounded-xl flex items-center justify-center space-x-1.5 shadow-md active:scale-95 transition-all cursor-pointer`}
|
||||
className={`w-full bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-bold text-xs sm:text-sm py-2.5 px-3 rounded-2xl flex items-center justify-center space-x-1.5 shadow-md active:scale-95 transition-all cursor-pointer`}
|
||||
>
|
||||
<ShoppingBag className="w-3.5 h-3.5" />
|
||||
<span>Ver Tallas & Pedir</span>
|
||||
<span>{colorCount > 1 ? `Ver ${colorCount} Colores & Tallas` : 'Ver Tallas & Pedir'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { X, Star, Truck, ShieldCheck, Zap, MessageCircle, ShoppingBag, Check } from 'lucide-react';
|
||||
import { Product, City } from '@/types';
|
||||
import {
|
||||
X,
|
||||
Star,
|
||||
Truck,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
MessageCircle,
|
||||
ShoppingBag,
|
||||
Check,
|
||||
Palette,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { Product, City, ProductColorVariant } from '@/types';
|
||||
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
|
||||
@ -13,6 +26,8 @@ interface ProductModalProps {
|
||||
selectedCity: City;
|
||||
}
|
||||
|
||||
const ALL_STANDARD_SIZES = ['36', '37', '38', '39', '40', '41', '42', '43', '44'];
|
||||
|
||||
export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
product,
|
||||
onClose,
|
||||
@ -22,14 +37,51 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
const { currentBrand } = useBrand();
|
||||
if (!product) return null;
|
||||
|
||||
const [selectedSize, setSelectedSize] = useState<string>(product.sizes[0] || '39');
|
||||
const [selectedImage, setSelectedImage] = useState<string>(product.images[0]);
|
||||
const colorsList: ProductColorVariant[] =
|
||||
product.colors && product.colors.length > 0
|
||||
? product.colors
|
||||
: [
|
||||
{
|
||||
id: 'col-default',
|
||||
colorName: product.colorName || 'Color Principal',
|
||||
image: product.images[0],
|
||||
images: product.images,
|
||||
sizes: product.sizes,
|
||||
inStock: product.inStock,
|
||||
},
|
||||
];
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState<ProductColorVariant>(colorsList[0]);
|
||||
const [selectedImage, setSelectedImage] = useState<string>(colorsList[0]?.image || product.images[0]);
|
||||
const [availableSizes, setAvailableSizes] = useState<string[]>(colorsList[0]?.sizes || product.sizes);
|
||||
const [selectedSize, setSelectedSize] = useState<string>(
|
||||
(colorsList[0]?.sizes && colorsList[0].sizes[0]) || product.sizes[0] || '39'
|
||||
);
|
||||
const [added, setAdded] = useState(false);
|
||||
|
||||
const handleColorChange = (variant: ProductColorVariant) => {
|
||||
setSelectedColor(variant);
|
||||
setSelectedImage(variant.image || (variant.images && variant.images[0]) || product.images[0]);
|
||||
const nextSizes = variant.sizes && variant.sizes.length > 0 ? variant.sizes : product.sizes;
|
||||
setAvailableSizes(nextSizes);
|
||||
if (!nextSizes.includes(selectedSize)) {
|
||||
setSelectedSize(nextSizes[0] || selectedSize);
|
||||
}
|
||||
};
|
||||
|
||||
const isSizeInStock = availableSizes.includes(selectedSize);
|
||||
const separation = calculateSeparationBreakdown(product.retailPrice);
|
||||
|
||||
const handleAdd = () => {
|
||||
onAddToCart(product, selectedSize);
|
||||
if (!isSizeInStock) return;
|
||||
onAddToCart(
|
||||
{
|
||||
...product,
|
||||
name: `${product.modelName || product.name} (${selectedColor.colorName})`,
|
||||
images: selectedColor.images || [selectedImage],
|
||||
},
|
||||
selectedSize
|
||||
);
|
||||
setAdded(true);
|
||||
setTimeout(() => {
|
||||
setAdded(false);
|
||||
@ -38,9 +90,10 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
};
|
||||
|
||||
const handleWhatsAppDirect = () => {
|
||||
const phone = process.env.NEXT_PUBLIC_WHATSAPP_PHONE || '573134848203';
|
||||
const text = `Hola ${currentBrand.name}! Quiero pedir las zapatillas:
|
||||
👟 *${product.name}* (${product.brand})
|
||||
const phone = currentBrand.whatsappNumber || '573167455877';
|
||||
const text = `Hola ${currentBrand.name}! 👋 Quiero pedir las zapatillas:
|
||||
👟 *${product.modelName || product.name}*
|
||||
🎨 Color: *${selectedColor.colorName}*
|
||||
📏 Talla: *${selectedSize}* (Horma Nacional)
|
||||
💎 Precio: *${formatCOP(product.retailPrice)} COP*
|
||||
🚚 Entrega en: *${selectedCity.name}* (Pago Contraentrega)
|
||||
@ -50,78 +103,111 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
window.open(`https://wa.me/${phone}?text=${encodeURIComponent(text)}`, '_blank');
|
||||
};
|
||||
|
||||
const handleWhatsAppRequestSize = (sizeToRequest: string) => {
|
||||
const phone = currentBrand.whatsappNumber || '573167455877';
|
||||
const text = `Hola ${currentBrand.name}! 👋 Estaba revisando la tienda y me gustaron mucho estas zapatillas:
|
||||
👟 *${product.modelName || product.name}*
|
||||
🎨 Color: *${selectedColor.colorName}*
|
||||
📏 Talla que necesito: *${sizeToRequest}* (Actualmente no aparece disponible en stock inmediato)
|
||||
📍 Ciudad de entrega: *${selectedCity.name}*
|
||||
|
||||
¿Es posible conseguir esta talla bajo pedido con sus bodegas aliadas para despacho contraentrega? Quedo muy atento, ¡muchas gracias!`;
|
||||
window.open(`https://wa.me/${phone}?text=${encodeURIComponent(text)}`, '_blank');
|
||||
};
|
||||
|
||||
const displaySizes = Array.from(new Set([...ALL_STANDARD_SIZES, ...product.sizes])).sort(
|
||||
(a, b) => parseInt(a, 10) - parseInt(b, 10)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-md flex items-center justify-center p-3 sm:p-4 overflow-y-auto animate-in fade-in duration-200">
|
||||
<div className={`bg-slate-900 border ${currentBrand.colors.border} text-white rounded-3xl max-w-3xl w-full overflow-hidden shadow-2xl relative my-auto max-h-[92vh] flex flex-col md:flex-row`}>
|
||||
{/* Close Button */}
|
||||
<div className="fixed inset-0 z-50 bg-black/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-4 overflow-y-auto animate-in fade-in duration-200">
|
||||
<div className={`bg-slate-900 border ${currentBrand.colors.border} text-white rounded-3xl max-w-4xl w-full overflow-hidden shadow-2xl relative my-auto max-h-[94vh] flex flex-col md:flex-row`}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-20 w-9 h-9 rounded-full bg-slate-800/90 hover:bg-slate-700 text-slate-300 hover:text-white flex items-center justify-center transition-all cursor-pointer border border-slate-700"
|
||||
className="absolute top-4 right-4 z-20 w-9 h-9 rounded-full bg-slate-800/90 hover:bg-slate-700 text-slate-300 hover:text-white flex items-center justify-center transition-all cursor-pointer border border-slate-700 shadow-md"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Left: Images */}
|
||||
{/* Left: Images & Color Preview */}
|
||||
<div className="md:w-1/2 bg-slate-950 p-4 sm:p-6 flex flex-col justify-between border-b md:border-b-0 md:border-r border-slate-800">
|
||||
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-slate-900 shadow-inner mb-3">
|
||||
<img
|
||||
src={selectedImage}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<span className={`absolute top-3 left-3 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-xs font-black px-2.5 py-1 rounded-lg uppercase shadow`}>
|
||||
-{Math.round(((product.originalPrice || 170000) - product.retailPrice) / (product.originalPrice || 170000) * 100)}% OFF
|
||||
</span>
|
||||
<div>
|
||||
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-slate-900 shadow-inner mb-3 border border-slate-800">
|
||||
<img
|
||||
src={selectedImage}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover transition-all duration-300"
|
||||
/>
|
||||
<span className={`absolute top-3 left-3 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white text-xs font-black px-2.5 py-1 rounded-lg uppercase shadow`}>
|
||||
-{Math.round(((product.originalPrice || 170000) - product.retailPrice) / (product.originalPrice || 170000) * 100)}% OFF
|
||||
</span>
|
||||
|
||||
<div className="absolute bottom-3 left-3 bg-slate-950/90 backdrop-blur-md px-3 py-1 rounded-full border border-white/20 text-xs font-bold text-white flex items-center gap-1.5 shadow-lg">
|
||||
<Palette className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>{selectedColor.colorName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-400">
|
||||
<span className="font-bold uppercase tracking-wider">Fotos del Colorway:</span>
|
||||
<span>{selectedColor.images?.length || 1} vistas</span>
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{(selectedColor.images && selectedColor.images.length > 0 ? selectedColor.images : product.images).map((img, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`w-16 h-16 rounded-xl overflow-hidden border-2 transition-all cursor-pointer shrink-0 ${
|
||||
selectedImage === img ? 'border-emerald-500 scale-105 shadow-md' : 'border-slate-800 opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<img src={img} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnails */}
|
||||
{product.images.length > 1 && (
|
||||
<div className="flex gap-2">
|
||||
{product.images.map((img, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`w-16 h-16 rounded-xl overflow-hidden border-2 transition-all cursor-pointer ${
|
||||
selectedImage === img ? 'border-white scale-105' : 'border-slate-800 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<img src={img} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trust note */}
|
||||
<div className="mt-3 bg-white/5 border border-white/10 rounded-xl p-2.5 text-xs text-slate-300 flex items-center gap-2">
|
||||
<ShieldCheck className={`w-4 h-4 shrink-0 ${selectedCity.firstExchangeFree ? 'text-emerald-400' : 'text-teal-400'}`} />
|
||||
<span>
|
||||
<div className="mt-4 bg-white/5 border border-white/10 rounded-2xl p-3 text-xs text-slate-300 flex items-center gap-2.5">
|
||||
<ShieldCheck className={`w-5 h-5 shrink-0 ${selectedCity.firstExchangeFree ? 'text-emerald-400' : 'text-teal-400'}`} />
|
||||
<div className="leading-tight">
|
||||
{selectedCity.firstExchangeFree ? (
|
||||
<><strong>1er Cambio de Talla GRATIS en {selectedCity.name}</strong> • Cambios posteriores: el cliente asume el flete</>
|
||||
<><strong>1er Cambio de Talla GRATIS en {selectedCity.name}</strong> • Cambios posteriores con flete estándar.</>
|
||||
) : (
|
||||
<><strong>Garantía de Cambio en {selectedCity.name}</strong> • El cliente asume los costos de flete</>
|
||||
<><strong>Garantía de Cambio en {selectedCity.name}</strong> • Envío asegurado contraentrega.</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Product Details & Actions */}
|
||||
<div className="md:w-1/2 p-5 sm:p-6 flex flex-col justify-between overflow-y-auto max-h-[60vh] md:max-h-[85vh]">
|
||||
{/* Right: Product Details */}
|
||||
<div className="md:w-1/2 p-5 sm:p-6 flex flex-col justify-between overflow-y-auto max-h-[60vh] md:max-h-[88vh] space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className={`text-xs font-bold uppercase tracking-wider text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>{product.brand}</span>
|
||||
<h2 className="text-xl sm:text-2xl font-black text-white leading-snug">{product.name}</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-xs font-black uppercase tracking-wider text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>
|
||||
{product.brand} • {product.category || 'Sneakers'}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 bg-slate-800 px-2 py-0.5 rounded-full font-mono">
|
||||
Ref: {product.id.slice(0, 10)}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-black text-white leading-snug mt-0.5">
|
||||
{product.modelName || product.name}
|
||||
</h2>
|
||||
<div className="flex items-center space-x-2 text-xs text-slate-400 mt-1">
|
||||
<div className="flex items-center text-amber-400 font-bold">
|
||||
<Star className="w-4 h-4 fill-amber-400 mr-1" />
|
||||
<span>{product.rating || 4.9}</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>+{product.salesCount || 200} pares entregados</span>
|
||||
<span>+{product.salesCount || 200} pares despachados</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Box */}
|
||||
<div className="bg-slate-950/70 border border-white/10 rounded-2xl p-3.5 space-y-2">
|
||||
<div className="bg-slate-950/80 border border-slate-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)}
|
||||
@ -133,38 +219,121 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Separation breakdown banner */}
|
||||
<div className="bg-white/5 rounded-xl p-2.5 border border-white/10 text-xs space-y-1">
|
||||
<p className="font-bold text-white flex items-center gap-1">
|
||||
<Zap className="w-3.5 h-3.5 fill-white" />
|
||||
¡Pago Contraentrega en {selectedCity.name}!
|
||||
</p>
|
||||
<p className="text-slate-300">
|
||||
Separas con solo <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y pagas los <strong className="text-white">{formatCOP(separation.balanceDue)}</strong> al recibir en tu puerta.
|
||||
<p className="text-slate-300 text-[11px] leading-relaxed">
|
||||
Separas con solo <strong className="text-white">{formatCOP(separation.depositAmount)}</strong> y pagas los <strong className="text-white">{formatCOP(separation.balanceDue)}</strong> al mensajero cuando llegue a tu casa.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size Selector */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold text-slate-200 uppercase">Selecciona tu Talla (Nacional CO):</span>
|
||||
<span className="text-[11px] text-slate-400 underline">Guía de Tallas</span>
|
||||
{/* Colorways Selector */}
|
||||
{colorsList.length > 0 && (
|
||||
<div className="space-y-2 pt-1 border-t border-slate-800/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-black text-white uppercase flex items-center gap-1.5">
|
||||
<Palette className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Color Seleccionado:</span>
|
||||
</span>
|
||||
<span className="text-xs font-bold text-emerald-300">
|
||||
{selectedColor.colorName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{colorsList.map((variant) => {
|
||||
const isCurrent = selectedColor.id === variant.id;
|
||||
return (
|
||||
<button
|
||||
key={variant.id}
|
||||
onClick={() => handleColorChange(variant)}
|
||||
className={`flex items-center gap-2 p-2 rounded-xl border text-left transition-all cursor-pointer ${
|
||||
isCurrent
|
||||
? 'bg-emerald-950/40 border-emerald-500 shadow-md ring-1 ring-emerald-500 text-white'
|
||||
: 'bg-slate-950 hover:bg-slate-800/70 border-slate-800 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg overflow-hidden shrink-0 border border-slate-700 bg-slate-900">
|
||||
<img src={variant.image} alt={variant.colorName} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<span className="text-[11px] font-bold leading-tight line-clamp-2">
|
||||
{variant.colorName}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
|
||||
{product.sizes.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setSelectedSize(size)}
|
||||
className={`py-2 rounded-xl text-sm font-black transition-all cursor-pointer ${
|
||||
selectedSize === size
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-md scale-105 border border-white/30`
|
||||
: 'bg-slate-950 hover:bg-slate-800 text-slate-300 border border-slate-800'
|
||||
}`}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Size Selector */}
|
||||
<div className="space-y-2 pt-1 border-t border-slate-800/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-black text-white uppercase">
|
||||
Selecciona tu Talla (CO / Nacional):
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400 font-medium">
|
||||
{isSizeInStock ? (
|
||||
<span className="text-emerald-400 font-bold flex items-center gap-1">
|
||||
<Check className="w-3 h-3" /> Stock Inmediato
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-400 font-bold flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" /> Solicitud Bajo Pedido
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 sm:grid-cols-5 gap-2">
|
||||
{displaySizes.map((size) => {
|
||||
const inStock = availableSizes.includes(size);
|
||||
const isSelected = selectedSize === size;
|
||||
|
||||
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 ${
|
||||
isSelected
|
||||
? inStock
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-lg scale-105 border border-white/30`
|
||||
: 'bg-amber-600 text-white shadow-lg scale-105 border border-amber-300'
|
||||
: inStock
|
||||
? 'bg-slate-950 hover:bg-slate-800 text-white border border-slate-800'
|
||||
: 'bg-slate-950/60 hover:bg-slate-900 text-slate-500 border border-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<span>{size}</span>
|
||||
<span className="text-[8px] font-mono leading-none mt-0.5">
|
||||
{inStock ? 'Listo' : 'Pedir'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Banner: ¿No encuentras tu talla? */}
|
||||
<div className="bg-gradient-to-r from-emerald-950/40 to-slate-950 border border-emerald-500/30 rounded-2xl p-3 flex items-center justify-between gap-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-xl bg-emerald-500/20 text-emerald-400 flex items-center justify-center shrink-0">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-white">¿No encuentras tu talla exacta?</p>
|
||||
<p className="text-[10px] text-slate-400">Verificamos disponibilidad con bodega en 5 minutos.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleWhatsAppRequestSize(selectedSize)}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white font-black text-[10px] px-2.5 py-1.5 rounded-xl shrink-0 cursor-pointer shadow flex items-center gap-1"
|
||||
>
|
||||
<span>Pedir Talla</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -185,35 +354,56 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="pt-4 border-t border-slate-800 flex flex-col gap-2.5 mt-4">
|
||||
<button
|
||||
onClick={handleWhatsAppDirect}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-sm py-3 px-4 rounded-xl flex items-center justify-center space-x-2 shadow-lg shadow-emerald-600/20 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 fill-white" />
|
||||
<span>Pedir por WhatsApp (Contraentrega Talla {selectedSize})</span>
|
||||
</button>
|
||||
{isSizeInStock ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleWhatsAppDirect}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-sm py-3.5 px-4 rounded-2xl flex items-center justify-center space-x-2 shadow-lg shadow-emerald-600/20 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 fill-white" />
|
||||
<span>Pedir por WhatsApp (Contraentrega Talla {selectedSize} • {selectedColor.colorName})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={added}
|
||||
className={`w-full font-bold text-sm py-3 px-4 rounded-xl flex items-center justify-center space-x-2 transition-all cursor-pointer ${
|
||||
added
|
||||
? 'bg-slate-800 text-white border border-white/20'
|
||||
: `bg-gradient-to-r ${currentBrand.colors.primaryGradient} hover:opacity-95 text-white shadow-lg active:scale-95`
|
||||
}`}
|
||||
>
|
||||
{added ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
<span>¡Añadido al Carrito!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
<span>Añadir al Carrito (Talla {selectedSize})</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={added}
|
||||
className={`w-full font-bold text-sm py-3.5 px-4 rounded-2xl flex items-center justify-center space-x-2 transition-all cursor-pointer ${
|
||||
added
|
||||
? 'bg-slate-800 text-white border border-white/20'
|
||||
: `bg-gradient-to-r ${currentBrand.colors.primaryGradient} hover:opacity-95 text-white shadow-lg active:scale-95`
|
||||
}`}
|
||||
>
|
||||
{added ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
<span>¡Añadido al Carrito!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
<span>Añadir al Carrito (Talla {selectedSize})</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-2xl p-3 text-xs text-amber-200 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-amber-400" />
|
||||
<span>
|
||||
La talla <strong>{selectedSize}</strong> en <strong>{selectedColor.colorName}</strong> no tiene despacho inmediato en 24h, pero podemos pedirla a la bodega central para ti.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleWhatsAppRequestSize(selectedSize)}
|
||||
className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 hover:opacity-95 text-white font-black text-sm py-3.5 px-4 rounded-2xl flex items-center justify-center space-x-2 shadow-xl shadow-emerald-600/30 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 fill-white" />
|
||||
<span>Solicitar Talla {selectedSize} Bajo Pedido por WhatsApp</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
100
zapply-app/src/lib/product-grouper.ts
Normal file
100
zapply-app/src/lib/product-grouper.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { Product, ProductColorVariant } from '@/types';
|
||||
|
||||
export function extractModelAndColor(productName: string, brand: string): { modelName: string; colorName: string } {
|
||||
let clean = productName.trim();
|
||||
|
||||
const colorPatterns = [
|
||||
{ pattern: /(triple white|all white|blanco total|blancas)/i, name: 'Triple White' },
|
||||
{ pattern: /(triple black|all black|negro total|negras)/i, name: 'Triple Black' },
|
||||
{ pattern: /(panda|black & white|black white|blanco y negro)/i, name: 'Panda (Black & White)' },
|
||||
{ pattern: /(military black|negro militar)/i, name: 'Military Black' },
|
||||
{ pattern: /(military blue|azul militar)/i, name: 'Military Blue' },
|
||||
{ pattern: /(white cement|cemento blanco)/i, name: 'White Cement' },
|
||||
{ pattern: /(red cement|cemento rojo)/i, name: 'Red Cement' },
|
||||
{ pattern: /(chicago|lost & found|rojo chicago)/i, name: 'Chicago Red' },
|
||||
{ pattern: /(pine green|verde pino|verde)/i, name: 'Pine Green' },
|
||||
{ pattern: /(photon dust|metallic silver|gris plata)/i, name: 'Photon Dust / Silver' },
|
||||
{ pattern: /(travis scott|olive|baroque brown)/i, name: 'Travis Scott Edition' },
|
||||
{ pattern: /(core black|black white)/i, name: 'Core Black' },
|
||||
{ pattern: /(chalk white|off white|crema|beige)/i, name: 'Chalk White / Cream' },
|
||||
{ pattern: /(blue fusion|royal blue|azul)/i, name: 'Royal Blue' },
|
||||
{ pattern: /(thunder yellow|amarillo thunder)/i, name: 'Thunder Yellow' },
|
||||
{ pattern: /(dama & caballero|dama|caballero)/i, name: 'Edición Unisex' },
|
||||
];
|
||||
|
||||
let detectedColor = 'Color Original';
|
||||
for (const cp of colorPatterns) {
|
||||
if (cp.pattern.test(clean)) {
|
||||
detectedColor = cp.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
modelName: clean,
|
||||
colorName: detectedColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function groupProductsByModel(products: Product[]): Product[] {
|
||||
const modelMap = new Map<string, Product>();
|
||||
|
||||
products.forEach((prod) => {
|
||||
if (prod.colors && prod.colors.length > 1) {
|
||||
modelMap.set(prod.id, prod);
|
||||
return;
|
||||
}
|
||||
|
||||
const { modelName, colorName } = extractModelAndColor(prod.name, prod.brand);
|
||||
const groupKey = prod.modelName || `${prod.brand.toLowerCase()}_${modelName.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
|
||||
if (!modelMap.has(groupKey)) {
|
||||
const primaryVariant: ProductColorVariant = {
|
||||
id: `col-${prod.id}-1`,
|
||||
colorName: prod.colorName || colorName,
|
||||
image: prod.images[0] || '',
|
||||
images: prod.images,
|
||||
sizes: prod.sizes,
|
||||
inStock: prod.inStock,
|
||||
};
|
||||
|
||||
modelMap.set(groupKey, {
|
||||
...prod,
|
||||
modelName: prod.modelName || modelName,
|
||||
colorName: prod.colorName || colorName,
|
||||
colors: [primaryVariant],
|
||||
});
|
||||
} else {
|
||||
const existing = modelMap.get(groupKey)!;
|
||||
const currentColors = existing.colors || [];
|
||||
|
||||
const alreadyHasImage = currentColors.some(
|
||||
(c) => c.image === prod.images[0] || c.colorName.toLowerCase() === (prod.colorName || colorName).toLowerCase()
|
||||
);
|
||||
|
||||
if (!alreadyHasImage) {
|
||||
currentColors.push({
|
||||
id: `col-${prod.id}-${currentColors.length + 1}`,
|
||||
colorName: prod.colorName || colorName,
|
||||
image: prod.images[0] || '',
|
||||
images: prod.images,
|
||||
sizes: prod.sizes,
|
||||
inStock: prod.inStock,
|
||||
});
|
||||
|
||||
const allSizes = Array.from(new Set([...existing.sizes, ...prod.sizes])).sort(
|
||||
(a, b) => parseInt(a, 10) - parseInt(b, 10)
|
||||
);
|
||||
|
||||
modelMap.set(groupKey, {
|
||||
...existing,
|
||||
sizes: allSizes,
|
||||
images: Array.from(new Set([...existing.images, ...prod.images])),
|
||||
colors: currentColors,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(modelMap.values());
|
||||
}
|
||||
@ -26,15 +26,29 @@ export interface User {
|
||||
lastLogin?: string;
|
||||
}
|
||||
|
||||
export interface ProductColorVariant {
|
||||
id: string;
|
||||
colorName: string;
|
||||
colorHex?: string;
|
||||
image: string;
|
||||
images?: string[];
|
||||
sizes: string[];
|
||||
inStock?: boolean;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
modelName?: string;
|
||||
colorName?: string;
|
||||
brand: string;
|
||||
supplierPrice: number;
|
||||
retailPrice: number;
|
||||
originalPrice?: number;
|
||||
sizes: string[];
|
||||
allSizes?: string[];
|
||||
images: string[];
|
||||
colors?: ProductColorVariant[];
|
||||
description: string;
|
||||
category: string;
|
||||
inStock: boolean;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user