diff --git a/dripzone-app/src/app/admin/feed-whatsapp/page.tsx b/dripzone-app/src/app/admin/feed-whatsapp/page.tsx index b655755..f08a1a8 100644 --- a/dripzone-app/src/app/admin/feed-whatsapp/page.tsx +++ b/dripzone-app/src/app/admin/feed-whatsapp/page.tsx @@ -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([]); @@ -44,6 +49,10 @@ export default function SupplierFeedPage() { const [isSyncingLive, setIsSyncingLive] = useState(false); const [actionMessage, setActionMessage] = useState(null); + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(12); + // Multi-selection state const [selectedPostIds, setSelectedPostIds] = useState>(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() {

Haz clic en "Sincronizar WhatsApp en Vivo" para escanear los mensajes recientes de tus grupos.

) : ( -
- {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; +
+
+ {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 ( -
- {/* Top Image & Group Bar */} -
-
- {img ? ( - {ext?.name - ) : ( -
- Sin Imagen -
- )} + return ( +
+ {/* Top Image & Group Bar */} +
+
+ {img ? ( + + ) : ( +
+ Sin Imagen +
+ )} {/* Group Badge & Checkbox */}
@@ -850,6 +878,77 @@ export default function SupplierFeedPage() {
); })} +
+ + {/* Feed Pagination Bar */} + {totalPages > 1 && ( +
+ + Mostrando {startIndex + 1} a{' '} + {Math.min(startIndex + pageSize, filteredPosts.length)} de{' '} + {filteredPosts.length} mensajes de WhatsApp (Pág.{' '} + {currentPage} de {totalPages}) + + +
+ + + + + {currentPage} / {totalPages} + + + + +
+
+ )}
)} @@ -874,10 +973,14 @@ export default function SupplierFeedPage() { {/* Product preview & Internet Intelligence badge */}
{editingPost.rawImages?.[0] && ( - Preview )}
diff --git a/dripzone-app/src/app/admin/pedidos/page.tsx b/dripzone-app/src/app/admin/pedidos/page.tsx index ef89845..3946b47 100644 --- a/dripzone-app/src/app/admin/pedidos/page.tsx +++ b/dripzone-app/src/app/admin/pedidos/page.tsx @@ -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([]); @@ -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" >
- +

{item.productName}

@@ -702,7 +711,16 @@ export default function AdminOrdersPage() { {group.entries.map((entry, idx) => ( - + {entry.item.productName} {entry.item.size} diff --git a/dripzone-app/src/app/admin/productos/page.tsx b/dripzone-app/src/app/admin/productos/page.tsx index 38194e7..c97fa68 100644 --- a/dripzone-app/src/app/admin/productos/page.tsx +++ b/dripzone-app/src/app/admin/productos/page.tsx @@ -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(null); const [toastMessage, setToastMessage] = useState(null); + // Pagination State + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(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 (

@@ -483,14 +501,14 @@ export default function AdminProductsPage() { - {filtered.length === 0 ? ( + {paginatedProducts.length === 0 ? ( No hay zapatillas que coincidan con el filtro seleccionado. ) : ( - 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 */} -
- + {colorwaysCount > 1 && ( @@ -660,6 +683,76 @@ export default function AdminProductsPage() {
+ + {/* Table Pagination Bar */} + {totalPages > 1 && ( +
+ + Mostrando {startIndex + 1} a{' '} + {Math.min(startIndex + pageSize, filtered.length)} de{' '} + {filtered.length} publicaciones (Pág.{' '} + {currentPage} de {totalPages}) + + +
+ + + + + {currentPage} / {totalPages} + + + + +
+
+ )}
{/* Modal de Agrupación Manual de Productos */} diff --git a/dripzone-app/src/app/api/download-apk/route.ts b/dripzone-app/src/app/api/download-apk/route.ts new file mode 100644 index 0000000..ca8acd7 --- /dev/null +++ b/dripzone-app/src/app/api/download-apk/route.ts @@ -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', + }, + }); +} diff --git a/dripzone-app/src/app/checkout/page.tsx b/dripzone-app/src/app/checkout/page.tsx index 77b85eb..a7b7e67 100644 --- a/dripzone-app/src/app/checkout/page.tsx +++ b/dripzone-app/src/app/checkout/page.tsx @@ -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() {
{checkoutItems.map((item, i) => (
- +

{item.productName}

Talla: {item.size} | Cant: {item.quantity}

diff --git a/dripzone-app/src/app/globals.css b/dripzone-app/src/app/globals.css index a2dc41e..eca08e9 100644 --- a/dripzone-app/src/app/globals.css +++ b/dripzone-app/src/app/globals.css @@ -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; +} + diff --git a/dripzone-app/src/app/page.tsx b/dripzone-app/src/app/page.tsx index ce54d59..cd9f234 100644 --- a/dripzone-app/src/app/page.tsx +++ b/dripzone-app/src/app/page.tsx @@ -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([]); @@ -25,6 +25,11 @@ export default function StoreHomePage() { const [selectedCity, setSelectedCity] = useState(INITIAL_CITIES[0]); // Cali const [activeCategory, setActiveCategory] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); + const [isLoading, setIsLoading] = useState(true); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(12); // Modals & Drawers state const [selectedProduct, setSelectedProduct] = useState(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 (
{/* Navigation Header */} @@ -174,15 +225,37 @@ export default function StoreHomePage() {
{/* Category & Brand Filters */} -
-
-

- - Colección de Calzado & Sneakers -

- - {filteredProducts.length} {filteredProducts.length === 1 ? 'modelo' : 'modelos'} - +
+
+
+

+ + Colección de Calzado & Sneakers +

+ + {filteredProducts.length} {filteredProducts.length === 1 ? 'modelo' : 'modelos'} + +
+ + {/* Page Size Selector */} +
+ Por página: +
+ {[12, 24, 48].map((size) => ( + + ))} +
+
- {/* Product Grid */} - {products.length === 0 ? ( + {/* Product Grid / Skeleton State */} + {isLoading ? ( +
+
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+
+ ) : products.length === 0 ? (
@@ -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`} > - Consultar Disponibilidad por WhatsApp
@@ -237,15 +317,113 @@ export default function StoreHomePage() {
) : ( -
- {filteredProducts.map((product) => ( - handleAddToCart(p, size)} - /> - ))} +
+
+ {paginatedProducts.map((product) => ( + handleAddToCart(p, size)} + /> + ))} +
+ + {/* Pagination Bar */} + {totalPages > 1 && ( +
+
+ Mostrando {startIndex + 1} -{' '} + {Math.min(startIndex + pageSize, filteredProducts.length)} de{' '} + {filteredProducts.length} modelos • Página{' '} + {currentPage} de {totalPages} +
+ +
+ {/* First & Prev */} + + + + + {/* Page Numbers */} + {getPageNumbers().map((pageNum, idx) => { + if (pageNum === '...') { + return ( + + ... + + ); + } + + const isCurrent = pageNum === currentPage; + return ( + + ); + })} + + {/* Next & Last */} + + + +
+
+ )}
)} diff --git a/dripzone-app/src/components/admin/GroupProductsModal.tsx b/dripzone-app/src/components/admin/GroupProductsModal.tsx index 7803942..3080901 100644 --- a/dripzone-app/src/components/admin/GroupProductsModal.tsx +++ b/dripzone-app/src/components/admin/GroupProductsModal.tsx @@ -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 = ({ : 'bg-neutral-950/60 border-neutral-800 hover:border-neutral-700' }`} > -
diff --git a/dripzone-app/src/components/admin/ManageVariantsModal.tsx b/dripzone-app/src/components/admin/ManageVariantsModal.tsx index 1aadc0c..a6dc965 100644 --- a/dripzone-app/src/components/admin/ManageVariantsModal.tsx +++ b/dripzone-app/src/components/admin/ManageVariantsModal.tsx @@ -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 = ({ className="bg-neutral-950/80 border border-neutral-800 rounded-2xl p-3.5 space-y-3 transition-all hover:border-neutral-700" >
- {v.colorName}
diff --git a/dripzone-app/src/components/store/CartDrawer.tsx b/dripzone-app/src/components/store/CartDrawer.tsx index bf5d24d..66ccde6 100644 --- a/dripzone-app/src/components/store/CartDrawer.tsx +++ b/dripzone-app/src/components/store/CartDrawer.tsx @@ -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 = ({ key={`${item.product.id}-${item.selectedSize}-${idx}`} className="flex gap-3 bg-slate-950 p-3 rounded-2xl border border-slate-800 relative group" > - {item.product.name}
diff --git a/dripzone-app/src/components/store/ProductCard.tsx b/dripzone-app/src/components/store/ProductCard.tsx index ba81da9..a2e1143 100644 --- a/dripzone-app/src/components/store/ProductCard.tsx +++ b/dripzone-app/src/components/store/ProductCard.tsx @@ -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 = ({ > {/* Image Container */}
- {product.name} {/* Badges */} @@ -113,7 +119,15 @@ export const ProductCard: React.FC = ({ activeImage === c.image ? 'border-purple-400 scale-110 shadow-md ring-1 ring-purple-400' : 'border-slate-700 opacity-70 hover:opacity-100' }`} > - {c.colorName} + ))} {product.colors.length > 4 && ( diff --git a/dripzone-app/src/components/store/ProductCardSkeleton.tsx b/dripzone-app/src/components/store/ProductCardSkeleton.tsx new file mode 100644 index 0000000..5db9c80 --- /dev/null +++ b/dripzone-app/src/components/store/ProductCardSkeleton.tsx @@ -0,0 +1,42 @@ +'use client'; + +import React from 'react'; + +export const ProductCardSkeleton: React.FC = () => { + return ( +
+ {/* Image Skeleton */} +
+
+
+
+
+
+
+
+ + {/* Content Skeleton */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ ); +}; diff --git a/dripzone-app/src/components/store/ProductModal.tsx b/dripzone-app/src/components/store/ProductModal.tsx index 458bcf4..9b7e5a1 100644 --- a/dripzone-app/src/components/store/ProductModal.tsx +++ b/dripzone-app/src/components/store/ProductModal.tsx @@ -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 = ({ 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 = ({ 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 ( -
+
{/* Close Button */} ))}
@@ -280,10 +293,14 @@ export const ProductModal: React.FC = ({ : 'border-neutral-800 hover:border-neutral-600 bg-neutral-900 opacity-75 hover:opacity-100' }`} > - {variant.colorName} {isCurrent && (
diff --git a/dripzone-app/src/components/ui/OptimizedImage.tsx b/dripzone-app/src/components/ui/OptimizedImage.tsx new file mode 100644 index 0000000..b101cec --- /dev/null +++ b/dripzone-app/src/components/ui/OptimizedImage.tsx @@ -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, '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 = ({ + 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(''); + + 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 ( +
+ {/* Shimmer skeleton while loading */} + {showSkeleton && !isLoaded && !hasError && ( +
+
+
+ )} + + {/* Fallback state on error */} + {hasError ? ( +
+ + {alt || 'DripZone Sneakers'} +
+ ) : ( + {alt} setIsLoaded(true)} + onError={() => { + if (currentSrc !== fallbackSrc) { + setCurrentSrc(fallbackSrc); + } else { + setHasError(true); + } + }} + className={`${className} transition-opacity duration-300 ${ + isLoaded ? 'opacity-100' : 'opacity-0' + }`} + {...restProps} + /> + )} +
+ ); +}; diff --git a/dripzone-app/src/lib/image-optimizer.ts b/dripzone-app/src/lib/image-optimizer.ts new file mode 100644 index 0000000..4f527b9 --- /dev/null +++ b/dripzone-app/src/lib/image-optimizer.ts @@ -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'; diff --git a/scripts/send_apk_to_whatsapp.js b/scripts/send_apk_to_whatsapp.js index a95505e..810a2f5 100644 --- a/scripts/send_apk_to_whatsapp.js +++ b/scripts/send_apk_to_whatsapp.js @@ -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 { diff --git a/src/components/NewsCard.tsx b/src/components/NewsCard.tsx index bacb188..16362be 100644 --- a/src/components/NewsCard.tsx +++ b/src/components/NewsCard.tsx @@ -83,9 +83,9 @@ export const NewsCard = ({ {imagenUrl ? ( <> {/* Blurred Background to fill empty spaces nicely */} - + {/* Main Image contained properly without crop */} - {titulo} + {titulo} ) : (