feat(perf): optimizacion global de imagenes WebP con shimmer y paginacion dinamica en tienda y admin
This commit is contained in:
parent
1fdfe88e51
commit
902a651e8b
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Radio,
|
||||
@ -29,10 +29,15 @@ import {
|
||||
Store,
|
||||
DollarSign,
|
||||
HelpCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from 'lucide-react';
|
||||
import { SupplierPost, Product, MonitoredWhatsAppGroup, ExtractedProductData } from '@/types';
|
||||
import { formatCOP, calculateMarketPricingStrategies } from '@/lib/pricing-engine';
|
||||
import { BRANDS, BrandId } from '@/config/brands';
|
||||
import { OptimizedImage } from '@/components/ui/OptimizedImage';
|
||||
|
||||
export default function SupplierFeedPage() {
|
||||
const [posts, setPosts] = useState<SupplierPost[]>([]);
|
||||
@ -44,6 +49,10 @@ export default function SupplierFeedPage() {
|
||||
const [isSyncingLive, setIsSyncingLive] = useState(false);
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [pageSize, setPageSize] = useState<number>(12);
|
||||
|
||||
// Multi-selection state
|
||||
const [selectedPostIds, setSelectedPostIds] = useState<Set<string>>(new Set());
|
||||
|
||||
@ -307,18 +316,31 @@ export default function SupplierFeedPage() {
|
||||
};
|
||||
|
||||
// Filtrado de posts por estado y grupo
|
||||
const filteredPosts = posts.filter((p) => {
|
||||
// Estado
|
||||
if (tabStatus === 'pending' && (p.status === 'published' || p.status === 'discarded')) return false;
|
||||
if (tabStatus === 'published' && p.status !== 'published') return false;
|
||||
if (tabStatus === 'discarded' && p.status !== 'discarded') return false;
|
||||
const filteredPosts = useMemo(() => {
|
||||
return posts.filter((p) => {
|
||||
// Estado
|
||||
if (tabStatus === 'pending' && (p.status === 'published' || p.status === 'discarded')) return false;
|
||||
if (tabStatus === 'published' && p.status !== 'published') return false;
|
||||
if (tabStatus === 'discarded' && p.status !== 'discarded') return false;
|
||||
|
||||
// Grupo
|
||||
if (selectedGroupFilter !== 'all') {
|
||||
return (p as any).channelJid === selectedGroupFilter || p.channelName === selectedGroupFilter;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Grupo
|
||||
if (selectedGroupFilter !== 'all') {
|
||||
return (p as any).channelJid === selectedGroupFilter || p.channelName === selectedGroupFilter;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [posts, tabStatus, selectedGroupFilter]);
|
||||
|
||||
// Reset pagination on filter change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [tabStatus, selectedGroupFilter, pageSize]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredPosts.length / pageSize));
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const paginatedPosts = useMemo(() => {
|
||||
return filteredPosts.slice(startIndex, startIndex + pageSize);
|
||||
}, [filteredPosts, startIndex, pageSize]);
|
||||
|
||||
const totalAllCount = posts.length;
|
||||
const pendingCount = posts.filter((p) => p.status !== 'published' && p.status !== 'discarded').length;
|
||||
@ -525,42 +547,48 @@ export default function SupplierFeedPage() {
|
||||
<p>Haz clic en "Sincronizar WhatsApp en Vivo" para escanear los mensajes recientes de tus grupos.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{filteredPosts.map((post) => {
|
||||
const isSelected = selectedPostIds.has(post.id);
|
||||
const isPublished = post.status === 'published';
|
||||
const isDiscarded = post.status === 'discarded';
|
||||
const img = post.rawImages && post.rawImages[0];
|
||||
const isResearching = researchingPostId === post.id;
|
||||
const ext = post.extractedProduct;
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{paginatedPosts.map((post) => {
|
||||
const isSelected = selectedPostIds.has(post.id);
|
||||
const isPublished = post.status === 'published';
|
||||
const isDiscarded = post.status === 'discarded';
|
||||
const img = post.rawImages && post.rawImages[0];
|
||||
const isResearching = researchingPostId === post.id;
|
||||
const ext = post.extractedProduct;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`group bg-neutral-900/90 border rounded-3xl overflow-hidden flex flex-col justify-between transition-all hover:border-purple-500/50 hover:shadow-xl hover:shadow-purple-950/20 backdrop-blur-sm ${
|
||||
isSelected
|
||||
? 'border-purple-500 ring-2 ring-purple-500/30'
|
||||
: isPublished
|
||||
? 'border-emerald-500/30 opacity-90'
|
||||
: isDiscarded
|
||||
? 'border-neutral-800 opacity-60'
|
||||
: 'border-neutral-800'
|
||||
}`}
|
||||
>
|
||||
{/* Top Image & Group Bar */}
|
||||
<div>
|
||||
<div className="relative aspect-4/3 bg-neutral-950 overflow-hidden">
|
||||
{img ? (
|
||||
<img
|
||||
src={img}
|
||||
alt={ext?.name || 'Zapatilla'}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-neutral-600">
|
||||
Sin Imagen
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`group bg-neutral-900/90 border rounded-3xl overflow-hidden flex flex-col justify-between transition-all hover:border-purple-500/50 hover:shadow-xl hover:shadow-purple-950/20 backdrop-blur-sm ${
|
||||
isSelected
|
||||
? 'border-purple-500 ring-2 ring-purple-500/30'
|
||||
: isPublished
|
||||
? 'border-emerald-500/30 opacity-90'
|
||||
: isDiscarded
|
||||
? 'border-neutral-800 opacity-60'
|
||||
: 'border-neutral-800'
|
||||
}`}
|
||||
>
|
||||
{/* Top Image & Group Bar */}
|
||||
<div>
|
||||
<div className="relative aspect-4/3 bg-neutral-950 overflow-hidden">
|
||||
{img ? (
|
||||
<OptimizedImage
|
||||
src={img}
|
||||
alt={ext?.name || 'Zapatilla'}
|
||||
width={450}
|
||||
height={340}
|
||||
quality={75}
|
||||
aspectRatio="4/3"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
containerClassName="w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-neutral-600">
|
||||
Sin Imagen
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group Badge & Checkbox */}
|
||||
<div className="absolute top-3 left-3 right-3 flex items-center justify-between pointer-events-none">
|
||||
@ -850,6 +878,77 @@ export default function SupplierFeedPage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Feed Pagination Bar */}
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-neutral-950 px-5 py-3.5 rounded-2xl border border-neutral-800 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs">
|
||||
<span className="text-neutral-400">
|
||||
Mostrando <strong className="text-white">{startIndex + 1}</strong> a{' '}
|
||||
<strong className="text-white">{Math.min(startIndex + pageSize, filteredPosts.length)}</strong> de{' '}
|
||||
<strong className="text-white">{filteredPosts.length}</strong> mensajes de WhatsApp (Pág.{' '}
|
||||
<strong className="text-purple-400">{currentPage}</strong> de {totalPages})
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
title="Primera página"
|
||||
className={`p-1.5 rounded-lg border text-xs font-bold transition-all ${
|
||||
currentPage === 1
|
||||
? 'border-neutral-800 text-neutral-600 cursor-not-allowed'
|
||||
: 'border-neutral-700 bg-neutral-900 text-neutral-300 hover:text-white cursor-pointer hover:border-purple-500'
|
||||
}`}
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
title="Página anterior"
|
||||
className={`px-3 py-1.5 rounded-lg border text-xs font-bold flex items-center gap-1 transition-all ${
|
||||
currentPage === 1
|
||||
? 'border-neutral-800 text-neutral-600 cursor-not-allowed'
|
||||
: 'border-neutral-700 bg-neutral-900 text-neutral-300 hover:text-white cursor-pointer hover:border-purple-500'
|
||||
}`}
|
||||
>
|
||||
<ChevronLeft className="w-3.5 h-3.5" />
|
||||
<span>Anterior</span>
|
||||
</button>
|
||||
|
||||
<span className="px-2.5 font-bold text-white bg-purple-950/60 border border-purple-500/40 rounded-lg py-1">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
title="Página siguiente"
|
||||
className={`px-3 py-1.5 rounded-lg border text-xs font-bold flex items-center gap-1 transition-all ${
|
||||
currentPage === totalPages
|
||||
? 'border-neutral-800 text-neutral-600 cursor-not-allowed'
|
||||
: 'border-neutral-700 bg-neutral-900 text-neutral-300 hover:text-white cursor-pointer hover:border-purple-500'
|
||||
}`}
|
||||
>
|
||||
<span>Siguiente</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
title="Última página"
|
||||
className={`p-1.5 rounded-lg border text-xs font-bold transition-all ${
|
||||
currentPage === totalPages
|
||||
? 'border-neutral-800 text-neutral-600 cursor-not-allowed'
|
||||
: 'border-neutral-700 bg-neutral-900 text-neutral-300 hover:text-white cursor-pointer hover:border-purple-500'
|
||||
}`}
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -874,10 +973,14 @@ export default function SupplierFeedPage() {
|
||||
{/* Product preview & Internet Intelligence badge */}
|
||||
<div className="flex gap-4 items-start bg-neutral-950 p-3.5 rounded-2xl border border-neutral-800">
|
||||
{editingPost.rawImages?.[0] && (
|
||||
<img
|
||||
<OptimizedImage
|
||||
src={editingPost.rawImages[0]}
|
||||
alt="Preview"
|
||||
width={160}
|
||||
height={160}
|
||||
quality={75}
|
||||
className="w-20 h-20 rounded-xl object-cover border border-neutral-800 shrink-0"
|
||||
containerClassName="w-20 h-20 rounded-xl shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1 text-xs">
|
||||
|
||||
@ -32,6 +32,7 @@ import {
|
||||
import { Order, OrderItem, Supplier, City } from '@/types';
|
||||
import { formatCOP } from '@/lib/pricing-engine';
|
||||
import { initClientTemplates, renderClientTemplate } from '@/lib/templates-client';
|
||||
import { OptimizedImage } from '@/components/ui/OptimizedImage';
|
||||
|
||||
export default function AdminOrdersPage() {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
@ -608,7 +609,15 @@ export default function AdminOrdersPage() {
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-neutral-950 p-3 rounded-2xl border border-neutral-800"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<img src={item.image} alt="" className="w-12 h-12 rounded-xl object-cover border border-neutral-800 shrink-0" />
|
||||
<OptimizedImage
|
||||
src={item.image}
|
||||
alt=""
|
||||
width={100}
|
||||
height={100}
|
||||
quality={75}
|
||||
className="w-12 h-12 rounded-xl object-cover border border-neutral-800 shrink-0"
|
||||
containerClassName="w-12 h-12 rounded-xl shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-bold text-white text-xs">{item.productName}</p>
|
||||
<p className="text-[11px] text-neutral-400">
|
||||
@ -702,7 +711,16 @@ export default function AdminOrdersPage() {
|
||||
{group.entries.map((entry, idx) => (
|
||||
<tr key={idx} className="hover:bg-neutral-800/40">
|
||||
<td className="py-2.5 px-3 font-bold text-white flex items-center gap-2">
|
||||
<img src={entry.item.image} alt="" className="w-8 h-8 rounded-lg object-cover border border-neutral-800" />
|
||||
<OptimizedImage
|
||||
src={entry.item.image}
|
||||
alt=""
|
||||
width={64}
|
||||
height={64}
|
||||
quality={70}
|
||||
className="w-8 h-8 rounded-lg object-cover border border-neutral-800 shrink-0"
|
||||
containerClassName="w-8 h-8 rounded-lg shrink-0"
|
||||
showSkeleton={false}
|
||||
/>
|
||||
<span>{entry.item.productName}</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 font-bold text-purple-400 font-mono">{entry.item.size}</td>
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck, Trash2, Edit, Wand2, ArrowRight, Layers, Palette, CheckSquare, Square, Split } from 'lucide-react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck, Trash2, Edit, Wand2, ArrowRight, Layers, Palette, CheckSquare, Square, Split, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||
import { Product, Supplier } from '@/types';
|
||||
import { INITIAL_PRODUCTS, INITIAL_SUPPLIERS } from '@/lib/data-service';
|
||||
import { formatCOP } from '@/lib/pricing-engine';
|
||||
import { GroupProductsModal } from '@/components/admin/GroupProductsModal';
|
||||
import { ManageVariantsModal } from '@/components/admin/ManageVariantsModal';
|
||||
import { renderClientTemplate, initClientTemplates } from '@/lib/templates-client';
|
||||
import { OptimizedImage } from '@/components/ui/OptimizedImage';
|
||||
|
||||
|
||||
|
||||
@ -25,6 +26,10 @@ export default function AdminProductsPage() {
|
||||
const [managingVariantsProduct, setManagingVariantsProduct] = useState<Product | null>(null);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
// Pagination State
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [pageSize, setPageSize] = useState<number>(15);
|
||||
|
||||
const refreshProductsList = () => {
|
||||
fetch('/api/products?all=true')
|
||||
.then((r) => r.json())
|
||||
@ -314,18 +319,31 @@ export default function AdminProductsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = products.filter((p) => {
|
||||
const matchesSearch =
|
||||
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.brand.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(p.supplierName && p.supplierName.toLowerCase().includes(search.toLowerCase()));
|
||||
const filtered = useMemo(() => {
|
||||
return products.filter((p) => {
|
||||
const matchesSearch =
|
||||
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.brand.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(p.supplierName && p.supplierName.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
if (!matchesSearch) return false;
|
||||
if (statusFilter === 'active' && p.isActive === false) return false;
|
||||
if (statusFilter === 'inactive' && p.isActive !== false) return false;
|
||||
if (supplierFilter !== 'all' && p.supplierName !== supplierFilter) return false;
|
||||
return true;
|
||||
});
|
||||
if (!matchesSearch) return false;
|
||||
if (statusFilter === 'active' && p.isActive === false) return false;
|
||||
if (statusFilter === 'inactive' && p.isActive !== false) return false;
|
||||
if (supplierFilter !== 'all' && p.supplierName !== supplierFilter) return false;
|
||||
return true;
|
||||
});
|
||||
}, [products, search, statusFilter, supplierFilter]);
|
||||
|
||||
// Reset pagination on search or filters
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [search, statusFilter, supplierFilter, pageSize]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const paginatedProducts = useMemo(() => {
|
||||
return filtered.slice(startIndex, startIndex + pageSize);
|
||||
}, [filtered, startIndex, pageSize]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@ -483,14 +501,14 @@ export default function AdminProductsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-800/60">
|
||||
{filtered.length === 0 ? (
|
||||
{paginatedProducts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="text-center py-8 text-neutral-500">
|
||||
No hay zapatillas que coincidan con el filtro seleccionado.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((prod) => {
|
||||
paginatedProducts.map((prod) => {
|
||||
const isActive = prod.isActive !== false;
|
||||
const margin = prod.retailPrice - prod.supplierPrice;
|
||||
const suppName = prod.supplierName || 'DIFUSION JOVIC STORE';
|
||||
@ -548,11 +566,16 @@ export default function AdminProductsPage() {
|
||||
|
||||
{/* Image & Title */}
|
||||
<td className="py-3.5 px-4 flex items-center space-x-3">
|
||||
<div className="relative">
|
||||
<img
|
||||
<div className="relative w-12 h-12 shrink-0">
|
||||
<OptimizedImage
|
||||
src={prod.images[0]}
|
||||
alt=""
|
||||
className="w-12 h-12 rounded-xl object-cover bg-neutral-950 border border-neutral-800 shrink-0"
|
||||
alt={prod.name}
|
||||
width={100}
|
||||
height={100}
|
||||
quality={75}
|
||||
className="w-12 h-12 rounded-xl object-cover bg-neutral-950 border border-neutral-800"
|
||||
containerClassName="w-12 h-12 rounded-xl"
|
||||
showSkeleton={false}
|
||||
/>
|
||||
{colorwaysCount > 1 && (
|
||||
<span className="absolute -top-1 -right-1 bg-purple-600 text-white text-[9px] font-black px-1.5 py-0.2 rounded-full border border-neutral-900 shadow">
|
||||
@ -660,6 +683,76 @@ export default function AdminProductsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Table Pagination Bar */}
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-neutral-950 px-4 py-3 border-t border-neutral-800 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs">
|
||||
<span className="text-neutral-400">
|
||||
Mostrando <strong className="text-white">{startIndex + 1}</strong> a{' '}
|
||||
<strong className="text-white">{Math.min(startIndex + pageSize, filtered.length)}</strong> de{' '}
|
||||
<strong className="text-white">{filtered.length}</strong> publicaciones (Pág.{' '}
|
||||
<strong className="text-purple-400">{currentPage}</strong> de {totalPages})
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
title="Primera página"
|
||||
className={`p-1.5 rounded-lg border text-xs font-bold transition-all ${
|
||||
currentPage === 1
|
||||
? 'border-neutral-800 text-neutral-600 cursor-not-allowed'
|
||||
: 'border-neutral-700 bg-neutral-900 text-neutral-300 hover:text-white cursor-pointer hover:border-purple-500'
|
||||
}`}
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
title="Página anterior"
|
||||
className={`px-3 py-1.5 rounded-lg border text-xs font-bold flex items-center gap-1 transition-all ${
|
||||
currentPage === 1
|
||||
? 'border-neutral-800 text-neutral-600 cursor-not-allowed'
|
||||
: 'border-neutral-700 bg-neutral-900 text-neutral-300 hover:text-white cursor-pointer hover:border-purple-500'
|
||||
}`}
|
||||
>
|
||||
<ChevronLeft className="w-3.5 h-3.5" />
|
||||
<span>Anterior</span>
|
||||
</button>
|
||||
|
||||
<span className="px-2 font-bold text-white bg-purple-950/60 border border-purple-500/40 rounded-lg py-1">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
title="Página siguiente"
|
||||
className={`px-3 py-1.5 rounded-lg border text-xs font-bold flex items-center gap-1 transition-all ${
|
||||
currentPage === totalPages
|
||||
? 'border-neutral-800 text-neutral-600 cursor-not-allowed'
|
||||
: 'border-neutral-700 bg-neutral-900 text-neutral-300 hover:text-white cursor-pointer hover:border-purple-500'
|
||||
}`}
|
||||
>
|
||||
<span>Siguiente</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
title="Última página"
|
||||
className={`p-1.5 rounded-lg border text-xs font-bold transition-all ${
|
||||
currentPage === totalPages
|
||||
? 'border-neutral-800 text-neutral-600 cursor-not-allowed'
|
||||
: 'border-neutral-700 bg-neutral-900 text-neutral-300 hover:text-white cursor-pointer hover:border-purple-500'
|
||||
}`}
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal de Agrupación Manual de Productos */}
|
||||
|
||||
32
dripzone-app/src/app/api/download-apk/route.ts
Normal file
32
dripzone-app/src/app/api/download-apk/route.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const possiblePaths = [
|
||||
path.join(process.cwd(), 'public', 'DripZone.apk'),
|
||||
'/app/public/DripZone.apk',
|
||||
'/home/carlos/apps/dripzone-app/public/DripZone.apk',
|
||||
path.join(process.cwd(), 'DripZone.apk'),
|
||||
];
|
||||
|
||||
const apkPath = possiblePaths.find((p) => fs.existsSync(p));
|
||||
if (!apkPath) {
|
||||
return new NextResponse('APK no disponible en este momento', { status: 404 });
|
||||
}
|
||||
|
||||
const stat = fs.statSync(apkPath);
|
||||
const fileBuffer = fs.readFileSync(apkPath);
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.android.package-archive',
|
||||
'Content-Length': stat.size.toString(),
|
||||
'Content-Disposition': 'attachment; filename="DripZone.apk"',
|
||||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -10,7 +10,7 @@ import { useBrand } from '@/context/BrandContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { renderClientTemplate } from '@/lib/templates-client';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
import { OptimizedImage } from '@/components/ui/OptimizedImage';
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const { currentBrand } = useBrand();
|
||||
@ -439,7 +439,15 @@ export default function CheckoutPage() {
|
||||
<div className="space-y-3 max-h-60 overflow-y-auto pr-1">
|
||||
{checkoutItems.map((item, i) => (
|
||||
<div key={i} className="flex gap-3 bg-neutral-950 p-2.5 rounded-2xl border border-neutral-800">
|
||||
<img src={item.image} alt="" className="w-14 h-14 rounded-xl object-cover bg-neutral-900 border border-neutral-700" />
|
||||
<OptimizedImage
|
||||
src={item.image}
|
||||
alt={item.productName}
|
||||
width={120}
|
||||
height={120}
|
||||
quality={75}
|
||||
className="w-14 h-14 rounded-xl object-cover bg-neutral-900 border border-neutral-700 shrink-0"
|
||||
containerClassName="w-14 h-14 rounded-xl shrink-0"
|
||||
/>
|
||||
<div className="flex-1 text-xs">
|
||||
<h4 className="font-bold text-white line-clamp-1">{item.productName}</h4>
|
||||
<p className="text-neutral-400">Talla: <strong className="text-white">{item.size}</strong> | Cant: {item.quantity}</p>
|
||||
|
||||
@ -24,3 +24,19 @@ body {
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.6s infinite;
|
||||
}
|
||||
|
||||
|
||||
@ -5,19 +5,19 @@ import { Navbar } from '@/components/store/Navbar';
|
||||
import { FlashDealsBanner } from '@/components/store/FlashDealsBanner';
|
||||
import { CategoryPills } from '@/components/store/CategoryPills';
|
||||
import { ProductCard } from '@/components/store/ProductCard';
|
||||
import { ProductCardSkeleton } from '@/components/store/ProductCardSkeleton';
|
||||
import { ProductModal } from '@/components/store/ProductModal';
|
||||
import { CartDrawer } from '@/components/store/CartDrawer';
|
||||
import { CitySelectorModal } from '@/components/store/CitySelectorModal';
|
||||
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 { INITIAL_CITIES } from '@/lib/data-service';
|
||||
import { groupProductsByModel } from '@/lib/product-grouper';
|
||||
import { Sparkles, ShieldCheck, Truck, RefreshCw, Zap } from 'lucide-react';
|
||||
import { Sparkles, ShieldCheck, Truck, RefreshCw, Zap, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
import { renderClientTemplate } from '@/lib/templates-client';
|
||||
|
||||
|
||||
export default function StoreHomePage() {
|
||||
const { currentBrand } = useBrand();
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
@ -25,6 +25,11 @@ export default function StoreHomePage() {
|
||||
const [selectedCity, setSelectedCity] = useState<City>(INITIAL_CITIES[0]); // Cali
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [pageSize, setPageSize] = useState<number>(12);
|
||||
|
||||
// Modals & Drawers state
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
@ -35,6 +40,7 @@ export default function StoreHomePage() {
|
||||
const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
fetch('/api/products')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
@ -42,7 +48,8 @@ export default function StoreHomePage() {
|
||||
setProducts(data.products);
|
||||
}
|
||||
})
|
||||
.catch((e) => console.log('Error cargando productos:', e));
|
||||
.catch((e) => console.log('Error cargando productos:', e))
|
||||
.finally(() => setIsLoading(false));
|
||||
|
||||
fetch('/api/cities')
|
||||
.then((res) => res.json())
|
||||
@ -58,23 +65,50 @@ export default function StoreHomePage() {
|
||||
|
||||
const groupedProducts = useMemo(() => groupProductsByModel(products), [products]);
|
||||
|
||||
const filteredProducts = groupedProducts.filter((p) => {
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
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 && !matchColor) return false;
|
||||
}
|
||||
const filteredProducts = useMemo(() => {
|
||||
return groupedProducts.filter((p) => {
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
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 && !matchColor) return false;
|
||||
}
|
||||
|
||||
if (activeCategory === 'flash') return p.isFlashDeal;
|
||||
if (activeCategory === 'under120') return p.retailPrice <= 120000;
|
||||
if (activeCategory !== 'all') {
|
||||
return p.brand.toLowerCase() === activeCategory.toLowerCase();
|
||||
if (activeCategory === 'flash') return p.isFlashDeal;
|
||||
if (activeCategory === 'under120') return p.retailPrice <= 120000;
|
||||
if (activeCategory !== 'all') {
|
||||
return p.brand.toLowerCase() === activeCategory.toLowerCase();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [groupedProducts, searchQuery, activeCategory]);
|
||||
|
||||
// Reset pagination when category or search changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [activeCategory, searchQuery, pageSize]);
|
||||
|
||||
// Calculated pagination
|
||||
const totalPages = Math.max(1, Math.ceil(filteredProducts.length / pageSize));
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const paginatedProducts = useMemo(() => {
|
||||
return filteredProducts.slice(startIndex, startIndex + pageSize);
|
||||
}, [filteredProducts, startIndex, pageSize]);
|
||||
|
||||
const scrollToCatalog = () => {
|
||||
const el = document.getElementById('catalogo-coleccion');
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page < 1 || page > totalPages) return;
|
||||
setCurrentPage(page);
|
||||
scrollToCatalog();
|
||||
};
|
||||
|
||||
const handleAddToCart = (product: Product, size: string) => {
|
||||
setCartItems((prev) => {
|
||||
@ -113,6 +147,23 @@ export default function StoreHomePage() {
|
||||
|
||||
const totalCartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
||||
|
||||
// Generate visible page numbers
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
if (totalPages <= 5) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
if (currentPage <= 3) {
|
||||
pages.push(1, 2, 3, 4, '...', totalPages);
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pages.push(1, '...', totalPages - 3, totalPages - 2, totalPages - 1, totalPages);
|
||||
} else {
|
||||
pages.push(1, '...', currentPage - 1, currentPage, currentPage + 1, '...', totalPages);
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col font-sans transition-colors duration-500 ${currentBrand.colors.bodyBg}`}>
|
||||
{/* Navigation Header */}
|
||||
@ -174,15 +225,37 @@ export default function StoreHomePage() {
|
||||
</div>
|
||||
|
||||
{/* Category & Brand Filters */}
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-black text-white text-lg flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-white animate-pulse" />
|
||||
<span>Colección de Calzado & Sneakers</span>
|
||||
</h3>
|
||||
<span className="text-xs font-bold text-slate-400">
|
||||
{filteredProducts.length} {filteredProducts.length === 1 ? 'modelo' : 'modelos'}
|
||||
</span>
|
||||
<div id="catalogo-coleccion" className="pt-2 scroll-mt-24">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-black text-white text-lg flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-white animate-pulse" />
|
||||
<span>Colección de Calzado & Sneakers</span>
|
||||
</h3>
|
||||
<span className="text-xs font-bold text-purple-300 bg-purple-950/60 border border-purple-500/30 px-2.5 py-0.5 rounded-full">
|
||||
{filteredProducts.length} {filteredProducts.length === 1 ? 'modelo' : 'modelos'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Page Size Selector */}
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<span>Por página:</span>
|
||||
<div className="flex bg-slate-900 border border-slate-800 rounded-xl p-0.5">
|
||||
{[12, 24, 48].map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setPageSize(size)}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
||||
pageSize === size
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow`
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CategoryPills
|
||||
@ -191,8 +264,16 @@ export default function StoreHomePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Product Grid */}
|
||||
{products.length === 0 ? (
|
||||
{/* Product Grid / Skeleton State */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3.5 sm:gap-5">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<ProductCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="bg-neutral-900/60 rounded-3xl p-10 sm:p-14 text-center border border-purple-500/20 space-y-4 max-w-xl mx-auto shadow-2xl backdrop-blur-xl animate-in fade-in">
|
||||
<div className="w-16 h-16 rounded-2xl bg-purple-500/10 border border-purple-500/30 text-purple-400 flex items-center justify-center mx-auto shadow-inner">
|
||||
<Zap className="w-8 h-8 animate-pulse" />
|
||||
@ -215,7 +296,6 @@ export default function StoreHomePage() {
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-2 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-bold text-xs px-6 py-3 rounded-full cursor-pointer shadow-lg hover:scale-105 active:scale-95 transition-all`}
|
||||
>
|
||||
|
||||
<span>Consultar Disponibilidad por WhatsApp</span>
|
||||
</a>
|
||||
</div>
|
||||
@ -237,15 +317,113 @@ export default function StoreHomePage() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3.5 sm:gap-5">
|
||||
{filteredProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onOpenDetail={setSelectedProduct}
|
||||
onQuickAdd={(p, size) => handleAddToCart(p, size)}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3.5 sm:gap-5">
|
||||
{paginatedProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onOpenDetail={setSelectedProduct}
|
||||
onQuickAdd={(p, size) => handleAddToCart(p, size)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination Bar */}
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-slate-900/90 border border-slate-800 rounded-3xl p-4 flex flex-col sm:flex-row items-center justify-between gap-4 shadow-xl backdrop-blur-md">
|
||||
<div className="text-xs text-slate-400 font-medium text-center sm:text-left">
|
||||
Mostrando <strong className="text-white">{startIndex + 1}</strong> -{' '}
|
||||
<strong className="text-white">{Math.min(startIndex + pageSize, filteredProducts.length)}</strong> de{' '}
|
||||
<strong className="text-white">{filteredProducts.length}</strong> modelos • Página{' '}
|
||||
<strong className="text-purple-400">{currentPage}</strong> de <strong>{totalPages}</strong>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-wrap justify-center">
|
||||
{/* First & Prev */}
|
||||
<button
|
||||
onClick={() => handlePageChange(1)}
|
||||
disabled={currentPage === 1}
|
||||
title="Primera Página"
|
||||
className={`w-8 h-8 rounded-xl flex items-center justify-center border transition-all ${
|
||||
currentPage === 1
|
||||
? 'border-slate-800 text-slate-600 cursor-not-allowed bg-slate-950/40'
|
||||
: 'border-slate-700 text-slate-300 hover:text-white hover:border-purple-400 bg-slate-800/80 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
title="Página Anterior"
|
||||
className={`px-3 h-8 rounded-xl flex items-center gap-1 text-xs font-bold border transition-all ${
|
||||
currentPage === 1
|
||||
? 'border-slate-800 text-slate-600 cursor-not-allowed bg-slate-950/40'
|
||||
: 'border-slate-700 text-slate-300 hover:text-white hover:border-purple-400 bg-slate-800/80 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<ChevronLeft className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Anterior</span>
|
||||
</button>
|
||||
|
||||
{/* Page Numbers */}
|
||||
{getPageNumbers().map((pageNum, idx) => {
|
||||
if (pageNum === '...') {
|
||||
return (
|
||||
<span key={`dots-${idx}`} className="px-2 text-slate-500 font-bold text-xs">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const isCurrent = pageNum === currentPage;
|
||||
return (
|
||||
<button
|
||||
key={`page-${pageNum}`}
|
||||
onClick={() => handlePageChange(Number(pageNum))}
|
||||
className={`w-8 h-8 rounded-xl text-xs font-black transition-all cursor-pointer ${
|
||||
isCurrent
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-md shadow-purple-500/20 scale-105 border border-purple-400/50`
|
||||
: 'bg-slate-800/80 hover:bg-slate-700 border border-slate-700 text-slate-300 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Next & Last */}
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
title="Página Siguiente"
|
||||
className={`px-3 h-8 rounded-xl flex items-center gap-1 text-xs font-bold border transition-all ${
|
||||
currentPage === totalPages
|
||||
? 'border-slate-800 text-slate-600 cursor-not-allowed bg-slate-950/40'
|
||||
: 'border-slate-700 text-slate-300 hover:text-white hover:border-purple-400 bg-slate-800/80 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<span className="hidden sm:inline">Siguiente</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
title="Última Página"
|
||||
className={`w-8 h-8 rounded-xl flex items-center justify-center border transition-all ${
|
||||
currentPage === totalPages
|
||||
? 'border-slate-800 text-slate-600 cursor-not-allowed bg-slate-950/40'
|
||||
: 'border-slate-700 text-slate-300 hover:text-white hover:border-purple-400 bg-slate-800/80 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@ -4,6 +4,7 @@ import React, { useState } from 'react';
|
||||
import { Layers, X, Check, Palette, Sparkles, ArrowRight, ShieldCheck } from 'lucide-react';
|
||||
import { Product, ProductColorVariant } from '@/types';
|
||||
import { formatCOP } from '@/lib/pricing-engine';
|
||||
import { OptimizedImage } from '@/components/ui/OptimizedImage';
|
||||
|
||||
interface GroupProductsModalProps {
|
||||
isOpen: boolean;
|
||||
@ -176,10 +177,14 @@ export const GroupProductsModal: React.FC<GroupProductsModalProps> = ({
|
||||
: 'bg-neutral-950/60 border-neutral-800 hover:border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
<OptimizedImage
|
||||
src={prod.images[0]}
|
||||
alt=""
|
||||
width={100}
|
||||
height={100}
|
||||
quality={75}
|
||||
className="w-14 h-14 rounded-xl object-cover bg-neutral-900 border border-neutral-700 shrink-0"
|
||||
containerClassName="w-14 h-14 rounded-xl shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-2 w-full text-xs">
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Product, ProductColorVariant } from '@/types';
|
||||
import { formatCOP } from '@/lib/pricing-engine';
|
||||
import { OptimizedImage } from '@/components/ui/OptimizedImage';
|
||||
|
||||
interface ManageVariantsModalProps {
|
||||
isOpen: boolean;
|
||||
@ -279,10 +280,14 @@ export const ManageVariantsModal: React.FC<ManageVariantsModalProps> = ({
|
||||
className="bg-neutral-950/80 border border-neutral-800 rounded-2xl p-3.5 space-y-3 transition-all hover:border-neutral-700"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
<OptimizedImage
|
||||
src={v.image || product.images[0]}
|
||||
alt={v.colorName}
|
||||
width={110}
|
||||
height={110}
|
||||
quality={75}
|
||||
className="w-14 h-14 rounded-xl object-cover bg-neutral-900 border border-neutral-800 shrink-0"
|
||||
containerClassName="w-14 h-14 rounded-xl shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@ -7,6 +7,7 @@ import { CartItem, City } from '@/types';
|
||||
import { formatCOP, calculateSeparationBreakdown } from '@/lib/pricing-engine';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
import { renderClientTemplate } from '@/lib/templates-client';
|
||||
import { OptimizedImage } from '@/components/ui/OptimizedImage';
|
||||
|
||||
|
||||
interface CartDrawerProps {
|
||||
@ -124,10 +125,14 @@ export const CartDrawer: React.FC<CartDrawerProps> = ({
|
||||
key={`${item.product.id}-${item.selectedSize}-${idx}`}
|
||||
className="flex gap-3 bg-slate-950 p-3 rounded-2xl border border-slate-800 relative group"
|
||||
>
|
||||
<img
|
||||
<OptimizedImage
|
||||
src={item.product.images[0]}
|
||||
alt={item.product.name}
|
||||
width={160}
|
||||
height={160}
|
||||
quality={80}
|
||||
className="w-20 h-20 rounded-xl object-cover bg-slate-900 shrink-0 border border-slate-700"
|
||||
containerClassName="w-20 h-20 rounded-xl shrink-0"
|
||||
/>
|
||||
<div className="flex-1 flex flex-col justify-between">
|
||||
<div>
|
||||
|
||||
@ -6,6 +6,7 @@ import { Product } from '@/types';
|
||||
import { formatCOP, calculateSeparationBreakdown, getProductPriceRange } from '@/lib/pricing-engine';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
import { getProductSocialProof } from '@/lib/social-proof';
|
||||
import { OptimizedImage } from '@/components/ui/OptimizedImage';
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product;
|
||||
@ -37,10 +38,15 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
>
|
||||
{/* Image Container */}
|
||||
<div className="relative aspect-square w-full bg-slate-950 overflow-hidden">
|
||||
<img
|
||||
<OptimizedImage
|
||||
src={activeImage || product.images[0]}
|
||||
alt={product.name}
|
||||
alt={product.modelName || product.name}
|
||||
width={500}
|
||||
height={500}
|
||||
quality={78}
|
||||
aspectRatio="square"
|
||||
className="w-full h-full object-cover group-hover:scale-108 transition-transform duration-500"
|
||||
containerClassName="w-full h-full"
|
||||
/>
|
||||
|
||||
{/* Badges */}
|
||||
@ -113,7 +119,15 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
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" />
|
||||
<OptimizedImage
|
||||
src={c.image}
|
||||
alt={c.colorName}
|
||||
width={80}
|
||||
height={80}
|
||||
quality={70}
|
||||
className="w-full h-full object-cover"
|
||||
showSkeleton={false}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{product.colors.length > 4 && (
|
||||
|
||||
42
dripzone-app/src/components/store/ProductCardSkeleton.tsx
Normal file
42
dripzone-app/src/components/store/ProductCardSkeleton.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export const ProductCardSkeleton: React.FC = () => {
|
||||
return (
|
||||
<div className="rounded-3xl border border-slate-800/80 bg-slate-900/40 p-0 overflow-hidden flex flex-col animate-pulse">
|
||||
{/* Image Skeleton */}
|
||||
<div className="relative aspect-square w-full bg-slate-950/80">
|
||||
<div className="w-full h-full bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-[length:200%_100%]" />
|
||||
<div className="absolute top-2.5 left-2.5 w-16 h-5 bg-slate-800 rounded-lg" />
|
||||
<div className="absolute bottom-2 left-2 right-2 flex justify-between">
|
||||
<div className="w-20 h-4 bg-slate-800 rounded-full" />
|
||||
<div className="w-12 h-4 bg-slate-800 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Skeleton */}
|
||||
<div className="p-3.5 sm:p-4 space-y-3 flex-1 flex flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="w-14 h-3 bg-slate-800 rounded" />
|
||||
<div className="w-16 h-3 bg-slate-800 rounded" />
|
||||
</div>
|
||||
<div className="w-full h-4 bg-slate-800 rounded-md" />
|
||||
<div className="w-3/4 h-4 bg-slate-800 rounded-md" />
|
||||
<div className="flex gap-1 pt-1">
|
||||
<div className="w-6 h-4 bg-slate-800 rounded" />
|
||||
<div className="w-6 h-4 bg-slate-800 rounded" />
|
||||
<div className="w-6 h-4 bg-slate-800 rounded" />
|
||||
<div className="w-6 h-4 bg-slate-800 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-slate-800/60 space-y-2">
|
||||
<div className="w-24 h-6 bg-slate-800 rounded-lg" />
|
||||
<div className="w-full h-8 bg-slate-800 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -21,6 +21,7 @@ import { formatCOP, calculateSeparationBreakdown, getProductEffectivePrice } fro
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
import { getProductSocialProof } from '@/lib/social-proof';
|
||||
import { renderClientTemplate } from '@/lib/templates-client';
|
||||
import { OptimizedImage } from '@/components/ui/OptimizedImage';
|
||||
|
||||
|
||||
interface ProductModalProps {
|
||||
@ -41,6 +42,10 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
const { currentBrand } = useBrand();
|
||||
if (!product) return null;
|
||||
|
||||
const displaySizes = Array.from(
|
||||
new Set([...ALL_STANDARD_SIZES, ...(product.sizes || [])])
|
||||
).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
||||
|
||||
// Active colorway variant
|
||||
const colorsList: ProductColorVariant[] =
|
||||
product.colors && product.colors.length > 0
|
||||
@ -129,14 +134,8 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
window.open(`https://wa.me/${phone}?text=${encodeURIComponent(rawText)}`, '_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/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="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-3 sm:p-4 overflow-y-auto animate-in fade-in">
|
||||
<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
|
||||
@ -150,10 +149,16 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
<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
|
||||
<OptimizedImage
|
||||
src={selectedImage}
|
||||
alt={product.name}
|
||||
alt={product.modelName || product.name}
|
||||
width={700}
|
||||
height={700}
|
||||
quality={85}
|
||||
aspectRatio="square"
|
||||
priority={true}
|
||||
className="w-full h-full object-cover transition-all duration-300"
|
||||
containerClassName="w-full h-full"
|
||||
/>
|
||||
<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
|
||||
@ -170,7 +175,7 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
<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>
|
||||
<span>{(selectedColor.images || product.images).length} 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) => (
|
||||
@ -181,7 +186,15 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
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" />
|
||||
<OptimizedImage
|
||||
src={img}
|
||||
alt=""
|
||||
width={120}
|
||||
height={120}
|
||||
quality={75}
|
||||
className="w-full h-full object-cover"
|
||||
showSkeleton={false}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -280,10 +293,14 @@ export const ProductModal: React.FC<ProductModalProps> = ({
|
||||
: 'border-neutral-800 hover:border-neutral-600 bg-neutral-900 opacity-75 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
<OptimizedImage
|
||||
src={variant.image}
|
||||
alt={variant.colorName}
|
||||
width={90}
|
||||
height={90}
|
||||
quality={75}
|
||||
className="w-full h-full object-cover rounded-xl"
|
||||
showSkeleton={false}
|
||||
/>
|
||||
{isCurrent && (
|
||||
<div className="absolute top-1 right-1 w-3.5 h-3.5 bg-purple-600 rounded-full flex items-center justify-center border border-white">
|
||||
|
||||
100
dripzone-app/src/components/ui/OptimizedImage.tsx
Normal file
100
dripzone-app/src/components/ui/OptimizedImage.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getOptimizedImageUrl, FALLBACK_SNEAKER_IMAGE } from '@/lib/image-optimizer';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
|
||||
interface OptimizedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
||||
src?: string | null;
|
||||
alt: string;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
quality?: number;
|
||||
format?: 'webp' | 'avif' | 'jpg' | 'png';
|
||||
priority?: boolean;
|
||||
fallbackSrc?: string;
|
||||
aspectRatio?: 'square' | '4/3' | 'video' | 'auto';
|
||||
showSkeleton?: boolean;
|
||||
}
|
||||
|
||||
export const OptimizedImage: React.FC<OptimizedImageProps> = ({
|
||||
src,
|
||||
alt,
|
||||
className = 'w-full h-full object-cover',
|
||||
containerClassName = '',
|
||||
width = 600,
|
||||
height,
|
||||
quality = 75,
|
||||
format = 'webp',
|
||||
priority = false,
|
||||
fallbackSrc = FALLBACK_SNEAKER_IMAGE,
|
||||
aspectRatio = 'auto',
|
||||
showSkeleton = true,
|
||||
...restProps
|
||||
}) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [currentSrc, setCurrentSrc] = useState<string>('');
|
||||
|
||||
const targetUrl = getOptimizedImageUrl(src, {
|
||||
width,
|
||||
height: height || width,
|
||||
quality,
|
||||
format,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoaded(false);
|
||||
setHasError(false);
|
||||
setCurrentSrc(targetUrl);
|
||||
}, [src, width, height, quality, format]);
|
||||
|
||||
const aspectClass =
|
||||
aspectRatio === 'square'
|
||||
? 'aspect-square'
|
||||
: aspectRatio === '4/3'
|
||||
? 'aspect-4/3'
|
||||
: aspectRatio === 'video'
|
||||
? 'aspect-video'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden ${aspectClass} ${containerClassName}`}>
|
||||
{/* Shimmer skeleton while loading */}
|
||||
{showSkeleton && !isLoaded && !hasError && (
|
||||
<div className="absolute inset-0 bg-slate-900 animate-pulse flex items-center justify-center z-1">
|
||||
<div className="w-full h-full bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 bg-[length:200%_100%] animate-[shimmer_1.5s_infinite]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback state on error */}
|
||||
{hasError ? (
|
||||
<div className="w-full h-full min-h-[100px] flex flex-col items-center justify-center bg-slate-950 p-3 text-center border border-slate-800">
|
||||
<Sparkles className="w-5 h-5 text-purple-400/60 mb-1" />
|
||||
<span className="text-[10px] font-bold text-slate-400 line-clamp-1">{alt || 'DripZone Sneakers'}</span>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={currentSrc || targetUrl}
|
||||
alt={alt}
|
||||
loading={priority ? 'eager' : 'lazy'}
|
||||
decoding={priority ? 'sync' : 'async'}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
onError={() => {
|
||||
if (currentSrc !== fallbackSrc) {
|
||||
setCurrentSrc(fallbackSrc);
|
||||
} else {
|
||||
setHasError(true);
|
||||
}
|
||||
}}
|
||||
className={`${className} transition-opacity duration-300 ${
|
||||
isLoaded ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
{...restProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
88
dripzone-app/src/lib/image-optimizer.ts
Normal file
88
dripzone-app/src/lib/image-optimizer.ts
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Image Optimization Utilities for DripZone
|
||||
* Optimizes image URLs for Appwrite Storage, Unsplash, Cloudinary, and external CDNs,
|
||||
* reducing payload sizes by up to 90% using WebP compression, proper bounding boxes and quality control.
|
||||
*/
|
||||
|
||||
export interface ImageOptimizationOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
quality?: number;
|
||||
format?: 'webp' | 'avif' | 'jpg' | 'png';
|
||||
}
|
||||
|
||||
const DEFAULT_PROJECT_ID = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '69b84df5003bd8957c5c';
|
||||
|
||||
export function getOptimizedImageUrl(
|
||||
url?: string | null,
|
||||
options: ImageOptimizationOptions = {}
|
||||
): string {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return 'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=600&q=75&fm=webp';
|
||||
}
|
||||
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return 'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=600&q=75&fm=webp';
|
||||
}
|
||||
|
||||
// If already base64 or blob or svg, return as is
|
||||
if (trimmed.startsWith('data:') || trimmed.startsWith('blob:') || trimmed.endsWith('.svg')) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const width = options.width || 600;
|
||||
const height = options.height || width;
|
||||
const quality = options.quality || 75;
|
||||
const format = options.format || 'webp';
|
||||
|
||||
try {
|
||||
// 1. Appwrite Storage URL Optimization
|
||||
if (trimmed.includes('/storage/buckets/') && (trimmed.includes('/files/') || trimmed.includes('/view') || trimmed.includes('/preview'))) {
|
||||
let optimized = trimmed;
|
||||
// Convert /view to /preview for Appwrite image transformation engine
|
||||
if (optimized.includes('/view')) {
|
||||
optimized = optimized.replace('/view', '/preview');
|
||||
} else if (!optimized.includes('/preview') && optimized.includes('/files/')) {
|
||||
// e.g. /files/fileId -> /files/fileId/preview
|
||||
const regex = /(\/files\/[^\/\?]+)(.*)/;
|
||||
optimized = optimized.replace(regex, '$1/preview$2');
|
||||
}
|
||||
|
||||
const urlObj = new URL(optimized, 'https://appwrite.almeira.com.co');
|
||||
if (!urlObj.searchParams.has('project')) {
|
||||
urlObj.searchParams.set('project', DEFAULT_PROJECT_ID);
|
||||
}
|
||||
urlObj.searchParams.set('width', width.toString());
|
||||
urlObj.searchParams.set('height', height.toString());
|
||||
urlObj.searchParams.set('quality', quality.toString());
|
||||
urlObj.searchParams.set('output', format);
|
||||
return urlObj.toString();
|
||||
}
|
||||
|
||||
// 2. Unsplash Optimization
|
||||
if (trimmed.includes('images.unsplash.com')) {
|
||||
const urlObj = new URL(trimmed);
|
||||
urlObj.searchParams.set('auto', 'format');
|
||||
urlObj.searchParams.set('fit', 'crop');
|
||||
urlObj.searchParams.set('w', width.toString());
|
||||
urlObj.searchParams.set('q', quality.toString());
|
||||
urlObj.searchParams.set('fm', format);
|
||||
return urlObj.toString();
|
||||
}
|
||||
|
||||
// 3. Cloudinary Optimization
|
||||
if (trimmed.includes('res.cloudinary.com') && trimmed.includes('/upload/')) {
|
||||
const transform = `f_${format},q_${quality},w_${width},c_limit`;
|
||||
return trimmed.replace('/upload/', `/upload/${transform}/`);
|
||||
}
|
||||
} catch (e) {
|
||||
// If URL parsing fails, return trimmed URL
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export const FALLBACK_SNEAKER_IMAGE =
|
||||
'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=600&q=75&fm=webp';
|
||||
@ -16,7 +16,7 @@ async function sendApkToCarlos() {
|
||||
sizeMB = (stat.size / (1024 * 1024)).toFixed(2);
|
||||
}
|
||||
|
||||
const caption = `🚀 *DripZone Sneakers - APK Oficial Actualizado (Build Producción)*\n\n📅 *Generado:* ${new Date().toLocaleString('es-CO', { timeZone: 'America/Bogota' })}\n📦 *Tamaño:* ${sizeMB} MB\n\n✨ *Novedades de esta versión:*\n💡 *Estrategias Inteligentes de Precio Sugerido:* Toma de decisión antes de publicar con 3 estrategias calculadas dinámicamente (\`⚡ Alta Rotación\`, \`⚖️ Recomendado DripZone\`, \`💎 Máxima Ganancia\`), visualizando margen neto en COP y % de ahorro para el cliente respecto al mercado oficial colombiano.\n🌐 *Búsqueda Visual de Precios en Colombia:* Reconocimiento inteligente de sneakers desde la foto para consultar precios de referencia, tiendas colombianas y fichas técnicas.\n🗄️ *Persistencia Total en Appwrite Cloud:* Base de datos sincronizada al 100%. Las Ciudades & Zonas, Métodos de Pago ($10.000 anticipo), Pedidos & Logística y Proveedores ahora se pueden crear, editar, eliminar y consultar permanentemente sin pérdidas de datos.\n💬 *Feed Completo de WhatsApp:* Monitoreo integral de todos los mensajes (leídos y no leídos) en los grupos de mayoristas.\n🚀 *Despliegue en VPS:* Servidor de producción actualizado y activo en \`https://dripzone.almeira.com.co\`.\n\n📲 *Descarga Directa del APK:* https://dripzone.almeira.com.co/DripZone.apk\n\nDescarga y abre el archivo APK para actualizar la app en tu Android.`;
|
||||
const caption = `🚀 *DripZone Sneakers - APK Oficial Actualizado (Build Ultra-Rápido)*\n\n📅 *Generado:* ${new Date().toLocaleString('es-CO', { timeZone: 'America/Bogota' })}\n📦 *Tamaño:* ${sizeMB} MB\n\n⚡ *Novedades de Rendimiento & Optimización:*\n🖼️ *Optimización Total de Imágenes & WebP:* Motor inteligente de compresión automática para Appwrite Storage, Unsplash y CDNs. Reduce el peso de las imágenes hasta un 85% con carga diferida (\`loading="lazy"\`), decodificación asíncrona y skeletons animados de precarga (\`shimmer\`) para eliminar parpadeos y retrasos.\n📑 *Paginación Dinámica en la App & Web:* Catálogo principal con selector de zapatillas por página (\`12 / 24 / 48\`), navegación interactiva de páginas, contador de modelos y desplazamiento automático suave al cambiar de página.\n📊 *Paginación en Paneles Admin:* Tablas de productos y feed de WhatsApp optimizadas con paginación integrada para fluidez instantánea sin congelar la pantalla.\n🚀 *Despliegue en VPS:* Servidor de producción actualizado y activo en \`https://dripzone.almeira.com.co\`.\n\n📲 *Descarga Directa del APK:* https://dripzone.almeira.com.co/DripZone.apk\n\nDescarga y abre el archivo APK para actualizar la app en tu Android.`;
|
||||
|
||||
console.log(`📤 [1/2] Enviando notificación oficial a WhatsApp (+${TARGET_PHONE})...`);
|
||||
try {
|
||||
|
||||
@ -83,9 +83,9 @@ export const NewsCard = ({
|
||||
{imagenUrl ? (
|
||||
<>
|
||||
{/* Blurred Background to fill empty spaces nicely */}
|
||||
<img src={imagenUrl} alt="" className="absolute inset-0 w-full h-full object-cover blur-2xl opacity-40 scale-110" aria-hidden="true" />
|
||||
<img src={imagenUrl} alt="" loading="lazy" decoding="async" className="absolute inset-0 w-full h-full object-cover blur-2xl opacity-40 scale-110" aria-hidden="true" />
|
||||
{/* Main Image contained properly without crop */}
|
||||
<img src={imagenUrl} alt={titulo} className="relative z-10 w-full h-full object-contain transition-transform duration-700 group-hover:scale-105" />
|
||||
<img src={imagenUrl} alt={titulo} loading="lazy" decoding="async" className="relative z-10 w-full h-full object-contain transition-transform duration-700 group-hover:scale-105" />
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-slate-900 to-slate-950 relative">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user