fix(admin): resolve node:net error by decoupling date utils & brand detection from server libraries and add client webpack fallbacks

This commit is contained in:
AI Bot 2026-09-20 14:31:28 -05:00
parent 1bba620689
commit 7028a5b397
6 changed files with 362 additions and 51 deletions

View File

@ -2,6 +2,23 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["node-appwrite", "@google/genai"],
turbopack: {},
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve = config.resolve || {};
config.resolve.fallback = {
...(config.resolve.fallback || {}),
net: false,
tls: false,
fs: false,
child_process: false,
dns: false,
http2: false,
};
}
return config;
},
images: {
remotePatterns: [
{ protocol: "https", hostname: "**" },

View File

@ -4,23 +4,52 @@ 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, 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';
import { GroupProductsModal } from '@/components/admin/GroupProductsModal';
import { ManageVariantsModal } from '@/components/admin/ManageVariantsModal';
import { renderClientTemplate, initClientTemplates } from '@/lib/templates-client';
import { OptimizedImage } from '@/components/ui/OptimizedImage';
import { sanitizePublicationTitle, sanitizePublicationText } from '@/lib/sanitizer';
import { cleanProductName, detectBrandFromText } from '@/lib/gemini-parser';
import { cleanProductName, detectBrandFromText } from '@/lib/brand-detection';
import { generateAttractiveProductCopy } from '@/lib/attractive-copy';
import { WhatsAppOrderLinkModal } from '@/components/admin/WhatsAppOrderLinkModal';
import { getProductPublicationDate, getProductAgeInDays, isProductOlderThanDays } from '@/lib/expiration-service';
import { getProductPublicationDate, getProductAgeInDays, isProductOlderThanDays } from '@/lib/product-date-utils';
import { FormattedDescription } from '@/components/store/FormattedDescription';
import { getCategoryIcon, normalizeCategoryName, matchCategory } from '@/lib/category-helper';
const CLIENT_INITIAL_SUPPLIERS: Supplier[] = [
{
id: "sup-jovic",
name: "💎 2️⃣ DIFUSIÓN JOVIC STORE COLOMBIA🍀",
phone: "573167455877",
contactPerson: "Asesor Jovic Store",
address: "Centro / Bodega Cali",
notes: "Proveedor oficial Nike, Jordan, Nocta y Vomero calidades Top.",
isActive: true
},
{
id: "sup-ccmio",
name: "EXCLUSIVE CC MÍO 3B",
phone: "573159876543",
contactPerson: "Despachos CC MÍO 3B",
address: "CC MÍO Local 3B, Cali",
notes: "Especialista en Adidas, Coach, Timberland y Skatewear.",
isActive: true
},
{
id: "sup-central",
name: "BODEGA CENTRAL MAYORISTA",
phone: "573101234567",
contactPerson: "Logística Despachos",
address: "San Andresito / Cali",
notes: "Despacho rápido en 24 horas para separación inmediata.",
isActive: true
}
];
export default function AdminProductsPage() {
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
const [suppliers, setSuppliers] = useState<Supplier[]>(INITIAL_SUPPLIERS);
const [products, setProducts] = useState<Product[]>([]);
const [suppliers, setSuppliers] = useState<Supplier[]>(CLIENT_INITIAL_SUPPLIERS);
const [search, setSearch] = useState('');
// Filtros Avanzados para Administradores

View File

@ -0,0 +1,255 @@
import { sanitizePublicationTitle } from './sanitizer';
export interface BrandSignature {
brand: string;
aliases: RegExp[];
models: { name: string; pattern: RegExp }[];
}
export const BRAND_SIGNATURES: BrandSignature[] = [
{
brand: 'Adidas',
aliases: [/\badidas\b/i, /\btrefoil\b/i, /\btr[eé]bol\b/i, /3\s*rayas|tres\s*rayas/i],
models: [
{ name: 'Adidas Samba OG', pattern: /\bsamba\b/i },
{ name: 'Adidas Gazelle Indoor', pattern: /\bgazelle\b/i },
{ name: 'Adidas Campus 00s', pattern: /\bcampus\b/i },
{ name: 'Adidas Handball Spezial', pattern: /\bspezial\b/i },
{ name: 'Adidas Forum Low', pattern: /\bforum\b/i },
{ name: 'Adidas Bad Bunny', pattern: /\bbad\s*bunny\b/i },
{ name: 'Adidas Stan Smith', pattern: /\bstan\s*smith\b/i },
{ name: 'Adidas Superstar', pattern: /\bsuperstar\b/i },
{ name: 'Adidas SL 72', pattern: /\bsl\s*72\b|\bsl72\b/i },
{ name: 'Adidas Response CL', pattern: /\bresponse\s*cl\b/i },
{ name: 'Adidas Yeezy Boost 350', pattern: /\byeezy\b|\b350\s*v2\b|\bfoam\s*runner\b/i },
{ name: 'Adidas Astir', pattern: /\bastir\b/i },
{ name: 'Adidas Ozelia', pattern: /\bozelia\b/i },
{ name: 'Adidas Adizero', pattern: /\badizero\b/i },
{ name: 'Adidas Country OG', pattern: /\bcountry\s*og\b/i },
{ name: 'Adidas NMD R1', pattern: /\bnmd\b/i },
{ name: 'Adidas Ultraboost', pattern: /\bultra\s*boost\b|\bultraboost\b/i },
],
},
{
brand: 'Jordan',
aliases: [/\bjordan\b/i, /\bjumpman\b/i, /\bair\s*jordan\b/i, /\baj\d+\b/i],
models: [
{ name: 'Jordan 4 Retro', pattern: /\b(?:retro\s*4|aj4|jordan\s*4|retro\s*iv)\b/i },
{ name: 'Jordan 1 Retro High OG', pattern: /\b(?:retro\s*1|aj1|jordan\s*1|retro\s*i)\b/i },
{ name: 'Jordan 3 Retro', pattern: /\b(?:retro\s*3|aj3|jordan\s*3|retro\s*iii)\b/i },
{ name: 'Jordan 11 Retro', pattern: /\b(?:retro\s*11|aj11|jordan\s*11|retro\s*xi)\b/i },
{ name: 'Jordan 1 Low Travis Scott', pattern: /\btravis\s*scott\b|\bcactus\s*jack\b/i },
{ name: 'Jordan Spizike Low', pattern: /\bspizike\b/i },
{ name: 'Jordan 5 Retro', pattern: /\b(?:retro\s*5|aj5|jordan\s*5)\b/i },
{ name: 'Jordan 6 Retro', pattern: /\b(?:retro\s*6|aj6|jordan\s*6)\b/i },
],
},
{
brand: 'New Balance',
aliases: [/\bnew\s*balance\b/i, /\bnb\b/i],
models: [
{ name: 'New Balance 9060', pattern: /\b9060\b/i },
{ name: 'New Balance 550', pattern: /\b550\b/i },
{ name: 'New Balance 530', pattern: /\b530\b/i },
{ name: 'New Balance 1906R', pattern: /\b1906\b|\b1906r\b|\b1906d\b/i },
{ name: 'New Balance 2002R', pattern: /\b2002\b|\b2002r\b|\bprotection\s*pack\b/i },
{ name: 'New Balance 327', pattern: /\b327\b/i },
{ name: 'New Balance 574', pattern: /\b574\b/i },
{ name: 'New Balance 990v6', pattern: /\b990\b|\b990v\d\b/i },
{ name: 'New Balance 991', pattern: /\b991\b/i },
{ name: 'New Balance 992', pattern: /\b992\b/i },
{ name: 'New Balance 993', pattern: /\b993\b/i },
{ name: 'New Balance 610', pattern: /\b610\b/i },
{ name: 'New Balance CT302', pattern: /\bct302\b/i },
],
},
{
brand: 'On Running',
aliases: [/\bon\s*running\b/i, /\bon\s*cloud\b/i, /\bcloud\b/i, /\bclould\b/i],
models: [
{ name: 'On Cloudmonster', pattern: /\bcloudmonster\b|\bcloud\s*monster\b/i },
{ name: 'On Cloudtilt Loewe', pattern: /\bcloudtilt\b|\bcloud\s*tilt\b|\bloewe\b/i },
{ name: 'On Cloud 5', pattern: /\bcloud\s*5\b/i },
{ name: 'On Cloud X3', pattern: /\bcloud\s*x\b|\bcloud\s*x3\b/i },
{ name: 'On Cloudsurfer', pattern: /\bcloudsurfer\b|\bcloud\s*surfer\b/i },
{ name: 'On Cloudstratus', pattern: /\bcloudstratus\b|\bcloud\s*stratus\b/i },
{ name: 'On Cloudrunner', pattern: /\bcloudrunner\b|\bcloud\s*runner\b/i },
{ name: 'On The Roger Centre Court', pattern: /\broger\b|\bthe\s*roger\b/i },
],
},
{
brand: 'Puma',
aliases: [/\bpuma\b/i],
models: [
{ name: 'Puma Palermo Special', pattern: /\bpalermo\b/i },
{ name: 'Puma Speedcat OG', pattern: /\bspeedcat\b|\bspeed\s*cat\b/i },
{ name: 'Puma Suede Classic', pattern: /\bsuede\b/i },
{ name: 'Puma Caven 2.0', pattern: /\bcaven\b/i },
{ name: 'Puma Slipstream', pattern: /\bslipstream\b/i },
{ name: 'Puma Mayze Wedge', pattern: /\bmayze\b/i },
{ name: 'Puma Easy Rider', pattern: /\beasy\s*rider\b/i },
{ name: 'Puma RS-X Efekt', pattern: /\brs-x\b|\brsx\b/i },
{ name: 'Puma CA Pro Classic', pattern: /\bca\s*pro\b/i },
],
},
{
brand: 'Asics',
aliases: [/\basics\b/i],
models: [
{ name: 'Asics Gel-Kayano 14', pattern: /\bgel[\s-]*kayano\b|\bkayano\b/i },
{ name: 'Asics Gel-NYC', pattern: /\bgel[\s-]*nyc\b|\bnyc\b/i },
{ name: 'Asics Gel-1130', pattern: /\bgel[\s-]*1130\b|\b1130\b/i },
{ name: 'Asics GT-2160', pattern: /\bgt[\s-]*2160\b|\b2160\b/i },
{ name: 'Asics Gel-Quantum 360', pattern: /\bgel[\s-]*quantum\b|\bquantum\b/i },
{ name: 'Asics Gel-Nimbus 9', pattern: /\bgel[\s-]*nimbus\b|\bnimbus\b/i },
{ name: 'Asics Gel-Lyte III', pattern: /\bgel[\s-]*lyte\b/i },
{ name: 'Asics Novablast 4', pattern: /\bnovablast\b/i },
],
},
{
brand: 'Nike',
aliases: [/\bnike\b/i, /\bswoosh\b/i],
models: [
{ name: 'Nike Air Force 1 07', pattern: /\bair\s*force\b|\baf1\b|\baf-1\b|\bforce\s*1\b/i },
{ name: 'Nike Dunk Low Retro Panda', pattern: /\bdunk\b|\bpanda\b|\bsb\s*dunk\b/i },
{ name: 'Nike Air Max 90', pattern: /\bair\s*max\s*90\b|\bam90\b|\bmax\s*90\b/i },
{ name: 'Nike Air Max 97', pattern: /\bair\s*max\s*97\b|\bam97\b|\bmax\s*97\b/i },
{ name: 'Nike Air Max 270', pattern: /\bair\s*max\s*270\b|\bmax\s*270\b/i },
{ name: 'Nike Air Max Plus TN', pattern: /\btn\b|\bair\s*max\s*plus\b|\btuned\b/i },
{ name: 'Nike Vomero 5', pattern: /\bvomero\b|\bvomero\s*5\b/i },
{ name: 'Nike P-6000 Retro', pattern: /\bp-6000\b|\bp6000\b/i },
{ name: 'Nike Shox TL', pattern: /\bshox\b|\bshox\s*tl\b|\br4\b/i },
{ name: 'Nike Cortez Classic', pattern: /\bcortez\b/i },
{ name: 'Nike Blazer Mid 77', pattern: /\bblazer\b/i },
{ name: 'Nike TC 7900 Chunky', pattern: /\btc\s*7900\b|\btc7900\b/i },
{ name: 'Nike Gamma Force', pattern: /\bgamma\s*force\b/i },
{ name: 'Nike Air VaporMax Plus', pattern: /\bvapormax\b|\bvapor\s*max\b/i },
{ name: 'Nike Pegasus 40', pattern: /\bpegasus\b/i },
{ name: 'Nike Killshot 2', pattern: /\bkillshot\b/i },
],
},
{
brand: 'Vans',
aliases: [/\bvans\b/i, /\boff\s*the\s*wall\b/i],
models: [
{ name: 'Vans Knu Skool Chunky', pattern: /\bknu\s*skool\b|\bknuskool\b|\bknu\b/i },
{ name: 'Vans Old Skool Classic', pattern: /\bold\s*skool\b|\boldskool\b/i },
],
},
{
brand: 'Converse',
aliases: [/\bconverse\b/i, /\ball\s*star\b/i],
models: [
{ name: 'Converse Chuck 70 Vintage', pattern: /\bchuck\s*70\b|\bchuck70\b/i },
{ name: 'Converse Chuck Taylor All Star', pattern: /\bchuck\s*taylor\b|\ball\s*star\b/i },
],
},
{
brand: 'Salomon',
aliases: [/\bsalomon\b/i],
models: [
{ name: 'Salomon XT-6 Gore-Tex', pattern: /\bxt-6\b|\bxt6\b/i },
{ name: 'Salomon XT-4 OG', pattern: /\bxt-4\b|\bxt4\b/i },
],
},
{
brand: 'Crocs',
aliases: [/\bcrocs\b/i],
models: [
{ name: 'Crocs Pollex Clog Salehe Bembury', pattern: /\bpollex\b|\bsalehe\b/i },
{ name: 'Crocs Echo Clog', pattern: /\becho\s*clog\b|\becho\b/i },
],
},
];
/**
* Detector determinístico y de alta precisión de marca y modelo desde texto (100% seguro para cliente y servidor).
*/
export function detectBrandFromText(text: string): {
brand: string;
modelName?: string;
confidence: number;
} {
const normalized = (text || '').toLowerCase();
for (const item of BRAND_SIGNATURES) {
for (const model of item.models) {
if (model.pattern.test(normalized)) {
return {
brand: item.brand,
modelName: model.name,
confidence: 0.96,
};
}
}
}
for (const item of BRAND_SIGNATURES) {
for (const alias of item.aliases) {
if (alias.test(normalized)) {
return {
brand: item.brand,
confidence: 0.88,
};
}
}
}
return {
brand: 'Urbano / Variado',
confidence: 0.4,
};
}
/**
* Limpia y formatea el nombre del producto garantizando que NUNCA se incluyan nombres
* de proveedores, bodegas, canales o grupos de WhatsApp.
*/
export function cleanProductName(
text: string,
detectedBrand: string,
extraForbidden: (string | undefined | null)[] = []
): string {
const lines = (text || '').split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
for (const line of lines) {
const lower = line.toLowerCase();
if (
lower.includes('difusion jovic') ||
lower.includes('jovic') ||
lower.includes('cc mío') ||
lower.includes('cc mio') ||
lower.includes('bodega central') ||
lower.includes('bodega mayorista') ||
lower.includes('proveedor') ||
lower.includes('canal mayorista') ||
lower.includes('canal de difusión') ||
lower.includes('canal de difusion') ||
lower.includes('grupo mayorista') ||
lower.includes('grupo whatsapp') ||
lower.includes('team emporio') ||
lower.includes('notificaciones') ||
lower.includes('medios y prensa') ||
lower.includes('calzado mayorista') ||
lower.includes('san andresito')
) {
continue;
}
const clean = line
.replace(/🔥|🔴|‼️|✨|🌸|👑|💎|🛍️|🌟|💸|💰|💵|💲|\*/g, '')
.replace(/dama\s*y\s*caballero|caballero|dama|unisex|recarga|nueva\s*mercanc[ií]a|[uú]ltima\s*unidad/gi, '')
.replace(/c[oó]digo\s*:\s*[\d-]+/gi, '')
.replace(/\b\d{2,3}[\.,]\d{3}\b|\b\d{2,3}k\b|\$\s*\d+/gi, '')
.replace(/tallas?:.*|precio:.*|eur\s*[\d\/-]+/gi, '')
.replace(/\s+/g, ' ')
.trim();
const sanitized = sanitizePublicationTitle(clean, extraForbidden, detectedBrand);
if (sanitized.length >= 3 && !/^\d+$/.test(sanitized) && !/^\d+[\.,]\d+$/.test(sanitized)) {
return sanitized;
}
}
const defaultGeneric = `${detectedBrand} Urban Edition`;
return sanitizePublicationTitle(defaultGeneric, extraForbidden, detectedBrand);
}

View File

@ -0,0 +1 @@
module.exports = {};

View File

@ -3,53 +3,17 @@ 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) return new 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();
}
import {
getProductPublicationDate,
getProductAgeInDays,
isProductOlderThanDays,
} from './product-date-utils';
/**
* Calcula la antigüedad en días de una publicación de calzado
*/
export function getProductAgeInDays(product: Product): number {
if (!product) return 0;
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 {
if (!product) return false;
return getProductAgeInDays(product) >= daysThreshold;
}
export {
getProductPublicationDate,
getProductAgeInDays,
isProductOlderThanDays,
};
export interface ExpirationExecutionResult {
success: boolean;

View File

@ -0,0 +1,45 @@
import { Product } from '@/types';
/**
* Determina la fecha exacta de publicación de una zapatilla de forma 100% segura para el cliente (navegador).
* No importa módulos de Node.js ni bases de datos de servidor.
*/
export function getProductPublicationDate(product: Product): Date {
if (!product) return new 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 {
if (!product) return 0;
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 {
if (!product) return false;
return getProductAgeInDays(product) >= daysThreshold;
}