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 { parseSupplierMessage } from '@/lib/gemini-parser';
import { getProductSocialProof } from '@/lib/social-proof';
import { generateAttractiveProductCopy } from '@/lib/attractive-copy';
import { SupplierPost, MonitoredWhatsAppGroup } from '@/types';
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co';
@ -218,13 +219,14 @@ export async function POST(request: Request) {
const newProduct = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName,
modelName: prodName,
brand: prodBrand,
supplierPrice: post.extractedProduct?.supplierPrice || 90000,
supplierPrice: post.extractedProduct?.supplierPrice || 75000,
retailPrice: retailPrice,
originalPrice: Math.round(retailPrice * 1.35),
sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: Array.isArray(post.rawImages) && post.rawImages.length > 0 ? post.rawImages : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: `Calzado garantizado de alta calidad para despacho inmediato en 24 horas y pago contraentrega.`,
description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }),
category: post.extractedProduct?.category || 'Calzado / Sneakers',
inStock: true,
isActive: true,
@ -236,13 +238,13 @@ export async function POST(request: Request) {
supplierPhone: post.supplierPhone || '573167455877',
};
dataService.addProduct(newProduct);
const added = dataService.addProduct(newProduct as any);
dataService.updateSupplierPostStatus(post.id, 'published');
return NextResponse.json({
success: true,
message: '¡Producto publicado exitosamente en la App Móvil y Web!',
product: newProduct,
message: `¡${prodName} publicado y agrupado en el catálogo!`,
product: added,
});
}
@ -261,13 +263,14 @@ export async function POST(request: Request) {
const newProd = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName,
modelName: prodName,
brand: prodBrand,
supplierPrice: p.extractedProduct?.supplierPrice || 90000,
supplierPrice: p.extractedProduct?.supplierPrice || 75000,
retailPrice,
originalPrice: Math.round(retailPrice * 1.35),
sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: p.rawImages || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: `Calzado garantizado para despacho inmediato en 24 horas con pago contraentrega.`,
description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }),
category: p.extractedProduct?.category || 'Calzado / Sneakers',
inStock: true,
isActive: true,
@ -279,14 +282,14 @@ export async function POST(request: Request) {
supplierPhone: p.supplierPhone || '573167455877',
};
dataService.addProduct(newProd);
const added = dataService.addProduct(newProd as any);
dataService.updateSupplierPostStatus(p.id, 'published');
createdProducts.push(newProd);
createdProducts.push(added);
}
return NextResponse.json({
success: true,
message: `¡Se publicaron ${createdProducts.length} productos en la App Móvil y Web!`,
message: `¡Se publicaron y agruparon ${createdProducts.length} productos en la App Móvil y Web!`,
products: createdProducts,
});
}

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[] = [
{
@ -191,16 +194,95 @@ class DataService {
private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS];
getProducts(): Product[] {
return this.products;
return groupProductsByModel(this.products);
}
getProductById(id: string): Product | undefined {
return this.products.find((p) => p.id === id);
const allGrouped = groupProductsByModel(this.products);
return allGrouped.find((p) => p.id === id || p.colors?.some((c) => c.id === id));
}
addProduct(product: Product): Product {
this.products.unshift(product);
return product;
const brand = product.brand || 'Sneakers';
const baseModelName = product.modelName || product.name;
const modelKey = normalizeModelKey(baseModelName, brand);
// Buscar si ya existe un producto del mismo modelo
const existing = this.products.find(
(p) => normalizeModelKey(p.modelName || p.name, p.brand) === modelKey
);
if (existing) {
const existingColors: ProductColorVariant[] =
Array.isArray(existing.colors) && existing.colors.length > 0
? existing.colors
: [
{
id: `color-${existing.id}-1`,
colorName: existing.colorName || 'Estilo #1',
image: existing.images[0],
images: existing.images,
sizes: existing.sizes,
inStock: existing.inStock !== false,
},
];
const newImage = product.images[0] || (product.colors && product.colors[0]?.image);
const isDuplicate = existingColors.some(
(c) => c.image === newImage || (product.colorName && c.colorName.toLowerCase() === product.colorName.toLowerCase())
);
if (!isDuplicate && newImage) {
existingColors.push({
id: `color-${Date.now()}-${existingColors.length + 1}`,
colorName: product.colorName || `Estilo #${existingColors.length + 1}`,
image: newImage,
images: product.images.length > 0 ? product.images : [newImage],
sizes: product.sizes && product.sizes.length > 0 ? product.sizes : existing.sizes,
inStock: product.inStock !== false,
});
}
existing.colors = existingColors;
existing.images = Array.from(new Set([...existing.images, ...product.images]));
existing.sizes = Array.from(new Set([...existing.sizes, ...(product.sizes || [])])).sort(
(a, b) => parseInt(a, 10) - parseInt(b, 10)
);
if (!existing.description || existing.description.length < 50) {
existing.description = generateAttractiveProductCopy(existing);
}
return existing;
}
// Si es un modelo nuevo
const proof = getProductSocialProof(product);
const initialColors: ProductColorVariant[] =
Array.isArray(product.colors) && product.colors.length > 0
? product.colors
: [
{
id: `color-${Date.now()}-1`,
colorName: product.colorName || 'Estilo Principal',
image: product.images[0],
images: product.images,
sizes: product.sizes || ['37', '38', '39', '40', '41', '42', '43'],
inStock: product.inStock !== false,
},
];
const formattedProduct: Product = {
...product,
modelName: baseModelName,
colors: initialColors,
description: product.description || generateAttractiveProductCopy(product),
rating: product.rating || proof.rating,
reviewsCount: product.reviewsCount || proof.reviewsCount,
salesCount: product.salesCount || proof.salesCount,
originBadge: product.originBadge || proof.badge,
};
this.products.unshift(formattedProduct);
return formattedProduct;
}
updateProduct(id: string, updates: Partial<Product>): Product | null {

View File

@ -2,52 +2,151 @@ import { GoogleGenAI } from '@google/genai';
import { ExtractedProductData } from '@/types';
import { calculateRetailPrice } from './pricing-engine';
import { getProductSocialProof } from './social-proof';
import { generateAttractiveProductCopy } from './attractive-copy';
/**
* Extractor robusto de precios mayoristas en COP desde mensajes de WhatsApp
* Soporta emojis de dinero (💸, 💰, 💵, 💲), palabras clave, formatos con punto (75.000), 'k' (75k) y 'mil' (75 mil).
*/
export function extractSupplierPrice(text: string): number {
// 1. Buscar patrones con símbolos o emojis de dinero y palabras clave
const explicitPattern =
/(?:💸|💰|💵|💲|precio|valor|costo|mayorista|mayor|mayoristas|\$)+[\s:=]*\$?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}\s*k|\d{2,3}\s*mil|\d{5,6})/i;
const explicitMatch = text.match(explicitPattern);
if (explicitMatch) {
const raw = explicitMatch[1].toLowerCase().replace(/\s+/g, '').replace(/\./g, '');
if (raw.endsWith('k') || raw.endsWith('mil')) {
const num = parseInt(raw.replace('mil', '').replace('k', ''), 10);
if (!isNaN(num) && num > 0) return num < 1000 ? num * 1000 : num;
}
const num = parseInt(raw, 10);
if (!isNaN(num)) {
if (num >= 40 && num <= 350) return num * 1000;
if (num >= 40000 && num <= 500000) return num;
}
}
// 2. Buscar números con formato de miles (ej: 75.000, 80.000, 100.000, 125.500)
const dotThousands = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\.(000|500)\b/);
if (dotThousands) {
const num = parseInt(dotThousands[0].replace(/\./g, ''), 10);
if (!isNaN(num) && num >= 40000 && num <= 500000) return num;
}
// 3. Buscar valores con 'k' o 'mil' (ej: 75k, 80 mil, 95k)
const kMatch = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\s*(?:k|mil)\b/i);
if (kMatch) {
const num = parseInt(kMatch[1], 10);
if (!isNaN(num)) return num * 1000;
}
// 4. Buscar números de 5 o 6 dígitos en rango de precio razonable (40000 a 300000)
const rawNumbers = text.match(/\b(4[5-9]\d{3}|[5-9]\d{4}|1\d{5}|2\d{5})\b/g);
if (rawNumbers && rawNumbers.length > 0) {
const num = parseInt(rawNumbers[0], 10);
if (!isNaN(num)) return num;
}
return 75000; // Valor por defecto representativo para calzado nacional
}
/**
* Extractor de tallas con normalización Unicode (soporta small-caps como ᴛᴀʟʟᴀs ᴅɪsᴘᴏɴɪʙʟᴇs:36/41/44)
*/
export function extractSizes(rawText: string): string[] {
const normalized = rawText
.normalize('NFKD')
.replace(/[ᴛ]/g, 't')
.replace(/[ᴀ]/g, 'a')
.replace(/[ʟ]/g, 'l')
.replace(/[s]/g, 's')
.replace(/[ᴅ]/g, 'd')
.replace(/[ɪ]/g, 'i')
.replace(/[ᴘ]/g, 'p')
.replace(/[ᴏ]/g, 'o')
.replace(/[ɴ]/g, 'n')
.replace(/[ʙ]/g, 'b')
.replace(/[ᴇ]/g, 'e');
const sizes: string[] = [];
// Rango explícito (ej: 36 a 43, 36 al 43, 38-43)
const rangeMatch = normalized.match(
/(?:tallas?|eur|talla|disponibles?)?[^\d\n]*\b(3[4-9]|4[0-6])\s*(?:a|al|-)\s*(3[4-9]|4[0-6])\b/i
);
if (rangeMatch) {
const start = parseInt(rangeMatch[1], 10);
const end = parseInt(rangeMatch[2], 10);
if (start <= end && start >= 34 && end <= 46) {
for (let s = start; s <= end; s++) sizes.push(s.toString());
return sizes;
}
}
// Sección de tallas o texto completo
const tallaSection = normalized.match(/(?:tallas?|eur|disponibles?|talla)[^\n]*/i);
const textToScan = tallaSection ? tallaSection[0] : normalized;
// Extraer números individuales de 2 dígitos entre 34 y 46
const matches = textToScan.match(/(?:^|[^\d])(3[4-9]|4[0-6])(?=[^\d]|$)/g);
if (matches) {
matches.forEach((m) => {
const num = m.replace(/[^\d]/g, '');
if (num && !sizes.includes(num)) {
sizes.push(num);
}
});
}
if (sizes.length === 0) {
return ['37', '38', '39', '40', '41', '42', '43'];
}
return sizes.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
}
/**
* Limpia y formatea el nombre del producto desde el mensaje del proveedor
*/
export function cleanProductName(text: string, detectedBrand: string): string {
const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
for (const line of lines) {
const clean = line
.replace(/🔥|🔴|‼️|✨|🌸|👑|💎|🛍️|🌟|💸|💰|💵|💲|\*/g, '')
.replace(/dama\s*y\s*caballero|caballero|dama|unisex|recarga|nueva\s*mercanc[ií]a|[uú]ltima\s*unidad/gi, '')
.replace(/c[oó]digo\s*:\s*[\d-]+/gi, '')
.replace(/\b\d{2,3}[\.,]\d{3}\b|\b\d{2,3}k\b|\$\s*\d+/gi, '')
.replace(/tallas?:.*|precio:.*|eur\s*[\d\/-]+/gi, '')
.replace(/\s+/g, ' ')
.trim();
if (clean.length >= 3 && !/^\d+$/.test(clean) && !/^\d+[\.,]\d+$/.test(clean)) {
return clean
.split(' ')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(' ');
}
}
return `${detectedBrand} Urban Edition`;
}
export async function parseSupplierMessage(text: string): Promise<ExtractedProductData> {
const apiKey = process.env.GEMINI_API_KEY;
const fallbackExtract = (): ExtractedProductData => {
let price = 90000;
const priceMatch = text.match(/(?:precio|valor|mayorista|costo|\$)\s*[:=]?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}k?)/i);
if (priceMatch) {
const raw = priceMatch[1].toLowerCase().replace(/\./g, '');
if (raw.endsWith('k')) {
price = parseInt(raw.replace('k', ''), 10) * 1000;
} else {
const num = parseInt(raw, 10);
price = num < 1000 ? num * 1000 : num;
}
}
const sizes: string[] = [];
const sizeRangeMatch = text.match(/(\d{2})\s*(?:a|al|-)\s*(\d{2})/i);
if (sizeRangeMatch) {
const start = parseInt(sizeRangeMatch[1], 10);
const end = parseInt(sizeRangeMatch[2], 10);
if (start >= 34 && end <= 46 && start <= end) {
for (let s = start; s <= end; s++) sizes.push(s.toString());
}
} else {
const individualSizes = text.match(/\b(3[4-9]|4[0-6])\b/g);
if (individualSizes) {
sizes.push(...Array.from(new Set(individualSizes)));
}
}
if (sizes.length === 0) {
sizes.push('37', '38', '39', '40', '41', '42', '43');
}
const price = extractSupplierPrice(text);
const sizes = extractSizes(text);
let brand = 'Nike';
if (/jordan/i.test(text)) brand = 'Jordan';
else if (/adidas|bad bunny|campus|forum/i.test(text)) brand = 'Adidas';
else if (/new balance|nb/i.test(text)) brand = 'New Balance';
else if (/puma/i.test(text)) brand = 'Puma';
else if (/asics/i.test(text)) brand = 'Asics';
else if (/vans/i.test(text)) brand = 'Vans';
if (/adidas|samba|campus|bad bunny|forum|gazelle|stan smith/i.test(text)) brand = 'Adidas';
else if (/jordan|air jordan|aj1|aj4|aj3/i.test(text)) brand = 'Jordan';
else if (/new balance|nb|9060|550|530|1906/i.test(text)) brand = 'New Balance';
else if (/puma|palermo|suede|speedcat/i.test(text)) brand = 'Puma';
else if (/asics|gel kayano|gel nyc/i.test(text)) brand = 'Asics';
else if (/vans|old skool|knu skool/i.test(text)) brand = 'Vans';
else if (/coach/i.test(text)) brand = 'Coach';
const cleanLines = text.split('\n').filter((l) => l.trim().length > 0 && !/🔥|🔴|llegando|promo/i.test(l));
const name = cleanLines[0] || `${brand} Sneakers Urban Edition`;
const cleanName = name.replace(/tallas?:.*|precio:.*|\$/gi, '').trim() || `${brand} Zapatillas`;
const cleanName = cleanProductName(text, brand);
const proof = getProductSocialProof({ name: cleanName, brand });
return {
@ -57,7 +156,7 @@ export async function parseSupplierMessage(text: string): Promise<ExtractedProdu
supplierPrice: price,
suggestedRetailPrice: calculateRetailPrice(price, 35, 30000),
category: 'Calzado / Sneakers',
confidence: 0.85,
confidence: 0.92,
rating: proof.rating,
reviewsCount: proof.reviewsCount,
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.
Analiza el siguiente mensaje extraído de un grupo mayorista de WhatsApp.
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").
- "brand": Marca ("Nike", "Jordan", "Adidas", "New Balance", "Puma", "Asics", 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.
- "supplierPrice": Precio de compra mayorista como número entero en COP (ej: 90000, 95000). Si dice "90mil" o "90", es 90000.
- "name": Nombre comercial limpio del modelo (ej: "Adidas Samba OG", "Nike Dunk Low Retro Panda", "New Balance 9060").
- "brand": Marca comercial ("Adidas", "Nike", "Jordan", "New Balance", "Puma", "Asics", "Vans", etc.).
- "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: 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.).
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 parsed = JSON.parse(cleanJson);
const supplierPrice = typeof parsed.supplierPrice === 'number' ? parsed.supplierPrice : 90000;
const sizes = Array.isArray(parsed.sizes) && parsed.sizes.length > 0 ? parsed.sizes.map(String) : ['37', '38', '39', '40', '41', '42', '43'];
const supplierPrice =
typeof parsed.supplierPrice === 'number' && parsed.supplierPrice >= 40000
? parsed.supplierPrice
: extractSupplierPrice(text);
const cleanName = parsed.name || 'Zapatilla Urbana';
const brand = parsed.brand || 'Nike';
const sizes =
Array.isArray(parsed.sizes) && parsed.sizes.length > 0
? parsed.sizes.map(String)
: extractSizes(text);
let brand = parsed.brand || 'Nike';
if (/samba|campus|bad bunny|forum|gazelle/i.test(parsed.name || text)) brand = 'Adidas';
if (/9060|550|1906/i.test(parsed.name || text)) brand = 'New Balance';
const cleanName = parsed.name || cleanProductName(text, brand);
const proof = getProductSocialProof({ name: cleanName, brand });
return {
@ -117,7 +226,7 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto
originBadge: proof.badge,
};
} catch (err) {
console.warn('Fallo llamada Gemini, utilizando extractor regex fallback:', err);
console.warn('Fallo llamada Gemini, utilizando extractor regex de alta precisión:', err);
return fallbackExtract();
}
}

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 { parseSupplierMessage } from '@/lib/gemini-parser';
import { getProductSocialProof } from '@/lib/social-proof';
import { generateAttractiveProductCopy } from '@/lib/attractive-copy';
import { SupplierPost, MonitoredWhatsAppGroup } from '@/types';
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co';
@ -218,13 +219,14 @@ export async function POST(request: Request) {
const newProduct = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName,
modelName: prodName,
brand: prodBrand,
supplierPrice: post.extractedProduct?.supplierPrice || 90000,
supplierPrice: post.extractedProduct?.supplierPrice || 75000,
retailPrice: retailPrice,
originalPrice: Math.round(retailPrice * 1.35),
sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: Array.isArray(post.rawImages) && post.rawImages.length > 0 ? post.rawImages : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: `Calzado garantizado de alta calidad para despacho inmediato en 24 horas y pago contraentrega.`,
description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }),
category: post.extractedProduct?.category || 'Calzado / Sneakers',
inStock: true,
isActive: true,
@ -236,13 +238,13 @@ export async function POST(request: Request) {
supplierPhone: post.supplierPhone || '573167455877',
};
dataService.addProduct(newProduct);
const added = dataService.addProduct(newProduct as any);
dataService.updateSupplierPostStatus(post.id, 'published');
return NextResponse.json({
success: true,
message: '¡Producto publicado exitosamente en la App Móvil y Web!',
product: newProduct,
message: `¡${prodName} publicado y agrupado en el catálogo!`,
product: added,
});
}
@ -261,13 +263,14 @@ export async function POST(request: Request) {
const newProd = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName,
modelName: prodName,
brand: prodBrand,
supplierPrice: p.extractedProduct?.supplierPrice || 90000,
supplierPrice: p.extractedProduct?.supplierPrice || 75000,
retailPrice,
originalPrice: Math.round(retailPrice * 1.35),
sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: p.rawImages || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: `Calzado garantizado para despacho inmediato en 24 horas con pago contraentrega.`,
description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }),
category: p.extractedProduct?.category || 'Calzado / Sneakers',
inStock: true,
isActive: true,
@ -279,14 +282,14 @@ export async function POST(request: Request) {
supplierPhone: p.supplierPhone || '573167455877',
};
dataService.addProduct(newProd);
const added = dataService.addProduct(newProd as any);
dataService.updateSupplierPostStatus(p.id, 'published');
createdProducts.push(newProd);
createdProducts.push(added);
}
return NextResponse.json({
success: true,
message: `¡Se publicaron ${createdProducts.length} productos en la App Móvil y Web!`,
message: `¡Se publicaron y agruparon ${createdProducts.length} productos en la App Móvil y Web!`,
products: createdProducts,
});
}

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[] = [
{
@ -191,16 +194,95 @@ class DataService {
private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS];
getProducts(): Product[] {
return this.products;
return groupProductsByModel(this.products);
}
getProductById(id: string): Product | undefined {
return this.products.find((p) => p.id === id);
const allGrouped = groupProductsByModel(this.products);
return allGrouped.find((p) => p.id === id || p.colors?.some((c) => c.id === id));
}
addProduct(product: Product): Product {
this.products.unshift(product);
return product;
const brand = product.brand || 'Sneakers';
const baseModelName = product.modelName || product.name;
const modelKey = normalizeModelKey(baseModelName, brand);
// Buscar si ya existe un producto del mismo modelo
const existing = this.products.find(
(p) => normalizeModelKey(p.modelName || p.name, p.brand) === modelKey
);
if (existing) {
const existingColors: ProductColorVariant[] =
Array.isArray(existing.colors) && existing.colors.length > 0
? existing.colors
: [
{
id: `color-${existing.id}-1`,
colorName: existing.colorName || 'Estilo #1',
image: existing.images[0],
images: existing.images,
sizes: existing.sizes,
inStock: existing.inStock !== false,
},
];
const newImage = product.images[0] || (product.colors && product.colors[0]?.image);
const isDuplicate = existingColors.some(
(c) => c.image === newImage || (product.colorName && c.colorName.toLowerCase() === product.colorName.toLowerCase())
);
if (!isDuplicate && newImage) {
existingColors.push({
id: `color-${Date.now()}-${existingColors.length + 1}`,
colorName: product.colorName || `Estilo #${existingColors.length + 1}`,
image: newImage,
images: product.images.length > 0 ? product.images : [newImage],
sizes: product.sizes && product.sizes.length > 0 ? product.sizes : existing.sizes,
inStock: product.inStock !== false,
});
}
existing.colors = existingColors;
existing.images = Array.from(new Set([...existing.images, ...product.images]));
existing.sizes = Array.from(new Set([...existing.sizes, ...(product.sizes || [])])).sort(
(a, b) => parseInt(a, 10) - parseInt(b, 10)
);
if (!existing.description || existing.description.length < 50) {
existing.description = generateAttractiveProductCopy(existing);
}
return existing;
}
// Si es un modelo nuevo
const proof = getProductSocialProof(product);
const initialColors: ProductColorVariant[] =
Array.isArray(product.colors) && product.colors.length > 0
? product.colors
: [
{
id: `color-${Date.now()}-1`,
colorName: product.colorName || 'Estilo Principal',
image: product.images[0],
images: product.images,
sizes: product.sizes || ['37', '38', '39', '40', '41', '42', '43'],
inStock: product.inStock !== false,
},
];
const formattedProduct: Product = {
...product,
modelName: baseModelName,
colors: initialColors,
description: product.description || generateAttractiveProductCopy(product),
rating: product.rating || proof.rating,
reviewsCount: product.reviewsCount || proof.reviewsCount,
salesCount: product.salesCount || proof.salesCount,
originBadge: product.originBadge || proof.badge,
};
this.products.unshift(formattedProduct);
return formattedProduct;
}
updateProduct(id: string, updates: Partial<Product>): Product | null {

View File

@ -2,52 +2,151 @@ import { GoogleGenAI } from '@google/genai';
import { ExtractedProductData } from '@/types';
import { calculateRetailPrice } from './pricing-engine';
import { getProductSocialProof } from './social-proof';
import { generateAttractiveProductCopy } from './attractive-copy';
/**
* Extractor robusto de precios mayoristas en COP desde mensajes de WhatsApp
* Soporta emojis de dinero (💸, 💰, 💵, 💲), palabras clave, formatos con punto (75.000), 'k' (75k) y 'mil' (75 mil).
*/
export function extractSupplierPrice(text: string): number {
// 1. Buscar patrones con símbolos o emojis de dinero y palabras clave
const explicitPattern =
/(?:💸|💰|💵|💲|precio|valor|costo|mayorista|mayor|mayoristas|\$)+[\s:=]*\$?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}\s*k|\d{2,3}\s*mil|\d{5,6})/i;
const explicitMatch = text.match(explicitPattern);
if (explicitMatch) {
const raw = explicitMatch[1].toLowerCase().replace(/\s+/g, '').replace(/\./g, '');
if (raw.endsWith('k') || raw.endsWith('mil')) {
const num = parseInt(raw.replace('mil', '').replace('k', ''), 10);
if (!isNaN(num) && num > 0) return num < 1000 ? num * 1000 : num;
}
const num = parseInt(raw, 10);
if (!isNaN(num)) {
if (num >= 40 && num <= 350) return num * 1000;
if (num >= 40000 && num <= 500000) return num;
}
}
// 2. Buscar números con formato de miles (ej: 75.000, 80.000, 100.000, 125.500)
const dotThousands = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\.(000|500)\b/);
if (dotThousands) {
const num = parseInt(dotThousands[0].replace(/\./g, ''), 10);
if (!isNaN(num) && num >= 40000 && num <= 500000) return num;
}
// 3. Buscar valores con 'k' o 'mil' (ej: 75k, 80 mil, 95k)
const kMatch = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\s*(?:k|mil)\b/i);
if (kMatch) {
const num = parseInt(kMatch[1], 10);
if (!isNaN(num)) return num * 1000;
}
// 4. Buscar números de 5 o 6 dígitos en rango de precio razonable (40000 a 300000)
const rawNumbers = text.match(/\b(4[5-9]\d{3}|[5-9]\d{4}|1\d{5}|2\d{5})\b/g);
if (rawNumbers && rawNumbers.length > 0) {
const num = parseInt(rawNumbers[0], 10);
if (!isNaN(num)) return num;
}
return 75000; // Valor por defecto representativo para calzado nacional
}
/**
* Extractor de tallas con normalización Unicode (soporta small-caps como ᴛᴀʟʟᴀs ᴅɪsᴘᴏɴɪʙʟᴇs:36/41/44)
*/
export function extractSizes(rawText: string): string[] {
const normalized = rawText
.normalize('NFKD')
.replace(/[ᴛ]/g, 't')
.replace(/[ᴀ]/g, 'a')
.replace(/[ʟ]/g, 'l')
.replace(/[s]/g, 's')
.replace(/[ᴅ]/g, 'd')
.replace(/[ɪ]/g, 'i')
.replace(/[ᴘ]/g, 'p')
.replace(/[ᴏ]/g, 'o')
.replace(/[ɴ]/g, 'n')
.replace(/[ʙ]/g, 'b')
.replace(/[ᴇ]/g, 'e');
const sizes: string[] = [];
// Rango explícito (ej: 36 a 43, 36 al 43, 38-43)
const rangeMatch = normalized.match(
/(?:tallas?|eur|talla|disponibles?)?[^\d\n]*\b(3[4-9]|4[0-6])\s*(?:a|al|-)\s*(3[4-9]|4[0-6])\b/i
);
if (rangeMatch) {
const start = parseInt(rangeMatch[1], 10);
const end = parseInt(rangeMatch[2], 10);
if (start <= end && start >= 34 && end <= 46) {
for (let s = start; s <= end; s++) sizes.push(s.toString());
return sizes;
}
}
// Sección de tallas o texto completo
const tallaSection = normalized.match(/(?:tallas?|eur|disponibles?|talla)[^\n]*/i);
const textToScan = tallaSection ? tallaSection[0] : normalized;
// Extraer números individuales de 2 dígitos entre 34 y 46
const matches = textToScan.match(/(?:^|[^\d])(3[4-9]|4[0-6])(?=[^\d]|$)/g);
if (matches) {
matches.forEach((m) => {
const num = m.replace(/[^\d]/g, '');
if (num && !sizes.includes(num)) {
sizes.push(num);
}
});
}
if (sizes.length === 0) {
return ['37', '38', '39', '40', '41', '42', '43'];
}
return sizes.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
}
/**
* Limpia y formatea el nombre del producto desde el mensaje del proveedor
*/
export function cleanProductName(text: string, detectedBrand: string): string {
const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
for (const line of lines) {
const clean = line
.replace(/🔥|🔴|‼️|✨|🌸|👑|💎|🛍️|🌟|💸|💰|💵|💲|\*/g, '')
.replace(/dama\s*y\s*caballero|caballero|dama|unisex|recarga|nueva\s*mercanc[ií]a|[uú]ltima\s*unidad/gi, '')
.replace(/c[oó]digo\s*:\s*[\d-]+/gi, '')
.replace(/\b\d{2,3}[\.,]\d{3}\b|\b\d{2,3}k\b|\$\s*\d+/gi, '')
.replace(/tallas?:.*|precio:.*|eur\s*[\d\/-]+/gi, '')
.replace(/\s+/g, ' ')
.trim();
if (clean.length >= 3 && !/^\d+$/.test(clean) && !/^\d+[\.,]\d+$/.test(clean)) {
return clean
.split(' ')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(' ');
}
}
return `${detectedBrand} Urban Edition`;
}
export async function parseSupplierMessage(text: string): Promise<ExtractedProductData> {
const apiKey = process.env.GEMINI_API_KEY;
const fallbackExtract = (): ExtractedProductData => {
let price = 90000;
const priceMatch = text.match(/(?:precio|valor|mayorista|costo|\$)\s*[:=]?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}k?)/i);
if (priceMatch) {
const raw = priceMatch[1].toLowerCase().replace(/\./g, '');
if (raw.endsWith('k')) {
price = parseInt(raw.replace('k', ''), 10) * 1000;
} else {
const num = parseInt(raw, 10);
price = num < 1000 ? num * 1000 : num;
}
}
const sizes: string[] = [];
const sizeRangeMatch = text.match(/(\d{2})\s*(?:a|al|-)\s*(\d{2})/i);
if (sizeRangeMatch) {
const start = parseInt(sizeRangeMatch[1], 10);
const end = parseInt(sizeRangeMatch[2], 10);
if (start >= 34 && end <= 46 && start <= end) {
for (let s = start; s <= end; s++) sizes.push(s.toString());
}
} else {
const individualSizes = text.match(/\b(3[4-9]|4[0-6])\b/g);
if (individualSizes) {
sizes.push(...Array.from(new Set(individualSizes)));
}
}
if (sizes.length === 0) {
sizes.push('37', '38', '39', '40', '41', '42', '43');
}
const price = extractSupplierPrice(text);
const sizes = extractSizes(text);
let brand = 'Nike';
if (/jordan/i.test(text)) brand = 'Jordan';
else if (/adidas|bad bunny|campus|forum/i.test(text)) brand = 'Adidas';
else if (/new balance|nb/i.test(text)) brand = 'New Balance';
else if (/puma/i.test(text)) brand = 'Puma';
else if (/asics/i.test(text)) brand = 'Asics';
else if (/vans/i.test(text)) brand = 'Vans';
if (/adidas|samba|campus|bad bunny|forum|gazelle|stan smith/i.test(text)) brand = 'Adidas';
else if (/jordan|air jordan|aj1|aj4|aj3/i.test(text)) brand = 'Jordan';
else if (/new balance|nb|9060|550|530|1906/i.test(text)) brand = 'New Balance';
else if (/puma|palermo|suede|speedcat/i.test(text)) brand = 'Puma';
else if (/asics|gel kayano|gel nyc/i.test(text)) brand = 'Asics';
else if (/vans|old skool|knu skool/i.test(text)) brand = 'Vans';
else if (/coach/i.test(text)) brand = 'Coach';
const cleanLines = text.split('\n').filter((l) => l.trim().length > 0 && !/🔥|🔴|llegando|promo/i.test(l));
const name = cleanLines[0] || `${brand} Sneakers Urban Edition`;
const cleanName = name.replace(/tallas?:.*|precio:.*|\$/gi, '').trim() || `${brand} Zapatillas`;
const cleanName = cleanProductName(text, brand);
const proof = getProductSocialProof({ name: cleanName, brand });
return {
@ -57,7 +156,7 @@ export async function parseSupplierMessage(text: string): Promise<ExtractedProdu
supplierPrice: price,
suggestedRetailPrice: calculateRetailPrice(price, 35, 30000),
category: 'Calzado / Sneakers',
confidence: 0.85,
confidence: 0.92,
rating: proof.rating,
reviewsCount: proof.reviewsCount,
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.
Analiza el siguiente mensaje extraído de un grupo mayorista de WhatsApp.
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").
- "brand": Marca ("Nike", "Jordan", "Adidas", "New Balance", "Puma", "Asics", 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.
- "supplierPrice": Precio de compra mayorista como número entero en COP (ej: 90000, 95000). Si dice "90mil" o "90", es 90000.
- "name": Nombre comercial limpio del modelo (ej: "Adidas Samba OG", "Nike Dunk Low Retro Panda", "New Balance 9060").
- "brand": Marca comercial ("Adidas", "Nike", "Jordan", "New Balance", "Puma", "Asics", "Vans", etc.).
- "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: 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.).
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 parsed = JSON.parse(cleanJson);
const supplierPrice = typeof parsed.supplierPrice === 'number' ? parsed.supplierPrice : 90000;
const sizes = Array.isArray(parsed.sizes) && parsed.sizes.length > 0 ? parsed.sizes.map(String) : ['37', '38', '39', '40', '41', '42', '43'];
const supplierPrice =
typeof parsed.supplierPrice === 'number' && parsed.supplierPrice >= 40000
? parsed.supplierPrice
: extractSupplierPrice(text);
const cleanName = parsed.name || 'Zapatilla Urbana';
const brand = parsed.brand || 'Nike';
const sizes =
Array.isArray(parsed.sizes) && parsed.sizes.length > 0
? parsed.sizes.map(String)
: extractSizes(text);
let brand = parsed.brand || 'Nike';
if (/samba|campus|bad bunny|forum|gazelle/i.test(parsed.name || text)) brand = 'Adidas';
if (/9060|550|1906/i.test(parsed.name || text)) brand = 'New Balance';
const cleanName = parsed.name || cleanProductName(text, brand);
const proof = getProductSocialProof({ name: cleanName, brand });
return {
@ -117,7 +226,7 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto
originBadge: proof.badge,
};
} catch (err) {
console.warn('Fallo llamada Gemini, utilizando extractor regex fallback:', err);
console.warn('Fallo llamada Gemini, utilizando extractor regex de alta precisión:', err);
return fallbackExtract();
}
}

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 { parseSupplierMessage } from '@/lib/gemini-parser';
import { getProductSocialProof } from '@/lib/social-proof';
import { generateAttractiveProductCopy } from '@/lib/attractive-copy';
import { SupplierPost, MonitoredWhatsAppGroup } from '@/types';
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co';
@ -218,13 +219,14 @@ export async function POST(request: Request) {
const newProduct = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName,
modelName: prodName,
brand: prodBrand,
supplierPrice: post.extractedProduct?.supplierPrice || 90000,
supplierPrice: post.extractedProduct?.supplierPrice || 75000,
retailPrice: retailPrice,
originalPrice: Math.round(retailPrice * 1.35),
sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: Array.isArray(post.rawImages) && post.rawImages.length > 0 ? post.rawImages : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: `Calzado garantizado de alta calidad para despacho inmediato en 24 horas y pago contraentrega.`,
description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }),
category: post.extractedProduct?.category || 'Calzado / Sneakers',
inStock: true,
isActive: true,
@ -236,13 +238,13 @@ export async function POST(request: Request) {
supplierPhone: post.supplierPhone || '573167455877',
};
dataService.addProduct(newProduct);
const added = dataService.addProduct(newProduct as any);
dataService.updateSupplierPostStatus(post.id, 'published');
return NextResponse.json({
success: true,
message: '¡Producto publicado exitosamente en la App Móvil y Web!',
product: newProduct,
message: `¡${prodName} publicado y agrupado en el catálogo!`,
product: added,
});
}
@ -261,13 +263,14 @@ export async function POST(request: Request) {
const newProd = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName,
modelName: prodName,
brand: prodBrand,
supplierPrice: p.extractedProduct?.supplierPrice || 90000,
supplierPrice: p.extractedProduct?.supplierPrice || 75000,
retailPrice,
originalPrice: Math.round(retailPrice * 1.35),
sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: p.rawImages || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: `Calzado garantizado para despacho inmediato en 24 horas con pago contraentrega.`,
description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }),
category: p.extractedProduct?.category || 'Calzado / Sneakers',
inStock: true,
isActive: true,
@ -279,14 +282,14 @@ export async function POST(request: Request) {
supplierPhone: p.supplierPhone || '573167455877',
};
dataService.addProduct(newProd);
const added = dataService.addProduct(newProd as any);
dataService.updateSupplierPostStatus(p.id, 'published');
createdProducts.push(newProd);
createdProducts.push(added);
}
return NextResponse.json({
success: true,
message: `¡Se publicaron ${createdProducts.length} productos en la App Móvil y Web!`,
message: `¡Se publicaron y agruparon ${createdProducts.length} productos en la App Móvil y Web!`,
products: createdProducts,
});
}

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[] = [
{
@ -191,16 +194,95 @@ class DataService {
private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS];
getProducts(): Product[] {
return this.products;
return groupProductsByModel(this.products);
}
getProductById(id: string): Product | undefined {
return this.products.find((p) => p.id === id);
const allGrouped = groupProductsByModel(this.products);
return allGrouped.find((p) => p.id === id || p.colors?.some((c) => c.id === id));
}
addProduct(product: Product): Product {
this.products.unshift(product);
return product;
const brand = product.brand || 'Sneakers';
const baseModelName = product.modelName || product.name;
const modelKey = normalizeModelKey(baseModelName, brand);
// Buscar si ya existe un producto del mismo modelo
const existing = this.products.find(
(p) => normalizeModelKey(p.modelName || p.name, p.brand) === modelKey
);
if (existing) {
const existingColors: ProductColorVariant[] =
Array.isArray(existing.colors) && existing.colors.length > 0
? existing.colors
: [
{
id: `color-${existing.id}-1`,
colorName: existing.colorName || 'Estilo #1',
image: existing.images[0],
images: existing.images,
sizes: existing.sizes,
inStock: existing.inStock !== false,
},
];
const newImage = product.images[0] || (product.colors && product.colors[0]?.image);
const isDuplicate = existingColors.some(
(c) => c.image === newImage || (product.colorName && c.colorName.toLowerCase() === product.colorName.toLowerCase())
);
if (!isDuplicate && newImage) {
existingColors.push({
id: `color-${Date.now()}-${existingColors.length + 1}`,
colorName: product.colorName || `Estilo #${existingColors.length + 1}`,
image: newImage,
images: product.images.length > 0 ? product.images : [newImage],
sizes: product.sizes && product.sizes.length > 0 ? product.sizes : existing.sizes,
inStock: product.inStock !== false,
});
}
existing.colors = existingColors;
existing.images = Array.from(new Set([...existing.images, ...product.images]));
existing.sizes = Array.from(new Set([...existing.sizes, ...(product.sizes || [])])).sort(
(a, b) => parseInt(a, 10) - parseInt(b, 10)
);
if (!existing.description || existing.description.length < 50) {
existing.description = generateAttractiveProductCopy(existing);
}
return existing;
}
// Si es un modelo nuevo
const proof = getProductSocialProof(product);
const initialColors: ProductColorVariant[] =
Array.isArray(product.colors) && product.colors.length > 0
? product.colors
: [
{
id: `color-${Date.now()}-1`,
colorName: product.colorName || 'Estilo Principal',
image: product.images[0],
images: product.images,
sizes: product.sizes || ['37', '38', '39', '40', '41', '42', '43'],
inStock: product.inStock !== false,
},
];
const formattedProduct: Product = {
...product,
modelName: baseModelName,
colors: initialColors,
description: product.description || generateAttractiveProductCopy(product),
rating: product.rating || proof.rating,
reviewsCount: product.reviewsCount || proof.reviewsCount,
salesCount: product.salesCount || proof.salesCount,
originBadge: product.originBadge || proof.badge,
};
this.products.unshift(formattedProduct);
return formattedProduct;
}
updateProduct(id: string, updates: Partial<Product>): Product | null {

View File

@ -2,52 +2,151 @@ import { GoogleGenAI } from '@google/genai';
import { ExtractedProductData } from '@/types';
import { calculateRetailPrice } from './pricing-engine';
import { getProductSocialProof } from './social-proof';
import { generateAttractiveProductCopy } from './attractive-copy';
/**
* Extractor robusto de precios mayoristas en COP desde mensajes de WhatsApp
* Soporta emojis de dinero (💸, 💰, 💵, 💲), palabras clave, formatos con punto (75.000), 'k' (75k) y 'mil' (75 mil).
*/
export function extractSupplierPrice(text: string): number {
// 1. Buscar patrones con símbolos o emojis de dinero y palabras clave
const explicitPattern =
/(?:💸|💰|💵|💲|precio|valor|costo|mayorista|mayor|mayoristas|\$)+[\s:=]*\$?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}\s*k|\d{2,3}\s*mil|\d{5,6})/i;
const explicitMatch = text.match(explicitPattern);
if (explicitMatch) {
const raw = explicitMatch[1].toLowerCase().replace(/\s+/g, '').replace(/\./g, '');
if (raw.endsWith('k') || raw.endsWith('mil')) {
const num = parseInt(raw.replace('mil', '').replace('k', ''), 10);
if (!isNaN(num) && num > 0) return num < 1000 ? num * 1000 : num;
}
const num = parseInt(raw, 10);
if (!isNaN(num)) {
if (num >= 40 && num <= 350) return num * 1000;
if (num >= 40000 && num <= 500000) return num;
}
}
// 2. Buscar números con formato de miles (ej: 75.000, 80.000, 100.000, 125.500)
const dotThousands = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\.(000|500)\b/);
if (dotThousands) {
const num = parseInt(dotThousands[0].replace(/\./g, ''), 10);
if (!isNaN(num) && num >= 40000 && num <= 500000) return num;
}
// 3. Buscar valores con 'k' o 'mil' (ej: 75k, 80 mil, 95k)
const kMatch = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\s*(?:k|mil)\b/i);
if (kMatch) {
const num = parseInt(kMatch[1], 10);
if (!isNaN(num)) return num * 1000;
}
// 4. Buscar números de 5 o 6 dígitos en rango de precio razonable (40000 a 300000)
const rawNumbers = text.match(/\b(4[5-9]\d{3}|[5-9]\d{4}|1\d{5}|2\d{5})\b/g);
if (rawNumbers && rawNumbers.length > 0) {
const num = parseInt(rawNumbers[0], 10);
if (!isNaN(num)) return num;
}
return 75000; // Valor por defecto representativo para calzado nacional
}
/**
* Extractor de tallas con normalización Unicode (soporta small-caps como ᴛᴀʟʟᴀs ᴅɪsᴘᴏɴɪʙʟᴇs:36/41/44)
*/
export function extractSizes(rawText: string): string[] {
const normalized = rawText
.normalize('NFKD')
.replace(/[ᴛ]/g, 't')
.replace(/[ᴀ]/g, 'a')
.replace(/[ʟ]/g, 'l')
.replace(/[s]/g, 's')
.replace(/[ᴅ]/g, 'd')
.replace(/[ɪ]/g, 'i')
.replace(/[ᴘ]/g, 'p')
.replace(/[ᴏ]/g, 'o')
.replace(/[ɴ]/g, 'n')
.replace(/[ʙ]/g, 'b')
.replace(/[ᴇ]/g, 'e');
const sizes: string[] = [];
// Rango explícito (ej: 36 a 43, 36 al 43, 38-43)
const rangeMatch = normalized.match(
/(?:tallas?|eur|talla|disponibles?)?[^\d\n]*\b(3[4-9]|4[0-6])\s*(?:a|al|-)\s*(3[4-9]|4[0-6])\b/i
);
if (rangeMatch) {
const start = parseInt(rangeMatch[1], 10);
const end = parseInt(rangeMatch[2], 10);
if (start <= end && start >= 34 && end <= 46) {
for (let s = start; s <= end; s++) sizes.push(s.toString());
return sizes;
}
}
// Sección de tallas o texto completo
const tallaSection = normalized.match(/(?:tallas?|eur|disponibles?|talla)[^\n]*/i);
const textToScan = tallaSection ? tallaSection[0] : normalized;
// Extraer números individuales de 2 dígitos entre 34 y 46
const matches = textToScan.match(/(?:^|[^\d])(3[4-9]|4[0-6])(?=[^\d]|$)/g);
if (matches) {
matches.forEach((m) => {
const num = m.replace(/[^\d]/g, '');
if (num && !sizes.includes(num)) {
sizes.push(num);
}
});
}
if (sizes.length === 0) {
return ['37', '38', '39', '40', '41', '42', '43'];
}
return sizes.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
}
/**
* Limpia y formatea el nombre del producto desde el mensaje del proveedor
*/
export function cleanProductName(text: string, detectedBrand: string): string {
const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
for (const line of lines) {
const clean = line
.replace(/🔥|🔴|‼️|✨|🌸|👑|💎|🛍️|🌟|💸|💰|💵|💲|\*/g, '')
.replace(/dama\s*y\s*caballero|caballero|dama|unisex|recarga|nueva\s*mercanc[ií]a|[uú]ltima\s*unidad/gi, '')
.replace(/c[oó]digo\s*:\s*[\d-]+/gi, '')
.replace(/\b\d{2,3}[\.,]\d{3}\b|\b\d{2,3}k\b|\$\s*\d+/gi, '')
.replace(/tallas?:.*|precio:.*|eur\s*[\d\/-]+/gi, '')
.replace(/\s+/g, ' ')
.trim();
if (clean.length >= 3 && !/^\d+$/.test(clean) && !/^\d+[\.,]\d+$/.test(clean)) {
return clean
.split(' ')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(' ');
}
}
return `${detectedBrand} Urban Edition`;
}
export async function parseSupplierMessage(text: string): Promise<ExtractedProductData> {
const apiKey = process.env.GEMINI_API_KEY;
const fallbackExtract = (): ExtractedProductData => {
let price = 90000;
const priceMatch = text.match(/(?:precio|valor|mayorista|costo|\$)\s*[:=]?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}k?)/i);
if (priceMatch) {
const raw = priceMatch[1].toLowerCase().replace(/\./g, '');
if (raw.endsWith('k')) {
price = parseInt(raw.replace('k', ''), 10) * 1000;
} else {
const num = parseInt(raw, 10);
price = num < 1000 ? num * 1000 : num;
}
}
const sizes: string[] = [];
const sizeRangeMatch = text.match(/(\d{2})\s*(?:a|al|-)\s*(\d{2})/i);
if (sizeRangeMatch) {
const start = parseInt(sizeRangeMatch[1], 10);
const end = parseInt(sizeRangeMatch[2], 10);
if (start >= 34 && end <= 46 && start <= end) {
for (let s = start; s <= end; s++) sizes.push(s.toString());
}
} else {
const individualSizes = text.match(/\b(3[4-9]|4[0-6])\b/g);
if (individualSizes) {
sizes.push(...Array.from(new Set(individualSizes)));
}
}
if (sizes.length === 0) {
sizes.push('37', '38', '39', '40', '41', '42', '43');
}
const price = extractSupplierPrice(text);
const sizes = extractSizes(text);
let brand = 'Nike';
if (/jordan/i.test(text)) brand = 'Jordan';
else if (/adidas|bad bunny|campus|forum/i.test(text)) brand = 'Adidas';
else if (/new balance|nb/i.test(text)) brand = 'New Balance';
else if (/puma/i.test(text)) brand = 'Puma';
else if (/asics/i.test(text)) brand = 'Asics';
else if (/vans/i.test(text)) brand = 'Vans';
if (/adidas|samba|campus|bad bunny|forum|gazelle|stan smith/i.test(text)) brand = 'Adidas';
else if (/jordan|air jordan|aj1|aj4|aj3/i.test(text)) brand = 'Jordan';
else if (/new balance|nb|9060|550|530|1906/i.test(text)) brand = 'New Balance';
else if (/puma|palermo|suede|speedcat/i.test(text)) brand = 'Puma';
else if (/asics|gel kayano|gel nyc/i.test(text)) brand = 'Asics';
else if (/vans|old skool|knu skool/i.test(text)) brand = 'Vans';
else if (/coach/i.test(text)) brand = 'Coach';
const cleanLines = text.split('\n').filter((l) => l.trim().length > 0 && !/🔥|🔴|llegando|promo/i.test(l));
const name = cleanLines[0] || `${brand} Sneakers Urban Edition`;
const cleanName = name.replace(/tallas?:.*|precio:.*|\$/gi, '').trim() || `${brand} Zapatillas`;
const cleanName = cleanProductName(text, brand);
const proof = getProductSocialProof({ name: cleanName, brand });
return {
@ -57,7 +156,7 @@ export async function parseSupplierMessage(text: string): Promise<ExtractedProdu
supplierPrice: price,
suggestedRetailPrice: calculateRetailPrice(price, 35, 30000),
category: 'Calzado / Sneakers',
confidence: 0.85,
confidence: 0.92,
rating: proof.rating,
reviewsCount: proof.reviewsCount,
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.
Analiza el siguiente mensaje extraído de un grupo mayorista de WhatsApp.
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").
- "brand": Marca ("Nike", "Jordan", "Adidas", "New Balance", "Puma", "Asics", 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.
- "supplierPrice": Precio de compra mayorista como número entero en COP (ej: 90000, 95000). Si dice "90mil" o "90", es 90000.
- "name": Nombre comercial limpio del modelo (ej: "Adidas Samba OG", "Nike Dunk Low Retro Panda", "New Balance 9060").
- "brand": Marca comercial ("Adidas", "Nike", "Jordan", "New Balance", "Puma", "Asics", "Vans", etc.).
- "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: 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.).
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 parsed = JSON.parse(cleanJson);
const supplierPrice = typeof parsed.supplierPrice === 'number' ? parsed.supplierPrice : 90000;
const sizes = Array.isArray(parsed.sizes) && parsed.sizes.length > 0 ? parsed.sizes.map(String) : ['37', '38', '39', '40', '41', '42', '43'];
const supplierPrice =
typeof parsed.supplierPrice === 'number' && parsed.supplierPrice >= 40000
? parsed.supplierPrice
: extractSupplierPrice(text);
const cleanName = parsed.name || 'Zapatilla Urbana';
const brand = parsed.brand || 'Nike';
const sizes =
Array.isArray(parsed.sizes) && parsed.sizes.length > 0
? parsed.sizes.map(String)
: extractSizes(text);
let brand = parsed.brand || 'Nike';
if (/samba|campus|bad bunny|forum|gazelle/i.test(parsed.name || text)) brand = 'Adidas';
if (/9060|550|1906/i.test(parsed.name || text)) brand = 'New Balance';
const cleanName = parsed.name || cleanProductName(text, brand);
const proof = getProductSocialProof({ name: cleanName, brand });
return {
@ -117,7 +226,7 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto
originBadge: proof.badge,
};
} catch (err) {
console.warn('Fallo llamada Gemini, utilizando extractor regex fallback:', err);
console.warn('Fallo llamada Gemini, utilizando extractor regex de alta precisión:', err);
return fallbackExtract();
}
}

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 { parseSupplierMessage } from '@/lib/gemini-parser';
import { getProductSocialProof } from '@/lib/social-proof';
import { generateAttractiveProductCopy } from '@/lib/attractive-copy';
import { SupplierPost, MonitoredWhatsAppGroup } from '@/types';
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co';
@ -218,13 +219,14 @@ export async function POST(request: Request) {
const newProduct = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName,
modelName: prodName,
brand: prodBrand,
supplierPrice: post.extractedProduct?.supplierPrice || 90000,
supplierPrice: post.extractedProduct?.supplierPrice || 75000,
retailPrice: retailPrice,
originalPrice: Math.round(retailPrice * 1.35),
sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: Array.isArray(post.rawImages) && post.rawImages.length > 0 ? post.rawImages : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: `Calzado garantizado de alta calidad para despacho inmediato en 24 horas y pago contraentrega.`,
description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }),
category: post.extractedProduct?.category || 'Calzado / Sneakers',
inStock: true,
isActive: true,
@ -236,13 +238,13 @@ export async function POST(request: Request) {
supplierPhone: post.supplierPhone || '573167455877',
};
dataService.addProduct(newProduct);
const added = dataService.addProduct(newProduct as any);
dataService.updateSupplierPostStatus(post.id, 'published');
return NextResponse.json({
success: true,
message: '¡Producto publicado exitosamente en la App Móvil y Web!',
product: newProduct,
message: `¡${prodName} publicado y agrupado en el catálogo!`,
product: added,
});
}
@ -261,13 +263,14 @@ export async function POST(request: Request) {
const newProd = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName,
modelName: prodName,
brand: prodBrand,
supplierPrice: p.extractedProduct?.supplierPrice || 90000,
supplierPrice: p.extractedProduct?.supplierPrice || 75000,
retailPrice,
originalPrice: Math.round(retailPrice * 1.35),
sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: p.rawImages || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: `Calzado garantizado para despacho inmediato en 24 horas con pago contraentrega.`,
description: generateAttractiveProductCopy({ name: prodName, brand: prodBrand, retailPrice }),
category: p.extractedProduct?.category || 'Calzado / Sneakers',
inStock: true,
isActive: true,
@ -279,14 +282,14 @@ export async function POST(request: Request) {
supplierPhone: p.supplierPhone || '573167455877',
};
dataService.addProduct(newProd);
const added = dataService.addProduct(newProd as any);
dataService.updateSupplierPostStatus(p.id, 'published');
createdProducts.push(newProd);
createdProducts.push(added);
}
return NextResponse.json({
success: true,
message: `¡Se publicaron ${createdProducts.length} productos en la App Móvil y Web!`,
message: `¡Se publicaron y agruparon ${createdProducts.length} productos en la App Móvil y Web!`,
products: createdProducts,
});
}

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[] = [
{
@ -191,16 +194,95 @@ class DataService {
private supplierPosts: SupplierPost[] = [...INITIAL_SUPPLIER_POSTS];
getProducts(): Product[] {
return this.products;
return groupProductsByModel(this.products);
}
getProductById(id: string): Product | undefined {
return this.products.find((p) => p.id === id);
const allGrouped = groupProductsByModel(this.products);
return allGrouped.find((p) => p.id === id || p.colors?.some((c) => c.id === id));
}
addProduct(product: Product): Product {
this.products.unshift(product);
return product;
const brand = product.brand || 'Sneakers';
const baseModelName = product.modelName || product.name;
const modelKey = normalizeModelKey(baseModelName, brand);
// Buscar si ya existe un producto del mismo modelo
const existing = this.products.find(
(p) => normalizeModelKey(p.modelName || p.name, p.brand) === modelKey
);
if (existing) {
const existingColors: ProductColorVariant[] =
Array.isArray(existing.colors) && existing.colors.length > 0
? existing.colors
: [
{
id: `color-${existing.id}-1`,
colorName: existing.colorName || 'Estilo #1',
image: existing.images[0],
images: existing.images,
sizes: existing.sizes,
inStock: existing.inStock !== false,
},
];
const newImage = product.images[0] || (product.colors && product.colors[0]?.image);
const isDuplicate = existingColors.some(
(c) => c.image === newImage || (product.colorName && c.colorName.toLowerCase() === product.colorName.toLowerCase())
);
if (!isDuplicate && newImage) {
existingColors.push({
id: `color-${Date.now()}-${existingColors.length + 1}`,
colorName: product.colorName || `Estilo #${existingColors.length + 1}`,
image: newImage,
images: product.images.length > 0 ? product.images : [newImage],
sizes: product.sizes && product.sizes.length > 0 ? product.sizes : existing.sizes,
inStock: product.inStock !== false,
});
}
existing.colors = existingColors;
existing.images = Array.from(new Set([...existing.images, ...product.images]));
existing.sizes = Array.from(new Set([...existing.sizes, ...(product.sizes || [])])).sort(
(a, b) => parseInt(a, 10) - parseInt(b, 10)
);
if (!existing.description || existing.description.length < 50) {
existing.description = generateAttractiveProductCopy(existing);
}
return existing;
}
// Si es un modelo nuevo
const proof = getProductSocialProof(product);
const initialColors: ProductColorVariant[] =
Array.isArray(product.colors) && product.colors.length > 0
? product.colors
: [
{
id: `color-${Date.now()}-1`,
colorName: product.colorName || 'Estilo Principal',
image: product.images[0],
images: product.images,
sizes: product.sizes || ['37', '38', '39', '40', '41', '42', '43'],
inStock: product.inStock !== false,
},
];
const formattedProduct: Product = {
...product,
modelName: baseModelName,
colors: initialColors,
description: product.description || generateAttractiveProductCopy(product),
rating: product.rating || proof.rating,
reviewsCount: product.reviewsCount || proof.reviewsCount,
salesCount: product.salesCount || proof.salesCount,
originBadge: product.originBadge || proof.badge,
};
this.products.unshift(formattedProduct);
return formattedProduct;
}
updateProduct(id: string, updates: Partial<Product>): Product | null {

View File

@ -2,52 +2,151 @@ import { GoogleGenAI } from '@google/genai';
import { ExtractedProductData } from '@/types';
import { calculateRetailPrice } from './pricing-engine';
import { getProductSocialProof } from './social-proof';
import { generateAttractiveProductCopy } from './attractive-copy';
/**
* Extractor robusto de precios mayoristas en COP desde mensajes de WhatsApp
* Soporta emojis de dinero (💸, 💰, 💵, 💲), palabras clave, formatos con punto (75.000), 'k' (75k) y 'mil' (75 mil).
*/
export function extractSupplierPrice(text: string): number {
// 1. Buscar patrones con símbolos o emojis de dinero y palabras clave
const explicitPattern =
/(?:💸|💰|💵|💲|precio|valor|costo|mayorista|mayor|mayoristas|\$)+[\s:=]*\$?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}\s*k|\d{2,3}\s*mil|\d{5,6})/i;
const explicitMatch = text.match(explicitPattern);
if (explicitMatch) {
const raw = explicitMatch[1].toLowerCase().replace(/\s+/g, '').replace(/\./g, '');
if (raw.endsWith('k') || raw.endsWith('mil')) {
const num = parseInt(raw.replace('mil', '').replace('k', ''), 10);
if (!isNaN(num) && num > 0) return num < 1000 ? num * 1000 : num;
}
const num = parseInt(raw, 10);
if (!isNaN(num)) {
if (num >= 40 && num <= 350) return num * 1000;
if (num >= 40000 && num <= 500000) return num;
}
}
// 2. Buscar números con formato de miles (ej: 75.000, 80.000, 100.000, 125.500)
const dotThousands = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\.(000|500)\b/);
if (dotThousands) {
const num = parseInt(dotThousands[0].replace(/\./g, ''), 10);
if (!isNaN(num) && num >= 40000 && num <= 500000) return num;
}
// 3. Buscar valores con 'k' o 'mil' (ej: 75k, 80 mil, 95k)
const kMatch = text.match(/\b([4-9]\d|1\d{2}|2\d{2})\s*(?:k|mil)\b/i);
if (kMatch) {
const num = parseInt(kMatch[1], 10);
if (!isNaN(num)) return num * 1000;
}
// 4. Buscar números de 5 o 6 dígitos en rango de precio razonable (40000 a 300000)
const rawNumbers = text.match(/\b(4[5-9]\d{3}|[5-9]\d{4}|1\d{5}|2\d{5})\b/g);
if (rawNumbers && rawNumbers.length > 0) {
const num = parseInt(rawNumbers[0], 10);
if (!isNaN(num)) return num;
}
return 75000; // Valor por defecto representativo para calzado nacional
}
/**
* Extractor de tallas con normalización Unicode (soporta small-caps como ᴛᴀʟʟᴀs ᴅɪsᴘᴏɴɪʙʟᴇs:36/41/44)
*/
export function extractSizes(rawText: string): string[] {
const normalized = rawText
.normalize('NFKD')
.replace(/[ᴛ]/g, 't')
.replace(/[ᴀ]/g, 'a')
.replace(/[ʟ]/g, 'l')
.replace(/[s]/g, 's')
.replace(/[ᴅ]/g, 'd')
.replace(/[ɪ]/g, 'i')
.replace(/[ᴘ]/g, 'p')
.replace(/[ᴏ]/g, 'o')
.replace(/[ɴ]/g, 'n')
.replace(/[ʙ]/g, 'b')
.replace(/[ᴇ]/g, 'e');
const sizes: string[] = [];
// Rango explícito (ej: 36 a 43, 36 al 43, 38-43)
const rangeMatch = normalized.match(
/(?:tallas?|eur|talla|disponibles?)?[^\d\n]*\b(3[4-9]|4[0-6])\s*(?:a|al|-)\s*(3[4-9]|4[0-6])\b/i
);
if (rangeMatch) {
const start = parseInt(rangeMatch[1], 10);
const end = parseInt(rangeMatch[2], 10);
if (start <= end && start >= 34 && end <= 46) {
for (let s = start; s <= end; s++) sizes.push(s.toString());
return sizes;
}
}
// Sección de tallas o texto completo
const tallaSection = normalized.match(/(?:tallas?|eur|disponibles?|talla)[^\n]*/i);
const textToScan = tallaSection ? tallaSection[0] : normalized;
// Extraer números individuales de 2 dígitos entre 34 y 46
const matches = textToScan.match(/(?:^|[^\d])(3[4-9]|4[0-6])(?=[^\d]|$)/g);
if (matches) {
matches.forEach((m) => {
const num = m.replace(/[^\d]/g, '');
if (num && !sizes.includes(num)) {
sizes.push(num);
}
});
}
if (sizes.length === 0) {
return ['37', '38', '39', '40', '41', '42', '43'];
}
return sizes.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
}
/**
* Limpia y formatea el nombre del producto desde el mensaje del proveedor
*/
export function cleanProductName(text: string, detectedBrand: string): string {
const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
for (const line of lines) {
const clean = line
.replace(/🔥|🔴|‼️|✨|🌸|👑|💎|🛍️|🌟|💸|💰|💵|💲|\*/g, '')
.replace(/dama\s*y\s*caballero|caballero|dama|unisex|recarga|nueva\s*mercanc[ií]a|[uú]ltima\s*unidad/gi, '')
.replace(/c[oó]digo\s*:\s*[\d-]+/gi, '')
.replace(/\b\d{2,3}[\.,]\d{3}\b|\b\d{2,3}k\b|\$\s*\d+/gi, '')
.replace(/tallas?:.*|precio:.*|eur\s*[\d\/-]+/gi, '')
.replace(/\s+/g, ' ')
.trim();
if (clean.length >= 3 && !/^\d+$/.test(clean) && !/^\d+[\.,]\d+$/.test(clean)) {
return clean
.split(' ')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(' ');
}
}
return `${detectedBrand} Urban Edition`;
}
export async function parseSupplierMessage(text: string): Promise<ExtractedProductData> {
const apiKey = process.env.GEMINI_API_KEY;
const fallbackExtract = (): ExtractedProductData => {
let price = 90000;
const priceMatch = text.match(/(?:precio|valor|mayorista|costo|\$)\s*[:=]?\s*(\d{2,3}(?:\.\d{3})*|\d{2,3}k?)/i);
if (priceMatch) {
const raw = priceMatch[1].toLowerCase().replace(/\./g, '');
if (raw.endsWith('k')) {
price = parseInt(raw.replace('k', ''), 10) * 1000;
} else {
const num = parseInt(raw, 10);
price = num < 1000 ? num * 1000 : num;
}
}
const sizes: string[] = [];
const sizeRangeMatch = text.match(/(\d{2})\s*(?:a|al|-)\s*(\d{2})/i);
if (sizeRangeMatch) {
const start = parseInt(sizeRangeMatch[1], 10);
const end = parseInt(sizeRangeMatch[2], 10);
if (start >= 34 && end <= 46 && start <= end) {
for (let s = start; s <= end; s++) sizes.push(s.toString());
}
} else {
const individualSizes = text.match(/\b(3[4-9]|4[0-6])\b/g);
if (individualSizes) {
sizes.push(...Array.from(new Set(individualSizes)));
}
}
if (sizes.length === 0) {
sizes.push('37', '38', '39', '40', '41', '42', '43');
}
const price = extractSupplierPrice(text);
const sizes = extractSizes(text);
let brand = 'Nike';
if (/jordan/i.test(text)) brand = 'Jordan';
else if (/adidas|bad bunny|campus|forum/i.test(text)) brand = 'Adidas';
else if (/new balance|nb/i.test(text)) brand = 'New Balance';
else if (/puma/i.test(text)) brand = 'Puma';
else if (/asics/i.test(text)) brand = 'Asics';
else if (/vans/i.test(text)) brand = 'Vans';
if (/adidas|samba|campus|bad bunny|forum|gazelle|stan smith/i.test(text)) brand = 'Adidas';
else if (/jordan|air jordan|aj1|aj4|aj3/i.test(text)) brand = 'Jordan';
else if (/new balance|nb|9060|550|530|1906/i.test(text)) brand = 'New Balance';
else if (/puma|palermo|suede|speedcat/i.test(text)) brand = 'Puma';
else if (/asics|gel kayano|gel nyc/i.test(text)) brand = 'Asics';
else if (/vans|old skool|knu skool/i.test(text)) brand = 'Vans';
else if (/coach/i.test(text)) brand = 'Coach';
const cleanLines = text.split('\n').filter((l) => l.trim().length > 0 && !/🔥|🔴|llegando|promo/i.test(l));
const name = cleanLines[0] || `${brand} Sneakers Urban Edition`;
const cleanName = name.replace(/tallas?:.*|precio:.*|\$/gi, '').trim() || `${brand} Zapatillas`;
const cleanName = cleanProductName(text, brand);
const proof = getProductSocialProof({ name: cleanName, brand });
return {
@ -57,7 +156,7 @@ export async function parseSupplierMessage(text: string): Promise<ExtractedProdu
supplierPrice: price,
suggestedRetailPrice: calculateRetailPrice(price, 35, 30000),
category: 'Calzado / Sneakers',
confidence: 0.85,
confidence: 0.92,
rating: proof.rating,
reviewsCount: proof.reviewsCount,
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.
Analiza el siguiente mensaje extraído de un grupo mayorista de WhatsApp.
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").
- "brand": Marca ("Nike", "Jordan", "Adidas", "New Balance", "Puma", "Asics", 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.
- "supplierPrice": Precio de compra mayorista como número entero en COP (ej: 90000, 95000). Si dice "90mil" o "90", es 90000.
- "name": Nombre comercial limpio del modelo (ej: "Adidas Samba OG", "Nike Dunk Low Retro Panda", "New Balance 9060").
- "brand": Marca comercial ("Adidas", "Nike", "Jordan", "New Balance", "Puma", "Asics", "Vans", etc.).
- "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: 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.).
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 parsed = JSON.parse(cleanJson);
const supplierPrice = typeof parsed.supplierPrice === 'number' ? parsed.supplierPrice : 90000;
const sizes = Array.isArray(parsed.sizes) && parsed.sizes.length > 0 ? parsed.sizes.map(String) : ['37', '38', '39', '40', '41', '42', '43'];
const supplierPrice =
typeof parsed.supplierPrice === 'number' && parsed.supplierPrice >= 40000
? parsed.supplierPrice
: extractSupplierPrice(text);
const cleanName = parsed.name || 'Zapatilla Urbana';
const brand = parsed.brand || 'Nike';
const sizes =
Array.isArray(parsed.sizes) && parsed.sizes.length > 0
? parsed.sizes.map(String)
: extractSizes(text);
let brand = parsed.brand || 'Nike';
if (/samba|campus|bad bunny|forum|gazelle/i.test(parsed.name || text)) brand = 'Adidas';
if (/9060|550|1906/i.test(parsed.name || text)) brand = 'New Balance';
const cleanName = parsed.name || cleanProductName(text, brand);
const proof = getProductSocialProof({ name: cleanName, brand });
return {
@ -117,7 +226,7 @@ Responde ÚNICAMENTE con el objeto JSON sin bloques de código markdown ni texto
originBadge: proof.badge,
};
} catch (err) {
console.warn('Fallo llamada Gemini, utilizando extractor regex fallback:', err);
console.warn('Fallo llamada Gemini, utilizando extractor regex de alta precisión:', err);
return fallbackExtract();
}
}

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