From fbc95815d8d6ba4303f2ab6e998cf9830b2cbd75 Mon Sep 17 00:00:00 2001 From: "fefe.clochette" Date: Mon, 26 Jan 2026 20:21:05 +0100 Subject: [PATCH] voyage --- header.php | 3 +- holidays.php | 16 + index.php | 30 +- modules/holidays/geocode.php | 105 ++++++ modules/holidays/holidays.css | 604 ++++++++++++++++++++++++++++++++++ modules/holidays/holidays.js | 368 +++++++++++++++++++++ modules/holidays/index.php | 506 ++++++++++++++++++++++++++++ modules/holidays/save.php | 194 +++++++++++ modules/holidays/view.php | 25 ++ 9 files changed, 1840 insertions(+), 11 deletions(-) create mode 100644 holidays.php create mode 100644 modules/holidays/geocode.php create mode 100644 modules/holidays/holidays.css create mode 100644 modules/holidays/holidays.js create mode 100644 modules/holidays/index.php create mode 100644 modules/holidays/save.php create mode 100644 modules/holidays/view.php diff --git a/header.php b/header.php index f9fdb7c..5d0a34b 100644 --- a/header.php +++ b/header.php @@ -36,7 +36,8 @@ if (!isset($activePage)) { class="pf-nav-link ">Accueil Family calendar - + Holidays Gift list diff --git a/holidays.php b/holidays.php new file mode 100644 index 0000000..dba16ac --- /dev/null +++ b/holidays.php @@ -0,0 +1,16 @@ +Accéder au module + + +
🎁
+

Holidays

+
+ Plannification des vacances. +
+ Accéder au module +
+ + + +
🎁
+

Gift list

+
+ Planifica els regals de cada nen, per al Tió, el Nadal i els Reis. +
+ Accedir al mòdul +
+
💰
@@ -49,16 +69,6 @@ require __DIR__ . '/header.php'; À venir
- - -
🎄
-

gift list

-
- Planifica els regals de cada nen, per al Tió, el Nadal i els Reis. -
- Accedir al mòdul -
- diff --git a/modules/holidays/geocode.php b/modules/holidays/geocode.php new file mode 100644 index 0000000..c2843fb --- /dev/null +++ b/modules/holidays/geocode.php @@ -0,0 +1,105 @@ + 'missing q']); + exit; +} + +// borne la limite entre 1 et 5 (usage perso) +$limit = (int)($_GET['limit'] ?? 1); +if ($limit < 1) $limit = 1; +if ($limit > 5) $limit = 5; + +// Cache local (silencieux si table absente) +$qNorm = mb_strtolower($q); +$qHash = hash('sha256', $qNorm); +try { + if ($limit === 1) { + $st = $pdo->prepare("SELECT lat, lng, display_name FROM pf_geocode_cache WHERE q_hash = ?"); + $st->execute([$qHash]); + if ($row = $st->fetch(PDO::FETCH_ASSOC)) { + echo json_encode([ + 'lat' => (float)$row['lat'], + 'lng' => (float)$row['lng'], + 'display_name' => $row['display_name'], + 'cached' => true + ]); + exit; + } + } +} catch (Throwable $e) { + // pas bloquant +} + +// Appel Nominatim (respect des règles d'usage) +$endpoint = 'https://nominatim.openstreetmap.org/search'; +$params = http_build_query([ + 'format' => 'jsonv2', + 'addressdetails' => 1, + 'limit' => $limit, + 'q' => $q, +], '', '&', PHP_QUERY_RFC3986); + +$url = $endpoint . '?' . $params; + +$ch = curl_init($url); +curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 10, + CURLOPT_HTTPHEADER => [ + // Contact fourni: ferlan.alexandre@gmail.com + 'User-Agent: PachaFamily-Holidays/1.0 (+contact: ferlan.alexandre@gmail.com)' + ], +]); +$body = curl_exec($ch); +$http = curl_getinfo($ch, CURLINFO_HTTP_CODE); +$err = curl_error($ch); +curl_close($ch); + +if ($body === false || $http !== 200) { + http_response_code(502); + echo json_encode(['error' => 'geocode_failed', 'details' => $err ?: ('HTTP '.$http)]); + exit; +} + +$data = json_decode($body, true); +if (!is_array($data) || empty($data)) { + http_response_code(404); + echo json_encode(['error' => 'not_found']); + exit; +} + +if ($limit === 1) { + $r = $data[0]; + $lat = round((float)$r['lat'], 6); + $lng = round((float)$r['lon'], 6); + $display = $r['display_name'] ?? null; + + try { + $st = $pdo->prepare("REPLACE INTO pf_geocode_cache (q_hash, q, lat, lng, display_name) VALUES (?, ?, ?, ?, ?)"); + $st->execute([$qHash, $q, $lat, $lng, $display]); + } catch (Throwable $e) {} + + echo json_encode(['lat' => $lat, 'lng' => $lng, 'display_name' => $display]); + exit; +} + +// Multi-résultats +$results = array_map(function ($r) { + return [ + 'lat' => round((float)$r['lat'], 6), + 'lng' => round((float)$r['lon'], 6), + 'display_name' => (string)($r['display_name'] ?? ''), + ]; +}, $data); + +echo json_encode(['results' => $results], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); diff --git a/modules/holidays/holidays.css b/modules/holidays/holidays.css new file mode 100644 index 0000000..fb533f9 --- /dev/null +++ b/modules/holidays/holidays.css @@ -0,0 +1,604 @@ +/* === Holidays === */ + +/* Titres et paragraphes (alignés sur pf-gift-list) */ +.pf-holidays h1 { + margin-bottom: 4px; +} +.pf-holidays p { + margin-top: 0; + margin-bottom: 12px; + font-size: 13px; + color: #4b5563; +} +/* Petit texte explicatif (même classe que gift-list pour cohérence) */ +.cl-legend { + font-size: 12px; + color: #6b7280; + margin-bottom: 8px; +} + +/* Titlebar (cohérent avec gift-list .cl-titlebar) */ +.pf-holidays__titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} +.hol-title-actions { + display: flex; + gap: 8px; +} + +/* Boutons (proches de .cl-view-btn) */ +.btn, +.hol-add-btn, +.hol-map-toggle { + padding: 8px 12px; + border: 1px solid #d9e2ec; + border-radius: 8px; + background: #f0f4f8; + color: #243b53; + text-decoration: none; + cursor: pointer; + font-size: 13px; +} +.btn:hover, +.hol-add-btn:hover, +.hol-map-toggle:hover { + background: #e5eef5; +} +.btn-edit { + background: #e0f2fe; + border-color: #93c5fd; + color: #0f4c81; +} +.btn-delete { + background: #fee2e2; + border-color: #fca5a5; + color: #7c2d12; +} + +/* Cards */ +.hol-idea-card { + background: #fff; + border: 1px solid #d9e2ec; + border-radius: 8px; + padding: 12px; +} +.hol-idea-card__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.hol-card-actions { + display: flex; + gap: 6px; + margin-top: 8px; +} + +/* Statuts (match gift-list badges ton léger) */ +.hol-status { + font-size: 11px; + padding: 3px 6px; + border-radius: 12px; + background: #e5e7eb; +} +.hol-status--favorite { + background: #fde68a; +} +.hol-status--shortlist { + background: #bfdbfe; +} +.hol-status--planned { + background: #bbf7d0; +} + +/* === Modales (alignement visuel avec cl-modal de gift-list) === */ +.hol-modal { + display: none; +} +.hol-modal.open { + display: block; +} + +.hol-backdrop { + position: fixed; + inset: 0; + background: rgba(17, 24, 39, 0.35); /* même teinte que gift-list */ + z-index: 999; +} + +.hol-dialog { + position: fixed; + z-index: 1000; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: min( + 560px, + calc(100% - 24px) + ); /* légèrement plus large que gift-list */ + background: #fff; + border: 1px solid #d9e2ec; + border-radius: 8px; + box-shadow: 0 10px 30px rgba(17, 24, 39, 0.25); /* même shadow */ +} + +/* Modale carte (grande largeur) */ +.hol-dialog--map { + width: min(1200px, calc(100% - 24px)); +} + +.hol-map-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid #e5e7eb; +} + +/* Formulaire modale (alignement gift-list) */ +.hol-form { + display: grid; + gap: 10px; + padding: 14px; +} +.hol-form h3 { + margin: 0 0 4px; + font-size: 16px; + color: #243b53; +} + +.hol-inline { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.hol-form label { + display: grid; + gap: 6px; + font-size: 12px; + color: #374151; +} + +.hol-form input, +.hol-form select, +.hol-form textarea { + font-size: 12px; + padding: 6px 8px; + border: 1px solid #d9e2ec; + border-radius: 6px; + background: #ffffff; + color: #111827; +} + +/* Actions modale */ +.hol-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} +.hol-cancel, +.hol-ok { + font-size: 12px; + padding: 6px 12px; + border: 1px solid #d9e2ec; + border-radius: 6px; + background: #f0f4f8; + cursor: pointer; +} +.hol-ok { + font-weight: 600; + color: #1f2933; +} +.hol-cancel { + color: #d03050; +} +.hol-cancel:hover, +.hol-ok:hover { + background: #d9e2ec; +} + +/* Focus accessible */ +.hol-form input:focus-visible, +.hol-form select:focus-visible, +.hol-form textarea:focus-visible, +.hol-cancel:focus-visible, +.hol-ok:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +/* === Picker multi-résultats Géocode (harmonisé) === */ +.hol-geocode-picker { + margin-top: 8px; + background: #fff; + border: 1px solid #d9e2ec; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); + overflow: hidden; +} +.hol-geocode-picker__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + background: #f8fafc; + border-bottom: 1px solid #e5e7eb; + font-size: 13px; + font-weight: 600; + color: #243b53; +} +.hol-geocode-picker__close { + border: none; + background: transparent; + cursor: pointer; + font-size: 18px; + line-height: 1; + padding: 2px 6px; +} +.hol-geocode-picker__list { + list-style: none; + margin: 0; + padding: 6px; + max-height: 260px; + overflow: auto; +} +.hol-geocode-picker__item { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 8px; + padding: 6px; + border-radius: 6px; +} +.hol-geocode-picker__item + .hol-geocode-picker__item { + margin-top: 4px; +} +.hol-geocode-picker__label { + font-size: 12px; + color: #111827; +} +.hol-geocode-picker__coords { + font-size: 11px; + color: #6b7280; +} +.hol-geocode-picker__pick { + padding: 6px 10px; + border: 1px solid #d9e2ec; + border-radius: 6px; + background: #f0f4f8; + cursor: pointer; + font-size: 12px; +} + +/* === Mobile Comfort (aligné gift-list) === */ +@media (pointer: coarse), (max-width: 780px) { + .pf-holidays h1 { + font-size: clamp(24px, 7vw, 30px); + margin: 10px 0 6px; + line-height: 1.2; + } + .pf-holidays h2 { + font-size: clamp(20px, 5.6vw, 24px); + line-height: 1.25; + margin: 10px 0 6px; + } + .pf-holidays h3 { + font-size: clamp(17px, 4.8vw, 20px); + line-height: 1.3; + margin: 8px 0 4px; + } + + .btn, + .hol-add-btn, + .hol-map-toggle { + font-size: 18px; + padding: 12px 14px; + min-height: 48px; + border-radius: 10px; + } + + .hol-form input, + .hol-form select, + .hol-form textarea, + .hol-cancel, + .hol-ok { + font-size: 16px; /* évite zoom iOS */ + min-height: 44px; + } + + .hol-dialog { + width: min(640px, calc(100% - 24px)); + } +} + +/* Cards: style proche gift-list */ +.hol-ideas-grid { + display: grid; + grid-template-columns: repeat(3, minmax(260px, 1fr)); + gap: 10px; +} +@media (max-width: 1100px) { + .hol-ideas-grid { + grid-template-columns: repeat(2, minmax(240px, 1fr)); + } +} +@media (max-width: 700px) { + .hol-ideas-grid { + grid-template-columns: 1fr; + } +} + +.hol-idea-card { + background: #fff; + border: 1px solid #d9e2ec; + border-radius: 8px; + padding: 12px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); + transition: + box-shadow 120ms ease, + transform 120ms ease; +} +.hol-idea-card:hover { + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); + transform: translateY(-1px); +} +.hol-idea-card__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.hol-idea-meta, +.hol-tags, +.hol-notes { + font-size: 12px; + color: #374151; + margin: 6px 0; +} +.hol-card-actions { + display: flex; + gap: 6px; + margin-top: 8px; +} +.hol-idea-card--archived { + opacity: 0.85; +} + +/* Boutons cohérents */ +.btn, +.hol-add-btn, +.hol-map-toggle { + padding: 8px 12px; + border: 1px solid #d9e2ec; + border-radius: 8px; + background: #f0f4f8; + color: #243b53; + text-decoration: none; + cursor: pointer; + font-size: 13px; +} +.btn:hover, +.hol-add-btn:hover, +.hol-map-toggle:hover { + background: #e5eef5; +} +.btn-edit { + background: #e0f2fe; + border-color: #93c5fd; + color: #0f4c81; +} +.btn-delete { + background: #fee2e2; + border-color: #fca5a5; + color: #7c2d12; +} + +/* Modale: harmonisée avec gift-list */ +.hol-modal { + display: none; +} +.hol-modal.open { + display: block; +} +.hol-backdrop { + position: fixed; + inset: 0; + background: rgba(17, 24, 39, 0.35); + z-index: 999; +} +.hol-dialog { + position: fixed; + z-index: 1000; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: min(560px, calc(100% - 24px)); + background: #fff; + border: 1px solid #d9e2ec; + border-radius: 8px; + box-shadow: 0 10px 30px rgba(17, 24, 39, 0.25); +} +.hol-dialog--map { + width: min(1200px, calc(100% - 24px)); +} +.hol-map-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid #e5e7eb; +} + +.hol-form { + display: grid; + gap: 10px; + padding: 14px; +} +.hol-form h3 { + margin: 0 0 4px; + font-size: 16px; + color: #243b53; +} +.hol-inline { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.hol-form label { + display: grid; + gap: 6px; + font-size: 12px; + color: #374151; +} +.hol-form input, +.hol-form select, +.hol-form textarea { + font-size: 12px; + padding: 6px 8px; + border: 1px solid #d9e2ec; + border-radius: 6px; + background: #fff; + color: #111827; +} +.hol-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} +.hol-cancel, +.hol-ok { + font-size: 12px; + padding: 6px 12px; + border: 1px solid #d9e2ec; + border-radius: 6px; + background: #f0f4f8; + cursor: pointer; +} +.hol-ok { + font-weight: 600; + color: #1f2933; +} +.hol-cancel { + color: #d03050; +} +.hol-cancel:hover, +.hol-ok:hover { + background: #d9e2ec; +} +.hol-form input:focus-visible, +.hol-form select:focus-visible, +.hol-form textarea:focus-visible, +.hol-cancel:focus-visible, +.hol-ok:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +/* Geocode picker harmonisé */ +.hol-geocode-picker { + margin-top: 8px; + background: #fff; + border: 1px solid #d9e2ec; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); + overflow: hidden; +} +.hol-geocode-picker__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + background: #f8fafc; + border-bottom: 1px solid #e5e7eb; + font-size: 13px; + font-weight: 600; + color: #243b53; +} +.hol-geocode-picker__close { + border: none; + background: transparent; + cursor: pointer; + font-size: 18px; + line-height: 1; + padding: 2px 6px; +} +.hol-geocode-picker__list { + list-style: none; + margin: 0; + padding: 6px; + max-height: 260px; + overflow: auto; +} +.hol-geocode-picker__item { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 8px; + padding: 6px; + border-radius: 6px; +} +.hol-geocode-picker__item + .hol-geocode-picker__item { + margin-top: 4px; +} +.hol-geocode-picker__label { + font-size: 12px; + color: #111827; +} +.hol-geocode-picker__coords { + font-size: 11px; + color: #6b7280; +} +.hol-geocode-picker__pick { + padding: 6px 10px; + border: 1px solid #d9e2ec; + border-radius: 6px; + background: #f0f4f8; + cursor: pointer; + font-size: 12px; +} + +/* Mobile comfort (match gift-list) */ +@media (pointer: coarse), (max-width: 780px) { + .pf-holidays h1 { + font-size: clamp(24px, 7vw, 30px); + margin: 10px 0 6px; + line-height: 1.2; + } + .pf-holidays h2 { + font-size: clamp(20px, 5.6vw, 24px); + line-height: 1.25; + margin: 10px 0 6px; + } + .pf-holidays h3 { + font-size: clamp(17px, 4.8vw, 20px); + line-height: 1.3; + margin: 8px 0 4px; + } + + .btn, + .hol-add-btn, + .hol-map-toggle { + font-size: 18px; + padding: 12px 14px; + min-height: 48px; + border-radius: 10px; + } + .hol-form input, + .hol-form select, + .hol-form textarea, + .hol-cancel, + .hol-ok { + font-size: 16px; + min-height: 44px; + } + .hol-dialog { + width: min(640px, calc(100% - 24px)); + } +} diff --git a/modules/holidays/holidays.js b/modules/holidays/holidays.js new file mode 100644 index 0000000..8147a2b --- /dev/null +++ b/modules/holidays/holidays.js @@ -0,0 +1,368 @@ +// modules/holidays/holidays.js + +document.addEventListener("DOMContentLoaded", () => { + // --- Modale "Ajouter une idée" + const addBtn = document.getElementById("hol-add-open"); + const addModal = document.getElementById("hol-add-modal"); + if (addBtn && addModal) { + const backdrop = addModal.querySelector(".hol-backdrop"); + const cancel = addModal.querySelector(".hol-cancel"); + const open = () => addModal.classList.add("open"); + const close = () => addModal.classList.remove("open"); + addBtn.addEventListener("click", open); + backdrop.addEventListener("click", close); + cancel.addEventListener("click", close); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") close(); + }); + } + + // --- Helpers modale "Éditer" + const editModal = document.getElementById("hol-edit-modal"); + const openEditModal = () => { + if (editModal) editModal.classList.add("open"); + }; + const closeEditModal = () => { + if (editModal) editModal.classList.remove("open"); + }; + + async function openEditForId(id) { + try { + const res = await fetch( + `/modules/holidays/view.php?id=${encodeURIComponent(id)}`, + { + headers: { Accept: "application/json" }, + }, + ); + if (!res.ok) throw new Error("HTTP " + res.status); + const it = await res.json(); + + if (!editModal) { + alert("Modale édition introuvable"); + return; + } + // Scope les sélecteurs à la modale pour éviter les null + const $ = (sel) => editModal.querySelector(sel); + const setVal = (sel, val) => { + const el = $(sel); + if (el) el.value = val ?? ""; + }; + + setVal("#edit-id", it.id); + setVal("#edit-title", it.title || ""); + setVal("#edit-country", it.country || ""); + setVal("#edit-region", it.region || ""); + setVal("#edit-city", it.city || ""); + setVal("#edit-lat", it.lat ?? ""); + setVal("#edit-lng", it.lng ?? ""); + setVal("#edit-start", it.desired_start_date ?? ""); + setVal("#edit-end", it.desired_end_date ?? ""); + setVal("#edit-season", it.season_hint || ""); + setVal("#edit-days", it.ideal_days ?? ""); + setVal("#edit-status", it.status || "draft"); + setVal("#edit-notes", it.notes || ""); + + openEditModal(); + } catch { + alert("Impossible de charger l’idée."); + } + } + + // --- Modale "Éditer" (liste + page détail) + if (editModal) { + const backdrop = editModal.querySelector(".hol-backdrop"); + const cancel = editModal.querySelector(".hol-cancel"); + if (backdrop) backdrop.addEventListener("click", closeEditModal); + if (cancel) cancel.addEventListener("click", closeEditModal); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeEditModal(); + }); + + // Boutons "Éditer" des cards (liste/planifiées/archivées) + document.querySelectorAll(".btn-edit[data-edit-id]").forEach((btn) => { + btn.addEventListener("click", () => { + const id = btn.getAttribute("data-edit-id"); + if (id) openEditForId(id); + }); + }); + + // Bouton "Éditer" sur la page détail (en-tête) + const headerEditBtn = document.getElementById("hol-edit-open"); + if (headerEditBtn) { + headerEditBtn.addEventListener("click", () => { + const id = headerEditBtn.getAttribute("data-edit-id"); + if (id) openEditForId(id); + }); + } + } + + // --- Suppression (liste + planifiées + page détail) + document.querySelectorAll(".btn-delete[data-del-id]").forEach((btn) => { + btn.addEventListener("click", () => { + const id = btn.getAttribute("data-del-id"); + if (!id) return; + if (confirm("Supprimer cette idée ?")) { + const form = document.createElement("form"); + form.method = "post"; + form.action = "/modules/holidays/save.php"; + form.innerHTML = ` + + + `; + document.body.appendChild(form); + form.submit(); + } + }); + }); + + // --- Géocodage via Nominatim + UI multi-résultats + document.querySelectorAll(".hol-geocode-btn").forEach((btn) => { + btn.addEventListener("click", async () => { + const scope = btn.getAttribute("data-scope"); // 'add' ou 'edit' + let form, city, region, country, latInput, lngInput; + + if (scope === "edit") { + form = btn.closest("form") || document; + city = (document.getElementById("edit-city")?.value || "").trim(); + region = (document.getElementById("edit-region")?.value || "").trim(); + country = (document.getElementById("edit-country")?.value || "").trim(); + latInput = document.getElementById("edit-lat"); + lngInput = document.getElementById("edit-lng"); + } else { + form = btn.closest("form"); + city = (form?.querySelector('input[name="city"]')?.value || "").trim(); + region = ( + form?.querySelector('input[name="region"]')?.value || "" + ).trim(); + country = ( + form?.querySelector('input[name="country"]')?.value || "" + ).trim(); + latInput = form?.querySelector('input[name="lat"]'); + lngInput = form?.querySelector('input[name="lng"]'); + } + + const q = [city, region, country].filter(Boolean).join(", "); + if (!q) { + alert("Renseigne au moins Ville/Pays."); + return; + } + + removeNearbyPicker(btn); + btn.disabled = true; + const original = btn.textContent; + btn.textContent = "Recherche..."; + + try { + const res = await fetch( + `/modules/holidays/geocode.php?q=${encodeURIComponent(q)}&limit=5`, + { + headers: { Accept: "application/json" }, + }, + ); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error || "Erreur géocodage"); + + if ("lat" in data && "lng" in data) { + if (latInput) latInput.value = data.lat; + if (lngInput) lngInput.value = data.lng; + return; + } + + if (Array.isArray(data.results) && data.results.length > 0) { + renderGeocodePicker(btn, data.results, (choice) => { + if (latInput) latInput.value = choice.lat; + if (lngInput) lngInput.value = choice.lng; + removeNearbyPicker(btn); + }); + } else { + alert("Aucun résultat."); + } + } catch { + alert("Impossible de trouver les coordonnées pour: " + q); + } finally { + btn.disabled = false; + btn.textContent = original; + } + }); + }); + + function renderGeocodePicker(anchorBtn, results, onPick) { + removeNearbyPicker(anchorBtn); + + const wrapper = document.createElement("div"); + wrapper.className = "hol-geocode-picker"; + + const header = document.createElement("div"); + header.className = "hol-geocode-picker__header"; + header.textContent = "Plusieurs résultats trouvés"; + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.className = "hol-geocode-picker__close"; + closeBtn.textContent = "×"; + closeBtn.addEventListener("click", () => removeNearbyPicker(anchorBtn)); + header.appendChild(closeBtn); + + const list = document.createElement("ul"); + list.className = "hol-geocode-picker__list"; + + results.forEach((r) => { + const li = document.createElement("li"); + li.className = "hol-geocode-picker__item"; + + const label = document.createElement("div"); + label.className = "hol-geocode-picker__label"; + label.textContent = r.display_name || `${r.lat}, ${r.lng}`; + + const coords = document.createElement("div"); + coords.className = "hol-geocode-picker__coords"; + coords.textContent = `(${r.lat}, ${r.lng})`; + + const pickBtn = document.createElement("button"); + pickBtn.type = "button"; + pickBtn.className = "hol-geocode-picker__pick"; + pickBtn.textContent = "Choisir"; + pickBtn.addEventListener("click", () => onPick(r)); + + li.appendChild(label); + li.appendChild(coords); + li.appendChild(pickBtn); + list.appendChild(li); + }); + + wrapper.appendChild(header); + wrapper.appendChild(list); + + const container = + anchorBtn.closest(".hol-inline") || anchorBtn.parentElement; + container.insertAdjacentElement("afterend", wrapper); + } + + function removeNearbyPicker(anchorBtn) { + const container = + anchorBtn.closest(".hol-inline") || anchorBtn.parentElement; + const next = container?.nextElementSibling; + if (next && next.classList.contains("hol-geocode-picker")) { + next.remove(); + } + } + + // --- Carte (Leaflet) + const mapBtn = document.getElementById("hol-map-open"); + const mapModal = document.getElementById("hol-map-modal"); + if (mapBtn && mapModal) { + const backdrop = mapModal.querySelector(".hol-backdrop"); + const cancel = mapModal.querySelector(".hol-cancel"); + const open = () => mapModal.classList.add("open"); + const close = () => mapModal.classList.remove("open"); + + let mapInitialized = false; + let map; + + function initMap() { + // Évite double initialisation + if (mapInitialized) return; + mapInitialized = true; + + // Données carte (safe fallback) + const MAP_DATA = Array.isArray(window.HOL_MAP_DATA) + ? window.HOL_MAP_DATA + : []; + console.log("HOL_MAP_DATA (safe):", MAP_DATA); + + // Leaflet dispo ? + if (typeof L === "undefined") { + console.error("Leaflet non chargé"); + return; + } + + // Init carte une seule fois (ne pas faire ça dans la boucle) + map = L.map("hol-map", { scrollWheelZoom: true }); + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: "© OpenStreetMap", + }).addTo(map); + + // Corrige la taille après affichage de la modale + setTimeout(() => map.invalidateSize(), 0); + + // Ajout des marqueurs + const markers = []; + MAP_DATA.forEach((it) => { + const lat = parseFloat(it.lat); + const lng = parseFloat(it.lng); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return; + + const color = + it.status === "planned" + ? "#16a34a" + : it.status === "favorite" + ? "#f59e0b" + : it.status === "shortlist" + ? "#3b82f6" + : "#6b7280"; + + const m = L.circleMarker([lat, lng], { + radius: 6, + color, + fillColor: color, + fillOpacity: 0.85, + }).addTo(map); + + const loc = [it.city, it.region, it.country].filter(Boolean).join(", "); + const dates = it.desired_start_date + ? `${it.desired_start_date}${it.desired_end_date ? " → " + it.desired_end_date : ""}` + : ""; + + m.bindPopup(` + ${esc(it.title || "")}
+ ${esc(loc)}
+ ${dates ? "Dates: " + esc(dates) + "
" : ""} + Statut: ${esc(it.status || "")}
+ Ouvrir + `); + + markers.push(m); + }); + + // Vue par défaut selon nombre de points + if (markers.length === 1) { + map.setView(markers[0].getLatLng(), 7); + } else if (markers.length > 1) { + const group = L.featureGroup(markers); + map.fitBounds(group.getBounds(), { padding: [20, 20] }); + } else { + console.warn( + "HOL_MAP_DATA vide ou coordonnées non valides.", + window.HOL_MAP_DATA, + ); + map.setView([20, 0], 2); + } + + // Re-valider la taille après rendu complet + setTimeout(() => map.invalidateSize(), 100); + } + + function esc(s) { + return String(s).replace( + /[&<>"']/g, + (c) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[c], + ); + } + + mapBtn.addEventListener("click", () => { + open(); + setTimeout(initMap, 0); + }); + if (backdrop) backdrop.addEventListener("click", close); + if (cancel) cancel.addEventListener("click", close); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") close(); + }); + } +}); diff --git a/modules/holidays/index.php b/modules/holidays/index.php new file mode 100644 index 0000000..582ad38 --- /dev/null +++ b/modules/holidays/index.php @@ -0,0 +1,506 @@ +prepare($sql); + $st->execute($params); + return $st->fetchAll(PDO::FETCH_ASSOC); + } +} + +$ideaId = isset($_GET['id']) ? (int)$_GET['id'] : 0; + +/* Préparer les données pour la carte AVANT le rendu (liste ou détail) */ +if ($ideaId > 0) { + // Vue détail: renvoyer l'idée courante (le JS filtrera lat/lng invalides) + $mapIdeas = hol_q($pdo, " + SELECT id, title, country, region, city, + CAST(lat AS DECIMAL(9,6)) AS lat, + CAST(lng AS DECIMAL(9,6)) AS lng, + status, desired_start_date, desired_end_date + FROM pf_holidays_ideas + WHERE id = ? + ", [$ideaId]); +} else { + // Vue liste: idées non archivées avec coordonnées + $mapIdeas = hol_q($pdo, " + SELECT id, title, country, region, city, + CAST(lat AS DECIMAL(9,6)) AS lat, + CAST(lng AS DECIMAL(9,6)) AS lng, + status, desired_start_date, desired_end_date + FROM pf_holidays_ideas + WHERE status IN ('draft','shortlist','favorite','planned') + AND lat IS NOT NULL + AND lng IS NOT NULL + "); +} + +if ($ideaId > 0) { + // Vue détail d'une idée + $rows = hol_q($pdo, "SELECT * FROM pf_holidays_ideas WHERE id = ?", [$ideaId]); + $idea = $rows[0] ?? null; + if (!$idea) { + echo '

Idée introuvable.

'; + return; + } + $transport = hol_q($pdo, "SELECT * FROM pf_holidays_transport WHERE idea_id = ? ORDER BY created_at DESC", [$ideaId]); + $lodging = hol_q($pdo, "SELECT * FROM pf_holidays_lodging WHERE idea_id = ? ORDER BY created_at DESC", [$ideaId]); + $acts = hol_q($pdo, "SELECT * FROM pf_holidays_activities WHERE idea_id = ? ORDER BY created_at DESC", [$ideaId]); + $budget = hol_q($pdo, "SELECT category, label, amount, per_person FROM pf_holidays_budget_items WHERE idea_id = ? ORDER BY created_at DESC", [$ideaId]); + $sumRow = hol_q($pdo, " + SELECT + SUM(CASE WHEN per_person=0 THEN amount ELSE 0 END) AS fixed_total, + SUM(CASE WHEN per_person=1 THEN amount ELSE 0 END) AS per_person_total + FROM pf_holidays_budget_items WHERE idea_id = ? + ", [$ideaId])[0] ?? ['fixed_total' => 0, 'per_person_total' => 0]; + $fixedTotal = (float)($sumRow['fixed_total'] ?? 0); + $ppTotal = (float)($sumRow['per_person_total'] ?? 0); + ?> + + + + + +
+

+
+ ← Retour + + + +
+
+ +

+ + + • Dates: + + • Saison: + + + • Durée idéale: j + + • Statut: +

+ +
+
+

Transport

+
+ + + + + + + + + +
+
    + +
  • + + + 🔗 +
  • + +
+
+ +
+

Hébergement

+
+ + + + + + + + + + + +
+
    + +
  • + + + + 🔗 +
  • + +
+
+ +
+

Activités

+
+ + + + + + + + + + +
+
    + +
  • + + + 🔗 +
  • + +
+
+ +
+

Budget

+
+ + + + + + + +
+ +
+ Fixe: € + • Par personne: € +
+ +
    + +
  • [] — + +
  • + +
+
+
+ + + + + + + + + + + + + +
+

Idées de vacances

+
+ + +
+
+ +
+

Vacances planifiées

+

Dates souhaitées, prêtes à être réservées.

+
+ +
+
+

+ planned +
+

+ + + • Dates: + + + • Durée idéale: j + +

+ +

+ +
+ Ouvrir + + +
+
+ +
+
+ +
+

Idées

+

Brouillons, favoris, shortlist.

+
+ +
+
+

+ +
+

+ + + • Dates: + + • Saison: + + + • Durée idéale: j + +

+ +

+ +
+ Ouvrir + + +
+
+ +
+
+ +
+

Archivées

+
+ +
+

+
+ + +
+
+ +
+
+ + + + + + + + + + + diff --git a/modules/holidays/save.php b/modules/holidays/save.php new file mode 100644 index 0000000..e79461f --- /dev/null +++ b/modules/holidays/save.php @@ -0,0 +1,194 @@ + NULL, sinon retourne la chaîne telle quelle + */ +function hol_norm_date($v): ?string { + if (!isset($v)) return null; + $s = trim((string)$v); + return $s === '' ? null : $s; +} + +try { + switch ($action) { + case 'create_idea': { + $status = $_POST['status'] ?? 'draft'; + $start = hol_norm_date($_POST['desired_start_date'] ?? null); + $end = hol_norm_date($_POST['desired_end_date'] ?? null); + if (!empty($start) && $status === 'draft') { $status = 'planned'; } + + $latVal = hol_norm_decimal($_POST['lat'] ?? null); + $lngVal = hol_norm_decimal($_POST['lng'] ?? null); + + $stmt = $pdo->prepare(" + INSERT INTO pf_holidays_ideas + (title, country, region, city, lat, lng, desired_start_date, desired_end_date, season_hint, ideal_days, status, notes) + VALUES + (:title,:country,:region,:city,:lat,:lng,:start,:end,:season,:days,:status,:notes) + "); + $stmt->execute([ + ':title' => trim($_POST['title'] ?? ''), + ':country'=> trim($_POST['country'] ?? ''), + ':region' => trim($_POST['region'] ?? ''), + ':city' => trim($_POST['city'] ?? ''), + ':lat' => $latVal, + ':lng' => $lngVal, + ':start' => $start, + ':end' => $end, + ':season' => trim($_POST['season_hint'] ?? ''), + ':days' => ($_POST['ideal_days'] !== '' ? (int)$_POST['ideal_days'] : null), + ':status' => $status, + ':notes' => trim($_POST['notes'] ?? ''), + ]); + $newId = (int)$pdo->lastInsertId(); + header("Location: /holidays.php?id={$newId}"); + exit; + } + + case 'update_idea': { + $status = $_POST['status'] ?? 'draft'; + $start = hol_norm_date($_POST['desired_start_date'] ?? null); + $end = hol_norm_date($_POST['desired_end_date'] ?? null); + if (!empty($start) && $status === 'draft') { $status = 'planned'; } + + $latVal = hol_norm_decimal($_POST['lat'] ?? null); + $lngVal = hol_norm_decimal($_POST['lng'] ?? null); + + $stmt = $pdo->prepare(" + UPDATE pf_holidays_ideas + SET title=:title, country=:country, region=:region, city=:city, lat=:lat, lng=:lng, + desired_start_date=:start, desired_end_date=:end, + season_hint=:season, ideal_days=:days, status=:status, notes=:notes + WHERE id=:id + "); + $stmt->execute([ + ':id' => (int)$_POST['id'], + ':title' => trim($_POST['title'] ?? ''), + ':country'=> trim($_POST['country'] ?? ''), + ':region' => trim($_POST['region'] ?? ''), + ':city' => trim($_POST['city'] ?? ''), + ':lat' => $latVal, + ':lng' => $lngVal, + ':start' => $start, + ':end' => $end, + ':season' => trim($_POST['season_hint'] ?? ''), + ':days' => ($_POST['ideal_days'] !== '' ? (int)$_POST['ideal_days'] : null), + ':status' => $status, + ':notes' => trim($_POST['notes'] ?? ''), + ]); + hol_back(); + } + + case 'delete_idea': { + $stmt = $pdo->prepare("DELETE FROM pf_holidays_ideas WHERE id = :id"); + $stmt->execute([':id' => (int)$_POST['id']]); + header("Location: /holidays.php"); + exit; + } + + case 'add_transport': { + $stmt = $pdo->prepare(" + INSERT INTO pf_holidays_transport (idea_id, mode, duration_min, cost, co2_kg, link, notes) + VALUES (:id,:mode,:dur,:cost,:co2,:link,:notes) + "); + $stmt->execute([ + ':id' => (int)$_POST['idea_id'], + ':mode' => strtoupper($_POST['mode'] ?? 'OTHER'), + ':dur' => ($_POST['duration_min'] !== '' ? (int)$_POST['duration_min'] : null), + ':cost' => hol_norm_decimal($_POST['cost'] ?? null), + ':co2' => hol_norm_decimal($_POST['co2_kg'] ?? null), + ':link' => trim($_POST['link'] ?? ''), + ':notes'=> trim($_POST['notes'] ?? ''), + ]); + hol_back(); + } + + case 'add_lodging': { + $stmt = $pdo->prepare(" + INSERT INTO pf_holidays_lodging (idea_id, type, location_text, price_per_n, nights, free_cancel, family_friendly, link, notes) + VALUES (:id,:type,:loc,:ppn,:n,:fc,:ff,:link,:notes) + "); + $stmt->execute([ + ':id' => (int)$_POST['idea_id'], + ':type' => strtoupper($_POST['type'] ?? 'OTHER'), + ':loc' => trim($_POST['location_text'] ?? ''), + ':ppn' => hol_norm_decimal($_POST['price_per_n'] ?? null), + ':n' => ($_POST['nights'] !== '' ? (int)$_POST['nights'] : null), + ':fc' => isset($_POST['free_cancel']) ? 1 : 0, + ':ff' => isset($_POST['family_friendly']) ? 1 : 0, + ':link' => trim($_POST['link'] ?? ''), + ':notes'=> trim($_POST['notes'] ?? ''), + ]); + hol_back(); + } + + case 'add_activity': { + $stmt = $pdo->prepare(" + INSERT INTO pf_holidays_activities (idea_id, name, kind, cost_est, need_booking, weather, link, notes) + VALUES (:id,:name,:kind,:cost,:need,:weather,:link,:notes) + "); + $stmt->execute([ + ':id' => (int)$_POST['idea_id'], + ':name' => trim($_POST['name'] ?? ''), + ':kind' => trim($_POST['kind'] ?? ''), + ':cost' => hol_norm_decimal($_POST['cost_est'] ?? null), + ':need' => isset($_POST['need_booking']) ? 1 : 0, + ':weather'=> strtoupper($_POST['weather'] ?? 'ANY'), + ':link' => trim($_POST['link'] ?? ''), + ':notes' => trim($_POST['notes'] ?? ''), + ]); + hol_back(); + } + + case 'add_budget': { + $stmt = $pdo->prepare(" + INSERT INTO pf_holidays_budget_items (idea_id, category, label, amount, per_person) + VALUES (:id,:cat,:label,:amt,:pp) + "); + $stmt->execute([ + ':id' => (int)$_POST['idea_id'], + ':cat' => strtoupper($_POST['category'] ?? 'OTHER'), + ':label' => trim($_POST['label'] ?? ''), + ':amt' => hol_norm_decimal($_POST['amount'] ?? null) ?? 0.0, + ':pp' => isset($_POST['per_person']) ? 1 : 0, + ]); + hol_back(); + } + + default: + http_response_code(400); + echo 'Unknown action'; + exit; + } +} catch (Throwable $e) { + http_response_code(500); + echo "Error: " . htmlspecialchars($e->getMessage()); +} diff --git a/modules/holidays/view.php b/modules/holidays/view.php new file mode 100644 index 0000000..5b38031 --- /dev/null +++ b/modules/holidays/view.php @@ -0,0 +1,25 @@ + 'bad id']); + exit; +} + +$st = $pdo->prepare("SELECT * FROM pf_holidays_ideas WHERE id = ?"); +$st->execute([$id]); +$it = $st->fetch(PDO::FETCH_ASSOC); + +if (!$it) { + http_response_code(404); + echo json_encode(['error' => 'not found']); + exit; +} + +echo json_encode($it, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);