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:
parent
c7abbd2d54
commit
d7e7ab4d8b
434
dripzone-app/src/app/admin/encargos/page.tsx
Normal file
434
dripzone-app/src/app/admin/encargos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
210
dripzone-app/src/app/api/custom-requests/route.ts
Normal file
210
dripzone-app/src/app/api/custom-requests/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
156
dripzone-app/src/app/api/whatsapp-media/route.ts
Normal file
156
dripzone-app/src/app/api/whatsapp-media/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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})...")
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user