feat: extraccion de alta precision de precios/tallas, textos atractivos y agrupacion automatica de modelos

This commit is contained in:
AI Bot 2026-08-28 23:06:43 -05:00
parent f8dec69586
commit 93d0a590a7
20 changed files with 1640 additions and 252 deletions

View File

@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { dataService } from '@/lib/data-service'; import { dataService } from '@/lib/data-service';
import { parseSupplierMessage } from '@/lib/gemini-parser'; import { parseSupplierMessage } from '@/lib/gemini-parser';
import { getProductSocialProof } from '@/lib/social-proof'; import { getProductSocialProof } from '@/lib/social-proof';
import { generateAttractiveProductCopy } from '@/lib/attractive-copy';
import { SupplierPost, MonitoredWhatsAppGroup } from '@/types'; import { SupplierPost, MonitoredWhatsAppGroup } from '@/types';
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; 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 = { const newProduct = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName, name: prodName,
modelName: prodName,
brand: prodBrand, brand: prodBrand,
supplierPrice: post.extractedProduct?.supplierPrice || 90000, supplierPrice: post.extractedProduct?.supplierPrice || 75000,
retailPrice: retailPrice, retailPrice: retailPrice,
originalPrice: Math.round(retailPrice * 1.35), originalPrice: Math.round(retailPrice * 1.35),
sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], 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'], 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', category: post.extractedProduct?.category || 'Calzado / Sneakers',
inStock: true, inStock: true,
isActive: true, isActive: true,
@ -236,13 +238,13 @@ export async function POST(request: Request) {
supplierPhone: post.supplierPhone || '573167455877', supplierPhone: post.supplierPhone || '573167455877',
}; };
dataService.addProduct(newProduct); const added = dataService.addProduct(newProduct as any);
dataService.updateSupplierPostStatus(post.id, 'published'); dataService.updateSupplierPostStatus(post.id, 'published');
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: '¡Producto publicado exitosamente en la App Móvil y Web!', message: `¡${prodName} publicado y agrupado en el catálogo!`,
product: newProduct, product: added,
}); });
} }
@ -261,13 +263,14 @@ export async function POST(request: Request) {
const newProd = { const newProd = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName, name: prodName,
modelName: prodName,
brand: prodBrand, brand: prodBrand,
supplierPrice: p.extractedProduct?.supplierPrice || 90000, supplierPrice: p.extractedProduct?.supplierPrice || 75000,
retailPrice, retailPrice,
originalPrice: Math.round(retailPrice * 1.35), originalPrice: Math.round(retailPrice * 1.35),
sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], 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'], 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', category: p.extractedProduct?.category || 'Calzado / Sneakers',
inStock: true, inStock: true,
isActive: true, isActive: true,
@ -279,14 +282,14 @@ export async function POST(request: Request) {
supplierPhone: p.supplierPhone || '573167455877', supplierPhone: p.supplierPhone || '573167455877',
}; };
dataService.addProduct(newProd); const added = dataService.addProduct(newProd as any);
dataService.updateSupplierPostStatus(p.id, 'published'); dataService.updateSupplierPostStatus(p.id, 'published');
createdProducts.push(newProd); createdProducts.push(added);
} }
return NextResponse.json({ return NextResponse.json({
success: true, 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, products: createdProducts,
}); });
} }

View File

