chore: initialize React + Vite project with Docker deployment and apartment listing application
This commit is contained in:
372
src/App.jsx
Normal file
372
src/App.jsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
Home,
|
||||
ArrowUpDown,
|
||||
Loader2,
|
||||
MapPin,
|
||||
} from "lucide-react";
|
||||
import AppartCard from "./components/AppartCard";
|
||||
import AddModal from "./components/AddModal";
|
||||
import { fetchAnnonces, addAnnonce, deleteAnnonce } from "./api";
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "prix_asc", label: "Prix croissant" },
|
||||
{ value: "prix_desc", label: "Prix decroissant" },
|
||||
{ value: "surface_desc", label: "Surface decroissante" },
|
||||
{ value: "note_desc", label: "Meilleure note" },
|
||||
{ value: "recent", label: "Plus recent" },
|
||||
];
|
||||
|
||||
function App() {
|
||||
const [annonces, setAnnonces] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sort, setSort] = useState("recent");
|
||||
const [filters, setFilters] = useState({
|
||||
meuble: null,
|
||||
parking: null,
|
||||
balcon: null,
|
||||
maxPrix: "",
|
||||
minSurface: "",
|
||||
});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchAnnonces();
|
||||
setAnnonces(data);
|
||||
} catch (err) {
|
||||
console.error("Erreur chargement:", err);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleAdd = async (form) => {
|
||||
await addAnnonce(form);
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
await deleteAnnonce(id);
|
||||
await load();
|
||||
};
|
||||
|
||||
const existingUrls = useMemo(
|
||||
() => annonces.map((a) => a.URL),
|
||||
[annonces]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = [...annonces];
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
list = list.filter(
|
||||
(a) =>
|
||||
a.ADDRESS?.toLowerCase().includes(q) ||
|
||||
a.URL?.toLowerCase().includes(q) ||
|
||||
a.CHARGES?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.meuble !== null) {
|
||||
list = list.filter((a) => a.MEUBLE === filters.meuble);
|
||||
}
|
||||
if (filters.parking !== null) {
|
||||
list = list.filter((a) => a.PARKING === filters.parking);
|
||||
}
|
||||
if (filters.balcon !== null) {
|
||||
list = list.filter((a) => a.BALCON === filters.balcon);
|
||||
}
|
||||
if (filters.maxPrix) {
|
||||
list = list.filter((a) => a.PRIX <= parseFloat(filters.maxPrix));
|
||||
}
|
||||
if (filters.minSurface) {
|
||||
list = list.filter((a) => a.SURFACE >= parseInt(filters.minSurface));
|
||||
}
|
||||
|
||||
switch (sort) {
|
||||
case "prix_asc":
|
||||
list.sort((a, b) => a.PRIX - b.PRIX);
|
||||
break;
|
||||
case "prix_desc":
|
||||
list.sort((a, b) => b.PRIX - a.PRIX);
|
||||
break;
|
||||
case "surface_desc":
|
||||
list.sort((a, b) => b.SURFACE - a.SURFACE);
|
||||
break;
|
||||
case "note_desc":
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
(b.NOTE_P || 0) + (b.NOTE_A || 0) - ((a.NOTE_P || 0) + (a.NOTE_A || 0))
|
||||
);
|
||||
break;
|
||||
case "recent":
|
||||
default:
|
||||
list.sort(
|
||||
(a, b) => new Date(b.createdAt) - new Date(a.createdAt)
|
||||
);
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [annonces, search, sort, filters]);
|
||||
|
||||
const activeFilterCount = [
|
||||
filters.meuble !== null,
|
||||
filters.parking !== null,
|
||||
filters.balcon !== null,
|
||||
!!filters.maxPrix,
|
||||
!!filters.minSurface,
|
||||
].filter(Boolean).length;
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!annonces.length) return null;
|
||||
const prices = annonces.map((a) => a.PRIX ?? 0).filter((p) => p > 0);
|
||||
if (!prices.length) return { count: annonces.length, avgPrix: 0, minPrix: 0, maxPrix: 0 };
|
||||
return {
|
||||
count: annonces.length,
|
||||
avgPrix: (prices.reduce((s, p) => s + p, 0) / prices.length).toFixed(0),
|
||||
minPrix: Math.min(...prices).toFixed(0),
|
||||
maxPrix: Math.max(...prices).toFixed(0),
|
||||
};
|
||||
}, [annonces]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-sand-50">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-40 bg-warm-50/90 backdrop-blur-md border-b border-warm-200/50">
|
||||
<div className="max-w-6xl mx-auto px-5 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-warm-400 rounded-2xl flex items-center justify-center shadow-sm">
|
||||
<Home size={18} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-sand-900 tracking-tight">
|
||||
Appart Nantes
|
||||
</h1>
|
||||
<p className="text-xs text-sand-500">
|
||||
Paulin & Agathe
|
||||
{stats && (
|
||||
<span className="ml-1.5 text-sand-400">
|
||||
-- {stats.count} annonce{stats.count > 1 ? "s" : ""} | moy.{" "}
|
||||
{stats.avgPrix} EUR | {stats.minPrix} - {stats.maxPrix} EUR
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="p-2.5 rounded-xl border border-sand-200 hover:bg-sand-100 transition-colors text-sand-500 disabled:opacity-50"
|
||||
title="Rafraichir"
|
||||
>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
className={loading ? "animate-spin" : ""}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="flex items-center gap-1.5 px-4 py-2.5 bg-warm-500 text-white rounded-xl text-sm font-semibold hover:bg-warm-600 transition-colors shadow-sm"
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span className="hidden sm:inline">Ajouter</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search + Sort + Filters toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Search
|
||||
size={15}
|
||||
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-sand-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Rechercher par adresse..."
|
||||
className="w-full pl-10 pr-4 py-2.5 text-sm border border-sand-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-warm-300/50 focus:border-warm-300 bg-white/80 text-sand-800 placeholder:text-sand-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<ArrowUpDown
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-sand-400 pointer-events-none"
|
||||
/>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value)}
|
||||
className="pl-9 pr-4 py-2.5 text-sm border border-sand-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-warm-300/50 focus:border-warm-300 bg-white/80 text-sand-700 appearance-none cursor-pointer"
|
||||
>
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`relative p-2.5 rounded-xl border transition-colors ${
|
||||
showFilters || activeFilterCount
|
||||
? "border-warm-400 bg-warm-50 text-warm-600"
|
||||
: "border-sand-200 hover:bg-sand-100 text-sand-500"
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal size={16} />
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-warm-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters panel */}
|
||||
{showFilters && (
|
||||
<div className="mt-3 p-3.5 bg-white/60 rounded-xl border border-sand-200 flex flex-wrap items-center gap-3">
|
||||
{[
|
||||
{ key: "meuble", label: "Meuble" },
|
||||
{ key: "parking", label: "Parking" },
|
||||
{ key: "balcon", label: "Balcon" },
|
||||
].map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-sand-600 font-medium">{label}</span>
|
||||
<select
|
||||
value={
|
||||
filters[key] === null
|
||||
? ""
|
||||
: filters[key]
|
||||
? "true"
|
||||
: "false"
|
||||
}
|
||||
onChange={(e) =>
|
||||
setFilters((f) => ({
|
||||
...f,
|
||||
[key]:
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value === "true",
|
||||
}))
|
||||
}
|
||||
className="px-2 py-1 text-xs border border-sand-200 rounded-lg bg-white text-sand-700 focus:outline-none focus:ring-1 focus:ring-warm-300"
|
||||
>
|
||||
<option value="">Tous</option>
|
||||
<option value="true">Oui</option>
|
||||
<option value="false">Non</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-sand-600 font-medium">Prix max</span>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.maxPrix}
|
||||
onChange={(e) =>
|
||||
setFilters((f) => ({ ...f, maxPrix: e.target.value }))
|
||||
}
|
||||
placeholder="EUR"
|
||||
className="w-20 px-2 py-1 text-xs border border-sand-200 rounded-lg bg-white text-sand-700 focus:outline-none focus:ring-1 focus:ring-warm-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-sand-600 font-medium">Surface min</span>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.minSurface}
|
||||
onChange={(e) =>
|
||||
setFilters((f) => ({ ...f, minSurface: e.target.value }))
|
||||
}
|
||||
placeholder="m2"
|
||||
className="w-20 px-2 py-1 text-xs border border-sand-200 rounded-lg bg-white text-sand-700 focus:outline-none focus:ring-1 focus:ring-warm-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
meuble: null,
|
||||
parking: null,
|
||||
balcon: null,
|
||||
maxPrix: "",
|
||||
minSurface: "",
|
||||
})
|
||||
}
|
||||
className="text-xs text-warm-600 hover:text-warm-800 font-semibold"
|
||||
>
|
||||
Reinitialiser
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="max-w-6xl mx-auto px-5 py-8">
|
||||
{loading && !annonces.length ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-sand-400">
|
||||
<Loader2 size={28} className="animate-spin mb-3 text-warm-400" />
|
||||
<p className="text-sm text-sand-500">Chargement des annonces...</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<div className="w-16 h-16 bg-warm-100 rounded-full flex items-center justify-center mb-4">
|
||||
<MapPin size={28} className="text-warm-400" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-sand-700">
|
||||
Aucune annonce trouvee
|
||||
</p>
|
||||
<p className="text-xs mt-1 text-sand-400">
|
||||
{annonces.length
|
||||
? "Essaie de modifier tes filtres"
|
||||
: "Ajoute ta premiere annonce !"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{filtered.map((a, idx) => (
|
||||
<AppartCard
|
||||
key={a.id ?? idx}
|
||||
annonce={a}
|
||||
onDelete={handleDelete}
|
||||
onUpdated={load}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Add modal */}
|
||||
<AddModal
|
||||
open={showAdd}
|
||||
onClose={() => setShowAdd(false)}
|
||||
onSubmit={handleAdd}
|
||||
existingUrls={existingUrls}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Reference in New Issue
Block a user