feat(perf): optimizacion global de imagenes WebP con shimmer y paginacion dinamica en tienda y admin

This commit is contained in:
AI Bot 2026-08-31 11:21:02 -05:00
parent 1fdfe88e51
commit 902a651e8b
17 changed files with 856 additions and 132 deletions

View File

@ -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">

View File

@ -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>

View File

@ -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 */}

View 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',
},
});
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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>

View File

@ -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">

View File

@ -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">

View File

@ -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>

View File

@ -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 && (

View 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>
);
};

View File

@ -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">

View 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>
);
};

View 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';

View File

@ -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 {

View File

@ -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">