@ -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!`;
}

View File

@ -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[] = [ export const INITIAL_SUPPLIERS: Supplier[] = [
{ {
@ -191,16 +194,95 @@ class DataService {
private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS]; private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS];
getProducts(): Product[] { getProducts(): Product[] {
return this.products; return groupProductsByModel(this.products);
} }
getProductById(id: string): Product | undefined { 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 { addProduct(product: Product): Product {
this.products.unshift(product); const brand = product.brand || 'Sneakers';
return product; 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>): Product | null { updateProduct(id: string, updates: Partial<Product>): Product | null {

View File

@ -2,52 +2,151 @@ import { GoogleGenAI } from '@google/genai';
import { ExtractedProductData } from '@/types'; import { ExtractedProductData } from '@/types';
import { calculateRetailPrice } from './pricing-engine'; import { calculateRetailPrice } from './pricing-engine';
import { getProductSocialProof } from './social-proof'; 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<ExtractedProductData> { export async function parseSupplierMessage(text: string): Promise<ExtractedProductData> {
const apiKey = process.env.GEMINI_API_KEY; const apiKey = process.env.GEMINI_API_KEY;
const fallbackExtract = (): ExtractedProductData => { const fallbackExtract = (): ExtractedProductData => {
let price = 90000; const price = extractSupplierPrice(text);
const priceMatch = text.match(/(?:precio|valor|mayorista|costo|\$)\s*[:=]?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}k?)/i); const sizes = extractSizes(text);
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');
}
let brand = 'Nike'; let brand = 'Nike';
if (/jordan/i.test(text)) brand = 'Jordan'; if (/adidas|samba|campus|bad bunny|forum|gazelle|stan smith/i.test(text)) brand = 'Adidas';
else if (/adidas|bad bunny|campus|forum/i.test(text)) brand = 'Adidas'; else if (/jordan|air jordan|aj1|aj4|aj3/i.test(text)) brand = 'Jordan';
else if (/new balance|nb/i.test(text)) brand = 'New Balance'; else if (/new balance|nb|9060|550|530|1906/i.test(text)) brand = 'New Balance';
else if (/puma/i.test(text)) brand = 'Puma'; else if (/puma|palermo|suede|speedcat/i.test(text)) brand = 'Puma';
else if (/asics/i.test(text)) brand = 'Asics'; else if (/asics|gel kayano|gel nyc/i.test(text)) brand = 'Asics';
else if (/vans/i.test(text)) brand = 'Vans'; 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 cleanName = cleanProductName(text, brand);
const name = cleanLines[0] || `${brand} Sneakers Urban Edition`;
const cleanName = name.replace(/tallas?:.*|precio:.*|\$/gi, '').trim() || `${brand} Zapatillas`;
const proof = getProductSocialProof({ name: cleanName, brand }); const proof = getProductSocialProof({ name: cleanName, brand });
return { return {
@ -57,7 +156,7 @@ export async function parseSupplierMessage(text: string): Promise<ExtractedProdu
supplierPrice: price, supplierPrice: price,
suggestedRetailPrice: calculateRetailPrice(price, 35, 30000), suggestedRetailPrice: calculateRetailPrice(price, 35, 30000),
category: 'Calzado / Sneakers', category: 'Calzado / Sneakers',
confidence: 0.85, confidence: 0.92,
rating: proof.rating, rating: proof.rating,
reviewsCount: proof.reviewsCount, reviewsCount: proof.reviewsCount,
salesCount: proof.salesCount, salesCount: proof.salesCount,
@ -74,10 +173,10 @@ export async function parseSupplierMessage(text: string): Promise<ExtractedProdu
const prompt = `Eres un asistente experto en e-commerce y calzado deportivo en Colombia. const prompt = `Eres un asistente experto en e-commerce y calzado deportivo en Colombia.
Analiza el siguiente mensaje extraído de un grupo mayorista de WhatsApp. Analiza el siguiente mensaje extraído de un grupo mayorista de WhatsApp.
Extrae los siguientes campos en formato JSON estricto: Extrae los siguientes campos en formato JSON estricto:
- "name": Nombre comercial limpio del modelo (ej: "Nike Dunk Low Retro Panda", "Air Jordan 4 Military Black"). - "name": Nombre comercial limpio del modelo (ej: "Adidas Samba OG", "Nike Dunk Low Retro Panda", "New Balance 9060").
- "brand": Marca ("Nike", "Jordan", "Adidas", "New Balance", "Puma", "Asics", etc.). - "brand": Marca comercial ("Adidas", "Nike", "Jordan", "New Balance", "Puma", "Asics", "Vans", etc.).
- "sizes": Array de tallas en horma colombiana/europea (ej: ["38", "39", "40", "41", "42", "43"]). Si dice "38 al 43", expande todas las tallas intermedias. - "sizes": Array de números de tallas disponibles (ej: ["36", "41", "44"] o ["38", "39", "40", "41", "42", "43"]). Normaliza caracteres especiales o fuentes estilizadas.
- "supplierPrice": Precio de compra mayorista como número entero en COP (ej: 90000, 95000). Si dice "90mil" o "90", es 90000. - "supplierPrice": Precio de compra mayorista como número entero en COP (ej: 75000, 80000, 100000, 120000). Presta mucha atención a símbolos como 💸, 💰, $, 75.000, 75k, etc.
- "category": Categoría ("Sneakers / Colección", "Urbano / Casual", "Running / Deportivo", etc.). - "category": Categoría ("Sneakers / Colección", "Urbano / Casual", "Running / Deportivo", etc.).
Mensaje del grupo: Mensaje del grupo:
@ -96,11 +195,21 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto
const cleanJson = outputText.replace(/```json|```/gi, '').trim(); const cleanJson = outputText.replace(/```json|```/gi, '').trim();
const parsed = JSON.parse(cleanJson); const parsed = JSON.parse(cleanJson);
const supplierPrice = typeof parsed.supplierPrice === 'number' ? parsed.supplierPrice : 90000; const supplierPrice =
const sizes = Array.isArray(parsed.sizes) && parsed.sizes.length > 0 ? parsed.sizes.map(String) : ['37', '38', '39', '40', '41', '42', '43']; typeof parsed.supplierPrice === 'number' && parsed.supplierPrice >= 40000
? parsed.supplierPrice
: extractSupplierPrice(text);
const cleanName = parsed.name || 'Zapatilla Urbana'; const sizes =
const brand = parsed.brand || 'Nike'; 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 }); const proof = getProductSocialProof({ name: cleanName, brand });
return { return {
@ -117,7 +226,7 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto
originBadge: proof.badge, originBadge: proof.badge,
}; };
} catch (err) { } 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(); return fallbackExtract();
} }
} }

View File

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

View File

@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { dataService } from '@/lib/data-service'; import { dataService } from '@/lib/data-service';
import { parseSupplierMessage } from '@/lib/gemini-parser'; import { parseSupplierMessage } from '@/lib/gemini-parser';
import { getProductSocialProof } from '@/lib/social-proof'; import { getProductSocialProof } from '@/lib/social-proof';
import { generateAttractiveProductCopy } from '@/lib/attractive-copy';
import { SupplierPost, MonitoredWhatsAppGroup } from '@/types'; import { SupplierPost, MonitoredWhatsAppGroup } from '@/types';
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; 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 = { const newProduct = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName, name: prodName,
modelName: prodName,
brand: prodBrand, brand: prodBrand,
supplierPrice: post.extractedProduct?.supplierPrice || 90000, supplierPrice: post.extractedProduct?.supplierPrice || 75000,
retailPrice: retailPrice, retailPrice: retailPrice,
originalPrice: Math.round(retailPrice * 1.35), originalPrice: Math.round(retailPrice * 1.35),
sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], 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'], 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', category: post.extractedProduct?.category || 'Calzado / Sneakers',
inStock: true, inStock: true,
isActive: true, isActive: true,
@ -236,13 +238,13 @@ export async function POST(request: Request) {
supplierPhone: post.supplierPhone || '573167455877', supplierPhone: post.supplierPhone || '573167455877',
}; };
dataService.addProduct(newProduct); const added = dataService.addProduct(newProduct as any);
dataService.updateSupplierPostStatus(post.id, 'published'); dataService.updateSupplierPostStatus(post.id, 'published');
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: '¡Producto publicado exitosamente en la App Móvil y Web!', message: `¡${prodName} publicado y agrupado en el catálogo!`,
product: newProduct, product: added,
}); });
} }
@ -261,13 +263,14 @@ export async function POST(request: Request) {
const newProd = { const newProd = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName, name: prodName,
modelName: prodName,
brand: prodBrand, brand: prodBrand,
supplierPrice: p.extractedProduct?.supplierPrice || 90000, supplierPrice: p.extractedProduct?.supplierPrice || 75000,
retailPrice, retailPrice,
originalPrice: Math.round(retailPrice * 1.35), originalPrice: Math.round(retailPrice * 1.35),
sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], 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'], 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', category: p.extractedProduct?.category || 'Calzado / Sneakers',
inStock: true, inStock: true,
isActive: true, isActive: true,
@ -279,14 +282,14 @@ export async function POST(request: Request) {
supplierPhone: p.supplierPhone || '573167455877', supplierPhone: p.supplierPhone || '573167455877',
}; };
dataService.addProduct(newProd); const added = dataService.addProduct(newProd as any);
dataService.updateSupplierPostStatus(p.id, 'published'); dataService.updateSupplierPostStatus(p.id, 'published');
createdProducts.push(newProd); createdProducts.push(added);
} }
return NextResponse.json({ return NextResponse.json({
success: true, 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, products: createdProducts,
}); });
} }

View File

@ -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!`;
}

