486 lines
16 KiB
JavaScript
486 lines
16 KiB
JavaScript
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" },
|
|
];
|
|
|
|
const DATE_RANGE_OPTIONS = [
|
|
{ value: "", label: "Date: Toutes" },
|
|
{ value: "today", label: "Aujourd'hui" },
|
|
{ value: "yesterday", label: "Hier" },
|
|
{ value: "last7", label: "7 derniers jours" },
|
|
{ value: "last30", label: "30 derniers jours" },
|
|
];
|
|
|
|
const parseDate = (value) => {
|
|
if (!value) return null;
|
|
|
|
const toDate = (input) => {
|
|
const date = new Date(input);
|
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
};
|
|
|
|
let date = value instanceof Date ? value : null;
|
|
|
|
if (!date && (typeof value === "number" || typeof value === "string")) {
|
|
date = toDate(value);
|
|
|
|
if (!date && typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
const dayMatch = trimmed.match(/^([0-9]{1,2})[\/\-]([0-9]{1,2})[\/\-]([0-9]{2,4})$/);
|
|
|
|
if (dayMatch) {
|
|
const [, day, month, year] = dayMatch;
|
|
const normalizedYear = year.length === 2 ? `20${year}` : year;
|
|
date = toDate(`${normalizedYear.padStart(4, "0")}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`);
|
|
} else {
|
|
const monthMatch = trimmed.match(/^([0-9]{1,2})[\/\-]([0-9]{2,4})$/);
|
|
if (monthMatch) {
|
|
const [, month, year] = monthMatch;
|
|
const normalizedYear = year.length === 2 ? `20${year}` : year;
|
|
date = toDate(`${normalizedYear.padStart(4, "0")}-${month.padStart(2, "0")}-01`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return date;
|
|
};
|
|
|
|
const matchesDateRange = (annonce, range) => {
|
|
if (!range) return true;
|
|
|
|
const today = new Date();
|
|
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
const startOfTomorrow = new Date(startOfToday);
|
|
startOfTomorrow.setDate(startOfTomorrow.getDate() + 1);
|
|
|
|
let start = null;
|
|
let end = null;
|
|
|
|
switch (range) {
|
|
case "today":
|
|
start = startOfToday;
|
|
end = startOfTomorrow;
|
|
break;
|
|
case "yesterday": {
|
|
const startOfYesterday = new Date(startOfToday);
|
|
startOfYesterday.setDate(startOfYesterday.getDate() - 1);
|
|
start = startOfYesterday;
|
|
end = startOfToday;
|
|
break;
|
|
}
|
|
case "last7": {
|
|
start = new Date(startOfToday);
|
|
start.setDate(start.getDate() - 6);
|
|
end = startOfTomorrow;
|
|
break;
|
|
}
|
|
case "last30": {
|
|
start = new Date(startOfToday);
|
|
start.setDate(start.getDate() - 29);
|
|
end = startOfTomorrow;
|
|
break;
|
|
}
|
|
default:
|
|
return true;
|
|
}
|
|
|
|
const dates = [parseDate(annonce.DATE_DISPO), parseDate(annonce.createdAt)].filter(Boolean);
|
|
if (!dates.length) return false;
|
|
return dates.some((date) => date >= start && date < end);
|
|
};
|
|
|
|
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: "",
|
|
dateRange: "",
|
|
});
|
|
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));
|
|
}
|
|
if (filters.dateRange) {
|
|
list = list.filter((a) => matchesDateRange(a, filters.dateRange));
|
|
}
|
|
|
|
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,
|
|
!!filters.dateRange,
|
|
].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>
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs text-sand-600 font-medium">Date</span>
|
|
<select
|
|
value={filters.dateRange}
|
|
onChange={(e) =>
|
|
setFilters((f) => ({
|
|
...f,
|
|
dateRange: e.target.value,
|
|
}))
|
|
}
|
|
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"
|
|
>
|
|
{DATE_RANGE_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{activeFilterCount > 0 && (
|
|
<button
|
|
onClick={() =>
|
|
setFilters({
|
|
meuble: null,
|
|
parking: null,
|
|
balcon: null,
|
|
maxPrix: "",
|
|
minSurface: "",
|
|
dateRange: "",
|
|
})
|
|
}
|
|
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 items-start">
|
|
{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;
|