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;
|
||||
39
src/api.js
Normal file
39
src/api.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const BASE = "https://n8n.holo795.fr/webhook/annonce_appart";
|
||||
|
||||
export async function fetchAnnonces() {
|
||||
const res = await fetch(`${BASE}/get`);
|
||||
const json = await res.json();
|
||||
const list = json.data ?? json;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.filter((item) => item && item.id && item.ADDRESS);
|
||||
}
|
||||
|
||||
export async function updateAnnonce(id, fields) {
|
||||
const res = await fetch(`${BASE}/update`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ...fields }),
|
||||
});
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : { ok: true };
|
||||
}
|
||||
|
||||
export async function addAnnonce(annonce) {
|
||||
const res = await fetch(`${BASE}/add`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(annonce),
|
||||
});
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteAnnonce(id) {
|
||||
const res = await fetch(`${BASE}/delete`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : { ok: true };
|
||||
}
|
||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
93
src/components/AddModal.jsx
Normal file
93
src/components/AddModal.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState } from "react";
|
||||
import { X, Plus, Loader2, Link } from "lucide-react";
|
||||
|
||||
export default function AddModal({ open, onClose, onSubmit, existingUrls }) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [urlError, setUrlError] = useState("");
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleUrlChange = (val) => {
|
||||
setUrl(val);
|
||||
if (existingUrls.includes(val.trim())) {
|
||||
setUrlError("Cette URL existe deja dans la liste !");
|
||||
} else {
|
||||
setUrlError("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (urlError || !url.trim()) return;
|
||||
setLoading(true);
|
||||
await onSubmit({ url: url.trim() });
|
||||
setUrl("");
|
||||
setLoading(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-sand-900/40 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between p-5 border-b border-sand-100">
|
||||
<h2 className="text-lg font-semibold text-sand-900">
|
||||
Ajouter une annonce
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg hover:bg-sand-100 transition-colors text-sand-400"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-sand-700 mb-1">
|
||||
URL de l'annonce
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Link
|
||||
size={15}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-sand-300"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
autoFocus
|
||||
value={url}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
placeholder="https://www.seloger.com/..."
|
||||
className={`w-full pl-9 pr-3 py-2.5 border rounded-xl text-sm text-sand-800 focus:outline-none focus:ring-2 ${
|
||||
urlError
|
||||
? "border-red-400 focus:ring-red-200"
|
||||
: "border-sand-200 focus:ring-warm-200"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{urlError && (
|
||||
<p className="text-red-500 text-xs mt-1.5">{urlError}</p>
|
||||
)}
|
||||
<p className="text-xs text-sand-400 mt-1.5">
|
||||
Les infos seront extraites automatiquement depuis l'annonce.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !!urlError || !url.trim()}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 bg-warm-500 text-white rounded-xl font-semibold text-sm hover:bg-warm-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Plus size={18} />
|
||||
)}
|
||||
{loading ? "Extraction en cours..." : "Ajouter"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
src/components/AppartCard.jsx
Normal file
275
src/components/AppartCard.jsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
MapPin,
|
||||
Euro,
|
||||
Maximize2,
|
||||
DoorOpen,
|
||||
Bus,
|
||||
Car,
|
||||
Calendar,
|
||||
Sofa,
|
||||
ParkingCircle,
|
||||
Fence,
|
||||
ExternalLink,
|
||||
Trash2,
|
||||
Save,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
import StarRating from "./StarRating";
|
||||
import { updateAnnonce } from "../api";
|
||||
|
||||
function Badge({ children, variant = "default" }) {
|
||||
const styles = {
|
||||
default: "bg-sand-100 text-sand-600",
|
||||
green: "bg-emerald-50 text-emerald-700",
|
||||
red: "bg-red-50 text-red-500",
|
||||
blue: "bg-sky-50 text-sky-700",
|
||||
amber: "bg-warm-100 text-warm-700",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[variant]}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoChip({ icon: Icon, label, value, sub }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-sm text-sand-600">
|
||||
<Icon size={14} className="text-warm-400 shrink-0" />
|
||||
<span>
|
||||
{label && <span className="text-sand-400">{label} </span>}
|
||||
<span className="font-medium text-sand-800">{value}</span>
|
||||
{sub && <span className="text-sand-400 text-xs ml-0.5">{sub}</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppartCard({ annonce, onDelete, onUpdated }) {
|
||||
const [noteP, setNoteP] = useState(annonce.NOTE_P || 0);
|
||||
const [noteA, setNoteA] = useState(annonce.NOTE_A || 0);
|
||||
const [commentP, setCommentP] = useState(annonce.COMMENTAIRE_P || "");
|
||||
const [commentA, setCommentA] = useState(annonce.COMMENTAIRE_A || "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const hasChanges =
|
||||
noteP !== (annonce.NOTE_P || 0) ||
|
||||
noteA !== (annonce.NOTE_A || 0) ||
|
||||
commentP !== (annonce.COMMENTAIRE_P || "") ||
|
||||
commentA !== (annonce.COMMENTAIRE_A || "");
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
await updateAnnonce(annonce.id, {
|
||||
NOTE_P: noteP,
|
||||
NOTE_A: noteA,
|
||||
COMMENTAIRE_P: commentP || null,
|
||||
COMMENTAIRE_A: commentA || null,
|
||||
});
|
||||
onUpdated?.();
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm("Supprimer cette annonce ?")) return;
|
||||
setDeleting(true);
|
||||
await onDelete(annonce.id);
|
||||
setDeleting(false);
|
||||
};
|
||||
|
||||
const avgNote = noteP || noteA ? ((noteP + noteA) / (noteP && noteA ? 2 : 1)).toFixed(1) : null;
|
||||
|
||||
const dateDispo = annonce.DATE_DISPO
|
||||
? new Date(annonce.DATE_DISPO).toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
: null;
|
||||
|
||||
const dateAjout = annonce.createdAt
|
||||
? new Date(annonce.createdAt).toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
: null;
|
||||
|
||||
const prixM2 = annonce.SURFACE && annonce.PRIX
|
||||
? ((annonce.PRIX) / annonce.SURFACE).toFixed(2)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-sand-200/70 shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden">
|
||||
{/* Image */}
|
||||
{annonce.IMAGE_URL && (
|
||||
<div className="relative h-40 overflow-hidden">
|
||||
<img
|
||||
src={annonce.IMAGE_URL}
|
||||
alt={annonce.ADDRESS}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent" />
|
||||
</div>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div className="p-5 pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<MapPin size={15} className="text-warm-500 shrink-0" />
|
||||
<h3 className="text-sm font-semibold text-sand-900 truncate">
|
||||
{annonce.ADDRESS}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{avgNote && (
|
||||
<Badge variant="amber">
|
||||
{avgNote}/5
|
||||
</Badge>
|
||||
)}
|
||||
{annonce.MEUBLE && <Badge variant="blue"><Sofa size={12} /> Meuble</Badge>}
|
||||
{annonce.PARKING && <Badge variant="green"><ParkingCircle size={12} /> Parking</Badge>}
|
||||
{annonce.BALCON && <Badge variant="green"><Fence size={12} /> Balcon</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-xl font-bold text-warm-600">
|
||||
{(annonce.PRIX ?? 0).toFixed(0)} <span className="text-sm font-normal text-sand-400">EUR</span>
|
||||
</div>
|
||||
{prixM2 && (
|
||||
<div className="text-xs text-sand-400">{prixM2} EUR/m2</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info chips */}
|
||||
<div className="px-5 pb-3">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
<InfoChip icon={Maximize2} value={`${annonce.SURFACE ?? 0} m2`} />
|
||||
<InfoChip icon={DoorOpen} value={`${annonce.NB_PIECE ?? 0} piece${(annonce.NB_PIECE ?? 0) > 1 ? "s" : ""}`} />
|
||||
{annonce.DISTANCE_V > 0 && (
|
||||
<InfoChip icon={Bus} label="Vatel" value={`${annonce.DISTANCE_V} min`} />
|
||||
)}
|
||||
{annonce.DISTANCE_S > 0 && (
|
||||
<InfoChip icon={Car} label="Sup de Vinci" value={`${annonce.DISTANCE_S} min`} />
|
||||
)}
|
||||
{dateDispo && <InfoChip icon={Calendar} label="Dispo" value={dateDispo} />}
|
||||
{dateAjout && <InfoChip icon={Calendar} label="Ajout" value={dateAjout} />}
|
||||
</div>
|
||||
|
||||
{annonce.CHARGES && (
|
||||
<p className="text-xs text-sand-400 mt-2 leading-relaxed line-clamp-2">
|
||||
{annonce.CHARGES}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable notes section */}
|
||||
<div className="border-t border-sand-100">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center justify-between px-5 py-2.5 text-sm text-sand-500 hover:bg-warm-50/40 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<MessageSquare size={14} />
|
||||
Notes et commentaires
|
||||
{hasChanges && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-warm-500" />
|
||||
)}
|
||||
</span>
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
{/* Paulin */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-warm-600 uppercase tracking-wide">
|
||||
Paulin
|
||||
</span>
|
||||
<StarRating value={noteP} onChange={setNoteP} />
|
||||
</div>
|
||||
<textarea
|
||||
value={commentP}
|
||||
onChange={(e) => setCommentP(e.target.value)}
|
||||
placeholder="L'avis de Paulin..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-sm border border-sand-200 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-warm-200 placeholder:text-sand-300 text-sand-800 bg-sand-50/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agathe */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-pink-500 uppercase tracking-wide">
|
||||
Agathe
|
||||
</span>
|
||||
<StarRating value={noteA} onChange={setNoteA} />
|
||||
</div>
|
||||
<textarea
|
||||
value={commentA}
|
||||
onChange={(e) => setCommentA(e.target.value)}
|
||||
placeholder="L'avis d'Agathe..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-sm border border-sand-200 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-pink-200 placeholder:text-sand-300 text-sand-800 bg-sand-50/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
{hasChanges && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-warm-500 text-white text-sm rounded-xl hover:bg-warm-600 transition-colors disabled:opacity-50 font-medium"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Save size={14} />
|
||||
)}
|
||||
{saving ? "Sauvegarde..." : "Sauvegarder"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="flex items-center justify-between px-5 py-3 bg-sand-50/50 border-t border-sand-100">
|
||||
<a
|
||||
href={annonce.URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-xs text-warm-500 hover:text-warm-700 font-medium transition-colors"
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
Voir l'annonce
|
||||
</a>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-1 text-xs text-sand-400 hover:text-red-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={13} />
|
||||
)}
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/components/PasswordGate.jsx
Normal file
91
src/components/PasswordGate.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useState } from "react";
|
||||
import { Lock, Loader2 } from "lucide-react";
|
||||
|
||||
const STORAGE_KEY = "appart_nantes_auth";
|
||||
|
||||
export default function PasswordGate({ children }) {
|
||||
const [authenticated, setAuthenticated] = useState(
|
||||
() => sessionStorage.getItem(STORAGE_KEY) === "true"
|
||||
);
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (authenticated) return children;
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
setTimeout(() => {
|
||||
if (password === import.meta.env.VITE_APP_PASSWORD) {
|
||||
sessionStorage.setItem(STORAGE_KEY, "true");
|
||||
setAuthenticated(true);
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-sand-50 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-14 h-14 bg-warm-400 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-sm">
|
||||
<Lock size={24} className="text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-sand-900 tracking-tight">
|
||||
Appart Nantes
|
||||
</h1>
|
||||
<p className="text-sm text-sand-400 mt-1">Paulin & Agathe</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white rounded-2xl border border-sand-200/70 shadow-sm p-6 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-sand-700 mb-1.5">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(false);
|
||||
}}
|
||||
placeholder="Entrez le mot de passe..."
|
||||
className={`w-full px-4 py-2.5 border rounded-xl text-sm text-sand-800 focus:outline-none focus:ring-2 placeholder:text-sand-300 ${
|
||||
error
|
||||
? "border-red-400 focus:ring-red-200"
|
||||
: "border-sand-200 focus:ring-warm-200"
|
||||
}`}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-red-500 text-xs mt-1.5">
|
||||
Mot de passe incorrect
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !password.trim()}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 bg-warm-500 text-white rounded-xl font-semibold text-sm hover:bg-warm-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
"Entrer"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/StarRating.jsx
Normal file
28
src/components/StarRating.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Star } from "lucide-react";
|
||||
|
||||
export default function StarRating({ value, onChange, disabled = false }) {
|
||||
return (
|
||||
<div className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange?.(star === value ? 0 : star)}
|
||||
className={`p-0 border-0 bg-transparent transition-colors ${
|
||||
disabled ? "cursor-default" : "cursor-pointer hover:scale-110"
|
||||
}`}
|
||||
>
|
||||
<Star
|
||||
size={18}
|
||||
className={
|
||||
star <= value
|
||||
? "fill-amber-400 text-amber-400"
|
||||
: "fill-none text-gray-300"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
src/index.css
Normal file
31
src/index.css
Normal file
@@ -0,0 +1,31 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--color-warm-50: #fdf8f3;
|
||||
--color-warm-100: #faeee0;
|
||||
--color-warm-200: #f3dcc4;
|
||||
--color-warm-300: #e8c49e;
|
||||
--color-warm-400: #d4a373;
|
||||
--color-warm-500: #c4874f;
|
||||
--color-warm-600: #b06e3a;
|
||||
--color-warm-700: #925630;
|
||||
--color-warm-800: #77462b;
|
||||
--color-warm-900: #623b27;
|
||||
--color-sand-50: #faf9f6;
|
||||
--color-sand-100: #f2f0ea;
|
||||
--color-sand-200: #e8e4da;
|
||||
--color-sand-300: #d5cfc1;
|
||||
--color-sand-400: #b8b0a0;
|
||||
--color-sand-500: #9e9483;
|
||||
--color-sand-600: #857a6b;
|
||||
--color-sand-700: #6d6458;
|
||||
--color-sand-800: #5b544a;
|
||||
--color-sand-900: #4d473f;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-sand-50);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
13
src/main.jsx
Normal file
13
src/main.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
import PasswordGate from './components/PasswordGate.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<PasswordGate>
|
||||
<App />
|
||||
</PasswordGate>
|
||||
</StrictMode>,
|
||||
)
|
||||
Reference in New Issue
Block a user