feat: filtros avanzados por rol y deshabilitacion automatica de 7 dias con reactivacion

This commit is contained in:
AI Bot 2026-09-12 08:47:17 -05:00
parent 7d051d2d83
commit 02bac37af0
10 changed files with 1723 additions and 163 deletions

View File

@ -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 &lt; 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">

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View 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()