feat: HD whatsapp media streaming proxy, multi-select and bulk actions in admin productos, custom requests module with WhatsApp notification, updated compiled Android APK

This commit is contained in:
AI Bot 2026-09-03 21:09:49 -05:00
parent c7abbd2d54
commit d7e7ab4d8b
16 changed files with 1909 additions and 69 deletions

View File

@ -0,0 +1,434 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Sparkles,
Package,
Clock,
CheckCircle2,
XCircle,
MessageCircle,
Phone,
Filter,
DollarSign,
Tag,
Search,
ExternalLink,
ChevronRight,
AlertCircle,
Eye,
} from 'lucide-react';
import { CustomProductRequest } from '@/types';
import { formatCOP } from '@/lib/pricing-engine';
export default function AdminCustomRequestsPage() {
const [requests, setRequests] = useState<CustomProductRequest[]>([]);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [search, setSearch] = useState('');
const [selectedRequest, setSelectedRequest] = useState<CustomProductRequest | null>(null);
const [isQuoting, setIsQuoting] = useState(false);
const [quotedPriceInput, setQuotedPriceInput] = useState('');
const [adminNotesInput, setAdminNotesInput] = useState('');
const [toastMessage, setToastMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchRequests = async () => {
try {
const res = await fetch('/api/custom-requests');
const data = await res.json();
if (data.success && Array.isArray(data.requests)) {
setRequests(data.requests);
}
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchRequests();
}, []);
const handleUpdateStatus = async (
id: string,
newStatus: CustomProductRequest['status'],
quotedPrice?: number,
notify: boolean = false
) => {
try {
const res = await fetch('/api/custom-requests', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
updates: {
status: newStatus,
quotedPrice: quotedPrice !== undefined ? quotedPrice : undefined,
adminNotes: adminNotesInput || undefined,
},
notifyCustomer: notify,
}),
});
const data = await res.json();
if (data.success) {
setRequests((prev) => prev.map((r) => (r.id === id ? data.request : r)));
setIsQuoting(false);
setToastMessage(`✅ Solicitud actualizada a "${newStatus.toUpperCase()}".`);
if (selectedRequest?.id === id) {
setSelectedRequest(data.request);
}
}
} catch (e) {
console.error(e);
}
};
const filtered = requests.filter((r) => {
if (statusFilter !== 'all' && r.status !== statusFilter) return false;
if (search) {
const q = search.toLowerCase();
const matchName = r.customerName?.toLowerCase().includes(q);
const matchModel = r.modelName?.toLowerCase().includes(q);
const matchBrand = r.brand?.toLowerCase().includes(q);
const matchPhone = r.customerPhone?.includes(q);
if (!matchName && !matchModel && !matchBrand && !matchPhone) return false;
}
return true;
});
const getStatusBadge = (status: CustomProductRequest['status']) => {
switch (status) {
case 'pending':
return (
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/40 flex items-center gap-1">
<Clock className="w-3 h-3" /> Pendiente
</span>
);
case 'searching':
return (
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold bg-blue-500/20 text-blue-300 border border-blue-500/40 flex items-center gap-1">
<Sparkles className="w-3 h-3" /> En Búsqueda
</span>
);
case 'quoted':
return (
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold bg-purple-500/20 text-purple-300 border border-purple-500/40 flex items-center gap-1">
<DollarSign className="w-3 h-3" /> Cotizado
</span>
);
case 'accepted':
case 'completed':
return (
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Apartado / Concretado
</span>
);
case 'rejected':
return (
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold bg-neutral-800 text-neutral-400 border border-neutral-700 flex items-center gap-1">
<XCircle className="w-3 h-3" /> Sin Disponibilidad
</span>
);
default:
return null;
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-black text-white flex items-center gap-2">
<Sparkles className="w-6 h-6 text-fuchsia-400" />
<span>Encargos y Pedidos Especiales ({requests.length})</span>
</h1>
<p className="text-xs text-neutral-400 mt-1">
Gestión de solicitudes de zapatillas y artículos que los clientes piden por encargo. Cotiza con proveedores y responde directamente por WhatsApp.
</p>
</div>
</div>
{toastMessage && (
<div className="bg-purple-950/80 border border-purple-500 text-purple-300 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-md">
<span>{toastMessage}</span>
<button onClick={() => setToastMessage(null)} className="font-bold text-white cursor-pointer ml-3">
✕
</button>
</div>
)}
{/* Filters & Search Toolbar */}
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3">
<div className="flex items-center space-x-1.5 bg-neutral-900 border border-neutral-800 p-1 rounded-2xl overflow-x-auto">
{[
{ id: 'all', label: `Todos (${requests.length})` },
{ id: 'pending', label: 'Pendientes' },
{ id: 'searching', label: 'En Búsqueda' },
{ id: 'quoted', label: 'Cotizados' },
{ id: 'completed', label: 'Concretados' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setStatusFilter(tab.id)}
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer ${
statusFilter === tab.id
? 'bg-purple-600 text-white shadow-md'
: 'text-neutral-400 hover:text-white'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="max-w-xs w-full">
<input
type="text"
placeholder="Buscar por cliente, modelo o teléfono..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400"
/>
</div>
</div>
{/* Requests Table / Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.length === 0 ? (
<div className="col-span-full text-center py-16 bg-neutral-900/50 border border-neutral-800 rounded-3xl p-6 text-neutral-500 text-xs">
No hay solicitudes de encargo que coincidan con los filtros.
</div>
) : (
filtered.map((req) => {
const hasImage = Boolean(req.imageUrl);
return (
<div
key={req.id}
className="bg-neutral-900/90 border border-neutral-800 hover:border-purple-500/50 rounded-3xl p-5 shadow-xl transition-all flex flex-col justify-between space-y-4"
>
<div>
<div className="flex items-start justify-between gap-2 mb-3">
{getStatusBadge(req.status)}
<span className="text-[10px] text-neutral-500 font-mono">
{new Date(req.createdAt).toLocaleDateString('es-CO', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
{hasImage ? (
<div
onClick={() => setSelectedRequest(req)}
className="relative aspect-video rounded-2xl overflow-hidden bg-black mb-3 border border-neutral-800 cursor-pointer group"
>
<img
src={req.imageUrl}
alt={req.modelName}
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white text-xs font-bold gap-1">
<Eye className="w-4 h-4" />
<span>Ver Detalle</span>
</div>
</div>
) : (
<div className="aspect-video rounded-2xl bg-neutral-950 border border-neutral-800 flex items-center justify-center text-neutral-600 text-xs font-bold mb-3">
Sin foto adjunta
</div>
)}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-purple-400 uppercase tracking-wider">
<span>{req.brand}</span>
<span>•</span>
<span>Talla {req.size}</span>
</div>
<h3 className="text-white font-bold text-sm leading-snug line-clamp-1">{req.modelName}</h3>
{req.colorway && <p className="text-neutral-400 text-xs">Color: {req.colorway}</p>}
</div>
<div className="mt-3 pt-3 border-t border-neutral-800/80 space-y-1.5 text-xs">
<div className="flex items-center justify-between text-neutral-300">
<span className="text-neutral-400">Cliente:</span>
<strong className="text-white">{req.customerName}</strong>
</div>
<div className="flex items-center justify-between text-neutral-300">
<span className="text-neutral-400">Ciudad:</span>
<span>{req.customerCity || 'Colombia'}</span>
</div>
{req.targetBudget && (
<div className="flex items-center justify-between text-neutral-300">
<span className="text-neutral-400">Presupuesto:</span>
<span className="text-purple-300 font-bold">{formatCOP(req.targetBudget)}</span>
</div>
)}
{req.quotedPrice && (
<div className="flex items-center justify-between text-emerald-400 bg-emerald-950/30 px-2.5 py-1 rounded-xl border border-emerald-500/30">
<span>Precio Cotizado:</span>
<strong className="font-bold">{formatCOP(req.quotedPrice)}</strong>
</div>
)}
</div>
</div>
{/* Action Buttons */}
<div className="pt-2 flex items-center gap-2">
<a
href={`https://wa.me/${req.customerPhone.replace(/\D/g, '')}?text=${encodeURIComponent(
`¡Hola ${req.customerName}! Te escribo de DripZone sobre tu solicitud para las ${req.brand} ${req.modelName} en talla ${req.size}.`
)}`}
target="_blank"
rel="noopener noreferrer"
className="flex-1 bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 shadow-md shadow-emerald-950/30 cursor-pointer transition-all"
>
<MessageCircle className="w-4 h-4" />
<span>WhatsApp</span>
</a>
<button
onClick={() => {
setSelectedRequest(req);
setQuotedPriceInput(req.quotedPrice ? req.quotedPrice.toString() : '');
setAdminNotesInput(req.adminNotes || '');
setIsQuoting(true);
}}
className="px-3.5 py-2 bg-neutral-800 hover:bg-purple-900/80 border border-neutral-700 hover:border-purple-500 text-white font-bold text-xs rounded-xl cursor-pointer transition-all"
>
Gestionar
</button>
</div>
</div>
);
})
)}
</div>
{/* Modal Detalle / Cotización */}
{selectedRequest && isQuoting && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
<h3 className="font-bold text-white text-base">Gestionar Encargo: {selectedRequest.modelName}</h3>
<button onClick={() => setIsQuoting(false)} className="text-neutral-400 hover:text-white cursor-pointer">
✕
</button>
</div>
<div className="space-y-3 text-xs">
{selectedRequest.imageUrl && (
<div className="relative aspect-video rounded-2xl overflow-hidden bg-black border border-neutral-800">
<img src={selectedRequest.imageUrl} alt="Foto" className="w-full h-full object-contain" />
</div>
)}
<div className="grid grid-cols-2 gap-3 p-3 bg-neutral-950 rounded-2xl border border-neutral-800">
<div>
<span className="text-neutral-500">Cliente:</span>
<p className="font-bold text-white">{selectedRequest.customerName}</p>
</div>
<div>
<span className="text-neutral-500">Teléfono:</span>
<p className="font-bold text-white">{selectedRequest.customerPhone}</p>
</div>
<div>
<span className="text-neutral-500">Marca / Talla:</span>
<p className="font-bold text-purple-300">
{selectedRequest.brand} - Talla {selectedRequest.size}
</p>
</div>
<div>
<span className="text-neutral-500">Presupuesto del Cliente:</span>
<p className="font-bold text-white">
{selectedRequest.targetBudget ? formatCOP(selectedRequest.targetBudget) : 'No especificado'}
</p>
</div>
</div>
{selectedRequest.notes && (
<div className="p-3 bg-neutral-950 rounded-2xl border border-neutral-800">
<span className="text-neutral-500 font-bold">Notas del Cliente:</span>
<p className="text-neutral-300 mt-0.5">{selectedRequest.notes}</p>
</div>
)}
<div>
<label className="block text-neutral-400 font-bold mb-1">Precio de Cotización ($ COP)</label>
<input
type="number"
placeholder="Ej. 165000"
value={quotedPriceInput}
onChange={(e) => setQuotedPriceInput(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2 text-white font-bold text-purple-400 outline-hidden focus:border-purple-400"
/>
</div>
<div>
<label className="block text-neutral-400 font-bold mb-1">Notas Internas / Proveedor</label>
<input
type="text"
placeholder="Ej. Disponible con proveedor Jovic a $95.000"
value={adminNotesInput}
onChange={(e) => setAdminNotesInput(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2 text-white outline-hidden focus:border-purple-400"
/>
</div>
<div className="space-y-2 pt-2 border-t border-neutral-800">
<p className="text-neutral-400 font-bold">Actualizar Estado:</p>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() =>
handleUpdateStatus(selectedRequest.id, 'searching', quotedPriceInput ? parseInt(quotedPriceInput, 10) : undefined)
}
className="p-2.5 rounded-xl bg-blue-950/60 hover:bg-blue-900 border border-blue-500/50 text-blue-200 font-bold cursor-pointer"
>
🔍 En Búsqueda
</button>
<button
type="button"
onClick={() =>
handleUpdateStatus(
selectedRequest.id,
'quoted',
quotedPriceInput ? parseInt(quotedPriceInput, 10) : undefined,
true
)
}
className="p-2.5 rounded-xl bg-purple-600 hover:bg-purple-500 text-white font-bold cursor-pointer shadow-md shadow-purple-950/40"
>
💬 Cotizar y Notificar Cliente
</button>
<button
type="button"
onClick={() =>
handleUpdateStatus(selectedRequest.id, 'completed', quotedPriceInput ? parseInt(quotedPriceInput, 10) : undefined)
}
className="p-2.5 rounded-xl bg-emerald-950/60 hover:bg-emerald-900 border border-emerald-500/50 text-emerald-200 font-bold cursor-pointer"
>
✅ Concretado / Apartado
</button>
<button
type="button"
onClick={() => handleUpdateStatus(selectedRequest.id, 'rejected')}
className="p-2.5 rounded-xl bg-neutral-800 hover:bg-neutral-700 text-neutral-400 font-bold cursor-pointer"
>
❌ Sin Disponibilidad
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -19,6 +19,7 @@ import {
ShieldAlert,
FileText,
LogIn,
Sparkles,
} from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { AuthModal } from '@/components/auth/AuthModal';
@ -37,6 +38,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{ href: '/admin/configuracion', label: 'Datos de la Tienda', icon: Store },
{ href: '/admin/feed-whatsapp', label: 'Feed WhatsApp & IA', icon: Radio, badge: 'Anti-Duplicados' },
{ href: '/admin/productos', label: 'Catálogo de Zapatillas', icon: Package },
{ href: '/admin/encargos', label: 'Encargos Especiales', icon: Sparkles, badge: 'Clientes' },
{ href: '/admin/ciudades', label: 'Ciudades & Fletes', icon: MapPin },
{ href: '/admin/metodos-pago', label: 'Medios de Pago & Anticipos', icon: CreditCard },
{ href: '/admin/pedidos', label: 'Pedidos & Logística', icon: ShoppingBag },

View File

@ -1,7 +1,7 @@
'use client';
import React, { useState, useEffect, useMemo } from 'react';
import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck, Trash2, Edit, Wand2, ArrowRight, Layers, Palette, CheckSquare, Square, Split, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck, Trash2, Edit, Wand2, ArrowRight, Layers, Palette, CheckSquare, Square, MinusSquare, Split, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Tag, AlertCircle, CheckCircle2 } from 'lucide-react';
import { Product, Supplier } from '@/types';
import { INITIAL_PRODUCTS, INITIAL_SUPPLIERS } from '@/lib/data-service';
import { formatCOP } from '@/lib/pricing-engine';
@ -28,6 +28,9 @@ export default function AdminProductsPage() {
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [managingVariantsProduct, setManagingVariantsProduct] = useState<Product | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
const [bulkCategorySelect, setBulkCategorySelect] = useState('Zapatillas');
const [isProcessingBulk, setIsProcessingBulk] = useState(false);
// Pagination State
const [currentPage, setCurrentPage] = useState<number>(1);
@ -365,6 +368,122 @@ export default function AdminProductsPage() {
return filtered.slice(startIndex, startIndex + pageSize);
}, [filtered, startIndex, pageSize]);
// Selección de página y global de productos
const isAllPageSelected = useMemo(() => {
if (paginatedProducts.length === 0) return false;
return paginatedProducts.every((p) => selectedProductIds.includes(p.id));
}, [paginatedProducts, selectedProductIds]);
const somePageSelected = useMemo(() => {
return paginatedProducts.some((p) => selectedProductIds.includes(p.id));
}, [paginatedProducts, selectedProductIds]);
const handleToggleSelectPage = () => {
setSelectedProductIds((prev) => {
const pageIds = paginatedProducts.map((p) => p.id);
if (isAllPageSelected) {
return prev.filter((id) => !pageIds.includes(id));
} else {
return Array.from(new Set([...prev, ...pageIds]));
}
});
};
const handleSelectAllFiltered = () => {
setSelectedProductIds(filtered.map((p) => p.id));
};
const handleBulkDelete = async () => {
const count = selectedProductIds.length;
if (!count) return;
const confirmMsg = `¿Estás seguro de eliminar permanentemente ${count} ${
count === 1 ? 'publicación seleccionada' : 'publicaciones seleccionadas'
} del catálogo? Esta acción no se puede deshacer.`;
if (!window.confirm(confirmMsg)) return;
setIsProcessingBulk(true);
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'bulk-delete',
productIds: selectedProductIds,
}),
});
const data = await res.json();
if (data.success) {
setProducts((prev) => prev.filter((p) => !selectedProductIds.includes(p.id)));
setSelectedProductIds([]);
setToastMessage(`🗑️ Se eliminaron permanentemente ${data.deletedCount || count} productos del catálogo.`);
} else {
alert('Error al eliminar: ' + (data.error || 'Error desconocido'));
}
} catch (e) {
console.error('Error en eliminación masiva:', e);
alert('Error de conexión al eliminar productos.');
} finally {
setIsProcessingBulk(false);
}
};
const handleBulkStatus = async (isActive: boolean) => {
const count = selectedProductIds.length;
if (!count) return;
setIsProcessingBulk(true);
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'bulk-status',
productIds: selectedProductIds,
isActive,
}),
});
const data = await res.json();
if (data.success) {
setProducts((prev) =>
prev.map((p) => (selectedProductIds.includes(p.id) ? { ...p, isActive } : p))
);
setToastMessage(`✨ Se ${isActive ? 'activaron' : 'ocultaron'} ${count} productos seleccionados.`);
}
} catch (e) {
console.error(e);
} finally {
setIsProcessingBulk(false);
}
};
const handleBulkCategory = async (cat: string) => {
const count = selectedProductIds.length;
if (!count || !cat) return;
setIsProcessingBulk(true);
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'bulk-category',
productIds: selectedProductIds,
category: cat,
}),
});
const data = await res.json();
if (data.success) {
setProducts((prev) =>
prev.map((p) => (selectedProductIds.includes(p.id) ? { ...p, category: cat } : p))
);
setIsCategoryModalOpen(false);
setToastMessage(`🏷️ Se asignó la categoría "${cat}" a ${count} productos.`);
}
} catch (e) {
console.error(e);
} finally {
setIsProcessingBulk(false);
}
};
return (
<div className="space-y-6">
{/* Header */}
@ -453,36 +572,152 @@ export default function AdminProductsPage() {
</div>
</div>
{/* Floating Bulk Grouping Toolbar */}
{selectedProductIds.length >= 2 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-neutral-900/95 border-2 border-purple-500 rounded-3xl p-3 px-5 shadow-2xl backdrop-blur-md flex items-center gap-4 animate-in slide-in-from-bottom-5">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-xl bg-purple-600 text-white flex items-center justify-center font-black text-xs">
{selectedProductIds.length}
</div>
<div>
<p className="text-white font-black text-xs leading-tight">
{selectedProductIds.length} Zapatillas Seleccionadas
</p>
<p className="text-[10px] text-neutral-400">
Puedes fusionarlas en 1 solo modelo con múltiples colorways.
</p>
</div>
</div>
{/* Barra de Selección de Página y Acciones Rápidas */}
<div className="bg-neutral-900/80 border border-neutral-800 rounded-2xl p-3 flex items-center justify-between gap-3 flex-wrap backdrop-blur-md shadow-md">
<div className="flex items-center gap-2.5 flex-wrap">
{/* Botón Seleccionar Página Actual */}
<button
type="button"
onClick={handleToggleSelectPage}
disabled={paginatedProducts.length === 0}
className={`flex items-center gap-2 text-xs font-bold px-3.5 py-2 rounded-xl border transition-all cursor-pointer ${
isAllPageSelected
? 'bg-purple-600 text-white border-purple-400 shadow-md shadow-purple-900/30 scale-102'
: somePageSelected
? 'bg-purple-950/80 text-purple-200 border-purple-500/50'
: 'bg-neutral-950 text-neutral-300 border-neutral-800 hover:border-neutral-700 hover:text-white'
}`}
title="Selecciona o deselecciona todas las zapatillas de la página activa"
>
{isAllPageSelected ? (
<CheckSquare className="w-4 h-4 text-white" />
) : somePageSelected ? (
<MinusSquare className="w-4 h-4 text-purple-400" />
) : (
<Square className="w-4 h-4 text-neutral-400" />
)}
<span>
{isAllPageSelected
? `Deseleccionar Página (${paginatedProducts.length})`
: `Seleccionar Página Actual (${paginatedProducts.length})`}
</span>
</button>
<div className="flex items-center gap-2">
{/* Botón Seleccionar Todo el Filtro */}
{filtered.length > paginatedProducts.length && (
<button
type="button"
onClick={handleSelectAllFiltered}
className="text-xs font-bold text-neutral-400 hover:text-purple-300 px-3 py-2 rounded-xl border border-neutral-800 hover:border-purple-500/50 bg-neutral-950 transition-all cursor-pointer flex items-center gap-1.5"
title="Seleccionar todas las publicaciones que coinciden con los filtros actuales"
>
<CheckSquare className="w-3.5 h-3.5 text-purple-400" />
<span>Seleccionar Todos los Filtrados ({filtered.length})</span>
</button>
)}
{selectedProductIds.length > 0 && (
<span className="text-xs text-purple-300 font-bold bg-purple-950/70 px-3 py-1.5 rounded-xl border border-purple-800/50 shadow-xs">
{selectedProductIds.length} {selectedProductIds.length === 1 ? 'producto seleccionado' : 'productos seleccionados'}
</span>
)}
</div>
{selectedProductIds.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
onClick={() => setSelectedProductIds([])}
className="text-xs text-neutral-400 hover:text-white px-3 py-2 rounded-xl bg-neutral-800 hover:bg-neutral-700 cursor-pointer font-bold"
className="text-xs text-neutral-400 hover:text-white px-3 py-1.5 rounded-xl bg-neutral-950 border border-neutral-800 hover:border-neutral-700 cursor-pointer font-bold transition-all"
>
Deseleccionar
</button>
<button
onClick={() => setIsGroupModalOpen(true)}
className="bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:opacity-95 text-white font-black text-xs px-4 py-2 rounded-xl shadow-lg shadow-purple-600/30 flex items-center gap-1.5 cursor-pointer"
type="button"
onClick={handleBulkDelete}
disabled={isProcessingBulk}
className="bg-rose-950/80 hover:bg-rose-900 border border-rose-500/60 text-rose-200 hover:text-white font-bold text-xs px-3.5 py-1.5 rounded-xl flex items-center gap-1.5 cursor-pointer transition-all shadow-md shadow-rose-950/30"
>
<Layers className="w-4 h-4" />
<span>Agrupar como Variantes de 1 Modelo</span>
<Trash2 className="w-3.5 h-3.5 text-rose-400" />
<span>Eliminar ({selectedProductIds.length})</span>
</button>
</div>
)}
</div>
{/* Floating Bulk Actions Toolbar */}
{selectedProductIds.length > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-neutral-900/95 border-2 border-purple-500 rounded-3xl p-3 px-5 shadow-2xl backdrop-blur-md flex items-center gap-3 animate-in slide-in-from-bottom-5 flex-wrap max-w-4xl">
<div className="flex items-center gap-2 pr-2 border-r border-neutral-800">
<div className="w-8 h-8 rounded-xl bg-purple-600 text-white flex items-center justify-center font-black text-xs">
{selectedProductIds.length}
</div>
<div className="hidden sm:block">
<p className="text-white font-black text-xs leading-tight">
{selectedProductIds.length} {selectedProductIds.length === 1 ? 'Producto Seleccionado' : 'Productos Seleccionados'}
</p>
<p className="text-[10px] text-neutral-400">Acciones rápidas en lote</p>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
onClick={handleBulkDelete}
disabled={isProcessingBulk}
className="bg-rose-600 hover:bg-rose-500 text-white font-bold text-xs px-3.5 py-2 rounded-xl flex items-center gap-1.5 shadow-md shadow-rose-950/40 cursor-pointer transition-all"
>
<Trash2 className="w-4 h-4" />
<span>Eliminar</span>
</button>
<button
type="button"
onClick={() => handleBulkStatus(true)}
disabled={isProcessingBulk}
className="bg-emerald-600/90 hover:bg-emerald-500 text-white font-bold text-xs px-3 py-2 rounded-xl flex items-center gap-1.5 shadow-md cursor-pointer transition-all"
>
<Eye className="w-4 h-4" />
<span>Activar</span>
</button>
<button
type="button"
onClick={() => handleBulkStatus(false)}
disabled={isProcessingBulk}
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 font-bold text-xs px-3 py-2 rounded-xl flex items-center gap-1.5 cursor-pointer transition-all"
>
<EyeOff className="w-4 h-4" />
<span>Ocultar</span>
</button>
<button
type="button"
onClick={() => setIsCategoryModalOpen(true)}
disabled={isProcessingBulk}
className="bg-neutral-800 hover:bg-purple-900/60 border border-neutral-700 hover:border-purple-500/60 text-purple-200 font-bold text-xs px-3 py-2 rounded-xl flex items-center gap-1.5 cursor-pointer transition-all"
>
<Tag className="w-4 h-4 text-purple-400" />
<span>Categoría</span>
</button>
{selectedProductIds.length >= 2 && (
<button
type="button"
onClick={() => setIsGroupModalOpen(true)}
className="bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:opacity-95 text-white font-black text-xs px-3.5 py-2 rounded-xl shadow-lg shadow-purple-600/30 flex items-center gap-1.5 cursor-pointer"
>
<Layers className="w-4 h-4" />
<span>Agrupar Variantes</span>
</button>
)}
<button
type="button"
onClick={() => setSelectedProductIds([])}
className="text-xs text-neutral-400 hover:text-white px-2.5 py-2 rounded-xl bg-neutral-800 hover:bg-neutral-700 cursor-pointer font-bold"
>
✕
</button>
</div>
</div>
@ -497,16 +732,13 @@ export default function AdminProductsPage() {
<th className="py-3.5 px-3 text-center w-10">
<input
type="checkbox"
checked={filtered.length > 0 && selectedProductIds.length === filtered.length}
onChange={(e) => {
if (e.target.checked) {
setSelectedProductIds(filtered.map((p) => p.id));
} else {
setSelectedProductIds([]);
}
checked={isAllPageSelected}
ref={(el) => {
if (el) el.indeterminate = somePageSelected && !isAllPageSelected;
}}
onChange={handleToggleSelectPage}
className="w-4 h-4 rounded-md accent-purple-600 cursor-pointer"
title="Seleccionar todas"
title={isAllPageSelected ? 'Deseleccionar página actual' : 'Seleccionar página actual'}
/>
</th>
<th className="py-3.5 px-4">Estado</th>
@ -1153,6 +1385,75 @@ export default function AdminProductsPage() {
</div>
</div>
)}
{/* Modal 4: Cambiar Categoría en Lote */}
{isCategoryModalOpen && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4">
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl max-w-md w-full p-6 shadow-2xl space-y-4 animate-in fade-in">
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
<div className="flex items-center gap-2">
<Tag className="w-5 h-5 text-purple-400" />
<h3 className="font-bold text-white text-base">Asignar Categoría en Bloque</h3>
</div>
<button
onClick={() => setIsCategoryModalOpen(false)}
className="text-neutral-400 hover:text-white cursor-pointer"
>
✕
</button>
</div>
<p className="text-xs text-neutral-300">
Selecciona la categoría a la que deseas asignar los{' '}
<strong className="text-purple-300">{selectedProductIds.length} productos seleccionados</strong>:
</p>
<div className="grid grid-cols-2 gap-2">
{[
'Zapatillas',
'Chanclas / Slides',
'Bolsos y Morrales',
'Maletines',
'Carrieles y Bandoleras',
'Gorras',
'Ropa Urbana',
'Accesorios',
].map((cat) => (
<button
key={cat}
type="button"
onClick={() => setBulkCategorySelect(cat)}
className={`p-2.5 rounded-xl text-xs font-bold text-left border transition-all cursor-pointer ${
bulkCategorySelect === cat
? 'bg-purple-600 text-white border-purple-400 shadow-md'
: 'bg-neutral-950 text-neutral-300 border-neutral-800 hover:border-neutral-700'
}`}
>
{cat}
</button>
))}
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-neutral-800">
<button
type="button"
onClick={() => setIsCategoryModalOpen(false)}
className="px-4 py-2 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded-xl text-xs font-bold cursor-pointer"
>
Cancelar
</button>
<button
type="button"
onClick={() => handleBulkCategory(bulkCategorySelect)}
disabled={isProcessingBulk}
className="px-4 py-2 bg-gradient-to-r from-purple-600 to-fuchsia-500 hover:from-purple-500 hover:to-fuchsia-400 text-white rounded-xl text-xs font-bold shadow-md cursor-pointer"
>
{isProcessingBulk ? 'Actualizando...' : 'Aplicar Categoría'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,210 @@
import { NextRequest, NextResponse } from 'next/server';
import { dataService } from '@/lib/data-service';
import { CustomProductRequest } from '@/types';
import fs from 'fs';
import path from 'path';
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co';
const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943';
const EVOLUTION_INSTANCE = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira';
const CARLOS_PHONE = '573167455877';
// Carpeta local para almacenar fotos de encargos especiales
const UPLOADS_DIR = path.join(process.cwd(), 'public', 'uploads', 'custom-requests');
function ensureUploadsDir() {
if (!fs.existsSync(UPLOADS_DIR)) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}
}
// Función auxiliar para enviar mensajes de WhatsApp vía Evolution API
async function sendWhatsAppMessage(number: string, text: string) {
try {
const cleanNumber = number.replace(/\D/g, '');
const fullNumber = cleanNumber.length === 10 ? `57${cleanNumber}` : cleanNumber;
const res = await fetch(`${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE}`, {
method: 'POST',
headers: {
apikey: EVOLUTION_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
number: fullNumber,
text: text,
}),
});
return await res.json();
} catch (err) {
console.warn('Error enviando WhatsApp a ' + number + ':', err);
return null;
}
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const userId = searchParams.get('userId') || undefined;
const requests = dataService.getCustomRequests(userId);
return NextResponse.json({ success: true, requests });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const {
userId,
customerName,
customerPhone,
customerEmail,
customerCity,
brand,
modelName,
size,
colorway,
targetBudget,
notes,
imageBase64,
} = body;
if (!customerName || !customerPhone || !brand || !modelName || !size) {
return NextResponse.json(
{
success: false,
error: 'Faltan campos obligatorios: Nombre, Teléfono, Marca, Modelo y Talla.',
},
{ status: 400 }
);
}
let finalImageUrl = body.imageUrl || '';
// Si el usuario envió una imagen en base64, guardarla en disco
if (imageBase64 && imageBase64.startsWith('data:image')) {
try {
ensureUploadsDir();
const matches = imageBase64.match(/^data:image\/([a-zA-Z0-9]+);base64,(.+)$/);
if (matches) {
const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1];
const buffer = Buffer.from(matches[2], 'base64');
const filename = `encargo-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`;
const filePath = path.join(UPLOADS_DIR, filename);
fs.writeFileSync(filePath, buffer);
finalImageUrl = `/uploads/custom-requests/${filename}`;
}
} catch (saveErr) {
console.warn('No se pudo guardar la imagen del encargo en disco:', saveErr);
}
}
// Crear la solicitud en DataService
const createdRequest = dataService.addCustomRequest({
userId,
customerName,
customerPhone,
customerEmail,
customerCity: customerCity || 'Colombia',
brand,
modelName,
size,
colorway: colorway || 'Original / Según Foto',
targetBudget: targetBudget ? parseInt(targetBudget, 10) : undefined,
notes: notes || '',
imageUrl: finalImageUrl,
status: 'pending',
});
// 1. Notificación inmediata a Carlos (+57 316 745 5877)
const budgetFormatted = createdRequest.targetBudget
? `$${createdRequest.targetBudget.toLocaleString('es-CO')}`
: 'A cotizar por bodega';
const adminMessage = `🚨 *NUEVA SOLICITUD DE ENCARGO ESPECIAL* 👟
-----------------------------------------
👤 *Cliente:* ${customerName}
📱 *WhatsApp:* ${customerPhone}
🏙️ *Ciudad:* ${customerCity || 'Colombia'}
${customerEmail ? `✉️ *Correo:* ${customerEmail}\n` : ''}
🏷️ *Marca:* ${brand}
👟 *Modelo:* ${modelName}
📏 *Talla Deseada:* ${size}
🎨 *Colorway:* ${colorway || 'Original / Ver Foto'}
💰 *Presupuesto Estimado:* ${budgetFormatted}
📝 *Notas del Cliente:* ${notes || 'Ninguna'}
📅 *Fecha:* ${new Date().toLocaleString('es-CO')}
-----------------------------------------
⚡ ¡Consulta de inmediato con tus proveedores mayoristas para enviar cotización al cliente!`;
sendWhatsAppMessage(CARLOS_PHONE, adminMessage).catch(() => {});
// 2. Confirmación automática al cliente por WhatsApp
const clientMessage = `👋 ¡Hola *${customerName}*!
Recibimos con éxito tu solicitud por encargo para las *${brand} ${modelName}* en talla *${size}*. 👟🔥
Nuestro equipo ya está verificando disponibilidad y mejor precio con nuestras bodegas y proveedores mayoristas. Te responderemos a este mismo chat con la cotización exacta en el menor tiempo posible.
¡Gracias por confiar en DripZone! 🚀`;
sendWhatsAppMessage(customerPhone, clientMessage).catch(() => {});
return NextResponse.json({
success: true,
message: '¡Solicitud de encargo recibida con éxito! Nuestro equipo te contactará por WhatsApp.',
request: createdRequest,
});
} catch (error: any) {
console.error('Error en POST /api/custom-requests:', error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { id, updates, notifyCustomer } = body;
if (!id) {
return NextResponse.json({ success: false, error: 'ID de solicitud requerido' }, { status: 400 });
}
const updated = dataService.updateCustomRequest(id, updates);
if (!updated) {
return NextResponse.json({ success: false, error: 'Solicitud no encontrada' }, { status: 404 });
}
// Si el admin activó la notificación al cliente (por ejemplo al cotizar el producto)
if (notifyCustomer && updated.customerPhone && updated.quotedPrice) {
const quoteMessage = `👋 ¡Hola *${updated.customerName}*!
¡Buenas noticias! Encontramos tu par solicitado: *${updated.brand} ${updated.modelName}* en talla *${updated.size}*. 👟✨
💰 *Precio Cotizado:* $${updated.quotedPrice.toLocaleString('es-CO')} COP
🚚 *Envío:* Despacho directo a tu dirección con pago contraentrega disponible.
¿Deseas confirmar tu pedido para apartarlo de bodega de inmediato? Respóndenos a este mensaje para coordinar la entrega.`;
sendWhatsAppMessage(updated.customerPhone, quoteMessage).catch(() => {});
}
return NextResponse.json({ success: true, request: updated });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ success: false, error: 'ID requerido' }, { status: 400 });
}
const deleted = dataService.deleteCustomRequest(id);
return NextResponse.json({ success: deleted });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
}

View File

@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { dataService } from '@/lib/data-service';
import { getProductSocialProof } from '@/lib/social-proof';
import { deleteProductFromAppwrite, saveProductToAppwrite } from '@/lib/server-appwrite';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
@ -18,6 +19,69 @@ export async function POST(request: Request) {
try {
const body = await request.json();
// ACCIÓN MASIVA: Borrado en bloque de múltiples productos
if (body.action === 'bulk-delete') {
const productIds: string[] = body.productIds || body.ids || [];
if (!Array.isArray(productIds) || productIds.length === 0) {
return NextResponse.json({ success: false, error: 'Lista de IDs requerida' }, { status: 400 });
}
let deletedCount = 0;
for (const id of productIds) {
const del = dataService.deleteProduct(id);
deleteProductFromAppwrite(id).catch(() => {});
if (del) deletedCount++;
}
return NextResponse.json({
success: true,
deletedCount,
message: `¡Se eliminaron permanentemente ${deletedCount} publicaciones del catálogo!`,
});
}
// ACCIÓN MASIVA: Activar / Ocultar en bloque
if (body.action === 'bulk-status') {
const productIds: string[] = body.productIds || body.ids || [];
const isActive = Boolean(body.isActive);
if (!Array.isArray(productIds) || productIds.length === 0) {
return NextResponse.json({ success: false, error: 'Lista de IDs requerida' }, { status: 400 });
}
let updatedCount = 0;
for (const id of productIds) {
const updated = dataService.updateProduct(id, { isActive });
if (updated) {
saveProductToAppwrite(updated).catch(() => {});
updatedCount++;
}
}
return NextResponse.json({
success: true,
updatedCount,
message: `¡Se ${isActive ? 'activaron' : 'ocultaron'} ${updatedCount} productos con éxito!`,
});
}
// ACCIÓN MASIVA: Cambiar categoría en bloque
if (body.action === 'bulk-category') {
const productIds: string[] = body.productIds || body.ids || [];
const category = body.category;
if (!category) {
return NextResponse.json({ success: false, error: 'Categoría requerida' }, { status: 400 });
}
let updatedCount = 0;
for (const id of productIds) {
const updated = dataService.updateProduct(id, { category });
if (updated) {
saveProductToAppwrite(updated).catch(() => {});
updatedCount++;
}
}
return NextResponse.json({
success: true,
updatedCount,
message: `¡Se actualizó la categoría a "${category}" en ${updatedCount} productos!`,
});
}
// ACCIÓN MANUAL: Desagrupar / Separar una variante específica como producto independiente
if (body.action === 'detach-variant') {
const { productId, variantId, customName, customPrice } = body;
@ -107,9 +171,9 @@ export async function POST(request: Request) {
},
];
for (const mergeId of productIdsToMerge || []) {
if (mergeId === targetProductId) continue;
const toMerge = allProducts.find((p) => p.id === mergeId);
for (const toMergeId of productIdsToMerge || []) {
if (toMergeId === targetProductId) continue;
const toMerge = allProducts.find((p) => p.id === toMergeId);
if (!toMerge) continue;
const mergeColors =
@ -141,6 +205,7 @@ export async function POST(request: Request) {
// Eliminar el producto que fue fusionado
dataService.deleteProduct(toMerge.id);
deleteProductFromAppwrite(toMerge.id).catch(() => {});
}
target.colors = colors;
@ -153,6 +218,7 @@ export async function POST(request: Request) {
}
dataService.updateProduct(target.id, target);
saveProductToAppwrite(target).catch(() => {});
return NextResponse.json({
success: true,
product: target,
@ -170,6 +236,7 @@ export async function POST(request: Request) {
originBadge: body.originBadge || proof.badge,
isActive: body.isActive !== undefined ? body.isActive : true,
});
saveProductToAppwrite(newProduct).catch(() => {});
return NextResponse.json({ success: true, product: newProduct });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
@ -189,6 +256,7 @@ export async function PUT(request: Request) {
return NextResponse.json({ success: false, error: 'Producto no encontrado' }, { status: 404 });
}
saveProductToAppwrite(updated).catch(() => {});
return NextResponse.json({ success: true, product: updated });
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
@ -198,19 +266,41 @@ export async function PUT(request: Request) {
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ success: false, error: 'ID de producto requerido' }, { status: 400 });
const singleId = searchParams.get('id');
const rawIds = searchParams.get('ids');
let body: any = null;
try {
body = await request.json();
} catch (_) {}
const idsToDelete: string[] = (
body?.ids ||
body?.productIds ||
(rawIds ? rawIds.split(',') : singleId ? [singleId] : [])
)
.map((x: string) => (x || '').trim())
.filter(Boolean);
if (idsToDelete.length === 0) {
return NextResponse.json(
{ success: false, error: 'ID o lista de IDs de producto requerida' },
{ status: 400 }
);
}
const deleted = dataService.deleteProduct(id);
if (!deleted) {
return NextResponse.json({ success: false, error: 'Producto no encontrado' }, { status: 404 });
let deletedCount = 0;
for (const id of idsToDelete) {
const deleted = dataService.deleteProduct(id);
deleteProductFromAppwrite(id).catch(() => {});
if (deleted) deletedCount++;
}
return NextResponse.json({ success: true, message: 'Producto eliminado exitosamente del catálogo' });
return NextResponse.json({
success: true,
deletedCount,
message: `¡Se ${deletedCount === 1 ? 'eliminó 1 producto' : `eliminaron ${deletedCount} productos`} exitosamente del catálogo!`,
});
} catch (error: any) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
}
}

View File

@ -113,15 +113,26 @@ export async function GET(request: Request) {
}
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;
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';
return true;
});
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';
return true;
})
.map((p) => {
// Si tiene thumbnail de baja resolución pero cuenta con ID de WhatsApp, suministrar la URL en Alta Resolución Full HD
if (p.whatsappMessageId && Array.isArray(p.rawImages) && (p.rawImages.length === 0 || p.rawImages[0].startsWith('data:image'))) {
return {
...p,
rawImages: [`/api/whatsapp-media?id=${encodeURIComponent(p.whatsappMessageId)}&remoteJid=${encodeURIComponent(p.channelJid || '')}`],
};
}
return p;
});
return NextResponse.json({
success: true,
@ -261,7 +272,12 @@ export async function GET(request: Request) {
const imgMsg = record.message?.imageMessage;
if (imgMsg) {
if (imgMsg.jpegThumbnail) {
if (rawMsgId) {
// URL de streaming en Alta Resolución (Full HD) desde Evolution API
imageUrls.push(
`/api/whatsapp-media?id=${encodeURIComponent(rawMsgId)}&remoteJid=${encodeURIComponent(remoteJid)}`
);
} else if (imgMsg.jpegThumbnail) {
imageUrls.push(`data:image/jpeg;base64,${imgMsg.jpegThumbnail}`);
} else if (imgMsg.url) {
imageUrls.push(imgMsg.url);
@ -391,7 +407,14 @@ export async function POST(request: Request) {
marketPriceSourceUrl: post.extractedProduct?.marketPriceSourceUrl || marketIntelligence?.marketPriceSourceUrl,
marketComparisonSummary: post.extractedProduct?.marketComparisonSummary || marketIntelligence?.marketComparisonSummary,
sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: Array.isArray(post.rawImages) && post.rawImages.length > 0 ? post.rawImages : ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
images: Array.isArray(post.rawImages) && post.rawImages.length > 0
? post.rawImages.map((img: string) => {
if (img.startsWith('data:image') && whatsappMsgId) {
return `/api/whatsapp-media?id=${encodeURIComponent(whatsappMsgId)}&remoteJid=${encodeURIComponent(post.channelJid || '')}`;
}
return img;
})
: ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: prodDescription,
category: prodCategory,
inStock: true,
@ -455,7 +478,14 @@ export async function POST(request: Request) {
marketPriceSourceUrl: p.extractedProduct?.marketPriceSourceUrl,
marketComparisonSummary: p.extractedProduct?.marketComparisonSummary,
sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: p.rawImages || ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
images: Array.isArray(p.rawImages) && p.rawImages.length > 0
? p.rawImages.map((img: string) => {
if (img.startsWith('data:image') && whatsappMsgId) {
return `/api/whatsapp-media?id=${encodeURIComponent(whatsappMsgId)}&remoteJid=${encodeURIComponent(p.channelJid || '')}`;
}
return img;
})
: ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: prodDescription,
category: prodCategory,
inStock: true,

View File

@ -0,0 +1,156 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co';
const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943';
const EVOLUTION_INSTANCE = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira';
// Carpeta local para almacenar en caché las imágenes descargadas en alta resolución
const CACHE_DIR = path.join(process.cwd(), 'public', 'cache', 'whatsapp-media');
function ensureCacheDir() {
if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR, { recursive: true });
}
}
// Fallback image buffer (placeholder sneaker)
const FALLBACK_IMAGE_URL = 'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const messageId = searchParams.get('id');
const remoteJid = searchParams.get('remoteJid');
const quality = searchParams.get('q') || 'high';
if (!messageId) {
return NextResponse.redirect(FALLBACK_IMAGE_URL);
}
const cleanId = messageId.replace(/[^a-zA-Z0-9_-]/g, '');
const cacheFilePath = path.join(CACHE_DIR, `${cleanId}.jpg`);
// 1. Si ya existe en caché local en disco, servir directamente en microsegundos
if (fs.existsSync(cacheFilePath)) {
const cachedBuffer = fs.readFileSync(cacheFilePath);
return new Response(cachedBuffer, {
status: 200,
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=31536000, immutable',
'ETag': `"${cleanId}"`,
'Content-Length': cachedBuffer.length.toString(),
},
});
}
// 2. Buscar el mensaje en Evolution API
let messageRecord: any = null;
// Primer intento: búsqueda directa por clave de mensaje
try {
const whereClause: any = { key: { id: messageId } };
if (remoteJid) {
whereClause.key.remoteJid = remoteJid;
}
const findRes = await fetch(`${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_INSTANCE}`, {
method: 'POST',
headers: {
apikey: EVOLUTION_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ where: whereClause }),
});
const findData = await findRes.json();
const records = findData.messages?.records || findData.records || (Array.isArray(findData) ? findData : []);
if (records.length > 0) {
messageRecord = records[0];
}
} catch (e) {
console.warn('Error buscando mensaje por ID exacto:', e);
}
// Segundo intento si no se encontró con where estricto: buscar en mensajes recientes del chat
if (!messageRecord && remoteJid) {
try {
const chatRes = await fetch(`${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_INSTANCE}?page=1&limit=50`, {
method: 'POST',
headers: {
apikey: EVOLUTION_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ where: { key: { remoteJid } } }),
});
const chatData = await chatRes.json();
const records = chatData.messages?.records || chatData.records || (Array.isArray(chatData) ? chatData : []);
messageRecord = records.find((r: any) => r.key?.id === messageId || r.key?.id?.includes(cleanId));
} catch (e) {
console.warn('Error en segundo intento de búsqueda:', e);
}
}
if (!messageRecord || !messageRecord.message?.imageMessage) {
// Si el mensaje no existe o no tiene imagen, redirigir a imagen fallback
return NextResponse.redirect(FALLBACK_IMAGE_URL);
}
// 3. Solicitar el medio en Alta Resolución Full HD a Evolution API
const mediaRes = await fetch(`${EVOLUTION_API_URL}/chat/getBase64FromMediaMessage/${EVOLUTION_INSTANCE}`, {
method: 'POST',
headers: {
apikey: EVOLUTION_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: messageRecord,
convertToMp4: false,
}),
});
const mediaData = await mediaRes.json();
if (mediaData && mediaData.base64) {
const base64Clean = mediaData.base64.replace(/^data:image\/[a-z]+;base64,/, '');
const imageBuffer = Buffer.from(base64Clean, 'base64');
// Guardar en caché de disco para visitas subsiguientes
try {
ensureCacheDir();
fs.writeFileSync(cacheFilePath, imageBuffer);
} catch (cacheErr) {
console.warn('No se pudo escribir archivo de caché:', cacheErr);
}
return new Response(imageBuffer, {
status: 200,
headers: {
'Content-Type': mediaData.mimetype || 'image/jpeg',
'Cache-Control': 'public, max-age=31536000, immutable',
'ETag': `"${cleanId}"`,
'Content-Length': imageBuffer.length.toString(),
},
});
}
// Si Evolution API no retornó base64 pero el mensaje tiene jpegThumbnail, usarlo como fallback temporal
if (messageRecord.message?.imageMessage?.jpegThumbnail) {
const thumbBuffer = Buffer.from(messageRecord.message.imageMessage.jpegThumbnail, 'base64');
return new Response(thumbBuffer, {
status: 200,
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=3600',
},
});
}
return NextResponse.redirect(FALLBACK_IMAGE_URL);
} catch (error: any) {
console.error('Error en GET /api/whatsapp-media:', error);
return NextResponse.redirect(FALLBACK_IMAGE_URL);
}
}