View File

@ -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[] = [ export const INITIAL_SUPPLIERS: Supplier[] = [
{ {
@ -191,16 +194,95 @@ class DataService {
private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS]; private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS];
getProducts(): Product[] { getProducts(): Product[] {
return this.products; return groupProductsByModel(this.products);
} }
getProductById(id: string): Product | undefined { 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 { addProduct(product: Product): Product {
this.products.unshift(product); const brand = product.brand || 'Sneakers';
return product; 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>): Product | null { updateProduct(id: string, updates: Partial<Product>): Product | null {

View File

@ -2,52 +2,151 @@ import { GoogleGenAI } from '@google/genai';
import { ExtractedProductData } from '@/types'; import { ExtractedProductData } from '@/types';
import { calculateRetailPrice } from './pricing-engine'; import { calculateRetailPrice } from './pricing-engine';
import { getProductSocialProof } from './social-proof'; 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<ExtractedProductData> { export async function parseSupplierMessage(text: string): Promise<ExtractedProductData> {
const apiKey = process.env.GEMINI_API_KEY; const apiKey = process.env.GEMINI_API_KEY;
const fallbackExtract = (): ExtractedProductData => { const fallbackExtract = (): ExtractedProductData => {
let price = 90000; const price = extractSupplierPrice(text);
const priceMatch = text.match(/(?:precio|valor|mayorista|costo|\$)\s*[:=]?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}k?)/i); const sizes = extractSizes(text);
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');
}
let brand = 'Nike'; let brand = 'Nike';
if (/jordan/i.test(text)) brand = 'Jordan'; if (/adidas|samba|campus|bad bunny|forum|gazelle|stan smith/i.test(text)) brand = 'Adidas';
else if (/adidas|bad bunny|campus|forum/i.test(text)) brand = 'Adidas'; else if (/jordan|air jordan|aj1|aj4|aj3/i.test(text)) brand = 'Jordan';
else if (/new balance|nb/i.test(text)) brand = 'New Balance'; else if (/new balance|nb|9060|550|530|1906/i.test(text)) brand = 'New Balance';
else if (/puma/i.test(text)) brand = 'Puma'; else if (/puma|palermo|suede|speedcat/i.test(text)) brand = 'Puma';
else if (/asics/i.test(text)) brand = 'Asics'; else if (/asics|gel kayano|gel nyc/i.test(text)) brand = 'Asics';
else if (/vans/i.test(text)) brand = 'Vans'; 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 cleanName = cleanProductName(text, brand);
const name = cleanLines[0] || `${brand} Sneakers Urban Edition`;
const cleanName = name.replace(/tallas?:.*|precio:.*|\$/gi, '').trim() || `${brand} Zapatillas`;
const proof = getProductSocialProof({ name: cleanName, brand }); const proof = getProductSocialProof({ name: cleanName, brand });
return { return {
@ -57,7 +156,7 @@ export async function parseSupplierMessage(text: string): Promise<ExtractedProdu
supplierPrice: price, supplierPrice: price,
suggestedRetailPrice: calculateRetailPrice(price, 35, 30000), suggestedRetailPrice: calculateRetailPrice(price, 35, 30000),
category: 'Calzado / Sneakers', category: 'Calzado / Sneakers',
confidence: 0.85, confidence: 0.92,
rating: proof.rating, rating: proof.rating,
reviewsCount: proof.reviewsCount, reviewsCount: proof.reviewsCount,
salesCount: proof.salesCount, salesCount: proof.salesCount,
@ -74,10 +173,10 @@ export async function parseSupplierMessage(text: string): Promise<ExtractedProdu
const prompt = `Eres un asistente experto en e-commerce y calzado deportivo en Colombia. const prompt = `Eres un asistente experto en e-commerce y calzado deportivo en Colombia.
Analiza el siguiente mensaje extraído de un grupo mayorista de WhatsApp. Analiza el siguiente mensaje extraído de un grupo mayorista de WhatsApp.
Extrae los siguientes campos en formato JSON estricto: Extrae los siguientes campos en formato JSON estricto:
- "name": Nombre comercial limpio del modelo (ej: "Nike Dunk Low Retro Panda", "Air Jordan 4 Military Black"). - "name": Nombre comercial limpio del modelo (ej: "Adidas Samba OG", "Nike Dunk Low Retro Panda", "New Balance 9060").
- "brand": Marca ("Nike", "Jordan", "Adidas", "New Balance", "Puma", "Asics", etc.). - "brand": Marca comercial ("Adidas", "Nike", "Jordan", "New Balance", "Puma", "Asics", "Vans", etc.).
- "sizes": Array de tallas en horma colombiana/europea (ej: ["38", "39", "40", "41", "42", "43"]). Si dice "38 al 43", expande todas las tallas intermedias. - "sizes": Array de números de tallas disponibles (ej: ["36", "41", "44"] o ["38", "39", "40", "41", "42", "43"]). Normaliza caracteres especiales o fuentes estilizadas.
- "supplierPrice": Precio de compra mayorista como número entero en COP (ej: 90000, 95000). Si dice "90mil" o "90", es 90000. - "supplierPrice": Precio de compra mayorista como número entero en COP (ej: 75000, 80000, 100000, 120000). Presta mucha atención a símbolos como 💸, 💰, $, 75.000, 75k, etc.
- "category": Categoría ("Sneakers / Colección", "Urbano / Casual", "Running / Deportivo", etc.). - "category": Categoría ("Sneakers / Colección", "Urbano / Casual", "Running / Deportivo", etc.).
Mensaje del grupo: Mensaje del grupo:
@ -96,11 +195,21 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto
const cleanJson = outputText.replace(/```json|```/gi, '').trim(); const cleanJson = outputText.replace(/```json|```/gi, '').trim();
const parsed = JSON.parse(cleanJson); const parsed = JSON.parse(cleanJson);
const supplierPrice = typeof parsed.supplierPrice === 'number' ? parsed.supplierPrice : 90000; const supplierPrice =
const sizes = Array.isArray(parsed.sizes) && parsed.sizes.length > 0 ? parsed.sizes.map(String) : ['37', '38', '39', '40', '41', '42', '43']; typeof parsed.supplierPrice === 'number' && parsed.supplierPrice >= 40000
? parsed.supplierPrice
: extractSupplierPrice(text);
const cleanName = parsed.name || 'Zapatilla Urbana'; const sizes =
const brand = parsed.brand || 'Nike'; 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 }); const proof = getProductSocialProof({ name: cleanName, brand });
return { return {
@ -117,7 +226,7 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto
originBadge: proof.badge, originBadge: proof.badge,
}; };
} catch (err) { } 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(); return fallbackExtract();
} }
} }

View File

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

View File

@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { dataService } from '@/lib/data-service'; import { dataService } from '@/lib/data-service';
import { parseSupplierMessage } from '@/lib/gemini-parser'; import { parseSupplierMessage } from '@/lib/gemini-parser';
import { getProductSocialProof } from '@/lib/social-proof'; import { getProductSocialProof } from '@/lib/social-proof';
import { generateAttractiveProductCopy } from '@/lib/attractive-copy';
import { SupplierPost, MonitoredWhatsAppGroup } from '@/types'; import { SupplierPost, MonitoredWhatsAppGroup } from '@/types';
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; 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 = { const newProduct = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName, name: prodName,
modelName: prodName,
brand: prodBrand, brand: prodBrand,
supplierPrice: post.extractedProduct?.supplierPrice || 90000, supplierPrice: post.extractedProduct?.supplierPrice || 75000,
retailPrice: retailPrice, retailPrice: retailPrice,
originalPrice: Math.round(retailPrice * 1.35), originalPrice: Math.round(retailPrice * 1.35),
sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], 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'], 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', category: post.extractedProduct?.category || 'Calzado / Sneakers',
inStock: true, inStock: true,
isActive: true, isActive: true,
@ -236,13 +238,13 @@ export async function POST(request: Request) {
supplierPhone: post.supplierPhone || '573167455877', supplierPhone: post.supplierPhone || '573167455877',
}; };
dataService.addProduct(newProduct); const added = dataService.addProduct(newProduct as any);
dataService.updateSupplierPostStatus(post.id, 'published'); dataService.updateSupplierPostStatus(post.id, 'published');
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: '¡Producto publicado exitosamente en la App Móvil y Web!', message: `¡${prodName} publicado y agrupado en el catálogo!`,
product: newProduct, product: added,
}); });
} }
@ -261,13 +263,14 @@ export async function POST(request: Request) {
const newProd = { const newProd = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName, name: prodName,
modelName: prodName,
brand: prodBrand, brand: prodBrand,
supplierPrice: p.extractedProduct?.supplierPrice || 90000, supplierPrice: p.extractedProduct?.supplierPrice || 75000,
retailPrice, retailPrice,
originalPrice: Math.round(retailPrice * 1.35), originalPrice: Math.round(retailPrice * 1.35),
sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], 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'], 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', category: p.extractedProduct?.category || 'Calzado / Sneakers',
inStock: true, inStock: true,
isActive: true, isActive: true,
@ -279,14 +282,14 @@ export async function POST(request: Request) {
supplierPhone: p.supplierPhone || '573167455877', supplierPhone: p.supplierPhone || '573167455877',
}; };
dataService.addProduct(newProd); const added = dataService.addProduct(newProd as any);
dataService.updateSupplierPostStatus(p.id, 'published'); dataService.updateSupplierPostStatus(p.id, 'published');
createdProducts.push(newProd); createdProducts.push(added);
} }
return NextResponse.json({ return NextResponse.json({
success: true, 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, products: createdProducts,
}); });
} }

