feat: format description aesthetics, remove horma nacional in favor of EUR, and fix duplicate notifications

This commit is contained in:
AI Bot 2026-09-12 09:53:56 -05:00
parent 31130bfbb5
commit b15dea465b
13 changed files with 564 additions and 45 deletions

View File

@ -49,7 +49,7 @@ object TemplateManager {
"Hola {tienda_nombre}! 👋 Quiero pedir las zapatillas:\n" +
"👟 *{zapatilla_nombre}*\n" +
"🎨 Color: *{color_nombre}*\n" +
"📏 Talla: *{talla}* (Horma Nacional)\n" +
"📏 Talla: *{talla}* (EUR)\n" +
"💎 Precio: *{precio} COP*\n" +
"🚚 Entrega en: *{ciudad}* (Pago Contraentrega)\n" +
"⚡ Deseo separar con $10.000 COP y cancelar el saldo en mi casa.\n\n" +

View File

@ -33,6 +33,7 @@ import {
OFFICIAL_COLLECTION_NAMES,
resolveProductCollection,
} from '@/lib/whatsapp-catalog';
import { FormattedDescription } from '@/components/store/FormattedDescription';
export default function AdminWhatsAppCatalogPage() {
const [products, setProducts] = useState<Product[]>([]);
@ -49,6 +50,7 @@ export default function AdminWhatsAppCatalogPage() {
// Modal para Generación de Enlaces de Pedido WhatsApp para Asesores
const [selectedProductForLink, setSelectedProductForLink] = useState<Product | null>(null);
const [isOrderLinkModalOpen, setIsOrderLinkModalOpen] = useState(false);
const [expandedDescId, setExpandedDescId] = useState<string | null>(null);
// Formulario de Meta API
const [metaCatalogId, setMetaCatalogId] = useState('');
@ -854,16 +856,34 @@ export default function AdminWhatsAppCatalogPage() {
<div className="bg-neutral-900/90 border border-neutral-800/80 rounded-xl p-2.5 space-y-1">
<div className="text-[10px] text-neutral-400 font-bold uppercase tracking-wider flex items-center justify-between">
<span>📏 Tallas disponibles:</span>
<span className="text-emerald-400 font-mono text-[9px]">Horma Nacional</span>
<span className="text-emerald-400 font-mono text-[9px]">Tallas EUR</span>
</div>
<div className="text-xs font-semibold text-white tracking-wide">
{sizesList}
</div>
</div>
{/* Descripción de la publicación */}
<div className="bg-neutral-900/50 rounded-xl p-2 text-[11px] text-neutral-400 line-clamp-2 leading-relaxed">
{product.description || 'Producto de alta calidad con garantía y cambio asegurado.'}
{/* Descripción de la publicación con formato y vista previa estética */}
<div className="bg-neutral-900/60 border border-neutral-800/80 rounded-xl p-2.5 text-[11px] space-y-1.5">
<div className="flex items-center justify-between text-neutral-400 font-semibold text-[10px] uppercase tracking-wider">
<span>Descripción / Copy WhatsApp:</span>
<button
type="button"
onClick={() => setExpandedDescId(expandedDescId === product.id ? null : product.id)}
className="text-purple-400 hover:text-purple-300 font-bold transition-colors cursor-pointer"
>
{expandedDescId === product.id ? 'Contraer ▲' : 'Ver Formato ▼'}
</button>
</div>
{expandedDescId === product.id ? (
<div className="pt-1 max-h-56 overflow-y-auto pr-1">
<FormattedDescription description={product.description} />
</div>
) : (
<p className="text-neutral-400 line-clamp-2 leading-relaxed whitespace-pre-line">
{product.description || 'Producto de alta calidad con garantía y cambio asegurado.'}
</p>
)}
</div>
{/* Botón para Asesor Comercial: Enlace Directo para WhatsApp */}

View File

@ -15,6 +15,7 @@ import { cleanProductName, detectBrandFromText } from '@/lib/gemini-parser';
import { generateAttractiveProductCopy } from '@/lib/attractive-copy';
import { WhatsAppOrderLinkModal } from '@/components/admin/WhatsAppOrderLinkModal';
import { getProductPublicationDate, getProductAgeInDays, isProductOlderThanDays } from '@/lib/expiration-service';
import { FormattedDescription } from '@/components/store/FormattedDescription';
export default function AdminProductsPage() {
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
@ -95,6 +96,8 @@ export default function AdminProductsPage() {
const [editSizesInput, setEditSizesInput] = useState('');
const [editImageUrl, setEditImageUrl] = useState('');
const [editCategory, setEditCategory] = useState('');
const [editDescription, setEditDescription] = useState('');
const [showDescPreview, setShowDescPreview] = useState(false);
// 1-to-1 AI processor form
const [rawText, setRawText] = useState('');
@ -328,6 +331,8 @@ export default function AdminProductsPage() {
setEditSizesInput(product.sizes.join(', '));
setEditImageUrl(product.images[0] || '');
setEditCategory(product.category || 'Sneakers / Urbano');
setEditDescription(product.description || '');
setShowDescPreview(false);
};
const handleSaveEdit = async (e: React.FormEvent) => {
@ -337,7 +342,7 @@ export default function AdminProductsPage() {
const sizes = editSizesInput.split(',').map((s) => s.trim()).filter(Boolean);
const chosenSupplier = suppliers.find((s) => s.id === editSupplierId) || suppliers[0];
const cleanProdName = sanitizePublicationTitle(editName, [chosenSupplier.name], editBrand);
const cleanProdDesc = sanitizePublicationText(editingProduct.description || '', [chosenSupplier.name]);
const cleanProdDesc = sanitizePublicationText(editDescription || '', [chosenSupplier.name]);
const updates = {
name: cleanProdName,
@ -1970,6 +1975,40 @@ export default function AdminProductsPage() {
/>
</div>
{/* Descripción de la Publicación con vista previa organizada */}
<div className="space-y-1.5 pt-1">
<div className="flex items-center justify-between">
<label className="block text-neutral-300 font-bold">
Descripción / Copy Comercial (WhatsApp & Vitrina)
</label>
<button
type="button"
onClick={() => setShowDescPreview(!showDescPreview)}
className="text-[11px] text-purple-400 hover:text-purple-300 font-bold flex items-center gap-1 cursor-pointer transition-colors"
>
<Sparkles className="w-3.5 h-3.5 text-purple-400" />
<span>{showDescPreview ? '✏️ Modo Editor' : '👁️ Vista Previa Formateada'}</span>
</button>
</div>
{!showDescPreview ? (
<textarea
rows={7}
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Escribe la descripción del producto con saltos de línea..."
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl p-3 text-white outline-hidden focus:border-purple-400 font-mono text-xs leading-relaxed resize-y"
/>
) : (
<div className="bg-neutral-950 border border-purple-500/30 rounded-xl p-3.5 max-h-64 overflow-y-auto shadow-inner">
<FormattedDescription description={editDescription} />
</div>
)}
<p className="text-[10px] text-neutral-500 leading-normal">
💡 Tip: Puedes usar *negrita*, _cursiva_, viñetas con • o guion -, y ⚠️ para avisos de disponibilidad. Todos los saltos de línea se verán organizados y estéticos en la tienda.
</p>
</div>
<div className="flex justify-end space-x-2 pt-3 border-t border-neutral-800">
<button
type="button"

View File

@ -28,24 +28,37 @@ import { formatCOP } from '@/lib/pricing-engine';
const processedMessagesCache = new Map<string, number>();
// Cache en memoria para control de cadencia de respuestas comerciales automáticas a clientes
const customerChatCache = new Map<string, number>();
// In-flight concurrency lock para evitar condiciones de carrera entre webhooks paralelos
const inFlightMessages = new Set<string>();
function isDuplicateMessage(msgId: string, text: string): boolean {
const key = `${msgId}:${(text || '').trim().substring(0, 60)}`;
function isDuplicateMessage(msgId: string, text: string = ''): boolean {
if (!msgId) return false;
const now = Date.now();
const lastSeen = processedMessagesCache.get(key);
// Limpieza de claves viejas (> 5 minutos)
// Limpieza de claves viejas (> 30 minutos)
for (const [k, time] of processedMessagesCache.entries()) {
if (now - time > 300000) {
if (now - time > 1800000) {
processedMessagesCache.delete(k);
}
}
if (lastSeen && now - lastSeen < 60000) {
// Verificar por ID de mensaje de WhatsApp (identificador universal)
const lastSeenById = processedMessagesCache.get(`id:${msgId}`);
if (lastSeenById && now - lastSeenById < 600000) {
return true;
}
processedMessagesCache.set(key, now);
// Verificar por contenido de texto si existe
const cleanSnippet = (text || '').trim().toLowerCase().substring(0, 80);
if (cleanSnippet.length > 15) {
const lastSeenByText = processedMessagesCache.get(`txt:${cleanSnippet}`);
if (lastSeenByText && now - lastSeenByText < 300000) {
return true;
}
processedMessagesCache.set(`txt:${cleanSnippet}`, now);
}
processedMessagesCache.set(`id:${msgId}`, now);
return false;
}
@ -62,15 +75,19 @@ export async function POST(request: Request) {
const rawEvent = payload.event || '';
const eventType = rawEvent.toLowerCase().replace(/_/g, '.');
// Filtrar eventos irrelevantes (solo procesar inserción de mensajes o envíos directos)
// FILTRO ESTRICTO: Solo procesar inserción de mensajes nuevos (messages.upsert)
// NUNCA procesar messages.update ni send.message para publicación de productos
// porque Evolution API dispara messages.update hasta 3 veces por tildes de entrega y lectura.
if (
rawEvent &&
!eventType.includes('messages.upsert') &&
!eventType.includes('messages.update') &&
!eventType.includes('send.message') &&
!eventType.includes('test')
) {
return NextResponse.json({ success: true, ignored: 'Evento no relevante', event: rawEvent });
return NextResponse.json({
success: true,
ignored: `Evento "${rawEvent}" ignorado. Solo "messages.upsert" procesa y publica productos para evitar duplicados.`,
event: rawEvent,
});
}
const data = payload.data || payload;
@ -367,7 +384,12 @@ export async function POST(request: Request) {
});
}
// 2. VERIFICACIÓN DE DUPLICADOS
// 2. VERIFICACIÓN DE DUPLICADOS Y BLOQUEO DE CONCURRENCIA
if (inFlightMessages.has(whatsappMessageId)) {
console.log(`🛡️ [DripZone Webhook] Mensaje ${whatsappMessageId} actualmente en procesamiento concurrente. Omitiendo duplicado.`);
return NextResponse.json({ success: true, ignored: 'Mensaje actualmente en procesamiento concurrente' });
}
if (isDuplicateMessage(whatsappMessageId, messageContent)) {
return NextResponse.json({ success: true, ignored: 'Mensaje duplicado en ráfaga ignorado' });
}
@ -381,6 +403,10 @@ export async function POST(request: Request) {
});
}
inFlightMessages.add(whatsappMessageId);
try {
// 3. EXTRACCIÓN MULTIMEDIA (IMAGEN HD Y THUMBNAIL)
let imageBase64: string | undefined = data.base64 || undefined;
@ -503,6 +529,25 @@ export async function POST(request: Request) {
const relativeImageUrl = `/api/whatsapp-media?id=${encodeURIComponent(whatsappMessageId)}&remoteJid=${encodeURIComponent(remoteJid)}`;
const fullMediaUrl = `https://drip-zone.online/api/whatsapp-media?id=${encodeURIComponent(whatsappMessageId)}&remoteJid=${encodeURIComponent(remoteJid)}`;
// Verificar si un producto con este modelo idéntico fue publicado en los últimos 10 minutos
const isAlreadyPublishedRecently = existingProducts.some((p) => {
if (p.whatsappMessageId === whatsappMessageId) return true;
if (p.name && prodName && p.name.toLowerCase().trim() === prodName.toLowerCase().trim()) {
const pDate = new Date(p.publishedAt || (p as any).createdAt || 0).getTime();
return Date.now() - pDate < 600000;
}
return false;
});
if (isAlreadyPublishedRecently) {
console.log(`🛡️ [DripZone Webhook] Producto idéntico detectado ("${prodName}"). Evitando publicación duplicada.`);
return NextResponse.json({
success: true,
ignored: 'Producto idéntico ya publicado recientemente',
name: prodName,
});
}
// 6. CREACIÓN Y PUBLICACIÓN DEL PRODUCTO EN LA APP Y CATÁLOGO
const newProduct: Product = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
@ -579,6 +624,9 @@ export async function POST(request: Request) {
post: newPost,
notification: notificationResult,
});
} finally {
inFlightMessages.delete(whatsappMessageId);
}
} catch (error: any) {
console.error('❌ Error general en Webhook WhatsApp DripZone:', error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });

View File

@ -145,7 +145,7 @@ export default function MisPedidosPage() {
case 'supplier_notified':
return {
label: 'En Preparación',
sublabel: 'Verificando horma y empaque con proveedor',
sublabel: 'Verificando talla EUR y empaque con proveedor',
badgeColor: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
icon: Package,
group: 'processing',
@ -574,7 +574,7 @@ export default function MisPedidosPage() {
)}
</div>
<div className="text-[11px] text-neutral-400 mt-0.5">
Total: <strong className="text-neutral-200">{totalPairs} {totalPairs === 1 ? 'par' : 'pares'}</strong> • Horma Nacional
Total: <strong className="text-neutral-200">{totalPairs} {totalPairs === 1 ? 'par' : 'pares'}</strong> • Tallas EUR
</div>
</div>
</div>
@ -781,7 +781,7 @@ export default function MisPedidosPage() {
<div className="pt-3 border-t border-neutral-800 flex flex-wrap items-center justify-between gap-3">
<div className="text-[11px] text-neutral-400 flex items-center gap-1.5">
<ShieldCheck className="w-4 h-4 text-emerald-400 shrink-0" />
<span>Tu pedido incluye garantía y primer cambio de talla gratuito (Horma Nacional).</span>
<span>Tu pedido incluye garantía y primer cambio de talla gratuito (Tallas EUR).</span>
</div>
<button

View File

@ -172,7 +172,7 @@ export function WhatsAppOrderLinkModal({
<div className="space-y-2">
<label className="text-xs font-bold text-neutral-300 flex items-center justify-between">
<span>Talla Acordada con el Cliente:</span>
<span className="text-emerald-400 font-mono text-[10px]">Horma Nacional</span>
<span className="text-emerald-400 font-mono text-[10px]">Tallas EUR</span>
</label>
<div className="flex flex-wrap gap-1.5">
{availableSizes.map((size) => (

View File

@ -0,0 +1,258 @@
'use client';
import React from 'react';
import { Sparkles, AlertCircle, CheckCircle2 } from 'lucide-react';
interface FormattedDescriptionProps {
description?: string;
className?: string;
}
/**
* Parsea texto con formato tipo WhatsApp/Markdown:
* - *texto* o **texto** -> negrita
* - _texto_ -> cursiva
*/
export function renderInlineFormatting(text: string): React.ReactNode {
if (!text) return null;
// Manejo de negritas (*texto* o **texto**)
const boldParts = text.split(/(\*\*?[^*]+\*\*?)/g);
return boldParts.map((bPart, bIdx) => {
const isBold =
(bPart.startsWith('**') && bPart.endsWith('**') && bPart.length > 4) ||
(bPart.startsWith('*') && bPart.endsWith('*') && bPart.length > 2);
const cleanB = isBold ? bPart.replace(/^\*+|\*+$/g, '') : bPart;
// Dentro del fragmento, procesar cursivas _texto_
const italicParts = cleanB.split(/(_[^_]+_)/g);
const content = italicParts.map((iPart, iIdx) => {
if (iPart.startsWith('_') && iPart.endsWith('_') && iPart.length > 2) {
return (
<em key={iIdx} className="italic text-neutral-300">
{iPart.slice(1, -1)}
</em>
);
}
return iPart;
});
if (isBold) {
return (
<strong key={bIdx} className="text-white font-bold tracking-tight">
{content}
</strong>
);
}
return <React.Fragment key={bIdx}>{content}</React.Fragment>;
});
}
/**
* Detecta si una línea es una viñeta o punto de característica
*/
function isBulletLine(line: string): boolean {
const trimmed = line.trim();
return /^(?:[•\-\*]|✅|✔|🔹|🔸|▪️|▫️|\d+[\.\)])\s*/u.test(trimmed);
}
/**
* Detecta si una línea es una advertencia / aviso de disponibilidad
*/
function isAlertLine(line: string): boolean {
const lower = line.toLowerCase();
return (
line.includes('⚠️') ||
lower.includes('disponibilidad limit') ||
lower.startsWith('*disponibilidad') ||
lower.startsWith('disponibilidad:')
);
}
/**
* Detecta si una línea es un encabezado de sección
*/
function isHeadingLine(line: string): boolean {
const trimmed = line.trim();
if (isAlertLine(trimmed)) return false;
if (trimmed.startsWith('✨')) return true;
if (
(trimmed.startsWith('*') && trimmed.endsWith('*') && trimmed.length < 75 && !trimmed.startsWith('* ')) ||
(trimmed.startsWith('**') && trimmed.endsWith('**') && trimmed.length < 75)
) {
return true;
}
return false;
}
export const FormattedDescription: React.FC<FormattedDescriptionProps> = ({
description,
className = '',
}) => {
if (!description || !description.trim()) {
return (
<div className={`text-xs text-neutral-500 italic ${className}`}>
Sin descripción disponible para este producto.
</div>
);
}
// Normalizar saltos de línea (reemplazar literales \n escapados si los hubiera)
const normalized = description
.replace(/\\n/g, '\n')
.replace(/\r\n/g, '\n')
.trim();
// Dividir en bloques separados por doble salto de línea (párrafos)
const rawBlocks = normalized.split(/\n\s*\n+/);
return (
<div className={`space-y-3.5 text-xs ${className}`}>
{rawBlocks.map((block, blockIdx) => {
// Dividir el bloque en líneas individuales
const lines = block
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
if (lines.length === 0) return null;
// Caso 1: Todo el bloque es de advertencia o disponibilidad
if (lines.some(isAlertLine) && lines.length <= 3) {
return (
<div
key={blockIdx}
className="flex items-start gap-2.5 p-3 rounded-2xl bg-amber-500/10 border border-amber-500/25 text-amber-200/90 shadow-sm"
>
<AlertCircle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<div className="space-y-1 text-xs leading-relaxed font-medium">
{lines.map((line, lIdx) => (
<p key={lIdx}>
{renderInlineFormatting(line.replace(/^⚠️\s*/, ''))}
</p>
))}
</div>
</div>
);
}
// Caso 2: El bloque contiene viñetas (o una mezcla de título y viñetas)
const hasBullets = lines.some(isBulletLine);
if (hasBullets) {
return (
<div key={blockIdx} className="space-y-2">
{lines.map((line, lIdx) => {
// Alerta aislada dentro del bloque
if (isAlertLine(line)) {
return (
<div
key={lIdx}
className="flex items-start gap-2.5 p-2.5 rounded-xl bg-amber-500/10 border border-amber-500/25 text-amber-200/90 shadow-sm mt-1"
>
<AlertCircle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<div className="text-xs leading-relaxed font-medium">
{renderInlineFormatting(line.replace(/^⚠️\s*/, ''))}
</div>
</div>
);
}
// Encabezado de sección (ej: ✨ *¿Por qué todos lo eligen?*)
if (isHeadingLine(line)) {
return (
<div
key={lIdx}
className="pt-2 pb-1 border-t border-neutral-800/80 flex items-center gap-2"
>
<Sparkles className="w-3.5 h-3.5 text-purple-400 shrink-0" />
<h4 className="font-extrabold text-white text-xs uppercase tracking-wider">
{renderInlineFormatting(line.replace(/^✨\s*/, ''))}
</h4>
</div>
);
}
// Viñeta de característica
if (isBulletLine(line)) {
// Limpiar el marcador inicial de viñeta
const cleanBullet = line.replace(/^(?:[•\-\*]|✅|✔|🔹|🔸|▪️|▫️|\d+[\.\)])\s*/u, '').trim();
// Extraer emoji si la viñeta tiene uno al inicio (ej: 💎, ☁️, 🚚)
const emojiMatch = cleanBullet.match(/^(\p{Extended_Pictographic}|\p{Emoji_Presentation})\s*/u);
const leadingEmoji = emojiMatch ? emojiMatch[1] : null;
const textWithoutEmoji = emojiMatch
? cleanBullet.slice(emojiMatch[0].length).trim()
: cleanBullet;
return (
<div
key={lIdx}
className="flex items-start gap-2.5 p-2.5 rounded-xl bg-neutral-900/60 border border-neutral-800/80 hover:border-neutral-700/80 transition-colors"
>
{leadingEmoji ? (
<span className="text-sm shrink-0 mt-0.5 select-none">{leadingEmoji}</span>
) : (
<span className="w-1.5 h-1.5 rounded-full bg-purple-400 shrink-0 mt-2 shadow-xs shadow-purple-500/50" />
)}
<div className="text-neutral-300 leading-relaxed flex-1">
{renderInlineFormatting(textWithoutEmoji)}
</div>
</div>
);
}
// Párrafo estándar dentro del bloque
return (
<p key={lIdx} className="text-neutral-300 leading-relaxed">
{renderInlineFormatting(line)}
</p>
);
})}
</div>
);
}
// Caso 3: Encabezado aislado
if (lines.length === 1 && isHeadingLine(lines[0])) {
return (
<div key={blockIdx} className="pt-2 pb-1 border-t border-neutral-800/80 flex items-center gap-2">
<Sparkles className="w-3.5 h-3.5 text-purple-400 shrink-0" />
<h4 className="font-extrabold text-white text-xs uppercase tracking-wider">
{renderInlineFormatting(lines[0].replace(/^✨\s*/, ''))}
</h4>
</div>
);
}
// Caso 4: Párrafo gancho inicial con emoji de fuego o similar
if (lines[0].startsWith('🔥')) {
return (
<div
key={blockIdx}
className="p-3 rounded-2xl bg-neutral-900/40 border border-neutral-800/60 text-neutral-200 leading-relaxed whitespace-pre-line"
>
{lines.map((line, lIdx) => (
<p key={lIdx} className={lIdx > 0 ? 'mt-1.5' : ''}>
{renderInlineFormatting(line)}
</p>
))}
</div>
);
}
// Caso 5: Párrafo estándar con saltos de línea preservados
return (
<div key={blockIdx} className="space-y-1.5">
{lines.map((line, lIdx) => (
<p key={lIdx} className="text-neutral-300/90 leading-relaxed">
{renderInlineFormatting(line)}
</p>
))}
</div>
);
})}
</div>
);
};

View File

@ -19,6 +19,7 @@ import { formatCOP, calculateSeparationBreakdown, getProductEffectivePrice } fro
import { useBrand } from '@/context/BrandContext';
import { getProductSocialProof } from '@/lib/social-proof';
import { OptimizedImage } from '@/components/ui/OptimizedImage';
import { FormattedDescription } from './FormattedDescription';
interface ProductModalProps {
@ -285,10 +286,10 @@ export const ProductModal: React.FC<ProductModalProps> = ({
<div className="space-y-2 pt-1 border-t border-neutral-800/80">
<div className="flex items-center justify-between">
<span className="text-xs font-black text-white uppercase">
Selecciona tu Talla (CO / Horma Nacional):
Selecciona tu Talla (EUR):
</span>
<span className="text-[11px] text-emerald-400 font-bold flex items-center gap-1">
<Check className="w-3 h-3" /> Horma Nacional
<span className="text-[11px] text-purple-400 font-bold flex items-center gap-1">
<Check className="w-3 h-3" /> Tallas EUR
</span>
</div>
@ -310,7 +311,7 @@ export const ProductModal: React.FC<ProductModalProps> = ({
>
<span>{size}</span>
<span className="text-[8px] font-mono leading-none mt-0.5 opacity-80">
{hasCustomSizePrice ? `$${Math.round(sizePrice / 1000)}k` : 'CO'}
{hasCustomSizePrice ? `$${Math.round(sizePrice / 1000)}k` : 'EUR'}
</span>
</button>
);
@ -319,8 +320,12 @@ export const ProductModal: React.FC<ProductModalProps> = ({
</div>
{/* Description */}
<div className="text-xs text-neutral-400 leading-relaxed border-t border-neutral-800 pt-3">
<p>{product.description}</p>
<div className="border-t border-neutral-800/80 pt-3.5">
<h4 className="text-[11px] font-black uppercase text-neutral-400 tracking-wider mb-2.5 flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-purple-400" />
<span>Detalles y Especificaciones</span>
</h4>
<FormattedDescription description={product.description} />
</div>
{/* Shipping note */}

View File

@ -294,11 +294,44 @@ export async function fetchRecentMediaFromSameChat(
* Notifica inmediatamente la publicación automática de un nuevo producto
* exclusivamente al WhatsApp personal de Carlos (+57 316 745 5877).
*/
const dispatchedNotificationsCache = new Map<string, number>();
export async function notifyNewProductPublished(
product: any,
sourceGroup: string,
mediaUrl?: string
): Promise<{ success: boolean; dispatchedTo: string[] }> {
const prodId = product?.id || '';
const prodName = (product?.name || '').trim().toLowerCase();
const msgId = product?.whatsappMessageId || '';
const dedupKey = `${prodId}:${msgId}:${prodName}`;
const now = Date.now();
// Limpieza de claves viejas (> 30 minutos)
for (const [k, time] of dispatchedNotificationsCache.entries()) {
if (now - time > 1800000) {
dispatchedNotificationsCache.delete(k);
}
}
const lastDispatched =
dispatchedNotificationsCache.get(dedupKey) ||
(prodName ? dispatchedNotificationsCache.get(`name:${prodName}`) : 0) ||
(msgId ? dispatchedNotificationsCache.get(`msg:${msgId}`) : 0);
if (lastDispatched && now - lastDispatched < 600000) {
console.log(
`🛡️ [notifyNewProductPublished] Notificación duplicada evitada para "${product?.name}" (despachada hace ${Math.round(
(now - lastDispatched) / 1000
)}s). No se reenvía a Carlos.`
);
return { success: true, dispatchedTo: [] };
}
dispatchedNotificationsCache.set(dedupKey, now);
if (prodName) dispatchedNotificationsCache.set(`name:${prodName}`, now);
if (msgId) dispatchedNotificationsCache.set(`msg:${msgId}`, now);
const NOTIFICATION_TARGETS = [
'573167455877', // Carlos (WhatsApp personal exclusivo)
];
@ -324,7 +357,7 @@ export async function notifyNewProductPublished(
`💵 *Valor de Venta (Público):* *${retailFormatted} COP*\n` +
`🏭 *Costo Proveedor:* *${supplierFormatted} COP*\n` +
`💰 *Ganancia Neta Estimada:* *+${profitFormatted} COP* (~${profitMarginPercent}% margen)\n` +
`📏 *Tallas Disponibles:* *${sizesText}*\n` +
`📏 *Tallas Disponibles:* *${sizesText}* (Tallas EUR)\n` +
`🎨 *Estilo / Color:* ${product.colorName || 'Estilo Principal'}\n` +
`📦 *Grupo Proveedor Origen:* ${sourceGroup || 'Canal Mayorista'}\n\n` +
`🛍️ *Canales de Publicación Activos:*\n` +

View File

@ -190,7 +190,7 @@ export function generateSalesCopy(params: {
return `✨ ${storeName.toUpperCase()} • LANZAMIENTO EXCLUSIVO ✨
👟 ${productName.toUpperCase()} (${brand})
📏 Tallas Disponibles: ${sizesFormatted} (Horma Nacional)
📏 Tallas Disponibles: ${sizesFormatted} (EUR)
💎 PRECIO DE VENTA: ${formatCOP(retailPrice)} COP
🚚 ¡DOMICILIO EN ${cityName.toUpperCase()} Y ENVÍOS A TODO EL PAÍS!

View File

@ -177,7 +177,7 @@ Estamos alistando tu paquete. Te enviaremos el número de guía o notificación
content: `Hola {tienda_nombre}! 👋 Quiero pedir las zapatillas:
👟 *{zapatilla_nombre}*
🎨 Color: *{color_nombre}*
📏 Talla: *{talla}* (Horma Nacional)
📏 Talla: *{talla}* (EUR)
💎 Precio: *{precio} COP*
🚚 Entrega en: *{ciudad}* (Pago Contraentrega)
⚡ Deseo separar con $10.000 COP y cancelar el saldo en mi casa.
@ -186,7 +186,7 @@ Estamos alistando tu paquete. Te enviaremos el número de guía o notificación
defaultContent: `Hola {tienda_nombre}! 👋 Quiero pedir las zapatillas:
👟 *{zapatilla_nombre}*
🎨 Color: *{color_nombre}*
📏 Talla: *{talla}* (Horma Nacional)
📏 Talla: *{talla}* (EUR)
💎 Precio: *{precio} COP*
🚚 Entrega en: *{ciudad}* (Pago Contraentrega)
⚡ Deseo separar con $10.000 COP y cancelar el saldo en mi casa.
@ -626,7 +626,7 @@ Cualquier duda o asesoría adicional, ¡estamos para servirte con todo el gusto!
content: `Colección: {coleccion_icono} *{coleccion_nombre} {tienda_nombre}*
{descripcion_producto}
📏 Tallas disponibles: {tallas} (Horma Nacional)
📏 Tallas disponibles: {tallas} (EUR)
💎 Precio oficial: {precio} COP
🚚 Envíos a toda Colombia (el valor del flete y tiempo de entrega varían según la ciudad de destino)
💵 Pago contraentrega sujeto a cobertura de tu ciudad
@ -641,7 +641,7 @@ La atención comercial y asesoría es por este WhatsApp. Para garantizar tu desp
defaultContent: `Colección: {coleccion_icono} *{coleccion_nombre} {tienda_nombre}*
{descripcion_producto}
📏 Tallas disponibles: {tallas} (Horma Nacional)
📏 Tallas disponibles: {tallas} (EUR)
💎 Precio oficial: {precio} COP
🚚 Envíos a toda Colombia (el valor del flete y tiempo de entrega varían según la ciudad de destino)
💵 Pago contraentrega sujeto a cobertura de tu ciudad
@ -657,7 +657,7 @@ La atención comercial y asesoría es por este WhatsApp. Para garantizar tu desp
{ key: 'tienda_nombre', label: 'Nombre Tienda', example: 'DripZone', description: 'Nombre de la marca' },
{ key: 'coleccion_icono', label: 'Ícono Colección', example: '👟', description: 'Emoji de categoría' },
{ key: 'coleccion_nombre', label: 'Nombre Colección', example: 'Zapatillas Urbanas', description: 'Categoría WhatsApp' },
{ key: 'descripcion_producto', label: 'Descripción Base', example: 'Zapatillas exclusivas en horma nacional...', description: 'Detalle del modelo' },
{ key: 'descripcion_producto', label: 'Descripción Base', example: 'Zapatillas exclusivas en numeración EUR...', description: 'Detalle del modelo' },
{ key: 'tallas', label: 'Tallas Disponibles', example: '37 al 43', description: 'Curva de tallas' },
{ key: 'precio', label: 'Precio COP', example: '$125.000', description: 'Precio de venta' },
{ key: 'enlace_compra', label: 'Enlace Directo Checkout', example: 'https://drip-zone.online/checkout?productId=...', description: 'Link de compra' },
@ -673,7 +673,7 @@ La atención comercial y asesoría es por este WhatsApp. Para garantizar tu desp
isActive: true,
content: `{saludo} Qué gusto saludarte de parte de *{tienda_nombre} Colombia* 🇨🇴
Para confirmar tu pedido de las *{producto_nombre}* en talla *{talla}* (Horma Nacional) y despacharlo con total garantía y factura:
Para confirmar tu pedido de las *{producto_nombre}* en talla *{talla}* (EUR) y despacharlo con total garantía y factura:
💎 *Precio Oficial:* {precio} COP
🚚 *Pago Contraentrega:* Separas con solo *$10.000* a través de Bold oficial (PSE o tarjetas) y el saldo restante lo cancelas contraentrega (en efectivo o mediante Bold) al recibir en tu puerta.
@ -690,7 +690,7 @@ Para confirmar tu pedido de las *{producto_nombre}* en talla *{talla}* (Horma Na
Cualquier inquietud con tu dirección o el pago, ¡aquí estamos para ayudarte!`,
defaultContent: `{saludo} Qué gusto saludarte de parte de *{tienda_nombre} Colombia* 🇨🇴
Para confirmar tu pedido de las *{producto_nombre}* en talla *{talla}* (Horma Nacional) y despacharlo con total garantía y factura:
Para confirmar tu pedido de las *{producto_nombre}* en talla *{talla}* (EUR) y despacharlo con total garantía y factura:
💎 *Precio Oficial:* {precio} COP
🚚 *Pago Contraentrega:* Separas con solo *$10.000* a través de Bold oficial (PSE o tarjetas) y el saldo restante lo cancelas contraentrega (en efectivo o mediante Bold) al recibir en tu puerta.
@ -759,7 +759,7 @@ Cualquier inquietud con tu dirección o el pago, ¡aquí estamos para ayudarte!`
isActive: true,
content: `👟 *{producto_nombre}*
━━━━━━━━━━━━━━━━━━━━━━
📏 *Tallas Disponibles:* {tallas} (Horma Nacional)
📏 *Tallas Disponibles:* {tallas} (EUR)
💵 *Precio Oficial:* *{precio} COP*
🚚 *Pago Contraentrega:* Pagas un anticipo de *$10.000* por Bold oficial para despacho garantizado y el saldo restante al recibir (en efectivo o mediante Bold).
📦 *Envío:* El costo del flete depende de la ciudad de destino.
@ -774,7 +774,7 @@ Para tu seguridad, asignación de factura y reserva de tu talla, el pedido y pag
Diligencia tus datos de envío en el enlace y hoy mismo programamos tu despacho. Si necesitas ayuda con tu talla habitual, ¡aquí estamos para asesorarte!`,
defaultContent: `👟 *{producto_nombre}*
━━━━━━━━━━━━━━━━━━━━━━
📏 *Tallas Disponibles:* {tallas} (Horma Nacional)
📏 *Tallas Disponibles:* {tallas} (EUR)
💵 *Precio Oficial:* *{precio} COP*
🚚 *Pago Contraentrega:* Pagas un anticipo de *$10.000* por Bold oficial para despacho garantizado y el saldo restante al recibir (en efectivo o mediante Bold).
📦 *Envío:* El costo del flete depende de la ciudad de destino.
@ -803,7 +803,7 @@ Diligencia tus datos de envío en el enlace y hoy mismo programamos tu despacho.
channel: 'whatsapp',
isActive: true,
content: `¡Hola! 👋 Te damos la bienvenida a *{tienda_nombre} Colombia* 🇨🇴
Tu tienda oficial de sneakers, zapatillas y streetwear con horma nacional.
Tu tienda oficial de sneakers, zapatillas y streetwear en tallas EUR.
━━━━━━━━━━━━━━━━━━━━━━
👀 *Puedes consultar nuestro catálogo y modelos aquí en WhatsApp o en nuestra tienda online:*
@ -820,7 +820,7 @@ Tu tienda oficial de sneakers, zapatillas y streetwear con horma nacional.
💬 *¿Te gustó algún modelo de nuestro catálogo de WhatsApp?* Indícanos el nombre o tu talla y con gusto te asesoramos y enviamos el enlace directo.`,
defaultContent: `¡Hola! 👋 Te damos la bienvenida a *{tienda_nombre} Colombia* 🇨🇴
Tu tienda oficial de sneakers, zapatillas y streetwear con horma nacional.
Tu tienda oficial de sneakers, zapatillas y streetwear en tallas EUR.
━━━━━━━━━━━━━━━━━━━━━━
👀 *Puedes consultar nuestro catálogo y modelos aquí en WhatsApp o en nuestra tienda online:*
@ -881,14 +881,14 @@ Tu tienda oficial de sneakers, zapatillas y streetwear con horma nacional.
isActive: true,
content: `🛡️ *Políticas de Garantía y Cambio de Talla - {tienda_nombre}*
👟 *Horma Nacional:* Todos nuestros modelos cuentan con horma nacional colombiana estándar.
👟 *Tallas EUR:* Todos nuestros modelos se manejan en numeración estándar EUR.
🔄 *Cambios de Talla:* La posibilidad y condiciones del cambio de talla dependen de la cobertura logística de cada ciudad:
• *Cali y cobertura metropolitana:* Primer cambio de talla coordinado con nuestro mensajero local.
• *Nivel Nacional:* Cambio de talla garantizado sujeto a disponibilidad de inventario; el flete logístico del reenvío se coordina según la ciudad de destino.
✅ *Garantía de Fábrica:* Respaldo total por defectos de manufactura en todos los envíos.`,
defaultContent: `🛡️ *Políticas de Garantía y Cambio de Talla - {tienda_nombre}*
👟 *Horma Nacional:* Todos nuestros modelos cuentan con horma nacional colombiana estándar.
👟 *Tallas EUR:* Todos nuestros modelos se manejan en numeración estándar EUR.
🔄 *Cambios de Talla:* La posibilidad y condiciones del cambio de talla dependen de la cobertura logística de cada ciudad:
• *Cali y cobertura metropolitana:* Primer cambio de talla coordinado con nuestro mensajero local.
• *Nivel Nacional:* Cambio de talla garantizado sujeto a disponibilidad de inventario; el flete logístico del reenvío se coordina según la ciudad de destino.

View File

@ -35,7 +35,7 @@ export const WHATSAPP_COLLECTIONS: Record<string, WhatsAppCatalogCollection> = {
productType: 'Calzado > Sneakers y Zapatillas',
googleCategory: 'Apparel & Accessories > Shoes',
googleCategoryId: '187',
description: 'Sneakers urbanos, zapatillas deportivas y calzado casual en horma nacional.',
description: 'Sneakers urbanos, zapatillas deportivas y calzado casual en numeración EUR.',
},
gorras: {
id: 'gorras',
@ -210,7 +210,7 @@ export function formatProductForWhatsAppCatalog(product: Product) {
// Descripción optimizada para WhatsApp Catalog utilizando la plantilla configurable
const fallbackProductDesc =
product.description || `${collection.name} ${title} exclusivas en horma nacional.`;
product.description || `${collection.name} ${title} exclusivas en numeración EUR.`;
const catalogDescription = dataService.renderMessageTemplate('catalog_product_desc', {
tienda_nombre: 'DripZone',

View File

@ -0,0 +1,116 @@
import paramiko
import os
import urllib.request
import json
import ssl
import sys
import io
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
HOST = "161.97.93.156"
USER = "carlos"
PASSWORD = "Innova1978+*?"
LOCAL_APK = r"c:\Users\Carlos\dev\almeira-web\dripzone-app\public\DripZone.apk"
REMOTE_DIR = "/home/carlos/apps/dripzone-app/public"
REMOTE_APK = "/home/carlos/apps/dripzone-app/public/DripZone.apk"
EVOLUTION_API_URL = "https://evolution.almeira.com.co"
EVOLUTION_API_KEY = "EE49029EEB85-4568-8F81-C9FDFC6E2943"
INSTANCE_NAME = "AutomataAlmeira"
TARGET_PHONE = "573167455877"
APK_URL = "https://drip-zone.online/DripZone.apk"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def upload_apk():
print(f"[1/3] Conectando a VPS {HOST} vía SSH/SFTP...")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(hostname=HOST, username=USER, password=PASSWORD, timeout=30)
sftp = client.open_sftp()
local_size_mb = os.path.getsize(LOCAL_APK) / (1024 * 1024)
print(f" -> Subiendo {LOCAL_APK} ({local_size_mb:.2f} MB) a {REMOTE_APK}...")
try:
sftp.mkdir("/home/carlos/apps/dripzone-app")
except:
pass
try:
sftp.mkdir(REMOTE_DIR)
except:
pass
sftp.put(LOCAL_APK, REMOTE_APK)
sftp.close()
print(" [OK] Archivo subido por SFTP al VPS.")
print("[2/3] Copiando APK dentro del contenedor Docker en producción...")
stdin, stdout, stderr = client.exec_command("docker ps --format '{{.Names}}'")
containers = [c.strip() for c in stdout.read().decode('utf-8').splitlines() if c.strip()]
drip_containers = [c for c in containers if 'bfmno' in c.lower() or 'drip' in c.lower()]
for c in drip_containers:
cmd = f"docker cp {REMOTE_APK} {c}:/app/public/DripZone.apk 2>/dev/null || docker cp {REMOTE_APK} {c}:/app/DripZone.apk 2>/dev/null || true"
client.exec_command(cmd)
print(f" -> Copiado a contenedor web: {c}")
client.close()
print(" [OK] APK actualizado en el servidor de producción.")
def send_whatsapp_notification():
print("[3/3] Enviando APK y notificación oficial al WhatsApp de Carlos (+57 316 745 5877)...")
message_text = (
"🛡️ *DripZone - Corrección Crítica de Monitoreo de Grupos & Sanitización Total*\n\n"
"Hola Carlos, se ha corregido y blindado por completo el sistema de escucha de WhatsApp:\n\n"
"🚫 *1. Exclusión Permanente de Grupos No Deseados:*\n"
"• *SOMOS BLACK DIAMONDS* (`120363400148850031@g.us`) y *TEAM EMPORIO* (`120363420942364759@g.us`) han sido agregados a una lista negra estricta e incondicional.\n"
"• Se eliminó el auto-registro que los volvía a activar en cada reinicio/despliegue del servidor.\n"
"• En la base de datos Appwrite y en el panel administrativo quedaron marcados permanentemente como: *⛔ IGNORADO EXPLICITAMENTE*.\n\n"
"🧹 *2. Eliminación de 22 Publicaciones Erróneas:* \n"
"• Se eliminaron por completo de Appwrite y de la tienda todos los mensajes ajenos a calzado que se habían colado de esos grupos (charlas de Zoom, conferencias, podcasts, anuncios de liderazgo, smoothies, ropa y relojes).\n\n"
"🔒 *3. Filtro Antispam y Validación de Calzado en Webhook:*\n"
"• El webhook descarta automáticamente cualquier mensaje de grupos no monitoreados.\n"
"• Si un mensaje contiene palabras de eventos, webinars, reuniones o contenido ajeno al calzado, es rechazado de inmediato y nunca se publicará.\n\n"
"📦 *4. App Android Compilada & Actualizada:*\n"
"• APK compilado limpiamente y optimizado (35.5 MB).\n"
f"🔗 *Descarga Directa:* {APK_URL}\n\n"
"Te adjunto el APK instalador a continuación."
)
req_text = urllib.request.Request(
f"{EVOLUTION_API_URL}/message/sendText/{INSTANCE_NAME}",
data=json.dumps({"number": TARGET_PHONE, "text": message_text, "delay": 500}).encode("utf-8"),
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req_text, context=ctx, timeout=30) as resp:
print(" [OK] Mensaje de texto enviado a WhatsApp (Status:", resp.status, ")")
except Exception as e:
print(" [Error texto WhatsApp]:", e)
# Enviar documento APK
req_media = urllib.request.Request(
f"{EVOLUTION_API_URL}/message/sendMedia/{INSTANCE_NAME}",
data=json.dumps({
"number": TARGET_PHONE,
"mediatype": "document",
"mimetype": "application/vnd.android.package-archive",
"fileName": "DripZone_Correccion_Grupos.apk",
"caption": "📲 *DripZone APK Actualizado - Blindaje de Grupos & Sanitización*",
"media": APK_URL
}).encode("utf-8"),
headers={"apikey": EVOLUTION_API_KEY, "Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req_media, context=ctx, timeout=180) as resp:
print(" [OK] Documento APK enviado a WhatsApp (Status:", resp.status, ")")
except Exception as e:
print(" [Error envío documento APK]:", e)
if __name__ == "__main__":
upload_apk()
send_whatsapp_notification()