View File

@ -14,9 +14,10 @@ import { AuthModal } from '@/components/auth/AuthModal';
import { Product, City, CartItem } from '@/types';
import { INITIAL_CITIES } from '@/lib/data-service';
import { groupProductsByModel } from '@/lib/product-grouper';
import { Sparkles, ShieldCheck, Truck, RefreshCw, Zap, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import { Sparkles, ShieldCheck, Truck, RefreshCw, Zap, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowRight } from 'lucide-react';
import { useBrand } from '@/context/BrandContext';
import { renderClientTemplate } from '@/lib/templates-client';
import { CustomRequestModal } from '@/components/custom-requests/CustomRequestModal';
export default function StoreHomePage() {
const { currentBrand } = useBrand();
@ -35,6 +36,7 @@ export default function StoreHomePage() {
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [isCartOpen, setIsCartOpen] = useState(false);
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
const [isCustomRequestOpen, setIsCustomRequestOpen] = useState(false);
const [cartItems, setCartItems] = useState<CartItem[]>([]);
const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
@ -240,6 +242,7 @@ export default function StoreHomePage() {
onOpenCitySelector={() => setIsCityModalOpen(true)}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onOpenCustomRequest={() => setIsCustomRequestOpen(true)}
/>
{/* Main Container */}
@ -492,6 +495,29 @@ export default function StoreHomePage() {
)}
</div>
)}
{/* Call to Action: Pedidos Especiales / Encargos */}
<div className="bg-gradient-to-r from-purple-950/80 via-neutral-900 to-purple-950/80 border border-purple-500/40 rounded-3xl p-6 sm:p-8 backdrop-blur-md shadow-2xl flex flex-col md:flex-row items-center justify-between gap-6 my-8">
<div className="space-y-2 text-center md:text-left">
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-purple-500/20 text-purple-300 text-xs font-bold border border-purple-500/30">
<Sparkles className="w-3.5 h-3.5 text-fuchsia-400 animate-pulse" />
<span>¿No encuentras tus zapatillas favoritas en el catálogo?</span>
</div>
<h3 className="text-xl sm:text-2xl font-black text-white">¡Pídelas por Encargo y las Conseguimos!</h3>
<p className="text-xs text-neutral-400 max-w-xl leading-relaxed">
Sube una foto o dinos la marca y modelo exacto que deseas. Lo cotizamos de inmediato con nuestras bodegas mayoristas al mejor precio del mercado con pago contraentrega.
</p>
</div>
<button
type="button"
onClick={() => setIsCustomRequestOpen(true)}
className="shrink-0 px-6 py-3 rounded-2xl bg-gradient-to-r from-purple-600 to-fuchsia-500 hover:from-purple-500 hover:to-fuchsia-400 text-white font-bold text-xs sm:text-sm flex items-center gap-2 shadow-lg shadow-purple-950/50 hover:scale-105 active:scale-95 transition-all cursor-pointer"
>
<Sparkles className="w-4 h-4" />
<span>Pide tus Zapatillas Aquí</span>
<ArrowRight className="w-4 h-4" />
</button>
</div>
</main>
{/* Footer */}
@ -540,6 +566,11 @@ export default function StoreHomePage() {
onSelectCity={setSelectedCity}
/>
<CustomRequestModal
isOpen={isCustomRequestOpen}
onClose={() => setIsCustomRequestOpen(false)}
/>
{/* Mobile App Dock */}
<MobileBottomDock
cartCount={cartCount}

View File

@ -0,0 +1,432 @@
'use client';
import React, { useState, useRef } from 'react';
import { Sparkles, Upload, X, CheckCircle2, MessageCircle, AlertCircle, ArrowRight, Camera } from 'lucide-react';
interface CustomRequestModalProps {
isOpen: boolean;
onClose: () => void;
initialBrand?: string;
initialModel?: string;
}
const POPULAR_BRANDS = [
'Nike',
'Jordan',
'Adidas',
'New Balance',
'Asics',
'Puma',
'Balenciaga',
'Vans',
'Otra',
];
const POPULAR_SIZES = ['36', '37', '38', '39', '40', '41', '42', '43', '44', '45'];
export const CustomRequestModal: React.FC<CustomRequestModalProps> = ({
isOpen,
onClose,
initialBrand = 'Nike',
initialModel = '',
}) => {
const [brand, setBrand] = useState(initialBrand);
const [customBrand, setCustomBrand] = useState('');
const [modelName, setModelName] = useState(initialModel);
const [size, setSize] = useState('40');
const [customSize, setCustomSize] = useState('');
const [colorway, setColorway] = useState('');
const [targetBudget, setTargetBudget] = useState('');
const [notes, setNotes] = useState('');
const [customerName, setCustomerName] = useState('');
const [customerPhone, setCustomerPhone] = useState('');
const [customerCity, setCustomerCity] = useState('');
const [imageBase64, setImageBase64] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
if (!isOpen) return null;
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) {
setErrorMessage('La foto no debe superar los 10MB.');
return;
}
const reader = new FileReader();
reader.onload = () => {
setImageBase64(reader.result as string);
setErrorMessage(null);
};
reader.readAsDataURL(file);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMessage(null);
const effectiveBrand = brand === 'Otra' && customBrand.trim() ? customBrand.trim() : brand;
const effectiveSize = size === 'custom' && customSize.trim() ? customSize.trim() : size;
if (!customerName.trim() || !customerPhone.trim() || !modelName.trim()) {
setErrorMessage('Por favor completa tu nombre, WhatsApp y el modelo de las zapatillas.');
return;
}
setIsLoading(true);
try {
const res = await fetch('/api/custom-requests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customerName: customerName.trim(),
customerPhone: customerPhone.trim(),
customerCity: customerCity.trim() || 'Colombia',
brand: effectiveBrand,
modelName: modelName.trim(),
size: effectiveSize,
colorway: colorway.trim(),
targetBudget: targetBudget ? parseInt(targetBudget.replace(/\D/g, ''), 10) : undefined,
notes: notes.trim(),
imageBase64: imageBase64,
}),
});
const data = await res.json();
if (data.success) {
setIsSuccess(true);
} else {
setErrorMessage(data.error || 'Ocurrió un error al enviar tu encargo.');
}
} catch (err: any) {
setErrorMessage('Error de conexión. Inténtalo nuevamente.');
} finally {
setIsLoading(false);
}
};
const handleResetAndClose = () => {
setIsSuccess(false);
setImageBase64(null);
setModelName('');
setColorway('');
setTargetBudget('');
setNotes('');
setErrorMessage(null);
onClose();
};
return (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-md flex items-center justify-center p-3 sm:p-4 overflow-y-auto animate-in fade-in">
<div className="bg-neutral-900 border border-purple-500/40 rounded-3xl max-w-xl w-full p-5 sm:p-7 shadow-2xl space-y-5 my-8 text-neutral-200 relative">
{/* Close button */}
<button
onClick={handleResetAndClose}
className="absolute top-5 right-5 text-neutral-400 hover:text-white bg-neutral-800/80 hover:bg-neutral-700 p-2 rounded-full cursor-pointer transition-all"
>
<X className="w-5 h-5" />
</button>
{isSuccess ? (
<div className="text-center py-8 space-y-4 animate-in zoom-in-95">
<div className="w-16 h-16 rounded-full bg-emerald-500/20 border-2 border-emerald-500 text-emerald-400 flex items-center justify-center mx-auto shadow-lg shadow-emerald-950/50">
<CheckCircle2 className="w-9 h-9" />
</div>
<div className="space-y-1.5">
<h3 className="text-xl font-black text-white">¡Encargo Recibido con Éxito!</h3>
<p className="text-xs text-neutral-300 max-w-md mx-auto leading-relaxed">
Hemos registrado tu solicitud para las{' '}
<strong className="text-purple-300">{brand} {modelName}</strong> en talla{' '}
<strong className="text-purple-300">{size}</strong>.
</p>
</div>
<div className="bg-neutral-950 border border-neutral-800 rounded-2xl p-4 text-xs text-neutral-400 max-w-md mx-auto text-left space-y-2">
<div className="flex items-center gap-2 text-purple-400 font-bold">
<Sparkles className="w-4 h-4" />
<span>¿Qué pasará ahora?</span>
</div>
<p className="text-[11px] leading-relaxed">
1. Nuestro equipo rastrea el par en nuestras bodegas y proveedores mayoristas.<br />
2. Te enviamos la cotización exacta con fotos y precio a tu WhatsApp <strong>{customerPhone}</strong>.<br />
3. Si estás de acuerdo, lo apartamos y coordinamos el despacho con pago contraentrega.
</p>
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 pt-2">
<button
onClick={handleResetAndClose}
className="w-full sm:w-auto px-6 py-2.5 rounded-xl bg-neutral-800 hover:bg-neutral-700 text-white font-bold text-xs cursor-pointer transition-all"
>
Volver al Catálogo
</button>
<a
href={`https://wa.me/573167455877?text=${encodeURIComponent(
`¡Hola! Acabo de enviar una solicitud de encargo para las ${brand} ${modelName} en talla ${size}. ¿Tienen disponibilidad?`
)}`}
target="_blank"
rel="noopener noreferrer"
className="w-full sm:w-auto px-6 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs flex items-center justify-center gap-2 shadow-lg shadow-emerald-950/40 cursor-pointer transition-all"
>
<MessageCircle className="w-4 h-4" />
<span>Escribir por WhatsApp</span>
</a>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Header */}
<div>
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-purple-950/80 border border-purple-500/40 text-purple-300 text-[11px] font-bold mb-2">
<Sparkles className="w-3.5 h-3.5 text-fuchsia-400" />
<span>Servicio Personalizado de Búsqueda</span>
</div>
<h2 className="text-xl font-black text-white">¿No encuentras tus Zapatillas?</h2>
<p className="text-xs text-neutral-400 mt-1">
Sube una foto o describe el modelo exacto que deseas. Lo cotizamos directamente con nuestras bodegas mayoristas al mejor precio.
</p>
</div>
{errorMessage && (
<div className="bg-rose-950/80 border border-rose-500/50 text-rose-300 p-3 rounded-2xl text-xs flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0 text-rose-400" />
<span>{errorMessage}</span>
</div>
)}
{/* Photo Uploader */}
<div>
<label className="block text-xs font-bold text-neutral-300 mb-1.5 flex items-center justify-between">
<span>Foto de Referencia (Opcional pero muy recomendada)</span>
{imageBase64 && (
<button
type="button"
onClick={() => setImageBase64(null)}
className="text-[10px] text-rose-400 hover:text-rose-300 cursor-pointer"
>
Eliminar foto
</button>
)}
</label>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageChange}
className="hidden"
/>
{imageBase64 ? (
<div className="relative aspect-video rounded-2xl overflow-hidden border border-purple-500/50 bg-black group">
<img src={imageBase64} alt="Referencia" className="w-full h-full object-contain" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1.5 bg-neutral-900/90 text-white rounded-xl text-xs font-bold border border-neutral-700 hover:border-purple-400 cursor-pointer"
>
Cambiar Foto
</button>
</div>
</div>
) : (
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-neutral-700 hover:border-purple-500/60 rounded-2xl p-4 sm:p-5 text-center cursor-pointer transition-all bg-neutral-950/50 hover:bg-neutral-950/80 group"
>
<div className="w-10 h-10 rounded-xl bg-purple-950/60 text-purple-400 flex items-center justify-center mx-auto mb-2 group-hover:scale-110 transition-transform">
<Camera className="w-5 h-5" />
</div>
<p className="text-xs font-bold text-white">Haz clic aquí para subir o tomar una foto</p>
<p className="text-[10px] text-neutral-500 mt-0.5">JPG, PNG o WEBP (Captura de pantalla, Instagram, etc.)</p>
</div>
)}
</div>
{/* Brand Pills */}
<div>
<label className="block text-xs font-bold text-neutral-300 mb-1.5">Marca *</label>
<div className="flex flex-wrap gap-1.5">
{POPULAR_BRANDS.map((b) => (
<button
key={b}
type="button"
onClick={() => setBrand(b)}
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all cursor-pointer ${
brand === b
? 'bg-purple-600 text-white border-purple-400 shadow-md shadow-purple-950/40 scale-105'
: 'bg-neutral-950 text-neutral-400 border-neutral-800 hover:text-white hover:border-neutral-700'
}`}
>
{b}
</button>
))}
</div>
{brand === 'Otra' && (
<input
type="text"
placeholder="Escribe la marca que buscas (ej. On Running, Hoka, Crocs...)"
value={customBrand}
onChange={(e) => setCustomBrand(e.target.value)}
className="mt-2 w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400"
/>
)}
</div>
{/* Model Name & Colorway */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-bold text-neutral-300 mb-1">Modelo / Referencia *</label>
<input
type="text"
required
placeholder="Ej. Air Jordan 4 Retro Military Black"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400"
/>
</div>
<div>
<label className="block text-xs font-bold text-neutral-300 mb-1">Colorway / Color</label>
<input
type="text"
placeholder="Ej. Blanco / Negro o Según Foto"
value={colorway}
onChange={(e) => setColorway(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400"
/>
</div>
</div>
{/* Size Selector */}
<div>
<label className="block text-xs font-bold text-neutral-300 mb-1.5">Talla Deseada (Colombia / US) *</label>
<div className="flex flex-wrap gap-1.5">
{POPULAR_SIZES.map((sz) => (
<button
key={sz}
type="button"
onClick={() => setSize(sz)}
className={`w-9 h-9 rounded-xl text-xs font-bold border transition-all cursor-pointer flex items-center justify-center ${
size === sz
? 'bg-purple-600 text-white border-purple-400 shadow-md scale-105'
: 'bg-neutral-950 text-neutral-400 border-neutral-800 hover:text-white hover:border-neutral-700'
}`}
>
{sz}
</button>
))}
</div>
</div>
{/* Budget & Details */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-bold text-neutral-300 mb-1">Presupuesto Estimado ($ COP)</label>
<input
type="text"
placeholder="Ej. 160.000 (Opcional)"
value={targetBudget}
onChange={(e) => setTargetBudget(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400"
/>
</div>
<div>
<label className="block text-xs font-bold text-neutral-300 mb-1">Ciudad de Entrega</label>
<input
type="text"
placeholder="Ej. Cali, Bogotá, Medellín..."
value={customerCity}
onChange={(e) => setCustomerCity(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400"
/>
</div>
</div>
{/* Notes */}
<div>
<label className="block text-xs font-bold text-neutral-300 mb-1">Notas Adicionales</label>
<textarea
rows={2}
placeholder="Ej. Las necesito con caja original, busco calidad 1.1 o G5, es para un regalo..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400 resize-none"
/>
</div>
{/* Customer Contact */}
<div className="p-3.5 bg-neutral-950/80 rounded-2xl border border-neutral-800 space-y-2.5">
<p className="text-xs font-bold text-purple-300">Datos de Contacto (Para enviarte la cotización):</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<div>
<input
type="text"
required
placeholder="Tu Nombre Completo *"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-400"
/>
</div>
<div>
<input
type="tel"
required
placeholder="Número de WhatsApp (10 dígitos) *"
value={customerPhone}
onChange={(e) => setCustomerPhone(e.target.value)}
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden focus:border-purple-400"
/>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={handleResetAndClose}
className="px-4 py-2.5 rounded-xl bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold text-xs cursor-pointer transition-all"
>
Cancelar
</button>
<button
type="submit"
disabled={isLoading}
className="px-6 py-2.5 rounded-xl bg-gradient-to-r from-purple-600 to-fuchsia-500 hover:from-purple-500 hover:to-fuchsia-400 text-white font-bold text-xs flex items-center gap-2 shadow-lg shadow-purple-950/40 cursor-pointer transition-all disabled:opacity-50"
>
{isLoading ? (
<>
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span>Enviando solicitud...</span>
</>
) : (
<>
<Sparkles className="w-4 h-4" />
<span>Solicitar por Encargo</span>
<ArrowRight className="w-3.5 h-3.5" />
</>
)}
</button>
</div>
</form>
)}
</div>
</div>
);
};

View File

@ -15,6 +15,7 @@ interface NavbarProps {
onOpenCitySelector: () => void;
searchQuery: string;
onSearchChange: (q: string) => void;
onOpenCustomRequest?: () => void;
}
export const Navbar: React.FC<NavbarProps> = ({
@ -24,6 +25,7 @@ export const Navbar: React.FC<NavbarProps> = ({
onOpenCitySelector,
searchQuery,
onSearchChange,
onOpenCustomRequest,
}) => {
const { currentBrand } = useBrand();
const { user, openAuthModal } = useAuth();
@ -106,6 +108,19 @@ export const Navbar: React.FC<NavbarProps> = ({
{/* Right Actions */}
<div className="flex items-center space-x-2 shrink-0">
{/* Custom Sneaker Request Button */}
{onOpenCustomRequest && (
<button
type="button"
onClick={onOpenCustomRequest}
className="flex items-center space-x-1 text-xs font-bold text-fuchsia-300 hover:text-white bg-purple-950/70 hover:bg-purple-900/80 px-3 py-2 rounded-full border border-purple-500/40 hover:border-purple-400 transition-all cursor-pointer shadow-xs"
title="Solicita cualquier zapatilla que no esté en catálogo"
>
<Sparkles className="w-3.5 h-3.5 text-fuchsia-400" />
<span className="hidden sm:inline">Pide tu Par</span>
</button>
)}
{/* User Auth Button */}
<button
onClick={openAuthModal}

View File

@ -1,4 +1,4 @@
import { Product, City, PaymentMethod, Order, SupplierPost, StoreSettings, Supplier, MonitoredWhatsAppGroup, User, ProductColorVariant, MessageTemplate } from '@/types';
import { Product, City, PaymentMethod, Order, SupplierPost, StoreSettings, Supplier, MonitoredWhatsAppGroup, User, ProductColorVariant, MessageTemplate, CustomProductRequest } from '@/types';
import { normalizeModelKey, groupProductsByModel } from './model-grouper';
import { generateAttractiveProductCopy } from './attractive-copy';
import { getProductSocialProof } from './social-proof';
@ -925,7 +925,86 @@ class DataService {
const rawContent = tpl?.content || DEFAULT_MESSAGE_TEMPLATES.find((t) => t.id === templateId)?.defaultContent || '';
return interpolateVariables(rawContent, variables);
}
// =========================================================================
// ENCARGOS / PEDIDOS ESPECIALES DE CLIENTES (CUSTOM REQUESTS)
// =========================================================================
private customRequests: CustomProductRequest[] = [
{
id: 'req-1',
customerName: 'Santiago Mejía',
customerPhone: '573167455877',
customerCity: 'Cali',
brand: 'Jordan',
modelName: 'Air Jordan 4 Retro Travis Scott Cactus Jack',
size: '41',
colorway: 'University Blue / Black-Varsity Red',
targetBudget: 220000,
notes: 'Las busco en calidad 1.1 o G5 con caja y llavero si es posible.',
imageUrl: 'https://images.unsplash.com/photo-1552346154-21d32810aba3?auto=format&fit=crop&w=800&q=80',
status: 'searching',
quotedPrice: 195000,
createdAt: '2026-09-02T16:20:00Z',
},
{
id: 'req-2',
customerName: 'Valeria Osorio',
customerPhone: '573001234567',
customerCity: 'Medellín',
brand: 'Adidas',
modelName: 'Wales Bonner x Samba Pony Tonal',
size: '38',
colorway: 'Cream White / Mystery Brown',
targetBudget: 180000,
notes: 'Quiero la versión con lengüeta doblada.',
imageUrl: 'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80',
status: 'pending',
createdAt: '2026-09-03T11:15:00Z',
},
];
getCustomRequests(userId?: string): CustomProductRequest[] {
if (userId) {
return this.customRequests.filter((r) => r.userId === userId);
}
return [...this.customRequests];
}
getCustomRequestById(id: string): CustomProductRequest | undefined {
return this.customRequests.find((r) => r.id === id);
}
addCustomRequest(
data: Omit<CustomProductRequest, 'id' | 'createdAt' | 'status'> & Partial<CustomProductRequest>
): CustomProductRequest {
const newRequest: CustomProductRequest = {
...data,
id: data.id || `req-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
status: data.status || 'pending',
createdAt: data.createdAt || new Date().toISOString(),
};
this.customRequests.unshift(newRequest);
return newRequest;
}
updateCustomRequest(id: string, updates: Partial<CustomProductRequest>): CustomProductRequest | null {
const idx = this.customRequests.findIndex((r) => r.id === id);
if (idx === -1) return null;
this.customRequests[idx] = {
...this.customRequests[idx],
...updates,
updatedAt: new Date().toISOString(),
};
return this.customRequests[idx];
}
deleteCustomRequest(id: string): boolean {
const prev = this.customRequests.length;
this.customRequests = this.customRequests.filter((r) => r.id !== id);
return this.customRequests.length < prev;
}
}
export const dataService = new DataService();

View File

@ -26,8 +26,13 @@ export function getOptimizedImageUrl(
return 'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=600&q=75&fm=webp';
}
// If already base64 or blob or svg, return as is
if (trimmed.startsWith('data:') || trimmed.startsWith('blob:') || trimmed.endsWith('.svg')) {
// If already base64 or blob or svg or internal whatsapp media stream, return as is
if (
trimmed.startsWith('data:') ||
trimmed.startsWith('blob:') ||
trimmed.endsWith('.svg') ||
trimmed.includes('/api/whatsapp-media')
) {
return trimmed;
}

View File

@ -515,6 +515,14 @@ export async function getProductsFromAppwrite(): Promise<Product[]> {
try {
if (d.images) images = typeof d.images === 'string' ? JSON.parse(d.images) : d.images;
if (Array.isArray(images) && d.whatsappMessageId) {
images = images.map((img: string) => {
if (img && typeof img === 'string' && img.startsWith('data:image')) {
return `/api/whatsapp-media?id=${encodeURIComponent(d.whatsappMessageId)}`;
}
return img;
});
}
} catch (_) {}
try {

View File

@ -256,3 +256,25 @@ export interface MessageTemplate {
updatedAt?: string;
}
export interface CustomProductRequest {
id: string;
userId?: string;
customerName: string;
customerPhone: string;
customerEmail?: string;
customerCity?: string;
brand: string;
modelName: string;
size: string;
colorway?: string;
targetBudget?: number;
notes?: string;
imageUrl?: string;
status: 'pending' | 'searching' | 'quoted' | 'accepted' | 'rejected' | 'completed';
quotedPrice?: number;
adminNotes?: string;
createdAt: string;
updatedAt?: string;
}

View File

@ -27,7 +27,7 @@ def send_request(endpoint, payload):
"Content-Type": "application/json"
})
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as response:
with urllib.request.urlopen(req, context=ctx, timeout=180) as response:
return response.status, response.read().decode("utf-8")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8")
@ -37,18 +37,21 @@ def send_request(endpoint, payload):
def send_update():
message_text = (
"🚀 *DripZone Sneakers - APK Oficial Actualizado*\n\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"
"✨ *Novedades de esta Actualización:*\n"
"📸 *1. Imágenes en Alta Definición (Full HD - Cero Borrosidad):*\n"
"• Se solucionó la causa raíz de las imágenes borrosas (los thumbnails de 32x32 px de WhatsApp).\n"
"• Implementado endpoint `/api/whatsapp-media` que extrae en streaming la foto original Full HD (~500 KB) desde Evolution API con caché local ultra-rápida.\n"
"• Tanto el feed de WhatsApp como las publicaciones de la tienda cargan ahora con nitidez y detalle total.\n\n"
"☑️ *2. Selección de Página y Acciones Masivas en Productos (`/admin/productos`):*\n"
"• Botón 'Seleccionar Página Actual' para marcar o desmarcar todos los productos visibles de la página en 1 clic.\n"
"• Barra flotante de acciones en lote: Eliminar permanentemente seleccionados (borrado en bloque en BD y Appwrite), Activar/Ocultar y Reasignar categoría en lote.\n\n"
"👟 *3. Módulo de Encargos / Pedidos Especiales para Clientes:*\n"
"• Los usuarios (registrados y visitantes) ahora pueden solicitar cualquier par no listado en el catálogo subiendo foto de referencia, marca, modelo, talla y presupuesto estimado.\n"
"• Al enviar la solicitud, te llega una alerta automática con todos los datos a tu WhatsApp (+57 316 745 5877) y el cliente recibe confirmación inmediata.\n"
"• Nuevo panel en `/admin/encargos` para gestionar y cotizar pedidos especiales con enlace a chat directo.\n\n"
"🌐 *Gitea:* Todos los cambios confirmados y respaldados en `main`.\n\n"
f"📲 *Descarga directa del APK:* {APK_URL}\n\n"
"Instala o actualiza el archivo en tu dispositivo Android para disfrutar de estas nuevas funciones."
"¡Instala o actualiza el APK en tu Android para disfrutar de todas estas mejoras!"
)
print(f"📤 [1/2] Enviando mensaje de texto a WhatsApp (+{TARGET_PHONE})...")

View File

@ -213,3 +213,25 @@ export interface MonitoredWhatsAppGroup {
hasLeft?: boolean;
notes?: string;
}
export interface CustomProductRequest {
id: string;
userId?: string;
customerName: string;
customerPhone: string;
customerEmail?: string;
customerCity?: string;
brand: string;
modelName: string;
size: string;
colorway?: string;
targetBudget?: number;
notes?: string;
imageUrl?: string;
status: 'pending' | 'searching' | 'quoted' | 'accepted' | 'rejected' | 'completed';
quotedPrice?: number;
adminNotes?: string;
createdAt: string;
updatedAt?: string;
}