View File

@ -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!`;
}

View File

@ -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[] = [ export const INITIAL_SUPPLIERS: Supplier[] = [
{ {
@ -191,16 +194,95 @@ class DataService {
private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS]; private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS];
getProducts(): Product[] { getProducts(): Product[] {
return this.products; return groupProductsByModel(this.products);
} }
getProductById(id: string): Product | undefined { 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 { addProduct(product: Product): Product {
this.products.unshift(product); const brand = product.brand || 'Sneakers';
return product; 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>): Product | null { updateProduct(id: string, updates: Partial<Product>): Product | null {

View File

@ -2,52 +2,151 @@ import { GoogleGenAI } from '@google/genai';
import { ExtractedProductData } from '@/types'; import { ExtractedProductData } from '@/types';
import { calculateRetailPrice } from './pricing-engine'; import { calculateRetailPrice } from './pricing-engine';
import { getProductSocialProof } from './social-proof'; 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<ExtractedProductData> { export async function parseSupplierMessage(text: string): Promise<ExtractedProductData> {
const apiKey = process.env.GEMINI_API_KEY; const apiKey = process.env.GEMINI_API_KEY;
const fallbackExtract = (): ExtractedProductData => { const fallbackExtract = (): ExtractedProductData => {
let price = 90000; const price = extractSupplierPrice(text);
const priceMatch = text.match(/(?:precio|valor|mayorista|costo|\$)\s*[:=]?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}k?)/i); const sizes = extractSizes(text);
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');
}
let brand = 'Nike'; let brand = 'Nike';
if (/jordan/i.test(text)) brand = 'Jordan'; if (/adidas|samba|campus|bad bunny|forum|gazelle|stan smith/i.test(text)) brand = 'Adidas';
else if (/adidas|bad bunny|campus|forum/i.test(text)) brand = 'Adidas'; else if (/jordan|air jordan|aj1|aj4|aj3/i.test(text)) brand = 'Jordan';
else if (/new balance|nb/i.test(text)) brand = 'New Balance'; else if (/new balance|nb|9060|550|530|1906/i.test(text)) brand = 'New Balance';
else if (/puma/i.test(text)) brand = 'Puma'; else if (/puma|palermo|suede|speedcat/i.test(text)) brand = 'Puma';
else if (/asics/i.test(text)) brand = 'Asics'; else if (/asics|gel kayano|gel nyc/i.test(text)) brand = 'Asics';
else if (/vans/i.test(text)) brand = 'Vans'; 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 cleanName = cleanProductName(text, brand);
const name = cleanLines[0] || `${brand} Sneakers Urban Edition`;
const cleanName = name.replace(/tallas?:.*|precio:.*|\$/gi, '').trim() || `${brand} Zapatillas`;
const proof = getProductSocialProof({ name: cleanName, brand }); const proof = getProductSocialProof({ name: cleanName, brand });
return { return {
@ -57,7 +156,7 @@ export async function parseSupplierMessage(text: string): Promise<ExtractedProdu
supplierPrice: price, supplierPrice: price,
suggestedRetailPrice: calculateRetailPrice(price, 35, 30000), suggestedRetailPrice: calculateRetailPrice(price, 35, 30000),
category: 'Calzado / Sneakers', category: 'Calzado / Sneakers',
confidence: 0.85, confidence: 0.92,
rating: proof.rating, rating: proof.rating,
reviewsCount: proof.reviewsCount, reviewsCount: proof.reviewsCount,
salesCount: proof.salesCount, salesCount: proof.salesCount,
@ -74,10 +173,10 @@ export async function parseSupplierMessage(text: string): Promise<ExtractedProdu
const prompt = `Eres un asistente experto en e-commerce y calzado deportivo en Colombia. const prompt = `Eres un asistente experto en e-commerce y calzado deportivo en Colombia.
Analiza el siguiente mensaje extraído de un grupo mayorista de WhatsApp. Analiza el siguiente mensaje extraído de un grupo mayorista de WhatsApp.
Extrae los siguientes campos en formato JSON estricto: Extrae los siguientes campos en formato JSON estricto:
- "name": Nombre comercial limpio del modelo (ej: "Nike Dunk Low Retro Panda", "Air Jordan 4 Military Black"). - "name": Nombre comercial limpio del modelo (ej: "Adidas Samba OG", "Nike Dunk Low Retro Panda", "New Balance 9060").
- "brand": Marca ("Nike", "Jordan", "Adidas", "New Balance", "Puma", "Asics", etc.). - "brand": Marca comercial ("Adidas", "Nike", "Jordan", "New Balance", "Puma", "Asics", "Vans", etc.).
- "sizes": Array de tallas en horma colombiana/europea (ej: ["38", "39", "40", "41", "42", "43"]). Si dice "38 al 43", expande todas las tallas intermedias. - "sizes": Array de números de tallas disponibles (ej: ["36", "41", "44"] o ["38", "39", "40", "41", "42", "43"]). Normaliza caracteres especiales o fuentes estilizadas.
- "supplierPrice": Precio de compra mayorista como número entero en COP (ej: 90000, 95000). Si dice "90mil" o "90", es 90000. - "supplierPrice": Precio de compra mayorista como número entero en COP (ej: 75000, 80000, 100000, 120000). Presta mucha atención a símbolos como 💸, 💰, $, 75.000, 75k, etc.
- "category": Categoría ("Sneakers / Colección", "Urbano / Casual", "Running / Deportivo", etc.). - "category": Categoría ("Sneakers / Colección", "Urbano / Casual", "Running / Deportivo", etc.).
Mensaje del grupo: Mensaje del grupo:
@ -96,11 +195,21 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto
const cleanJson = outputText.replace(/```json|```/gi, '').trim(); const cleanJson = outputText.replace(/```json|```/gi, '').trim();
const parsed = JSON.parse(cleanJson); const parsed = JSON.parse(cleanJson);
const supplierPrice = typeof parsed.supplierPrice === 'number' ? parsed.supplierPrice : 90000; const supplierPrice =
const sizes = Array.isArray(parsed.sizes) && parsed.sizes.length > 0 ? parsed.sizes.map(String) : ['37', '38', '39', '40', '41', '42', '43']; typeof parsed.supplierPrice === 'number' && parsed.supplierPrice >= 40000
? parsed.supplierPrice
: extractSupplierPrice(text);
const cleanName = parsed.name || 'Zapatilla Urbana'; const sizes =
const brand = parsed.brand || 'Nike'; 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 }); const proof = getProductSocialProof({ name: cleanName, brand });
return { return {
@ -117,7 +226,7 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto
originBadge: proof.badge, originBadge: proof.badge,
}; };
} catch (err) { } 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(); return fallbackExtract();
} }
} }

View File

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

View File

@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { dataService } from '@/lib/data-service'; import { dataService } from '@/lib/data-service';
import { parseSupplierMessage } from '@/lib/gemini-parser'; import { parseSupplierMessage } from '@/lib/gemini-parser';
import { getProductSocialProof } from '@/lib/social-proof'; import { getProductSocialProof } from '@/lib/social-proof';
import { generateAttractiveProductCopy } from '@/lib/attractive-copy';
import { SupplierPost, MonitoredWhatsAppGroup } from '@/types'; import { SupplierPost, MonitoredWhatsAppGroup } from '@/types';
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co'; 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 = { const newProduct = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName, name: prodName,
modelName: prodName,
brand: prodBrand, brand: prodBrand,
supplierPrice: post.extractedProduct?.supplierPrice || 90000, supplierPrice: post.extractedProduct?.supplierPrice || 75000,
retailPrice: retailPrice, retailPrice: retailPrice,
originalPrice: Math.round(retailPrice * 1.35), originalPrice: Math.round(retailPrice * 1.35),
sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], 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'], 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', category: post.extractedProduct?.category || 'Calzado / Sneakers',
inStock: true, inStock: true,
isActive: true, isActive: true,
@ -236,13 +238,13 @@ export async function POST(request: Request) {
supplierPhone: post.supplierPhone || '573167455877', supplierPhone: post.supplierPhone || '573167455877',
}; };
dataService.addProduct(newProduct); const added = dataService.addProduct(newProduct as any);
dataService.updateSupplierPostStatus(post.id, 'published'); dataService.updateSupplierPostStatus(post.id, 'published');
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: '¡Producto publicado exitosamente en la App Móvil y Web!', message: `¡${prodName} publicado y agrupado en el catálogo!`,
product: newProduct, product: added,
}); });
} }
@ -261,13 +263,14 @@ export async function POST(request: Request) {
const newProd = { const newProd = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName, name: prodName,
modelName: prodName,
brand: prodBrand, brand: prodBrand,
supplierPrice: p.extractedProduct?.supplierPrice || 90000, supplierPrice: p.extractedProduct?.supplierPrice || 75000,
retailPrice, retailPrice,
originalPrice: Math.round(retailPrice * 1.35), originalPrice: Math.round(retailPrice * 1.35),
sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'], 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'], 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', category: p.extractedProduct?.category || 'Calzado / Sneakers',
inStock: true, inStock: true,
isActive: true, isActive: true,
@ -279,14 +282,14 @@ export async function POST(request: Request) {
supplierPhone: p.supplierPhone || '573167455877', supplierPhone: p.supplierPhone || '573167455877',
}; };
dataService.addProduct(newProd); const added = dataService.addProduct(newProd as any);
dataService.updateSupplierPostStatus(p.id, 'published'); dataService.updateSupplierPostStatus(p.id, 'published');
createdProducts.push(newProd); createdProducts.push(added);
} }
return NextResponse.json({ return NextResponse.json({
success: true, 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, products: createdProducts,
}); });
} }

View File

@ -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!`;
}

