feat: extraccion de alta precision de precios/tallas, textos atractivos y agrupacion automatica de modelos
This commit is contained in:
parent
f8dec69586
commit
93d0a590a7
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
37
almeira-store-app/src/lib/attractive-copy.ts
Normal file
37
almeira-store-app/src/lib/attractive-copy.ts
Normal 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!`;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
116
almeira-store-app/src/lib/model-grouper.ts
Normal file
116
almeira-store-app/src/lib/model-grouper.ts
Normal 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());
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
37
dripzone-app/src/lib/attractive-copy.ts
Normal file
37
dripzone-app/src/lib/attractive-copy.ts
Normal 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!`;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
116
dripzone-app/src/lib/model-grouper.ts
Normal file
116
dripzone-app/src/lib/model-grouper.ts
Normal 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());
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
37
sneaky-app/src/lib/attractive-copy.ts
Normal file
37
sneaky-app/src/lib/attractive-copy.ts
Normal 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!`;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
116
sneaky-app/src/lib/model-grouper.ts
Normal file
116
sneaky-app/src/lib/model-grouper.ts
Normal 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());
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
37
zapply-app/src/lib/attractive-copy.ts
Normal file
37
zapply-app/src/lib/attractive-copy.ts
Normal 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!`;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
116
zapply-app/src/lib/model-grouper.ts
Normal file
116
zapply-app/src/lib/model-grouper.ts
Normal 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());
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user