Feat: Cambios Varios
This commit is contained in:
@@ -15,8 +15,9 @@ function App() {
|
||||
</nav>
|
||||
<div>
|
||||
<a
|
||||
href="http://localhost:5174"
|
||||
href="http://localhost:5177"
|
||||
className="bg-primary-600 text-white px-5 py-2 rounded-full font-medium hover:bg-primary-700 transition"
|
||||
target='_blank'
|
||||
>
|
||||
Publicar Aviso
|
||||
</a>
|
||||
|
||||
@@ -3,12 +3,23 @@ import SearchBar from '../components/SearchBar';
|
||||
import ListingCard from '../components/ListingCard';
|
||||
import { publicService } from '../services/publicService';
|
||||
import type { Listing, Category } from '../types';
|
||||
import { Filter, X } from 'lucide-react';
|
||||
import { processCategoriesForSelect, type FlatCategory } from '../utils/categoryTreeUtils';
|
||||
|
||||
export default function HomePage() {
|
||||
const [listings, setListings] = useState<Listing[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
|
||||
// Usamos FlatCategory para el renderizado
|
||||
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
|
||||
const [rawCategories, setRawCategories] = useState<Category[]>([]); // Guardamos raw para los botones del home
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Estado de Búsqueda
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectedCatId, setSelectedCatId] = useState<number | null>(null);
|
||||
const [dynamicFilters, setDynamicFilters] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData();
|
||||
}, []);
|
||||
@@ -21,72 +32,136 @@ export default function HomePage() {
|
||||
publicService.getCategories()
|
||||
]);
|
||||
setListings(latestListings);
|
||||
setCategories(cats);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setRawCategories(cats);
|
||||
|
||||
// Procesamos el árbol para el select
|
||||
const processed = processCategoriesForSelect(cats);
|
||||
setFlatCategories(processed);
|
||||
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
const performSearch = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await publicService.searchListings(query);
|
||||
setListings(results);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const response = await import('../services/api').then(m => m.default.post('/listings/search', {
|
||||
query: searchText,
|
||||
categoryId: selectedCatId,
|
||||
filters: dynamicFilters
|
||||
}));
|
||||
setListings(response.data);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const mainCategories = categories.filter(c => !c.parentId);
|
||||
useEffect(() => {
|
||||
if (searchText || selectedCatId || Object.keys(dynamicFilters).length > 0) {
|
||||
performSearch();
|
||||
}
|
||||
}, [selectedCatId, dynamicFilters]);
|
||||
|
||||
const handleSearchText = (q: string) => {
|
||||
setSearchText(q);
|
||||
performSearch();
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setDynamicFilters({});
|
||||
setSelectedCatId(null);
|
||||
setSearchText('');
|
||||
publicService.getLatestListings().then(setListings); // Reset list
|
||||
}
|
||||
|
||||
// Para los botones del Home, solo mostramos los Raíz
|
||||
const rootCategories = rawCategories.filter(c => !c.parentId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pb-20">
|
||||
{/* Hero Section */}
|
||||
<div className="bg-primary-900 text-white py-20 px-4 relative overflow-hidden">
|
||||
{/* Hero */}
|
||||
<div className="bg-primary-900 text-white py-16 px-4 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary-900 to-gray-900 opacity-90"></div>
|
||||
<div className="max-w-4xl mx-auto text-center relative z-10">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6">Encuentra tu próximo objetivo</h1>
|
||||
<p className="text-xl text-primary-100 mb-10">Clasificados verificados de Autos, Propiedades y más.</p>
|
||||
<h1 className="text-3xl md:text-5xl font-bold mb-6">Encuentra tu próximo objetivo</h1>
|
||||
<div className="flex justify-center">
|
||||
<SearchBar onSearch={handleSearch} />
|
||||
<SearchBar onSearch={handleSearchText} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories Quick Links */}
|
||||
<div className="max-w-6xl mx-auto px-4 -mt-8 relative z-20">
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 flex flex-wrap justify-center gap-4 md:gap-8 border border-gray-100">
|
||||
{mainCategories.map(cat => (
|
||||
<button key={cat.id} className="flex flex-col items-center gap-2 group">
|
||||
<div className="w-12 h-12 rounded-full bg-primary-50 flex items-center justify-center text-primary-600 group-hover:bg-primary-600 group-hover:text-white transition">
|
||||
<div className="font-bold text-lg">{cat.name.charAt(0)}</div>
|
||||
<div className="max-w-7xl mx-auto px-4 mt-8 flex flex-col lg:flex-row gap-8">
|
||||
|
||||
{/* SIDEBAR DE FILTROS */}
|
||||
<div className="w-full lg:w-64 flex-shrink-0 space-y-6">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-100">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-bold text-gray-800 flex items-center gap-2">
|
||||
<Filter size={18} /> Filtros
|
||||
</h3>
|
||||
{(selectedCatId || Object.keys(dynamicFilters).length > 0) && (
|
||||
<button onClick={clearFilters} className="text-xs text-red-500 hover:underline flex items-center">
|
||||
<X size={12} /> Limpiar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtro Categoría (MEJORADO) */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-2">Categoría</label>
|
||||
<select
|
||||
className="w-full border border-gray-300 rounded p-2 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
|
||||
value={selectedCatId || ''}
|
||||
onChange={(e) => setSelectedCatId(Number(e.target.value) || null)}
|
||||
>
|
||||
<option value="">Todas las categorías</option>
|
||||
{flatCategories.map(cat => (
|
||||
<option
|
||||
key={cat.id}
|
||||
value={cat.id}
|
||||
className={cat.level === 0 ? "font-bold text-gray-900" : "text-gray-600"}
|
||||
>
|
||||
{cat.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Filtros Dinámicos */}
|
||||
{selectedCatId && (
|
||||
<div className="space-y-3 pt-3 border-t">
|
||||
<p className="text-xs font-bold text-gray-400 uppercase">Atributos</p>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filtrar por Kilometraje..."
|
||||
className="w-full border p-2 rounded text-sm"
|
||||
onChange={(e) => setDynamicFilters({ ...dynamicFilters, 'Kilometraje': e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-600 group-hover:text-primary-700">{cat.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latest Listings */}
|
||||
<div className="max-w-6xl mx-auto px-4 mt-16">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-8">
|
||||
{loading ? 'Cargando...' : 'Resultados Recientes'}
|
||||
</h2>
|
||||
|
||||
{!loading && listings.length === 0 && (
|
||||
<div className="text-center text-gray-500 py-10">
|
||||
No se encontraron avisos con esos criterios.
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{listings.map(listing => (
|
||||
<ListingCard key={listing.id} listing={listing} />
|
||||
))}
|
||||
{/* LISTADO */}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
{loading ? 'Cargando...' : selectedCatId
|
||||
? `${listings.length} Resultados en ${flatCategories.find(c => c.id === selectedCatId)?.name}`
|
||||
: 'Resultados Recientes'}
|
||||
</h2>
|
||||
|
||||
{!loading && listings.length === 0 && (
|
||||
<div className="text-center text-gray-500 py-20 bg-white rounded-lg border border-dashed border-gray-300">
|
||||
No se encontraron avisos con esos criterios.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{listings.map(listing => (
|
||||
<ListingCard key={listing.id} listing={listing} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
71
frontend/public-web/src/utils/categoryTreeUtils.ts
Normal file
71
frontend/public-web/src/utils/categoryTreeUtils.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId?: number | null;
|
||||
}
|
||||
|
||||
export interface FlatCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
level: number;
|
||||
parentId: number | null;
|
||||
label: string; // Para mostrar en el select con indentación
|
||||
}
|
||||
|
||||
interface CategoryNode extends Category {
|
||||
children: CategoryNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el árbol y luego lo aplana ordenadamente con niveles
|
||||
*/
|
||||
export const processCategoriesForSelect = (rawCategories: Category[]): FlatCategory[] => {
|
||||
const map = new Map<number, CategoryNode>();
|
||||
const roots: CategoryNode[] = [];
|
||||
|
||||
// 1. Map
|
||||
rawCategories.forEach(cat => {
|
||||
// @ts-ignore
|
||||
map.set(cat.id, { ...cat, children: [] });
|
||||
});
|
||||
|
||||
// 2. Tree
|
||||
rawCategories.forEach(cat => {
|
||||
const node = map.get(cat.id);
|
||||
if (node) {
|
||||
if (cat.parentId && map.has(cat.parentId)) {
|
||||
map.get(cat.parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Flatten Recursive
|
||||
const flatten = (nodes: CategoryNode[], level = 0): FlatCategory[] => {
|
||||
let result: FlatCategory[] = [];
|
||||
// Ordenar alfabéticamente
|
||||
const sorted = nodes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const node of sorted) {
|
||||
// Creamos el label visual con espacios
|
||||
// Usamos caracteres unicode especiales para que se note la jerarquía
|
||||
const prefix = level === 0 ? '' : '\u00A0\u00A0'.repeat(level) + '↳ ';
|
||||
|
||||
result.push({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
parentId: node.parentId || null,
|
||||
level: level,
|
||||
label: prefix + node.name
|
||||
});
|
||||
|
||||
if (node.children.length > 0) {
|
||||
result = [...result, ...flatten(node.children, level + 1)];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return flatten(roots);
|
||||
};
|
||||
Reference in New Issue
Block a user