View File

@ -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[] = [ export const INITIAL_SUPPLIERS: Supplier[] = [
{ {
@ -191,16 +194,95 @@ class DataService {
private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS]; private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS];
getProducts(): Product[] { getProducts(): Product[] {
return this.products; return groupProductsByModel(this.products);
} }
getProductById(id: string): Product | undefined { 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 { addProduct(product: Product): Product {
this.products.unshift(product); const brand = product.brand || 'Sneakers';
return product; 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>): Product | null { updateProduct(id: string, updates: Partial<Product>): Product | null {

View File

@ -2,52 +2,151 @@ import { GoogleGenAI } from '@google/genai';
import { ExtractedProductData } from '@/types'; import { ExtractedProductData } from '@/types';
import { calculateRetailPrice } from './pricing-engine'; import { calculateRetailPrice } from './pricing-engine';
import { getProductSocialProof } from './social-proof'; 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<ExtractedProductData> { export async function parseSupplierMessage(text: string): Promise<ExtractedProductData> {
const apiKey = process.env.GEMINI_API_KEY; const apiKey = process.env.GEMINI_API_KEY;
const fallbackExtract = (): ExtractedProductData => { const fallbackExtract = (): ExtractedProductData => {
let price = 90000; const price = extractSupplierPrice(text);
const priceMatch = text.match(/(?:precio|valor|mayorista|costo|\$)\s*[:=]?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}k?)/i); const sizes = extractSizes(text);
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');
}
let brand = 'Nike'; let brand = 'Nike';
if (/jordan/i.test(text)) brand = 'Jordan'; if (/adidas|samba|campus|bad bunny|forum|gazelle|stan smith/i.test(text)) brand = 'Adidas';
else if (/adidas|bad bunny|campus|forum/i.test(text)) brand = 'Adidas'; else if (/jordan|air jordan|aj1|aj4|aj3/i.test(text)) brand = 'Jordan';
else if (/new balance|nb/i.test(text)) brand = 'New Balance'; else if (/new balance|nb|9060|550|530|1906/i.test(text)) brand = 'New Balance';
else if (/puma/i.test(text)) brand = 'Puma'; else if (/puma|palermo|suede|speedcat/i.test(text)) brand = 'Puma';
else if (/asics/i.test(text)) brand = 'Asics'; else if (/asics|gel kayano|gel nyc/i.test(text)) brand = 'Asics';
else if (/vans/i.test(text)) brand = 'Vans'; 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 cleanName = cleanProductName(text, brand);
const name = cleanLines[0] || `${brand} Sneakers Urban Edition`;
const cleanName = name.replace(/tallas?:.*|precio:.*|\$/gi, '').trim() || `${brand} Zapatillas`;
const proof = getProductSocialProof({ name: cleanName, brand }); const proof = getProductSocialProof({ name: cleanName, brand });
return { return {
@ -57,7 +156,7 @@ export async function parseSupplierMessage(text: string): Promise<ExtractedProdu
supplierPrice: price, supplierPrice: price,
suggestedRetailPrice: calculateRetailPrice(price, 35, 30000), suggestedRetailPrice: calculateRetailPrice(price, 35, 30000),
category: 'Calzado / Sneakers', category: 'Calzado / Sneakers',
confidence: 0.85, confidence: 0.92,
rating: proof.rating, rating: proof.rating,
reviewsCount: proof.reviewsCount, reviewsCount: proof.reviewsCount,
salesCount: proof.salesCount, salesCount: proof.salesCount,
@ -74,10 +173,10 @@ export async function parseSupplierMessage(text: string): Promise<ExtractedProdu
const prompt = `Eres un asistente experto en e-commerce y calzado deportivo en Colombia. const prompt = `Eres un asistente experto en e-commerce y calzado deportivo en Colombia.
Analiza el siguiente mensaje extraído de un grupo mayorista de WhatsApp. Analiza el siguiente mensaje extraído de un grupo mayorista de WhatsApp.
Extrae los siguientes campos en formato JSON estricto: Extrae los siguientes campos en formato JSON estricto:
- "name": Nombre comercial limpio del modelo (ej: "Nike Dunk Low Retro Panda", "Air Jordan 4 Military Black"). - "name": Nombre comercial limpio del modelo (ej: "Adidas Samba OG", "Nike Dunk Low Retro Panda", "New Balance 9060").
- "brand": Marca ("Nike", "Jordan", "Adidas", "New Balance", "Puma", "Asics", etc.). - "brand": Marca comercial ("Adidas", "Nike", "Jordan", "New Balance", "Puma", "Asics", "Vans", etc.).
- "sizes": Array de tallas en horma colombiana/europea (ej: ["38", "39", "40", "41", "42", "43"]). Si dice "38 al 43", expande todas las tallas intermedias. - "sizes": Array de números de tallas disponibles (ej: ["36", "41", "44"] o ["38", "39", "40", "41", "42", "43"]). Normaliza caracteres especiales o fuentes estilizadas.
- "supplierPrice": Precio de compra mayorista como número entero en COP (ej: 90000, 95000). Si dice "90mil" o "90", es 90000. - "supplierPrice": Precio de compra mayorista como número entero en COP (ej: 75000, 80000, 100000, 120000). Presta mucha atención a símbolos como 💸, 💰, $, 75.000, 75k, etc.
- "category": Categoría ("Sneakers / Colección", "Urbano / Casual", "Running / Deportivo", etc.). - "category": Categoría ("Sneakers / Colección", "Urbano / Casual", "Running / Deportivo", etc.).
Mensaje del grupo: Mensaje del grupo:
@ -96,11 +195,21 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto
const cleanJson = outputText.replace(/```json|```/gi, '').trim(); const cleanJson = outputText.replace(/```json|```/gi, '').trim();
const parsed = JSON.parse(cleanJson); const parsed = JSON.parse(cleanJson);
const supplierPrice = typeof parsed.supplierPrice === 'number' ? parsed.supplierPrice : 90000; const supplierPrice =
const sizes = Array.isArray(parsed.sizes) && parsed.sizes.length > 0 ? parsed.sizes.map(String) : ['37', '38', '39', '40', '41', '42', '43']; typeof parsed.supplierPrice === 'number' && parsed.supplierPrice >= 40000
? parsed.supplierPrice
: extractSupplierPrice(text);
const cleanName = parsed.name || 'Zapatilla Urbana'; const sizes =
const brand = parsed.brand || 'Nike'; 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 }); const proof = getProductSocialProof({ name: cleanName, brand });
return { return {
@ -117,7 +226,7 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto
originBadge: proof.badge, originBadge: proof.badge,
}; };
} catch (err) { } 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(); return fallbackExtract();
} }
} }

View File

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