Compare commits
6 Commits
4853d5c51d
...
july
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eeaa7c1e5 | |||
| 0591ead62e | |||
| 46453604ac | |||
| 60055dddac | |||
| ba99b79d35 | |||
| 8f5c0cce5e |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
.env
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="description" content="Liste des annonces d'appartements à Nantes | Paulin et Agathe" />
|
<meta name="description" content="Liste des annonces d'appartements à Marseille | Vincent et Antoine" />
|
||||||
<link rel="icon" href="/logo.png" />
|
<link rel="icon" href="/logo.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Appart List</title>
|
<title>Appart List</title>
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -2525,7 +2525,6 @@
|
|||||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
@@ -2627,7 +2626,6 @@
|
|||||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^2.0.3"
|
"detect-libc": "^2.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
119
src/App.jsx
119
src/App.jsx
@@ -21,6 +21,93 @@ const SORT_OPTIONS = [
|
|||||||
{ value: "recent", label: "Plus recent" },
|
{ 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() {
|
function App() {
|
||||||
const [annonces, setAnnonces] = useState([]);
|
const [annonces, setAnnonces] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -33,6 +120,7 @@ function App() {
|
|||||||
balcon: null,
|
balcon: null,
|
||||||
maxPrix: "",
|
maxPrix: "",
|
||||||
minSurface: "",
|
minSurface: "",
|
||||||
|
dateRange: "",
|
||||||
});
|
});
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
@@ -94,6 +182,9 @@ function App() {
|
|||||||
if (filters.minSurface) {
|
if (filters.minSurface) {
|
||||||
list = list.filter((a) => a.SURFACE >= parseInt(filters.minSurface));
|
list = list.filter((a) => a.SURFACE >= parseInt(filters.minSurface));
|
||||||
}
|
}
|
||||||
|
if (filters.dateRange) {
|
||||||
|
list = list.filter((a) => matchesDateRange(a, filters.dateRange));
|
||||||
|
}
|
||||||
|
|
||||||
switch (sort) {
|
switch (sort) {
|
||||||
case "prix_asc":
|
case "prix_asc":
|
||||||
@@ -108,7 +199,7 @@ function App() {
|
|||||||
case "note_desc":
|
case "note_desc":
|
||||||
list.sort(
|
list.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(b.NOTE_P || 0) + (b.NOTE_A || 0) - ((a.NOTE_P || 0) + (a.NOTE_A || 0))
|
(b.NOTE_V || 0) + (b.NOTE_A || 0) - ((a.NOTE_V || 0) + (a.NOTE_A || 0))
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "recent":
|
case "recent":
|
||||||
@@ -127,6 +218,7 @@ function App() {
|
|||||||
filters.balcon !== null,
|
filters.balcon !== null,
|
||||||
!!filters.maxPrix,
|
!!filters.maxPrix,
|
||||||
!!filters.minSurface,
|
!!filters.minSurface,
|
||||||
|
!!filters.dateRange,
|
||||||
].filter(Boolean).length;
|
].filter(Boolean).length;
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
@@ -153,10 +245,10 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-sand-900 tracking-tight">
|
<h1 className="text-xl font-bold text-sand-900 tracking-tight">
|
||||||
Appart Nantes
|
Appart Marseille
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-sand-500">
|
<p className="text-xs text-sand-500">
|
||||||
Paulin & Agathe
|
Vincent & Antoine
|
||||||
{stats && (
|
{stats && (
|
||||||
<span className="ml-1.5 text-sand-400">
|
<span className="ml-1.5 text-sand-400">
|
||||||
-- {stats.count} annonce{stats.count > 1 ? "s" : ""} | moy.{" "}
|
-- {stats.count} annonce{stats.count > 1 ? "s" : ""} | moy.{" "}
|
||||||
@@ -302,6 +394,26 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{activeFilterCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -311,6 +423,7 @@ function App() {
|
|||||||
balcon: null,
|
balcon: null,
|
||||||
maxPrix: "",
|
maxPrix: "",
|
||||||
minSurface: "",
|
minSurface: "",
|
||||||
|
dateRange: "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="text-xs text-warm-600 hover:text-warm-800 font-semibold"
|
className="text-xs text-warm-600 hover:text-warm-800 font-semibold"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const BASE = "https://n8n.holo795.fr/webhook/annonce_appart";
|
const BASE = "https://n8n.holo795.fr/webhook/annonce_appart_july";
|
||||||
|
|
||||||
export async function fetchAnnonces() {
|
export async function fetchAnnonces() {
|
||||||
const res = await fetch(`${BASE}/get`);
|
const res = await fetch(`${BASE}/get`);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { X, Plus, Loader2, Link } from "lucide-react";
|
import { X, Plus, Loader2, Link } from "lucide-react";
|
||||||
|
|
||||||
export default function AddModal({ open, onClose, onSubmit, existingUrls }) {
|
export default function AddModal({ open, onClose, onSubmit, existingUrls }) {
|
||||||
@@ -6,11 +6,29 @@ export default function AddModal({ open, onClose, onSubmit, existingUrls }) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [urlError, setUrlError] = useState("");
|
const [urlError, setUrlError] = useState("");
|
||||||
|
|
||||||
|
const normalizeUrl = (raw) => {
|
||||||
|
if (!raw) return "";
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw.trim());
|
||||||
|
parsed.hash = "";
|
||||||
|
parsed.search = "";
|
||||||
|
return `${parsed.origin}${parsed.pathname}`;
|
||||||
|
} catch (err) {
|
||||||
|
return raw.trim().split(/[?#]/)[0];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedExisting = useMemo(
|
||||||
|
() => existingUrls.map((item) => normalizeUrl(item)),
|
||||||
|
[existingUrls]
|
||||||
|
);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const handleUrlChange = (val) => {
|
const handleUrlChange = (val) => {
|
||||||
setUrl(val);
|
setUrl(val);
|
||||||
if (existingUrls.includes(val.trim())) {
|
const normalized = normalizeUrl(val);
|
||||||
|
if (normalized && normalizedExisting.includes(normalized)) {
|
||||||
setUrlError("Cette URL existe deja dans la liste !");
|
setUrlError("Cette URL existe deja dans la liste !");
|
||||||
} else {
|
} else {
|
||||||
setUrlError("");
|
setUrlError("");
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
MapPin,
|
MapPin,
|
||||||
Euro,
|
Euro,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
DoorOpen,
|
DoorOpen,
|
||||||
Bus,
|
|
||||||
Car,
|
|
||||||
Calendar,
|
Calendar,
|
||||||
Sofa,
|
Sofa,
|
||||||
ParkingCircle,
|
ParkingCircle,
|
||||||
@@ -65,12 +63,20 @@ const formatFrDate = (value) => {
|
|||||||
date = toDate(value);
|
date = toDate(value);
|
||||||
|
|
||||||
if (!date && typeof value === "string") {
|
if (!date && typeof value === "string") {
|
||||||
const match = value.trim().match(/^([0-9]{1,2})[\/\-]([0-9]{1,2})[\/\-]([0-9]{2,4})$/);
|
const trimmed = value.trim();
|
||||||
|
const dayMatch = trimmed.match(/^([0-9]{1,2})[\/\-]([0-9]{1,2})[\/\-]([0-9]{2,4})$/);
|
||||||
|
|
||||||
if (match) {
|
if (dayMatch) {
|
||||||
const [, day, month, year] = match;
|
const [, day, month, year] = dayMatch;
|
||||||
const normalizedYear = year.length === 2 ? `20${year}` : year;
|
const normalizedYear = year.length === 2 ? `20${year}` : year;
|
||||||
date = toDate(`${normalizedYear.padStart(4, "0")}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`);
|
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`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,30 +90,69 @@ const formatFrDate = (value) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeFeedback = (noteV, noteA, commentV, commentA) => ({
|
||||||
|
NOTE_V: typeof noteV === "number" ? noteV : parseFloat(noteV) || 0,
|
||||||
|
NOTE_A: typeof noteA === "number" ? noteA : parseFloat(noteA) || 0,
|
||||||
|
COMMENTAIRE_V: commentV?.trim() ? commentV.trim() : null,
|
||||||
|
COMMENTAIRE_A: commentA?.trim() ? commentA.trim() : null,
|
||||||
|
});
|
||||||
|
|
||||||
export default function AppartCard({ annonce, onDelete, onUpdated }) {
|
export default function AppartCard({ annonce, onDelete, onUpdated }) {
|
||||||
const [noteP, setNoteP] = useState(annonce.NOTE_P || 0);
|
const [noteV, setNoteV] = useState(annonce.NOTE_V || 0);
|
||||||
const [noteA, setNoteA] = useState(annonce.NOTE_A || 0);
|
const [noteA, setNoteA] = useState(annonce.NOTE_A || 0);
|
||||||
const [commentP, setCommentP] = useState(annonce.COMMENTAIRE_P || "");
|
const [commentV, setCommentV] = useState(annonce.COMMENTAIRE_V || "");
|
||||||
const [commentA, setCommentA] = useState(annonce.COMMENTAIRE_A || "");
|
const [commentA, setCommentA] = useState(annonce.COMMENTAIRE_A || "");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [pendingSave, setPendingSave] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
const hasChanges =
|
const lastSavedRef = useRef(
|
||||||
noteP !== (annonce.NOTE_P || 0) ||
|
normalizeFeedback(noteV, noteA, commentV, commentA)
|
||||||
noteA !== (annonce.NOTE_A || 0) ||
|
);
|
||||||
commentP !== (annonce.COMMENTAIRE_P || "") ||
|
|
||||||
commentA !== (annonce.COMMENTAIRE_A || "");
|
|
||||||
|
|
||||||
const hasPaulinFeedback = (noteP ?? 0) > 0 || !!commentP?.trim();
|
const hasVincentFeedback = (noteV ?? 0) > 0 || !!commentV?.trim();
|
||||||
const hasAgatheFeedback = (noteA ?? 0) > 0 || !!commentA?.trim();
|
const hasAntoineFeedback = (noteA ?? 0) > 0 || !!commentA?.trim();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const normalized = normalizeFeedback(noteV, noteA, commentV, commentA);
|
||||||
|
const lastSaved = lastSavedRef.current;
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.NOTE_V === lastSaved.NOTE_V &&
|
||||||
|
normalized.NOTE_A === lastSaved.NOTE_A &&
|
||||||
|
normalized.COMMENTAIRE_V === lastSaved.COMMENTAIRE_V &&
|
||||||
|
normalized.COMMENTAIRE_A === lastSaved.COMMENTAIRE_A
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingSave(true);
|
||||||
|
|
||||||
|
const timeout = setTimeout(async () => {
|
||||||
|
setPendingSave(false);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateAnnonce(annonce.id, normalized);
|
||||||
|
lastSavedRef.current = normalized;
|
||||||
|
onUpdated?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erreur sauvegarde annonce", err);
|
||||||
|
setPendingSave(true);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, 700);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [annonce.id, commentA, commentV, noteA, noteV, onUpdated]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
await updateAnnonce(annonce.id, {
|
await updateAnnonce(annonce.id, {
|
||||||
NOTE_P: noteP,
|
NOTE_V: noteV,
|
||||||
NOTE_A: noteA,
|
NOTE_A: noteA,
|
||||||
COMMENTAIRE_P: commentP || null,
|
COMMENTAIRE_V: commentV || null,
|
||||||
COMMENTAIRE_A: commentA || null,
|
COMMENTAIRE_A: commentA || null,
|
||||||
});
|
});
|
||||||
onUpdated?.();
|
onUpdated?.();
|
||||||
@@ -121,7 +166,7 @@ export default function AppartCard({ annonce, onDelete, onUpdated }) {
|
|||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const avgNote = noteP || noteA ? ((noteP + noteA) / (noteP && noteA ? 2 : 1)).toFixed(1) : null;
|
const avgNote = noteV || noteA ? ((noteV + noteA) / (noteV && noteA ? 2 : 1)).toFixed(1) : null;
|
||||||
|
|
||||||
const dateDispo = formatFrDate(annonce.DATE_DISPO);
|
const dateDispo = formatFrDate(annonce.DATE_DISPO);
|
||||||
const dateAjout = formatFrDate(annonce.createdAt);
|
const dateAjout = formatFrDate(annonce.createdAt);
|
||||||
@@ -181,11 +226,12 @@ export default function AppartCard({ annonce, onDelete, onUpdated }) {
|
|||||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
|
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
|
||||||
<InfoChip icon={Maximize2} value={`${annonce.SURFACE ?? 0} m2`} />
|
<InfoChip icon={Maximize2} value={`${annonce.SURFACE ?? 0} m2`} />
|
||||||
<InfoChip icon={DoorOpen} value={`${annonce.NB_PIECE ?? 0} piece${(annonce.NB_PIECE ?? 0) > 1 ? "s" : ""}`} />
|
<InfoChip icon={DoorOpen} value={`${annonce.NB_PIECE ?? 0} piece${(annonce.NB_PIECE ?? 0) > 1 ? "s" : ""}`} />
|
||||||
{annonce.DISTANCE_V > 0 && (
|
{annonce.ARRONDISSEMENT && (
|
||||||
<InfoChip icon={Bus} label="Vatel" value={`${annonce.DISTANCE_V} min`} />
|
<InfoChip
|
||||||
)}
|
icon={MapPin}
|
||||||
{annonce.DISTANCE_S > 0 && (
|
label="Arrond."
|
||||||
<InfoChip icon={Car} label="Sup de Vinci" value={`${annonce.DISTANCE_S} min`} />
|
value={`${annonce.ARRONDISSEMENT}${annonce.ARRONDISSEMENT === 1 ? "er" : "e"}`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{dateDispo && <InfoChip icon={Calendar} label="Dispo" value={dateDispo} />}
|
{dateDispo && <InfoChip icon={Calendar} label="Dispo" value={dateDispo} />}
|
||||||
{dateAjout && <InfoChip icon={Calendar} label="Ajout" value={dateAjout} />}
|
{dateAjout && <InfoChip icon={Calendar} label="Ajout" value={dateAjout} />}
|
||||||
@@ -207,15 +253,15 @@ export default function AppartCard({ annonce, onDelete, onUpdated }) {
|
|||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<MessageSquare size={14} />
|
<MessageSquare size={14} />
|
||||||
Notes et commentaires
|
Notes et commentaires
|
||||||
{(hasPaulinFeedback || hasAgatheFeedback) && (
|
{(hasVincentFeedback || hasAntoineFeedback) && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
{hasPaulinFeedback && (
|
{hasVincentFeedback && (
|
||||||
<span className="w-4 h-4 rounded-full bg-warm-500 text-white text-[9px] font-semibold flex items-center justify-center">
|
<span className="w-4 h-4 rounded-full bg-warm-500 text-white text-[9px] font-semibold flex items-center justify-center">
|
||||||
P
|
V
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{hasAgatheFeedback && (
|
{hasAntoineFeedback && (
|
||||||
<span className="w-4 h-4 rounded-full bg-pink-500 text-white text-[9px] font-semibold flex items-center justify-center">
|
<span className="w-4 h-4 rounded-full bg-blue-600 text-white text-[9px] font-semibold flex items-center justify-center">
|
||||||
A
|
A
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -225,57 +271,48 @@ export default function AppartCard({ annonce, onDelete, onUpdated }) {
|
|||||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{(pendingSave || saving) && (
|
||||||
|
<div className="px-5 pb-2 text-xs text-sand-400">
|
||||||
|
{saving ? "Sauvegarde..." : "Sauvegarde automatique imminente..."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="px-5 pb-4 space-y-3">
|
<div className="px-5 pb-4 space-y-3">
|
||||||
{/* Paulin */}
|
{/* Vincent */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-semibold text-warm-600 uppercase tracking-wide">
|
<span className="text-xs font-semibold text-warm-600 uppercase tracking-wide">
|
||||||
Paulin
|
Vincent
|
||||||
</span>
|
</span>
|
||||||
<StarRating value={noteP} onChange={setNoteP} />
|
<StarRating value={noteV} onChange={setNoteV} />
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={commentP}
|
value={commentV}
|
||||||
onChange={(e) => setCommentP(e.target.value)}
|
onChange={(e) => setCommentV(e.target.value)}
|
||||||
placeholder="L'avis de Paulin..."
|
placeholder="L'avis de Vincent..."
|
||||||
rows={2}
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Agathe */}
|
{/* Antoine */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-semibold text-pink-500 uppercase tracking-wide">
|
<span className="text-xs font-semibold text-blue-700 uppercase tracking-wide">
|
||||||
Agathe
|
Antoine
|
||||||
</span>
|
</span>
|
||||||
<StarRating value={noteA} onChange={setNoteA} />
|
<StarRating value={noteA} onChange={setNoteA} />
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={commentA}
|
value={commentA}
|
||||||
onChange={(e) => setCommentA(e.target.value)}
|
onChange={(e) => setCommentA(e.target.value)}
|
||||||
placeholder="L'avis d'Agathe..."
|
placeholder="L'avis d'Antoine..."
|
||||||
rows={2}
|
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"
|
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-blue-200 placeholder:text-sand-300 text-sand-800 bg-sand-50/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Lock, Loader2 } from "lucide-react";
|
import { Lock, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
const STORAGE_KEY = "appart_nantes_auth";
|
const STORAGE_KEY = "appart_marseille_auth";
|
||||||
|
|
||||||
export default function PasswordGate({ children }) {
|
export default function PasswordGate({ children }) {
|
||||||
const [authenticated, setAuthenticated] = useState(
|
const [authenticated, setAuthenticated] = useState(
|
||||||
@@ -37,9 +37,9 @@ export default function PasswordGate({ children }) {
|
|||||||
<Lock size={24} className="text-white" />
|
<Lock size={24} className="text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-sand-900 tracking-tight">
|
<h1 className="text-2xl font-bold text-sand-900 tracking-tight">
|
||||||
Appart Nantes
|
Appart Marseille
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-sand-400 mt-1">Paulin & Agathe</p>
|
<p className="text-sm text-sand-400 mt-1">Vincent & Antoine</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
|
|||||||
Reference in New Issue
Block a user