Feat Varios 3
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
|
||||
import { usePublicAuthStore } from './store/publicAuthStore';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import api from './services/api';
|
||||
|
||||
// Páginas e Interfaz
|
||||
import HomePage from './pages/HomePage';
|
||||
@@ -43,12 +44,46 @@ const SocialIcons = {
|
||||
function App() {
|
||||
const { user, logout } = usePublicAuthStore();
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
setIsUserMenuOpen(false);
|
||||
};
|
||||
|
||||
// Cargar conteo de mensajes no leídos
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
const loadUnread = async () => {
|
||||
try {
|
||||
const res = await api.get('/usernotes/unread-count');
|
||||
setUnreadCount(res.data);
|
||||
} catch (e) {
|
||||
console.error("Error cargando notificaciones", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnreadChange = (e: any) => {
|
||||
if (e.detail !== undefined) {
|
||||
setUnreadCount(e.detail);
|
||||
} else {
|
||||
loadUnread();
|
||||
}
|
||||
};
|
||||
|
||||
loadUnread();
|
||||
window.addEventListener('unread-count-changed', handleUnreadChange);
|
||||
|
||||
// Polling cada 60 segundos por si acaso
|
||||
const interval = setInterval(loadUnread, 60000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('unread-count-changed', handleUnreadChange);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="flex flex-col min-h-screen font-sans bg-[#f8fafc]">
|
||||
@@ -68,57 +103,86 @@ function App() {
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{user ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||
className="flex items-center gap-3 bg-slate-50 pl-4 pr-3 py-2 rounded-2xl hover:bg-slate-100 transition-all border border-slate-100 group"
|
||||
<div className="flex items-center gap-3">
|
||||
{/* CAMPANA DE NOTIFICACIONES */}
|
||||
<Link
|
||||
to="/profile?tab=mensajes"
|
||||
className="relative p-3 bg-slate-50 rounded-2xl border border-slate-100 text-slate-400 hover:text-blue-600 hover:bg-white transition-all group"
|
||||
title="Ver mensajes"
|
||||
>
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-blue-200">
|
||||
<UserIcon size={16} />
|
||||
</div>
|
||||
<span className="hidden sm:block text-xs font-black text-slate-900 uppercase tracking-widest">
|
||||
{user.username}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`text-slate-400 transition-transform duration-300 ${isUserMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isUserMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setIsUserMenuOpen(false)}></div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="absolute right-0 mt-3 w-64 bg-white rounded-[2rem] shadow-2xl border border-slate-100 py-4 z-20 overflow-hidden"
|
||||
>
|
||||
<div className="px-6 py-3 border-b border-slate-50 mb-2">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Cuenta vinculada</p>
|
||||
<p className="text-sm font-bold text-slate-900 truncate">{user.username}</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/profile"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center gap-3 px-6 py-4 hover:bg-blue-50 text-slate-600 hover:text-blue-600 transition-colors group"
|
||||
>
|
||||
<div className="p-2 bg-slate-50 group-hover:bg-blue-100 rounded-xl transition-colors">
|
||||
<UserIcon size={18} className="group-hover:text-blue-600" />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase tracking-widest">Mi Perfil</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-6 py-4 mt-2 hover:bg-rose-50 text-rose-500 border-t border-slate-50 transition-colors group"
|
||||
>
|
||||
<div className="p-2 bg-slate-50 group-hover:bg-rose-100 rounded-xl transition-colors">
|
||||
<LogOut size={18} />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase tracking-widest">Cerrar Sesión</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
<Mail size={20} className="group-hover:scale-110 transition-transform" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-2 right-2 flex h-5 w-5 items-center justify-center rounded-full bg-rose-500 text-[10px] font-black text-white ring-4 ring-white shadow-lg shadow-rose-200 animate-bounce">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Link>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||
className="flex items-center gap-3 bg-slate-50 pl-4 pr-3 py-2 rounded-2xl hover:bg-slate-100 transition-all border border-slate-100 group"
|
||||
>
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-blue-200">
|
||||
<UserIcon size={16} />
|
||||
</div>
|
||||
<span className="hidden sm:block text-xs font-black text-slate-900 uppercase tracking-widest">
|
||||
{user.username}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`text-slate-400 transition-transform duration-300 ${isUserMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isUserMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setIsUserMenuOpen(false)}></div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="absolute right-0 mt-3 w-64 bg-white rounded-[2rem] shadow-2xl border border-slate-100 py-4 z-20 overflow-hidden"
|
||||
>
|
||||
<div className="px-6 py-3 border-b border-slate-50 mb-2">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Cuenta vinculada</p>
|
||||
<p className="text-sm font-bold text-slate-900 truncate">{user.username}</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/profile"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center gap-3 px-6 py-4 hover:bg-blue-50 text-slate-600 hover:text-blue-600 transition-colors group"
|
||||
>
|
||||
<div className="p-2 bg-slate-50 group-hover:bg-blue-100 rounded-xl transition-colors">
|
||||
<UserIcon size={18} className="group-hover:text-blue-600" />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase tracking-widest">Mi Perfil</span>
|
||||
</Link>
|
||||
|
||||
{/* Acceso a mensajes desde el menú movil o dropdown */}
|
||||
<Link
|
||||
to="/profile?tab=mensajes"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex md:hidden items-center gap-3 px-6 py-4 hover:bg-blue-50 text-slate-600 hover:text-blue-600 transition-colors group"
|
||||
>
|
||||
<div className="p-2 bg-slate-50 group-hover:bg-blue-100 rounded-xl transition-colors">
|
||||
<Mail size={18} className="group-hover:text-blue-600" />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase tracking-widest">Mensajes</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-6 py-4 mt-2 hover:bg-rose-50 text-rose-500 border-t border-slate-50 transition-colors group"
|
||||
>
|
||||
<div className="p-2 bg-slate-50 group-hover:bg-rose-100 rounded-xl transition-colors">
|
||||
<LogOut size={18} />
|
||||
</div>
|
||||
<span className="text-xs font-black uppercase tracking-widest">Cerrar Sesión</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Link to="/login" className="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-slate-900 hover:text-blue-600 transition-all px-4">
|
||||
|
||||
@@ -46,7 +46,8 @@ export default function HomePage() {
|
||||
const response = await api.post('/listings/search', {
|
||||
query: searchText,
|
||||
categoryId: selectedCatId,
|
||||
attributes: dynamicFilters
|
||||
attributes: dynamicFilters,
|
||||
onlyActive: true
|
||||
});
|
||||
setListings(response.data);
|
||||
} catch (e) {
|
||||
|
||||
@@ -121,6 +121,15 @@ export default function ListingDetailPage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Featured Badge */}
|
||||
{listing.isFeatured && (
|
||||
<div className="mb-6 flex">
|
||||
<span className="bg-amber-400 text-white px-3 py-1.5 rounded-xl text-[10px] font-black uppercase tracking-wider shadow-md shadow-amber-200">
|
||||
★ Destacado
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="text-3xl font-black text-slate-900 mb-6 uppercase tracking-tighter leading-tight">
|
||||
{listing.title}
|
||||
</h1>
|
||||
@@ -148,13 +157,13 @@ export default function ListingDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button className="w-full py-4 bg-[#00D15D] hover:bg-[#00B851] text-white rounded-2xl font-black uppercase text-[11px] tracking-widest flex items-center justify-center gap-3 shadow-xl shadow-emerald-500/20 transition-all active:scale-95">
|
||||
<MessageCircle size={18} />
|
||||
Contactar Vendedor
|
||||
</button>
|
||||
<button className="w-full py-4 bg-slate-900 hover:bg-black text-white rounded-2xl font-black uppercase text-[11px] tracking-widest transition-all active:scale-95 shadow-xl shadow-slate-200">
|
||||
Realizar Oferta
|
||||
</button>
|
||||
{listing.allowContact !== false && (
|
||||
<button className="w-full py-4 bg-[#00D15D] hover:bg-[#00B851] text-white rounded-2xl font-black uppercase text-[11px] tracking-widest flex items-center justify-center gap-3 shadow-xl shadow-emerald-500/20 transition-all active:scale-95">
|
||||
<MessageCircle size={18} />
|
||||
Contactar Vendedor
|
||||
</button>
|
||||
)}
|
||||
{/* Botón Ofrecer removido por solicitud */}
|
||||
</div>
|
||||
|
||||
{/* Stats integrados */}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { publicAuthService } from '../services/authService';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { Listing } from '../types';
|
||||
import {
|
||||
User, Package, Settings, ChevronRight,
|
||||
Clock, Eye, ShieldCheck, QrCode, Lock,
|
||||
Bell, RefreshCcw
|
||||
Bell, RefreshCcw, Send, MessageSquare, X
|
||||
} from 'lucide-react';
|
||||
import { usePublicAuthStore } from '../store/publicAuthStore';
|
||||
import api from '../services/api';
|
||||
@@ -19,7 +19,7 @@ interface MfaData {
|
||||
secret: string;
|
||||
}
|
||||
|
||||
type TabType = 'listings' | 'security' | 'settings';
|
||||
type TabType = 'listings' | 'security' | 'settings' | 'mensajes';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, logout } = usePublicAuthStore();
|
||||
@@ -31,8 +31,88 @@ export default function ProfilePage() {
|
||||
const baseUrl = import.meta.env.VITE_BASE_URL;
|
||||
const setRepublishData = useWizardStore(state => state.setRepublishData);
|
||||
const [republishTarget, setRepublishTarget] = useState<Listing | null>(null);
|
||||
const [republishDetail, setRepublishDetail] = useState<any | null>(null);
|
||||
const [loadingRepublish, setLoadingRepublish] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [billingData, setBillingData] = useState({
|
||||
billingName: '',
|
||||
billingAddress: '',
|
||||
billingTaxId: '',
|
||||
billingTaxType: '',
|
||||
email: ''
|
||||
});
|
||||
|
||||
const [notes, setNotes] = useState<any[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [selectedThreadListingId, setSelectedThreadListingId] = useState<number | null>(null);
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get('tab');
|
||||
if (tab === 'mensajes') setActiveTab('mensajes');
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'settings') {
|
||||
api.get('/profile').then(res => {
|
||||
setBillingData({
|
||||
billingName: res.data.billingName || '',
|
||||
billingAddress: res.data.billingAddress || '',
|
||||
billingTaxId: res.data.billingTaxId || '',
|
||||
billingTaxType: res.data.billingTaxType || '',
|
||||
email: res.data.email || ''
|
||||
});
|
||||
}).catch(console.error);
|
||||
}
|
||||
if (activeTab === 'mensajes') {
|
||||
loadMessages();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const loadMessages = async () => {
|
||||
try {
|
||||
const res = await api.get('/usernotes/my-notes');
|
||||
setNotes(res.data);
|
||||
// Actualizar conteo de no leídos
|
||||
const unreadRes = await api.get('/usernotes/unread-count');
|
||||
setUnreadCount(unreadRes.data);
|
||||
|
||||
// Notificar al header (App.tsx)
|
||||
window.dispatchEvent(new CustomEvent('unread-count-changed', { detail: unreadRes.data }));
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleReply = async (listingId: number) => {
|
||||
if (!replyText.trim()) return;
|
||||
try {
|
||||
await api.post(`/usernotes/listing/${listingId}/reply`, { message: replyText });
|
||||
setReplyText('');
|
||||
loadMessages();
|
||||
} catch {
|
||||
alert("Error al enviar respuesta");
|
||||
}
|
||||
};
|
||||
|
||||
const markThreadAsRead = async (listingId: number) => {
|
||||
// El backend lo hace automáticamente cuando pides el historial por aviso (en una implementación real ideal)
|
||||
// Pero aquí lo hacemos manual o confiamos en el endpoint GetByListing que marcaba como leídos.
|
||||
try {
|
||||
await api.get(`/usernotes/listing/${listingId}`);
|
||||
loadMessages();
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
try {
|
||||
await api.put('/profile', billingData);
|
||||
alert('Ajustes guardados correctamente.');
|
||||
} catch (error) {
|
||||
alert('Error al guardar ajustes.');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
@@ -70,6 +150,19 @@ export default function ProfilePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Carga de detalle para el modal de republicación
|
||||
useEffect(() => {
|
||||
if (republishTarget) {
|
||||
setLoadingRepublish(true);
|
||||
api.get(`/listings/${republishTarget.id}`)
|
||||
.then(res => setRepublishDetail(res.data))
|
||||
.catch(() => alert("Error al cargar detalles del aviso"))
|
||||
.finally(() => setLoadingRepublish(false));
|
||||
} else {
|
||||
setRepublishDetail(null);
|
||||
}
|
||||
}, [republishTarget]);
|
||||
|
||||
const handleSetupMfa = async () => {
|
||||
try {
|
||||
const data = await publicAuthService.setupMfa();
|
||||
@@ -190,6 +283,13 @@ export default function ProfilePage() {
|
||||
active={activeTab === 'security'}
|
||||
onClick={() => setActiveTab('security')}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={<Bell size={18} />}
|
||||
label="Mensajes"
|
||||
active={activeTab === 'mensajes'}
|
||||
onClick={() => setActiveTab('mensajes')}
|
||||
badge={unreadCount}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={<Settings size={18} />}
|
||||
label="Ajustes"
|
||||
@@ -250,20 +350,67 @@ export default function ProfilePage() {
|
||||
<div className="text-right">
|
||||
<p className="font-black text-slate-900 text-lg">${item.price.toLocaleString()}</p>
|
||||
|
||||
{/* BADGE DINÁMICO DE ESTADO */}
|
||||
<span className={clsx(
|
||||
"text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1",
|
||||
// Estilos para Publicado
|
||||
item.status === 'Published' && "text-emerald-500 bg-emerald-50 border-emerald-100",
|
||||
// Estilos para Pendiente de Moderación (Naranja)
|
||||
item.status === 'Pending' && "text-amber-500 bg-amber-50 border-amber-100",
|
||||
// Estilos para Borrador o Error (Gris)
|
||||
(item.status === 'Draft' || !item.status) && "text-slate-400 bg-slate-50 border-slate-200"
|
||||
)}>
|
||||
{item.status === 'Published' ? 'Publicado' :
|
||||
item.status === 'Pending' ? 'En Revisión' :
|
||||
'Borrador'}
|
||||
</span>
|
||||
{(() => {
|
||||
const now = new Date();
|
||||
const startDate = item.publicationStartDate ? new Date(item.publicationStartDate) : (item.approvedAt ? new Date(item.approvedAt) : null);
|
||||
const days = item.printDaysCount || 0;
|
||||
|
||||
// Calcular fecha fin: Inicio + días
|
||||
let endDate = null;
|
||||
if (startDate && days > 0) {
|
||||
endDate = new Date(startDate);
|
||||
endDate.setDate(endDate.getDate() + days);
|
||||
}
|
||||
|
||||
if (item.status === 'Pending') {
|
||||
return (
|
||||
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-amber-500 bg-amber-50 border-amber-100">
|
||||
En Revisión
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.status === 'Rejected') {
|
||||
return (
|
||||
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-rose-500 bg-rose-50 border-rose-100">
|
||||
Rechazado
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.status === 'Published') {
|
||||
// Si ya terminó el plazo
|
||||
if (endDate && now > endDate) {
|
||||
return (
|
||||
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-slate-400 bg-slate-50 border-slate-200">
|
||||
Finalizado
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Si está aprobado pero falta para que empiece
|
||||
if (startDate && now < startDate) {
|
||||
return (
|
||||
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-blue-500 bg-blue-50 border-blue-100">
|
||||
Aprobado
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Por defecto: Publicado (Activo)
|
||||
return (
|
||||
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-emerald-500 bg-emerald-50 border-emerald-100">
|
||||
Publicado
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1 text-slate-400 bg-slate-50 border-slate-200">
|
||||
Borrador
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,61 +527,361 @@ export default function ProfilePage() {
|
||||
{activeTab === 'settings' && (
|
||||
<motion.div key="settings" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
|
||||
<h2 className="text-2xl font-black text-slate-900 mb-8 uppercase tracking-tighter">Ajustes de cuenta</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InputGroup label="Nombre de usuario" value={user?.username || ''} disabled />
|
||||
<InputGroup label="Email de contacto" value={user?.email || 'admin@sigcm.com'} />
|
||||
<InputGroup label="Teléfono / WhatsApp" placeholder="+54..." />
|
||||
<InputGroup label="Ubicación" value="La Plata, Buenos Aires" />
|
||||
|
||||
<div className="bg-blue-50/50 p-6 rounded-3xl mb-8 border border-blue-100">
|
||||
<h3 className="text-sm font-black text-blue-900 uppercase mb-4">Datos de Contacto</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InputGroup label="Nombre de usuario" value={user?.username || ''} disabled />
|
||||
<InputGroup
|
||||
label="Email de contacto"
|
||||
value={billingData.email}
|
||||
onChange={(e: any) => setBillingData({ ...billingData, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 p-6 rounded-3xl mb-8 border border-slate-100">
|
||||
<h3 className="text-sm font-black text-slate-900 uppercase mb-4 flex gap-2 items-center">
|
||||
<Settings size={16} className="text-slate-400" /> Datos de Facturación
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InputGroup
|
||||
label="Razón Social / Nombre"
|
||||
placeholder="Ej: Juan Perez"
|
||||
value={billingData.billingName}
|
||||
onChange={(e: any) => setBillingData({ ...billingData, billingName: e.target.value })}
|
||||
/>
|
||||
<InputGroup
|
||||
label="CUIT / DNI"
|
||||
placeholder="20-12345678-9"
|
||||
value={billingData.billingTaxId}
|
||||
onChange={(e: any) => setBillingData({ ...billingData, billingTaxId: e.target.value })}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest pl-1">Condición Fiscal</label>
|
||||
<select
|
||||
value={billingData.billingTaxType}
|
||||
onChange={(e) => setBillingData({ ...billingData, billingTaxType: e.target.value })}
|
||||
className="w-full bg-white border border-slate-200 rounded-2xl px-5 py-4 text-sm font-bold text-slate-700 focus:ring-4 focus:ring-blue-100 transition-all outline-none appearance-none"
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
<option value="Consumidor Final">Consumidor Final</option>
|
||||
<option value="Responsable Inscripto">Responsable Inscripto</option>
|
||||
<option value="Monotributista">Monotributista</option>
|
||||
<option value="Exento">Exento</option>
|
||||
</select>
|
||||
</div>
|
||||
<InputGroup
|
||||
label="Domicilio Fiscal"
|
||||
placeholder="Calle 123, Ciudad"
|
||||
value={billingData.billingAddress}
|
||||
onChange={(e: any) => setBillingData({ ...billingData, billingAddress: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 pt-10 border-t border-slate-50 flex justify-end">
|
||||
<button className="bg-blue-600 text-white px-10 py-4 rounded-2xl font-black uppercase text-xs tracking-widest shadow-xl shadow-blue-200 hover:bg-blue-700 transition-all">
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
className="bg-blue-600 text-white px-10 py-4 rounded-2xl font-black uppercase text-xs tracking-widest shadow-xl shadow-blue-200 hover:bg-blue-700 transition-all"
|
||||
>
|
||||
Guardar cambios
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === 'mensajes' && (
|
||||
<motion.div key="mensajes" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h2 className="text-2xl font-black text-slate-900 uppercase tracking-tighter leading-none">Mensajes de Moderación</h2>
|
||||
{unreadCount > 0 && (
|
||||
<span className="bg-rose-500 text-white px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest animate-pulse">
|
||||
{unreadCount} Nuevos
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 opacity-30">
|
||||
<MessageSquare size={64} className="mb-4" />
|
||||
<p className="text-slate-400 text-sm font-bold uppercase tracking-widest">No tienes mensajes</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Agrupar mensajes por ListingId preliminarmente */}
|
||||
{Array.from(new Set(notes.map(n => n.listingId))).map(listingId => {
|
||||
const thread = notes.filter(n => n.listingId === listingId).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
const hasUnread = thread.some(n => !n.isRead && n.isFromModerator);
|
||||
const isExpanded = selectedThreadListingId === listingId;
|
||||
|
||||
return (
|
||||
<div key={listingId} className={clsx(
|
||||
"rounded-[2rem] border transition-all overflow-hidden",
|
||||
hasUnread ? "border-blue-200 bg-blue-50/30" : "border-slate-100 bg-slate-50/50"
|
||||
)}>
|
||||
{/* Cabecera del Hilo */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedThreadListingId(isExpanded ? null : listingId);
|
||||
if (!isExpanded) markThreadAsRead(listingId);
|
||||
}}
|
||||
className="w-full p-6 flex items-center justify-between hover:bg-white/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={clsx(
|
||||
"w-12 h-12 rounded-2xl flex items-center justify-center transition-colors",
|
||||
hasUnread ? "bg-blue-600 text-white shadow-lg shadow-blue-100" : "bg-slate-200 text-slate-500"
|
||||
)}>
|
||||
<MessageSquare size={20} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h4 className="font-black text-slate-900 uppercase text-xs">Conversación sobre Aviso #{listingId}</h4>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase mt-1">
|
||||
{thread.length} Mensajes • Último: {new Date(thread[thread.length - 1].createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className={clsx("text-slate-300 transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
|
||||
{/* Cuerpo del Chat */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
className="overflow-hidden bg-white/40 border-t border-slate-100"
|
||||
>
|
||||
<div className="p-6 space-y-4">
|
||||
{thread.map((msg) => (
|
||||
<div key={msg.id} className={clsx(
|
||||
"flex flex-col max-w-[85%]",
|
||||
msg.isFromModerator ? "self-start" : "self-end ml-auto items-end"
|
||||
)}>
|
||||
<div className={clsx(
|
||||
"px-5 py-3 rounded-[1.5rem] shadow-sm text-sm",
|
||||
msg.isFromModerator
|
||||
? "bg-white border border-slate-100 text-slate-700 rounded-bl-none"
|
||||
: "bg-blue-600 text-white rounded-br-none"
|
||||
)}>
|
||||
{msg.message}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 px-2">
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tighter">
|
||||
{msg.isFromModerator ? 'Soporte SIG-CM' : 'Tú'} • {new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
{!msg.isRead && msg.isFromModerator && (
|
||||
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full"></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Caja de Respuesta */}
|
||||
<div className="mt-8 pt-6 border-t border-slate-100">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
placeholder="Escribe una respuesta al moderador..."
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
className="w-full bg-slate-50 border-none rounded-2xl p-4 pr-14 text-sm focus:ring-2 focus:ring-blue-100 outline-none transition-all resize-none h-24 font-medium"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleReply(listingId)}
|
||||
disabled={!replyText.trim()}
|
||||
className="absolute bottom-4 right-4 p-3 bg-blue-600 text-white rounded-xl shadow-lg shadow-blue-200 hover:bg-blue-700 disabled:opacity-50 disabled:shadow-none transition-all"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div >
|
||||
{/* MODAL DE CONFIRMACIÓN */}
|
||||
{/* MODAL DE CONFIRMACIÓN DE REPUBLIVACIÓN PREMIUM */}
|
||||
<AnimatePresence>
|
||||
{republishTarget && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
onClick={() => setRepublishTarget(null)}
|
||||
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-slate-900/80 backdrop-blur-md"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
className="relative bg-white p-8 rounded-[2.5rem] shadow-2xl max-w-sm w-full text-center border border-slate-100"
|
||||
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||
className="relative bg-white rounded-[3rem] shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col border border-slate-100"
|
||||
>
|
||||
<div className="w-16 h-16 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<RefreshCcw size={32} />
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-slate-900 uppercase tracking-tighter mb-3">Republicar Aviso</h3>
|
||||
<p className="text-sm text-slate-500 font-medium mb-8 leading-relaxed">
|
||||
Se creará una nueva publicación basada en <strong>{republishTarget.title}</strong>. Podrás editar los datos antes de realizar el pago.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
{/* HEADER TIPO PANEL */}
|
||||
<div className="p-8 border-b border-slate-50 flex justify-between items-start bg-slate-50/50">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-[10px] font-black text-blue-600 uppercase tracking-[0.2em] mb-2">
|
||||
<RefreshCcw size={12} className="animate-spin-slow" /> Republicar aviso
|
||||
</div>
|
||||
<h3 className="text-2xl font-black text-slate-900 uppercase tracking-tighter leading-none">
|
||||
{republishTarget.title}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setRepublishTarget(null)}
|
||||
className="flex-1 py-4 px-6 bg-slate-100 text-slate-500 font-black uppercase text-[10px] tracking-widest rounded-2xl hover:bg-slate-200 transition-all"
|
||||
className="p-3 bg-white text-slate-400 hover:text-slate-900 rounded-2xl shadow-sm border border-slate-100 transition-all"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmRepublish}
|
||||
className="flex-1 py-4 px-6 bg-blue-600 text-white font-black uppercase text-[10px] tracking-widest rounded-2xl shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all"
|
||||
>
|
||||
Confirmar
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CONTENIDO SCROLLABLE */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
{loadingRepublish ? (
|
||||
<div className="py-20 text-center flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-blue-100 border-t-blue-600 rounded-full animate-spin"></div>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Recuperando ficha técnica...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
||||
|
||||
{/* IZQUIERDA: Ficha y Galería */}
|
||||
<div className="lg:col-span-7 space-y-10">
|
||||
|
||||
{/* PREVIEW IMAGE GRID */}
|
||||
{republishDetail?.images?.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{republishDetail.images.slice(0, 2).map((img: any) => (
|
||||
<div key={img.id} className="aspect-video rounded-3xl overflow-hidden border border-slate-100 shadow-sm">
|
||||
<img src={`${baseUrl}${img.url}`} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FICHA TÉCNICA MINI */}
|
||||
<div>
|
||||
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-4 flex items-center gap-2">
|
||||
<Package size={14} /> Ficha Técnica a clonar
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{republishDetail?.attributes?.map((attr: any) => (
|
||||
<div key={attr.id} className="p-4 bg-slate-50 rounded-2xl border border-slate-100">
|
||||
<span className="block text-[8px] font-black text-slate-400 uppercase mb-1">{attr.attributeName}</span>
|
||||
<span className="text-xs font-black text-slate-700 uppercase">{attr.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TEXTO IMPRESO */}
|
||||
{republishDetail?.listing?.printText && (
|
||||
<div className="p-6 bg-blue-50/30 rounded-3xl border border-blue-50">
|
||||
<h4 className="text-[10px] font-black text-blue-600 uppercase tracking-[0.2em] mb-4 flex items-center gap-2">
|
||||
<Clock size={14} /> Texto de Impresión
|
||||
</h4>
|
||||
<p className="text-sm font-medium text-blue-900 italic leading-relaxed">
|
||||
"{republishDetail.listing.printText}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* DERECHA: Advertencias y CTA */}
|
||||
<div className="lg:col-span-5 space-y-6">
|
||||
|
||||
{/* CARD DE PRECIO ACTUAL */}
|
||||
<div className="p-8 bg-slate-900 rounded-[2.5rem] shadow-2xl text-white relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-8 opacity-10">
|
||||
<RefreshCcw size={80} />
|
||||
</div>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 relative z-10">Último precio</p>
|
||||
<div className="text-4xl font-black text-emerald-400 tracking-tighter relative z-10 transition-all">
|
||||
${republishTarget.price.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-6 pt-6 border-t border-white/10 flex flex-col gap-2 relative z-10">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-black text-white/40 uppercase">Vistas totales</span>
|
||||
<span className="text-xs font-black flex items-center gap-1"><Eye size={12} /> {republishTarget.viewCount}</span>
|
||||
</div>
|
||||
{republishTarget.publicationStartDate && (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-black text-white/40 uppercase">Publicado el</span>
|
||||
<span className="text-[10px] font-black">{new Date(republishTarget.publicationStartDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ADVERTENCIA DE VIGENCIA */}
|
||||
{(() => {
|
||||
const now = new Date();
|
||||
const startDate = republishTarget.publicationStartDate ? new Date(republishTarget.publicationStartDate) : (republishTarget.approvedAt ? new Date(republishTarget.approvedAt) : null);
|
||||
const days = republishTarget.printDaysCount || 0;
|
||||
let endDate = null;
|
||||
if (startDate && days > 0) {
|
||||
endDate = new Date(startDate);
|
||||
endDate.setDate(endDate.getDate() + days);
|
||||
}
|
||||
const remainsActive = republishTarget.status === 'Published' && (endDate ? now <= endDate : true);
|
||||
|
||||
if (remainsActive) {
|
||||
const dateStr = endDate?.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit', year: 'numeric' }) || 'N/A';
|
||||
return (
|
||||
<div className="p-6 bg-amber-50 border border-amber-100 rounded-3xl">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-amber-500 text-white rounded-xl shadow-lg shadow-amber-200">
|
||||
<ShieldCheck size={16} />
|
||||
</div>
|
||||
<span className="text-[10px] font-black text-amber-900 uppercase">Aviso Vigente</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-800 font-medium leading-relaxed">
|
||||
Este aviso caduca el <strong>{dateStr}</strong>. Si lo republicas generará un nuevo cargo duplicado.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* INFO DE CLONACIÓN */}
|
||||
<div className="p-6 bg-slate-50 rounded-3xl border border-slate-100">
|
||||
<p className="text-[10px] text-slate-500 font-bold leading-relaxed">
|
||||
Al confirmar, se abrirá el wizard con todos los datos cargados. Podrás ajustar precio, fotos y texto antes de pagar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ACCIONES */}
|
||||
<div className="flex flex-col gap-3 pt-4">
|
||||
<button
|
||||
onClick={handleConfirmRepublish}
|
||||
className="w-full py-5 bg-blue-600 text-white font-black uppercase text-xs tracking-widest rounded-3xl shadow-2xl shadow-blue-200 hover:bg-blue-700 transition-all flex items-center justify-center gap-3 active:scale-95"
|
||||
>
|
||||
Confirmar y Editar <ChevronRight size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRepublishTarget(null)}
|
||||
className="w-full py-4 text-slate-400 font-black uppercase text-[10px] tracking-widest hover:text-slate-900 transition-colors"
|
||||
>
|
||||
Cerrar Vista Previa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
@@ -444,30 +891,41 @@ export default function ProfilePage() {
|
||||
}
|
||||
|
||||
// COMPONENTES AUXILIARES PARA LIMPIEZA
|
||||
function SidebarItem({ icon, label, active, onClick }: { icon: any, label: string, active: boolean, onClick: () => void }) {
|
||||
function SidebarItem({ icon, label, active, onClick, badge }: { icon: any, label: string, active: boolean, onClick: () => void, badge?: number }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full flex items-center gap-3 px-5 py-4 rounded-[1.5rem] transition-all duration-300 font-bold text-[11px] uppercase tracking-[0.15em] ${active
|
||||
className={`w-full flex items-center justify-between px-5 py-4 rounded-[1.5rem] transition-all duration-300 font-bold text-[11px] uppercase tracking-[0.15em] ${active
|
||||
? 'bg-blue-600 text-white shadow-xl shadow-blue-200'
|
||||
: 'text-slate-400 hover:text-slate-900 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<span className={active ? 'text-white' : 'text-slate-300 group-hover:text-slate-600'}>
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={active ? 'text-white' : 'text-slate-300 group-hover:text-slate-600'}>
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</div>
|
||||
{badge ? badge > 0 && (
|
||||
<span className={clsx(
|
||||
"w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-black",
|
||||
active ? "bg-white text-blue-600" : "bg-rose-500 text-white shadow-lg shadow-rose-200"
|
||||
)}>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroup({ label, value, disabled, placeholder }: any) {
|
||||
function InputGroup({ label, value, onChange, disabled, placeholder }: any) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={value}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
className={`w-full px-6 py-4 bg-slate-50 border-none rounded-2xl focus:ring-2 focus:ring-blue-100 outline-none transition-all font-bold text-slate-700 ${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-slate-100'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useWizardStore } from '../store/wizardStore';
|
||||
import CategorySelection from './Steps/CategorySelection';
|
||||
import OperationSelection from './Steps/OperationSelection';
|
||||
@@ -9,13 +10,24 @@ import SummaryStep from './Steps/SummaryStep';
|
||||
import { wizardService } from '../services/wizardService';
|
||||
import type { AttributeDefinition } from '../types';
|
||||
import SEO from '../components/SEO';
|
||||
import api from '../services/api';
|
||||
import { User as UserIcon } from 'lucide-react';
|
||||
|
||||
export default function PublishPage() {
|
||||
const { step, selectedCategory } = useWizardStore();
|
||||
const [definitions, setDefinitions] = useState<AttributeDefinition[]>([]);
|
||||
const [profileComplete, setProfileComplete] = useState<boolean | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/profile').then(res => {
|
||||
const data = res.data;
|
||||
const isComplete = !!(data.billingName && data.billingAddress && data.billingTaxId && data.billingTaxType);
|
||||
setProfileComplete(isComplete);
|
||||
}).catch(() => setProfileComplete(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// CAMBIO: Agregamos el guard aquí también
|
||||
if (selectedCategory?.id) {
|
||||
wizardService.getAttributes(selectedCategory.id)
|
||||
.then(setDefinitions)
|
||||
@@ -23,6 +35,34 @@ export default function PublishPage() {
|
||||
}
|
||||
}, [selectedCategory?.id]);
|
||||
|
||||
if (profileComplete === null) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (profileComplete === false) {
|
||||
return (
|
||||
<div className="min-h-[70vh] flex flex-col items-center justify-center p-10 text-center animate-in fade-in zoom-in duration-500">
|
||||
<div className="w-24 h-24 bg-amber-50 text-amber-500 rounded-[2.5rem] flex items-center justify-center mb-8 shadow-xl shadow-amber-100 border border-amber-100">
|
||||
<UserIcon size={48} />
|
||||
</div>
|
||||
<h2 className="text-3xl font-black text-slate-900 uppercase tracking-tighter mb-4">Perfil Incompleto</h2>
|
||||
<p className="text-slate-500 max-w-sm mb-10 font-medium leading-relaxed">
|
||||
Para poder publicar un aviso, primero debes completar tus datos de facturación. Esto es obligatorio para la emisión legal de comprobantes.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/profile?tab=settings')}
|
||||
className="bg-slate-900 text-white px-12 py-5 rounded-[2rem] font-black uppercase text-xs tracking-widest shadow-2xl shadow-slate-200 hover:bg-blue-600 transition-all active:scale-95"
|
||||
>
|
||||
Completar mis datos ahora
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans pb-20">
|
||||
<SEO
|
||||
|
||||
@@ -61,7 +61,11 @@ export default function SummaryStep({ definitions }: { definitions: AttributeDef
|
||||
isBold: false,
|
||||
isFrame: false,
|
||||
printFontSize: 'normal',
|
||||
printAlignment: 'left'
|
||||
printAlignment: 'left',
|
||||
publicationStartDate: attributes['publicationStartDate'],
|
||||
isFeatured: attributes['isFeatured'] === 'true',
|
||||
allowContact: attributes['allowContact'] === 'true',
|
||||
couponCode: attributes['couponCode']
|
||||
};
|
||||
|
||||
const result = await wizardService.createListing(payload);
|
||||
|
||||
@@ -13,27 +13,35 @@ interface PricingResult {
|
||||
wordCount: number;
|
||||
specialCharCount: number;
|
||||
details: string;
|
||||
discount: number;
|
||||
}
|
||||
|
||||
export default function TextEditorStep() {
|
||||
const { selectedCategory, attributes, setAttribute, setStep } = useWizardStore();
|
||||
const [text, setText] = useState(attributes['description'] || '');
|
||||
const [days, setDays] = useState(parseInt(attributes['days'] as string) || 3);
|
||||
const [startDate, setStartDate] = useState<Date>(new Date());
|
||||
const [isFeatured, setIsFeatured] = useState<boolean>(false);
|
||||
const [allowContact, setAllowContact] = useState<boolean>(true);
|
||||
const [couponCode, setCouponCode] = useState<string>('');
|
||||
|
||||
const [pricing, setPricing] = useState<PricingResult>({
|
||||
totalPrice: 0,
|
||||
baseCost: 0,
|
||||
extraCost: 0,
|
||||
wordCount: 0,
|
||||
specialCharCount: 0,
|
||||
details: ''
|
||||
details: '',
|
||||
discount: 0
|
||||
});
|
||||
const [loadingPrice, setLoadingPrice] = useState(false);
|
||||
|
||||
const debouncedText = useDebounce(text, 800);
|
||||
const debouncedCoupon = useDebounce(couponCode, 800);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCategory || debouncedText.length === 0) {
|
||||
setPricing({ totalPrice: 0, baseCost: 0, extraCost: 0, wordCount: 0, specialCharCount: 0, details: '' });
|
||||
setPricing({ totalPrice: 0, baseCost: 0, extraCost: 0, wordCount: 0, specialCharCount: 0, details: '', discount: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,9 +52,11 @@ export default function TextEditorStep() {
|
||||
categoryId: selectedCategory.id,
|
||||
text: debouncedText,
|
||||
days: days,
|
||||
isBold: false,
|
||||
isFrame: false,
|
||||
startDate: new Date().toISOString()
|
||||
isBold: false, // Could expose this too
|
||||
isFrame: false, // Could expose this too
|
||||
isFeatured: isFeatured,
|
||||
startDate: startDate.toISOString(),
|
||||
couponCode: debouncedCoupon
|
||||
});
|
||||
setPricing(response.data);
|
||||
} catch (error) {
|
||||
@@ -57,7 +67,7 @@ export default function TextEditorStep() {
|
||||
};
|
||||
|
||||
calculatePrice();
|
||||
}, [debouncedText, days, selectedCategory]);
|
||||
}, [debouncedText, days, selectedCategory, isFeatured, startDate, debouncedCoupon]);
|
||||
|
||||
const handleContinue = () => {
|
||||
if (text.trim().length === 0) {
|
||||
@@ -67,6 +77,10 @@ export default function TextEditorStep() {
|
||||
setAttribute('description', text);
|
||||
setAttribute('days', days.toString());
|
||||
setAttribute('adFee', pricing.totalPrice.toString());
|
||||
setAttribute('publicationStartDate', startDate.toISOString());
|
||||
setAttribute('allowContact', allowContact.toString());
|
||||
setAttribute('isFeatured', isFeatured.toString());
|
||||
setAttribute('couponCode', couponCode);
|
||||
setStep(5); // Siguiente paso
|
||||
};
|
||||
|
||||
@@ -128,28 +142,88 @@ export default function TextEditorStep() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* BLOQUE 2: CONFIGURACIÓN DE DÍAS */}
|
||||
<section className="bg-white p-8 rounded-[2.5rem] shadow-xl border border-slate-100 flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-slate-900 rounded-2xl text-white">
|
||||
<Calendar size={20} />
|
||||
{/* BLOQUE 2: CONFIGURACIÓN DE PUBLICACIÓN */}
|
||||
<section className="bg-white p-8 rounded-[2.5rem] shadow-xl border border-slate-100 space-y-8">
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 justify-between">
|
||||
{/* Días */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="p-3 bg-slate-900 rounded-2xl text-white">
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black uppercase text-xs text-slate-900">Duración</h4>
|
||||
<div className="flex items-center bg-slate-100 rounded-xl p-1 mt-2">
|
||||
<button onClick={() => setDays(Math.max(1, days - 1))} className="w-10 py-2 font-black text-lg text-slate-400 hover:text-slate-900">-</button>
|
||||
<input
|
||||
type="number"
|
||||
value={days}
|
||||
onChange={(e) => setDays(parseInt(e.target.value) || 1)}
|
||||
className="w-12 text-center bg-transparent border-none outline-none font-black text-lg text-blue-600"
|
||||
/>
|
||||
<button onClick={() => setDays(days + 1)} className="w-10 py-2 font-black text-lg text-slate-400 hover:text-slate-900">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black uppercase text-xs text-slate-900">Duración de la oferta</h4>
|
||||
<p className="text-slate-400 text-[10px] font-bold uppercase tracking-wider">¿Cuántos días se publicará?</p>
|
||||
|
||||
{/* Fecha de Inicio */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="p-3 bg-blue-50 rounded-2xl text-blue-600">
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black uppercase text-xs text-slate-900">Fecha de Inicio</h4>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate.toISOString().split('T')[0]}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
onChange={(e) => setStartDate(new Date(e.target.value))}
|
||||
className="mt-2 text-sm font-bold text-slate-700 bg-slate-100 rounded-xl px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center bg-slate-100 rounded-2xl p-1 w-full md:w-48">
|
||||
<button onClick={() => setDays(Math.max(1, days - 1))} className="flex-1 py-3 font-black text-xl text-slate-400 hover:text-slate-900">-</button>
|
||||
<input
|
||||
type="number"
|
||||
value={days}
|
||||
onChange={(e) => setDays(parseInt(e.target.value) || 1)}
|
||||
className="w-16 text-center bg-transparent border-none outline-none font-black text-xl text-blue-600"
|
||||
/>
|
||||
<button onClick={() => setDays(days + 1)} className="flex-1 py-3 font-black text-xl text-slate-400 hover:text-slate-900">+</button>
|
||||
<div className="h-px bg-slate-100 w-full"></div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 justify-between items-start">
|
||||
{/* Destacado */}
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<div className={`w-14 h-8 rounded-full p-1 transition-colors ${isFeatured ? 'bg-amber-400' : 'bg-slate-200'}`}>
|
||||
<div className={`w-6 h-6 rounded-full bg-white shadow-sm transition-transform ${isFeatured ? 'translate-x-6' : ''}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black uppercase text-xs text-slate-900 group-hover:text-amber-500 transition-colors">Destacar Aviso</h4>
|
||||
<p className="text-[10px] text-slate-400 font-bold">Posición preferencial (+ $500/día)</p>
|
||||
</div>
|
||||
<input type="checkbox" className="hidden" checked={isFeatured} onChange={(e) => setIsFeatured(e.target.checked)} />
|
||||
</label>
|
||||
|
||||
{/* Permitir Contacto */}
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<div className={`w-14 h-8 rounded-full p-1 transition-colors ${allowContact ? 'bg-green-500' : 'bg-slate-200'}`}>
|
||||
<div className={`w-6 h-6 rounded-full bg-white shadow-sm transition-transform ${allowContact ? 'translate-x-6' : ''}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black uppercase text-xs text-slate-900 group-hover:text-green-600 transition-colors">Permitir Contacto</h4>
|
||||
<p className="text-[10px] text-slate-400 font-bold">Mostrar botón de WhatsApp/Email</p>
|
||||
</div>
|
||||
<input type="checkbox" className="hidden" checked={allowContact} onChange={(e) => setAllowContact(e.target.checked)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 p-6 rounded-2xl flex items-center gap-4">
|
||||
<span className="text-xs font-black uppercase text-slate-400">Cupón:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={couponCode}
|
||||
onChange={(e) => setCouponCode(e.target.value.toUpperCase())}
|
||||
placeholder="CÓDIGO"
|
||||
className="bg-transparent border-b-2 border-slate-200 focus:border-blue-500 outline-none font-mono font-bold text-slate-700 w-32 text-center uppercase"
|
||||
/>
|
||||
{pricing.discount > 0 && <span className="text-xs font-bold text-green-600">¡Aplicado!</span>}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{/* BLOQUE 3: PREVISUALIZACIÓN */}
|
||||
|
||||
@@ -14,6 +14,12 @@ export interface Listing {
|
||||
images?: ListingImage[];
|
||||
viewCount: number;
|
||||
overlayStatus?: 'Vendido' | 'Alquilado' | 'Reservado' | null;
|
||||
publicationStartDate?: string;
|
||||
approvedAt?: string;
|
||||
isFeatured?: boolean;
|
||||
featuredExpiry?: string;
|
||||
allowContact?: boolean;
|
||||
printDaysCount?: number;
|
||||
}
|
||||
|
||||
export interface ListingAttribute {
|
||||
@@ -77,4 +83,8 @@ export interface CreateListingDto {
|
||||
printFontSize?: string;
|
||||
printAlignment?: string;
|
||||
imagesToClone?: string[];
|
||||
publicationStartDate?: string;
|
||||
isFeatured?: boolean;
|
||||
allowContact?: boolean;
|
||||
couponCode?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user