feat: filtros avanzados por rol y deshabilitacion automatica de 7 dias con reactivacion
This commit is contained in:
parent
7d051d2d83
commit
02bac37af0
@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck, Trash2, Edit, Wand2, ArrowRight, Layers, Palette, CheckSquare, Square, MinusSquare, Split, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Tag, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck, Trash2, Edit, Wand2, ArrowRight, Layers, Palette, CheckSquare, Square, MinusSquare, Split, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Tag, AlertCircle, CheckCircle2, Clock, RefreshCw, Zap, ShieldCheck, ArrowUpDown, History } from 'lucide-react';
|
||||
import { Product, Supplier } from '@/types';
|
||||
import { INITIAL_PRODUCTS, INITIAL_SUPPLIERS } from '@/lib/data-service';
|
||||
import { formatCOP } from '@/lib/pricing-engine';
|
||||
@ -14,13 +14,31 @@ import { sanitizePublicationTitle, sanitizePublicationText } from '@/lib/sanitiz
|
||||
import { cleanProductName, detectBrandFromText } from '@/lib/gemini-parser';
|
||||
import { generateAttractiveProductCopy } from '@/lib/attractive-copy';
|
||||
import { WhatsAppOrderLinkModal } from '@/components/admin/WhatsAppOrderLinkModal';
|
||||
import { getProductPublicationDate, getProductAgeInDays, isProductOlderThanDays } from '@/lib/expiration-service';
|
||||
|
||||
export default function AdminProductsPage() {
|
||||
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>(INITIAL_SUPPLIERS);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
|
||||
// Filtros Avanzados para Administradores
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive' | 'recent' | 'expired' | 'manually_kept'>('all');
|
||||
const [supplierFilter, setSupplierFilter] = useState<string>('all');
|
||||
const [brandFilter, setBrandFilter] = useState<string>('all');
|
||||
const [sizeFilter, setSizeFilter] = useState<string>('all');
|
||||
const [stockFilter, setStockFilter] = useState<'all' | 'in_stock' | 'out_of_stock'>('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'margin_desc' | 'price_desc' | 'price_asc' | 'cost_desc'>('newest');
|
||||
|
||||
// Control de Vencimiento Automático (7 Días)
|
||||
const [expirationControl, setExpirationControl] = useState<{
|
||||
lastAutoDisableRunAt: string | null;
|
||||
lastAutoDisableCutoffDate: string | null;
|
||||
lastAutoDisableProcessedCount: number;
|
||||
daysThreshold: number;
|
||||
} | null>(null);
|
||||
const [isRunningAutoDisable, setIsRunningAutoDisable] = useState(false);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isAiModalOpen, setIsAiModalOpen] = useState(false);
|
||||
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
|
||||
@ -43,7 +61,14 @@ export default function AdminProductsPage() {
|
||||
const refreshProductsList = () => {
|
||||
fetch('/api/products?all=true')
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && setProducts(d.products))
|
||||
.then((d) => {
|
||||
if (d.success) {
|
||||
setProducts(d.products);
|
||||
if (d.expirationControl) {
|
||||
setExpirationControl(d.expirationControl);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
@ -79,7 +104,14 @@ export default function AdminProductsPage() {
|
||||
useEffect(() => {
|
||||
fetch('/api/products?all=true')
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && setProducts(d.products))
|
||||
.then((d) => {
|
||||
if (d.success) {
|
||||
setProducts(d.products);
|
||||
if (d.expirationControl) {
|
||||
setExpirationControl(d.expirationControl);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetch('/api/suppliers?all=true')
|
||||
@ -90,11 +122,87 @@ export default function AdminProductsPage() {
|
||||
initClientTemplates();
|
||||
}, []);
|
||||
|
||||
const handleRunAutoDisableNow = async () => {
|
||||
setIsRunningAutoDisable(true);
|
||||
try {
|
||||
const res = await fetch('/api/products/auto-disable', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ daysCutoff: 7 }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setToastMessage(`⚡ ${data.message}`);
|
||||
refreshProductsList();
|
||||
} else {
|
||||
alert('Error ejecutando verificación: ' + (data.error || 'Error desconocido'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error al ejecutar verificación de 7 días:', e);
|
||||
alert('Error de conexión al procesar vencimiento.');
|
||||
} finally {
|
||||
setIsRunningAutoDisable(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReenableProduct = async (product: Product) => {
|
||||
try {
|
||||
const res = await fetch('/api/products/auto-disable', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'reenable', productId: product.id }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setProducts((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === product.id
|
||||
? {
|
||||
...p,
|
||||
isActive: true,
|
||||
inStock: true,
|
||||
manuallyEnabled: true,
|
||||
autoDisabledAt: undefined,
|
||||
}
|
||||
: p
|
||||
)
|
||||
);
|
||||
setToastMessage(
|
||||
`🛡️ ¡Publicación "${product.name}" habilitada con éxito! Se mantendrá visible en la tienda porque aún tiene stock confirmado.`
|
||||
);
|
||||
} else {
|
||||
alert('Error al re-habilitar: ' + (data.error || 'Error desconocido'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error re-habilitando producto:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setStatusFilter('all');
|
||||
setSupplierFilter('all');
|
||||
setBrandFilter('all');
|
||||
setSizeFilter('all');
|
||||
setStockFilter('all');
|
||||
setCategoryFilter('all');
|
||||
setSortBy('newest');
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const handleToggleProductStatus = async (product: Product) => {
|
||||
const newStatus = product.isActive === false ? true : false;
|
||||
|
||||
setProducts((prev) =>
|
||||
prev.map((p) => (p.id === product.id ? { ...p, isActive: newStatus } : p))
|
||||
prev.map((p) =>
|
||||
p.id === product.id
|
||||
? {
|
||||
...p,
|
||||
isActive: newStatus,
|
||||
manuallyEnabled: newStatus ? true : p.manuallyEnabled,
|
||||
autoDisabledAt: newStatus ? undefined : p.autoDisabledAt,
|
||||
}
|
||||
: p
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
@ -103,14 +211,22 @@ export default function AdminProductsPage() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: product.id,
|
||||
updates: { isActive: newStatus },
|
||||
updates: {
|
||||
isActive: newStatus,
|
||||
manuallyEnabled: newStatus ? true : product.manuallyEnabled,
|
||||
autoDisabledAt: newStatus ? undefined : product.autoDisabledAt,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setToastMessage(
|
||||
`Zapatilla "${product.name}" ahora está ${newStatus ? '🟢 ACTIVA (Visible en la tienda)' : '⚪ INACTIVA (Oculta de la tienda)'}.`
|
||||
`Zapatilla "${product.name}" ahora está ${
|
||||
newStatus
|
||||
? '🟢 ACTIVA (Visible en la tienda con vigencia protegida)'
|
||||
: '⚪ INACTIVA (Oculta de la tienda)'
|
||||
}.`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@ -346,25 +462,136 @@ export default function AdminProductsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Dynamic lists for filters
|
||||
const availableBrands = useMemo(() => {
|
||||
const brands = Array.from(new Set(products.map((p) => p.brand).filter(Boolean)));
|
||||
return brands.sort();
|
||||
}, [products]);
|
||||
|
||||
const availableCategories = useMemo(() => {
|
||||
const cats = Array.from(new Set(products.map((p) => p.category).filter(Boolean)));
|
||||
return cats.sort();
|
||||
}, [products]);
|
||||
|
||||
const allSizesList = ['35', '36', '37', '38', '39', '40', '41', '42', '43', '44', '45'];
|
||||
|
||||
// Resumen de conteos para pestañas de estado
|
||||
const statusCounts = useMemo(() => {
|
||||
let active = 0;
|
||||
let inactive = 0;
|
||||
let recent = 0;
|
||||
let expired = 0;
|
||||
let manuallyKept = 0;
|
||||
|
||||
for (const p of products) {
|
||||
const isOld = isProductOlderThanDays(p, 7);
|
||||
const isActive = p.isActive !== false;
|
||||
if (isActive) {
|
||||
active++;
|
||||
if (p.manuallyEnabled) manuallyKept++;
|
||||
if (!isOld) recent++;
|
||||
} else {
|
||||
inactive++;
|
||||
if (isOld || p.autoDisabledAt) expired++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
all: products.length,
|
||||
active,
|
||||
inactive,
|
||||
recent,
|
||||
expired,
|
||||
manuallyKept,
|
||||
};
|
||||
}, [products]);
|
||||
|
||||
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()));
|
||||
return products
|
||||
.filter((p) => {
|
||||
const pubDate = getProductPublicationDate(p);
|
||||
const isOld = isProductOlderThanDays(p, 7);
|
||||
const isActive = p.isActive !== false;
|
||||
|
||||
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]);
|
||||
// 1. Filtro de búsqueda por texto
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
const matchesSearch =
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.brand.toLowerCase().includes(q) ||
|
||||
(p.supplierName && p.supplierName.toLowerCase().includes(q)) ||
|
||||
(p.category && p.category.toLowerCase().includes(q)) ||
|
||||
(p.whatsappMessageId && p.whatsappMessageId.includes(q)) ||
|
||||
p.sizes.some((s) => s.includes(q));
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
|
||||
// Reset pagination on search or filters
|
||||
// 2. Filtro de Estado & Vencimiento
|
||||
if (statusFilter === 'active' && !isActive) return false;
|
||||
if (statusFilter === 'inactive' && isActive) return false;
|
||||
if (statusFilter === 'recent') {
|
||||
// Drops vigentes recientes (< 7 días y activas)
|
||||
if (isOld || !isActive) return false;
|
||||
}
|
||||
if (statusFilter === 'expired') {
|
||||
// Publicaciones con más de 7 días que están deshabilitadas
|
||||
if (!isOld || isActive) return false;
|
||||
}
|
||||
if (statusFilter === 'manually_kept') {
|
||||
// Publicaciones reactivadas expresamente por el admin con stock
|
||||
if (!p.manuallyEnabled || !isActive) return false;
|
||||
}
|
||||
|
||||
// 3. Filtro por Proveedor
|
||||
if (supplierFilter !== 'all' && p.supplierName !== supplierFilter) return false;
|
||||
|
||||
// 4. Filtro por Marca
|
||||
if (brandFilter !== 'all' && p.brand.toLowerCase() !== brandFilter.toLowerCase()) return false;
|
||||
|
||||
// 5. Filtro por Talla
|
||||
if (sizeFilter !== 'all' && !p.sizes.includes(sizeFilter)) return false;
|
||||
|
||||
// 6. Filtro por Stock
|
||||
if (stockFilter === 'in_stock' && p.inStock === false) return false;
|
||||
if (stockFilter === 'out_of_stock' && p.inStock !== false) return false;
|
||||
|
||||
// 7. Filtro por Categoría
|
||||
if (categoryFilter !== 'all' && (!p.category || !p.category.toLowerCase().includes(categoryFilter.toLowerCase()))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = getProductPublicationDate(a).getTime();
|
||||
const dateB = getProductPublicationDate(b).getTime();
|
||||
const marginA = a.retailPrice - a.supplierPrice;
|
||||
const marginB = b.retailPrice - b.supplierPrice;
|
||||
|
||||
if (sortBy === 'newest') return dateB - dateA;
|
||||
if (sortBy === 'oldest') return dateA - dateB;
|
||||
if (sortBy === 'margin_desc') return marginB - marginA;
|
||||
if (sortBy === 'price_desc') return b.retailPrice - a.retailPrice;
|
||||
if (sortBy === 'price_asc') return a.retailPrice - b.retailPrice;
|
||||
if (sortBy === 'cost_desc') return b.supplierPrice - a.supplierPrice;
|
||||
return 0;
|
||||
});
|
||||
}, [
|
||||
products,
|
||||
search,
|
||||
statusFilter,
|
||||
supplierFilter,
|
||||
brandFilter,
|
||||
sizeFilter,
|
||||
stockFilter,
|
||||
categoryFilter,
|
||||
sortBy,
|
||||
]);
|
||||
|
||||
// Reset pagination on search or any filter change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [search, statusFilter, supplierFilter, pageSize]);
|
||||
}, [search, statusFilter, supplierFilter, brandFilter, sizeFilter, stockFilter, categoryFilter, sortBy, pageSize]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
@ -538,50 +765,294 @@ export default function AdminProductsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters & Search Toolbar */}
|
||||
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3">
|
||||
<div className="flex items-center space-x-1.5 bg-neutral-900 border border-neutral-800 p-1 rounded-2xl overflow-x-auto">
|
||||
{/* Panel de Control de Vencimiento Automático (7 Días) */}
|
||||
<div className="bg-neutral-900/90 border border-purple-500/30 rounded-3xl p-4 sm:p-5 shadow-xl backdrop-blur-md relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-purple-600/5 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4">
|
||||
<div className="space-y-1.5 max-w-2xl">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="bg-purple-950/80 text-purple-300 text-[11px] font-black px-2.5 py-0.5 rounded-full border border-purple-500/40 flex items-center gap-1.5 shadow-xs">
|
||||
<Clock className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Control de Vencimiento Automático</span>
|
||||
</span>
|
||||
<span className="bg-emerald-950/80 text-emerald-300 text-[11px] font-bold px-2 py-0.5 rounded-full border border-emerald-500/30 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
|
||||
<span>Regla Activa: 7 Días por Defecto</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-neutral-300 leading-relaxed">
|
||||
Las publicaciones con más de 7 días se deshabilitan automáticamente del catálogo público para proteger el stock y rotación.
|
||||
Como administrador, puedes <strong className="text-white">re-habilitar zapatillas con stock vigente</strong> en cualquier momento con un solo clic.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-neutral-400 flex-wrap pt-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<History className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>
|
||||
Última verificación procesada:{' '}
|
||||
<strong className="text-white">
|
||||
{expirationControl?.lastAutoDisableRunAt
|
||||
? new Date(expirationControl.lastAutoDisableRunAt).toLocaleString('es-CO', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: 'Al consultar catálogo'}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
{expirationControl?.lastAutoDisableProcessedCount !== undefined && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
<span>
|
||||
Último lote deshabilitado:{' '}
|
||||
<strong className="text-amber-300">
|
||||
{expirationControl.lastAutoDisableProcessedCount} publicaciones
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 w-full lg:w-auto justify-end flex-wrap shrink-0">
|
||||
<button
|
||||
onClick={handleRunAutoDisableNow}
|
||||
disabled={isRunningAutoDisable}
|
||||
className="w-full sm:w-auto bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white font-black text-xs px-4 py-2.5 rounded-xl flex items-center justify-center gap-2 shadow-lg shadow-purple-900/30 cursor-pointer transition-all active:scale-98 disabled:opacity-50"
|
||||
title="Ejecutar ahora la revisión y deshabilitar publicaciones mayores a 7 días que no hayan sido re-habilitadas manualmente"
|
||||
>
|
||||
{isRunningAutoDisable ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin text-purple-200" />
|
||||
<span>Verificando Catálogo...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-4 h-4 text-amber-300" />
|
||||
<span>⚡ Ejecutar Verificación de 7 Días Ahora</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters Toolbar */}
|
||||
<div className="space-y-3">
|
||||
{/* Status & Expiration Tabs */}
|
||||
<div className="flex items-center space-x-1.5 bg-neutral-900 border border-neutral-800 p-1.5 rounded-2xl overflow-x-auto scrollbar-none">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer ${
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer whitespace-nowrap ${
|
||||
statusFilter === 'all' ? 'bg-white text-neutral-950 shadow' : 'text-neutral-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Todos ({products.length})
|
||||
Todas ({statusCounts.all})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('active')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 whitespace-nowrap ${
|
||||
statusFilter === 'active'
|
||||
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow'
|
||||
: 'text-neutral-400 hover:text-emerald-300'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>Activas</span>
|
||||
<Eye className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Activas ({statusCounts.active})</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('recent')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 whitespace-nowrap ${
|
||||
statusFilter === 'recent'
|
||||
? 'bg-blue-500/20 text-blue-300 border border-blue-500/40 shadow'
|
||||
: 'text-neutral-400 hover:text-blue-300'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Vigentes < 7d ({statusCounts.recent})</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('expired')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 whitespace-nowrap ${
|
||||
statusFilter === 'expired'
|
||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 shadow'
|
||||
: 'text-neutral-400 hover:text-amber-300'
|
||||
}`}
|
||||
>
|
||||
<AlertCircle className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>Vencidas (+7d Deshabilitadas) ({statusCounts.expired})</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('manually_kept')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 whitespace-nowrap ${
|
||||
statusFilter === 'manually_kept'
|
||||
? 'bg-purple-500/20 text-purple-300 border border-purple-500/40 shadow'
|
||||
: 'text-neutral-400 hover:text-purple-300'
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Con Stock Protegido ({statusCounts.manuallyKept})</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('inactive')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 whitespace-nowrap ${
|
||||
statusFilter === 'inactive'
|
||||
? 'bg-neutral-800 text-white border border-neutral-700 shadow'
|
||||
: 'text-neutral-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<EyeOff className="w-3.5 h-3.5" />
|
||||
<span>Ocultas</span>
|
||||
<EyeOff className="w-3.5 h-3.5 text-neutral-400" />
|
||||
<span>Ocultas ({statusCounts.inactive})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por modelo, marca o proveedor..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400"
|
||||
/>
|
||||
{/* Dropdown Filters & Search */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-7 gap-2">
|
||||
{/* Búsqueda */}
|
||||
<div className="col-span-2 sm:col-span-3 lg:col-span-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por modelo, marca, proveedor, color o ID..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filtro Proveedor */}
|
||||
<div>
|
||||
<select
|
||||
value={supplierFilter}
|
||||
onChange={(e) => setSupplierFilter(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-2.5 py-2 text-xs text-neutral-300 outline-hidden focus:border-purple-400 cursor-pointer"
|
||||
>
|
||||
<option value="all">🏢 Proveedor: Todos</option>
|
||||
{suppliers.map((s) => (
|
||||
<option key={s.id} value={s.name}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Filtro Marca */}
|
||||
<div>
|
||||
<select
|
||||
value={brandFilter}
|
||||
onChange={(e) => setBrandFilter(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-2.5 py-2 text-xs text-neutral-300 outline-hidden focus:border-purple-400 cursor-pointer"
|
||||
>
|
||||
<option value="all">⚡ Marca: Todas</option>
|
||||
{availableBrands.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Filtro Talla */}
|
||||
<div>
|
||||
<select
|
||||
value={sizeFilter}
|
||||
onChange={(e) => setSizeFilter(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-2.5 py-2 text-xs text-neutral-300 outline-hidden focus:border-purple-400 cursor-pointer"
|
||||
>
|
||||
<option value="all">👟 Talla: Todas</option>
|
||||
{allSizesList.map((sz) => (
|
||||
<option key={sz} value={sz}>
|
||||
Talla {sz}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Filtro Stock */}
|
||||
<div>
|
||||
<select
|
||||
value={stockFilter}
|
||||
onChange={(e) => setStockFilter(e.target.value as any)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-2.5 py-2 text-xs text-neutral-300 outline-hidden focus:border-purple-400 cursor-pointer"
|
||||
>
|
||||
<option value="all">📦 Stock: Todos</option>
|
||||
<option value="in_stock">✅ Con Stock</option>
|
||||
<option value="out_of_stock">❌ Agotadas</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Ordenamiento */}
|
||||
<div>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-2.5 py-2 text-xs text-purple-300 font-bold outline-hidden focus:border-purple-400 cursor-pointer"
|
||||
>
|
||||
<option value="newest">🕒 Más Recientes</option>
|
||||
<option value="oldest">📅 Más Antiguos</option>
|
||||
<option value="margin_desc">💰 Mayor Margen</option>
|
||||
<option value="price_desc">💎 Mayor Precio Venta</option>
|
||||
<option value="price_asc">🏷️ Menor Precio Venta</option>
|
||||
<option value="cost_desc">🏭 Mayor Costo Proveedor</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resumen de filtros aplicados */}
|
||||
{(statusFilter !== 'all' ||
|
||||
supplierFilter !== 'all' ||
|
||||
brandFilter !== 'all' ||
|
||||
sizeFilter !== 'all' ||
|
||||
stockFilter !== 'all' ||
|
||||
categoryFilter !== 'all' ||
|
||||
search.trim()) && (
|
||||
<div className="flex items-center justify-between gap-2 pt-1 text-xs text-neutral-400 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span>Filtros activos:</span>
|
||||
{statusFilter !== 'all' && (
|
||||
<span className="bg-purple-950/80 text-purple-300 px-2 py-0.5 rounded-lg border border-purple-800/50">
|
||||
Estado: {statusFilter}
|
||||
</span>
|
||||
)}
|
||||
{supplierFilter !== 'all' && (
|
||||
<span className="bg-neutral-800 text-neutral-300 px-2 py-0.5 rounded-lg border border-neutral-700">
|
||||
{supplierFilter}
|
||||
</span>
|
||||
)}
|
||||
{brandFilter !== 'all' && (
|
||||
<span className="bg-neutral-800 text-neutral-300 px-2 py-0.5 rounded-lg border border-neutral-700">
|
||||
{brandFilter}
|
||||
</span>
|
||||
)}
|
||||
{sizeFilter !== 'all' && (
|
||||
<span className="bg-neutral-800 text-neutral-300 px-2 py-0.5 rounded-lg border border-neutral-700">
|
||||
Talla {sizeFilter}
|
||||
</span>
|
||||
)}
|
||||
{stockFilter !== 'all' && (
|
||||
<span className="bg-neutral-800 text-neutral-300 px-2 py-0.5 rounded-lg border border-neutral-700">
|
||||
{stockFilter === 'in_stock' ? 'En Stock' : 'Agotado'}
|
||||
</span>
|
||||
)}
|
||||
{search.trim() && (
|
||||
<span className="bg-neutral-800 text-neutral-300 px-2 py-0.5 rounded-lg border border-neutral-700">
|
||||
"{search}"
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleResetFilters}
|
||||
className="text-purple-400 hover:text-purple-300 font-bold underline cursor-pointer text-xs"
|
||||
>
|
||||
Restablecer Filtros
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Barra de Selección de Página y Acciones Rápidas */}
|
||||
@ -754,6 +1225,7 @@ export default function AdminProductsPage() {
|
||||
/>
|
||||
</th>
|
||||
<th className="py-3.5 px-4">Estado</th>
|
||||
<th className="py-3.5 px-4">Vigencia / Publicación</th>
|
||||
<th className="py-3.5 px-4">Producto</th>
|
||||
<th className="py-3.5 px-4">Proveedor Asignado</th>
|
||||
<th className="py-3.5 px-4">Marca</th>
|
||||
@ -767,7 +1239,7 @@ export default function AdminProductsPage() {
|
||||
<tbody className="divide-y divide-neutral-800/60">
|
||||
{paginatedProducts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="text-center py-8 text-neutral-500">
|
||||
<td colSpan={11} className="text-center py-8 text-neutral-500">
|
||||
No hay zapatillas que coincidan con el filtro seleccionado.
|
||||
</td>
|
||||
</tr>
|
||||
@ -779,6 +1251,9 @@ export default function AdminProductsPage() {
|
||||
const suppPhone = prod.supplierPhone || '573134848203';
|
||||
const isSelected = selectedProductIds.includes(prod.id);
|
||||
const colorwaysCount = prod.colors?.length || 1;
|
||||
const pubDate = getProductPublicationDate(prod);
|
||||
const ageDays = getProductAgeInDays(prod);
|
||||
const isOld = isProductOlderThanDays(prod, 7);
|
||||
|
||||
return (
|
||||
<tr
|
||||
@ -828,6 +1303,56 @@ export default function AdminProductsPage() {
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* Vigencia / Publicación & Control de Vencimiento */}
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="space-y-1.5 min-w-[140px]">
|
||||
<div className="flex items-center gap-1 text-[11px] text-neutral-300 font-medium">
|
||||
<Clock className="w-3 h-3 text-purple-400 shrink-0" />
|
||||
<span>
|
||||
{pubDate.toLocaleDateString('es-CO', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
})}
|
||||
</span>
|
||||
<span className="text-[10px] text-neutral-500">
|
||||
({ageDays === 0 ? 'Hoy' : `hace ${ageDays}d`})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isOld && !isActive ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded-md bg-amber-500/15 text-amber-300 border border-amber-500/30 w-fit">
|
||||
<AlertCircle className="w-2.5 h-2.5" />
|
||||
<span>Vencida (+7d)</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReenableProduct(prod)}
|
||||
className="text-[10px] font-black px-2 py-1 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white flex items-center gap-1 cursor-pointer transition-all shadow-sm w-fit"
|
||||
title="Re-habilitar zapatilla porque aún tiene stock disponible o continúa vigente"
|
||||
>
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-200" />
|
||||
<span>Habilitar (Con Stock)</span>
|
||||
</button>
|
||||
</div>
|
||||
) : isOld && isActive && prod.manuallyEnabled ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded-md bg-purple-500/20 text-purple-300 border border-purple-500/30 w-fit">
|
||||
<ShieldCheck className="w-2.5 h-2.5 text-purple-400" />
|
||||
<span>Stock Vigente (+7d)</span>
|
||||
</span>
|
||||
) : isActive ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded-md bg-blue-500/15 text-blue-300 border border-blue-500/30 w-fit">
|
||||
<Sparkles className="w-2.5 h-2.5 text-blue-400" />
|
||||
<span>Drop Vigente</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded-md bg-neutral-800 text-neutral-400 border border-neutral-700 w-fit">
|
||||
<span>Oculta Manual</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Image & Title */}
|
||||
<td className="py-3.5 px-4 flex items-center space-x-3">
|
||||
<div className="relative w-12 h-12 shrink-0">
|
||||
|
||||
61
dripzone-app/src/app/api/products/auto-disable/route.ts
Normal file
61
dripzone-app/src/app/api/products/auto-disable/route.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import {
|
||||
processProductExpiration,
|
||||
reenableProductWithStock,
|
||||
getExpirationStatusSummary,
|
||||
} from '@/lib/expiration-service';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const summary = getExpirationStatusSummary();
|
||||
return NextResponse.json({ success: true, summary });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch (_) {}
|
||||
|
||||
// Acción para re-habilitar producto específico con stock
|
||||
if (body.action === 'reenable') {
|
||||
const { productId } = body;
|
||||
if (!productId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'ID de producto requerido para reactivar' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const updated = await reenableProductWithStock(productId);
|
||||
if (!updated) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Producto no encontrado' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
product: updated,
|
||||
message: `¡Zapatilla "${updated.name}" habilitada con éxito y marcada con stock vigente!`,
|
||||
});
|
||||
}
|
||||
|
||||
// Acción de ejecución del proceso de vencimiento (7 días por defecto)
|
||||
const daysCutoff = typeof body.daysCutoff === 'number' ? body.daysCutoff : 7;
|
||||
const result = await processProductExpiration({ daysCutoff });
|
||||
const summary = getExpirationStatusSummary();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result,
|
||||
summary,
|
||||
message: result.message,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,8 @@ import { dataService } from '@/lib/data-service';
|
||||
import { getProductSocialProof } from '@/lib/social-proof';
|
||||
import { deleteProductFromAppwrite, saveProductToAppwrite, getProductsFromAppwrite } from '@/lib/server-appwrite';
|
||||
import { registerProductInWhatsAppCatalog } from '@/lib/whatsapp-catalog';
|
||||
import { processProductExpiration } from '@/lib/expiration-service';
|
||||
import { Product } from '@/types';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
@ -21,11 +23,33 @@ export async function GET(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Verificación y deshabilitación automática de publicaciones con más de 7 días
|
||||
const settings = dataService.getSettings();
|
||||
const lastRun = settings.lastAutoDisableRunAt ? new Date(settings.lastAutoDisableRunAt).getTime() : 0;
|
||||
// Si no se ha corrido hoy o hace más de 2 horas, ejecutar de forma proactiva
|
||||
if (Date.now() - lastRun > 2 * 60 * 60 * 1000) {
|
||||
try {
|
||||
await processProductExpiration();
|
||||
products = dataService.getProducts();
|
||||
} catch (e) {
|
||||
console.warn('Error al procesar vencimiento automático de publicaciones:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!includeAll) {
|
||||
products = products.filter((p) => p.isActive !== false);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, products });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
products,
|
||||
expirationControl: {
|
||||
lastAutoDisableRunAt: settings.lastAutoDisableRunAt || null,
|
||||
lastAutoDisableCutoffDate: settings.lastAutoDisableCutoffDate || null,
|
||||
lastAutoDisableProcessedCount: settings.lastAutoDisableProcessedCount || 0,
|
||||
daysThreshold: settings.autoDisableOlderThanDays || 7,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@ -51,7 +75,7 @@ export async function POST(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
// ACCIÓN MASIVA: Activar / Ocultar en bloque
|
||||
// ACCIÓN MASIVA: Activar / Ocultar en bloque con protección de stock
|
||||
if (body.action === 'bulk-status') {
|
||||
const productIds: string[] = body.productIds || body.ids || [];
|
||||
const isActive = Boolean(body.isActive);
|
||||
@ -60,7 +84,13 @@ export async function POST(request: Request) {
|
||||
}
|
||||
let updatedCount = 0;
|
||||
for (const id of productIds) {
|
||||
const updated = dataService.updateProduct(id, { isActive });
|
||||
const updates: Partial<Product> = { isActive };
|
||||
if (isActive) {
|
||||
// Si el administrador activa la publicación, protegerla contra deshabilitación automática
|
||||
updates.manuallyEnabled = true;
|
||||
updates.inStock = true;
|
||||
}
|
||||
const updated = dataService.updateProduct(id, updates);
|
||||
if (updated) {
|
||||
saveProductToAppwrite(updated).catch(() => {});
|
||||
registerProductInWhatsAppCatalog(updated).catch(() => {});
|
||||
@ -70,7 +100,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
updatedCount,
|
||||
message: `¡Se ${isActive ? 'activaron' : 'ocultaron'} ${updatedCount} productos con éxito!`,
|
||||
message: `¡Se ${isActive ? 'activaron (con vigencia de stock protegida)' : 'ocultaron'} ${updatedCount} productos con éxito!`,
|
||||
});
|
||||
}
|
||||
|
||||
@ -268,6 +298,11 @@ export async function PUT(request: Request) {
|
||||
return NextResponse.json({ success: false, error: 'ID de producto requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (updates && updates.isActive === true) {
|
||||
updates.manuallyEnabled = true;
|
||||
updates.autoDisabledAt = undefined;
|
||||
}
|
||||
|
||||
const updated = dataService.updateProduct(id, updates);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: 'Producto no encontrado' }, { status: 404 });
|
||||
|
||||
@ -18,6 +18,7 @@ import { Sparkles, ShieldCheck, Truck, RefreshCw, Zap, ChevronLeft, ChevronRight
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
import { renderClientTemplate } from '@/lib/templates-client';
|
||||
import { CustomRequestModal } from '@/components/custom-requests/CustomRequestModal';
|
||||
import { CustomerFilterBar } from '@/components/store/CustomerFilterBar';
|
||||
|
||||
export default function StoreHomePage() {
|
||||
const { currentBrand } = useBrand();
|
||||
@ -28,6 +29,13 @@ export default function StoreHomePage() {
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
// Filtros Avanzados para el Usuario Final
|
||||
const [selectedBrand, setSelectedBrand] = useState<string>('all');
|
||||
const [selectedSize, setSelectedSize] = useState<string>('all');
|
||||
const [selectedPriceRange, setSelectedPriceRange] = useState<string>('all');
|
||||
const [onlyInStock, setOnlyInStock] = useState<boolean>(false);
|
||||
const [customerSortBy, setCustomerSortBy] = useState<any>('newest');
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [pageSize, setPageSize] = useState<number>(12);
|
||||
@ -67,129 +75,218 @@ export default function StoreHomePage() {
|
||||
|
||||
const groupedProducts = useMemo(() => groupProductsByModel(products), [products]);
|
||||
|
||||
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;
|
||||
}
|
||||
const customerBrands = useMemo(() => {
|
||||
return Array.from(new Set(groupedProducts.map((p) => p.brand).filter(Boolean))).sort();
|
||||
}, [groupedProducts]);
|
||||
|
||||
if (activeCategory === 'flash') return p.isFlashDeal;
|
||||
if (activeCategory === 'under120') return p.retailPrice <= 120000;
|
||||
if (activeCategory !== 'all') {
|
||||
const catLow = activeCategory.toLowerCase();
|
||||
const prodCat = (p.category || '').toLowerCase();
|
||||
const prodBrand = (p.brand || '').toLowerCase();
|
||||
const prodName = (p.name || '').toLowerCase();
|
||||
|
||||
if (catLow === 'zapatillas') {
|
||||
return (
|
||||
prodCat.includes('zapatilla') ||
|
||||
prodCat.includes('sneaker') ||
|
||||
prodCat.includes('tenis') ||
|
||||
(!prodCat.includes('chancla') &&
|
||||
!prodCat.includes('sandalia') &&
|
||||
!prodCat.includes('bolso') &&
|
||||
!prodCat.includes('malet') &&
|
||||
!prodCat.includes('morral') &&
|
||||
!prodCat.includes('carriel') &&
|
||||
!prodCat.includes('gorra') &&
|
||||
!prodCat.includes('ropa') &&
|
||||
!prodCat.includes('accesorio') &&
|
||||
!prodName.includes('chancla') &&
|
||||
!prodName.includes('sandalia') &&
|
||||
!prodName.includes('gorra') &&
|
||||
!prodName.includes('morral') &&
|
||||
!prodName.includes('carriel'))
|
||||
);
|
||||
}
|
||||
if (catLow === 'sandalias' || catLow === 'chanclas') {
|
||||
return (
|
||||
prodCat.includes('sandalia') ||
|
||||
prodCat.includes('chancla') ||
|
||||
prodCat.includes('slide') ||
|
||||
prodCat.includes('croc') ||
|
||||
prodName.includes('sandalia') ||
|
||||
prodName.includes('chancla') ||
|
||||
prodName.includes('slide') ||
|
||||
prodName.includes('croc')
|
||||
);
|
||||
}
|
||||
if (catLow === 'gorras') {
|
||||
return prodCat.includes('gorra') || prodCat.includes('cap') || prodName.includes('gorra');
|
||||
}
|
||||
if (catLow === 'bolsos') {
|
||||
return (
|
||||
prodCat.includes('bolso') ||
|
||||
prodCat.includes('cartera') ||
|
||||
prodName.includes('bolso') ||
|
||||
prodName.includes('tote')
|
||||
);
|
||||
}
|
||||
if (catLow === 'maletines') {
|
||||
return (
|
||||
prodCat.includes('malet') ||
|
||||
prodCat.includes('morral') ||
|
||||
prodCat.includes('mochila') ||
|
||||
prodName.includes('morral') ||
|
||||
prodName.includes('mochila') ||
|
||||
prodName.includes('malet')
|
||||
);
|
||||
}
|
||||
if (catLow === 'carrieles') {
|
||||
return (
|
||||
prodCat.includes('carriel') ||
|
||||
prodCat.includes('bandolera') ||
|
||||
prodCat.includes('riñonera') ||
|
||||
prodCat.includes('rinonera') ||
|
||||
prodCat.includes('crossbody') ||
|
||||
prodName.includes('carriel') ||
|
||||
prodName.includes('crossbody') ||
|
||||
prodName.includes('bandolera')
|
||||
);
|
||||
}
|
||||
if (catLow === 'ropa') {
|
||||
return (
|
||||
prodCat.includes('ropa') ||
|
||||
prodCat.includes('camiseta') ||
|
||||
prodCat.includes('buzo') ||
|
||||
prodCat.includes('hoodie') ||
|
||||
prodCat.includes('pantalon') ||
|
||||
prodCat.includes('short') ||
|
||||
prodCat.includes('streetwear') ||
|
||||
prodName.includes('camiseta') ||
|
||||
prodName.includes('hoodie') ||
|
||||
prodName.includes('buzo')
|
||||
);
|
||||
}
|
||||
if (catLow === 'accesorios') {
|
||||
return (
|
||||
prodCat.includes('accesorio') ||
|
||||
prodCat.includes('billetera') ||
|
||||
prodCat.includes('cinturon') ||
|
||||
prodCat.includes('correa') ||
|
||||
prodCat.includes('reloj') ||
|
||||
prodCat.includes('gafas') ||
|
||||
prodName.includes('billetera') ||
|
||||
prodName.includes('cinturon')
|
||||
);
|
||||
}
|
||||
|
||||
// Por marca directa (Nike, Adidas, etc.) o coincidencia exacta
|
||||
return prodBrand === catLow || prodCat === catLow;
|
||||
}
|
||||
return true;
|
||||
const customerSizes = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
groupedProducts.forEach((p) => {
|
||||
p.sizes?.forEach((s) => set.add(s));
|
||||
p.allSizes?.forEach((s) => set.add(s));
|
||||
});
|
||||
}, [groupedProducts, searchQuery, activeCategory]);
|
||||
const arr = Array.from(set).filter(Boolean);
|
||||
arr.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
||||
return arr.length > 0 ? arr : ['36', '37', '38', '39', '40', '41', '42', '43', '44'];
|
||||
}, [groupedProducts]);
|
||||
|
||||
// Reset pagination when category or search changes
|
||||
const activeFiltersCount =
|
||||
(selectedBrand !== 'all' ? 1 : 0) +
|
||||
(selectedSize !== 'all' ? 1 : 0) +
|
||||
(selectedPriceRange !== 'all' ? 1 : 0) +
|
||||
(onlyInStock ? 1 : 0) +
|
||||
(customerSortBy !== 'newest' ? 1 : 0);
|
||||
|
||||
const handleResetCustomerFilters = () => {
|
||||
setSelectedBrand('all');
|
||||
setSelectedSize('all');
|
||||
setSelectedPriceRange('all');
|
||||
setOnlyInStock(false);
|
||||
setCustomerSortBy('newest');
|
||||
setActiveCategory('all');
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
return groupedProducts
|
||||
.filter((p) => {
|
||||
// 1. Búsqueda por texto (Nombre, marca, color o talla)
|
||||
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;
|
||||
}
|
||||
|
||||
// 2. Filtro por Marca seleccionada
|
||||
if (selectedBrand !== 'all') {
|
||||
if (p.brand.toLowerCase() !== selectedBrand.toLowerCase()) return false;
|
||||
}
|
||||
|
||||
// 3. Filtro por Talla seleccionada
|
||||
if (selectedSize !== 'all') {
|
||||
const hasSize = p.sizes.includes(selectedSize) || (p.allSizes && p.allSizes.includes(selectedSize));
|
||||
if (!hasSize) return false;
|
||||
}
|
||||
|
||||
// 4. Filtro por Rango de Precio
|
||||
if (selectedPriceRange === 'under100' && p.retailPrice >= 100000) return false;
|
||||
if (selectedPriceRange === '100to140' && (p.retailPrice < 100000 || p.retailPrice > 140000)) return false;
|
||||
if (selectedPriceRange === '140to180' && (p.retailPrice <= 140000 || p.retailPrice > 180000)) return false;
|
||||
if (selectedPriceRange === 'over180' && p.retailPrice <= 180000) return false;
|
||||
|
||||
// 5. Filtro Solo en Stock
|
||||
if (onlyInStock && p.inStock === false) return false;
|
||||
|
||||
// 6. Filtro por Categoría
|
||||
if (activeCategory === 'flash') return p.isFlashDeal;
|
||||
if (activeCategory === 'under120') return p.retailPrice <= 120000;
|
||||
if (activeCategory !== 'all') {
|
||||
const catLow = activeCategory.toLowerCase();
|
||||
const prodCat = (p.category || '').toLowerCase();
|
||||
const prodBrand = (p.brand || '').toLowerCase();
|
||||
const prodName = (p.name || '').toLowerCase();
|
||||
|
||||
if (catLow === 'zapatillas') {
|
||||
return (
|
||||
prodCat.includes('zapatilla') ||
|
||||
prodCat.includes('sneaker') ||
|
||||
prodCat.includes('tenis') ||
|
||||
(!prodCat.includes('chancla') &&
|
||||
!prodCat.includes('sandalia') &&
|
||||
!prodCat.includes('bolso') &&
|
||||
!prodCat.includes('malet') &&
|
||||
!prodCat.includes('morral') &&
|
||||
!prodCat.includes('carriel') &&
|
||||
!prodCat.includes('gorra') &&
|
||||
!prodCat.includes('ropa') &&
|
||||
!prodCat.includes('accesorio') &&
|
||||
!prodName.includes('chancla') &&
|
||||
!prodName.includes('sandalia') &&
|
||||
!prodName.includes('gorra') &&
|
||||
!prodName.includes('morral') &&
|
||||
!prodName.includes('carriel'))
|
||||
);
|
||||
}
|
||||
if (catLow === 'sandalias' || catLow === 'chanclas') {
|
||||
return (
|
||||
prodCat.includes('sandalia') ||
|
||||
prodCat.includes('chancla') ||
|
||||
prodCat.includes('slide') ||
|
||||
prodCat.includes('croc') ||
|
||||
prodName.includes('sandalia') ||
|
||||
prodName.includes('chancla') ||
|
||||
prodName.includes('slide') ||
|
||||
prodName.includes('croc')
|
||||
);
|
||||
}
|
||||
if (catLow === 'gorras') {
|
||||
return prodCat.includes('gorra') || prodCat.includes('cap') || prodName.includes('gorra');
|
||||
}
|
||||
if (catLow === 'bolsos') {
|
||||
return (
|
||||
prodCat.includes('bolso') ||
|
||||
prodCat.includes('cartera') ||
|
||||
prodName.includes('bolso') ||
|
||||
prodName.includes('tote')
|
||||
);
|
||||
}
|
||||
if (catLow === 'maletines') {
|
||||
return (
|
||||
prodCat.includes('malet') ||
|
||||
prodCat.includes('morral') ||
|
||||
prodCat.includes('mochila') ||
|
||||
prodName.includes('morral') ||
|
||||
prodName.includes('mochila') ||
|
||||
prodName.includes('malet')
|
||||
);
|
||||
}
|
||||
if (catLow === 'carrieles') {
|
||||
return (
|
||||
prodCat.includes('carriel') ||
|
||||
prodCat.includes('bandolera') ||
|
||||
prodCat.includes('riñonera') ||
|
||||
prodCat.includes('rinonera') ||
|
||||
prodCat.includes('crossbody') ||
|
||||
prodName.includes('carriel') ||
|
||||
prodName.includes('crossbody') ||
|
||||
prodName.includes('bandolera')
|
||||
);
|
||||
}
|
||||
if (catLow === 'ropa') {
|
||||
return (
|
||||
prodCat.includes('ropa') ||
|
||||
prodCat.includes('camiseta') ||
|
||||
prodCat.includes('buzo') ||
|
||||
prodCat.includes('hoodie') ||
|
||||
prodCat.includes('pantalon') ||
|
||||
prodCat.includes('short') ||
|
||||
prodCat.includes('streetwear') ||
|
||||
prodName.includes('camiseta') ||
|
||||
prodName.includes('hoodie') ||
|
||||
prodName.includes('buzo')
|
||||
);
|
||||
}
|
||||
if (catLow === 'accesorios') {
|
||||
return (
|
||||
prodCat.includes('accesorio') ||
|
||||
prodCat.includes('billetera') ||
|
||||
prodCat.includes('cinturon') ||
|
||||
prodCat.includes('correa') ||
|
||||
prodCat.includes('reloj') ||
|
||||
prodCat.includes('gafas') ||
|
||||
prodName.includes('billetera') ||
|
||||
prodName.includes('cinturon')
|
||||
);
|
||||
}
|
||||
|
||||
// Por marca directa (Nike, Adidas, etc.) o coincidencia exacta
|
||||
return prodBrand === catLow || prodCat === catLow;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (customerSortBy === 'price_asc') return a.retailPrice - b.retailPrice;
|
||||
if (customerSortBy === 'price_desc') return b.retailPrice - a.retailPrice;
|
||||
if (customerSortBy === 'popular') {
|
||||
return (b.salesCount || b.reviewsCount || 0) - (a.salesCount || a.reviewsCount || 0);
|
||||
}
|
||||
if (customerSortBy === 'discount') {
|
||||
const discA = (a.originalPrice || a.retailPrice) - a.retailPrice;
|
||||
const discB = (b.originalPrice || b.retailPrice) - b.retailPrice;
|
||||
return discB - discA;
|
||||
}
|
||||
// 'newest'
|
||||
const dateA = new Date(a.createdAt || a.publishedAt || 0).getTime();
|
||||
const dateB = new Date(b.createdAt || b.publishedAt || 0).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
}, [
|
||||
groupedProducts,
|
||||
searchQuery,
|
||||
activeCategory,
|
||||
selectedBrand,
|
||||
selectedSize,
|
||||
selectedPriceRange,
|
||||
onlyInStock,
|
||||
customerSortBy,
|
||||
]);
|
||||
|
||||
// Reset pagination when category, search, or any filter changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [activeCategory, searchQuery, pageSize]);
|
||||
}, [
|
||||
activeCategory,
|
||||
searchQuery,
|
||||
selectedBrand,
|
||||
selectedSize,
|
||||
selectedPriceRange,
|
||||
onlyInStock,
|
||||
customerSortBy,
|
||||
pageSize,
|
||||
]);
|
||||
|
||||
// Calculated pagination
|
||||
const totalPages = Math.max(1, Math.ceil(filteredProducts.length / pageSize));
|
||||
@ -364,6 +461,25 @@ export default function StoreHomePage() {
|
||||
activeCategory={activeCategory}
|
||||
onSelectCategory={setActiveCategory}
|
||||
/>
|
||||
|
||||
{/* Filtros de Calzado para el Usuario Final (Tallas, Marcas, Precios, Stock, Orden) */}
|
||||
<CustomerFilterBar
|
||||
brands={customerBrands}
|
||||
sizes={customerSizes}
|
||||
selectedBrand={selectedBrand}
|
||||
onSelectBrand={setSelectedBrand}
|
||||
selectedSize={selectedSize}
|
||||
onSelectSize={setSelectedSize}
|
||||
selectedPriceRange={selectedPriceRange}
|
||||
onSelectPriceRange={setSelectedPriceRange}
|
||||
onlyInStock={onlyInStock}
|
||||
onToggleInStock={setOnlyInStock}
|
||||
sortBy={customerSortBy}
|
||||
onSelectSortBy={setCustomerSortBy}
|
||||
onResetFilters={handleResetCustomerFilters}
|
||||
activeFiltersCount={activeFiltersCount}
|
||||
totalProductsCount={filteredProducts.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Product Grid / Skeleton State */}
|
||||
|
||||
458
dripzone-app/src/components/store/CustomerFilterBar.tsx
Normal file
458
dripzone-app/src/components/store/CustomerFilterBar.tsx
Normal file
@ -0,0 +1,458 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Filter, SlidersHorizontal, Check, X, ArrowUpDown, Tag, Sparkles, Zap } from 'lucide-react';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
|
||||
interface CustomerFilterBarProps {
|
||||
brands: string[];
|
||||
sizes: string[];
|
||||
selectedBrand: string;
|
||||
onSelectBrand: (brand: string) => void;
|
||||
selectedSize: string;
|
||||
onSelectSize: (size: string) => void;
|
||||
selectedPriceRange: string;
|
||||
onSelectPriceRange: (range: string) => void;
|
||||
onlyInStock: boolean;
|
||||
onToggleInStock: (val: boolean) => void;
|
||||
sortBy: string;
|
||||
onSelectSortBy: (sort: string) => void;
|
||||
onResetFilters: () => void;
|
||||
activeFiltersCount: number;
|
||||
totalProductsCount: number;
|
||||
}
|
||||
|
||||
export const CustomerFilterBar: React.FC<CustomerFilterBarProps> = ({
|
||||
brands,
|
||||
sizes,
|
||||
selectedBrand,
|
||||
onSelectBrand,
|
||||
selectedSize,
|
||||
onSelectSize,
|
||||
selectedPriceRange,
|
||||
onSelectPriceRange,
|
||||
onlyInStock,
|
||||
onToggleInStock,
|
||||
sortBy,
|
||||
onSelectSortBy,
|
||||
onResetFilters,
|
||||
activeFiltersCount,
|
||||
totalProductsCount,
|
||||
}) => {
|
||||
const { currentBrand } = useBrand();
|
||||
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false);
|
||||
|
||||
const priceRanges = [
|
||||
{ id: 'all', label: 'Todos los precios' },
|
||||
{ id: 'under100', label: 'Menos de $100.000' },
|
||||
{ id: '100to140', label: '$100.000 - $140.000' },
|
||||
{ id: '140to180', label: '$140.000 - $180.000' },
|
||||
{ id: 'over180', label: 'Más de $180.000' },
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ id: 'newest', label: '⚡ Novedades / Recientes' },
|
||||
{ id: 'price_asc', label: '💰 Menor Precio' },
|
||||
{ id: 'price_desc', label: '💎 Mayor Precio' },
|
||||
{ id: 'popular', label: '⭐ Más Populares' },
|
||||
{ id: 'discount', label: '🔥 Mayor Descuento' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pt-1">
|
||||
{/* 1. Barra Principal de Tallas Rápidas y Botón de Filtros */}
|
||||
<div className="bg-slate-900/70 border border-slate-800/80 rounded-2xl p-2.5 sm:p-3 backdrop-blur-md shadow-md">
|
||||
<div className="flex items-center justify-between gap-2.5 flex-wrap sm:flex-nowrap">
|
||||
{/* Selector de Tallas Rápido (Desplazamiento horizontal) */}
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto scrollbar-none py-0.5 flex-1 min-w-0">
|
||||
<span className="text-[11px] font-black text-slate-400 uppercase tracking-wider shrink-0 mr-1 hidden sm:inline-flex items-center gap-1">
|
||||
<span>Talla:</span>
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => onSelectSize('all')}
|
||||
className={`shrink-0 text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer ${
|
||||
selectedSize === 'all'
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-md font-black`
|
||||
: 'bg-slate-950 text-slate-400 hover:text-white border border-slate-800 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
Todas
|
||||
</button>
|
||||
|
||||
{sizes.map((s) => {
|
||||
const isSelected = selectedSize === s;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onSelectSize(isSelected ? 'all' : s)}
|
||||
className={`shrink-0 text-xs font-mono font-bold px-2.5 py-1.5 rounded-xl transition-all cursor-pointer select-none ${
|
||||
isSelected
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white shadow-md scale-105 border border-white/20 font-black`
|
||||
: 'bg-slate-950 text-slate-300 hover:text-white border border-slate-800 hover:border-slate-700'
|
||||
}`}
|
||||
title={`Filtrar por calzado talla ${s}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Botones de Control y Filtros Móvil/Desktop */}
|
||||
<div className="flex items-center gap-2 shrink-0 ml-auto">
|
||||
{/* Toggle Solo en Stock */}
|
||||
<button
|
||||
onClick={() => onToggleInStock(!onlyInStock)}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl border flex items-center gap-1.5 transition-all cursor-pointer select-none ${
|
||||
onlyInStock
|
||||
? 'bg-emerald-500/20 border-emerald-500/50 text-emerald-300 shadow-sm'
|
||||
: 'bg-slate-950 text-slate-400 border-slate-800 hover:text-white hover:border-slate-700'
|
||||
}`}
|
||||
title="Mostrar únicamente zapatillas disponibles con inventario listo para despacho"
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full ${onlyInStock ? 'bg-emerald-400 animate-pulse' : 'bg-slate-600'}`} />
|
||||
<span className="hidden md:inline">Solo en Stock</span>
|
||||
<span className="md:hidden">Stock</span>
|
||||
</button>
|
||||
|
||||
{/* Selector de Orden */}
|
||||
<div className="relative hidden lg:block">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => onSelectSortBy(e.target.value)}
|
||||
className="bg-slate-950 text-slate-200 border border-slate-800 hover:border-slate-700 rounded-xl px-3 py-1.5 text-xs font-bold outline-hidden cursor-pointer"
|
||||
>
|
||||
{sortOptions.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Botón Abrir Drawer de Filtros Avanzados */}
|
||||
<button
|
||||
onClick={() => setIsMobileDrawerOpen(true)}
|
||||
className={`flex items-center gap-1.5 text-xs font-bold px-3.5 py-1.5 rounded-xl border transition-all cursor-pointer select-none ${
|
||||
activeFiltersCount > 0
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white border-white/20 shadow-md`
|
||||
: 'bg-slate-950 text-slate-300 border-slate-800 hover:text-white hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal className="w-3.5 h-3.5" />
|
||||
<span>Filtros</span>
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className="w-4 h-4 rounded-full bg-white text-slate-950 text-[10px] font-black flex items-center justify-center ml-0.5">
|
||||
{activeFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Filtros Desplegados en Pantalla Grande (Desktop Strip) */}
|
||||
<div className="hidden sm:grid sm:grid-cols-4 gap-2.5 bg-slate-900/40 p-2 rounded-2xl border border-slate-800/60">
|
||||
{/* Marca */}
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold text-slate-400 mb-1 px-1">Marca</label>
|
||||
<select
|
||||
value={selectedBrand}
|
||||
onChange={(e) => onSelectBrand(e.target.value)}
|
||||
className="w-full bg-slate-950 text-slate-200 border border-slate-800 rounded-xl px-3 py-1.5 text-xs font-medium outline-hidden cursor-pointer"
|
||||
>
|
||||
<option value="all">⚡ Todas las Marcas</option>
|
||||
{brands.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Rango de Precio */}
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold text-slate-400 mb-1 px-1">Presupuesto</label>
|
||||
<select
|
||||
value={selectedPriceRange}
|
||||
onChange={(e) => onSelectPriceRange(e.target.value)}
|
||||
className="w-full bg-slate-950 text-slate-200 border border-slate-800 rounded-xl px-3 py-1.5 text-xs font-medium outline-hidden cursor-pointer"
|
||||
>
|
||||
{priceRanges.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Talla Específica */}
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold text-slate-400 mb-1 px-1">Talla</label>
|
||||
<select
|
||||
value={selectedSize}
|
||||
onChange={(e) => onSelectSize(e.target.value)}
|
||||
className="w-full bg-slate-950 text-slate-200 border border-slate-800 rounded-xl px-3 py-1.5 text-xs font-mono font-bold outline-hidden cursor-pointer"
|
||||
>
|
||||
<option value="all">👟 Todas las Tallas</option>
|
||||
{sizes.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
Talla {s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Ordenar */}
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold text-slate-400 mb-1 px-1">Ordenar por</label>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => onSelectSortBy(e.target.value)}
|
||||
className="w-full bg-slate-950 text-slate-200 border border-slate-800 rounded-xl px-3 py-1.5 text-xs font-medium outline-hidden cursor-pointer"
|
||||
>
|
||||
{sortOptions.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Chips de Filtros Activos y Limpiar Todo */}
|
||||
{activeFiltersCount > 0 && (
|
||||
<div className="flex items-center justify-between gap-2 pt-0.5 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[11px] text-slate-400">Filtrando por:</span>
|
||||
{selectedBrand !== 'all' && (
|
||||
<button
|
||||
onClick={() => onSelectBrand('all')}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-bold px-2.5 py-0.5 rounded-full bg-purple-950/80 text-purple-300 border border-purple-500/40 hover:bg-purple-900 cursor-pointer transition-colors"
|
||||
>
|
||||
<span>Marca: {selectedBrand}</span>
|
||||
<X className="w-3 h-3 text-purple-400" />
|
||||
</button>
|
||||
)}
|
||||
{selectedSize !== 'all' && (
|
||||
<button
|
||||
onClick={() => onSelectSize('all')}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-bold px-2.5 py-0.5 rounded-full bg-purple-950/80 text-purple-300 border border-purple-500/40 hover:bg-purple-900 cursor-pointer transition-colors"
|
||||
>
|
||||
<span>Talla {selectedSize}</span>
|
||||
<X className="w-3 h-3 text-purple-400" />
|
||||
</button>
|
||||
)}
|
||||
{selectedPriceRange !== 'all' && (
|
||||
<button
|
||||
onClick={() => onSelectPriceRange('all')}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-bold px-2.5 py-0.5 rounded-full bg-purple-950/80 text-purple-300 border border-purple-500/40 hover:bg-purple-900 cursor-pointer transition-colors"
|
||||
>
|
||||
<span>
|
||||
{priceRanges.find((p) => p.id === selectedPriceRange)?.label || selectedPriceRange}
|
||||
</span>
|
||||
<X className="w-3 h-3 text-purple-400" />
|
||||
</button>
|
||||
)}
|
||||
{onlyInStock && (
|
||||
<button
|
||||
onClick={() => onToggleInStock(false)}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-bold px-2.5 py-0.5 rounded-full bg-emerald-950/80 text-emerald-300 border border-emerald-500/40 hover:bg-emerald-900 cursor-pointer transition-colors"
|
||||
>
|
||||
<span>Solo con Stock</span>
|
||||
<X className="w-3 h-3 text-emerald-400" />
|
||||
</button>
|
||||
)}
|
||||
{sortBy !== 'newest' && (
|
||||
<button
|
||||
onClick={() => onSelectSortBy('newest')}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-bold px-2.5 py-0.5 rounded-full bg-blue-950/80 text-blue-300 border border-blue-500/40 hover:bg-blue-900 cursor-pointer transition-colors"
|
||||
>
|
||||
<span>{sortOptions.find((s) => s.id === sortBy)?.label}</span>
|
||||
<X className="w-3 h-3 text-blue-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onResetFilters}
|
||||
className="text-xs text-purple-400 hover:text-purple-300 font-bold underline cursor-pointer"
|
||||
>
|
||||
Limpiar filtros
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 4. Drawer / Modal Móvil de Filtros */}
|
||||
{isMobileDrawerOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/80 backdrop-blur-sm animate-in fade-in">
|
||||
<div className="bg-slate-900 border border-slate-800 w-full sm:max-w-md rounded-t-3xl sm:rounded-3xl p-5 space-y-5 max-h-[90vh] overflow-y-auto shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-5 h-5 text-purple-400" />
|
||||
<h3 className="text-base font-black text-white">Filtrar Catálogo</h3>
|
||||
<span className="text-xs text-slate-400">({totalProductsCount} zapatillas)</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsMobileDrawerOpen(false)}
|
||||
className="text-slate-400 hover:text-white p-1 rounded-xl bg-slate-800"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Marcas */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black uppercase tracking-wider text-slate-300">Marca</label>
|
||||
<div className="flex gap-2 flex-wrap max-h-32 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => onSelectBrand('all')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl border ${
|
||||
selectedBrand === 'all'
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white border-transparent`
|
||||
: 'bg-slate-950 text-slate-400 border-slate-800'
|
||||
}`}
|
||||
>
|
||||
Todas
|
||||
</button>
|
||||
{brands.map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => onSelectBrand(b)}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl border ${
|
||||
selectedBrand === b
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white border-transparent`
|
||||
: 'bg-slate-950 text-slate-400 border-slate-800'
|
||||
}`}
|
||||
>
|
||||
{b}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tallas */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black uppercase tracking-wider text-slate-300">Talla</label>
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
<button
|
||||
onClick={() => onSelectSize('all')}
|
||||
className={`col-span-2 text-xs font-bold py-2 rounded-xl border text-center ${
|
||||
selectedSize === 'all'
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white border-transparent`
|
||||
: 'bg-slate-950 text-slate-400 border-slate-800'
|
||||
}`}
|
||||
>
|
||||
Todas
|
||||
</button>
|
||||
{sizes.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onSelectSize(selectedSize === s ? 'all' : s)}
|
||||
className={`text-xs font-mono font-bold py-2 rounded-xl border text-center ${
|
||||
selectedSize === s
|
||||
? `bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white border-transparent font-black`
|
||||
: 'bg-slate-950 text-slate-300 border-slate-800'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Presupuesto */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black uppercase tracking-wider text-slate-300">Rango de Precio</label>
|
||||
<div className="space-y-1.5">
|
||||
{priceRanges.map((pr) => (
|
||||
<button
|
||||
key={pr.id}
|
||||
onClick={() => onSelectPriceRange(pr.id)}
|
||||
className={`w-full text-left text-xs font-bold px-3.5 py-2 rounded-xl border flex items-center justify-between ${
|
||||
selectedPriceRange === pr.id
|
||||
? `bg-purple-950/80 border-purple-500 text-purple-200`
|
||||
: 'bg-slate-950 text-slate-400 border-slate-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span>{pr.label}</span>
|
||||
{selectedPriceRange === pr.id && <Check className="w-4 h-4 text-purple-400" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ordenamiento */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black uppercase tracking-wider text-slate-300">Ordenar Por</label>
|
||||
<div className="space-y-1.5">
|
||||
{sortOptions.map((so) => (
|
||||
<button
|
||||
key={so.id}
|
||||
onClick={() => onSelectSortBy(so.id)}
|
||||
className={`w-full text-left text-xs font-bold px-3.5 py-2 rounded-xl border flex items-center justify-between ${
|
||||
sortBy === so.id
|
||||
? 'bg-purple-950/80 border-purple-500 text-purple-200'
|
||||
: 'bg-slate-950 text-slate-400 border-slate-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span>{so.label}</span>
|
||||
{sortBy === so.id && <Check className="w-4 h-4 text-purple-400" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Solo en stock */}
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={() => onToggleInStock(!onlyInStock)}
|
||||
className={`w-full text-xs font-bold p-3 rounded-2xl border flex items-center justify-between ${
|
||||
onlyInStock
|
||||
? 'bg-emerald-950/80 border-emerald-500/50 text-emerald-300'
|
||||
: 'bg-slate-950 border-slate-800 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${onlyInStock ? 'bg-emerald-400 animate-pulse' : 'bg-slate-600'}`} />
|
||||
<span>Solo con inventario disponible en bodega</span>
|
||||
</div>
|
||||
<div
|
||||
className={`w-10 h-5 rounded-full p-0.5 transition-colors ${
|
||||
onlyInStock ? 'bg-emerald-500' : 'bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
onlyInStock ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Acciones de Footer */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
onResetFilters();
|
||||
setIsMobileDrawerOpen(false);
|
||||
}}
|
||||
className="flex-1 bg-slate-950 text-slate-400 hover:text-white font-bold text-xs py-3 rounded-2xl border border-slate-800"
|
||||
>
|
||||
Limpiar Todo
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsMobileDrawerOpen(false)}
|
||||
className={`flex-1 bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-black text-xs py-3 rounded-2xl shadow-lg`}
|
||||
>
|
||||
Ver ({totalProductsCount}) Resultados
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -320,6 +320,11 @@ export const DEFAULT_STORE_SETTINGS: StoreSettings = {
|
||||
defaultDepositAmount: 10000,
|
||||
autoPublishSupplierPosts: true,
|
||||
whatsappCatalogAutoSync: true,
|
||||
autoDisableOlderThanDays: 7,
|
||||
lastAutoDisableRunAt: '',
|
||||
lastAutoDisableProcessedCount: 0,
|
||||
lastAutoDisableCutoffDate: '',
|
||||
autoDisableEnabled: true,
|
||||
};
|
||||
|
||||
class DataService {
|
||||
@ -438,6 +443,7 @@ class DataService {
|
||||
},
|
||||
];
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const formattedProduct: Product = {
|
||||
...product,
|
||||
id: product.id || `snk-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
@ -449,6 +455,9 @@ class DataService {
|
||||
reviewsCount: product.reviewsCount || proof.reviewsCount,
|
||||
salesCount: product.salesCount || proof.salesCount,
|
||||
originBadge: product.originBadge || proof.badge,
|
||||
createdAt: product.createdAt || nowIso,
|
||||
publishedAt: product.publishedAt || product.createdAt || nowIso,
|
||||
manuallyEnabled: product.manuallyEnabled || false,
|
||||
};
|
||||
|
||||
this.products.unshift(formattedProduct);
|
||||
|
||||
214
dripzone-app/src/lib/expiration-service.ts
Normal file
214
dripzone-app/src/lib/expiration-service.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import { Product, StoreSettings } from '@/types';
|
||||
import { dataService } from './data-service';
|
||||
import { saveProductToAppwrite } from './server-appwrite';
|
||||
import { registerProductInWhatsAppCatalog } from './whatsapp-catalog';
|
||||
|
||||
/**
|
||||
* Determina la fecha exacta de publicación de una zapatilla.
|
||||
* Prioriza:
|
||||
* 1. publishedAt
|
||||
* 2. createdAt
|
||||
* 3. Timestamp extraído del ID 'snk-<timestamp>-...'
|
||||
* 4. Fallback al momento actual
|
||||
*/
|
||||
export function getProductPublicationDate(product: Product): Date {
|
||||
if (product.publishedAt) {
|
||||
const d = new Date(product.publishedAt);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
if (product.createdAt) {
|
||||
const d = new Date(product.createdAt);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
if (product.id && typeof product.id === 'string' && product.id.startsWith('snk-')) {
|
||||
const parts = product.id.split('-');
|
||||
if (parts[1]) {
|
||||
const ts = parseInt(parts[1], 10);
|
||||
if (!isNaN(ts) && ts > 1000000000000 && ts < 2500000000000) {
|
||||
return new Date(ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula la antigüedad en días de una publicación de calzado
|
||||
*/
|
||||
export function getProductAgeInDays(product: Product): number {
|
||||
const pubDate = getProductPublicationDate(product);
|
||||
const diffMs = Date.now() - pubDate.getTime();
|
||||
return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si una publicación supera el umbral de días establecido (por defecto 7 días)
|
||||
*/
|
||||
export function isProductOlderThanDays(product: Product, daysThreshold: number = 7): boolean {
|
||||
return getProductAgeInDays(product) >= daysThreshold;
|
||||
}
|
||||
|
||||
export interface ExpirationExecutionResult {
|
||||
success: boolean;
|
||||
disabledCount: number;
|
||||
totalChecked: number;
|
||||
totalActive: number;
|
||||
totalDisabledTotal: number;
|
||||
cutoffDate: string;
|
||||
runAt: string;
|
||||
daysThreshold: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa el catálogo de zapatillas y deshabilita automáticamente aquellas publicaciones
|
||||
* con más de 7 días de antigüedad, respetando aquellas que los administradores hayan
|
||||
* re-habilitado manualmente por tener stock o vigencia confirmada.
|
||||
*/
|
||||
export async function processProductExpiration(options?: {
|
||||
daysCutoff?: number;
|
||||
force?: boolean;
|
||||
}): Promise<ExpirationExecutionResult> {
|
||||
const settings = dataService.getSettings();
|
||||
const daysThreshold = options?.daysCutoff ?? settings.autoDisableOlderThanDays ?? 7;
|
||||
const now = new Date();
|
||||
const cutoffTime = now.getTime() - daysThreshold * 24 * 60 * 60 * 1000;
|
||||
const cutoffDateIso = new Date(cutoffTime).toISOString();
|
||||
const runAtIso = now.toISOString();
|
||||
|
||||
// Obtener todos los productos (incluyendo inactivos para control)
|
||||
const allProducts = dataService.getProducts();
|
||||
let disabledCount = 0;
|
||||
let totalChecked = 0;
|
||||
|
||||
for (const product of allProducts) {
|
||||
totalChecked++;
|
||||
const pubDate = getProductPublicationDate(product);
|
||||
const isPastCutoff = pubDate.getTime() < cutoffTime;
|
||||
|
||||
// Solo evaluar productos que están actualmente activos
|
||||
if (product.isActive !== false) {
|
||||
if (isPastCutoff) {
|
||||
// Si el administrador ya lo re-habilitó manualmente por tener stock o estar vigente, NO se deshabilita
|
||||
if (product.manuallyEnabled === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deshabilitar automáticamente por superar el umbral de 7 días
|
||||
const updated = dataService.updateProduct(product.id, {
|
||||
isActive: false,
|
||||
autoDisabledAt: runAtIso,
|
||||
inStock: product.inStock !== false, // Conserva el estado de stock
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
disabledCount++;
|
||||
// Sincronizar en segundo plano con Appwrite y catálogo de Meta / WhatsApp
|
||||
saveProductToAppwrite(updated).catch(() => {});
|
||||
registerProductInWhatsAppCatalog(updated).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Guardar metadata de control en la configuración de la tienda
|
||||
const updatedSettings: Partial<StoreSettings> = {
|
||||
lastAutoDisableRunAt: runAtIso,
|
||||
lastAutoDisableCutoffDate: cutoffDateIso,
|
||||
lastAutoDisableProcessedCount: disabledCount,
|
||||
autoDisableOlderThanDays: daysThreshold,
|
||||
};
|
||||
|
||||
dataService.updateSettings(updatedSettings);
|
||||
|
||||
const finalProducts = dataService.getProducts();
|
||||
const totalActive = finalProducts.filter((p) => p.isActive !== false).length;
|
||||
const totalDisabledTotal = finalProducts.filter((p) => p.isActive === false).length;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
disabledCount,
|
||||
totalChecked,
|
||||
totalActive,
|
||||
totalDisabledTotal,
|
||||
cutoffDate: cutoffDateIso,
|
||||
runAt: runAtIso,
|
||||
daysThreshold,
|
||||
message:
|
||||
disabledCount > 0
|
||||
? `Se deshabilitaron automáticamente ${disabledCount} publicaciones con más de ${daysThreshold} días de antigüedad.`
|
||||
: `Verificación completada: no habían publicaciones pendientes de deshabilitar con más de ${daysThreshold} días.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-habilita una zapatilla a consideración del administrador
|
||||
* (por ejemplo, porque aún tienen stock o la publicación continúa vigente).
|
||||
* Marca 'manuallyEnabled: true' para protegerla de futuras limpiezas automáticas.
|
||||
*/
|
||||
export async function reenableProductWithStock(productId: string): Promise<Product | null> {
|
||||
const updated = dataService.updateProduct(productId, {
|
||||
isActive: true,
|
||||
inStock: true,
|
||||
manuallyEnabled: true,
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
saveProductToAppwrite(updated).catch(() => {});
|
||||
registerProductInWhatsAppCatalog(updated).catch(() => {});
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumen consolidado del estado de vencimiento del catálogo para el panel administrativo
|
||||
*/
|
||||
export function getExpirationStatusSummary() {
|
||||
const settings = dataService.getSettings();
|
||||
const products = dataService.getProducts();
|
||||
const daysThreshold = settings.autoDisableOlderThanDays || 7;
|
||||
const now = Date.now();
|
||||
const cutoffTime = now - daysThreshold * 24 * 60 * 60 * 1000;
|
||||
|
||||
let activeCount = 0;
|
||||
let inactiveCount = 0;
|
||||
let expiredOlderThan7Days = 0;
|
||||
let manuallyKeptActive = 0;
|
||||
let recentCount = 0;
|
||||
|
||||
for (const p of products) {
|
||||
const pubDate = getProductPublicationDate(p);
|
||||
const isOld = pubDate.getTime() < cutoffTime;
|
||||
|
||||
if (p.isActive !== false) {
|
||||
activeCount++;
|
||||
if (p.manuallyEnabled) {
|
||||
manuallyKeptActive++;
|
||||
} else if (isOld) {
|
||||
// En caso de que aún no se haya corrido el proceso
|
||||
} else {
|
||||
recentCount++;
|
||||
}
|
||||
} else {
|
||||
inactiveCount++;
|
||||
if (isOld || p.autoDisabledAt) {
|
||||
expiredOlderThan7Days++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
daysThreshold,
|
||||
autoDisableEnabled: settings.autoDisableEnabled !== false,
|
||||
lastRunAt: settings.lastAutoDisableRunAt || null,
|
||||
lastCutoffDate: settings.lastAutoDisableCutoffDate || null,
|
||||
lastProcessedCount: settings.lastAutoDisableProcessedCount || 0,
|
||||
totalProducts: products.length,
|
||||
activeCount,
|
||||
inactiveCount,
|
||||
expiredOlderThan7Days,
|
||||
manuallyKeptActive,
|
||||
recentCount,
|
||||
};
|
||||
}
|
||||
@ -553,6 +553,10 @@ export async function getProductsFromAppwrite(): Promise<Product[]> {
|
||||
supplierName: d.supplierName || '',
|
||||
supplierPhone: d.supplierPhone || '',
|
||||
whatsappMessageId: d.whatsappMessageId || '',
|
||||
createdAt: d.createdAt || d.$createdAt || new Date().toISOString(),
|
||||
publishedAt: d.publishedAt || d.createdAt || d.$createdAt || new Date().toISOString(),
|
||||
autoDisabledAt: d.autoDisabledAt || undefined,
|
||||
manuallyEnabled: d.manuallyEnabled === true || d.manuallyEnabled === 'true' || false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -81,6 +81,11 @@ export interface Product {
|
||||
whatsappMessageId?: string;
|
||||
whatsappCatalogSynced?: boolean;
|
||||
whatsappCatalogSyncAt?: string;
|
||||
|
||||
createdAt?: string;
|
||||
publishedAt?: string;
|
||||
autoDisabledAt?: string;
|
||||
manuallyEnabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@ -221,6 +226,11 @@ export interface StoreSettings {
|
||||
metaCatalogId?: string;
|
||||
metaAccessToken?: string;
|
||||
whatsappCatalogAutoSync?: boolean;
|
||||
autoDisableOlderThanDays?: number;
|
||||
lastAutoDisableRunAt?: string;
|
||||
lastAutoDisableProcessedCount?: number;
|
||||
lastAutoDisableCutoffDate?: string;
|
||||
autoDisableEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface MonitoredWhatsAppGroup {
|
||||
|
||||
128
scripts/deploy_and_send_filters_apk.py
Normal file
128
scripts/deploy_and_send_filters_apk.py
Normal file
@ -0,0 +1,128 @@
|
||||
import paramiko
|
||||
import os
|
||||
import urllib.request
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import io
|
||||
|
||||
if sys.platform == 'win32':
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
HOST = "161.97.93.156"
|
||||
USER = "carlos"
|
||||
PASSWORD = "Innova1978+*?"
|
||||
LOCAL_APK = r"c:\Users\Carlos\dev\almeira-web\dripzone-app\public\DripZone.apk"
|
||||
REMOTE_DIR = "/home/carlos/apps/dripzone-app/public"
|
||||
REMOTE_APK = "/home/carlos/apps/dripzone-app/public/DripZone.apk"
|
||||
|
||||
EVOLUTION_API_URL = "https://evolution.almeira.com.co"
|
||||
EVOLUTION_API_KEY = "EE49029EEB85-4568-8F81-C9FDFC6E2943"
|
||||
INSTANCE_NAME = "AutomataAlmeira"
|
||||
TARGET_PHONE = "573167455877"
|
||||
APK_URL = "https://dripzone.almeira.com.co/DripZone.apk"
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
def upload_apk():
|
||||
print(f"[1/3] Conectando a VPS {HOST} vía SSH/SFTP...")
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30)
|
||||
|
||||
sftp = client.open_sftp()
|
||||
local_size_mb = os.path.getsize(LOCAL_APK) / (1024 * 1024)
|
||||
print(f" -> Subiendo {LOCAL_APK} ({local_size_mb:.2f} MB) a {REMOTE_APK}...")
|
||||
try:
|
||||
sftp.mkdir("/home/carlos/apps/dripzone-app")
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
sftp.mkdir(REMOTE_DIR)
|
||||
except:
|
||||
pass
|
||||
sftp.put(LOCAL_APK, REMOTE_APK)
|
||||
sftp.close()
|
||||
print(" [OK] Archivo subido por SFTP al VPS.")
|
||||
|
||||
print("[2/3] Identificando contenedores de DripZone y copiando APK...")
|
||||
stdin, stdout, stderr = client.exec_command("docker ps --format '{{.Names}}'")
|
||||
containers = [c.strip() for c in stdout.read().decode('utf-8').splitlines() if c.strip()]
|
||||
print(" Contenedores activos:", containers)
|
||||
|
||||
drip_containers = [c for c in containers if 'drip' in c.lower() or 'bfmno' in c.lower() or 'app' in c.lower()]
|
||||
for c in drip_containers:
|
||||
cmd = f"docker cp {REMOTE_APK} {c}:/app/public/DripZone.apk 2>/dev/null || docker cp {REMOTE_APK} {c}:/app/DripZone.apk 2>/dev/null || true"
|
||||
client.exec_command(cmd)
|
||||
print(f" -> Copiado a contenedor: {c}")
|
||||
|
||||
client.close()
|
||||
print(" [OK] APK actualizado en el servidor de producción.")
|
||||
|
||||
def send_whatsapp_notification():
|
||||
print("[3/3] Enviando APK y notificación oficial al WhatsApp de Carlos (+57 316 745 5877)...")
|
||||
|
||||
message_text = (
|
||||
"👟 *DripZone - Sistema de Filtros & Control de Vencimiento de 7 Días*\n\n"
|
||||
"¡Hola Carlos! Se ha implementado con éxito la nueva arquitectura de filtros de calzado y el ciclo de vida automático de publicaciones:\n\n"
|
||||
"🎯 *1. Filtros Especializados para el Usuario Final (Tienda):*\n"
|
||||
"• Barra de selección rápida horizontal por tallas colombianas (35 a 44) y botón 'Todas'.\n"
|
||||
"• Filtro por Marcas destacadas (Nike, Adidas, Jordan, New Balance, Puma, etc.).\n"
|
||||
"• Rangos de precios segmentados (<$100k, $100k-$140k, $140k-$180k, >$180k).\n"
|
||||
"• Switch para mostrar únicamente 'Solo con Stock disponible'.\n"
|
||||
"• Ordenamiento por: Más recientes, Menor precio, Mayor precio, Más populares y Mayor descuento.\n"
|
||||
"• Modal/Drawer lateral responsivo para celulares y chips interactivos con eliminación de filtros activos.\n\n"
|
||||
"⚙️ *2. Filtros Avanzados para Administradores (/admin/productos):*\n"
|
||||
"• Pestañas de estado: Todas, Activas, Vigentes (<7 días), Vencidas (+7 días), Con Stock Protegido y Ocultas.\n"
|
||||
"• Filtros por Proveedor mayorista, Marca, Talla, Stock y Ordenamiento (Vigencia, Margen, Costo, Precio).\n"
|
||||
"• Visualización exclusiva interna de costos, márgenes de ganancia y trazabilidad de proveedores.\n\n"
|
||||
"⏳ *3. Deshabilitación Automática por Defecto (>7 Días):*\n"
|
||||
"• Las publicaciones con más de 7 días se desactivan automáticamente (`isActive = false`).\n"
|
||||
"• Registro de control de la última fecha y hora procesada (`lastAutoDisableRunAt`) y lote de productos evaluados.\n"
|
||||
"• Ejecución en segundo plano programada y botón manual interactivo en el panel: `⚡ Ejecutar Verificación de 7 Días Ahora`.\n\n"
|
||||
"🛡️ *4. Reactivación Administrativa con Stock Vigente:*\n"
|
||||
"• Nueva columna 'Vigencia / Publicación' que indica los días transcurridos desde el posteo.\n"
|
||||
"• Los administradores pueden reactivar cualquier zapatilla anterior a 7 días con un solo clic: botón `✅ Habilitar (Con Stock)`.\n"
|
||||
"• Al reactivarse, queda blindada (`manuallyEnabled = true`) para que los procesos automáticos no la vuelvan a ocultar.\n\n"
|
||||
"📱 *5. APK Android Compilado & Actualizado:*\n"
|
||||
"• APK compilado limpiamente y optimizado (35.5 MB).\n"
|
||||
f"🔗 *Descarga Directa:* {APK_URL}\n\n"
|
||||
"Te adjunto el archivo APK instalador a continuación."
|
||||
)
|
||||
|
||||
req_text = urllib.request.Request(
|
||||
f"{EVOLUTION_API_URL}/message/sendText/{INSTANCE_NAME}",
|
||||
data=json.dumps({"number": TARGET_PHONE, "text": message_text, "delay": 500}).encode("utf-8"),
|
||||
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req_text, context=ctx, timeout=30) as resp:
|
||||
print(" [OK] Mensaje de texto enviado a WhatsApp (Status:", resp.status, ")")
|
||||
except Exception as e:
|
||||
print(" [Error texto WhatsApp]:", e)
|
||||
|
||||
# Enviar documento APK
|
||||
req_media = urllib.request.Request(
|
||||
f"{EVOLUTION_API_URL}/message/sendMedia/{INSTANCE_NAME}",
|
||||
data=json.dumps({
|
||||
"number": TARGET_PHONE,
|
||||
"mediatype": "document",
|
||||
"mimetype": "application/vnd.android.package-archive",
|
||||
"fileName": "DripZone_Filtros_Vencimiento.apk",
|
||||
"caption": "📲 *DripZone APK Actualizado - Filtros & Control de Vencimiento 7 Días*",
|
||||
"media": APK_URL
|
||||
}).encode("utf-8"),
|
||||
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req_media, context=ctx, timeout=120) as resp:
|
||||
print(" [OK] Documento APK enviado a WhatsApp (Status:", resp.status, ")")
|
||||
except Exception as e:
|
||||
print(" [Error envío documento APK]:", e)
|
||||
|
||||
if __name__ == "__main__":
|
||||
upload_apk()
|
||||
send_whatsapp_notification()
|
||||
Loading…
Reference in New Issue
Block a user