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,
|
ShieldAlert,
|
||||||
FileText,
|
FileText,
|
||||||
LogIn,
|
LogIn,
|
||||||
|
Sparkles,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { AuthModal } from '@/components/auth/AuthModal';
|
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/configuracion', label: 'Datos de la Tienda', icon: Store },
|
||||||
{ href: '/admin/feed-whatsapp', label: 'Feed WhatsApp & IA', icon: Radio, badge: 'Anti-Duplicados' },
|
{ 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/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/ciudades', label: 'Ciudades & Fletes', icon: MapPin },
|
||||||
{ href: '/admin/metodos-pago', label: 'Medios de Pago & Anticipos', icon: CreditCard },
|
{ href: '/admin/metodos-pago', label: 'Medios de Pago & Anticipos', icon: CreditCard },
|
||||||
{ href: '/admin/pedidos', label: 'Pedidos & Logística', icon: ShoppingBag },
|
{ href: '/admin/pedidos', label: 'Pedidos & Logística', icon: ShoppingBag },
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
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 { Product, Supplier } from '@/types';
|
||||||
import { INITIAL_PRODUCTS, INITIAL_SUPPLIERS } from '@/lib/data-service';
|
import { INITIAL_PRODUCTS, INITIAL_SUPPLIERS } from '@/lib/data-service';
|
||||||
import { formatCOP } from '@/lib/pricing-engine';
|
import { formatCOP } from '@/lib/pricing-engine';
|
||||||
@ -28,6 +28,9 @@ export default function AdminProductsPage() {
|
|||||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||||
const [managingVariantsProduct, setManagingVariantsProduct] = useState<Product | null>(null);
|
const [managingVariantsProduct, setManagingVariantsProduct] = useState<Product | null>(null);
|
||||||
const [toastMessage, setToastMessage] = useState<string | 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
|
// Pagination State
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
@ -365,6 +368,122 @@ export default function AdminProductsPage() {
|
|||||||
return filtered.slice(startIndex, startIndex + pageSize);
|
return filtered.slice(startIndex, startIndex + pageSize);
|
||||||
}, [filtered, 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -453,36 +572,152 @@ export default function AdminProductsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Bulk Grouping Toolbar */}
|
{/* Barra de Selección de Página y Acciones Rápidas */}
|
||||||
{selectedProductIds.length >= 2 && (
|
<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="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.5 flex-wrap">
|
||||||
<div className="flex items-center gap-2">
|
{/* Botón Seleccionar Página Actual */}
|
||||||
<div className="w-8 h-8 rounded-xl bg-purple-600 text-white flex items-center justify-center font-black text-xs">
|
<button
|
||||||
{selectedProductIds.length}
|
type="button"
|
||||||
</div>
|
onClick={handleToggleSelectPage}
|
||||||
<div>
|
disabled={paginatedProducts.length === 0}
|
||||||
<p className="text-white font-black text-xs leading-tight">
|
className={`flex items-center gap-2 text-xs font-bold px-3.5 py-2 rounded-xl border transition-all cursor-pointer ${
|
||||||
{selectedProductIds.length} Zapatillas Seleccionadas
|
isAllPageSelected
|
||||||
</p>
|
? 'bg-purple-600 text-white border-purple-400 shadow-md shadow-purple-900/30 scale-102'
|
||||||
<p className="text-[10px] text-neutral-400">
|
: somePageSelected
|
||||||
Puedes fusionarlas en 1 solo modelo con múltiples colorways.
|
? 'bg-purple-950/80 text-purple-200 border-purple-500/50'
|
||||||
</p>
|
: 'bg-neutral-950 text-neutral-300 border-neutral-800 hover:border-neutral-700 hover:text-white'
|
||||||
</div>
|
}`}
|
||||||
</div>
|
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
|
<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([])}
|
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
|
Deseleccionar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsGroupModalOpen(true)}
|
type="button"
|
||||||
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"
|
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" />
|
<Trash2 className="w-3.5 h-3.5 text-rose-400" />
|
||||||
<span>Agrupar como Variantes de 1 Modelo</span>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -497,16 +732,13 @@ export default function AdminProductsPage() {
|
|||||||
<th className="py-3.5 px-3 text-center w-10">
|
<th className="py-3.5 px-3 text-center w-10">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={filtered.length > 0 && selectedProductIds.length === filtered.length}
|
checked={isAllPageSelected}
|
||||||
onChange={(e) => {
|
ref={(el) => {
|
||||||
if (e.target.checked) {
|
if (el) el.indeterminate = somePageSelected && !isAllPageSelected;
|
||||||
setSelectedProductIds(filtered.map((p) => p.id));
|
|
||||||
} else {
|
|
||||||
setSelectedProductIds([]);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
onChange={handleToggleSelectPage}
|
||||||
className="w-4 h-4 rounded-md accent-purple-600 cursor-pointer"
|
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>
|
||||||
<th className="py-3.5 px-4">Estado</th>
|
<th className="py-3.5 px-4">Estado</th>
|
||||||
@ -1153,6 +1385,75 @@ export default function AdminProductsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</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 { NextResponse } from 'next/server';
|
||||||
import { dataService } from '@/lib/data-service';
|
import { dataService } from '@/lib/data-service';
|
||||||
import { getProductSocialProof } from '@/lib/social-proof';
|
import { getProductSocialProof } from '@/lib/social-proof';
|
||||||
|
import { deleteProductFromAppwrite, saveProductToAppwrite } from '@/lib/server-appwrite';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
@ -18,6 +19,69 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const body = await request.json();
|
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
|
// ACCIÓN MANUAL: Desagrupar / Separar una variante específica como producto independiente
|
||||||
if (body.action === 'detach-variant') {
|
if (body.action === 'detach-variant') {
|
||||||
const { productId, variantId, customName, customPrice } = body;
|
const { productId, variantId, customName, customPrice } = body;
|
||||||
@ -107,9 +171,9 @@ export async function POST(request: Request) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const mergeId of productIdsToMerge || []) {
|
for (const toMergeId of productIdsToMerge || []) {
|
||||||
if (mergeId === targetProductId) continue;
|
if (toMergeId === targetProductId) continue;
|
||||||
const toMerge = allProducts.find((p) => p.id === mergeId);
|
const toMerge = allProducts.find((p) => p.id === toMergeId);
|
||||||
if (!toMerge) continue;
|
if (!toMerge) continue;
|
||||||
|
|
||||||
const mergeColors =
|
const mergeColors =
|
||||||
@ -141,6 +205,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
// Eliminar el producto que fue fusionado
|
// Eliminar el producto que fue fusionado
|
||||||
dataService.deleteProduct(toMerge.id);
|
dataService.deleteProduct(toMerge.id);
|
||||||
|
deleteProductFromAppwrite(toMerge.id).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
target.colors = colors;
|
target.colors = colors;
|
||||||
@ -153,6 +218,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dataService.updateProduct(target.id, target);
|
dataService.updateProduct(target.id, target);
|
||||||
|
saveProductToAppwrite(target).catch(() => {});
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
product: target,
|
product: target,
|
||||||
@ -170,6 +236,7 @@ export async function POST(request: Request) {
|
|||||||
originBadge: body.originBadge || proof.badge,
|
originBadge: body.originBadge || proof.badge,
|
||||||
isActive: body.isActive !== undefined ? body.isActive : true,
|
isActive: body.isActive !== undefined ? body.isActive : true,
|
||||||
});
|
});
|
||||||
|
saveProductToAppwrite(newProduct).catch(() => {});
|
||||||
return NextResponse.json({ success: true, product: newProduct });
|
return NextResponse.json({ success: true, product: newProduct });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
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 });
|
return NextResponse.json({ success: false, error: 'Producto no encontrado' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveProductToAppwrite(updated).catch(() => {});
|
||||||
return NextResponse.json({ success: true, product: updated });
|
return NextResponse.json({ success: true, product: updated });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
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) {
|
export async function DELETE(request: Request) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const id = searchParams.get('id');
|
const singleId = searchParams.get('id');
|
||||||
if (!id) {
|
const rawIds = searchParams.get('ids');
|
||||||
return NextResponse.json({ success: false, error: 'ID de producto requerido' }, { status: 400 });
|
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);
|
let deletedCount = 0;
|
||||||
if (!deleted) {
|
for (const id of idsToDelete) {
|
||||||
return NextResponse.json({ success: false, error: 'Producto no encontrado' }, { status: 404 });
|
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) {
|
} catch (error: any) {
|
||||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
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) {
|
if (!fetchLive || targetJids.length === 0) {
|
||||||
const filteredSaved = savedPosts.filter((p) => {
|
const filteredSaved = savedPosts
|
||||||
// Si el mensaje es de un grupo del que salimos y estamos en pendientes, no mostrarlo
|
.filter((p) => {
|
||||||
if (p.channelJid && leftJids.has(p.channelJid) && statusFilter === 'pending') return false;
|
// 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 === 'pending') return p.status === 'pending_review';
|
||||||
if (statusFilter === 'published') return p.status === 'published' || p.status === 'published_store';
|
if (statusFilter === 'published') return p.status === 'published' || p.status === 'published_store';
|
||||||
if (statusFilter === 'discarded') return p.status === 'discarded';
|
if (statusFilter === 'discarded') return p.status === 'discarded';
|
||||||
return true;
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
@ -261,7 +272,12 @@ export async function GET(request: Request) {
|
|||||||
const imgMsg = record.message?.imageMessage;
|
const imgMsg = record.message?.imageMessage;
|
||||||
|
|
||||||
if (imgMsg) {
|
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}`);
|
imageUrls.push(`data:image/jpeg;base64,${imgMsg.jpegThumbnail}`);
|
||||||
} else if (imgMsg.url) {
|
} else if (imgMsg.url) {
|
||||||
imageUrls.push(imgMsg.url);
|
imageUrls.push(imgMsg.url);
|
||||||
@ -391,7 +407,14 @@ export async function POST(request: Request) {
|
|||||||
marketPriceSourceUrl: post.extractedProduct?.marketPriceSourceUrl || marketIntelligence?.marketPriceSourceUrl,
|
marketPriceSourceUrl: post.extractedProduct?.marketPriceSourceUrl || marketIntelligence?.marketPriceSourceUrl,
|
||||||
marketComparisonSummary: post.extractedProduct?.marketComparisonSummary || marketIntelligence?.marketComparisonSummary,
|
marketComparisonSummary: post.extractedProduct?.marketComparisonSummary || marketIntelligence?.marketComparisonSummary,
|
||||||
sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'],
|
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,
|
description: prodDescription,
|
||||||
category: prodCategory,
|
category: prodCategory,
|
||||||
inStock: true,
|
inStock: true,
|
||||||
@ -455,7 +478,14 @@ export async function POST(request: Request) {
|
|||||||
marketPriceSourceUrl: p.extractedProduct?.marketPriceSourceUrl,
|
marketPriceSourceUrl: p.extractedProduct?.marketPriceSourceUrl,
|
||||||
marketComparisonSummary: p.extractedProduct?.marketComparisonSummary,
|
marketComparisonSummary: p.extractedProduct?.marketComparisonSummary,
|
||||||
sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'],
|
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,
|
description: prodDescription,
|
||||||
category: prodCategory,
|
category: prodCategory,
|
||||||
inStock: true,
|
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 { Product, City, CartItem } from '@/types';
|
||||||
import { INITIAL_CITIES } from '@/lib/data-service';
|
import { INITIAL_CITIES } from '@/lib/data-service';
|
||||||
import { groupProductsByModel } from '@/lib/product-grouper';
|
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 { useBrand } from '@/context/BrandContext';
|
||||||
import { renderClientTemplate } from '@/lib/templates-client';
|
import { renderClientTemplate } from '@/lib/templates-client';
|
||||||
|
import { CustomRequestModal } from '@/components/custom-requests/CustomRequestModal';
|
||||||
|
|
||||||
export default function StoreHomePage() {
|
export default function StoreHomePage() {
|
||||||
const { currentBrand } = useBrand();
|
const { currentBrand } = useBrand();
|
||||||
@ -35,6 +36,7 @@ export default function StoreHomePage() {
|
|||||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||||
const [isCartOpen, setIsCartOpen] = useState(false);
|
const [isCartOpen, setIsCartOpen] = useState(false);
|
||||||
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
||||||
|
const [isCustomRequestOpen, setIsCustomRequestOpen] = useState(false);
|
||||||
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
||||||
|
|
||||||
const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
||||||
@ -240,6 +242,7 @@ export default function StoreHomePage() {
|
|||||||
onOpenCitySelector={() => setIsCityModalOpen(true)}
|
onOpenCitySelector={() => setIsCityModalOpen(true)}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
|
onOpenCustomRequest={() => setIsCustomRequestOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Container */}
|
{/* Main Container */}
|
||||||
@ -492,6 +495,29 @@ export default function StoreHomePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
@ -540,6 +566,11 @@ export default function StoreHomePage() {
|
|||||||
onSelectCity={setSelectedCity}
|
onSelectCity={setSelectedCity}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CustomRequestModal
|
||||||
|
isOpen={isCustomRequestOpen}
|
||||||
|
onClose={() => setIsCustomRequestOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Mobile App Dock */}
|
{/* Mobile App Dock */}
|
||||||
<MobileBottomDock
|
<MobileBottomDock
|
||||||
cartCount={cartCount}
|
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;
|
onOpenCitySelector: () => void;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
onSearchChange: (q: string) => void;
|
onSearchChange: (q: string) => void;
|
||||||
|
onOpenCustomRequest?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Navbar: React.FC<NavbarProps> = ({
|
export const Navbar: React.FC<NavbarProps> = ({
|
||||||
@ -24,6 +25,7 @@ export const Navbar: React.FC<NavbarProps> = ({
|
|||||||
onOpenCitySelector,
|
onOpenCitySelector,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
|
onOpenCustomRequest,
|
||||||
}) => {
|
}) => {
|
||||||
const { currentBrand } = useBrand();
|
const { currentBrand } = useBrand();
|
||||||
const { user, openAuthModal } = useAuth();
|
const { user, openAuthModal } = useAuth();
|
||||||
@ -106,6 +108,19 @@ export const Navbar: React.FC<NavbarProps> = ({
|
|||||||
|
|
||||||
{/* Right Actions */}
|
{/* Right Actions */}
|
||||||
<div className="flex items-center space-x-2 shrink-0">
|
<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 */}
|
{/* User Auth Button */}
|
||||||
<button
|
<button
|
||||||
onClick={openAuthModal}
|
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 { normalizeModelKey, groupProductsByModel } from './model-grouper';
|
||||||
import { generateAttractiveProductCopy } from './attractive-copy';
|
import { generateAttractiveProductCopy } from './attractive-copy';
|
||||||
import { getProductSocialProof } from './social-proof';
|
import { getProductSocialProof } from './social-proof';
|
||||||
@ -925,7 +925,86 @@ class DataService {
|
|||||||
const rawContent = tpl?.content || DEFAULT_MESSAGE_TEMPLATES.find((t) => t.id === templateId)?.defaultContent || '';
|
const rawContent = tpl?.content || DEFAULT_MESSAGE_TEMPLATES.find((t) => t.id === templateId)?.defaultContent || '';
|
||||||
return interpolateVariables(rawContent, variables);
|
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();
|
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';
|
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 already base64 or blob or svg or internal whatsapp media stream, return as is
|
||||||
if (trimmed.startsWith('data:') || trimmed.startsWith('blob:') || trimmed.endsWith('.svg')) {
|
if (
|
||||||
|
trimmed.startsWith('data:') ||
|
||||||
|
trimmed.startsWith('blob:') ||
|
||||||
|
trimmed.endsWith('.svg') ||
|
||||||
|
trimmed.includes('/api/whatsapp-media')
|
||||||
|
) {
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -515,6 +515,14 @@ export async function getProductsFromAppwrite(): Promise<Product[]> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (d.images) images = typeof d.images === 'string' ? JSON.parse(d.images) : d.images;
|
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 (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -256,3 +256,25 @@ export interface MessageTemplate {
|
|||||||
updatedAt?: string;
|
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"
|
"Content-Type": "application/json"
|
||||||
})
|
})
|
||||||
try:
|
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")
|
return response.status, response.read().decode("utf-8")
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
return e.code, e.read().decode("utf-8")
|
return e.code, e.read().decode("utf-8")
|
||||||
@ -37,18 +37,21 @@ def send_request(endpoint, payload):
|
|||||||
def send_update():
|
def send_update():
|
||||||
message_text = (
|
message_text = (
|
||||||
"🚀 *DripZone Sneakers - APK Oficial Actualizado*\n\n"
|
"🚀 *DripZone Sneakers - APK Oficial Actualizado*\n\n"
|
||||||
"✨ *Identificación y Categorización Inteligente Multi-Producto:*\n"
|
"✨ *Novedades de esta Actualización:*\n"
|
||||||
"👟 *Zapatillas & Sneakers:* Detección de siluetas, modelos urbanos y deportivos.\n"
|
"📸 *1. Imágenes en Alta Definición (Full HD - Cero Borrosidad):*\n"
|
||||||
"🩴 *Chanclas & Sandalias:* Clasificación precisa de chanclas, slides, crocs y pantuflas con textos de confort.\n"
|
"• Se solucionó la causa raíz de las imágenes borrosas (los thumbnails de 32x32 px de WhatsApp).\n"
|
||||||
"👜 *Bolsos & Carteras:* Reconocimiento de bolsos de mano, tote bags y carteras con copys persuasivos de lujo.\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"
|
||||||
"🎒 *Maletines & Morrales:* Detección de maletines para portátil, mochilas y tulas deportivas.\n"
|
"• Tanto el feed de WhatsApp como las publicaciones de la tienda cargan ahora con nitidez y detalle total.\n\n"
|
||||||
"👝 *Carrieles & Bandoleras:* Identificación de carrieles tradicionales, bandoleras, riñoneras, pecheras y crossbody.\n"
|
"☑️ *2. Selección de Página y Acciones Masivas en Productos (`/admin/productos`):*\n"
|
||||||
"🧢 *Gorras & Accesorios:* Categorización de gorras, caps, sombreros, billeteras y correas.\n"
|
"• Botón 'Seleccionar Página Actual' para marcar o desmarcar todos los productos visibles de la página en 1 clic.\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"
|
"• 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"
|
||||||
"🛍️ *Catálogo de Clientes Adaptado:* Los clientes ahora pueden filtrar por categoría directamente en la tienda.\n"
|
"👟 *3. Módulo de Encargos / Pedidos Especiales para Clientes:*\n"
|
||||||
"🌐 *VPS Desplegado:* Contenedor en producción activo en `https://dripzone.almeira.com.co`.\n\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"
|
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})...")
|
print(f"📤 [1/2] Enviando mensaje de texto a WhatsApp (+{TARGET_PHONE})...")
|
||||||
|
|||||||
@ -213,3 +213,25 @@ export interface MonitoredWhatsAppGroup {
|
|||||||
hasLeft?: boolean;
|
hasLeft?: boolean;
|
||||||
notes?: string;
|
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