diff --git a/deploy_dripzone_vps.py b/deploy_dripzone_vps.py
index bff8c30..cab5a3a 100644
--- a/deploy_dripzone_vps.py
+++ b/deploy_dripzone_vps.py
@@ -78,6 +78,10 @@ def deploy():
cd {REMOTE_DIR}
docker compose -f docker-compose.prod.yml down --remove-orphans || true
DOCKER_BUILDKIT=1 docker compose -f docker-compose.prod.yml up -d --build
+ sleep 3
+ docker cp {REMOTE_DIR}/public/DripZone.apk dripzone-production:/app/public/DripZone.apk || true
+ docker cp {REMOTE_DIR}/public/DripZone.apk dripzone-production:/app/DripZone.apk || true
+ docker exec dripzone-production chmod 644 /app/public/DripZone.apk /app/DripZone.apk || true
docker ps | grep dripzone
"""
diff --git a/dripzone-app/src/app/admin/feed-whatsapp/page.tsx b/dripzone-app/src/app/admin/feed-whatsapp/page.tsx
index 932218a..213db81 100644
--- a/dripzone-app/src/app/admin/feed-whatsapp/page.tsx
+++ b/dripzone-app/src/app/admin/feed-whatsapp/page.tsx
@@ -20,6 +20,9 @@ import {
Filter,
CheckSquare,
Square,
+ MinusSquare,
+ AlertCircle,
+ AlertTriangle,
Edit3,
Trash2,
ArrowUpRight,
@@ -383,14 +386,6 @@ export default function SupplierFeedPage() {
});
};
- const handleSelectAllCurrentPage = () => {
- const next = new Set(selectedPostIds);
- paginatedPosts.forEach((p) => {
- if (p.status !== 'published') next.add(p.id);
- });
- setSelectedPostIds(next);
- };
-
const handleDeselectAll = () => {
setSelectedPostIds(new Set());
};
@@ -550,6 +545,88 @@ export default function SupplierFeedPage() {
const publishedCount = posts.filter((p) => p.status === 'published').length;
const discardedCount = posts.filter((p) => p.status === 'discarded').length;
+ const selectedGroupObj = useMemo(() => {
+ return monitoredGroups.find((g) => g.jid === selectedGroupFilter);
+ }, [monitoredGroups, selectedGroupFilter]);
+
+ // Selección de página y global
+ const selectableOnPage = useMemo(() => {
+ return paginatedPosts.filter((p) => p.status !== 'published' && p.status !== 'discarded');
+ }, [paginatedPosts]);
+
+ const isAllPageSelected = useMemo(() => {
+ if (selectableOnPage.length === 0) return false;
+ return selectableOnPage.every((p) => selectedPostIds.has(p.id));
+ }, [selectableOnPage, selectedPostIds]);
+
+ const somePageSelected = useMemo(() => {
+ return selectableOnPage.some((p) => selectedPostIds.has(p.id));
+ }, [selectableOnPage, selectedPostIds]);
+
+ const handleToggleSelectPage = () => {
+ setSelectedPostIds((prev) => {
+ const next = new Set(prev);
+ if (isAllPageSelected) {
+ selectableOnPage.forEach((p) => next.delete(p.id));
+ } else {
+ selectableOnPage.forEach((p) => next.add(p.id));
+ }
+ return next;
+ });
+ };
+
+ const handleSelectAllFiltered = () => {
+ const selectableFiltered = filteredPosts.filter((p) => p.status !== 'published' && p.status !== 'discarded');
+ setSelectedPostIds((prev) => {
+ const next = new Set(prev);
+ selectableFiltered.forEach((p) => next.add(p.id));
+ return next;
+ });
+ };
+
+ const handleDiscardGroupMessages = async (groupJid: string, groupName: string) => {
+ const confirmMsg = `¿Deseas descartar y archivar TODOS los mensajes del grupo "${groupName}"? No volverán a aparecer en pendientes.`;
+ if (!window.confirm(confirmMsg)) return;
+
+ setIsLoading(true);
+ try {
+ const res = await fetch('/api/whatsapp-feed', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'discard-group',
+ groupJid,
+ groupName,
+ }),
+ });
+ const data = await res.json();
+ if (data.success) {
+ setPosts((prev) =>
+ prev.map((p) =>
+ (groupJid && p.channelJid === groupJid) || (groupName && p.channelName === groupName)
+ ? { ...p, status: 'discarded' }
+ : p
+ )
+ );
+ setSelectedPostIds((prev) => {
+ const next = new Set(prev);
+ posts.forEach((p) => {
+ if ((groupJid && p.channelJid === groupJid) || (groupName && p.channelName === groupName)) {
+ next.delete(p.id);
+ }
+ });
+ return next;
+ });
+ setActionMessage(`🗑️ Se han descartado ${data.discardedCount ?? 'todos los'} mensajes de "${groupName}".`);
+ }
+ } catch (e) {
+ console.error(e);
+ setActionMessage('❌ Error al descartar mensajes del grupo.');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
const handleOpenEdit = (post: SupplierPost) => {
setEditingPost(post);
const prodName = post.extractedProduct?.name || 'Artículo Urbano';
@@ -711,11 +788,14 @@ export default function SupplierFeedPage() {
className="bg-neutral-950 border border-neutral-800 text-neutral-300 text-xs rounded-xl px-3 py-2 outline-hidden focus:border-purple-500 max-w-[220px] truncate"
>
- {monitoredGroups.map((g) => (
-
- ))}
+ {monitoredGroups.map((g) => {
+ const isInactive = g.hasLeft || g.isStillMember === false;
+ return (
+
+ );
+ })}
@@ -752,38 +832,134 @@ export default function SupplierFeedPage() {
})}
- {/* Bulk Action Bar */}
+ {/* Aviso Inteligente si el usuario seleccionó un grupo del que ya salió */}
+ {selectedGroupObj && (selectedGroupObj.hasLeft || selectedGroupObj.isStillMember === false) && (
+
+
-
+
{selectedPostIds.size} {selectedPostIds.size === 1 ? 'mensaje seleccionado' : 'mensajes seleccionados'}
diff --git a/dripzone-app/src/app/api/whatsapp-feed/route.ts b/dripzone-app/src/app/api/whatsapp-feed/route.ts
index 6c0ffaa..6e82373 100644
--- a/dripzone-app/src/app/api/whatsapp-feed/route.ts
+++ b/dripzone-app/src/app/api/whatsapp-feed/route.ts
@@ -91,23 +91,32 @@ export async function GET(request: Request) {
console.warn('Error resolviendo chats de WhatsApp:', e);
}
- // 3. Determinar JIDs objetivo a consultar
+ // 3. Determinar JIDs objetivo a consultar (excluyendo grupos de los que salimos o inactivos)
+ const leftJids = new Set(
+ localGroups.filter((g) => g.hasLeft || g.isStillMember === false || !g.isMonitored).map((g) => g.jid)
+ );
+
let targetJids: string[] = [];
if (filterJid && filterJid !== 'all') {
targetJids = [filterJid];
} else {
- const monitored = localGroups.filter((g) => g.isMonitored).map((g) => g.jid);
+ const monitored = localGroups.filter((g) => g.isMonitored && !g.hasLeft && g.isStillMember !== false).map((g) => g.jid);
if (monitored.length > 0) {
targetJids = monitored;
} else if (liveGroups.length > 0) {
- targetJids = liveGroups.map((g: any) => g.remoteJid || g.id);
+ targetJids = liveGroups
+ .map((g: any) => g.remoteJid || g.id)
+ .filter((jid: string) => !leftJids.has(jid));
} else {
- targetJids = Array.from(groupsMap.keys());
+ targetJids = Array.from(groupsMap.keys()).filter((jid: string) => !leftJids.has(jid));
}
}
if (!fetchLive || targetJids.length === 0) {
const filteredSaved = savedPosts.filter((p) => {
+ // Si el mensaje es de un grupo del que salimos y estamos en pendientes, no mostrarlo
+ if (p.channelJid && leftJids.has(p.channelJid) && statusFilter === 'pending') return false;
+
if (statusFilter === 'pending') return p.status === 'pending_review';
if (statusFilter === 'published') return p.status === 'published' || p.status === 'published_store';
if (statusFilter === 'discarded') return p.status === 'discarded';
@@ -484,9 +493,15 @@ export async function POST(request: Request) {
}
if (action === 'bulk-discard' && Array.isArray(posts)) {
- for (const p of posts) {
- await updateSupplierPostStatusInAppwrite(p.id, 'discarded');
- dataService.updateSupplierPostStatus(p.id, 'discarded');
+ const BATCH_SIZE = 10;
+ for (let i = 0; i < posts.length; i += BATCH_SIZE) {
+ const batch = posts.slice(i, i + BATCH_SIZE);
+ await Promise.all(
+ batch.map(async (p) => {
+ await updateSupplierPostStatusInAppwrite(p.id, 'discarded');
+ dataService.updateSupplierPostStatus(p.id, 'discarded');
+ })
+ );
}
return NextResponse.json({
success: true,
@@ -494,6 +509,37 @@ export async function POST(request: Request) {
});
}
+ if (action === 'discard-group' && (body.groupJid || body.groupName)) {
+ const { groupJid, groupName } = body;
+ const allSaved = await getSupplierPostsFromAppwrite();
+ const matchingPosts = allSaved.filter(
+ (p: SupplierPost) =>
+ (groupJid && p.channelJid === groupJid) ||
+ (groupName && (p.channelName === groupName || p.supplierName === groupName))
+ );
+
+ const BATCH_SIZE = 10;
+ for (let i = 0; i < matchingPosts.length; i += BATCH_SIZE) {
+ const batch = matchingPosts.slice(i, i + BATCH_SIZE);
+ await Promise.all(
+ batch.map(async (p: SupplierPost) => {
+ await updateSupplierPostStatusInAppwrite(p.id, 'discarded');
+ dataService.updateSupplierPostStatus(p.id, 'discarded');
+ })
+ );
+ }
+
+ if (groupJid) {
+ dataService.toggleGroupMonitoring(groupJid, false);
+ }
+
+ return NextResponse.json({
+ success: true,
+ message: `¡Se han descartado y archivado ${matchingPosts.length} mensajes del grupo!`,
+ discardedCount: matchingPosts.length,
+ });
+ }
+
if (action === 'market-research' && post) {
const img = post.rawImages && post.rawImages[0];
const res = await performMarketIntelligenceSearch(img, post.rawMessage, post.extractedProduct?.supplierPrice);
diff --git a/dripzone-app/src/app/api/whatsapp-groups/route.ts b/dripzone-app/src/app/api/whatsapp-groups/route.ts
index 30b77b9..b345475 100644
--- a/dripzone-app/src/app/api/whatsapp-groups/route.ts
+++ b/dripzone-app/src/app/api/whatsapp-groups/route.ts
@@ -40,28 +40,57 @@ export async function GET(request: Request) {
);
if (liveGroups.length > 0) {
- const merged: MonitoredWhatsAppGroup[] = liveGroups.map((lg: any) => {
- const jid = lg.remoteJid || lg.id || lg.jid;
- const name = lg.name || lg.pushName || lg.subject || 'Grupo WhatsApp';
- const existing = localGroups.find((g) => g.jid === jid || g.name.toLowerCase() === name.toLowerCase());
+ const merged: MonitoredWhatsAppGroup[] = await Promise.all(
+ liveGroups.map(async (lg: any) => {
+ const jid = lg.remoteJid || lg.id || lg.jid;
+ const name = lg.name || lg.pushName || lg.subject || 'Grupo WhatsApp';
+ let isStillMember = true;
+ let participants = undefined;
- // Pre-marcar como monitoreados grupos reconocidos de proveedores si no había configuración previa
- const isKnownSupplierGroup = /exclusive|emporio|jovic|diamonds|mayorista|calzado|sneakers|zapatillas/i.test(name);
- const isMonitored = existing ? existing.isMonitored : isKnownSupplierGroup;
+ try {
+ const infoRes = await fetch(
+ `${EVOLUTION_API_URL}/group/findGroupInfos/${EVOLUTION_INSTANCE_NAME}?groupJid=${jid}`,
+ {
+ headers: { apikey: EVOLUTION_API_KEY },
+ signal: AbortSignal.timeout(3500),
+ }
+ );
+ if (infoRes.status === 404 || infoRes.status === 401 || infoRes.status === 403) {
+ isStillMember = false;
+ } else if (infoRes.ok) {
+ const infoData = await infoRes.json();
+ if (Array.isArray(infoData.participants)) {
+ participants = infoData.participants.length;
+ }
+ }
+ } catch {
+ // Si falla por timeout, mantener estado previo
+ }
- return {
- id: existing?.id || `grp-${jid.replace(/\D/g, '').slice(-6)}`,
- jid,
- name,
- isMonitored,
- supplierId: existing?.supplierId,
- supplierName: existing?.supplierName || name,
- profitMarginOverride: existing?.profitMarginOverride || 35,
- autoPublish: existing?.autoPublish ?? true,
- participantsCount: lg.unreadCount !== undefined ? lg.unreadCount : undefined,
- notes: existing?.notes || '',
- };
- });
+ const existing = localGroups.find((g) => g.jid === jid || g.name.toLowerCase() === name.toLowerCase());
+
+ // Pre-marcar como monitoreados grupos reconocidos de proveedores si no había configuración previa
+ const isKnownSupplierGroup = /exclusive|emporio|jovic|diamonds|mayorista|calzado|sneakers|zapatillas/i.test(name);
+ const isMonitored = existing
+ ? (existing.isMonitored && isStillMember)
+ : (isKnownSupplierGroup && isStillMember);
+
+ return {
+ id: existing?.id || `grp-${jid.replace(/\D/g, '').slice(-6)}`,
+ jid,
+ name,
+ isMonitored: isStillMember ? isMonitored : false,
+ isStillMember,
+ hasLeft: !isStillMember,
+ supplierId: existing?.supplierId,
+ supplierName: existing?.supplierName || name,
+ profitMarginOverride: existing?.profitMarginOverride || 35,
+ autoPublish: existing?.autoPublish ?? true,
+ participantsCount: participants ?? lg.unreadCount,
+ notes: isStillMember ? (existing?.notes || '') : 'Saliste de este grupo en WhatsApp',
+ };
+ })
+ );
// Asegurar que los locales sigan presentes
localGroups.forEach((lg) => {
@@ -70,6 +99,9 @@ export async function GET(request: Request) {
}
});
+ // Guardar estado actualizado en dataService
+ dataService.saveMonitoredGroups(merged);
+
return NextResponse.json({
success: true,
isLive: true,
@@ -95,9 +127,9 @@ export async function POST(request: Request) {
try {
const body = await request.json();
- if (body.action === 'toggle') {
+ if (body.action === 'toggle' || body.action === 'deactivate-group') {
const { idOrJid, isMonitored } = body;
- const updated = dataService.toggleGroupMonitoring(idOrJid, isMonitored);
+ const updated = dataService.toggleGroupMonitoring(idOrJid, isMonitored !== undefined ? isMonitored : false);
return NextResponse.json({ success: true, group: updated });
}
diff --git a/dripzone-app/src/types/index.ts b/dripzone-app/src/types/index.ts
index a05fa6a..e1e282e 100644
--- a/dripzone-app/src/types/index.ts
+++ b/dripzone-app/src/types/index.ts
@@ -224,6 +224,8 @@ export interface MonitoredWhatsAppGroup {
autoPublish: boolean;
participantsCount?: number;
lastMessageAt?: string;
+ isStillMember?: boolean;
+ hasLeft?: boolean;
notes?: string;
}
diff --git a/scripts/send_whatsapp_delivery.py b/scripts/send_whatsapp_delivery.py
index 77a9321..b65ea1e 100644
--- a/scripts/send_whatsapp_delivery.py
+++ b/scripts/send_whatsapp_delivery.py
@@ -37,18 +37,18 @@ def send_request(endpoint, payload):
def send_update():
message_text = (
"🚀 *DripZone Sneakers - APK Oficial Actualizado*\n\n"
- "✨ *Acceso Completo a Miles de Mensajes & Paginación Avanzada:*\n"
- "📊 *Eliminación del Tope de 60:* Se eliminó la restricción que limitaba la vista a 60 mensajes. "
- "Ahora la plataforma escanea en paralelo todas las páginas del historial de los grupos de WhatsApp, "
- "cargando cientos o miles de mensajes sin recortes.\n"
- "📖 *Paginación Dinámica:* Puedes seleccionar ver 12, 24, 48, 96 o 150 productos por página, "
- "con contador total exacto y selector para saltar a cualquier página instantáneamente.\n"
- "⚡ *Escaneo Todo el Historial:* Botón especial para explorar a profundidad (+1,000 mensajes) en segundos.\n"
- "🆔 *Control de Duplicados & Sanitización:* Se omiten automáticamente los ya publicados y descartados, "
- "con privacidad total comercial (sin nombres de grupos ni costos).\n"
+ "✨ *Identificación y Categorización Inteligente Multi-Producto:*\n"
+ "👟 *Zapatillas & Sneakers:* Detección de siluetas, modelos urbanos y deportivos.\n"
+ "🩴 *Chanclas & Sandalias:* Clasificación precisa de chanclas, slides, crocs y pantuflas con textos de confort.\n"
+ "👜 *Bolsos & Carteras:* Reconocimiento de bolsos de mano, tote bags y carteras con copys persuasivos de lujo.\n"
+ "🎒 *Maletines & Morrales:* Detección de maletines para portátil, mochilas y tulas deportivas.\n"
+ "👝 *Carrieles & Bandoleras:* Identificación de carrieles tradicionales, bandoleras, riñoneras, pecheras y crossbody.\n"
+ "🧢 *Gorras & Accesorios:* Categorización de gorras, caps, sombreros, billeteras y correas.\n"
+ "🏷️ *Filtro Rápido en Feed Administrativo:* Nueva barra de categorías con conteo en tiempo real y selector en el modal de edición.\n"
+ "🛍️ *Catálogo de Clientes Adaptado:* Los clientes ahora pueden filtrar por categoría directamente en la tienda.\n"
"🌐 *VPS Desplegado:* Contenedor en producción activo en `https://dripzone.almeira.com.co`.\n\n"
f"📲 *Descarga directa del APK:* {APK_URL}\n\n"
- "Descarga e instala el archivo para disfrutar de la experiencia completa."
+ "Instala o actualiza el archivo en tu dispositivo Android para disfrutar de estas nuevas funciones."
)
print(f"📤 [1/2] Enviando mensaje de texto a WhatsApp (+{TARGET_PHONE})...")
diff --git a/zapply-app/src/app/api/whatsapp-groups/route.ts b/zapply-app/src/app/api/whatsapp-groups/route.ts
index 30b77b9..b345475 100644
--- a/zapply-app/src/app/api/whatsapp-groups/route.ts
+++ b/zapply-app/src/app/api/whatsapp-groups/route.ts
@@ -40,28 +40,57 @@ export async function GET(request: Request) {
);
if (liveGroups.length > 0) {
- const merged: MonitoredWhatsAppGroup[] = liveGroups.map((lg: any) => {
- const jid = lg.remoteJid || lg.id || lg.jid;
- const name = lg.name || lg.pushName || lg.subject || 'Grupo WhatsApp';
- const existing = localGroups.find((g) => g.jid === jid || g.name.toLowerCase() === name.toLowerCase());
+ const merged: MonitoredWhatsAppGroup[] = await Promise.all(
+ liveGroups.map(async (lg: any) => {
+ const jid = lg.remoteJid || lg.id || lg.jid;
+ const name = lg.name || lg.pushName || lg.subject || 'Grupo WhatsApp';
+ let isStillMember = true;
+ let participants = undefined;
- // Pre-marcar como monitoreados grupos reconocidos de proveedores si no había configuración previa
- const isKnownSupplierGroup = /exclusive|emporio|jovic|diamonds|mayorista|calzado|sneakers|zapatillas/i.test(name);
- const isMonitored = existing ? existing.isMonitored : isKnownSupplierGroup;
+ try {
+ const infoRes = await fetch(
+ `${EVOLUTION_API_URL}/group/findGroupInfos/${EVOLUTION_INSTANCE_NAME}?groupJid=${jid}`,
+ {
+ headers: { apikey: EVOLUTION_API_KEY },
+ signal: AbortSignal.timeout(3500),
+ }
+ );
+ if (infoRes.status === 404 || infoRes.status === 401 || infoRes.status === 403) {
+ isStillMember = false;
+ } else if (infoRes.ok) {
+ const infoData = await infoRes.json();
+ if (Array.isArray(infoData.participants)) {
+ participants = infoData.participants.length;
+ }
+ }
+ } catch {
+ // Si falla por timeout, mantener estado previo
+ }
- return {
- id: existing?.id || `grp-${jid.replace(/\D/g, '').slice(-6)}`,
- jid,
- name,
- isMonitored,
- supplierId: existing?.supplierId,
- supplierName: existing?.supplierName || name,
- profitMarginOverride: existing?.profitMarginOverride || 35,
- autoPublish: existing?.autoPublish ?? true,
- participantsCount: lg.unreadCount !== undefined ? lg.unreadCount : undefined,
- notes: existing?.notes || '',
- };
- });
+ const existing = localGroups.find((g) => g.jid === jid || g.name.toLowerCase() === name.toLowerCase());
+
+ // Pre-marcar como monitoreados grupos reconocidos de proveedores si no había configuración previa
+ const isKnownSupplierGroup = /exclusive|emporio|jovic|diamonds|mayorista|calzado|sneakers|zapatillas/i.test(name);
+ const isMonitored = existing
+ ? (existing.isMonitored && isStillMember)
+ : (isKnownSupplierGroup && isStillMember);
+
+ return {
+ id: existing?.id || `grp-${jid.replace(/\D/g, '').slice(-6)}`,
+ jid,
+ name,
+ isMonitored: isStillMember ? isMonitored : false,
+ isStillMember,
+ hasLeft: !isStillMember,
+ supplierId: existing?.supplierId,
+ supplierName: existing?.supplierName || name,
+ profitMarginOverride: existing?.profitMarginOverride || 35,
+ autoPublish: existing?.autoPublish ?? true,
+ participantsCount: participants ?? lg.unreadCount,
+ notes: isStillMember ? (existing?.notes || '') : 'Saliste de este grupo en WhatsApp',
+ };
+ })
+ );
// Asegurar que los locales sigan presentes
localGroups.forEach((lg) => {
@@ -70,6 +99,9 @@ export async function GET(request: Request) {
}
});
+ // Guardar estado actualizado en dataService
+ dataService.saveMonitoredGroups(merged);
+
return NextResponse.json({
success: true,
isLive: true,
@@ -95,9 +127,9 @@ export async function POST(request: Request) {
try {
const body = await request.json();
- if (body.action === 'toggle') {
+ if (body.action === 'toggle' || body.action === 'deactivate-group') {
const { idOrJid, isMonitored } = body;
- const updated = dataService.toggleGroupMonitoring(idOrJid, isMonitored);
+ const updated = dataService.toggleGroupMonitoring(idOrJid, isMonitored !== undefined ? isMonitored : false);
return NextResponse.json({ success: true, group: updated });
}
diff --git a/zapply-app/src/types/index.ts b/zapply-app/src/types/index.ts
index 5432358..4349d30 100644
--- a/zapply-app/src/types/index.ts
+++ b/zapply-app/src/types/index.ts
@@ -209,5 +209,7 @@ export interface MonitoredWhatsAppGroup {
autoPublish: boolean;
participantsCount?: number;
lastMessageAt?: string;
+ isStillMember?: boolean;
+ hasLeft?: boolean;
notes?: string;
}