From 93d0a590a74f371db3af34a9783b3f39d7ab7a5b Mon Sep 17 00:00:00 2001 From: AI Bot Date: Fri, 28 Aug 2026 23:06:43 -0500 Subject: [PATCH] feat: extraccion de alta precision de precios/tallas, textos atractivos y agrupacion automatica de modelos --- .../src/app/api/whatsapp-feed/route.ts | 23 +- almeira-store-app/src/lib/attractive-copy.ts | 37 ++++ almeira-store-app/src/lib/data-service.ts | 92 +++++++- almeira-store-app/src/lib/gemini-parser.ts | 205 ++++++++++++++---- almeira-store-app/src/lib/model-grouper.ts | 116 ++++++++++ .../src/app/api/whatsapp-feed/route.ts | 23 +- dripzone-app/src/lib/attractive-copy.ts | 37 ++++ dripzone-app/src/lib/data-service.ts | 92 +++++++- dripzone-app/src/lib/gemini-parser.ts | 205 ++++++++++++++---- dripzone-app/src/lib/model-grouper.ts | 116 ++++++++++ sneaky-app/src/app/api/whatsapp-feed/route.ts | 23 +- sneaky-app/src/lib/attractive-copy.ts | 37 ++++ sneaky-app/src/lib/data-service.ts | 92 +++++++- sneaky-app/src/lib/gemini-parser.ts | 205 ++++++++++++++---- sneaky-app/src/lib/model-grouper.ts | 116 ++++++++++ zapply-app/src/app/api/whatsapp-feed/route.ts | 23 +- zapply-app/src/lib/attractive-copy.ts | 37 ++++ zapply-app/src/lib/data-service.ts | 92 +++++++- zapply-app/src/lib/gemini-parser.ts | 205 ++++++++++++++---- zapply-app/src/lib/model-grouper.ts | 116 ++++++++++ 20 files changed, 1640 insertions(+), 252 deletions(-) create mode 100644 almeira-store-app/src/lib/attractive-copy.ts create mode 100644 almeira-store-app/src/lib/model-grouper.ts create mode 100644 dripzone-app/src/lib/attractive-copy.ts create mode 100644 dripzone-app/src/lib/model-grouper.ts create mode 100644 sneaky-app/src/lib/attractive-copy.ts create mode 100644 sneaky-app/src/lib/model-grouper.ts create mode 100644 zapply-app/src/lib/attractive-copy.ts create mode 100644 zapply-app/src/lib/model-grouper.ts diff --git a/almeira-store-app/src/app/api/whatsapp-feed/route.ts b/almeira-store-app/src/app/api/whatsapp-feed/route.ts index 9b5cb57..35f438a 100644 --- a/almeira-store-app/src/app/api/whatsapp-feed/route.ts +++ b/almeira-store-app/src/app/api/whatsapp-feed/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { dataService } from '@/lib/data-service'; import { parseSupplierMessage } from '@/lib/gemini-parser'; import { getProductSocialProof } from '@/lib/social-proof'; +import { generateAttractiveProductCopy } from '@/lib/attractive-copy'; import { SupplierPost, MonitoredWhatsAppGroup } from '@/types'; const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; @@ -218,13 +219,14 @@ export async function POST(request: Request) { const newProduct = { id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, name: prodName, + modelName: prodName, brand: prodBrand, - supplierPrice: post.extractedProduct?.supplierPrice || 90000, + supplierPrice: post.extractedProduct?.supplierPrice || 75000, retailPrice: retailPrice, originalPrice: Math.round(retailPrice * 1.35), sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], images: Array.isArray(post.rawImages) && post.rawImages.length > 0 ? post.rawImages : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], - description: `Calzado garantizado de alta calidad para despacho inmediato en 24 horas y pago contraentrega.`, + description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }), category: post.extractedProduct?.category || 'Calzado / Sneakers', inStock: true, isActive: true, @@ -236,13 +238,13 @@ export async function POST(request: Request) { supplierPhone: post.supplierPhone || '573167455877', }; - dataService.addProduct(newProduct); + const added = dataService.addProduct(newProduct as any); dataService.updateSupplierPostStatus(post.id, 'published'); return NextResponse.json({ success: true, - message: '¡Producto publicado exitosamente en la App Móvil y Web!', - product: newProduct, + message: `¡${prodName} publicado y agrupado en el catálogo!`, + product: added, }); } @@ -261,13 +263,14 @@ export async function POST(request: Request) { const newProd = { id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, name: prodName, + modelName: prodName, brand: prodBrand, - supplierPrice: p.extractedProduct?.supplierPrice || 90000, + supplierPrice: p.extractedProduct?.supplierPrice || 75000, retailPrice, originalPrice: Math.round(retailPrice * 1.35), sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], images: p.rawImages || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], - description: `Calzado garantizado para despacho inmediato en 24 horas con pago contraentrega.`, + description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }), category: p.extractedProduct?.category || 'Calzado / Sneakers', inStock: true, isActive: true, @@ -279,14 +282,14 @@ export async function POST(request: Request) { supplierPhone: p.supplierPhone || '573167455877', }; - dataService.addProduct(newProd); + const added = dataService.addProduct(newProd as any); dataService.updateSupplierPostStatus(p.id, 'published'); - createdProducts.push(newProd); + createdProducts.push(added); } return NextResponse.json({ success: true, - message: `¡Se publicaron ${createdProducts.length} productos en la App Móvil y Web!`, + message: `¡Se publicaron y agruparon ${createdProducts.length} productos en la App Móvil y Web!`, products: createdProducts, }); } diff --git a/almeira-store-app/src/lib/attractive-copy.ts b/almeira-store-app/src/lib/attractive-copy.ts new file mode 100644 index 0000000..846c655 --- /dev/null +++ b/almeira-store-app/src/lib/attractive-copy.ts @@ -0,0 +1,37 @@ +/** + * Generador de Textos Comerciales Persuasivos y Atractivos para Calzado Deportivo + * Produce descripciones llamativas con llamados a la acción (CTA), beneficios de calidad G5/1.1, + * garantía de entrega y estética street-wear colombiana para maximizar conversiones. + */ + +export function generateAttractiveProductCopy(product: { + name: string; + brand: string; + category?: string; + supplierPrice?: number; + retailPrice?: number; +}): string { + const brand = product.brand || 'Sneakers'; + const name = product.name || 'Zapatillas Exclusivas'; + + const hooks = [ + `🔥 ¡Lleva tu outfit al siguiente nivel con las icónicas ${name}! Diseñadas para quienes no negocian entre estilo de alto impacto y máxima comodidad para el día a día.`, + `⚡ Las ${name} son el drop definitivo que tu colección estaba esperando. Silueta imbatible, acabados prémium y una estética urbana que no pasa desapercibida.`, + `💎 Edición Prémium ${brand}: ${name}. Fabricadas con materiales de máxima durabilidad, textura prémium y una combinación de colores pensada para resaltar en cualquier ocasión.`, + `🚀 Dale un upgrade a tus pasos con las exclusivas ${name}. Comodidad extrema desde el primer uso, amortiguación reactiva y estilo 100% garantizado.`, + ]; + + const seed = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + const selectedHook = hooks[Math.abs(seed) % hooks.length]; + + return `${selectedHook} + +✨ *¿Por qué todos las eligen?* +• 💎 *Calidad G5 / 1.1 Prémium:* Materiales de alto rendimiento, costuras reforzadas y terminaciones impecables. +• ☁️ *Confort Inigualable:* Plantilla acolchada ergonómica para máxima suavidad al caminar o entrenar. +• 🚚 *Despacho Prioritario Nacional:* Despachamos en 24 horas hábiles a cualquier ciudad de Colombia. +• 💵 *Pago 100% Contraentrega:* Realizas tu pedido con total tranquilidad y pagas al recibir en tu puerta. +• 🛡️ *Garantía Directa:* Revisas tu producto y verificas tu talla con total respaldo. + +⚠️ *Disponibilidad Limitada:* Pocas unidades disponibles por talla en bodega nacional. ¡Asegura tu par ahora antes de que se agoten!`; +} diff --git a/almeira-store-app/src/lib/data-service.ts b/almeira-store-app/src/lib/data-service.ts index 91edef1..ff67b19 100644 --- a/almeira-store-app/src/lib/data-service.ts +++ b/almeira-store-app/src/lib/data-service.ts @@ -1,4 +1,7 @@ -import { Product, City, PaymentMethod, Order, SupplierPost, StoreSettings, Supplier, MonitoredWhatsAppGroup, User } from '@/types'; +import { Product, City, PaymentMethod, Order, SupplierPost, StoreSettings, Supplier, MonitoredWhatsAppGroup, User, ProductColorVariant } from '@/types'; +import { normalizeModelKey, groupProductsByModel } from './model-grouper'; +import { generateAttractiveProductCopy } from './attractive-copy'; +import { getProductSocialProof } from './social-proof'; export const INITIAL_SUPPLIERS: Supplier[] = [ { @@ -191,16 +194,95 @@ class DataService { private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS]; getProducts(): Product[] { - return this.products; + return groupProductsByModel(this.products); } getProductById(id: string): Product | undefined { - return this.products.find((p) => p.id === id); + const allGrouped = groupProductsByModel(this.products); + return allGrouped.find((p) => p.id === id || p.colors?.some((c) => c.id === id)); } addProduct(product: Product): Product { - this.products.unshift(product); - return product; + const brand = product.brand || 'Sneakers'; + const baseModelName = product.modelName || product.name; + const modelKey = normalizeModelKey(baseModelName, brand); + + // Buscar si ya existe un producto del mismo modelo + const existing = this.products.find( + (p) => normalizeModelKey(p.modelName || p.name, p.brand) === modelKey + ); + + if (existing) { + const existingColors: ProductColorVariant[] = + Array.isArray(existing.colors) && existing.colors.length > 0 + ? existing.colors + : [ + { + id: `color-${existing.id}-1`, + colorName: existing.colorName || 'Estilo #1', + image: existing.images[0], + images: existing.images, + sizes: existing.sizes, + inStock: existing.inStock !== false, + }, + ]; + + const newImage = product.images[0] || (product.colors && product.colors[0]?.image); + const isDuplicate = existingColors.some( + (c) => c.image === newImage || (product.colorName && c.colorName.toLowerCase() === product.colorName.toLowerCase()) + ); + + if (!isDuplicate && newImage) { + existingColors.push({ + id: `color-${Date.now()}-${existingColors.length + 1}`, + colorName: product.colorName || `Estilo #${existingColors.length + 1}`, + image: newImage, + images: product.images.length > 0 ? product.images : [newImage], + sizes: product.sizes && product.sizes.length > 0 ? product.sizes : existing.sizes, + inStock: product.inStock !== false, + }); + } + + existing.colors = existingColors; + existing.images = Array.from(new Set([...existing.images, ...product.images])); + existing.sizes = Array.from(new Set([...existing.sizes, ...(product.sizes || [])])).sort( + (a, b) => parseInt(a, 10) - parseInt(b, 10) + ); + if (!existing.description || existing.description.length < 50) { + existing.description = generateAttractiveProductCopy(existing); + } + return existing; + } + + // Si es un modelo nuevo + const proof = getProductSocialProof(product); + const initialColors: ProductColorVariant[] = + Array.isArray(product.colors) && product.colors.length > 0 + ? product.colors + : [ + { + id: `color-${Date.now()}-1`, + colorName: product.colorName || 'Estilo Principal', + image: product.images[0], + images: product.images, + sizes: product.sizes || ['37', '38', '39', '40', '41', '42', '43'], + inStock: product.inStock !== false, + }, + ]; + + const formattedProduct: Product = { + ...product, + modelName: baseModelName, + colors: initialColors, + description: product.description || generateAttractiveProductCopy(product), + rating: product.rating || proof.rating, + reviewsCount: product.reviewsCount || proof.reviewsCount, + salesCount: product.salesCount || proof.salesCount, + originBadge: product.originBadge || proof.badge, + }; + + this.products.unshift(formattedProduct); + return formattedProduct; } updateProduct(id: string, updates: Partial): Product | null { diff --git a/almeira-store-app/src/lib/gemini-parser.ts b/almeira-store-app/src/lib/gemini-parser.ts index 9277fcd..9d8d008 100644 --- a/almeira-store-app/src/lib/gemini-parser.ts +++ b/almeira-store-app/src/lib/gemini-parser.ts @@ -2,52 +2,151 @@ import { GoogleGenAI } from '@google/genai'; import { ExtractedProductData } from '@/types'; import { calculateRetailPrice } from './pricing-engine'; import { getProductSocialProof } from './social-proof'; +import { generateAttractiveProductCopy } from './attractive-copy'; + +/** + * Extractor robusto de precios mayoristas en COP desde mensajes de WhatsApp + * Soporta emojis de dinero (💸, 💰, 💵, 💲), palabras clave, formatos con punto (75.000), 'k' (75k) y 'mil' (75 mil). + */ +export function extractSupplierPrice(text: string): number { + // 1. Buscar patrones con símbolos o emojis de dinero y palabras clave + const explicitPattern = + /(?:💸|💰|💵|💲|precio|valor|costo|mayorista|mayor|mayoristas|\$)+[\s:=]*\$?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}\s*k|\d{2,3}\s*mil|\d{5,6})/i; + const explicitMatch = text.match(explicitPattern); + if (explicitMatch) { + const raw = explicitMatch[1].toLowerCase().replace(/\s+/g, '').replace(/\./g, ''); + if (raw.endsWith('k') || raw.endsWith('mil')) { + const num = parseInt(raw.replace('mil', '').replace('k', ''), 10); + if (!isNaN(num) && num > 0) return num < 1000 ? num * 1000 : num; + } + const num = parseInt(raw, 10); + if (!isNaN(num)) { + if (num >= 40 && num <= 350) return num * 1000; + if (num >= 40000 && num <= 500000) return num; + } + } + + // 2. Buscar números con formato de miles (ej: 75.000, 80.000, 100.000, 125.500) + const dotThousands = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\.(000|500)\b/); + if (dotThousands) { + const num = parseInt(dotThousands[0].replace(/\./g, ''), 10); + if (!isNaN(num) && num >= 40000 && num <= 500000) return num; + } + + // 3. Buscar valores con 'k' o 'mil' (ej: 75k, 80 mil, 95k) + const kMatch = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\s*(?:k|mil)\b/i); + if (kMatch) { + const num = parseInt(kMatch[1], 10); + if (!isNaN(num)) return num * 1000; + } + + // 4. Buscar números de 5 o 6 dígitos en rango de precio razonable (40000 a 300000) + const rawNumbers = text.match(/\b(4[5-9]\d{3}|[5-9]\d{4}|1\d{5}|2\d{5})\b/g); + if (rawNumbers && rawNumbers.length > 0) { + const num = parseInt(rawNumbers[0], 10); + if (!isNaN(num)) return num; + } + + return 75000; // Valor por defecto representativo para calzado nacional +} + +/** + * Extractor de tallas con normalización Unicode (soporta small-caps como ᴛᴀʟʟᴀs ᴅɪsᴘᴏɴɪʙʟᴇs:36/41/44) + */ +export function extractSizes(rawText: string): string[] { + const normalized = rawText + .normalize('NFKD') + .replace(/[ᴛ]/g, 't') + .replace(/[ᴀ]/g, 'a') + .replace(/[ʟ]/g, 'l') + .replace(/[s]/g, 's') + .replace(/[ᴅ]/g, 'd') + .replace(/[ɪ]/g, 'i') + .replace(/[ᴘ]/g, 'p') + .replace(/[ᴏ]/g, 'o') + .replace(/[ɴ]/g, 'n') + .replace(/[ʙ]/g, 'b') + .replace(/[ᴇ]/g, 'e'); + + const sizes: string[] = []; + + // Rango explícito (ej: 36 a 43, 36 al 43, 38-43) + const rangeMatch = normalized.match( + /(?:tallas?|eur|talla|disponibles?)?[^\d\n]*\b(3[4-9]|4[0-6])\s*(?:a|al|-)\s*(3[4-9]|4[0-6])\b/i + ); + if (rangeMatch) { + const start = parseInt(rangeMatch[1], 10); + const end = parseInt(rangeMatch[2], 10); + if (start <= end && start >= 34 && end <= 46) { + for (let s = start; s <= end; s++) sizes.push(s.toString()); + return sizes; + } + } + + // Sección de tallas o texto completo + const tallaSection = normalized.match(/(?:tallas?|eur|disponibles?|talla)[^\n]*/i); + const textToScan = tallaSection ? tallaSection[0] : normalized; + + // Extraer números individuales de 2 dígitos entre 34 y 46 + const matches = textToScan.match(/(?:^|[^\d])(3[4-9]|4[0-6])(?=[^\d]|$)/g); + if (matches) { + matches.forEach((m) => { + const num = m.replace(/[^\d]/g, ''); + if (num && !sizes.includes(num)) { + sizes.push(num); + } + }); + } + + if (sizes.length === 0) { + return ['37', '38', '39', '40', '41', '42', '43']; + } + + return sizes.sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); +} + +/** + * Limpia y formatea el nombre del producto desde el mensaje del proveedor + */ +export function cleanProductName(text: string, detectedBrand: string): string { + const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0); + for (const line of lines) { + 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(); + + if (clean.length >= 3 && !/^\d+$/.test(clean) && !/^\d+[\.,]\d+$/.test(clean)) { + return clean + .split(' ') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(' '); + } + } + return `${detectedBrand} Urban Edition`; +} export async function parseSupplierMessage(text: string): Promise { const apiKey = process.env.GEMINI_API_KEY; const fallbackExtract = (): ExtractedProductData => { - let price = 90000; - const priceMatch = text.match(/(?:precio|valor|mayorista|costo|\$)\s*[:=]?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}k?)/i); - if (priceMatch) { - const raw = priceMatch[1].toLowerCase().replace(/\./g, ''); - if (raw.endsWith('k')) { - price = parseInt(raw.replace('k', ''), 10) * 1000; - } else { - const num = parseInt(raw, 10); - price = num < 1000 ? num * 1000 : num; - } - } - - const sizes: string[] = []; - const sizeRangeMatch = text.match(/(\d{2})\s*(?:a|al|-)\s*(\d{2})/i); - if (sizeRangeMatch) { - const start = parseInt(sizeRangeMatch[1], 10); - const end = parseInt(sizeRangeMatch[2], 10); - if (start >= 34 && end <= 46 && start <= end) { - for (let s = start; s <= end; s++) sizes.push(s.toString()); - } - } else { - const individualSizes = text.match(/\b(3[4-9]|4[0-6])\b/g); - if (individualSizes) { - sizes.push(...Array.from(new Set(individualSizes))); - } - } - if (sizes.length === 0) { - sizes.push('37', '38', '39', '40', '41', '42', '43'); - } + const price = extractSupplierPrice(text); + const sizes = extractSizes(text); let brand = 'Nike'; - if (/jordan/i.test(text)) brand = 'Jordan'; - else if (/adidas|bad bunny|campus|forum/i.test(text)) brand = 'Adidas'; - else if (/new balance|nb/i.test(text)) brand = 'New Balance'; - else if (/puma/i.test(text)) brand = 'Puma'; - else if (/asics/i.test(text)) brand = 'Asics'; - else if (/vans/i.test(text)) brand = 'Vans'; + if (/adidas|samba|campus|bad bunny|forum|gazelle|stan smith/i.test(text)) brand = 'Adidas'; + else if (/jordan|air jordan|aj1|aj4|aj3/i.test(text)) brand = 'Jordan'; + else if (/new balance|nb|9060|550|530|1906/i.test(text)) brand = 'New Balance'; + else if (/puma|palermo|suede|speedcat/i.test(text)) brand = 'Puma'; + else if (/asics|gel kayano|gel nyc/i.test(text)) brand = 'Asics'; + else if (/vans|old skool|knu skool/i.test(text)) brand = 'Vans'; + else if (/coach/i.test(text)) brand = 'Coach'; - const cleanLines = text.split('\n').filter((l) => l.trim().length > 0 && !/🔥|🔴|llegando|promo/i.test(l)); - const name = cleanLines[0] || `${brand} Sneakers Urban Edition`; - const cleanName = name.replace(/tallas?:.*|precio:.*|\$/gi, '').trim() || `${brand} Zapatillas`; + const cleanName = cleanProductName(text, brand); const proof = getProductSocialProof({ name: cleanName, brand }); return { @@ -57,7 +156,7 @@ export async function parseSupplierMessage(text: string): Promise 0 ? parsed.sizes.map(String) : ['37', '38', '39', '40', '41', '42', '43']; + const supplierPrice = + typeof parsed.supplierPrice === 'number' && parsed.supplierPrice >= 40000 + ? parsed.supplierPrice + : extractSupplierPrice(text); - const cleanName = parsed.name || 'Zapatilla Urbana'; - const brand = parsed.brand || 'Nike'; + const sizes = + Array.isArray(parsed.sizes) && parsed.sizes.length > 0 + ? parsed.sizes.map(String) + : extractSizes(text); + + let brand = parsed.brand || 'Nike'; + if (/samba|campus|bad bunny|forum|gazelle/i.test(parsed.name || text)) brand = 'Adidas'; + if (/9060|550|1906/i.test(parsed.name || text)) brand = 'New Balance'; + + const cleanName = parsed.name || cleanProductName(text, brand); const proof = getProductSocialProof({ name: cleanName, brand }); return { @@ -117,7 +226,7 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto originBadge: proof.badge, }; } catch (err) { - console.warn('Fallo llamada Gemini, utilizando extractor regex fallback:', err); + console.warn('Fallo llamada Gemini, utilizando extractor regex de alta precisión:', err); return fallbackExtract(); } } diff --git a/almeira-store-app/src/lib/model-grouper.ts b/almeira-store-app/src/lib/model-grouper.ts new file mode 100644 index 0000000..54296e9 --- /dev/null +++ b/almeira-store-app/src/lib/model-grouper.ts @@ -0,0 +1,116 @@ +import { Product, ProductColorVariant } from '@/types'; +import { generateAttractiveProductCopy } from './attractive-copy'; +import { getProductSocialProof } from './social-proof'; + +/** + * Normaliza el nombre del modelo para identificar automáticamente productos del mismo modelo + * pero con diferente colorway (ej: "Adidas Samba", "Adidas Samba Blanco y Negro", "Adidas Samba Gum") + */ +export function normalizeModelKey(name: string, brand?: string): string { + let clean = (name || '').toLowerCase(); + + if (brand) { + const b = brand.toLowerCase(); + if (!clean.includes(b)) clean = `${b} ${clean}`; + } + + clean = clean + .replace( + /\b(blanco|negro|gris|azul|verde|rojo|rosa|beige|gum|cream|shadow|panda|retro|og|h|dama|caballero|unisex|ref|\d{3}-\d{3}|edition|edicion|clasico|clasica|sneakers|zapatillas|calzado|para|de|y|en|con)\b/gi, + '' + ) + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, '-') + .replace(/^-+|-+$/g, ''); + + return clean || 'sneakers'; +} + +/** + * Agrupa una lista de productos que corresponden al mismo modelo pero diferente color + * Convirtiéndolos en un único producto con múltiples colorways (ProductColorVariant). + */ +export function groupProductsByModel(products: Product[]): Product[] { + const modelMap = new Map(); + + for (const prod of products) { + const brand = prod.brand || 'Sneakers'; + const baseModelName = prod.modelName || prod.name; + const modelKey = normalizeModelKey(baseModelName, brand); + + if (!modelMap.has(modelKey)) { + // Inicializar el primer producto para este modelo + const colors: ProductColorVariant[] = + Array.isArray(prod.colors) && prod.colors.length > 0 + ? [...prod.colors] + : [ + { + id: `color-${Date.now()}-1`, + colorName: prod.colorName || 'Estilo Principal', + image: prod.images[0], + images: prod.images, + sizes: prod.sizes || ['37', '38', '39', '40', '41', '42', '43'], + inStock: prod.inStock !== false, + }, + ]; + + const proof = getProductSocialProof(prod); + + modelMap.set(modelKey, { + ...prod, + modelName: baseModelName, + colors, + description: prod.description || generateAttractiveProductCopy(prod), + rating: prod.rating || proof.rating, + reviewsCount: prod.reviewsCount || proof.reviewsCount, + salesCount: prod.salesCount || proof.salesCount, + }); + } else { + // El modelo ya existe: fusionar colorways, fotos y tallas + const existing = modelMap.get(modelKey)!; + const existingColors = existing.colors || []; + + // Extraer los colorways del nuevo producto + const incomingColors: ProductColorVariant[] = + Array.isArray(prod.colors) && prod.colors.length > 0 + ? prod.colors + : [ + { + id: `color-${Date.now()}-${existingColors.length + 1}`, + colorName: prod.colorName || `Estilo #${existingColors.length + 1}`, + image: prod.images[0], + images: prod.images, + sizes: prod.sizes || ['37', '38', '39', '40', '41', '42', '43'], + inStock: prod.inStock !== false, + }, + ]; + + // Agregar colorways que no estén ya presentes (por URL de imagen) + incomingColors.forEach((inc) => { + const isDuplicate = existingColors.some( + (ec) => ec.image === inc.image || ec.colorName.toLowerCase() === inc.colorName.toLowerCase() + ); + if (!isDuplicate) { + existingColors.push({ + ...inc, + id: inc.id || `color-${Date.now()}-${existingColors.length + 1}`, + }); + } + }); + + // Fusionar imágenes sin duplicados + const mergedImages = Array.from(new Set([...existing.images, ...prod.images])); + + // Fusionar tallas sin duplicados + const mergedSizes = Array.from(new Set([...existing.sizes, ...(prod.sizes || [])])).sort( + (a, b) => parseInt(a, 10) - parseInt(b, 10) + ); + + existing.colors = existingColors; + existing.images = mergedImages; + existing.sizes = mergedSizes; + } + } + + return Array.from(modelMap.values()); +} diff --git a/dripzone-app/src/app/api/whatsapp-feed/route.ts b/dripzone-app/src/app/api/whatsapp-feed/route.ts index 9b5cb57..35f438a 100644 --- a/dripzone-app/src/app/api/whatsapp-feed/route.ts +++ b/dripzone-app/src/app/api/whatsapp-feed/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { dataService } from '@/lib/data-service'; import { parseSupplierMessage } from '@/lib/gemini-parser'; import { getProductSocialProof } from '@/lib/social-proof'; +import { generateAttractiveProductCopy } from '@/lib/attractive-copy'; import { SupplierPost, MonitoredWhatsAppGroup } from '@/types'; const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; @@ -218,13 +219,14 @@ export async function POST(request: Request) { const newProduct = { id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, name: prodName, + modelName: prodName, brand: prodBrand, - supplierPrice: post.extractedProduct?.supplierPrice || 90000, + supplierPrice: post.extractedProduct?.supplierPrice || 75000, retailPrice: retailPrice, originalPrice: Math.round(retailPrice * 1.35), sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], images: Array.isArray(post.rawImages) && post.rawImages.length > 0 ? post.rawImages : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], - description: `Calzado garantizado de alta calidad para despacho inmediato en 24 horas y pago contraentrega.`, + description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }), category: post.extractedProduct?.category || 'Calzado / Sneakers', inStock: true, isActive: true, @@ -236,13 +238,13 @@ export async function POST(request: Request) { supplierPhone: post.supplierPhone || '573167455877', }; - dataService.addProduct(newProduct); + const added = dataService.addProduct(newProduct as any); dataService.updateSupplierPostStatus(post.id, 'published'); return NextResponse.json({ success: true, - message: '¡Producto publicado exitosamente en la App Móvil y Web!', - product: newProduct, + message: `¡${prodName} publicado y agrupado en el catálogo!`, + product: added, }); } @@ -261,13 +263,14 @@ export async function POST(request: Request) { const newProd = { id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, name: prodName, + modelName: prodName, brand: prodBrand, - supplierPrice: p.extractedProduct?.supplierPrice || 90000, + supplierPrice: p.extractedProduct?.supplierPrice || 75000, retailPrice, originalPrice: Math.round(retailPrice * 1.35), sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], images: p.rawImages || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], - description: `Calzado garantizado para despacho inmediato en 24 horas con pago contraentrega.`, + description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }), category: p.extractedProduct?.category || 'Calzado / Sneakers', inStock: true, isActive: true, @@ -279,14 +282,14 @@ export async function POST(request: Request) { supplierPhone: p.supplierPhone || '573167455877', }; - dataService.addProduct(newProd); + const added = dataService.addProduct(newProd as any); dataService.updateSupplierPostStatus(p.id, 'published'); - createdProducts.push(newProd); + createdProducts.push(added); } return NextResponse.json({ success: true, - message: `¡Se publicaron ${createdProducts.length} productos en la App Móvil y Web!`, + message: `¡Se publicaron y agruparon ${createdProducts.length} productos en la App Móvil y Web!`, products: createdProducts, }); } diff --git a/dripzone-app/src/lib/attractive-copy.ts b/dripzone-app/src/lib/attractive-copy.ts new file mode 100644 index 0000000..846c655 --- /dev/null +++ b/dripzone-app/src/lib/attractive-copy.ts @@ -0,0 +1,37 @@ +/** + * Generador de Textos Comerciales Persuasivos y Atractivos para Calzado Deportivo + * Produce descripciones llamativas con llamados a la acción (CTA), beneficios de calidad G5/1.1, + * garantía de entrega y estética street-wear colombiana para maximizar conversiones. + */ + +export function generateAttractiveProductCopy(product: { + name: string; + brand: string; + category?: string; + supplierPrice?: number; + retailPrice?: number; +}): string { + const brand = product.brand || 'Sneakers'; + const name = product.name || 'Zapatillas Exclusivas'; + + const hooks = [ + `🔥 ¡Lleva tu outfit al siguiente nivel con las icónicas ${name}! Diseñadas para quienes no negocian entre estilo de alto impacto y máxima comodidad para el día a día.`, + `⚡ Las ${name} son el drop definitivo que tu colección estaba esperando. Silueta imbatible, acabados prémium y una estética urbana que no pasa desapercibida.`, + `💎 Edición Prémium ${brand}: ${name}. Fabricadas con materiales de máxima durabilidad, textura prémium y una combinación de colores pensada para resaltar en cualquier ocasión.`, + `🚀 Dale un upgrade a tus pasos con las exclusivas ${name}. Comodidad extrema desde el primer uso, amortiguación reactiva y estilo 100% garantizado.`, + ]; + + const seed = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + const selectedHook = hooks[Math.abs(seed) % hooks.length]; + + return `${selectedHook} + +✨ *¿Por qué todos las eligen?* +• 💎 *Calidad G5 / 1.1 Prémium:* Materiales de alto rendimiento, costuras reforzadas y terminaciones impecables. +• ☁️ *Confort Inigualable:* Plantilla acolchada ergonómica para máxima suavidad al caminar o entrenar. +• 🚚 *Despacho Prioritario Nacional:* Despachamos en 24 horas hábiles a cualquier ciudad de Colombia. +• 💵 *Pago 100% Contraentrega:* Realizas tu pedido con total tranquilidad y pagas al recibir en tu puerta. +• 🛡️ *Garantía Directa:* Revisas tu producto y verificas tu talla con total respaldo. + +⚠️ *Disponibilidad Limitada:* Pocas unidades disponibles por talla en bodega nacional. ¡Asegura tu par ahora antes de que se agoten!`; +} diff --git a/dripzone-app/src/lib/data-service.ts b/dripzone-app/src/lib/data-service.ts index 91edef1..ff67b19 100644 --- a/dripzone-app/src/lib/data-service.ts +++ b/dripzone-app/src/lib/data-service.ts @@ -1,4 +1,7 @@ -import { Product, City, PaymentMethod, Order, SupplierPost, StoreSettings, Supplier, MonitoredWhatsAppGroup, User } from '@/types'; +import { Product, City, PaymentMethod, Order, SupplierPost, StoreSettings, Supplier, MonitoredWhatsAppGroup, User, ProductColorVariant } from '@/types'; +import { normalizeModelKey, groupProductsByModel } from './model-grouper'; +import { generateAttractiveProductCopy } from './attractive-copy'; +import { getProductSocialProof } from './social-proof'; export const INITIAL_SUPPLIERS: Supplier[] = [ { @@ -191,16 +194,95 @@ class DataService { private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS]; getProducts(): Product[] { - return this.products; + return groupProductsByModel(this.products); } getProductById(id: string): Product | undefined { - return this.products.find((p) => p.id === id); + const allGrouped = groupProductsByModel(this.products); + return allGrouped.find((p) => p.id === id || p.colors?.some((c) => c.id === id)); } addProduct(product: Product): Product { - this.products.unshift(product); - return product; + const brand = product.brand || 'Sneakers'; + const baseModelName = product.modelName || product.name; + const modelKey = normalizeModelKey(baseModelName, brand); + + // Buscar si ya existe un producto del mismo modelo + const existing = this.products.find( + (p) => normalizeModelKey(p.modelName || p.name, p.brand) === modelKey + ); + + if (existing) { + const existingColors: ProductColorVariant[] = + Array.isArray(existing.colors) && existing.colors.length > 0 + ? existing.colors + : [ + { + id: `color-${existing.id}-1`, + colorName: existing.colorName || 'Estilo #1', + image: existing.images[0], + images: existing.images, + sizes: existing.sizes, + inStock: existing.inStock !== false, + }, + ]; + + const newImage = product.images[0] || (product.colors && product.colors[0]?.image); + const isDuplicate = existingColors.some( + (c) => c.image === newImage || (product.colorName && c.colorName.toLowerCase() === product.colorName.toLowerCase()) + ); + + if (!isDuplicate && newImage) { + existingColors.push({ + id: `color-${Date.now()}-${existingColors.length + 1}`, + colorName: product.colorName || `Estilo #${existingColors.length + 1}`, + image: newImage, + images: product.images.length > 0 ? product.images : [newImage], + sizes: product.sizes && product.sizes.length > 0 ? product.sizes : existing.sizes, + inStock: product.inStock !== false, + }); + } + + existing.colors = existingColors; + existing.images = Array.from(new Set([...existing.images, ...product.images])); + existing.sizes = Array.from(new Set([...existing.sizes, ...(product.sizes || [])])).sort( + (a, b) => parseInt(a, 10) - parseInt(b, 10) + ); + if (!existing.description || existing.description.length < 50) { + existing.description = generateAttractiveProductCopy(existing); + } + return existing; + } + + // Si es un modelo nuevo + const proof = getProductSocialProof(product); + const initialColors: ProductColorVariant[] = + Array.isArray(product.colors) && product.colors.length > 0 + ? product.colors + : [ + { + id: `color-${Date.now()}-1`, + colorName: product.colorName || 'Estilo Principal', + image: product.images[0], + images: product.images, + sizes: product.sizes || ['37', '38', '39', '40', '41', '42', '43'], + inStock: product.inStock !== false, + }, + ]; + + const formattedProduct: Product = { + ...product, + modelName: baseModelName, + colors: initialColors, + description: product.description || generateAttractiveProductCopy(product), + rating: product.rating || proof.rating, + reviewsCount: product.reviewsCount || proof.reviewsCount, + salesCount: product.salesCount || proof.salesCount, + originBadge: product.originBadge || proof.badge, + }; + + this.products.unshift(formattedProduct); + return formattedProduct; } updateProduct(id: string, updates: Partial): Product | null { diff --git a/dripzone-app/src/lib/gemini-parser.ts b/dripzone-app/src/lib/gemini-parser.ts index 9277fcd..9d8d008 100644 --- a/dripzone-app/src/lib/gemini-parser.ts +++ b/dripzone-app/src/lib/gemini-parser.ts @@ -2,52 +2,151 @@ import { GoogleGenAI } from '@google/genai'; import { ExtractedProductData } from '@/types'; import { calculateRetailPrice } from './pricing-engine'; import { getProductSocialProof } from './social-proof'; +import { generateAttractiveProductCopy } from './attractive-copy'; + +/** + * Extractor robusto de precios mayoristas en COP desde mensajes de WhatsApp + * Soporta emojis de dinero (💸, 💰, 💵, 💲), palabras clave, formatos con punto (75.000), 'k' (75k) y 'mil' (75 mil). + */ +export function extractSupplierPrice(text: string): number { + // 1. Buscar patrones con símbolos o emojis de dinero y palabras clave + const explicitPattern = + /(?:💸|💰|💵|💲|precio|valor|costo|mayorista|mayor|mayoristas|\$)+[\s:=]*\$?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}\s*k|\d{2,3}\s*mil|\d{5,6})/i; + const explicitMatch = text.match(explicitPattern); + if (explicitMatch) { + const raw = explicitMatch[1].toLowerCase().replace(/\s+/g, '').replace(/\./g, ''); + if (raw.endsWith('k') || raw.endsWith('mil')) { + const num = parseInt(raw.replace('mil', '').replace('k', ''), 10); + if (!isNaN(num) && num > 0) return num < 1000 ? num * 1000 : num; + } + const num = parseInt(raw, 10); + if (!isNaN(num)) { + if (num >= 40 && num <= 350) return num * 1000; + if (num >= 40000 && num <= 500000) return num; + } + } + + // 2. Buscar números con formato de miles (ej: 75.000, 80.000, 100.000, 125.500) + const dotThousands = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\.(000|500)\b/); + if (dotThousands) { + const num = parseInt(dotThousands[0].replace(/\./g, ''), 10); + if (!isNaN(num) && num >= 40000 && num <= 500000) return num; + } + + // 3. Buscar valores con 'k' o 'mil' (ej: 75k, 80 mil, 95k) + const kMatch = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\s*(?:k|mil)\b/i); + if (kMatch) { + const num = parseInt(kMatch[1], 10); + if (!isNaN(num)) return num * 1000; + } + + // 4. Buscar números de 5 o 6 dígitos en rango de precio razonable (40000 a 300000) + const rawNumbers = text.match(/\b(4[5-9]\d{3}|[5-9]\d{4}|1\d{5}|2\d{5})\b/g); + if (rawNumbers && rawNumbers.length > 0) { + const num = parseInt(rawNumbers[0], 10); + if (!isNaN(num)) return num; + } + + return 75000; // Valor por defecto representativo para calzado nacional +} + +/** + * Extractor de tallas con normalización Unicode (soporta small-caps como ᴛᴀʟʟᴀs ᴅɪsᴘᴏɴɪʙʟᴇs:36/41/44) + */ +export function extractSizes(rawText: string): string[] { + const normalized = rawText + .normalize('NFKD') + .replace(/[ᴛ]/g, 't') + .replace(/[ᴀ]/g, 'a') + .replace(/[ʟ]/g, 'l') + .replace(/[s]/g, 's') + .replace(/[ᴅ]/g, 'd') + .replace(/[ɪ]/g, 'i') + .replace(/[ᴘ]/g, 'p') + .replace(/[ᴏ]/g, 'o') + .replace(/[ɴ]/g, 'n') + .replace(/[ʙ]/g, 'b') + .replace(/[ᴇ]/g, 'e'); + + const sizes: string[] = []; + + // Rango explícito (ej: 36 a 43, 36 al 43, 38-43) + const rangeMatch = normalized.match( + /(?:tallas?|eur|talla|disponibles?)?[^\d\n]*\b(3[4-9]|4[0-6])\s*(?:a|al|-)\s*(3[4-9]|4[0-6])\b/i + ); + if (rangeMatch) { + const start = parseInt(rangeMatch[1], 10); + const end = parseInt(rangeMatch[2], 10); + if (start <= end && start >= 34 && end <= 46) { + for (let s = start; s <= end; s++) sizes.push(s.toString()); + return sizes; + } + } + + // Sección de tallas o texto completo + const tallaSection = normalized.match(/(?:tallas?|eur|disponibles?|talla)[^\n]*/i); + const textToScan = tallaSection ? tallaSection[0] : normalized; + + // Extraer números individuales de 2 dígitos entre 34 y 46 + const matches = textToScan.match(/(?:^|[^\d])(3[4-9]|4[0-6])(?=[^\d]|$)/g); + if (matches) { + matches.forEach((m) => { + const num = m.replace(/[^\d]/g, ''); + if (num && !sizes.includes(num)) { + sizes.push(num); + } + }); + } + + if (sizes.length === 0) { + return ['37', '38', '39', '40', '41', '42', '43']; + } + + return sizes.sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); +} + +/** + * Limpia y formatea el nombre del producto desde el mensaje del proveedor + */ +export function cleanProductName(text: string, detectedBrand: string): string { + const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0); + for (const line of lines) { + 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(); + + if (clean.length >= 3 && !/^\d+$/.test(clean) && !/^\d+[\.,]\d+$/.test(clean)) { + return clean + .split(' ') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(' '); + } + } + return `${detectedBrand} Urban Edition`; +} export async function parseSupplierMessage(text: string): Promise { const apiKey = process.env.GEMINI_API_KEY; const fallbackExtract = (): ExtractedProductData => { - let price = 90000; - const priceMatch = text.match(/(?:precio|valor|mayorista|costo|\$)\s*[:=]?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}k?)/i); - if (priceMatch) { - const raw = priceMatch[1].toLowerCase().replace(/\./g, ''); - if (raw.endsWith('k')) { - price = parseInt(raw.replace('k', ''), 10) * 1000; - } else { - const num = parseInt(raw, 10); - price = num < 1000 ? num * 1000 : num; - } - } - - const sizes: string[] = []; - const sizeRangeMatch = text.match(/(\d{2})\s*(?:a|al|-)\s*(\d{2})/i); - if (sizeRangeMatch) { - const start = parseInt(sizeRangeMatch[1], 10); - const end = parseInt(sizeRangeMatch[2], 10); - if (start >= 34 && end <= 46 && start <= end) { - for (let s = start; s <= end; s++) sizes.push(s.toString()); - } - } else { - const individualSizes = text.match(/\b(3[4-9]|4[0-6])\b/g); - if (individualSizes) { - sizes.push(...Array.from(new Set(individualSizes))); - } - } - if (sizes.length === 0) { - sizes.push('37', '38', '39', '40', '41', '42', '43'); - } + const price = extractSupplierPrice(text); + const sizes = extractSizes(text); let brand = 'Nike'; - if (/jordan/i.test(text)) brand = 'Jordan'; - else if (/adidas|bad bunny|campus|forum/i.test(text)) brand = 'Adidas'; - else if (/new balance|nb/i.test(text)) brand = 'New Balance'; - else if (/puma/i.test(text)) brand = 'Puma'; - else if (/asics/i.test(text)) brand = 'Asics'; - else if (/vans/i.test(text)) brand = 'Vans'; + if (/adidas|samba|campus|bad bunny|forum|gazelle|stan smith/i.test(text)) brand = 'Adidas'; + else if (/jordan|air jordan|aj1|aj4|aj3/i.test(text)) brand = 'Jordan'; + else if (/new balance|nb|9060|550|530|1906/i.test(text)) brand = 'New Balance'; + else if (/puma|palermo|suede|speedcat/i.test(text)) brand = 'Puma'; + else if (/asics|gel kayano|gel nyc/i.test(text)) brand = 'Asics'; + else if (/vans|old skool|knu skool/i.test(text)) brand = 'Vans'; + else if (/coach/i.test(text)) brand = 'Coach'; - const cleanLines = text.split('\n').filter((l) => l.trim().length > 0 && !/🔥|🔴|llegando|promo/i.test(l)); - const name = cleanLines[0] || `${brand} Sneakers Urban Edition`; - const cleanName = name.replace(/tallas?:.*|precio:.*|\$/gi, '').trim() || `${brand} Zapatillas`; + const cleanName = cleanProductName(text, brand); const proof = getProductSocialProof({ name: cleanName, brand }); return { @@ -57,7 +156,7 @@ export async function parseSupplierMessage(text: string): Promise 0 ? parsed.sizes.map(String) : ['37', '38', '39', '40', '41', '42', '43']; + const supplierPrice = + typeof parsed.supplierPrice === 'number' && parsed.supplierPrice >= 40000 + ? parsed.supplierPrice + : extractSupplierPrice(text); - const cleanName = parsed.name || 'Zapatilla Urbana'; - const brand = parsed.brand || 'Nike'; + const sizes = + Array.isArray(parsed.sizes) && parsed.sizes.length > 0 + ? parsed.sizes.map(String) + : extractSizes(text); + + let brand = parsed.brand || 'Nike'; + if (/samba|campus|bad bunny|forum|gazelle/i.test(parsed.name || text)) brand = 'Adidas'; + if (/9060|550|1906/i.test(parsed.name || text)) brand = 'New Balance'; + + const cleanName = parsed.name || cleanProductName(text, brand); const proof = getProductSocialProof({ name: cleanName, brand }); return { @@ -117,7 +226,7 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto originBadge: proof.badge, }; } catch (err) { - console.warn('Fallo llamada Gemini, utilizando extractor regex fallback:', err); + console.warn('Fallo llamada Gemini, utilizando extractor regex de alta precisión:', err); return fallbackExtract(); } } diff --git a/dripzone-app/src/lib/model-grouper.ts b/dripzone-app/src/lib/model-grouper.ts new file mode 100644 index 0000000..54296e9 --- /dev/null +++ b/dripzone-app/src/lib/model-grouper.ts @@ -0,0 +1,116 @@ +import { Product, ProductColorVariant } from '@/types'; +import { generateAttractiveProductCopy } from './attractive-copy'; +import { getProductSocialProof } from './social-proof'; + +/** + * Normaliza el nombre del modelo para identificar automáticamente productos del mismo modelo + * pero con diferente colorway (ej: "Adidas Samba", "Adidas Samba Blanco y Negro", "Adidas Samba Gum") + */ +export function normalizeModelKey(name: string, brand?: string): string { + let clean = (name || '').toLowerCase(); + + if (brand) { + const b = brand.toLowerCase(); + if (!clean.includes(b)) clean = `${b} ${clean}`; + } + + clean = clean + .replace( + /\b(blanco|negro|gris|azul|verde|rojo|rosa|beige|gum|cream|shadow|panda|retro|og|h|dama|caballero|unisex|ref|\d{3}-\d{3}|edition|edicion|clasico|clasica|sneakers|zapatillas|calzado|para|de|y|en|con)\b/gi, + '' + ) + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, '-') + .replace(/^-+|-+$/g, ''); + + return clean || 'sneakers'; +} + +/** + * Agrupa una lista de productos que corresponden al mismo modelo pero diferente color + * Convirtiéndolos en un único producto con múltiples colorways (ProductColorVariant). + */ +export function groupProductsByModel(products: Product[]): Product[] { + const modelMap = new Map(); + + for (const prod of products) { + const brand = prod.brand || 'Sneakers'; + const baseModelName = prod.modelName || prod.name; + const modelKey = normalizeModelKey(baseModelName, brand); + + if (!modelMap.has(modelKey)) { + // Inicializar el primer producto para este modelo + const colors: ProductColorVariant[] = + Array.isArray(prod.colors) && prod.colors.length > 0 + ? [...prod.colors] + : [ + { + id: `color-${Date.now()}-1`, + colorName: prod.colorName || 'Estilo Principal', + image: prod.images[0], + images: prod.images, + sizes: prod.sizes || ['37', '38', '39', '40', '41', '42', '43'], + inStock: prod.inStock !== false, + }, + ]; + + const proof = getProductSocialProof(prod); + + modelMap.set(modelKey, { + ...prod, + modelName: baseModelName, + colors, + description: prod.description || generateAttractiveProductCopy(prod), + rating: prod.rating || proof.rating, + reviewsCount: prod.reviewsCount || proof.reviewsCount, + salesCount: prod.salesCount || proof.salesCount, + }); + } else { + // El modelo ya existe: fusionar colorways, fotos y tallas + const existing = modelMap.get(modelKey)!; + const existingColors = existing.colors || []; + + // Extraer los colorways del nuevo producto + const incomingColors: ProductColorVariant[] = + Array.isArray(prod.colors) && prod.colors.length > 0 + ? prod.colors + : [ + { + id: `color-${Date.now()}-${existingColors.length + 1}`, + colorName: prod.colorName || `Estilo #${existingColors.length + 1}`, + image: prod.images[0], + images: prod.images, + sizes: prod.sizes || ['37', '38', '39', '40', '41', '42', '43'], + inStock: prod.inStock !== false, + }, + ]; + + // Agregar colorways que no estén ya presentes (por URL de imagen) + incomingColors.forEach((inc) => { + const isDuplicate = existingColors.some( + (ec) => ec.image === inc.image || ec.colorName.toLowerCase() === inc.colorName.toLowerCase() + ); + if (!isDuplicate) { + existingColors.push({ + ...inc, + id: inc.id || `color-${Date.now()}-${existingColors.length + 1}`, + }); + } + }); + + // Fusionar imágenes sin duplicados + const mergedImages = Array.from(new Set([...existing.images, ...prod.images])); + + // Fusionar tallas sin duplicados + const mergedSizes = Array.from(new Set([...existing.sizes, ...(prod.sizes || [])])).sort( + (a, b) => parseInt(a, 10) - parseInt(b, 10) + ); + + existing.colors = existingColors; + existing.images = mergedImages; + existing.sizes = mergedSizes; + } + } + + return Array.from(modelMap.values()); +} diff --git a/sneaky-app/src/app/api/whatsapp-feed/route.ts b/sneaky-app/src/app/api/whatsapp-feed/route.ts index 9b5cb57..35f438a 100644 --- a/sneaky-app/src/app/api/whatsapp-feed/route.ts +++ b/sneaky-app/src/app/api/whatsapp-feed/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { dataService } from '@/lib/data-service'; import { parseSupplierMessage } from '@/lib/gemini-parser'; import { getProductSocialProof } from '@/lib/social-proof'; +import { generateAttractiveProductCopy } from '@/lib/attractive-copy'; import { SupplierPost, MonitoredWhatsAppGroup } from '@/types'; const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; @@ -218,13 +219,14 @@ export async function POST(request: Request) { const newProduct = { id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, name: prodName, + modelName: prodName, brand: prodBrand, - supplierPrice: post.extractedProduct?.supplierPrice || 90000, + supplierPrice: post.extractedProduct?.supplierPrice || 75000, retailPrice: retailPrice, originalPrice: Math.round(retailPrice * 1.35), sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], images: Array.isArray(post.rawImages) && post.rawImages.length > 0 ? post.rawImages : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], - description: `Calzado garantizado de alta calidad para despacho inmediato en 24 horas y pago contraentrega.`, + description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }), category: post.extractedProduct?.category || 'Calzado / Sneakers', inStock: true, isActive: true, @@ -236,13 +238,13 @@ export async function POST(request: Request) { supplierPhone: post.supplierPhone || '573167455877', }; - dataService.addProduct(newProduct); + const added = dataService.addProduct(newProduct as any); dataService.updateSupplierPostStatus(post.id, 'published'); return NextResponse.json({ success: true, - message: '¡Producto publicado exitosamente en la App Móvil y Web!', - product: newProduct, + message: `¡${prodName} publicado y agrupado en el catálogo!`, + product: added, }); } @@ -261,13 +263,14 @@ export async function POST(request: Request) { const newProd = { id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, name: prodName, + modelName: prodName, brand: prodBrand, - supplierPrice: p.extractedProduct?.supplierPrice || 90000, + supplierPrice: p.extractedProduct?.supplierPrice || 75000, retailPrice, originalPrice: Math.round(retailPrice * 1.35), sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], images: p.rawImages || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], - description: `Calzado garantizado para despacho inmediato en 24 horas con pago contraentrega.`, + description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }), category: p.extractedProduct?.category || 'Calzado / Sneakers', inStock: true, isActive: true, @@ -279,14 +282,14 @@ export async function POST(request: Request) { supplierPhone: p.supplierPhone || '573167455877', }; - dataService.addProduct(newProd); + const added = dataService.addProduct(newProd as any); dataService.updateSupplierPostStatus(p.id, 'published'); - createdProducts.push(newProd); + createdProducts.push(added); } return NextResponse.json({ success: true, - message: `¡Se publicaron ${createdProducts.length} productos en la App Móvil y Web!`, + message: `¡Se publicaron y agruparon ${createdProducts.length} productos en la App Móvil y Web!`, products: createdProducts, }); } diff --git a/sneaky-app/src/lib/attractive-copy.ts b/sneaky-app/src/lib/attractive-copy.ts new file mode 100644 index 0000000..846c655 --- /dev/null +++ b/sneaky-app/src/lib/attractive-copy.ts @@ -0,0 +1,37 @@ +/** + * Generador de Textos Comerciales Persuasivos y Atractivos para Calzado Deportivo + * Produce descripciones llamativas con llamados a la acción (CTA), beneficios de calidad G5/1.1, + * garantía de entrega y estética street-wear colombiana para maximizar conversiones. + */ + +export function generateAttractiveProductCopy(product: { + name: string; + brand: string; + category?: string; + supplierPrice?: number; + retailPrice?: number; +}): string { + const brand = product.brand || 'Sneakers'; + const name = product.name || 'Zapatillas Exclusivas'; + + const hooks = [ + `🔥 ¡Lleva tu outfit al siguiente nivel con las icónicas ${name}! Diseñadas para quienes no negocian entre estilo de alto impacto y máxima comodidad para el día a día.`, + `⚡ Las ${name} son el drop definitivo que tu colección estaba esperando. Silueta imbatible, acabados prémium y una estética urbana que no pasa desapercibida.`, + `💎 Edición Prémium ${brand}: ${name}. Fabricadas con materiales de máxima durabilidad, textura prémium y una combinación de colores pensada para resaltar en cualquier ocasión.`, + `🚀 Dale un upgrade a tus pasos con las exclusivas ${name}. Comodidad extrema desde el primer uso, amortiguación reactiva y estilo 100% garantizado.`, + ]; + + const seed = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + const selectedHook = hooks[Math.abs(seed) % hooks.length]; + + return `${selectedHook} + +✨ *¿Por qué todos las eligen?* +• 💎 *Calidad G5 / 1.1 Prémium:* Materiales de alto rendimiento, costuras reforzadas y terminaciones impecables. +• ☁️ *Confort Inigualable:* Plantilla acolchada ergonómica para máxima suavidad al caminar o entrenar. +• 🚚 *Despacho Prioritario Nacional:* Despachamos en 24 horas hábiles a cualquier ciudad de Colombia. +• 💵 *Pago 100% Contraentrega:* Realizas tu pedido con total tranquilidad y pagas al recibir en tu puerta. +• 🛡️ *Garantía Directa:* Revisas tu producto y verificas tu talla con total respaldo. + +⚠️ *Disponibilidad Limitada:* Pocas unidades disponibles por talla en bodega nacional. ¡Asegura tu par ahora antes de que se agoten!`; +} diff --git a/sneaky-app/src/lib/data-service.ts b/sneaky-app/src/lib/data-service.ts index 91edef1..ff67b19 100644 --- a/sneaky-app/src/lib/data-service.ts +++ b/sneaky-app/src/lib/data-service.ts @@ -1,4 +1,7 @@ -import { Product, City, PaymentMethod, Order, SupplierPost, StoreSettings, Supplier, MonitoredWhatsAppGroup, User } from '@/types'; +import { Product, City, PaymentMethod, Order, SupplierPost, StoreSettings, Supplier, MonitoredWhatsAppGroup, User, ProductColorVariant } from '@/types'; +import { normalizeModelKey, groupProductsByModel } from './model-grouper'; +import { generateAttractiveProductCopy } from './attractive-copy'; +import { getProductSocialProof } from './social-proof'; export const INITIAL_SUPPLIERS: Supplier[] = [ { @@ -191,16 +194,95 @@ class DataService { private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS]; getProducts(): Product[] { - return this.products; + return groupProductsByModel(this.products); } getProductById(id: string): Product | undefined { - return this.products.find((p) => p.id === id); + const allGrouped = groupProductsByModel(this.products); + return allGrouped.find((p) => p.id === id || p.colors?.some((c) => c.id === id)); } addProduct(product: Product): Product { - this.products.unshift(product); - return product; + const brand = product.brand || 'Sneakers'; + const baseModelName = product.modelName || product.name; + const modelKey = normalizeModelKey(baseModelName, brand); + + // Buscar si ya existe un producto del mismo modelo + const existing = this.products.find( + (p) => normalizeModelKey(p.modelName || p.name, p.brand) === modelKey + ); + + if (existing) { + const existingColors: ProductColorVariant[] = + Array.isArray(existing.colors) && existing.colors.length > 0 + ? existing.colors + : [ + { + id: `color-${existing.id}-1`, + colorName: existing.colorName || 'Estilo #1', + image: existing.images[0], + images: existing.images, + sizes: existing.sizes, + inStock: existing.inStock !== false, + }, + ]; + + const newImage = product.images[0] || (product.colors && product.colors[0]?.image); + const isDuplicate = existingColors.some( + (c) => c.image === newImage || (product.colorName && c.colorName.toLowerCase() === product.colorName.toLowerCase()) + ); + + if (!isDuplicate && newImage) { + existingColors.push({ + id: `color-${Date.now()}-${existingColors.length + 1}`, + colorName: product.colorName || `Estilo #${existingColors.length + 1}`, + image: newImage, + images: product.images.length > 0 ? product.images : [newImage], + sizes: product.sizes && product.sizes.length > 0 ? product.sizes : existing.sizes, + inStock: product.inStock !== false, + }); + } + + existing.colors = existingColors; + existing.images = Array.from(new Set([...existing.images, ...product.images])); + existing.sizes = Array.from(new Set([...existing.sizes, ...(product.sizes || [])])).sort( + (a, b) => parseInt(a, 10) - parseInt(b, 10) + ); + if (!existing.description || existing.description.length < 50) { + existing.description = generateAttractiveProductCopy(existing); + } + return existing; + } + + // Si es un modelo nuevo + const proof = getProductSocialProof(product); + const initialColors: ProductColorVariant[] = + Array.isArray(product.colors) && product.colors.length > 0 + ? product.colors + : [ + { + id: `color-${Date.now()}-1`, + colorName: product.colorName || 'Estilo Principal', + image: product.images[0], + images: product.images, + sizes: product.sizes || ['37', '38', '39', '40', '41', '42', '43'], + inStock: product.inStock !== false, + }, + ]; + + const formattedProduct: Product = { + ...product, + modelName: baseModelName, + colors: initialColors, + description: product.description || generateAttractiveProductCopy(product), + rating: product.rating || proof.rating, + reviewsCount: product.reviewsCount || proof.reviewsCount, + salesCount: product.salesCount || proof.salesCount, + originBadge: product.originBadge || proof.badge, + }; + + this.products.unshift(formattedProduct); + return formattedProduct; } updateProduct(id: string, updates: Partial): Product | null { diff --git a/sneaky-app/src/lib/gemini-parser.ts b/sneaky-app/src/lib/gemini-parser.ts index 9277fcd..9d8d008 100644 --- a/sneaky-app/src/lib/gemini-parser.ts +++ b/sneaky-app/src/lib/gemini-parser.ts @@ -2,52 +2,151 @@ import { GoogleGenAI } from '@google/genai'; import { ExtractedProductData } from '@/types'; import { calculateRetailPrice } from './pricing-engine'; import { getProductSocialProof } from './social-proof'; +import { generateAttractiveProductCopy } from './attractive-copy'; + +/** + * Extractor robusto de precios mayoristas en COP desde mensajes de WhatsApp + * Soporta emojis de dinero (💸, 💰, 💵, 💲), palabras clave, formatos con punto (75.000), 'k' (75k) y 'mil' (75 mil). + */ +export function extractSupplierPrice(text: string): number { + // 1. Buscar patrones con símbolos o emojis de dinero y palabras clave + const explicitPattern = + /(?:💸|💰|💵|💲|precio|valor|costo|mayorista|mayor|mayoristas|\$)+[\s:=]*\$?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}\s*k|\d{2,3}\s*mil|\d{5,6})/i; + const explicitMatch = text.match(explicitPattern); + if (explicitMatch) { + const raw = explicitMatch[1].toLowerCase().replace(/\s+/g, '').replace(/\./g, ''); + if (raw.endsWith('k') || raw.endsWith('mil')) { + const num = parseInt(raw.replace('mil', '').replace('k', ''), 10); + if (!isNaN(num) && num > 0) return num < 1000 ? num * 1000 : num; + } + const num = parseInt(raw, 10); + if (!isNaN(num)) { + if (num >= 40 && num <= 350) return num * 1000; + if (num >= 40000 && num <= 500000) return num; + } + } + + // 2. Buscar números con formato de miles (ej: 75.000, 80.000, 100.000, 125.500) + const dotThousands = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\.(000|500)\b/); + if (dotThousands) { + const num = parseInt(dotThousands[0].replace(/\./g, ''), 10); + if (!isNaN(num) && num >= 40000 && num <= 500000) return num; + } + + // 3. Buscar valores con 'k' o 'mil' (ej: 75k, 80 mil, 95k) + const kMatch = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\s*(?:k|mil)\b/i); + if (kMatch) { + const num = parseInt(kMatch[1], 10); + if (!isNaN(num)) return num * 1000; + } + + // 4. Buscar números de 5 o 6 dígitos en rango de precio razonable (40000 a 300000) + const rawNumbers = text.match(/\b(4[5-9]\d{3}|[5-9]\d{4}|1\d{5}|2\d{5})\b/g); + if (rawNumbers && rawNumbers.length > 0) { + const num = parseInt(rawNumbers[0], 10); + if (!isNaN(num)) return num; + } + + return 75000; // Valor por defecto representativo para calzado nacional +} + +/** + * Extractor de tallas con normalización Unicode (soporta small-caps como ᴛᴀʟʟᴀs ᴅɪsᴘᴏɴɪʙʟᴇs:36/41/44) + */ +export function extractSizes(rawText: string): string[] { + const normalized = rawText + .normalize('NFKD') + .replace(/[ᴛ]/g, 't') + .replace(/[ᴀ]/g, 'a') + .replace(/[ʟ]/g, 'l') + .replace(/[s]/g, 's') + .replace(/[ᴅ]/g, 'd') + .replace(/[ɪ]/g, 'i') + .replace(/[ᴘ]/g, 'p') + .replace(/[ᴏ]/g, 'o') + .replace(/[ɴ]/g, 'n') + .replace(/[ʙ]/g, 'b') + .replace(/[ᴇ]/g, 'e'); + + const sizes: string[] = []; + + // Rango explícito (ej: 36 a 43, 36 al 43, 38-43) + const rangeMatch = normalized.match( + /(?:tallas?|eur|talla|disponibles?)?[^\d\n]*\b(3[4-9]|4[0-6])\s*(?:a|al|-)\s*(3[4-9]|4[0-6])\b/i + ); + if (rangeMatch) { + const start = parseInt(rangeMatch[1], 10); + const end = parseInt(rangeMatch[2], 10); + if (start <= end && start >= 34 && end <= 46) { + for (let s = start; s <= end; s++) sizes.push(s.toString()); + return sizes; + } + } + + // Sección de tallas o texto completo + const tallaSection = normalized.match(/(?:tallas?|eur|disponibles?|talla)[^\n]*/i); + const textToScan = tallaSection ? tallaSection[0] : normalized; + + // Extraer números individuales de 2 dígitos entre 34 y 46 + const matches = textToScan.match(/(?:^|[^\d])(3[4-9]|4[0-6])(?=[^\d]|$)/g); + if (matches) { + matches.forEach((m) => { + const num = m.replace(/[^\d]/g, ''); + if (num && !sizes.includes(num)) { + sizes.push(num); + } + }); + } + + if (sizes.length === 0) { + return ['37', '38', '39', '40', '41', '42', '43']; + } + + return sizes.sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); +} + +/** + * Limpia y formatea el nombre del producto desde el mensaje del proveedor + */ +export function cleanProductName(text: string, detectedBrand: string): string { + const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0); + for (const line of lines) { + 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(); + + if (clean.length >= 3 && !/^\d+$/.test(clean) && !/^\d+[\.,]\d+$/.test(clean)) { + return clean + .split(' ') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(' '); + } + } + return `${detectedBrand} Urban Edition`; +} export async function parseSupplierMessage(text: string): Promise { const apiKey = process.env.GEMINI_API_KEY; const fallbackExtract = (): ExtractedProductData => { - let price = 90000; - const priceMatch = text.match(/(?:precio|valor|mayorista|costo|\$)\s*[:=]?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}k?)/i); - if (priceMatch) { - const raw = priceMatch[1].toLowerCase().replace(/\./g, ''); - if (raw.endsWith('k')) { - price = parseInt(raw.replace('k', ''), 10) * 1000; - } else { - const num = parseInt(raw, 10); - price = num < 1000 ? num * 1000 : num; - } - } - - const sizes: string[] = []; - const sizeRangeMatch = text.match(/(\d{2})\s*(?:a|al|-)\s*(\d{2})/i); - if (sizeRangeMatch) { - const start = parseInt(sizeRangeMatch[1], 10); - const end = parseInt(sizeRangeMatch[2], 10); - if (start >= 34 && end <= 46 && start <= end) { - for (let s = start; s <= end; s++) sizes.push(s.toString()); - } - } else { - const individualSizes = text.match(/\b(3[4-9]|4[0-6])\b/g); - if (individualSizes) { - sizes.push(...Array.from(new Set(individualSizes))); - } - } - if (sizes.length === 0) { - sizes.push('37', '38', '39', '40', '41', '42', '43'); - } + const price = extractSupplierPrice(text); + const sizes = extractSizes(text); let brand = 'Nike'; - if (/jordan/i.test(text)) brand = 'Jordan'; - else if (/adidas|bad bunny|campus|forum/i.test(text)) brand = 'Adidas'; - else if (/new balance|nb/i.test(text)) brand = 'New Balance'; - else if (/puma/i.test(text)) brand = 'Puma'; - else if (/asics/i.test(text)) brand = 'Asics'; - else if (/vans/i.test(text)) brand = 'Vans'; + if (/adidas|samba|campus|bad bunny|forum|gazelle|stan smith/i.test(text)) brand = 'Adidas'; + else if (/jordan|air jordan|aj1|aj4|aj3/i.test(text)) brand = 'Jordan'; + else if (/new balance|nb|9060|550|530|1906/i.test(text)) brand = 'New Balance'; + else if (/puma|palermo|suede|speedcat/i.test(text)) brand = 'Puma'; + else if (/asics|gel kayano|gel nyc/i.test(text)) brand = 'Asics'; + else if (/vans|old skool|knu skool/i.test(text)) brand = 'Vans'; + else if (/coach/i.test(text)) brand = 'Coach'; - const cleanLines = text.split('\n').filter((l) => l.trim().length > 0 && !/🔥|🔴|llegando|promo/i.test(l)); - const name = cleanLines[0] || `${brand} Sneakers Urban Edition`; - const cleanName = name.replace(/tallas?:.*|precio:.*|\$/gi, '').trim() || `${brand} Zapatillas`; + const cleanName = cleanProductName(text, brand); const proof = getProductSocialProof({ name: cleanName, brand }); return { @@ -57,7 +156,7 @@ export async function parseSupplierMessage(text: string): Promise 0 ? parsed.sizes.map(String) : ['37', '38', '39', '40', '41', '42', '43']; + const supplierPrice = + typeof parsed.supplierPrice === 'number' && parsed.supplierPrice >= 40000 + ? parsed.supplierPrice + : extractSupplierPrice(text); - const cleanName = parsed.name || 'Zapatilla Urbana'; - const brand = parsed.brand || 'Nike'; + const sizes = + Array.isArray(parsed.sizes) && parsed.sizes.length > 0 + ? parsed.sizes.map(String) + : extractSizes(text); + + let brand = parsed.brand || 'Nike'; + if (/samba|campus|bad bunny|forum|gazelle/i.test(parsed.name || text)) brand = 'Adidas'; + if (/9060|550|1906/i.test(parsed.name || text)) brand = 'New Balance'; + + const cleanName = parsed.name || cleanProductName(text, brand); const proof = getProductSocialProof({ name: cleanName, brand }); return { @@ -117,7 +226,7 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto originBadge: proof.badge, }; } catch (err) { - console.warn('Fallo llamada Gemini, utilizando extractor regex fallback:', err); + console.warn('Fallo llamada Gemini, utilizando extractor regex de alta precisión:', err); return fallbackExtract(); } } diff --git a/sneaky-app/src/lib/model-grouper.ts b/sneaky-app/src/lib/model-grouper.ts new file mode 100644 index 0000000..54296e9 --- /dev/null +++ b/sneaky-app/src/lib/model-grouper.ts @@ -0,0 +1,116 @@ +import { Product, ProductColorVariant } from '@/types'; +import { generateAttractiveProductCopy } from './attractive-copy'; +import { getProductSocialProof } from './social-proof'; + +/** + * Normaliza el nombre del modelo para identificar automáticamente productos del mismo modelo + * pero con diferente colorway (ej: "Adidas Samba", "Adidas Samba Blanco y Negro", "Adidas Samba Gum") + */ +export function normalizeModelKey(name: string, brand?: string): string { + let clean = (name || '').toLowerCase(); + + if (brand) { + const b = brand.toLowerCase(); + if (!clean.includes(b)) clean = `${b} ${clean}`; + } + + clean = clean + .replace( + /\b(blanco|negro|gris|azul|verde|rojo|rosa|beige|gum|cream|shadow|panda|retro|og|h|dama|caballero|unisex|ref|\d{3}-\d{3}|edition|edicion|clasico|clasica|sneakers|zapatillas|calzado|para|de|y|en|con)\b/gi, + '' + ) + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, '-') + .replace(/^-+|-+$/g, ''); + + return clean || 'sneakers'; +} + +/** + * Agrupa una lista de productos que corresponden al mismo modelo pero diferente color + * Convirtiéndolos en un único producto con múltiples colorways (ProductColorVariant). + */ +export function groupProductsByModel(products: Product[]): Product[] { + const modelMap = new Map(); + + for (const prod of products) { + const brand = prod.brand || 'Sneakers'; + const baseModelName = prod.modelName || prod.name; + const modelKey = normalizeModelKey(baseModelName, brand); + + if (!modelMap.has(modelKey)) { + // Inicializar el primer producto para este modelo + const colors: ProductColorVariant[] = + Array.isArray(prod.colors) && prod.colors.length > 0 + ? [...prod.colors] + : [ + { + id: `color-${Date.now()}-1`, + colorName: prod.colorName || 'Estilo Principal', + image: prod.images[0], + images: prod.images, + sizes: prod.sizes || ['37', '38', '39', '40', '41', '42', '43'], + inStock: prod.inStock !== false, + }, + ]; + + const proof = getProductSocialProof(prod); + + modelMap.set(modelKey, { + ...prod, + modelName: baseModelName, + colors, + description: prod.description || generateAttractiveProductCopy(prod), + rating: prod.rating || proof.rating, + reviewsCount: prod.reviewsCount || proof.reviewsCount, + salesCount: prod.salesCount || proof.salesCount, + }); + } else { + // El modelo ya existe: fusionar colorways, fotos y tallas + const existing = modelMap.get(modelKey)!; + const existingColors = existing.colors || []; + + // Extraer los colorways del nuevo producto + const incomingColors: ProductColorVariant[] = + Array.isArray(prod.colors) && prod.colors.length > 0 + ? prod.colors + : [ + { + id: `color-${Date.now()}-${existingColors.length + 1}`, + colorName: prod.colorName || `Estilo #${existingColors.length + 1}`, + image: prod.images[0], + images: prod.images, + sizes: prod.sizes || ['37', '38', '39', '40', '41', '42', '43'], + inStock: prod.inStock !== false, + }, + ]; + + // Agregar colorways que no estén ya presentes (por URL de imagen) + incomingColors.forEach((inc) => { + const isDuplicate = existingColors.some( + (ec) => ec.image === inc.image || ec.colorName.toLowerCase() === inc.colorName.toLowerCase() + ); + if (!isDuplicate) { + existingColors.push({ + ...inc, + id: inc.id || `color-${Date.now()}-${existingColors.length + 1}`, + }); + } + }); + + // Fusionar imágenes sin duplicados + const mergedImages = Array.from(new Set([...existing.images, ...prod.images])); + + // Fusionar tallas sin duplicados + const mergedSizes = Array.from(new Set([...existing.sizes, ...(prod.sizes || [])])).sort( + (a, b) => parseInt(a, 10) - parseInt(b, 10) + ); + + existing.colors = existingColors; + existing.images = mergedImages; + existing.sizes = mergedSizes; + } + } + + return Array.from(modelMap.values()); +} diff --git a/zapply-app/src/app/api/whatsapp-feed/route.ts b/zapply-app/src/app/api/whatsapp-feed/route.ts index 9b5cb57..35f438a 100644 --- a/zapply-app/src/app/api/whatsapp-feed/route.ts +++ b/zapply-app/src/app/api/whatsapp-feed/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { dataService } from '@/lib/data-service'; import { parseSupplierMessage } from '@/lib/gemini-parser'; import { getProductSocialProof } from '@/lib/social-proof'; +import { generateAttractiveProductCopy } from '@/lib/attractive-copy'; import { SupplierPost, MonitoredWhatsAppGroup } from '@/types'; const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; @@ -218,13 +219,14 @@ export async function POST(request: Request) { const newProduct = { id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, name: prodName, + modelName: prodName, brand: prodBrand, - supplierPrice: post.extractedProduct?.supplierPrice || 90000, + supplierPrice: post.extractedProduct?.supplierPrice || 75000, retailPrice: retailPrice, originalPrice: Math.round(retailPrice * 1.35), sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], images: Array.isArray(post.rawImages) && post.rawImages.length > 0 ? post.rawImages : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], - description: `Calzado garantizado de alta calidad para despacho inmediato en 24 horas y pago contraentrega.`, + description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }), category: post.extractedProduct?.category || 'Calzado / Sneakers', inStock: true, isActive: true, @@ -236,13 +238,13 @@ export async function POST(request: Request) { supplierPhone: post.supplierPhone || '573167455877', }; - dataService.addProduct(newProduct); + const added = dataService.addProduct(newProduct as any); dataService.updateSupplierPostStatus(post.id, 'published'); return NextResponse.json({ success: true, - message: '¡Producto publicado exitosamente en la App Móvil y Web!', - product: newProduct, + message: `¡${prodName} publicado y agrupado en el catálogo!`, + product: added, }); } @@ -261,13 +263,14 @@ export async function POST(request: Request) { const newProd = { id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, name: prodName, + modelName: prodName, brand: prodBrand, - supplierPrice: p.extractedProduct?.supplierPrice || 90000, + supplierPrice: p.extractedProduct?.supplierPrice || 75000, retailPrice, originalPrice: Math.round(retailPrice * 1.35), sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], images: p.rawImages || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'], - description: `Calzado garantizado para despacho inmediato en 24 horas con pago contraentrega.`, + description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }), category: p.extractedProduct?.category || 'Calzado / Sneakers', inStock: true, isActive: true, @@ -279,14 +282,14 @@ export async function POST(request: Request) { supplierPhone: p.supplierPhone || '573167455877', }; - dataService.addProduct(newProd); + const added = dataService.addProduct(newProd as any); dataService.updateSupplierPostStatus(p.id, 'published'); - createdProducts.push(newProd); + createdProducts.push(added); } return NextResponse.json({ success: true, - message: `¡Se publicaron ${createdProducts.length} productos en la App Móvil y Web!`, + message: `¡Se publicaron y agruparon ${createdProducts.length} productos en la App Móvil y Web!`, products: createdProducts, }); } diff --git a/zapply-app/src/lib/attractive-copy.ts b/zapply-app/src/lib/attractive-copy.ts new file mode 100644 index 0000000..846c655 --- /dev/null +++ b/zapply-app/src/lib/attractive-copy.ts @@ -0,0 +1,37 @@ +/** + * Generador de Textos Comerciales Persuasivos y Atractivos para Calzado Deportivo + * Produce descripciones llamativas con llamados a la acción (CTA), beneficios de calidad G5/1.1, + * garantía de entrega y estética street-wear colombiana para maximizar conversiones. + */ + +export function generateAttractiveProductCopy(product: { + name: string; + brand: string; + category?: string; + supplierPrice?: number; + retailPrice?: number; +}): string { + const brand = product.brand || 'Sneakers'; + const name = product.name || 'Zapatillas Exclusivas'; + + const hooks = [ + `🔥 ¡Lleva tu outfit al siguiente nivel con las icónicas ${name}! Diseñadas para quienes no negocian entre estilo de alto impacto y máxima comodidad para el día a día.`, + `⚡ Las ${name} son el drop definitivo que tu colección estaba esperando. Silueta imbatible, acabados prémium y una estética urbana que no pasa desapercibida.`, + `💎 Edición Prémium ${brand}: ${name}. Fabricadas con materiales de máxima durabilidad, textura prémium y una combinación de colores pensada para resaltar en cualquier ocasión.`, + `🚀 Dale un upgrade a tus pasos con las exclusivas ${name}. Comodidad extrema desde el primer uso, amortiguación reactiva y estilo 100% garantizado.`, + ]; + + const seed = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + const selectedHook = hooks[Math.abs(seed) % hooks.length]; + + return `${selectedHook} + +✨ *¿Por qué todos las eligen?* +• 💎 *Calidad G5 / 1.1 Prémium:* Materiales de alto rendimiento, costuras reforzadas y terminaciones impecables. +• ☁️ *Confort Inigualable:* Plantilla acolchada ergonómica para máxima suavidad al caminar o entrenar. +• 🚚 *Despacho Prioritario Nacional:* Despachamos en 24 horas hábiles a cualquier ciudad de Colombia. +• 💵 *Pago 100% Contraentrega:* Realizas tu pedido con total tranquilidad y pagas al recibir en tu puerta. +• 🛡️ *Garantía Directa:* Revisas tu producto y verificas tu talla con total respaldo. + +⚠️ *Disponibilidad Limitada:* Pocas unidades disponibles por talla en bodega nacional. ¡Asegura tu par ahora antes de que se agoten!`; +} diff --git a/zapply-app/src/lib/data-service.ts b/zapply-app/src/lib/data-service.ts index 91edef1..ff67b19 100644 --- a/zapply-app/src/lib/data-service.ts +++ b/zapply-app/src/lib/data-service.ts @@ -1,4 +1,7 @@ -import { Product, City, PaymentMethod, Order, SupplierPost, StoreSettings, Supplier, MonitoredWhatsAppGroup, User } from '@/types'; +import { Product, City, PaymentMethod, Order, SupplierPost, StoreSettings, Supplier, MonitoredWhatsAppGroup, User, ProductColorVariant } from '@/types'; +import { normalizeModelKey, groupProductsByModel } from './model-grouper'; +import { generateAttractiveProductCopy } from './attractive-copy'; +import { getProductSocialProof } from './social-proof'; export const INITIAL_SUPPLIERS: Supplier[] = [ { @@ -191,16 +194,95 @@ class DataService { private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS]; getProducts(): Product[] { - return this.products; + return groupProductsByModel(this.products); } getProductById(id: string): Product | undefined { - return this.products.find((p) => p.id === id); + const allGrouped = groupProductsByModel(this.products); + return allGrouped.find((p) => p.id === id || p.colors?.some((c) => c.id === id)); } addProduct(product: Product): Product { - this.products.unshift(product); - return product; + const brand = product.brand || 'Sneakers'; + const baseModelName = product.modelName || product.name; + const modelKey = normalizeModelKey(baseModelName, brand); + + // Buscar si ya existe un producto del mismo modelo + const existing = this.products.find( + (p) => normalizeModelKey(p.modelName || p.name, p.brand) === modelKey + ); + + if (existing) { + const existingColors: ProductColorVariant[] = + Array.isArray(existing.colors) && existing.colors.length > 0 + ? existing.colors + : [ + { + id: `color-${existing.id}-1`, + colorName: existing.colorName || 'Estilo #1', + image: existing.images[0], + images: existing.images, + sizes: existing.sizes, + inStock: existing.inStock !== false, + }, + ]; + + const newImage = product.images[0] || (product.colors && product.colors[0]?.image); + const isDuplicate = existingColors.some( + (c) => c.image === newImage || (product.colorName && c.colorName.toLowerCase() === product.colorName.toLowerCase()) + ); + + if (!isDuplicate && newImage) { + existingColors.push({ + id: `color-${Date.now()}-${existingColors.length + 1}`, + colorName: product.colorName || `Estilo #${existingColors.length + 1}`, + image: newImage, + images: product.images.length > 0 ? product.images : [newImage], + sizes: product.sizes && product.sizes.length > 0 ? product.sizes : existing.sizes, + inStock: product.inStock !== false, + }); + } + + existing.colors = existingColors; + existing.images = Array.from(new Set([...existing.images, ...product.images])); + existing.sizes = Array.from(new Set([...existing.sizes, ...(product.sizes || [])])).sort( + (a, b) => parseInt(a, 10) - parseInt(b, 10) + ); + if (!existing.description || existing.description.length < 50) { + existing.description = generateAttractiveProductCopy(existing); + } + return existing; + } + + // Si es un modelo nuevo + const proof = getProductSocialProof(product); + const initialColors: ProductColorVariant[] = + Array.isArray(product.colors) && product.colors.length > 0 + ? product.colors + : [ + { + id: `color-${Date.now()}-1`, + colorName: product.colorName || 'Estilo Principal', + image: product.images[0], + images: product.images, + sizes: product.sizes || ['37', '38', '39', '40', '41', '42', '43'], + inStock: product.inStock !== false, + }, + ]; + + const formattedProduct: Product = { + ...product, + modelName: baseModelName, + colors: initialColors, + description: product.description || generateAttractiveProductCopy(product), + rating: product.rating || proof.rating, + reviewsCount: product.reviewsCount || proof.reviewsCount, + salesCount: product.salesCount || proof.salesCount, + originBadge: product.originBadge || proof.badge, + }; + + this.products.unshift(formattedProduct); + return formattedProduct; } updateProduct(id: string, updates: Partial): Product | null { diff --git a/zapply-app/src/lib/gemini-parser.ts b/zapply-app/src/lib/gemini-parser.ts index 9277fcd..9d8d008 100644 --- a/zapply-app/src/lib/gemini-parser.ts +++ b/zapply-app/src/lib/gemini-parser.ts @@ -2,52 +2,151 @@ import { GoogleGenAI } from '@google/genai'; import { ExtractedProductData } from '@/types'; import { calculateRetailPrice } from './pricing-engine'; import { getProductSocialProof } from './social-proof'; +import { generateAttractiveProductCopy } from './attractive-copy'; + +/** + * Extractor robusto de precios mayoristas en COP desde mensajes de WhatsApp + * Soporta emojis de dinero (💸, 💰, 💵, 💲), palabras clave, formatos con punto (75.000), 'k' (75k) y 'mil' (75 mil). + */ +export function extractSupplierPrice(text: string): number { + // 1. Buscar patrones con símbolos o emojis de dinero y palabras clave + const explicitPattern = + /(?:💸|💰|💵|💲|precio|valor|costo|mayorista|mayor|mayoristas|\$)+[\s:=]*\$?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}\s*k|\d{2,3}\s*mil|\d{5,6})/i; + const explicitMatch = text.match(explicitPattern); + if (explicitMatch) { + const raw = explicitMatch[1].toLowerCase().replace(/\s+/g, '').replace(/\./g, ''); + if (raw.endsWith('k') || raw.endsWith('mil')) { + const num = parseInt(raw.replace('mil', '').replace('k', ''), 10); + if (!isNaN(num) && num > 0) return num < 1000 ? num * 1000 : num; + } + const num = parseInt(raw, 10); + if (!isNaN(num)) { + if (num >= 40 && num <= 350) return num * 1000; + if (num >= 40000 && num <= 500000) return num; + } + } + + // 2. Buscar números con formato de miles (ej: 75.000, 80.000, 100.000, 125.500) + const dotThousands = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\.(000|500)\b/); + if (dotThousands) { + const num = parseInt(dotThousands[0].replace(/\./g, ''), 10); + if (!isNaN(num) && num >= 40000 && num <= 500000) return num; + } + + // 3. Buscar valores con 'k' o 'mil' (ej: 75k, 80 mil, 95k) + const kMatch = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\s*(?:k|mil)\b/i); + if (kMatch) { + const num = parseInt(kMatch[1], 10); + if (!isNaN(num)) return num * 1000; + } + + // 4. Buscar números de 5 o 6 dígitos en rango de precio razonable (40000 a 300000) + const rawNumbers = text.match(/\b(4[5-9]\d{3}|[5-9]\d{4}|1\d{5}|2\d{5})\b/g); + if (rawNumbers && rawNumbers.length > 0) { + const num = parseInt(rawNumbers[0], 10); + if (!isNaN(num)) return num; + } + + return 75000; // Valor por defecto representativo para calzado nacional +} + +/** + * Extractor de tallas con normalización Unicode (soporta small-caps como ᴛᴀʟʟᴀs ᴅɪsᴘᴏɴɪʙʟᴇs:36/41/44) + */ +export function extractSizes(rawText: string): string[] { + const normalized = rawText + .normalize('NFKD') + .replace(/[ᴛ]/g, 't') + .replace(/[ᴀ]/g, 'a') + .replace(/[ʟ]/g, 'l') + .replace(/[s]/g, 's') + .replace(/[ᴅ]/g, 'd') + .replace(/[ɪ]/g, 'i') + .replace(/[ᴘ]/g, 'p') + .replace(/[ᴏ]/g, 'o') + .replace(/[ɴ]/g, 'n') + .replace(/[ʙ]/g, 'b') + .replace(/[ᴇ]/g, 'e'); + + const sizes: string[] = []; + + // Rango explícito (ej: 36 a 43, 36 al 43, 38-43) + const rangeMatch = normalized.match( + /(?:tallas?|eur|talla|disponibles?)?[^\d\n]*\b(3[4-9]|4[0-6])\s*(?:a|al|-)\s*(3[4-9]|4[0-6])\b/i + ); + if (rangeMatch) { + const start = parseInt(rangeMatch[1], 10); + const end = parseInt(rangeMatch[2], 10); + if (start <= end && start >= 34 && end <= 46) { + for (let s = start; s <= end; s++) sizes.push(s.toString()); + return sizes; + } + } + + // Sección de tallas o texto completo + const tallaSection = normalized.match(/(?:tallas?|eur|disponibles?|talla)[^\n]*/i); + const textToScan = tallaSection ? tallaSection[0] : normalized; + + // Extraer números individuales de 2 dígitos entre 34 y 46 + const matches = textToScan.match(/(?:^|[^\d])(3[4-9]|4[0-6])(?=[^\d]|$)/g); + if (matches) { + matches.forEach((m) => { + const num = m.replace(/[^\d]/g, ''); + if (num && !sizes.includes(num)) { + sizes.push(num); + } + }); + } + + if (sizes.length === 0) { + return ['37', '38', '39', '40', '41', '42', '43']; + } + + return sizes.sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); +} + +/** + * Limpia y formatea el nombre del producto desde el mensaje del proveedor + */ +export function cleanProductName(text: string, detectedBrand: string): string { + const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0); + for (const line of lines) { + 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(); + + if (clean.length >= 3 && !/^\d+$/.test(clean) && !/^\d+[\.,]\d+$/.test(clean)) { + return clean + .split(' ') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(' '); + } + } + return `${detectedBrand} Urban Edition`; +} export async function parseSupplierMessage(text: string): Promise { const apiKey = process.env.GEMINI_API_KEY; const fallbackExtract = (): ExtractedProductData => { - let price = 90000; - const priceMatch = text.match(/(?:precio|valor|mayorista|costo|\$)\s*[:=]?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}k?)/i); - if (priceMatch) { - const raw = priceMatch[1].toLowerCase().replace(/\./g, ''); - if (raw.endsWith('k')) { - price = parseInt(raw.replace('k', ''), 10) * 1000; - } else { - const num = parseInt(raw, 10); - price = num < 1000 ? num * 1000 : num; - } - } - - const sizes: string[] = []; - const sizeRangeMatch = text.match(/(\d{2})\s*(?:a|al|-)\s*(\d{2})/i); - if (sizeRangeMatch) { - const start = parseInt(sizeRangeMatch[1], 10); - const end = parseInt(sizeRangeMatch[2], 10); - if (start >= 34 && end <= 46 && start <= end) { - for (let s = start; s <= end; s++) sizes.push(s.toString()); - } - } else { - const individualSizes = text.match(/\b(3[4-9]|4[0-6])\b/g); - if (individualSizes) { - sizes.push(...Array.from(new Set(individualSizes))); - } - } - if (sizes.length === 0) { - sizes.push('37', '38', '39', '40', '41', '42', '43'); - } + const price = extractSupplierPrice(text); + const sizes = extractSizes(text); let brand = 'Nike'; - if (/jordan/i.test(text)) brand = 'Jordan'; - else if (/adidas|bad bunny|campus|forum/i.test(text)) brand = 'Adidas'; - else if (/new balance|nb/i.test(text)) brand = 'New Balance'; - else if (/puma/i.test(text)) brand = 'Puma'; - else if (/asics/i.test(text)) brand = 'Asics'; - else if (/vans/i.test(text)) brand = 'Vans'; + if (/adidas|samba|campus|bad bunny|forum|gazelle|stan smith/i.test(text)) brand = 'Adidas'; + else if (/jordan|air jordan|aj1|aj4|aj3/i.test(text)) brand = 'Jordan'; + else if (/new balance|nb|9060|550|530|1906/i.test(text)) brand = 'New Balance'; + else if (/puma|palermo|suede|speedcat/i.test(text)) brand = 'Puma'; + else if (/asics|gel kayano|gel nyc/i.test(text)) brand = 'Asics'; + else if (/vans|old skool|knu skool/i.test(text)) brand = 'Vans'; + else if (/coach/i.test(text)) brand = 'Coach'; - const cleanLines = text.split('\n').filter((l) => l.trim().length > 0 && !/🔥|🔴|llegando|promo/i.test(l)); - const name = cleanLines[0] || `${brand} Sneakers Urban Edition`; - const cleanName = name.replace(/tallas?:.*|precio:.*|\$/gi, '').trim() || `${brand} Zapatillas`; + const cleanName = cleanProductName(text, brand); const proof = getProductSocialProof({ name: cleanName, brand }); return { @@ -57,7 +156,7 @@ export async function parseSupplierMessage(text: string): Promise 0 ? parsed.sizes.map(String) : ['37', '38', '39', '40', '41', '42', '43']; + const supplierPrice = + typeof parsed.supplierPrice === 'number' && parsed.supplierPrice >= 40000 + ? parsed.supplierPrice + : extractSupplierPrice(text); - const cleanName = parsed.name || 'Zapatilla Urbana'; - const brand = parsed.brand || 'Nike'; + const sizes = + Array.isArray(parsed.sizes) && parsed.sizes.length > 0 + ? parsed.sizes.map(String) + : extractSizes(text); + + let brand = parsed.brand || 'Nike'; + if (/samba|campus|bad bunny|forum|gazelle/i.test(parsed.name || text)) brand = 'Adidas'; + if (/9060|550|1906/i.test(parsed.name || text)) brand = 'New Balance'; + + const cleanName = parsed.name || cleanProductName(text, brand); const proof = getProductSocialProof({ name: cleanName, brand }); return { @@ -117,7 +226,7 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto originBadge: proof.badge, }; } catch (err) { - console.warn('Fallo llamada Gemini, utilizando extractor regex fallback:', err); + console.warn('Fallo llamada Gemini, utilizando extractor regex de alta precisión:', err); return fallbackExtract(); } } diff --git a/zapply-app/src/lib/model-grouper.ts b/zapply-app/src/lib/model-grouper.ts new file mode 100644 index 0000000..54296e9 --- /dev/null +++ b/zapply-app/src/lib/model-grouper.ts @@ -0,0 +1,116 @@ +import { Product, ProductColorVariant } from '@/types'; +import { generateAttractiveProductCopy } from './attractive-copy'; +import { getProductSocialProof } from './social-proof'; + +/** + * Normaliza el nombre del modelo para identificar automáticamente productos del mismo modelo + * pero con diferente colorway (ej: "Adidas Samba", "Adidas Samba Blanco y Negro", "Adidas Samba Gum") + */ +export function normalizeModelKey(name: string, brand?: string): string { + let clean = (name || '').toLowerCase(); + + if (brand) { + const b = brand.toLowerCase(); + if (!clean.includes(b)) clean = `${b} ${clean}`; + } + + clean = clean + .replace( + /\b(blanco|negro|gris|azul|verde|rojo|rosa|beige|gum|cream|shadow|panda|retro|og|h|dama|caballero|unisex|ref|\d{3}-\d{3}|edition|edicion|clasico|clasica|sneakers|zapatillas|calzado|para|de|y|en|con)\b/gi, + '' + ) + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, '-') + .replace(/^-+|-+$/g, ''); + + return clean || 'sneakers'; +} + +/** + * Agrupa una lista de productos que corresponden al mismo modelo pero diferente color + * Convirtiéndolos en un único producto con múltiples colorways (ProductColorVariant). + */ +export function groupProductsByModel(products: Product[]): Product[] { + const modelMap = new Map(); + + for (const prod of products) { + const brand = prod.brand || 'Sneakers'; + const baseModelName = prod.modelName || prod.name; + const modelKey = normalizeModelKey(baseModelName, brand); + + if (!modelMap.has(modelKey)) { + // Inicializar el primer producto para este modelo + const colors: ProductColorVariant[] = + Array.isArray(prod.colors) && prod.colors.length > 0 + ? [...prod.colors] + : [ + { + id: `color-${Date.now()}-1`, + colorName: prod.colorName || 'Estilo Principal', + image: prod.images[0], + images: prod.images, + sizes: prod.sizes || ['37', '38', '39', '40', '41', '42', '43'], + inStock: prod.inStock !== false, + }, + ]; + + const proof = getProductSocialProof(prod); + + modelMap.set(modelKey, { + ...prod, + modelName: baseModelName, + colors, + description: prod.description || generateAttractiveProductCopy(prod), + rating: prod.rating || proof.rating, + reviewsCount: prod.reviewsCount || proof.reviewsCount, + salesCount: prod.salesCount || proof.salesCount, + }); + } else { + // El modelo ya existe: fusionar colorways, fotos y tallas + const existing = modelMap.get(modelKey)!; + const existingColors = existing.colors || []; + + // Extraer los colorways del nuevo producto + const incomingColors: ProductColorVariant[] = + Array.isArray(prod.colors) && prod.colors.length > 0 + ? prod.colors + : [ + { + id: `color-${Date.now()}-${existingColors.length + 1}`, + colorName: prod.colorName || `Estilo #${existingColors.length + 1}`, + image: prod.images[0], + images: prod.images, + sizes: prod.sizes || ['37', '38', '39', '40', '41', '42', '43'], + inStock: prod.inStock !== false, + }, + ]; + + // Agregar colorways que no estén ya presentes (por URL de imagen) + incomingColors.forEach((inc) => { + const isDuplicate = existingColors.some( + (ec) => ec.image === inc.image || ec.colorName.toLowerCase() === inc.colorName.toLowerCase() + ); + if (!isDuplicate) { + existingColors.push({ + ...inc, + id: inc.id || `color-${Date.now()}-${existingColors.length + 1}`, + }); + } + }); + + // Fusionar imágenes sin duplicados + const mergedImages = Array.from(new Set([...existing.images, ...prod.images])); + + // Fusionar tallas sin duplicados + const mergedSizes = Array.from(new Set([...existing.sizes, ...(prod.sizes || [])])).sort( + (a, b) => parseInt(a, 10) - parseInt(b, 10) + ); + + existing.colors = existingColors; + existing.images = mergedImages; + existing.sizes = mergedSizes; + } + } + + return Array.from(modelMap.values()); +}