361 lines
16 KiB
TypeScript
361 lines
16 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import SearchBar from '../components/SearchBar';
|
|
import ListingCard from '../components/ListingCard';
|
|
import { publicService } from '../services/publicService';
|
|
import type { Listing } from '../types';
|
|
import { Filter, X, Grid, List as ListIcon, TrendingUp, Search, Zap } from 'lucide-react';
|
|
import { processCategoriesForSelect, type FlatCategory } from '../utils/categoryTreeUtils';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import SEO from '../components/SEO';
|
|
import { WebsiteSearchSchema, OrganizationSchema } from '../components/SchemaMarkup';
|
|
|
|
export default function HomePage() {
|
|
const [listings, setListings] = useState<Listing[]>([]);
|
|
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Search State
|
|
const [searchText, setSearchText] = useState('');
|
|
const [selectedCatId, setSelectedCatId] = useState<number | null>(null);
|
|
const [dynamicFilters, setDynamicFilters] = useState<Record<string, string>>({});
|
|
|
|
useEffect(() => {
|
|
loadInitialData();
|
|
}, []);
|
|
|
|
const loadInitialData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [latestListings, cats] = await Promise.all([
|
|
publicService.getLatestListings(),
|
|
publicService.getCategories()
|
|
]);
|
|
setListings(latestListings);
|
|
setFlatCategories(processCategoriesForSelect(cats));
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const performSearch = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const { default: api } = await import('../services/api');
|
|
const response = await api.post('/listings/search', {
|
|
query: searchText,
|
|
categoryId: selectedCatId,
|
|
attributes: dynamicFilters
|
|
});
|
|
setListings(response.data);
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [searchText, selectedCatId, dynamicFilters]);
|
|
|
|
useEffect(() => {
|
|
if (selectedCatId || Object.keys(dynamicFilters).length > 0 || searchText) {
|
|
performSearch();
|
|
}
|
|
}, [selectedCatId, dynamicFilters, searchText, performSearch]);
|
|
|
|
const handleSearchSubmit = (q: string) => {
|
|
setSearchText(q);
|
|
performSearch();
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setDynamicFilters({});
|
|
setSelectedCatId(null);
|
|
setSearchText('');
|
|
loadInitialData();
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#f8fafc] font-sans pb-24">
|
|
<SEO
|
|
title="Los Mejores Clasificados"
|
|
description="Encuentra autos, inmuebles y servicios verificados. Publica tu aviso con la confianza del Diario El Día."
|
|
/>
|
|
<WebsiteSearchSchema />
|
|
<OrganizationSchema />
|
|
|
|
{/* --- HERO SECTION REFINADO --- */}
|
|
<div className="relative bg-[#020617] pt-24 pb-36 px-4 overflow-hidden">
|
|
|
|
{/* Capas de fondo: Orbes de luz y textura */}
|
|
<div className="absolute inset-0 z-0">
|
|
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[60%] bg-blue-600/10 rounded-full blur-[120px] animate-pulse"></div>
|
|
<div className="absolute bottom-[-10%] right-[-5%] w-[30%] h-[50%] bg-indigo-500/10 rounded-full blur-[100px]"></div>
|
|
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/carbon-fibre.png')] opacity-[0.03]"></div>
|
|
</div>
|
|
|
|
<div className="max-w-5xl mx-auto text-center relative z-10">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.6 }}
|
|
>
|
|
{/* Badge superior estilizado */}
|
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-blue-500/5 border border-blue-500/20 mb-8 shadow-2xl">
|
|
<Zap size={14} className="text-blue-400 fill-blue-400/20" />
|
|
<span className="text-[9px] font-black text-blue-300 uppercase tracking-[0.3em]">
|
|
Marketplace Oficial Diario El Día
|
|
</span>
|
|
</div>
|
|
|
|
<h1 className="text-5xl md:text-7xl font-black text-white mb-6 tracking-tighter leading-[0.95] uppercase">
|
|
Tu próximo <br />
|
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-blue-500 to-indigo-400 drop-shadow-[0_0_25px_rgba(59,130,246,0.3)]">
|
|
gran hallazgo
|
|
</span>
|
|
</h1>
|
|
|
|
<p className="text-slate-400 text-base md:text-lg max-w-xl mx-auto mb-12 font-medium leading-relaxed opacity-80">
|
|
Explora miles de clasificados verificados con la confianza de siempre, ahora en una experiencia digital Premium.
|
|
</p>
|
|
</motion.div>
|
|
|
|
{/* Buscador con efecto Glassmorphism */}
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ delay: 0.2, duration: 0.5 }}
|
|
className="max-w-2xl mx-auto"
|
|
>
|
|
<div className="bg-white/5 backdrop-blur-md p-1.5 rounded-[2rem] border border-white/10 shadow-[0_20px_50px_rgba(0,0,0,0.3)]">
|
|
<SearchBar onSearch={handleSearchSubmit} />
|
|
</div>
|
|
|
|
{/* Sugerencias rápidas */}
|
|
<div className="mt-6 flex flex-wrap justify-center gap-4">
|
|
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Tendencias:</span>
|
|
{['Departamentos', 'Camionetas', 'Motos', 'Servicios'].map((tag) => (
|
|
<button
|
|
key={tag}
|
|
className="text-[10px] font-black text-slate-400 hover:text-blue-400 uppercase tracking-tighter transition-colors"
|
|
>
|
|
#{tag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
|
|
{/* Degradado hacia la sección blanca de abajo */}
|
|
<div className="absolute bottom-0 left-0 w-full h-24 bg-gradient-to-t from-[#f8fafc] to-transparent"></div>
|
|
</div>
|
|
|
|
{/* Main Content Layout */}
|
|
<div className="max-w-7xl mx-auto px-4 -mt-24 relative z-20">
|
|
<div className="flex flex-col lg:flex-row gap-10">
|
|
|
|
{/* SIDEBAR: Advanced Filters */}
|
|
<aside className="w-full lg:w-80 flex-shrink-0">
|
|
<div className="bg-white rounded-[3rem] shadow-2xl shadow-slate-200/40 border border-slate-100 p-10 mt-10 lg:mt-0 sticky top-28">
|
|
<div className="flex justify-between items-center mb-10">
|
|
<h3 className="text-xs font-black text-slate-900 uppercase tracking-widest flex items-center gap-3">
|
|
<div className="p-2 bg-blue-50 rounded-lg text-blue-600">
|
|
<Filter size={18} />
|
|
</div>
|
|
Filtros
|
|
</h3>
|
|
{(selectedCatId || Object.keys(dynamicFilters).length > 0 || searchText) && (
|
|
<button
|
|
onClick={clearFilters}
|
|
className="text-[9px] font-black text-red-500 hover:bg-red-50 hover:px-3 py-2 rounded-xl transition-all flex items-center gap-1 uppercase tracking-tighter"
|
|
>
|
|
<X size={12} /> Limpiar
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-10">
|
|
{/* Category Selection */}
|
|
<div>
|
|
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-4 ml-1">Rubro Principal</label>
|
|
<div className="relative">
|
|
<select
|
|
className="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4.5 text-sm font-black text-slate-800 focus:ring-4 focus:ring-blue-600/5 focus:border-blue-200 outline-none transition-all appearance-none cursor-pointer"
|
|
value={selectedCatId || ''}
|
|
onChange={(e) => {
|
|
setSelectedCatId(Number(e.target.value) || null);
|
|
setDynamicFilters({});
|
|
}}
|
|
>
|
|
<option value="">Todos los rubros</option>
|
|
{flatCategories.map(cat => (
|
|
<option
|
|
key={cat.id}
|
|
value={cat.id}
|
|
className={cat.level === 0 ? "font-black" : "font-semibold"}
|
|
>
|
|
{cat.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-slate-300">
|
|
<Filter size={14} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dynamic Attributes */}
|
|
<AnimatePresence>
|
|
{selectedCatId && (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.95 }}
|
|
className="space-y-6 pt-10 border-t border-slate-50"
|
|
>
|
|
<p className="flex items-center gap-3 text-[10px] font-black text-blue-600 uppercase tracking-widest">
|
|
<TrendingUp size={16} /> Búsqueda avanzada
|
|
</p>
|
|
|
|
<div className="space-y-4">
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
placeholder="Ej: Kilometraje, Ambientes..."
|
|
className="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 text-sm font-bold text-slate-700 outline-none focus:ring-4 focus:ring-blue-600/5 focus:border-blue-200 transition-all placeholder:text-slate-300"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
const val = (e.target as HTMLInputElement).value;
|
|
if (val) {
|
|
setDynamicFilters({ ...dynamicFilters, [Date.now()]: val });
|
|
(e.target as HTMLInputElement).value = '';
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-300">
|
|
<Search size={14} />
|
|
</div>
|
|
</div>
|
|
|
|
{Object.keys(dynamicFilters).length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{Object.entries(dynamicFilters).map(([k, v]) => (
|
|
<motion.span
|
|
key={k}
|
|
layout
|
|
initial={{ scale: 0.5, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
className="bg-blue-600 text-white px-3 py-1.5 rounded-xl text-[10px] font-black flex items-center gap-2 shadow-lg shadow-blue-200 uppercase tracking-tighter"
|
|
>
|
|
{v}
|
|
<X
|
|
size={12}
|
|
className="cursor-pointer hover:bg-white/20 rounded"
|
|
onClick={() => {
|
|
const next = { ...dynamicFilters }; delete next[k]; setDynamicFilters(next);
|
|
}}
|
|
/>
|
|
</motion.span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* MAIN LIST: Content Grid */}
|
|
<main className="flex-1 lg:pt-10">
|
|
<div className="mb-12 flex flex-col md:flex-row md:items-end justify-between gap-6">
|
|
<div>
|
|
<motion.h2
|
|
key={selectedCatId || 'root'}
|
|
initial={{ opacity: 0, x: -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
className="text-4xl font-black text-slate-900 tracking-tighter uppercase leading-none"
|
|
>
|
|
{loading ? 'Sincronizando...' : selectedCatId
|
|
? flatCategories.find(c => c.id === selectedCatId)?.name
|
|
: 'Últimos Clasificados'}
|
|
</motion.h2>
|
|
<div className="flex items-center gap-3 mt-3">
|
|
<span className="flex items-center gap-1.5 px-3 py-1 bg-white rounded-full border border-slate-100 text-[10px] font-black text-slate-400 uppercase tracking-widest shadow-sm">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-green-500"></div>
|
|
{listings.length} resultados
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center bg-white p-1.5 rounded-2xl shadow-xl shadow-slate-200/50 border border-slate-100">
|
|
<button className="p-2.5 bg-slate-900 text-white rounded-xl shadow-lg">
|
|
<Grid size={18} />
|
|
</button>
|
|
<button className="p-2.5 text-slate-300 hover:text-slate-500 rounded-xl">
|
|
<ListIcon size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Grid Container */}
|
|
<div className="relative min-h-[600px]">
|
|
<AnimatePresence mode="popLayout">
|
|
{loading ? (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-10"
|
|
>
|
|
{[1, 2, 3, 4, 5, 6].map(i => (
|
|
<div key={i} className="bg-white rounded-[3rem] h-[420px] animate-pulse border border-slate-50 shadow-sm"></div>
|
|
))}
|
|
</motion.div>
|
|
) : listings.length === 0 ? (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="flex flex-col items-center justify-center py-40 bg-white rounded-[4rem] border border-slate-100 shadow-2xl shadow-slate-200/50 group"
|
|
>
|
|
<div className="bg-slate-50 w-32 h-32 rounded-full flex items-center justify-center mb-8 border border-white shadow-inner group-hover:scale-110 transition-transform duration-500">
|
|
<Search size={48} className="text-slate-200 group-hover:text-blue-200 transition-colors" />
|
|
</div>
|
|
<h3 className="text-2xl font-black text-slate-900 uppercase tracking-tighter">Sin resultados</h3>
|
|
<p className="text-slate-400 font-bold uppercase tracking-widest text-[10px] mt-2 mb-10 max-w-xs text-center opacity-70">
|
|
No hay avisos que coincidan con los criterios aplicados actualmente.
|
|
</p>
|
|
<button
|
|
onClick={clearFilters}
|
|
className="bg-slate-900 hover:bg-black text-white px-10 py-4 rounded-2xl font-black uppercase text-xs tracking-[0.2em] shadow-2xl shadow-slate-200 active:scale-95 transition-all"
|
|
>
|
|
Restablecer vista
|
|
</button>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-10"
|
|
>
|
|
{listings.map((listing, idx) => (
|
|
<motion.div
|
|
key={listing.id}
|
|
initial={{ opacity: 0, y: 30 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: idx * 0.05, duration: 0.4 }}
|
|
>
|
|
<ListingCard listing={listing} />
|
|
</motion.div>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |