{"id":77,"date":"2026-01-07T20:32:25","date_gmt":"2026-01-07T19:32:25","guid":{"rendered":"https:\/\/camperistiinliberta.it\/?page_id=77"},"modified":"2026-02-11T21:14:13","modified_gmt":"2026-02-11T20:14:13","slug":"camperbuddy","status":"publish","type":"page","link":"https:\/\/camperistiinliberta.it\/?page_id=77","title":{"rendered":"CamperBuddy"},"content":{"rendered":"\n<doctype html>\n<html lang=\"it\">\n<head>\n<meta charset=\"utf-8\"\/>\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"\/>\n<title>CamperPulse<\/title>\n\n<link rel=\"stylesheet\" href=\"https:\/\/unpkg.com\/leaflet@1.9.4\/dist\/leaflet.css\"\/>\n<script src=\"https:\/\/unpkg.com\/leaflet@1.9.4\/dist\/leaflet.js\"><\/script>\n\n<link rel=\"stylesheet\" href=\"https:\/\/unpkg.com\/leaflet.markercluster@1.5.3\/dist\/MarkerCluster.css\"\/>\n<link rel=\"stylesheet\" href=\"https:\/\/unpkg.com\/leaflet.markercluster@1.5.3\/dist\/MarkerCluster.Default.css\"\/>\n<script src=\"https:\/\/unpkg.com\/leaflet.markercluster@1.5.3\/dist\/leaflet.markercluster.js\"><\/script>\n\n<script src=\"https:\/\/cdn.jsdelivr.net\/npm\/@supabase\/supabase-js@2\/dist\/umd\/supabase.js\"><\/script>\n\n<style>\n:root{--bg:#05070d;--bg2:#000;--panel:#141b33;--panel2:#1b233f;--line:#222;--line2:#2a345f;--txt:#eaf2ff;--muted:#c8d3ff;--aqua:#00e5ff;--pink:#ff2bd6;--red:#ff3b3b;--gold:#ffd84d;--shadow:0 12px 30px rgba(0,0,0,.55);--glowA:0 0 10px rgba(0,229,255,.55),0 0 20px rgba(255,43,214,.45);--r:14px}\nhtml,body{margin:0;padding:0;height:100%;overflow:hidden;font-family:system-ui;background:var(--bg);color:var(--txt)}\n#app{position:fixed;inset:0;display:flex;flex-direction:column}body.admin-bar #app{top:32px}\n#map{flex:1;position:relative;width:100%;height:auto;background:#0b1020;min-height:35vh;z-index:1}\n#uiPanel{width:100%;background:var(--bg);border-top:2px solid var(--aqua);box-shadow:0 -18px 40px rgba(0,0,0,.55);padding:10px;overflow:auto;max-height:45vh;-webkit-overflow-scrolling:touch;position:relative;z-index:2}\nbody.admin-bar #uiPanel{max-height:calc(45vh - 32px)}\ninput,textarea{width:100%;padding:8px;border-radius:10px;border:1px solid #3a466e;background:var(--bg);color:#fff;box-sizing:border-box}textarea{resize:none}\nbutton{padding:8px 12px;border-radius:999px;border:none;font-weight:900;cursor:pointer}\n.btnMain{background:linear-gradient(135deg,var(--aqua),var(--pink));color:#000}\n.btnAlt{background:var(--panel);color:#fff}\n.btnDark{background:var(--panel2);color:#fff}\n.btnRed{background:#2a0f12;color:var(--red);border:1px solid var(--red)}.btnRed:hover{background:var(--red);color:#000;box-shadow:0 0 12px rgba(255,59,59,.6)}\n#chatWrap{width:100%;background:transparent;display:block}\n#authBox{padding:14px;background:var(--bg);border:1px solid var(--line2);border-radius:14px;box-shadow:var(--shadow);position:relative}\n#topBar{display:flex;align-items:center;justify-content:center;gap:0;margin:0 0 8px}\n#title{text-align:center;font-size:22px;font-weight:1000;color:var(--aqua);text-shadow:var(--glowA);margin:0}\n#langWrap{position:relative;margin-left:10px}\n#langBtn{width:36px;height:28px;border-radius:12px;background:rgba(20,27,51,.92);border:1px solid var(--line2);color:#fff;display:grid;place-items:center;font-size:16px;padding:0}\n#langDrop{display:none;position:absolute;top:34px;right:0;z-index:13000;background:rgba(20,27,51,.94);border:1px solid var(--line2);border-radius:14px;box-shadow:var(--shadow);padding:8px;gap:6px}\n#langDrop.open{display:grid}\n.langOpt{width:44px;height:34px;border-radius:14px;background:rgba(5,7,13,.92);border:1px solid var(--line2);color:#fff;display:grid;place-items:center;font-size:18px;padding:0}\n#menuBtn{width:100%;padding:12px 14px;border-radius:16px;background:linear-gradient(135deg,var(--aqua),var(--pink));color:#000;font-weight:1000;margin-top:10px}\n.spotSearchTop{\n  width:100%;\n  height:44px;\n  margin:10px 0 10px 0;\n  border:none;\n  border-radius:16px;\n  background:var(--grad);\n  color:#0b0f1a;\n  font-weight:1000;\n  letter-spacing:.5px;\n}\n.spotSearchTop:active{ transform:scale(.99); }\n\n#menuDrop{display:none;padding:12px;border-radius:16px;background:rgba(20,27,51,.92);border:1px solid var(--line2);box-shadow:var(--shadow);position:fixed;z-index:12000;overflow:auto;max-height:min(70vh,520px);left:10px;top:10px;width:calc(100vw - 20px);-webkit-overflow-scrolling:touch}\n#menuDrop.open{display:grid;gap:10px}.menuRow{display:grid;gap:8px}.menuRow button{width:100%;border-radius:16px}\n.menuMe{width:100%;padding:10px 14px;border-radius:16px;background:rgba(5,7,13,.65);border:1px solid var(--line2);text-align:center;font-size:13px;color:var(--muted)}\n#chatBody{display:none;height:100%;min-height:0;background:var(--bg2);overflow:hidden;-webkit-overflow-scrolling:touch;grid-template-rows:minmax(150px,32%) 1fr}\n#chatList{background:var(--bg2);border-bottom:1px solid var(--line);overflow-y:auto;min-height:0;-webkit-overflow-scrolling:touch}\n.chatItem{display:flex;gap:10px;padding:10px;border-bottom:1px solid #1e1e1e;cursor:pointer}.chatItem.active{background:#0f1324}\n.chatText{flex:1;min-width:0}.chatTopRow{display:flex;align-items:center;gap:6px;justify-content:flex-start}\n.chatName{flex:0 1 auto;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:900;max-width:calc(100% - 46px)}\n.chatLast{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.chatDel{width:30px;height:30px;border-radius:12px;display:grid;place-items:center;flex:0 0 auto;padding:0}\n#chat{display:flex;flex-direction:column;min-width:0;background:var(--bg2);border-top:1px solid #111;flex:1;min-height:0;height:auto;overflow:hidden}\n#chatHeader{padding:12px;background:var(--bg2);border-bottom:1px solid var(--line);font-weight:900}\n#messages{flex:1;min-height:0;overflow-y:auto;padding:12px;background:var(--bg2);-webkit-overflow-scrolling:touch}\n.msg{max-width:70%;padding:10px;border-radius:12px;margin:6px 0;word-wrap:break-word}.me{margin-left:auto;background:var(--aqua);color:#000}.them{background:var(--panel2)}\n#composer{display:flex;flex-direction:column;gap:8px;padding:10px;border-top:1px solid var(--line);background:var(--bg2);flex:0 0 auto}\n#composerRow{display:flex;gap:8px}#composerRow input{flex:1}#composerRow button{background:linear-gradient(135deg,var(--aqua),var(--pink))}\n.leaflet-control{z-index:9600!important}\n.leaflet-control-layers{background:var(--bg)!important;color:var(--txt)!important;border:1px solid var(--line2)!important;border-radius:14px!important;box-shadow:var(--shadow)!important;overflow:hidden}\n.leaflet-control-layers-toggle{width:44px!important;height:44px!important;border-radius:16px!important;background-color:rgba(5,7,13,.92)!important;border:1px solid var(--line2)!important;box-shadow:0 0 14px rgba(0,229,255,.18)}\n.leaflet-control-layers label{color:var(--txt)!important;font-weight:900}\n#spotPanel{position:fixed;inset:0;background:var(--bg);z-index:9002;transform:translateY(110%);transition:transform .32s ease;display:flex;flex-direction:column;box-shadow:0 0 0 1px rgba(42,52,95,.55),0 18px 50px rgba(0,0,0,.65);border-top:2px solid var(--aqua)}\nbody.admin-bar #spotPanel{inset:32px 0 0 0}\n#spotPanel.open{transform:translateY(0)}\n#spotPanel.pick{inset:auto 0 0;height:55%;transform:translateY(0);box-shadow:0 -18px 40px rgba(0,0,0,.55);border-top:2px solid var(--aqua)}\nbody.admin-bar #spotPanel.pick{inset:auto 0 0;height:55%}\n#spotHeader{padding:12px;padding-top:calc(12px + env(safe-area-inset-top));display:flex;align-items:center;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);background:rgba(5,7,13,.96);backdrop-filter:blur(8px)}\n#spotHeaderTitle{font-weight:1000;color:var(--aqua);text-shadow:0 0 12px rgba(0,229,255,.25)}\n#spotClose{background:var(--panel);color:#fff;border-radius:14px;padding:6px 12px;font-weight:1000}\n#spotBody{overflow-y:auto;padding:12px;display:grid;gap:12px;border-bottom:1px solid var(--line)}\n#spotTypeRow{display:grid;grid-template-columns:1fr 1fr;gap:8px}\n.spTypeBtn{background:var(--panel);color:#fff;border:1px solid var(--line2);border-radius:14px;padding:10px 8px;font-weight:1000;display:flex;flex-direction:column;align-items:center;gap:6px}\n.spTypeBtn.active{background:linear-gradient(135deg,var(--aqua),var(--pink));color:#000;border:none}\n.spTypeEmoji{font-size:20px;line-height:1}.spTypeLabel{font-size:11px;opacity:.98;text-align:center}\n#spotPickBtn{width:100%}#spotDesc{max-height:90px}\n#spotPhotoRow{display:grid;gap:8px}\n#spotPreview{width:100%;max-width:300px;height:auto;border-radius:12px;border:1px solid var(--line2);display:none}\n#spotActions{display:flex;gap:8px}#spotCancelEditBtn{display:none}\n#spotList{flex:1;overflow-y:auto;padding:12px}\n.spItem{border:1px solid var(--line2);border-radius:12px;padding:10px;margin-bottom:10px;background:#070a14}\n.spTop{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}\n.spUser{font-weight:1000}.spMeta{font-size:12px;color:var(--muted);margin-top:2px}\n.spAddr{font-size:12px;color:var(--txt);margin-top:6px}\n.spDesc{font-size:13px;color:var(--txt);margin-top:8px;white-space:pre-wrap}\n.spPhoto{width:100%;max-width:300px;height:auto;margin-top:10px;border-radius:12px;border:1px solid var(--line2)}\n.spBtns{display:flex;gap:8px;margin-top:8px}\n.spBtnEdit{background:var(--panel);color:#fff;border:1px solid #333}\n.spBtnDel{background:#2a0f12;color:var(--red);border:1px solid var(--red)}\n.amenRow{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:8px}\n.amenBtn{width:40px;height:40px;border-radius:14px;border:1px solid var(--line2);background:var(--panel);color:#fff;display:grid;place-items:center;font-size:18px;padding:0;cursor:pointer;user-select:none;-webkit-tap-highlight-color:transparent}\n.amenBtn.on{background:linear-gradient(135deg,var(--aqua),var(--pink));color:#000;border:none;box-shadow:0 0 14px rgba(0,229,255,.18)}\n.amenMini{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:8px}\n.amenTag{width:30px;height:30px;border-radius:12px;border:1px solid var(--line2);background:#0d1328;color:var(--muted);display:grid;place-items:center;font-size:14px;opacity:.45;filter:saturate(.8)}\n.amenTag.on{opacity:1;filter:none;background:linear-gradient(135deg,var(--aqua),var(--pink));color:#000;border:none;box-shadow:0 0 12px rgba(0,229,255,.28),0 0 18px rgba(255,43,214,.22)}\n.starRow{display:flex;align-items:center;gap:10px;margin-top:10px;flex-wrap:wrap}\n.starDisplay{font-size:16px;letter-spacing:1px}.starDisplay .on{color:var(--gold)}.starDisplay .off{color:#55618a}\n.starVote{display:flex;gap:4px;align-items:center}\n.starVote button{width:30px;height:30px;border-radius:10px;border:1px solid var(--line2);background:var(--panel);color:#fff;display:grid;place-items:center;font-size:16px;padding:0}\n.starVote button.on{background:linear-gradient(135deg,var(--gold),var(--pink));color:#000;border:none}\n.starVote button:disabled{opacity:.45;cursor:not-allowed}\n.leaflet-popup-content-wrapper,.leaflet-popup-tip{background:var(--bg)!important;border:1px solid var(--line2)!important}\n.leaflet-popup-content-wrapper{color:var(--txt)!important;box-shadow:var(--shadow)!important}\n.leaflet-popup-close-button{color:var(--txt)!important;font-size:18px!important;opacity:.9!important}\n.leaflet-popup-close-button:hover{opacity:1!important}\n.leaflet-popup-content{margin:10px 12px!important}\n.leaflet-control-attribution{color:var(--muted)!important;background:rgba(5,7,13,.75)!important;border-radius:8px;padding:2px 6px}\n.marker-cluster{box-shadow:0 0 16px rgba(0,229,255,.20)!important}\n.marker-cluster div{color:var(--txt)!important;font-weight:1000!important;border-radius:999px!important}\n.modal{position:fixed;inset:0;z-index:20000;display:none;background:rgba(0,0,0,.66);-webkit-overflow-scrolling:touch}\n.modal.open{display:block}\n.modalCard{position:absolute;inset:0;background:var(--bg);display:flex;flex-direction:column;box-shadow:0 0 0 1px rgba(42,52,95,.55),0 18px 50px rgba(0,0,0,.65)}\n.modalHeader{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 16px;padding-top:calc(12px + env(safe-area-inset-top));border-bottom:1px solid var(--line);background:rgba(5,7,13,.96);backdrop-filter:blur(8px)}\n.modalTitle{font-weight:1000;color:var(--aqua);text-shadow:0 0 12px rgba(0,229,255,.25);line-height:1.1}\n.modalClose{width:46px;height:46px;border-radius:18px;border:1px solid var(--line2);background:var(--panel);color:#fff;font-weight:1000;font-size:20px;display:grid;place-items:center;cursor:pointer;flex:0 0 auto}\n.modalClose:hover{box-shadow:0 0 16px rgba(0,229,255,.18);transform:translateY(-1px)}\n#spotModalBody{flex:1;overflow-y:auto;padding:14px 16px;padding-bottom:calc(18px + env(safe-area-inset-bottom));-webkit-overflow-scrolling:touch;touch-action:pan-y}\n#chatModalBody{flex:1;overflow:hidden;padding:0;min-height:0}\n#rvFormModal.pick{background:transparent;pointer-events:none}\n#rvFormModal.pick .modalCard{pointer-events:auto;inset:auto 0 0;height:55%;border-top:2px solid var(--aqua);box-shadow:0 -18px 40px rgba(0,0,0,.55)}\nbody.admin-bar #rvFormModal.pick .modalCard{bottom:0}\n#rvListModalBody{flex:1;overflow-y:auto;padding:12px 12px 0;-webkit-overflow-scrolling:touch}\n#rvListFooter{position:sticky;bottom:0;left:0;right:0;padding:12px;background:rgba(5,7,13,.96);backdrop-filter:blur(8px);border-top:1px solid var(--line)}\n#rvAddBtn{width:100%;padding:12px 14px;border-radius:16px;background:linear-gradient(135deg,var(--aqua),var(--pink));color:#000;font-weight:1000}\n#rvFormModalBody{flex:1;overflow-y:auto;padding:12px 12px calc(18px + env(safe-area-inset-bottom));-webkit-overflow-scrolling:touch}\n.hint{font-size:12px;color:var(--muted);margin-top:-2px}\n.rvItem{border:1px solid var(--line2);border-radius:12px;padding:10px;margin:0 0 10px;background:#070a14}\n.rvTop{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}\n.rvUser{font-weight:1000}\n.rvMeta{font-size:12px;color:var(--muted);margin-top:2px}\n.rvText{margin:8px 0;font-size:14px}\n.rvBtns{display:flex;gap:8px}\n.rvReplies{margin-top:10px;padding-top:10px;border-top:1px dashed var(--line2);display:grid;gap:8px}\n.rvReplyItem{border:1px solid #1d2236;border-radius:12px;padding:8px;background:#070a14}\n.rvReplyMeta{font-size:12px;color:var(--muted);display:flex;justify-content:space-between;gap:10px}\n.rvReplyBody{font-size:13px;color:var(--txt);margin-top:6px;white-space:pre-wrap}\n.rvReplyDel{border-radius:12px;padding:4px 10px;font-weight:1000;font-size:12px}\n.rvReplyForm{display:flex;gap:8px;align-items:flex-start}\n.rvReplyForm textarea{min-height:40px;max-height:80px}\n.rvReplyBtn{border-radius:14px;padding:8px 12px;font-weight:1000;white-space:nowrap}\n@media (max-width:980px){#uiPanel{max-height:52vh}#chatBody{grid-template-rows:minmax(140px,28%) 1fr}#spotPanel.pick{height:60%}#rvFormModal.pick .modalCard{height:60%}}\n\/* \u2705 ADD-ON Profilo: riuso amenBtn per hobby *\/\n.amenBtn.on{background:linear-gradient(135deg,var(--aqua),var(--pink));color:#000;border:none;box-shadow:0 0 14px rgba(0,229,255,.18)}\n\n\n \/* \u2705 ADDED: layout bottom-left controls (layer + my tools) *\/\n#map .leaflet-bottom.leaflet-left{display:flex;flex-direction:row;align-items:flex-end;gap:8px;padding:0 0 10px 10px;pointer-events:none}\n#map .leaflet-bottom.leaflet-left .leaflet-control{margin:0!important;pointer-events:auto}\n.cpNavCtrl{display:flex;align-items:center;gap:8px;padding:8px;border-radius:14px;background:rgba(5,7,13,.92);border:1px solid var(--line2);box-shadow:var(--shadow);backdrop-filter:blur(6px);position:relative;width:calc(100vw - 26px - env(safe-area-inset-left) - env(safe-area-inset-right));max-width:720px;box-sizing:border-box}\n.cpLeftBtns{display:flex;align-items:center;gap:8px;flex:0 0 auto}\n.cpCityWrap{display:flex;align-items:center;gap:8px;flex:1 1 auto;min-width:0}\n.cpCityInput{flex:1 1 auto;min-width:0;width:100%;box-sizing:border-box}\n.cpCityGoBtn{flex:0 0 auto;min-width:78px}\n\n\/* \u2705 FIX: centra la barra in basso (iOS\/Android) *\/\n.leaflet-bottom.leaflet-left{\n  left:0 !important;\n  width:100% !important;\n  display:flex !important;\n  justify-content:center !important;\n  pointer-events:none !important;\n}\n.leaflet-bottom.leaflet-left .leaflet-control{\n  float:none !important;\n  margin:0 0 12px 0 !important;\n  pointer-events:auto !important;\n}\n.cpNavCtrl{flex-wrap:nowrap;justify-content:flex-start}\n.cpNavCtrl .cpLayersWrap{order:0}\n.cpNavCtrl .cpNavBtn{order:0}\n\n\n@media (max-width:480px){\n  .cpCityGoBtn{min-width:70px}\n}\n\n.leaflet-bottom .leaflet-control{overflow:visible!important}\n.leaflet-bottom .leaflet-control-container{overflow:visible!important}\n\n.cpNavBtn{width:44px;height:44px;border-radius:16px;border:1px solid var(--line2);background:rgba(5,7,13,.92);color:var(--txt);font-weight:1000;display:grid;place-items:center;cursor:pointer}\n.cpNavBtn:hover{box-shadow:0 0 14px rgba(0,229,255,.18)}\n.cpCityWrap{display:flex;align-items:center;gap:8px;flex:1 1 auto;min-width:0;margin-right:auto;order:0}\n.cpCityInput{flex:1 1 auto;min-width:0;width:100%;height:44px;border-radius:16px;border:1px solid var(--line2);background:rgba(5,7,13,.92);color:#fff;padding:0 12px;box-sizing:border-box;-webkit-appearance:none;appearance:none}\n.cpGoBtn{height:44px;padding:0 14px;border-radius:16px;font-weight:1000;background:linear-gradient(135deg,var(--aqua),var(--pink));color:#000;border:none;cursor:pointer}\n.cpGoBtn:disabled{opacity:.6;cursor:not-allowed}\n\n\/* \u2705 ADD-ON: piccolo bottone 'Cerca spot' sopra la barra citt\u00e0 *\/\n.cpSpotMini{position:absolute;left:50%;transform:translateX(-50%);top:-54px;height:42px;padding:0 28px;border-radius:20px;font-weight:1000;font-size:clamp(13px,2.6vw,15px);line-height:42px;min-width:170px;max-width:70vw;box-sizing:border-box;text-align:center;white-space:nowrap;background:linear-gradient(135deg,var(--aqua),var(--pink));color:#000;border:none;cursor:pointer;z-index:50;box-shadow:0 0 14px rgba(0,229,255,.12)}\n\n\n\/* \u2705 ADD-ON: dropdown layers dentro la cpNavCtrl *\/\n.cpLayersWrap{position:relative}\n.cpNavCtrl .cpLayersWrap{order:0}\n.cpNavCtrl .cpNavBtn{order:0}\n\n.cpLayersBtn{\n  width:44px;height:44px;border-radius:16px;border:1px solid var(--line2);\n  background:rgba(5,7,13,.92);color:var(--txt);font-weight:1000;\n  display:grid;place-items:center;cursor:pointer\n}\n.cpLayersBtn:hover{box-shadow:0 0 14px rgba(0,229,255,.18)}\n\n.cpLayersDrop{\n  display:none;position:absolute;left:0;bottom:52px;z-index:13000;\n  background:rgba(20,27,51,.96);border:1px solid var(--line2);\n  border-radius:14px;box-shadow:var(--shadow);padding:10px;min-width:220px\n}\n.cpLayersDrop.open{display:grid;gap:8px}\n\n\/* \u2705 ADD-ON: BikeTool panel *\/\n.btPanel{\n  position:absolute;\n  top:12px; right:12px;\n  width:min(340px, calc(100vw - 24px));\n  max-height:calc(100vh - 24px);\n  overflow:hidden;\n  border-radius:16px;\n  background:rgba(5,7,13,.94);\n  border:1px solid var(--line2);\n  box-shadow:var(--shadow);\n  backdrop-filter:blur(8px);\n  z-index:12000;\n  display:none;\n}\n.btPanel.open{display:flex;flex-direction:column}\nbody.admin-bar .btPanel{top:44px}\n.btHead{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px;border-bottom:1px solid var(--line)}\n.btTitle{font-weight:1000;color:var(--aqua);text-shadow:0 0 12px rgba(0,229,255,.25)}\n.btX{background:var(--panel);color:#fff;border:1px solid var(--line2);border-radius:12px;padding:6px 10px;font-weight:1000;cursor:pointer}\n.btBody{padding:12px;display:grid;gap:10px;overflow:auto}\n.btSep{height:1px;background:var(--line);opacity:.9;margin:6px 0}\n.btSub{font-weight:1000;color:var(--txt)}\n.btList{display:grid;gap:8px}\n.btItem{border:1px solid var(--line2);border-radius:14px;background:rgba(10,14,28,.75);padding:10px}\n.btItemTop{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}\n.btItemTitle{font-weight:1000}\n.btItemMeta{color:var(--muted);font-size:12px;margin-top:2px}\n.btItemBtns{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}\n.btItemBtns button{padding:8px 10px;border-radius:12px;font-weight:1000}\n.btPlanBox{display:grid}\n\n.cpLayRow{display:flex;align-items:center;gap:10px;color:var(--txt);font-weight:900}\n.cpLayRow input{width:18px;height:18px;accent-color:var(--aqua);filter:brightness(1.2)}\n\n\/* \u2705 FIX: Layers dentro la barra senza essere tagliato *\/\n.cpNavCtrl .leaflet-control-layers{\n  background:transparent!important;\n  border:none!important;\n  box-shadow:none!important;\n  overflow:visible!important;  \/* <-- questo evita il taglio *\/\n  padding:0!important;\n  margin:0!important;\n}\n\n\/* Il bottone layers come gli altri bottoni della barra *\/\n.cpNavCtrl .leaflet-control-layers-toggle{\n  width:44px!important;\n  height:44px!important;\n  border-radius:16px!important;\n  background-color:rgba(5,7,13,.92)!important;\n  border:1px solid var(--line2)!important;\n  box-shadow:0 0 14px rgba(0,229,255,.18)!important;\n}\n\n\/* Quando apri la lista layer, deve restare \u201cbella\u201d *\/\n.cpNavCtrl .leaflet-control-layers-expanded{\n  padding:8px!important;\n  background:rgba(20,27,51,.94)!important;\n  border:1px solid var(--line2)!important;\n  border-radius:14px!important;\n  box-shadow:var(--shadow)!important;\n}\n\n\/* \u2705 popup menu a tendina (nome -> chat\/profilo) *\/\n.pu{min-width:170px}\n.puName{\n  all:unset;\n  cursor:pointer;\n  font-weight:900;\n  display:flex;\n  align-items:center;\n  gap:6px;\n  padding:2px 0;\n}\n.puMenu{display:none;margin-top:6px;gap:6px}\n.pu.open .puMenu{display:grid}\n.puMenu button{\n  width:100%;\n  padding:8px 10px;\n  border-radius:12px;\n  border:1px solid var(--line2);\n  background:rgba(20,27,51,.96);\n  color:var(--txt);\n  font-weight:900;\n  cursor:pointer;\n}\n.puNA{\n  display:block;\n  padding:8px 10px;\n  border-radius:12px;\n  border:1px dashed var(--line2);\n  opacity:.65;\n}\n\n\/* \u2705 ADD-ON: user action menu + profile modal *\/\n.uAct{position:fixed;inset:0;z-index:21000;display:none;background:rgba(0,0,0,.45)}\n.uAct.open{display:flex;align-items:flex-end;justify-content:center}\n.uActCard{width:min(520px,calc(100vw - 20px));margin:0 10px 14px;background:rgba(20,27,51,.96);border:1px solid var(--line2);border-radius:18px;box-shadow:var(--shadow);padding:14px;position:relative}\n.uActTitle{font-weight:1000;color:var(--aqua);text-shadow:var(--glowA);margin-bottom:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.uActBtns{display:grid;grid-template-columns:1fr 1fr;gap:10px}\n.uBtnMain{background:linear-gradient(135deg,var(--aqua),var(--pink));color:#000;font-weight:1000;border-radius:16px}\n.uBtnAlt{background:var(--panel2);color:#fff;font-weight:1000;border-radius:16px;border:1px solid var(--line2)}\n.uBtnClose{position:absolute;right:10px;top:10px;width:40px;height:40px;border-radius:14px;border:1px solid var(--line2);background:var(--panel);color:#fff;font-weight:1000;font-size:18px;padding:0;display:grid;place-items:center}\n.uModal{position:fixed;inset:0;z-index:22000;display:none;background:rgba(0,0,0,.66)}\n.uModal.open{display:block}\n.uModalCard{position:absolute;inset:0;background:var(--bg);display:flex;flex-direction:column}\n.uModalHeader{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 16px;padding-top:calc(12px + env(safe-area-inset-top));border-bottom:1px solid var(--line);background:rgba(5,7,13,.96);backdrop-filter:blur(8px)}\n.uModalTitle{font-weight:1000;color:var(--aqua);text-shadow:0 0 12px rgba(0,229,255,.25);line-height:1.1}\n.uModalBody{flex:1;overflow-y:auto;padding:14px 16px;padding-bottom:calc(18px + env(safe-area-inset-bottom));-webkit-overflow-scrolling:touch}\n.uProfRow{display:grid;gap:10px}\n.uProfImg{width:110px;height:110px;border-radius:18px;border:1px solid var(--line2);object-fit:cover;background:#0b1020}\n.uProfName{font-weight:1000;font-size:18px}\n.uProfTxt{color:var(--txt);white-space:pre-wrap}\n.uProfMuted{color:var(--muted);font-size:13px}\n\n\n\/* \u2705 ADD-ON: Hide tours list (markers only) *\/\n#btToursList{display:none!important;}\n\n\/* \u2705 ADD-ON: Bike tour start marker icon *\/\n.btTourStartIcon{background:transparent;border:0;}\n.btTourStartIcon .btTourPin{\n  width:30px;height:30px;border-radius:999px;\n  display:flex;align-items:center;justify-content:center;\n  background:#ff2bd6;\n  color:#05070d;\n  font-size:16px;\n  box-shadow:0 0 0 3px rgba(255,43,214,.25), 0 6px 16px rgba(0,0,0,.5);\n  border:1px solid rgba(255,255,255,.18);\n}\n\n\n\/* \u2705 ADD-ON: Elevation profile modal *\/\n#elevModal{position:fixed;left:0;right:0;top:0;bottom:0;display:none;z-index:99999;background:transparent;align-items:flex-end;justify-content:center;padding:0;pointer-events:none}\n#elevModal.open{display:flex}\n#elevCard{width:100vw;max-width:980px;max-height:38vh;background:rgba(10,12,20,.98);border:1px solid rgba(255,43,214,.35);border-radius:16px 16px 0 0;box-shadow:0 -10px 30px rgba(0,0,0,.55);overflow:hidden;pointer-events:auto}\n#elevHandle{width:56px;height:5px;border-radius:999px;background:rgba(255,255,255,.25);margin:10px auto 6px auto}\n#elevHead{display:flex;gap:10px;align-items:center;justify-content:space-between;padding:12px 14px;border-bottom:1px solid rgba(255,255,255,.08)}\n#elevTitle{font-weight:800}\n#elevClose{cursor:pointer;border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.06);color:#fff;border-radius:999px;padding:6px 10px;font-weight:700}\n\n#elevBtns{display:flex;gap:10px;align-items:center}\n#elevDelete{cursor:pointer;border:1px solid rgba(255,59,59,.45);background:rgba(255,59,59,.12);color:#fff;border-radius:999px;padding:6px 10px;font-weight:800}\n#elevDelete:hover{background:rgba(255,59,59,.18)}\n\n#elevStart{cursor:pointer;border:1px solid rgba(70,255,180,.35);background:rgba(70,255,180,.10);color:#fff;border-radius:999px;padding:6px 10px;font-weight:800}\n#elevStart:hover{background:rgba(70,255,180,.16)}\n#elevStop{cursor:pointer;border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.06);color:#fff;border-radius:999px;padding:6px 10px;font-weight:800}\n#elevStop:hover{background:rgba(255,255,255,.10)}\n#elevLive{display:flex;gap:10px;flex-wrap:wrap;align-items:center;opacity:.92;font-size:13px;margin:6px 0 10px 0}\n#elevLive span{display:inline-block;padding:3px 8px;border-radius:999px;border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.04)}\n.bikeMeIcon{background:transparent;border:none;}\n.bikeMeWrap{width:36px;height:36px;border-radius:50%;background:rgba(255,255,255,.92);border:2px solid rgba(255,0,140,.85);display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(0,0,0,.35);}\n.bikeMeEmoji{font-size:20px;line-height:1;}\n\n\n#elevBody{padding:12px 14px}\n#elevMeta{opacity:.85;font-size:13px;margin:0 0 10px 0}\n#elevCanvasWrap{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:10px}\n#elevLoading{display:none;opacity:.85;font-size:13px;margin-top:10px}\n#elevLoading.on{display:block}\n\n\/* \u2705 OSM places icons *\/\n.osmIco{background:transparent;border:none}\n.osmIcoWrap{width:34px;height:34px;border-radius:50%;background:rgba(255,255,255,.92);border:2px solid rgba(0,229,255,.75);display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(0,0,0,.35)}\n.osmIcoEmoji{font-size:18px;line-height:1}\n\n\/* \u2705 FIX click bottone OSM in WP\/mobile *\/\n.cpOsmSearchBtn{\n  pointer-events:auto !important;\n  cursor:pointer !important;\n  position:relative;\n  z-index:999999;\n  touch-action:manipulation;\n}\n.leaflet-control,\n.leaflet-top,\n.leaflet-bottom,\n.leaflet-control-layers,\n.leaflet-control-layers *{\n  pointer-events:auto;\n}\n\n\n@media (max-width:480px){\n  .cpNavCtrl{gap:6px;padding:7px;border-radius:16px}\n  .cpNavBtn{width:42px;height:42px;border-radius:16px}\n  .cpGoBtn{height:42px;border-radius:16px}\n  .cpCityInput{height:42px;border-radius:16px;font-size:16px}\n}\n\n\/* \u2705 FIX DEFINITIVO: barra citt\u00e0 centrata + layout stabile (iOS\/Android) *\/\n.leaflet-bottom.leaflet-left{left:0;right:0;width:100%;}\n.leaflet-bottom.leaflet-left .leaflet-control{float:none;margin-left:auto;margin-right:auto;}\n#map .cpNavCtrl{\n  margin-left:auto;margin-right:auto;\n  display:grid;\n  grid-template-columns:auto 1fr auto;\n  align-items:center;\n  gap:8px;\n  justify-content:unset;\n}\n#map .cpLeftBtns{display:flex;flex-direction:row;align-items:center;gap:8px;}\n#map .cpCityWrap{display:flex;align-items:center;min-width:0;margin-right:0 !important;}\n#map .cpCityInput{width:100%;min-width:0;}\n#map .cpGoBtn.cpCityGoBtn{white-space:nowrap;}\n@media (max-width:480px){\n  #map .cpNavCtrl{gap:6px;padding:7px;}\n  #map .cpLeftBtns{gap:6px;}\n}\n\n\n\/* \u2705 FIX: city bar must stay on ONE line (mobile safe) *\/\n#map .cpNavCtrl{\n  display:flex !important;\n  flex-wrap:nowrap !important;\n  align-items:center !important;\n  justify-content:flex-start !important;\n  gap:10px !important;\n  width:100% !important;\n  box-sizing:border-box !important;\n  white-space:nowrap !important;\n}\n#map .cpLeftBtns{\n  display:flex !important;\n  flex-wrap:nowrap !important;\n  gap:10px !important;\n  align-items:center !important;\n  flex:0 0 auto !important;\n}\n#map .cpCityWrap{\n  display:flex !important;\n  flex-wrap:nowrap !important;\n  align-items:center !important;\n  flex:1 1 auto !important;\n  min-width:0 !important;\n}\n#map .cpCityInput{\n  flex:1 1 auto !important;\n  min-width:0 !important;\n  width:100% !important;\n}\n#map .cpCityGoBtn{\n  flex:0 0 auto !important;\n  min-width:86px !important;\n}\n@media (max-width:480px){\n  #map .cpNavCtrl{gap:8px !important; padding:10px !important;}\n  #map .cpLeftBtns{gap:8px !important;}\n  #map .cpCityGoBtn{min-width:78px !important;}\n  #map .cpCityInput{font-size:16px !important;}\n}\n\n\n\n\/* \u2705 FINAL FIX: bottom city bar ONE LINE, centered, no wrap, iOS\/Android safe *\/\n.leaflet-bottom.leaflet-left{\n  left:0 !important;\n  right:0 !important;\n  width:100% !important;\n}\n.leaflet-bottom.leaflet-left .leaflet-control{\n  float:none !important;\n  margin-left:auto !important;\n  margin-right:auto !important;\n}\n\n\/* bar container *\/\n#map .cpNavCtrl{\n  display:flex !important;\n  flex-wrap:nowrap !important;\n  align-items:center !important;\n  justify-content:flex-start !important;\n  gap:10px !important;\n\n  width:calc(100vw - 26px - env(safe-area-inset-left) - env(safe-area-inset-right)) !important;\n  max-width:720px !important;\n  box-sizing:border-box !important;\n  margin-left:auto !important;\n  margin-right:auto !important;\n}\n\n\/* LEFT BUTTONS must stay on one row *\/\n#map .cpLeftBtns{\n  display:flex !important;\n  flex-direction:row !important;\n  align-items:center !important;\n  gap:8px !important;\n  flex:0 0 auto !important;\n  min-width:0 !important;\n}\n\n\/* keep layers wrapper from stacking *\/\n#map .cpLayersWrap{\n  display:flex !important;\n  flex-direction:row !important;\n  align-items:center !important;\n  flex:0 0 auto !important;\n}\n\n\/* city input takes remaining space *\/\n#map .cpCityWrap{\n  display:flex !important;\n  align-items:center !important;\n  flex:1 1 auto !important;\n  min-width:0 !important;\n}\n#map .cpCityInput{\n  width:100% !important;\n  min-width:0 !important;\n  flex:1 1 auto !important;\n}\n\n\/* go button fixed size, never wraps *\/\n#map .cpCityGoBtn{\n  flex:0 0 auto !important;\n  white-space:nowrap !important;\n}\n\n\/* spot button is outside flow already; ensure it doesn't affect layout *\/\n#map .cpSpotMini{\n  position:absolute !important;\n  left:50% !important;\n  transform:translateX(-50%) !important;\n  white-space:nowrap !important;\n}\n\n\/* small screens: tighten spacing, still one line *\/\n@media (max-width:480px){\n  #map .cpNavCtrl{\n    gap:8px !important;\n    padding:8px !important;\n    width:calc(100vw - 20px - env(safe-area-inset-left) - env(safe-area-inset-right)) !important;\n  }\n  #map .cpCityGoBtn{ padding:0 12px !important; }\n}\n\n<\/style>\n\n<script src=\"https:\/\/cdn.jsdelivr.net\/npm\/chart.js@4.4.1\/dist\/chart.umd.min.js\"><\/script>\n<\/head>\n\n<body>\n<div id=\"app\">\n  <div id=\"map\"><\/div>\n\n  <!-- \u2705 ADD-ON: BikeTool Tour Planner (panel) -->\n  <div id=\"bikeToolPanel\" class=\"btPanel\" aria-hidden=\"true\">\n    <div class=\"btHead\">\n      <div class=\"btTitle\">\ud83d\udeb2 BikeTool<\/div>\n      <button id=\"btClose\" type=\"button\" class=\"btX\" title=\"Chiudi\">\u2715<\/button>\n    <\/div>\n\n    <div class=\"btBody\">\n      <button id=\"btPlanBtn\" type=\"button\" class=\"btnMain\" style=\"width:100%\">\ud83e\udded Pianifica tour sulla mappa<\/button>\n\n      <div id=\"btPlanBox\" style=\"display:none;gap:8px\" class=\"btPlanBox\">\n        <div class=\"hint\" style=\"margin:0\">Tocca\/clicca sulla mappa per aggiungere punti. (Minimo 2)<\/div>\n        <div style=\"display:flex;gap:8px\">\n          <button id=\"btSaveBtn\" type=\"button\" class=\"btnMain\" style=\"flex:1\">Salva<\/button>\n          <button id=\"btCancelBtn\" type=\"button\" class=\"btnAlt\">Annulla<\/button>\n        <\/div>\n      <\/div>\n\n      <div class=\"btSep\"><\/div>\n      <div class=\"btSub\">Tour pubblici<\/div>\n      <div id=\"btToursList\" class=\"btList\"><\/div>\n    <\/div>\n  <\/div>\n\n\n  <div id=\"uiPanel\">\n    <div id=\"chatWrap\">\n      <div id=\"authBox\">\n        <div id=\"topBar\">\n          <div id=\"title\">CamperPulse<\/div>\n          <div id=\"langWrap\" aria-label=\"Lingua\">\n            <button id=\"langBtn\" type=\"button\" title=\"Lingua\">\ud83c\uddee\ud83c\uddf9<\/button>\n            <div id=\"langDrop\" aria-hidden=\"true\">\n              <button class=\"langOpt\" id=\"lang_it\" type=\"button\" title=\"Italiano\">\ud83c\uddee\ud83c\uddf9<\/button>\n              <button class=\"langOpt\" id=\"lang_en\" type=\"button\" title=\"English\">\ud83c\uddec\ud83c\udde7<\/button>\n              <button class=\"langOpt\" id=\"lang_de\" type=\"button\" title=\"Deutsch\">\ud83c\udde9\ud83c\uddea<\/button>\n              <button class=\"langOpt\" id=\"lang_es\" type=\"button\" title=\"Espa\u00f1ol\">\ud83c\uddea\ud83c\uddf8<\/button>\n              <button class=\"langOpt\" id=\"lang_fr\" type=\"button\" title=\"Fran\u00e7ais\">\ud83c\uddeb\ud83c\uddf7<\/button>\n            <\/div>\n          <\/div>\n        <\/div>\n\n        <div id=\"authForm\">\n          <div id=\"nickRow\"><input id=\"nickname\" placeholder=\"Nick name (quello che vedranno gli utenti)\"><\/div>\n          <div id=\"nickDisplayRow\" style=\"display:none;align-items:center;gap:10px;margin-bottom:8px\">\n            <div style=\"flex:1;color:#cfe6ff\"><span id=\"nickLabel\">Nick:<\/span> <b id=\"nickDisplay\" style=\"color:var(--aqua)\"><\/b><\/div>\n            <button type=\"button\" class=\"btnAlt\" id=\"changeNickBtn\">Cambia<\/button>\n          <\/div>\n          <input id=\"email\" placeholder=\"Email\" autocomplete=\"username\">\n          <input id=\"password\" type=\"password\" placeholder=\"Password\" autocomplete=\"current-password\">\n          <div style=\"display:flex;gap:8px;margin-top:8px\">\n            <button class=\"btnMain\" id=\"loginBtn\" type=\"button\">Login<\/button>\n            <button class=\"btnAlt\" id=\"signupBtn\" type=\"button\">Registrati<\/button>\n          <\/div>\n        <\/div>\n\n        <div id=\"userInfo\" style=\"display:none\">\n          <button id=\"menuBtn\" type=\"button\">MEN\u00d9 \u25be<\/button>\n        <\/div>\n      <\/div>\n\n      <div id=\"chatBody\">\n        <div id=\"chatList\"><\/div>\n        <div id=\"chat\">\n          <div id=\"chatHeader\">Seleziona una chat<\/div>\n          <div id=\"messages\"><\/div>\n          <div id=\"composer\">\n            <div id=\"composerRow\">\n              <input id=\"msgInput\" placeholder=\"Scrivi un messaggio\" disabled>\n              <button id=\"sendBtn\" type=\"button\" disabled>${esc(lblSend)}<\/button>\n            <\/div>\n          <\/div>\n        <\/div>\n      <\/div>\n\n    <\/div>\n  <\/div>\n<\/div>\n\n<div id=\"menuDrop\" aria-hidden=\"true\">\n  <div class=\"menuMe\"><span id=\"userInfoLabel\">Loggato come<\/span> <b id=\"userEmail\"><\/b><\/div>\n  <div class=\"menuRow\">\n    <button id=\"radarBtn\" class=\"btnDark\" type=\"button\">\ud83d\udfe2 Radar ON<\/button>\n    <button id=\"notifBtn\" class=\"btnDark\" type=\"button\">\ud83d\udd14 Notifiche OFF<\/button>\n  <\/div>\n  <div class=\"menuRow\">\n    <button id=\"refreshBtn\" class=\"btnDark\" type=\"button\">\ud83d\udd04 Refresh<\/button>\n    <button id=\"logoutBtn\" class=\"btnDark\" type=\"button\">Logout<\/button>\n  <\/div>\n  <div class=\"menuRow\">\n    <button id=\"rvToggle\" class=\"btnDark\" type=\"button\">\ud83d\udccd Ritrovi<\/button>\n    <button id=\"spotToggle\" class=\"btnDark\" type=\"button\">\ud83d\uddfa\ufe0f Spot<\/button>\n    <button id=\"chatToggle\" class=\"btnDark\" type=\"button\">\ud83d\udcac Chat<\/button>\n    <button id=\"profileToggle\" class=\"btnDark\" type=\"button\">\ud83d\udc64 Profilo<\/button>\n\n  <\/div>\n<\/div>\n\n<div id=\"rvListModal\" class=\"modal\" aria-hidden=\"true\" style=\"z-index:20500\">\n  <div class=\"modalCard\" role=\"dialog\" aria-modal=\"true\">\n    <div class=\"modalHeader\">\n      <div id=\"rvListTitle\" class=\"modalTitle\">\ud83d\udccd Ritrovi camperisti<\/div>\n      <button id=\"rvListCloseBtn\" class=\"modalClose\" type=\"button\">\u2715<\/button>\n    <\/div>\n    <div id=\"rvListModalBody\"><div id=\"rvList\"><\/div><\/div>\n    <div id=\"rvListFooter\"><button id=\"rvAddBtn\" type=\"button\">Aggiungi un ritrovo<\/button><\/div>\n  <\/div>\n<\/div>\n\n<div id=\"rvFormModal\" class=\"modal\" aria-hidden=\"true\" style=\"z-index:20600\">\n  <div class=\"modalCard\" role=\"dialog\" aria-modal=\"true\">\n    <div class=\"modalHeader\">\n      <div id=\"rvFormTitle\" class=\"modalTitle\">Aggiungi ritrovo<\/div>\n      <button id=\"rvFormCloseBtn\" class=\"modalClose\" type=\"button\">\u2715<\/button>\n    <\/div>\n    <div id=\"rvFormModalBody\">\n      <div id=\"rvForm\" style=\"display:grid;gap:8px\">\n        <div style=\"display:grid;grid-template-columns:1fr 1fr;gap:8px\">\n          <div>\n            <input id=\"rvAddress\" placeholder=\"Indirizzo esatto (clicca su mappa)\" readonly>\n            <div class=\"hint\" id=\"rvHint\">Premi \u201cSeleziona da mappa\u201d, poi clicca un punto sulla mappa.<\/div>\n          <\/div>\n          <div>\n            <input id=\"rvContact\" placeholder=\"Contatto (mail o telefono)\">\n            <button id=\"rvPickBtn\" class=\"btnDark\" type=\"button\" style=\"margin-top:8px;width:100%\">\ud83d\udccc Seleziona da mappa<\/button>\n          <\/div>\n        <\/div>\n        <textarea id=\"rvText\" maxlength=\"200\" placeholder=\"Messaggio (max 200 caratteri)\"><\/textarea>\n        <div style=\"display:grid;grid-template-columns:1fr 1fr;gap:8px\">\n          <input id=\"rvStart\" type=\"datetime-local\">\n          <input id=\"rvEnd\" type=\"datetime-local\">\n        <\/div>\n        <div class=\"hint\" id=\"rvDalAlHint\" style=\"margin-top:-6px\">\n          Data ritrovo: <b>DAL<\/b> e <b>AL<\/b> (obbligatorio). Dopo 24 ore dalla fine viene cancellato automaticamente.\n        <\/div>\n        <div style=\"display:flex;gap:8px\">\n          <button class=\"btnMain\" id=\"rvPublishBtn\" type=\"button\" style=\"flex:1\">Pubblica<\/button>\n          <button id=\"rvCancelEditBtn\" class=\"btnAlt\" type=\"button\" style=\"display:none\">Annulla<\/button>\n        <\/div>\n      <\/div>\n    <\/div>\n  <\/div>\n<\/div>\n\n<div id=\"spotPanel\" aria-hidden=\"true\">\n  <div id=\"spotHeader\">\n    <div id=\"spotHeaderTitle\">Spot per la notte<\/div>\n    <button id=\"spotClose\" type=\"button\">Chiudi<\/button>\n  <\/div>\n\n  <div id=\"spotBody\">\n    <div id=\"spotTypeRow\">\n      <button id=\"spType_camping\" class=\"spTypeBtn active\" type=\"button\">\n        <div class=\"spTypeEmoji\">\ud83c\udfd5\ufe0f<\/div><div class=\"spTypeLabel\" id=\"spTypeCampingLbl\">Camping \/ Area sosta<\/div>\n      <\/button>\n      <button id=\"spType_free\" class=\"spTypeBtn\" type=\"button\">\n        <div class=\"spTypeEmoji\">\ud83c\udd7f\ufe0f<\/div><div class=\"spTypeLabel\" id=\"spTypeFreeLbl\">Parcheggio in libera<\/div>\n      <\/button>\n    <\/div>\n\n    <div>\n      <input id=\"spotName\" maxlength=\"50\" placeholder=\"Nome spot (es. Parking Lago \/ Area Sosta XYZ)\">\n      <div class=\"hint\" id=\"spotNameHint\" style=\"margin-top:6px\">Nome facoltativo (max 50 caratteri). Comparir\u00e0 nella card e nella scheda spot.<\/div>\n    <\/div>\n\n    <div>\n      <input id=\"spotAddress\" placeholder=\"Indirizzo spot (clicca su mappa)\" readonly>\n      <button id=\"spotPickBtn\" class=\"btnDark\" type=\"button\">\ud83d\udccc Seleziona da mappa<\/button>\n      <div id=\"spotHint\" class=\"hint\" style=\"margin-top:6px\">Premi \u201cSeleziona da mappa\u201d, poi clicca un punto sulla mappa.<\/div>\n    <\/div>\n\n    <div>\n      <input id=\"spotContact\" maxlength=\"120\" placeholder=\"Contatto (mail \/ telefono \/ sito web)\">\n      <div class=\"hint\" id=\"spotContactHint\" style=\"margin-top:6px\">Facoltativo. Es: mail, telefono o link.<\/div>\n    <\/div>\n\n    <div>\n      <input id=\"spotPrice\" maxlength=\"30\" placeholder=\"Costo a notte (es. 12\u20ac \/ 10 \/ gratuito)\">\n      <div class=\"hint\" id=\"spotPriceHint\" style=\"margin-top:6px\">Facoltativo. Puoi scrivere anche \u201cgratuito\u201d.<\/div>\n    <\/div>\n\n    <div>\n      <textarea id=\"spotDesc\" maxlength=\"100\" placeholder=\"Descrizione (max 100 caratteri)\"><\/textarea>\n      <div class=\"hint\" id=\"spotDescHint\" style=\"margin-top:6px\">Max 100 caratteri.<\/div>\n    <\/div>\n\n    <div>\n      <div class=\"hint\" id=\"amenTitle\" style=\"margin-bottom:6px;font-weight:1000\">Servizi disponibili (tocca le icone)<\/div>\n      <div class=\"amenRow\" id=\"amenRow\">\n        <button type=\"button\" class=\"amenBtn\" id=\"amen_power\" title=\"Corrente\">\u26a1<\/button>\n        <button type=\"button\" class=\"amenBtn\" id=\"amen_water\" title=\"Acqua\">\ud83d\udeb0<\/button>\n        <button type=\"button\" class=\"amenBtn\" id=\"amen_dump\" title=\"Scarico\">\u267b\ufe0f<\/button>\n        <button type=\"button\" class=\"amenBtn\" id=\"amen_toilet\" title=\"Bagno\">\ud83d\udebb<\/button>\n        <button type=\"button\" class=\"amenBtn\" id=\"amen_pets\" title=\"Animali ammessi\">\ud83d\udc3e<\/button>\n      <\/div>\n      <div class=\"hint\" id=\"amenHint\" style=\"margin-top:6px\">Queste icone compariranno sulla card e nella scheda spot.<\/div>\n    <\/div>\n\n    <div id=\"spotPhotoRow\">\n      <input id=\"spotPhoto\" type=\"file\" accept=\"image\/*\">\n      <img id=\"spotPreview\" alt=\"Anteprima foto spot\">\n      <div class=\"hint\" id=\"spotPhotoHint\">Foto max 300\u00d7300 (se pi\u00f9 grande la ridimensiono io).<\/div>\n    <\/div>\n\n    <div id=\"spotActions\">\n      <button class=\"btnMain\" id=\"spotPublishBtn\" type=\"button\" style=\"flex:1\">Aggiungi Luogo<\/button>\n      <button id=\"spotCancelEditBtn\" class=\"btnAlt\" type=\"button\">Annulla<\/button>\n    <\/div>\n  <\/div>\n\n  <div id=\"spotList\"><\/div>\n<\/div>\n\n<div id=\"spotModal\" class=\"modal\" aria-hidden=\"true\">\n  <div id=\"spotModalCard\" class=\"modalCard\" role=\"dialog\" aria-modal=\"true\">\n    <div id=\"spotModalHeader\" class=\"modalHeader\">\n      <div id=\"spotModalTitle\" class=\"modalTitle\">Spot<\/div>\n      <button id=\"spotModalCloseBtn\" class=\"modalClose\" type=\"button\">\u2715<\/button>\n    <\/div>\n    <div id=\"spotModalBody\"><\/div>\n  <\/div>\n<\/div>\n\n<div id=\"chatModal\" class=\"modal\" aria-hidden=\"true\" style=\"z-index:21000\">\n  <div id=\"chatModalCard\" class=\"modalCard\" role=\"dialog\" aria-modal=\"true\">\n    <div id=\"chatModalHeader\" class=\"modalHeader\">\n      <div id=\"chatModalTitle\" class=\"modalTitle\">Chat<\/div>\n      <button id=\"chatModalCloseBtn\" class=\"modalClose\" type=\"button\">\u2715<\/button>\n    <\/div>\n    <div id=\"chatModalBody\"><\/div>\n  <\/div>\n<\/div>\n\n<div id=\"profileModal\" class=\"modal\" aria-hidden=\"true\" style=\"z-index:22000\">\n  <div class=\"modalCard\" role=\"dialog\" aria-modal=\"true\">\n    <div class=\"modalHeader\">\n      <div id=\"profileModalTitle\" class=\"modalTitle\">\ud83d\udc64 Profilo<\/div>\n      <button id=\"profileModalCloseBtn\" class=\"modalClose\" type=\"button\">\u2715<\/button>\n    <\/div>\n\n    <div id=\"profileModalBody\" style=\"flex:1;overflow-y:auto;padding:14px 16px;padding-bottom:calc(18px + env(safe-area-inset-bottom));-webkit-overflow-scrolling:touch\">\n      <div style=\"display:grid;gap:10px\">\n\n        <div id=\"profPhotoHint\" class=\"hint\">Foto profilo (max 300\u00d7300, se pi\u00f9 grande la ridimensiono io)<\/div>\n        <input id=\"profPhoto\" type=\"file\" accept=\"image\/*\">\n        <img id=\"profPreview\" alt=\"Anteprima profilo\" style=\"width:100%;max-width:300px;height:auto;border-radius:12px;border:1px solid var(--line2);display:none\">\n\n        <div>\n          <div id=\"profNickHint\" class=\"hint\">Nickname<\/div>\n          <input id=\"profNick\" readonly>\n        <\/div>\n\n        <div>\n          <div id=\"profBioHint\" class=\"hint\">Presentazione (max 300 caratteri)<\/div>\n          <textarea id=\"profBio\" maxlength=\"300\" placeholder=\"Racconta due cose su di te...\"><\/textarea>\n        <\/div>\n\n        <div>\n          <div id=\"profHobbyHint\" class=\"hint\" style=\"font-weight:1000\">Hobby (tocca le icone)<\/div>\n          <div style=\"display:flex;gap:8px;flex-wrap:wrap\">\n            <button type=\"button\" class=\"amenBtn\" id=\"hob_walk\" title=\"Camminate\">\ud83e\udd7e<\/button>\n            <button type=\"button\" class=\"amenBtn\" id=\"hob_bike\" title=\"Bicicletta\">\ud83d\udeb4<\/button>\n            <button type=\"button\" class=\"amenBtn\" id=\"hob_kayak\" title=\"Kayak \/ Windsurf\">\ud83d\udef6<\/button>\n            <button type=\"button\" class=\"amenBtn\" id=\"hob_climb\" title=\"Arrampicata\">\ud83e\uddd7<\/button>\n            <button type=\"button\" class=\"amenBtn\" id=\"hob_fish\" title=\"Pesca\">\ud83c\udfa3<\/button>\n            <button type=\"button\" class=\"amenBtn\" id=\"hob_yoga\" title=\"Yoga\">\ud83e\uddd8<\/button>\n          <\/div>\n        <\/div>\n\n        <div style=\"display:flex;gap:8px\">\n          <button id=\"profSaveBtn\" class=\"btnMain\" type=\"button\" style=\"flex:1\">Salva<\/button>\n          <button id=\"profResetBtn\" class=\"btnAlt\" type=\"button\">Reset<\/button>\n        <\/div>\n\n      <\/div>\n    <\/div>\n  <\/div>\n<\/div>\n\n\n<script>\naddEventListener(\"error\",(e)=>console.error(\"JS ERROR:\",e?.message,e?.error));\naddEventListener(\"unhandledrejection\",(e)=>console.error(\"PROMISE ERROR:\",e?.reason));\n\nconst $=(id)=>document.getElementById(id);\n\n\/\/ \u2705 ADD-ON: Thunderforest Bike tiles\nlet bikeTile = null;\n\nfunction initBikeTile(){\n  if(bikeTile) return;\n  bikeTile = L.tileLayer(\n    \"https:\/\/{s}.tile.thunderforest.com\/cycle\/{z}\/{x}\/{y}.png?apikey=0d8190ca21e448bd98aac4ab20cab440\",\n    { subdomains:\"abc\", maxZoom:22, attribution:\"\u00a9 OpenStreetMap, Thunderforest\" }\n  );\n}\n\n\/* \u2705 ADD-ON: BikeTool Tour Planner (Supabase)\n   - quando attivi \"\ud83d\udeb2 BikeTool\" appare un pannello con \"Pianifica tour\"\n   - clic sulla mappa per aggiungere punti, poi \"Salva\"\n   - i tour vengono salvati su Supabase e visti da tutti\n   \u26a0\ufe0f Richiede tabella Supabase:\n     table: bike_tours\n     columns:\n       id uuid primary key default gen_random_uuid()\n       created_at timestamptz default now()\n       user_id uuid\n       username text\n       title text\n       path jsonb   -- array di punti [{lat:...,lng:...},...]\n*\/\nconst BIKE_TOURS_TABLE=\"bike_tours\";\n\nlet bikeToolOn=false;\nlet bikeToursLayer=null;      \/\/ layerGroup con polylines\nlet bikePlanOn=false;\nlet bikePlanPts=[];\nlet bikePlanLine=null;\n\n\/\/ \u2705 ADD-ON: Routing su strada (OpenRouteService - cycling)\n\/\/ Nota: per GeoJSON la forma \u00e8 [lng,lat]. Qui lavoriamo in [lat,lng] per Leaflet.\nconst ORS_API_KEY = \"eyJvcmciOiI1YjNjZTM1OTc4NTExMTAwMDFjZjYyNDgiLCJpZCI6IjNkYzJkYTZkMzlkOTQ0N2VhZjIzMGNkZWZhYTI1ZWEyIiwiaCI6Im11cm11cjY0In0=\";\nconst ORS_PROFILE = \"cycling-regular\";\nconst ORS_ENDPOINT = \"https:\/\/api.openrouteservice.org\/v2\/directions\/\" + ORS_PROFILE + \"\/geojson\";\n\nlet bikePlanRoutePts=[];   \/\/ traccia finale (segue la strada): [[lat,lng],...]\nlet btRoutingBusy=false;\nlet btRoutedWpIdx=0; \/\/ quanti waypoint sono gi\u00e0 \"agganciati\" alla traccia\nlet btProcessing=false;\nasync function routeSegmentORS(fromLatLng, toLatLng){\n  try{\n    if(!ORS_API_KEY) throw new Error(\"ORS key mancante\");\n\n    const body = {\n      coordinates: [\n        [fromLatLng.lng, fromLatLng.lat],\n        [toLatLng.lng, toLatLng.lat]\n      ]\n    };\n\n    const res = await fetch(ORS_ENDPOINT, {\n      method: \"POST\",\n      headers: {\n        \"Authorization\": ORS_API_KEY,\n        \"Content-Type\": \"application\/json\"\n      },\n      body: JSON.stringify(body)\n    });\n\n    if(!res.ok) throw new Error(\"ORS HTTP \" + res.status);\n\n    const gj = await res.json();\n    var coords = null;\n    if(gj){\n      if(gj.features){\n        if(gj.features[0]){\n          if(gj.features[0].geometry){\n            coords = gj.features[0].geometry.coordinates;\n          }\n        }\n      }\n    }\n    if(!coords || !coords.length) throw new Error(\"ORS geometry mancante\");\n\n    const latlngs = coords.map(c => [c[1], c[0]]); \/\/ [lng,lat] -> [lat,lng]\n    return latlngs;\n  }catch(e){\n    console.warn(\"routeSegmentORS fail:\", e);\n    \/\/ fallback: segmento dritto\n    return [[fromLatLng.lat, fromLatLng.lng],[toLatLng.lat, toLatLng.lng]];\n  }\n}\n\n\n\/\/ \u2705 ADD-ON: calcola lunghezza percorso (km) da punti Leaflet [[lat,lng],...]\nfunction calcPtsKm(pts){\n  try{\n    if(!pts) return 0;\n    if(pts.length < 2) return 0;\n    var total = 0;\n    for(var i=1;i<pts.length;i++){\n      var a = L.latLng(pts[i-1][0], pts[i-1][1]);\n      var b = L.latLng(pts[i][0], pts[i][1]);\n      total += a.distanceTo(b);\n    }\n    return Math.round((total\/1000)*10)\/10; \/\/ 1 decimale\n  }catch(e){\n    return 0;\n  }\n}\n\n\n\/* \u2705 ADD-ON: Elevation profile (routing) *\/\nvar _elevChart = null;\nvar _elevTourId = null;\n\nvar _elevChartLabels = [];\nvar _elevChartElev = [];\nvar _elevCursorIdx = -1;\n\nvar _tourFollowPts = null;\nvar _tourFollowCumM = null;\nvar _tourFollowTotalM = 0;\n\nvar _tourGpsWatchId = null;\nvar _tourGpsMarker = null;\nvar _tourGpsLastTs = null;\nvar _tourGpsLastLatLng = null;\nvar _tourFollowOn = false;\n\nfunction _samplePtsLatLng(pts, maxN){\n  try{\n    if(!pts) return [];\n    if(pts.length <= maxN) return pts.slice();\n    var out = [];\n    var step = Math.ceil(pts.length \/ maxN);\n    for(var i=0;i<pts.length;i+=step){\n      out.push(pts[i]);\n    }\n    \/\/ assicura ultimo punto\n    var last = pts[pts.length-1];\n    var lastOut = out[out.length-1];\n    if(!lastOut || lastOut[0]!==last[0] || lastOut[1]!==last[1]) out.push(last);\n    return out;\n  }catch(e){\n    return pts || [];\n  }\n}\n\nasync function _fetchElevationsOpenTopo(pts){\n  var elevations = [];\n\n  try{\n    if(!pts) return elevations;\n    if(!Array.isArray(pts)) return elevations;\n    if(pts.length < 2) return elevations;\n\n    \/\/ campionamento per non avere una lista infinita\n    var maxPoints = 240;\n    var step = 1;\n    if(pts.length > maxPoints) step = Math.ceil(pts.length \/ maxPoints);\n\n    var lats = [];\n    var lngs = [];\n\n    for(var i=0; i<pts.length; i+=step){\n      var p = pts[i];\n      if(!p) continue;\n      if(p.length < 2) continue;\n\n      var lat = p[0];\n      var lng = p[1];\n\n      if(typeof lat !== \"number\") lat = parseFloat(lat);\n      if(typeof lng !== \"number\") lng = parseFloat(lng);\n\n      if(isNaN(lat) || isNaN(lng)) continue;\n\n      lats.push(lat);\n      lngs.push(lng);\n    }\n\n    \/\/ includi sempre l\u2019ultimo punto\n    var last = pts[pts.length - 1];\n    if(last){\n      if(last.length >= 2){\n        var latL = last[0], lngL = last[1];\n        if(typeof latL !== \"number\") latL = parseFloat(latL);\n        if(typeof lngL !== \"number\") lngL = parseFloat(lngL);\n\n        if((!isNaN(latL)) * (!isNaN(lngL))){\n          var needPush = true;\n          if(lats.length){\n            var lastLat = lats[lats.length - 1];\n            var lastLng = lngs[lngs.length - 1];\n            if((lastLat === latL) * (lastLng === lngL)) needPush = false;\n          }\n          if(needPush){\n            lats.push(latL);\n            lngs.push(lngL);\n          }\n        }\n      }\n    }\n\n    if(lats.length < 2) return elevations;\n    if(lats.length !== lngs.length) throw new Error(\"Elevation: lat\/lng mismatch\");\n\n    \/\/ batch per evitare 400\n    var BATCH = 40;\n    var AMP = String.fromCharCode(38);\n\n    for(var s=0; s<lats.length; s+=BATCH){\n      var subLat = lats.slice(s, s + BATCH);\n      var subLng = lngs.slice(s, s + BATCH);\n\n      if(subLat.length !== subLng.length) throw new Error(\"Elevation: batch mismatch\");\n\n      var latStr = subLat.join(\",\");\n      var lngStr = subLng.join(\",\");\n\n      var url = \"https:\/\/api.open-meteo.com\/v1\/elevation?latitude=\" + encodeURIComponent(latStr)\n              + AMP + \"longitude=\" + encodeURIComponent(lngStr);\n\n      var res = await fetch(url);\n      if(!res) throw new Error(\"Elevation fetch: no response\");\n      if(!res.ok) throw new Error(\"Elevation HTTP \" + res.status);\n\n      var js = await res.json();\n      if(!js) throw new Error(\"Elevation: json vuoto\");\n\n      var arr = null;\n      if(Array.isArray(js.elevation)) arr = js.elevation;\n      if(!arr){\n        if(Array.isArray(js.elevations)) arr = js.elevations;\n      }\n      if(!arr) throw new Error(\"Elevation: array mancante\");\n\n      for(var k=0; k<arr.length; k++){\n        var v = arr[k];\n        if(typeof v !== \"number\") v = 0;\n        elevations.push(v);\n      }\n    }\n\n    \/\/ normalizza lunghezza\n    if(elevations.length < lats.length){\n      while(elevations.length < lats.length) elevations.push(0);\n    }else if(elevations.length > lats.length){\n      elevations = elevations.slice(0, lats.length);\n    }\n\n    return elevations;\n\n  }catch(e){\n    console.warn(\"_fetchElevationsOpenTopo:\", e);\n\n    var n = 0;\n    if(pts){\n      if(Array.isArray(pts)) n = pts.length;\n    }\n    for (var j = 0; j < n; j++) elevations.push(0);\n    return elevations;\n  }\n}\n\n\nfunction _buildElevProfile(pts, elevs){\n  \/\/ ritorna {distKm:[], elev:[], gainM, lossM}\n  var distKm = [];\n  var elev = [];\n  var gain = 0;\n  var loss = 0;\n\n  if(!pts || !elevs || pts.length!==elevs.length) return {distKm:[], elev:[], gainM:0, lossM:0};\n\n  var cum = 0;\n  distKm.push(0);\n  elev.push(elevs[0] || 0);\n\n  for(var i=1;i<pts.length;i++){\n    var a = L.latLng(pts[i-1][0], pts[i-1][1]);\n    var b = L.latLng(pts[i][0], pts[i][1]);\n    cum += a.distanceTo(b);\n    distKm.push(Math.round((cum\/1000)*10)\/10);\n\n    var ePrev = (typeof elevs[i-1]===\"number\") ? elevs[i-1] : 0;\n    var eNow  = (typeof elevs[i]===\"number\") ? elevs[i] : 0;\n    elev.push(eNow);\n\n    var d = eNow - ePrev;\n    if(d > 0) gain += d;\n    if(d < 0) loss += (-d);\n  }\n\n  return {\n    distKm: distKm,\n    elev: elev,\n    gainM: Math.round(gain),\n    lossM: Math.round(loss)\n  };\n}\n\nfunction openElevModal(){\n  var m = document.getElementById(\"elevModal\");\n  if(m) m.classList.add(\"open\");\n}\nfunction closeElevModal(){\n  var m = document.getElementById(\"elevModal\");\n  if(m) m.classList.remove(\"open\");\n}\n\nfunction _setElevLoading(on){\n  var el = document.getElementById(\"elevLoading\");\n  if(!el) return;\n  if(on) el.classList.add(\"on\"); else el.classList.remove(\"on\");\n}\n\nfunction renderElevChart(title, meta, distKm, elevArr){\n  try{\n    var trL = t();\n    var defTitle = \"Profilo dislivello\";\n    var lblAlt = \"Altitudine (m)\";\n    var lblYou = \"Tu\";\n    var lblDist = \"Distanza (km)\";\n    if(trL){\n      if(trL.elevTitle) defTitle = trL.elevTitle;\n      if(trL.elevAlt) lblAlt = trL.elevAlt;\n      if(trL.elevYou) lblYou = trL.elevYou;\n      if(trL.elevDist) lblDist = trL.elevDist;\n    }\n    var t = document.getElementById(\"elevTitle\");\n    var m = document.getElementById(\"elevMeta\");\n    if(t) t.textContent = title || defTitle;\n    if(m) m.innerHTML = meta || \"\";\n\n    \n    _elevChartLabels = distKm || [];\n    _elevChartElev = elevArr || [];\n    _elevCursorIdx = -1;\nvar canvas = document.getElementById(\"elevChart\");\n    if(!canvas) return;\n\n    if(_elevChart){\n      try{ _elevChart.destroy(); }catch(e){}\n      _elevChart = null;\n    }\n\n    _elevChart = new Chart(canvas, {\n      type: \"line\",\n      data: {\n        labels: distKm,\n        datasets: [{\n          label: lblAlt,\n          data: elevArr,\n          borderColor: \"#ff2bd6\",\n          borderWidth: 2,\n          pointRadius: 0,\n          tension: 0.2\n        },{\n          label: lblYou,\n          data: [],\n          borderColor: \"rgba(0,0,0,0)\",\n          borderWidth: 0,\n          pointRadius: 6,\n          pointHoverRadius: 7,\n          showLine: false\n        }]\n      },\n      options: {\n        responsive: true,\n        animation: false,\n        maintainAspectRatio: false,\n        plugins: {\n          legend: { display: false },\n          tooltip: { enabled: true }\n        },\n        scales: {\n          x: { title: { display: true, text: lblDist } },\n          y: { title: { display: true, text: lblAlt } }\n        }\n      }\n    });\n  }catch(e){\n    console.warn(\"renderElevChart:\", e);\n  }\n}\n\nasync function showTourElevation(tour, pts){\n  try{\n    if(!pts || pts.length < 2) return;\n\n    openElevModal();\n\n    \/\/ id tour (serve per bottone Cancella)\n    var _tid = null;\n    if(tour){\n      if(tour.id) _tid = tour.id;\n      else if(tour.tour_id) _tid = tour.tour_id;\n    }\n    _elevTourId = _tid;\n\n    var delBtn = document.getElementById(\"elevDelete\");\n    if(delBtn){\n      delBtn.style.display = \"inline-block\";\n      if(_elevTourId) delBtn.textContent = \"Cancella tour\";\n      else delBtn.textContent = \"Azzera tour\";\n    }\n\n    _setElevLoading(true);\n\n    var sampled = _samplePtsLatLng(pts, 220);\n    var elevs = await _fetchElevationsOpenTopo(sampled);\n    var prof = _buildElevProfile(sampled, elevs);\n\n    var km = 0;\nif (prof) {\n  if (prof.distKm) {\n    if (prof.distKm.length) {\n      km = prof.distKm[prof.distKm.length - 1];\n    }\n  }\n}\n\n    var trE = t();\n    var lblLen = \"Lunghezza\";\n    var lblGain = \"Salita\";\n    var lblLoss = \"Discesa\";\n    if(trE){\n      if(trE.elevLen) lblLen = trE.elevLen;\n      if(trE.elevGain) lblGain = trE.elevGain;\n      if(trE.elevLoss) lblLoss = trE.elevLoss;\n    }\n\n    var meta = \"<b>\" + lblLen + \":<\/b> \" + km + \" km\"\n             + \" &nbsp; | &nbsp; <b>\" + lblGain + \":<\/b> \" + prof.gainM + \" m\"\n             + \" &nbsp; | &nbsp; <b>\" + lblLoss + \":<\/b> \" + prof.lossM + \" m\";\n\n        var _ttl = \"Tour\";\n    if(tour){\n      if(tour.title) _ttl = tour.title;\n    }\n    renderElevChart(_ttl, meta, prof.distKm, prof.elev);\n    \/\/ prepara follow GPS sul percorso mostrato\n    _tourStopFollow(true);\n\n    _tourFollowPts = pts;\n    _tourFollowCumM = _tourComputeCum(_tourFollowPts);\n    _tourFollowTotalM = 0;\n    if(_tourFollowCumM){\n      if(_tourFollowCumM.length){\n        _tourFollowTotalM = _tourFollowCumM[_tourFollowCumM.length - 1];\n      }\n    }\n\n    var live = document.getElementById(\"elevLive\");\n    if(live) live.style.display = \"none\";\n    _tourLiveSet(0, 0, 0);\n\n    var bStart = document.getElementById(\"elevStart\");\n    var bStop = document.getElementById(\"elevStop\");\n    if(bStart) bStart.style.display = \"inline-block\";\n    if(bStop) bStop.style.display = \"none\";\n\n  }finally{\n    _setElevLoading(false);\n  }\n}\n\n\/\/ bind chiusura modal + delete\nfunction _tourLiveSet(km, altM, speedKmh){\n  var p = document.getElementById(\"elevLiveProg\");\n  var a = document.getElementById(\"elevLiveAlt\");\n  var s = document.getElementById(\"elevLiveSpeed\");\n\n  var kmTxt = \"0.0 km\";\n  var altTxt = \"0 m\";\n  var spTxt = \"0 km\/h\";\n\n  if(typeof km === \"number\"){\n    if(!isNaN(km)){\n      kmTxt = (Math.round(km * 10) \/ 10).toFixed(1) + \" km\";\n    }\n  }\n  if(typeof altM === \"number\"){\n    if(!isNaN(altM)){\n      altTxt = Math.round(altM) + \" m\";\n    }\n  }\n  if(typeof speedKmh === \"number\"){\n    if(!isNaN(speedKmh)){\n      spTxt = Math.round(speedKmh) + \" km\/h\";\n    }\n  }\n\n  if(p) p.textContent = kmTxt;\n  if(a) a.textContent = altTxt;\n  if(s) s.textContent = spTxt;\n}\n\nfunction _tourComputeCum(pts){\n  var cum = [];\n  try{\n    if(!pts) return cum;\n    if(!Array.isArray(pts)) return cum;\n    if(pts.length < 1) return cum;\n\n    var total = 0;\n    cum.push(0);\n\n    for(var i=1; i<pts.length; i++){\n      var p0 = pts[i-1];\n      var p1 = pts[i];\n\n      if(!p0) { cum.push(total); continue; }\n      if(!p1) { cum.push(total); continue; }\n      if(p0.length < 2) { cum.push(total); continue; }\n      if(p1.length < 2) { cum.push(total); continue; }\n\n      var a = L.latLng(p0[0], p0[1]);\n      var b = L.latLng(p1[0], p1[1]);\n\n      total += a.distanceTo(b);\n      cum.push(total);\n    }\n    return cum;\n  }catch(e){\n    console.warn(\"_tourComputeCum:\", e);\n    return cum;\n  }\n}\n\nfunction _tourGetElevAtKm(km){\n  try{\n    var labels = _elevChartLabels;\n    var elevs = _elevChartElev;\n\n    if(!labels) return 0;\n    if(!elevs) return 0;\n    if(!labels.length) return 0;\n    if(labels.length !== elevs.length) return 0;\n\n    var best = 0;\n    var bestD = 1e18;\n\n    for(var i=0; i<labels.length; i++){\n      var x = labels[i];\n      if(typeof x !== \"number\") x = parseFloat(x);\n      if(isNaN(x)) continue;\n\n      var d = Math.abs(x - km);\n      if(d < bestD){\n        bestD = d;\n        best = i;\n      }\n    }\n    var v = elevs[best];\n    if(typeof v !== \"number\") v = parseFloat(v);\n    if(isNaN(v)) v = 0;\n    return v;\n  }catch(e){\n    console.warn(\"_tourGetElevAtKm:\", e);\n    return 0;\n  }\n}\n\nfunction _elevSetCursorAtKm(km, elevM){\n  try{\n    if(!_elevChart) return;\n\n    var labels = _elevChartLabels;\n    if(!labels) return;\n    if(!labels.length) return;\n\n    var best = 0;\n    var bestD = 1e18;\n\n    for(var i=0; i<labels.length; i++){\n      var x = labels[i];\n      if(typeof x !== \"number\") x = parseFloat(x);\n      if(isNaN(x)) continue;\n\n      var d = Math.abs(x - km);\n      if(d < bestD){\n        bestD = d;\n        best = i;\n      }\n    }\n\n    if(_elevCursorIdx === best){\n      \/\/ evita update inutili\n    }else{\n      _elevCursorIdx = best;\n\n      var arr = [];\n      for(var j=0; j<labels.length; j++) arr.push(null);\n\n      var y = elevM;\n      if(typeof y !== \"number\") y = parseFloat(y);\n      if(isNaN(y)) y = _tourGetElevAtKm(km);\n\n      arr[best] = y;\n\n      if(_elevChart.data){\n        if(_elevChart.data.datasets){\n          if(_elevChart.data.datasets.length >= 2){\n            _elevChart.data.datasets[1].data = arr;\n          }\n        }\n      }\n\n      try{\n        _elevChart.update(\"none\");\n      }catch(eu){\n        try{ _elevChart.update(); }catch(e2){}\n      }\n    }\n  }catch(e){\n    console.warn(\"_elevSetCursorAtKm:\", e);\n  }\n}\n\nfunction _tourNearestOnRoute(lat, lng){\n  var out = { km: 0, distM: 0, snap: null };\n\n  try{\n    if(!_tourFollowPts) return out;\n    if(!_tourFollowCumM) return out;\n    if(_tourFollowPts.length < 2) return out;\n\n    var pLL = L.latLng(lat, lng);\n    var p = L.CRS.EPSG3857.project(pLL);\n\n    var bestD2 = 1e30;\n    var bestKm = 0;\n    var bestSnap = null;\n\n    for(var i=1; i<_tourFollowPts.length; i++){\n      var a0 = _tourFollowPts[i-1];\n      var b0 = _tourFollowPts[i];\n\n      if(!a0) continue;\n      if(!b0) continue;\n      if(a0.length < 2) continue;\n      if(b0.length < 2) continue;\n\n      var aLL = L.latLng(a0[0], a0[1]);\n      var bLL = L.latLng(b0[0], b0[1]);\n\n      var a = L.CRS.EPSG3857.project(aLL);\n      var b = L.CRS.EPSG3857.project(bLL);\n\n      var vx = b.x - a.x;\n      var vy = b.y - a.y;\n\n      var wx = p.x - a.x;\n      var wy = p.y - a.y;\n\n      var denom = (vx*vx + vy*vy);\n      if(denom <= 0) continue;\n\n      var t = (wx*vx + wy*vy) \/ denom;\n      if(t < 0) t = 0;\n      if(t > 1) t = 1;\n\n      var projx = a.x + t * vx;\n      var projy = a.y + t * vy;\n\n      var dx = p.x - projx;\n      var dy = p.y - projy;\n\n      var d2 = dx*dx + dy*dy;\n      if(d2 < bestD2){\n        bestD2 = d2;\n\n        var along = 0;\n        if(_tourFollowCumM){\n          if(_tourFollowCumM.length > i-1){\n            along = _tourFollowCumM[i-1];\n          }\n        }\n\n        var segLen = Math.sqrt(denom);\n        along = along + segLen * t;\n\n        bestKm = along \/ 1000;\n\n        var slat = aLL.lat + (bLL.lat - aLL.lat) * t;\n        var slng = aLL.lng + (bLL.lng - aLL.lng) * t;\n        bestSnap = L.latLng(slat, slng);\n      }\n    }\n\n    out.km = bestKm;\n    out.distM = Math.sqrt(bestD2);\n    out.snap = bestSnap;\n    return out;\n\n  }catch(e){\n    console.warn(\"_tourNearestOnRoute:\", e);\n    return out;\n  }\n}\n\nfunction _tourStartFollow(){\n  try{\n    if(_tourGpsWatchId !== null) return;\n\n    if(!navigator.geolocation){\n      if(typeof toast === \"function\") toast(\"GPS non disponibile\");\n      return;\n    }\n\n    var live = document.getElementById(\"elevLive\");\n    if(live) live.style.display = \"flex\";\n\n    var bStart = document.getElementById(\"elevStart\");\n    var bStop = document.getElementById(\"elevStop\");\n    if(bStart) bStart.style.display = \"none\";\n    if(bStop) bStop.style.display = \"inline-block\";\n\n    _tourFollowOn = true;\n\n    var opt = { enableHighAccuracy: true, maximumAge: 1000, timeout: 15000 };\n\n    _tourGpsWatchId = navigator.geolocation.watchPosition(function(pos){\n      try{\n        if(!pos) return;\n        if(!pos.coords) return;\n\n        var lat = pos.coords.latitude;\n        var lng = pos.coords.longitude;\n\n        if(typeof lat !== \"number\") lat = parseFloat(lat);\n        if(typeof lng !== \"number\") lng = parseFloat(lng);\n\n        if(isNaN(lat)) return;\n        if(isNaN(lng)) return;\n\n        var here = L.latLng(lat, lng);\n\n        \/\/ marker bici\n        if(!_tourGpsMarker){\n          var html = '<div class=\"bikeMeWrap\"><span class=\"bikeMeEmoji\">\ud83d\udeb4<\/span><\/div>';\n          var ico = L.divIcon({ className: \"bikeMeIcon\", html: html, iconSize: [36,36], iconAnchor:[18,18] });\n          _tourGpsMarker = L.marker(here, { icon: ico, zIndexOffset: 9000, interactive:false }).addTo(map);\n        }else{\n          _tourGpsMarker.setLatLng(here);\n        }\n\n        \/\/ centra (soft)\n        if(_tourFollowOn){\n          try{ map.panTo(here, { animate: false }); }catch(e){}\n        }\n\n        \/\/ velocit\u00e0\n        var speedKmh = 0;\n        var sp = pos.coords.speed;\n        if(typeof sp === \"number\"){\n          if(!isNaN(sp)){\n            speedKmh = sp * 3.6;\n          }\n        }else{\n          if(_tourGpsLastLatLng){\n            if(_tourGpsLastTs){\n              var dt = (pos.timestamp - _tourGpsLastTs) \/ 1000;\n              if(typeof dt === \"number\"){\n                if(dt > 0){\n                  var dd = _tourGpsLastLatLng.distanceTo(here);\n                  speedKmh = (dd \/ dt) * 3.6;\n                }\n              }\n            }\n          }\n        }\n        _tourGpsLastLatLng = here;\n        _tourGpsLastTs = pos.timestamp;\n\n        \/\/ progress su percorso + pallino su grafico\n        var near = _tourNearestOnRoute(lat, lng);\n        var km = 0;\n        if(near){\n          if(typeof near.km === \"number\"){\n            if(!isNaN(near.km)) km = near.km;\n          }\n        }\n\n        var altM = _tourGetElevAtKm(km);\n        _tourLiveSet(km, altM, speedKmh);\n        _elevSetCursorAtKm(km, altM);\n\n      }catch(eu){\n        console.warn(\"tourFollow pos:\", eu);\n      }\n    }, function(err){\n      console.warn(\"GPS error:\", err);\n      if(typeof toast === \"function\") toast(\"Errore GPS\");\n    }, opt);\n\n  }catch(e){\n    console.warn(\"_tourStartFollow:\", e);\n  }\n}\n\nfunction _tourStopFollow(removeMarker){\n  try{\n    _tourFollowOn = false;\n\n    if(_tourGpsWatchId !== null){\n      try{ navigator.geolocation.clearWatch(_tourGpsWatchId); }catch(e){}\n      _tourGpsWatchId = null;\n    }\n\n    _tourGpsLastTs = null;\n    _tourGpsLastLatLng = null;\n\n    if(removeMarker){\n      if(_tourGpsMarker){\n        try{ map.removeLayer(_tourGpsMarker); }catch(e){}\n      }\n      _tourGpsMarker = null;\n    }\n\n    \/\/ reset UI bottoni\n    var bStart = document.getElementById(\"elevStart\");\n    var bStop = document.getElementById(\"elevStop\");\n    if(bStart) bStart.style.display = \"inline-block\";\n    if(bStop) bStop.style.display = \"none\";\n\n    var live = document.getElementById(\"elevLive\");\n    if(live) live.style.display = \"none\";\n\n  }catch(e){\n    console.warn(\"_tourStopFollow:\", e);\n  }\n}\n\ndocument.addEventListener(\"click\", async function(ev){\n  var t = null;\n  if(ev) t = ev.target;\n  if(!t) return;\n\n  if(t.id === \"elevClose\"){ _tourStopFollow(false); closeElevModal(); }\n  if(t.id === \"elevModal\"){ _tourStopFollow(false); closeElevModal(); }\n\n  if(t.id === \"elevStart\"){\n    var bp = document.getElementById(\"bikeToolPanel\");\n    if(bp){\n      bp.classList.remove(\"open\");\n      bp.setAttribute(\"aria-hidden\",\"true\");\n    }\n    _tourStartFollow();\n  }\n  if(t.id === \"elevStop\"){ _tourStopFollow(false); }\n\n  if(t.id === \"elevDelete\"){\n    try{\n      if(_elevTourId){\n        await deleteBikeTour(_elevTourId);\n        _tourStopFollow(true);\n        closeElevModal();\n        return;\n      }\n\n      \/\/ se non \u00e8 un tour salvato (nessun id), azzera il planning corrente\n      if(typeof clearBikePlanning === \"function\") clearBikePlanning();\n      _tourStopFollow(true);\n      closeElevModal();\n    }catch(e){\n      console.warn(\"elevDelete:\", e);\n    }\n  }\n});\n\n\nasync function addBikeWaypoint(latlng){\n  \/\/ \u2705 FIX: ogni nuovo segmento parte SEMPRE dall\u2019ultimo punto gi\u00e0 disegnato (routePts),\n  \/\/ non dal punto di partenza del tour. Inoltre non perdi click mentre ORS calcola.\n  try{\n    if(!bikePlanOn) return;\n    if(!latlng) return;\n\n    \/\/ salva waypoint sempre\n    bikePlanPts.push([latlng.lat, latlng.lng]);\n\n    \/\/ primo punto: inizializza route + indice\n    if(bikePlanPts.length === 1){\n      bikePlanRoutePts = [[latlng.lat, latlng.lng]];\n      btRoutedWpIdx = 1; \/\/ il primo waypoint \u00e8 gi\u00e0 nella traccia\n      renderBikePlanLine();\n      return;\n    }\n\n    \/\/ avvia processatore (se gi\u00e0 in corsa non fa nulla)\n    _btProcessQueue();\n  }catch(e){\n    console.warn(\"addBikeWaypoint:\", e);\n  }\n}\n\nasync function _btProcessQueue(){\n  try{\n    if(btProcessing) return;\n    btProcessing = true;\n\n    \/\/ processa finch\u00e9 ci sono waypoint non ancora routati\n    while(bikePlanOn * (btRoutedWpIdx < bikePlanPts.length)){\n      \/\/ start: ultimo punto disegnato (routePts), altrimenti ultimo waypoint routato\n      var from = null;\n      if(bikePlanRoutePts){\n        if(bikePlanRoutePts.length){\n          var last = bikePlanRoutePts[bikePlanRoutePts.length - 1];\n          if(last){\n            if(last.length >= 2){\n              from = {lat: last[0], lng: last[1]};\n            }\n          }\n        }\n      }\n      if(!from){\n        var prevWp = bikePlanPts[btRoutedWpIdx - 1];\n        from = {lat: prevWp[0], lng: prevWp[1]};\n      }\n\n      \/\/ end: prossimo waypoint cliccato (in ordine)\n      var currWp = bikePlanPts[btRoutedWpIdx];\n      var to = {lat: currWp[0], lng: currWp[1]};\n\n      btRoutingBusy = true;\n\n      var seg = await routeSegmentORS(from, to);\n\n      \/\/ \u2705 forza gli estremi sul punto reale (attacco perfetto)\n      if(seg){\n        if(seg.length){\n          seg[0] = [from.lat, from.lng];\n          seg[seg.length - 1] = [to.lat, to.lng];\n        }else{\n          seg = [[from.lat, from.lng],[to.lat, to.lng]];\n        }\n      }else{\n        seg = [[from.lat, from.lng],[to.lat, to.lng]];\n      }\n\n      \/\/ unisci alla traccia evitando duplicato del primo punto del segmento\n      if((bikePlanRoutePts.length > 0) * (seg.length > 0)){\n        \/\/ se il primo punto del segmento coincide con l\u2019ultimo della traccia, lo rimuovo\n        var lastPt = bikePlanRoutePts[bikePlanRoutePts.length - 1];\n        var fstPt = seg[0];\n        if(lastPt){\n          if(fstPt){\n            if((lastPt[0] === fstPt[0]) * (lastPt[1] === fstPt[1])) seg.shift();\n          }else{\n            seg.shift();\n          }\n        }else{\n          seg.shift();\n        }\n      }\n\n      bikePlanRoutePts = bikePlanRoutePts.concat(seg);\n      btRoutedWpIdx = btRoutedWpIdx + 1;\n\n      renderBikePlanLine();\n\n      btRoutingBusy = false;\n    }\n  }catch(e){\n    console.warn(\"_btProcessQueue:\", e);\n  }finally{\n    btRoutingBusy = false;\n    btProcessing = false;\n  }\n}\n\n\nfunction btPanel(){ return $(\"bikeToolPanel\"); }\nfunction btOpen(on){\n  const p=btPanel();\n  if(!p) return;\n  p.classList.toggle(\"open\",!!on);\n  p.setAttribute(\"aria-hidden\",on?\"false\":\"true\");\n}\n\nfunction initBikeToursLayer(){\n  if(bikeToursLayer) return;\n  bikeToursLayer = L.layerGroup();\n}\n\nfunction clearBikePlanning(){\n  bikePlanOn=false;\n  bikePlanPts=[];\n  bikePlanRoutePts=[];\n  btRoutingBusy=false;\n  btRoutedWpIdx=0;\n  btProcessing=false;\n  if(bikePlanLine){ try{ map.removeLayer(bikePlanLine); }catch(e){}; bikePlanLine=null; }\n  const box=$(\"btPlanBox\");\n  if(box) box.style.display=\"none\";\n}\n\nfunction renderBikePlanLine(){\n  if(!map) return;\n  if(bikePlanLine){ try{ map.removeLayer(bikePlanLine); }catch(e){}; bikePlanLine=null; }\n\n  const pts = (bikePlanRoutePts ? (bikePlanRoutePts.length>=2) : false) ? bikePlanRoutePts : bikePlanPts;\n  if(!pts || pts.length<1) return;\n\n  var bikePlanStyle = { color: String.fromCharCode(35) + \"ff2bd6\", weight: 10, opacity: 0.9 };\nbikePlanLine = L.polyline(pts, bikePlanStyle).addTo(map);\n}\n\nfunction setBikeTool(on){\n  bikeToolOn=!!on;\n\n  \/\/ pannello\n  btOpen(bikeToolOn);\n\n  \/\/ layers\n  initBikeTile();\n  initBikeToursLayer();\n\n  if(!map) return;\n\n  if (bikeToolOn) {\n  try {\n    if (bikeTile) {\n      if (!map.hasLayer(bikeTile)) bikeTile.addTo(map);\n    }\n  } catch(e) {}\n\n  try {\n    if (bikeToursLayer) {\n      if (!map.hasLayer(bikeToursLayer)) bikeToursLayer.addTo(map);\n    }\n  } catch(e) {}\n\n  loadBikeTours();\n} else {\n  try {\n    if (bikeTile) {\n      if (map.hasLayer(bikeTile)) map.removeLayer(bikeTile);\n    }\n  } catch(e) {}\n\n  try {\n    if (bikeToursLayer) {\n      if (map.hasLayer(bikeToursLayer)) map.removeLayer(bikeToursLayer);\n    }\n  } catch(e) {}\n\n  clearBikePlanning();\n}\n\n}\n\nasync function loadBikeTours(){\n  try{\n    if(!bikeToolOn) return;\n    if(!sb) return;\n\n    initBikeToursLayer();\n    bikeToursLayer.clearLayers();\n\n    const {data,error}=await sb.from(BIKE_TOURS_TABLE).select(\"*\").order(\"created_at\",{ascending:false}).limit(80);\n    if(error){ console.warn(\"loadBikeTours:\", error.message); return; }\n\n    const list=(data||[]).filter(x=> (Array.isArray(x.path) ? (x.path.length>=2) : false) );\n    \/\/ disegna su mappa\n    list.forEach(tour=>{\n      const pts = tour.path.map(function(p){return [Number(p.lat), Number(p.lng)];}).filter(function(p){return (isFinite(p[0]) ? isFinite(p[1]) : false);});\n      if(pts.length<2) return;\n\n      \/\/ linea del tour (pink + pi\u00f9 larga)\n      const line = L.polyline(pts,{color:\"#ff2bd6\",weight:10,opacity:0.9});\n      line.addTo(bikeToursLayer);\n\n      \/\/ icona sul punto di partenza\n      const start = pts[0];\n      const icon = L.divIcon({\n        className: \"btTourStartIcon\",\n        html: '<div class=\"btTourPin\">\ud83d\udeb2<\/div>',\n        iconSize: [30,30],\n        iconAnchor: [15,15]\n      });\n\n      const when = tour.created_at ? new Date(tour.created_at).toLocaleString(\"it-IT\") : \"\";\n      const km = calcPtsKm(pts);\n      const popupHtml =\n        `<b>${escHtml(tour.title||\"Tour\")}<\/b>` +\n        (tour.username?`<br><span style=\"opacity:.85\">${escHtml(tour.username)}<\/span>`:\"\") +\n        (km?`<br><span style=\"opacity:.85\"><b>Lunghezza:<\/b> ${km} km<\/span>`:\"\") +\n        (when?`<br><span style=\"opacity:.75;font-size:12px\">${escHtml(when)}<\/span>`:\"\");\n\n      const mk = L.marker(start,{icon});\n      mk.bindPopup(popupHtml);\n      mk.addTo(bikeToursLayer);\n\n      \/\/ \u2705 Profilo dislivello (clic sull'icona)\n      mk.on(\"click\", function(){\n        try{ showTourElevation(tour, pts); }catch(e){}\n      });\n\n      \/\/ info anche cliccando la linea\n      line.bindPopup(popupHtml);\n    });\n    \/\/ lista nel pannello rimossa: ora i tour sono visibili solo tramite icona sul punto di partenza\n  }catch(e){ console.warn(\"loadBikeTours:\", e); }\n}\n\nasync function deleteBikeTour(id){\n  try{\n    if(!user) return alert(\"Devi essere loggato per eliminare un tour.\");\n    if(!id) return;\n\n    if(!confirm(\"Eliminare questo tour?\")) return;\n\n    const { data, error } = await sb\n      .from(BIKE_TOURS_TABLE)\n      .delete()\n      .eq(\"id\", id)\n      .select(\"id\"); \/\/ cos\u00ec sappiamo se ha cancellato davvero\n\n    if(error){\n      console.warn(\"deleteBikeTour error:\", error);\n      return alert(\"Errore eliminazione: \" + (error.message || error));\n    }\n\n    if(!data || !data.length){\n      \/\/ se arrivi qui, di solito \u00e8 RLS che blocca (o id non trovato)\n      return alert(\"Non eliminato. Probabile blocco permessi (RLS) o tour non tuo.\");\n    }\n\n    await loadBikeTours();\n  }catch(e){\n    console.warn(\"deleteBikeTour catch:\", e);\n    alert(\"Errore eliminazione (catch): \" + (e?.message || e));\n  }\n}\n\nasync function saveBikeTour(){\n  try{\n    if(!user) return alert(t().profLoginReq);\n    if(bikePlanPts.length<2) return alert(\"Aggiungi almeno 2 punti sulla mappa.\");\n    const title=(prompt(\"Nome tour:\", \"Tour in bici\")||\"\").trim()||\"Tour in bici\";\n\n    let ptsToSave = bikePlanPts;\nif (bikePlanRoutePts) {\n  if (bikePlanRoutePts.length >= 2) {\n    ptsToSave = bikePlanRoutePts;\n  }\n}\n\n    const payload={\n      user_id:user.id,\n      username: myDisplayNick ? myDisplayNick() : (getNick?getNick():\"\"),\n      title,\n      path: ptsToSave.map(p=>({lat:+p[0], lng:+p[1]}))\n    };\n    const {error}=await sb.from(BIKE_TOURS_TABLE).insert(payload);\n    if(error) return alert(error.message);\n    clearBikePlanning();\n    await loadBikeTours();\n  }catch(e){ console.warn(\"saveBikeTour:\", e); }\n}\n\nfunction hookBikeToolUI(){\n  \/\/ pannello bottoni\n  if($(\"btClose\")) $(\"btClose\").onclick=()=>{\n    setBikeTool(false);\n    try{\n      const ch=document.querySelector('.cpLayersDrop input[data-ly=\"biketool\"]');\n      if(ch) ch.checked=false;\n    }catch(e){}\n  };\n\n  if($(\"btPlanBtn\")) $(\"btPlanBtn\").onclick=()=>{\n    if(!user) return alert(t().profLoginReq);\n    bikePlanOn=true;\n    bikePlanPts=[];\n    renderBikePlanLine();\n    const box=$(\"btPlanBox\");\n    if(box) box.style.display=\"grid\";\n  };\n\n  if($(\"btCancelBtn\")) $(\"btCancelBtn\").onclick=()=>clearBikePlanning();\n  if($(\"btSaveBtn\")) $(\"btSaveBtn\").onclick=()=>saveBikeTour();\n\n  \/\/ intercetta click mappa per planner\n  if(map){\n    map.on(\"click\", (e)=>{\n      if(!bikeToolOn) return;\n      if(!bikePlanOn) return;\n      var ll = null;\n      if(e){ if(e.latlng) ll = e.latlng; }\n      if(!ll) return;\n      addBikeWaypoint(ll);\n    });\n  }\n}\n\n\n\nconst esc=(x)=>String(x||\"\").replace(\/[<>&]\/g,ch=>({\"<\":\"&lt;\",\">\":\"&gt;\",\"&\":\"&amp;\"}[ch]));\nconst escAttr=(x)=>{\n  var s=String(x||\"\");\n  \/\/ escape for HTML attributes\n  s=s.replace(\/&\/g,\"&amp;\");\n  s=s.replace(\/<\/g,\"&lt;\");\n  s=s.replace(\/>\/g,\"&gt;\");\n  s=s.replace(\/\"\/g,\"&quot;\");\n  s=s.replace(\/'\/g,\"&#39;\");\n  return s;\n};\n\n\/\/ \u2705 Linkify: rende cliccabili URL \/ email \/ telefoni (WP-safe: niente AND)\nfunction linkifyText(raw){\n  var str=String(raw||\"\");\n  if(str===\"\") return \"\";\n  var re=\/(\\bhttps?:\\\/\\\/[^\\s<]+|\\bwww\\.[^\\s<]+|\\b[\\w.+-]+@[\\w.-]+\\.[A-Za-z]{2,}|(?:\\+?\\d[\\d\\s().-]{6,}\\d))\/g;\n  var out=\"\";\n  var last=0;\n  var m=null;\n  while(true){\n    m=re.exec(str);\n    if(!m) break;\n    var token=m[0];\n    var start=m.index;\n    var end=start+token.length;\n\n    out += esc(str.slice(last,start));\n\n    var href=\"\";\n    var t=String(token||\"\").trim();\n\n    \/\/ email\n    if(href===\"\"){\n      if(t.indexOf(\"@\")>-1){\n        if(t.indexOf(\":\/\/\")===-1){\n          if(t.indexOf(\"www.\")!==0){\n            href=\"mailto:\"+t;\n          }\n        }\n      }\n    }\n\n    \/\/ url (http\/https\/www)\n    if(href===\"\"){\n      var low=t.toLowerCase();\n      if(low.indexOf(\"http:\/\/\")===0){ href=t; }\n      if(href===\"\"){\n        if(low.indexOf(\"https:\/\/\")===0){ href=t; }\n      }\n      if(href===\"\"){\n        if(low.indexOf(\"www.\")===0){ href=\"https:\/\/\"+t; }\n      }\n    }\n\n    \/\/ phone\n    if(href===\"\"){\n      var clean=\"\";\n      var k=0;\n      if(t.length>0){\n        if(t.charAt(0)===\"+\"){\n          clean=\"+\";\n          k=1;\n        }\n      }\n      for(; k<t.length; k++){\n        var c=t.charAt(k);\n        if(c>=\"0\"){\n          if(c<=\"9\"){\n            clean += c;\n          }\n        }\n      }\n      \/\/ minimo 7 cifre\n      var digitsOnly=clean;\n      if(digitsOnly.charAt(0)===\"+\") digitsOnly=digitsOnly.slice(1);\n      if(digitsOnly.length>=7){\n        href=\"tel:\"+clean;\n      }\n    }\n\n    if(href===\"\"){\n      out += esc(token);\n    }else{\n      var isTel=false;\n      var isMail=false;\n      if(href.indexOf(\"tel:\")===0) isTel=true;\n      if(href.indexOf(\"mailto:\")===0) isMail=true;\n\n      var attrs='href=\"'+escAttr(href)+'\"';\n      if(!isTel){\n        if(!isMail){\n          attrs += ' target=\"_blank\" rel=\"noopener\"';\n        }\n      }\n      out += '<a '+attrs+' style=\"color:var(--aqua);text-decoration:underline\">'+esc(token)+'<\/a>';\n    }\n\n    last=end;\n  }\n  out += esc(str.slice(last));\n  return out;\n}\n\nconst show=(el,display)=>{if(el)el.style.display=display||\"\"};\nconst hide=(el)=>{if(el)el.style.display=\"none\"};\nconst setAria=(el,open)=>{if(el)el.setAttribute(\"aria-hidden\",open?\"false\":\"true\")};\n\nconst APP_NAME=\"CamperPulse\";\ndocument.title=APP_NAME;\n\nconst SB_URL = \"https:\/\/knxmhhdzjnchafkigjzo.supabase.co\";\nconst SB_KEY = \"sb_publishable_9xJNnzDuhwXWx76SHb2ILQ_PWsLDhvD\";\n\/\/ \u2705 INCOLLA QUI LA CHIAVE \"anon\" DI SUPABASE (inizia con eyJ...)\n\/\/ Project Settings \u2192 API \u2192 Project API keys \u2192 anon public\nconst SB_ANON_JWT = \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtueG1oaGR6am5jaGFma2lnanpvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjgxMjkxNjAsImV4cCI6MjA4MzcwNTE2MH0.wRe3IyoKVLq0lGgljf_Fj3sm9kH9n0zOPgKY3-3r23w\";\nconst sb = supabase.createClient(SB_URL, SB_KEY);\nwindow.sb=sb;\nconst SW_PATH=\"\/firebase-messaging-sw.js\",\n      VAPID_PUBLIC_KEY=\"BGfLFGifotZx1NSMS5mumaabiIUXTyloznogoID1bpjhdBliAzasshvuKzgUypqPm_FJRgAJ9UCnC-y7iXOpwjE\",\n      PUSH_TABLE=\"push_subscriptions\";\n\n\/* \u2705 TRADUZIONI *\/\nconst LANG_KEY=\"cb_lang\";let langOpen=false;\nconst LANGS={it:{cityPh:\"Citt\u00e0 (es. Verona)\",goBtn:\"Vai\",navigator:\"\ud83e\udded Navigatore\",spotContactLbl:\"Contatto\",spotCostLbl:\"Costo a notte\",spotSiteLbl:\"Sito\",spotPhoneLbl:\"Telefono\",spotHoursLbl:\"Orari\",spotCapacityLbl:\"Capienza\",spotReviewsLbl:\"Recensioni\",spotNoReviews:\"Nessuna recensione ancora.\",spotReviewPh:(m)=>`Scrivi una recensione (max ${m})`,spotScrollReviews:\"Scorri per leggere tutte le recensioni \u2705\",spotLoginToReview:\"Fai login per votare e scrivere recensioni.\",spotPhotoAlt:\"Foto spot\",yes:\"s\u00ec\",no:\"no\",flag:\"\ud83c\uddee\ud83c\uddf9\"},en:{cityPh:\"City (e.g., Verona)\",goBtn:\"Go\",navigator:\"\ud83e\udded Navigate\",spotContactLbl:\"Contact\",spotCostLbl:\"Cost per night\",spotSiteLbl:\"Website\",spotPhoneLbl:\"Phone\",spotHoursLbl:\"Hours\",spotCapacityLbl:\"Capacity\",spotReviewsLbl:\"Reviews\",spotNoReviews:\"No reviews yet.\",spotReviewPh:(m)=>`Write a review (max ${m})`,spotScrollReviews:\"Scroll to read all reviews \u2705\",spotLoginToReview:\"Log in to rate and write reviews.\",spotPhotoAlt:\"Spot photo\",yes:\"yes\",no:\"no\",flag:\"\ud83c\uddec\ud83c\udde7\"},de:{cityPh:\"Stadt (z.B. Verona)\",goBtn:\"Los\",navigator:\"\ud83e\udded Navigation\",spotContactLbl:\"Kontakt\",spotCostLbl:\"Preis pro Nacht\",spotSiteLbl:\"Webseite\",spotPhoneLbl:\"Telefon\",spotHoursLbl:\"\u00d6ffnungszeiten\",spotCapacityLbl:\"Kapazit\u00e4t\",spotReviewsLbl:\"Bewertungen\",spotNoReviews:\"Noch keine Bewertungen.\",spotReviewPh:(m)=>`Bewertung schreiben (max ${m})`,spotScrollReviews:\"Scrollen, um alle Bewertungen zu lesen \u2705\",spotLoginToReview:\"Bitte einloggen, um zu bewerten und zu schreiben.\",spotPhotoAlt:\"Spot-Foto\",yes:\"ja\",no:\"nein\",flag:\"\ud83c\udde9\ud83c\uddea\"},es:{cityPh:\"Ciudad (p. ej. Verona)\",goBtn:\"Ir\",navigator:\"\ud83e\udded Navegar\",spotContactLbl:\"Contacto\",spotCostLbl:\"Costo por noche\",spotSiteLbl:\"Sitio web\",spotPhoneLbl:\"Tel\u00e9fono\",spotHoursLbl:\"Horario\",spotCapacityLbl:\"Capacidad\",spotReviewsLbl:\"Rese\u00f1as\",spotNoReviews:\"A\u00fan no hay rese\u00f1as.\",spotReviewPh:(m)=>`Escribe una rese\u00f1a (m\u00e1x ${m})`,spotScrollReviews:\"Desliza para leer todas las rese\u00f1as \u2705\",spotLoginToReview:\"Inicia sesi\u00f3n para valorar y escribir rese\u00f1as.\",spotPhotoAlt:\"Foto del spot\",yes:\"s\u00ed\",no:\"no\",flag:\"\ud83c\uddea\ud83c\uddf8\"},fr:{cityPh:\"Ville (ex. V\u00e9rone)\",goBtn:\"Aller\",navigator:\"\ud83e\udded Navigation\",spotContactLbl:\"Contact\",spotCostLbl:\"Co\u00fbt par nuit\",spotSiteLbl:\"Site web\",spotPhoneLbl:\"T\u00e9l\u00e9phone\",spotHoursLbl:\"Horaires\",spotCapacityLbl:\"Capacit\u00e9\",spotReviewsLbl:\"Avis\",spotNoReviews:\"Aucun avis pour le moment.\",spotReviewPh:(m)=>`\u00c9crire un avis (max ${m})`,spotScrollReviews:\"Fais d\u00e9filer pour lire tous les avis \u2705\",spotLoginToReview:\"Connecte-toi pour noter et \u00e9crire un avis.\",spotPhotoAlt:\"Photo du spot\",yes:\"oui\",no:\"non\",flag:\"\ud83c\uddeb\ud83c\uddf7\"}};\nconst i18n={\n  it:{menu:\"MEN\u00d9\",searchSpot:\"Cerca spot\",searchingSpot:\"Cercando...\",logged:\"Loggato come\",nick:\"Nick:\",nickPh:\"Nick name (quello che vedranno gli utenti)\",email:\"Email\",pass:\"Password\",\n    login:\"Login\",signup:\"Registrati\",change:\"Cambia\",\n    radarOn:\"\ud83d\udfe2 Radar ON\",radarOff:\"\ud83d\udd34 Radar OFF\",\n    notifOn:\"\ud83d\udd14 Notifiche ON\",notifOff:\"\ud83d\udd14 Notifiche OFF\",\n    refresh:\"\ud83d\udd04 Refresh\",logout:\"Logout\",\n    ritrovi:\"\ud83d\udccd Ritrovi\",spot:\"\ud83d\uddfa\ufe0f Spot\",chat:\"\ud83d\udcac Chat\",profile:\"\ud83d\udc64 Profil\",profTitle:\"\ud83d\udc64 Profil\",profPhotoHint:\"Photo de profil (max 300\u00d7300, si plus grande je redimensionne)\",profPreviewAlt:\"Aper\u00e7u du profil\",profNickHint:\"Pseudo\",profBioHint:\"Pr\u00e9sentation (max 300 caract\u00e8res)\",profBioPh:\"Dis deux choses sur toi...\",profHobbyHint:\"Hobbies (tape les ic\u00f4nes)\",profSave:\"Enregistrer\",profReset:\"Reset\",hob_walk:\"Randonn\u00e9e\",hob_bike:\"V\u00e9lo\",hob_kayak:\"Kayak \/ Windsurf\",hob_climb:\"Escalade\",hob_fish:\"P\u00eache\",hob_yoga:\"Yoga\",profLoginReq:\"Tu dois \u00eatre connect\u00e9.\",profPhotoErr:\"Erreur photo. R\u00e9essaie.\",profSaved:\"Profil enregistr\u00e9 \u2705\",profResetAsk:\"R\u00e9initialiser le profil ?\",selChat:\"Seleziona una chat\",noChats:\"Nessuna chat ancora. Clicca un camper sulla mappa per iniziare \ud83d\ude0e\",\n    writeMsg:\"Scrivi un messaggio\",send:\"Invia\",delChatAsk:\"Cancellare chat?\",\n    rvListTitle:\"\ud83d\udccd Ritrovi camperisti\",rvAdd:\"Aggiungi un ritrovo\",rvAddTitle:\"Aggiungi ritrovo\",rvEditTitle:\"Modifica ritrovo\",\n    rvAddressPh:\"Indirizzo esatto (clicca su mappa)\",rvHint:\"Premi \u201cSeleziona da mappa\u201d, poi clicca un punto sulla mappa.\",\n    rvContactPh:\"Contatto (mail o telefono)\",rvPick:\"\ud83d\udccc Seleziona da mappa\",rvPickOn:\"\ud83d\udfe3 Clicca sulla mappa...\",\n    rvTextPh:\"Messaggio (max 200 caratteri)\",rvDalAlHint:\"Data ritrovo: DAL e AL (obbligatorio). Dopo 24 ore dalla fine viene cancellato automaticamente.\",\n    publish:\"Pubblica\",saveEdits:\"Salva modifiche\",cancel:\"Annulla\",\n    rvEmpty:\"Nessun ritrovo pubblicato. Sii il primo \ud83d\ude0e\",\n    edit:\"Modifica\",delete:\"Cancella\",reply:\"Rispondi\",replyPh:(m)=>`Rispondi (max ${m} caratteri)`,loginToReply:\"Fai login per rispondere\",noReplies:\"Nessuna risposta ancora.\",\n    spotHeader:\"Spot per la notte\",close:\"Chiudi\",\n    spotCamping:\"Camping \/ Area sosta\",spotFree:\"Parcheggio in libera\",\n    spotNamePh:\"Nome spot (es. Parking Lago \/ Area Sosta XYZ)\",spotNameHint:\"Nome facoltativo (max 50 caratteri). Comparir\u00e0 nella card e nella scheda spot.\",\n    spotAddressPh:\"Indirizzo spot (clicca su mappa)\",spotPick:\"\ud83d\udccc Seleziona da mappa\",spotPickOn:\"\ud83d\udfe3 Clicca sulla mappa...\",spotHint:\"Premi \u201cSeleziona da mappa\u201d, poi clicca un punto sulla mappa.\",\n    spotContactPh:\"Contatto (mail \/ telefono \/ sito web)\",spotContactHint:\"Facoltativo. Es: mail, telefono o link.\",\n    spotPricePh:\"Costo a notte (es. 12\u20ac \/ 10 \/ gratuito)\",spotPriceHint:\"Facoltativo. Puoi scrivere anche \u201cgratuito\u201d.\",\n    spotDescPh:\"Descrizione (max 100 caratteri)\",spotDescHint:\"Max 100 caratteri.\",\n    amenTitle:\"Servizi disponibili (tocca le icone)\",amenHint:\"Queste icone compariranno sulla card e nella scheda spot.\",\n    spotPhotoHint:\"Foto max 300\u00d7300 (se pi\u00f9 grande la ridimensiono io).\",\n    addPlace:\"Aggiungi Luogo\"\n  ,lyFree:\"Spot in libera\",lyOsm:\"Aree sosta + Campeggi (OSM)\",lyBike:\"BikeTool\"},\n  en:{menu:\"MENU\",searchSpot:\"Search spot\",searchingSpot:\"Searching...\",logged:\"Logged in as\",nick:\"Nick:\",nickPh:\"Nickname (visible to users)\",email:\"Email\",pass:\"Password\",\n    login:\"Login\",signup:\"Sign up\",change:\"Change\",\n    radarOn:\"\ud83d\udfe2 Radar ON\",radarOff:\"\ud83d\udd34 Radar OFF\",\n    notifOn:\"\ud83d\udd14 Notifications ON\",notifOff:\"\ud83d\udd14 Notifications OFF\",\n    refresh:\"\ud83d\udd04 Refresh\",logout:\"Logout\",\n    ritrovi:\"\ud83d\udccd Meetups\",spot:\"\ud83d\uddfa\ufe0f Spots\",chat:\"\ud83d\udcac Chat\",\n    selChat:\"Select a chat\",noChats:\"No chats yet. Tap a camper on the map to start \ud83d\ude0e\",\n    writeMsg:\"Write a message\",send:\"Send\",delChatAsk:\"Delete chat?\",\n    rvListTitle:\"\ud83d\udccd Camper meetups\",rvAdd:\"Add a meetup\",rvAddTitle:\"Add meetup\",rvEditTitle:\"Edit meetup\",\n    rvAddressPh:\"Exact address (tap map)\",rvHint:\"Tap \u201cPick from map\u201d, then tap a point on the map.\",\n    rvContactPh:\"Contact (email or phone)\",rvPick:\"\ud83d\udccc Pick from map\",rvPickOn:\"\ud83d\udfe3 Tap the map...\",\n    rvTextPh:\"Message (max 200 chars)\",rvDalAlHint:\"Meetup dates: FROM and TO (required). Auto-deletes 24h after end.\",\n    publish:\"Publish\",saveEdits:\"Save\",cancel:\"Cancel\",\n    rvEmpty:\"No meetups yet. Be the first \ud83d\ude0e\",\n    edit:\"Edit\",delete:\"Delete\",reply:\"Reply\",replyPh:(m)=>`Reply (max ${m} chars)`,loginToReply:\"Login to reply\",noReplies:\"No replies yet.\",\n    spotHeader:\"Overnight spots\",close:\"Close\",\n    spotCamping:\"Camping \/ Service area\",spotFree:\"Free parking\",\n    spotNamePh:\"Spot name (optional)\",spotNameHint:\"Optional (max 50). Shown in card and spot page.\",\n    spotAddressPh:\"Spot address (tap map)\",spotPick:\"\ud83d\udccc Pick from map\",spotPickOn:\"\ud83d\udfe3 Tap the map...\",spotHint:\"Tap \u201cPick from map\u201d, then tap a point on the map.\",\n    spotContactPh:\"Contact (email \/ phone \/ website)\",spotContactHint:\"Optional.\",\n    spotPricePh:\"Price per night (e.g. 12\u20ac \/ free)\",spotPriceHint:\"Optional.\",\n    spotDescPh:\"Description (max 100)\",spotDescHint:\"Max 100 chars.\",\n    amenTitle:\"Available services (tap icons)\",amenHint:\"These icons will show on the card and spot page.\",\n    spotPhotoHint:\"Photo max 300\u00d7300 (auto resize).\",\n    addPlace:\"Add place\"\n  ,lyFree:\"Free spots\",lyOsm:\"Stopovers + campsites (OSM)\",lyBike:\"BikeTool\"},\n  de:{menu:\"MEN\u00dc\",searchSpot:\"Spot suchen\",searchingSpot:\"Suche...\",logged:\"Angemeldet als\",nick:\"Nick:\",nickPh:\"Nickname (f\u00fcr Nutzer sichtbar)\",email:\"E-Mail\",pass:\"Passwort\",\n    login:\"Login\",signup:\"Registrieren\",change:\"\u00c4ndern\",\n    radarOn:\"\ud83d\udfe2 Radar AN\",radarOff:\"\ud83d\udd34 Radar AUS\",\n    notifOn:\"\ud83d\udd14 Benachrichtigungen AN\",notifOff:\"\ud83d\udd14 Benachrichtigungen AUS\",\n    refresh:\"\ud83d\udd04 Aktualisieren\",logout:\"Logout\",\n    ritrovi:\"\ud83d\udccd Treffen\",spot:\"\ud83d\uddfa\ufe0f Spots\",chat:\"\ud83d\udcac Chat\",\n    selChat:\"Chat ausw\u00e4hlen\",noChats:\"Noch keine Chats. Tippe einen Camper auf der Karte \ud83d\ude0e\",\n    writeMsg:\"Nachricht schreiben\",send:\"Senden\",delChatAsk:\"Chat l\u00f6schen?\",\n    rvListTitle:\"\ud83d\udccd Camper-Treffen\",rvAdd:\"Treffen hinzuf\u00fcgen\",rvAddTitle:\"Treffen hinzuf\u00fcgen\",rvEditTitle:\"Treffen bearbeiten\",\n    rvAddressPh:\"Adresse (Karte antippen)\",rvHint:\"\u201eVon Karte w\u00e4hlen\u201c dr\u00fccken, dann Punkt auf der Karte antippen.\",\n    rvContactPh:\"Kontakt (E-Mail oder Tel.)\",rvPick:\"\ud83d\udccc Von Karte w\u00e4hlen\",rvPickOn:\"\ud83d\udfe3 Karte antippen...\",\n    rvTextPh:\"Nachricht (max 200)\",rvDalAlHint:\"Datum: VON und BIS (Pflicht). L\u00f6scht 24h nach Ende.\",\n    publish:\"Ver\u00f6ffentlichen\",saveEdits:\"Speichern\",cancel:\"Abbrechen\",\n    rvEmpty:\"Noch kein Treffen. Sei der Erste \ud83d\ude0e\",\n    edit:\"Bearbeiten\",delete:\"L\u00f6schen\",reply:\"Antworten\",replyPh:(m)=>`Antworten (max ${m})`,loginToReply:\"Zum Antworten einloggen\",noReplies:\"Noch keine Antworten.\",\n    spotHeader:\"\u00dcbernachtungs-Spots\",close:\"Schlie\u00dfen\",\n    spotCamping:\"Camping \/ Stellplatz\",spotFree:\"Freies Parken\",\n    spotNamePh:\"Spot-Name (optional)\",spotNameHint:\"Optional (max 50).\",\n    spotAddressPh:\"Adresse (Karte antippen)\",spotPick:\"\ud83d\udccc Von Karte w\u00e4hlen\",spotPickOn:\"\ud83d\udfe3 Karte antippen...\",spotHint:\"\u201eVon Karte w\u00e4hlen\u201c, dann Punkt antippen.\",\n    spotContactPh:\"Kontakt (E-Mail \/ Tel. \/ Website)\",spotContactHint:\"Optional.\",\n    spotPricePh:\"Preis pro Nacht (z.B. 12\u20ac \/ gratis)\",spotPriceHint:\"Optional.\",\n    spotDescPh:\"Beschreibung (max 100)\",spotDescHint:\"Max 100 Zeichen.\",\n    amenTitle:\"Services (Icons antippen)\",amenHint:\"Diese Icons erscheinen in Karte & Detail.\",\n    spotPhotoHint:\"Foto max 300\u00d7300 (Auto-Resize).\",\n    addPlace:\"Ort hinzuf\u00fcgen\"\n  ,lyFree:\"Freistehen\",lyOsm:\"Stellplaetze + Camping (OSM)\",lyBike:\"BikeTool\"},\n  es:{menu:\"MEN\u00da\",searchSpot:\"Buscar spot\",searchingSpot:\"Buscando...\",logged:\"Conectado como\",nick:\"Nick:\",nickPh:\"Nick (visible para usuarios)\",email:\"Email\",pass:\"Contrase\u00f1a\",\n    login:\"Login\",signup:\"Registrarse\",change:\"Cambiar\",\n    radarOn:\"\ud83d\udfe2 Radar ON\",radarOff:\"\ud83d\udd34 Radar OFF\",\n    notifOn:\"\ud83d\udd14 Notificaciones ON\",notifOff:\"\ud83d\udd14 Notificaciones OFF\",\n    refresh:\"\ud83d\udd04 Refresh\",logout:\"Logout\",\n    ritrovi:\"\ud83d\udccd Quedadas\",spot:\"\ud83d\uddfa\ufe0f Spots\",chat:\"\ud83d\udcac Chat\",\n    selChat:\"Selecciona un chat\",noChats:\"A\u00fan no hay chats. Toca un camper en el mapa \ud83d\ude0e\",\n    writeMsg:\"Escribe un mensaje\",send:\"Enviar\",delChatAsk:\"\u00bfBorrar chat?\",\n    rvListTitle:\"\ud83d\udccd Quedadas camper\",rvAdd:\"A\u00f1adir quedada\",rvAddTitle:\"A\u00f1adir quedada\",rvEditTitle:\"Editar quedada\",\n    rvAddressPh:\"Direcci\u00f3n (toca el mapa)\",rvHint:\"Pulsa \u201cSeleccionar del mapa\u201d, luego toca un punto.\",\n    rvContactPh:\"Contacto (email o tel\u00e9fono)\",rvPick:\"\ud83d\udccc Seleccionar del mapa\",rvPickOn:\"\ud83d\udfe3 Toca el mapa...\",\n    rvTextPh:\"Mensaje (m\u00e1x 200)\",rvDalAlHint:\"Fechas: DESDE y HASTA (obligatorio). Se borra 24h tras finalizar.\",\n    publish:\"Publicar\",saveEdits:\"Guardar\",cancel:\"Cancelar\",\n    rvEmpty:\"No hay quedadas. S\u00e9 el primero \ud83d\ude0e\",\n    edit:\"Editar\",delete:\"Borrar\",reply:\"Responder\",replyPh:(m)=>`Responder (m\u00e1x ${m})`,loginToReply:\"Inicia sesi\u00f3n para responder\",noReplies:\"Sin respuestas.\",\n    spotHeader:\"Spots noche\",close:\"Cerrar\",\n    spotCamping:\"Camping \/ \u00c1rea\",spotFree:\"Parking libre\",\n    spotNamePh:\"Nombre del spot (opcional)\",spotNameHint:\"Opcional (m\u00e1x 50).\",\n    spotAddressPh:\"Direcci\u00f3n (toca el mapa)\",spotPick:\"\ud83d\udccc Seleccionar del mapa\",spotPickOn:\"\ud83d\udfe3 Toca el mapa...\",spotHint:\"Pulsa \u201cSeleccionar del mapa\u201d, luego toca un punto.\",\n    spotContactPh:\"Contacto (email \/ tel\u00e9fono \/ web)\",spotContactHint:\"Opcional.\",\n    spotPricePh:\"Precio noche (ej. 12\u20ac \/ gratis)\",spotPriceHint:\"Opcional.\",\n    spotDescPh:\"Descripci\u00f3n (m\u00e1x 100)\",spotDescHint:\"M\u00e1x 100.\",\n    amenTitle:\"Servicios (toca iconos)\",amenHint:\"Aparecer\u00e1n en la tarjeta y ficha.\",\n    spotPhotoHint:\"Foto m\u00e1x 300\u00d7300 (auto).\",\n    addPlace:\"A\u00f1adir lugar\"\n  ,lyFree:\"En libre\",lyOsm:\"Areas + campings (OSM)\",lyBike:\"BikeTool\"},\n  fr:{menu:\"MENU\",searchSpot:\"Chercher spot\",searchingSpot:\"Recherche...\",logged:\"Connect\u00e9 en tant que\",nick:\"Nick:\",nickPh:\"Pseudo (visible aux utilisateurs)\",email:\"Email\",pass:\"Mot de passe\",\n    login:\"Login\",signup:\"S\u2019inscrire\",change:\"Changer\",\n    radarOn:\"\ud83d\udfe2 Radar ON\",radarOff:\"\ud83d\udd34 Radar OFF\",\n    notifOn:\"\ud83d\udd14 Notifications ON\",notifOff:\"\ud83d\udd14 Notifications OFF\",\n    refresh:\"\ud83d\udd04 Refresh\",logout:\"Logout\",\n    ritrovi:\"\ud83d\udccd Rencontres\",spot:\"\ud83d\uddfa\ufe0f Spots\",chat:\"\ud83d\udcac Chat\",\n    selChat:\"S\u00e9lectionne un chat\",noChats:\"Pas encore de chats. Clique un camper sur la carte \ud83d\ude0e\",\n    writeMsg:\"\u00c9crire un message\",send:\"Envoyer\",delChatAsk:\"Supprimer le chat ?\",\n    rvListTitle:\"\ud83d\udccd Rencontres camper\",rvAdd:\"Ajouter une rencontre\",rvAddTitle:\"Ajouter une rencontre\",rvEditTitle:\"Modifier la rencontre\",\n    rvAddressPh:\"Adresse (clique la carte)\",rvHint:\"Clique \u201cChoisir sur la carte\u201d, puis clique un point.\",\n    rvContactPh:\"Contact (email ou t\u00e9l\u00e9phone)\",rvPick:\"\ud83d\udccc Choisir sur la carte\",rvPickOn:\"\ud83d\udfe3 Clique la carte...\",\n    rvTextPh:\"Message (max 200)\",rvDalAlHint:\"Dates : DU et AU (obligatoire). Suppression auto 24h apr\u00e8s la fin.\",\n    publish:\"Publier\",saveEdits:\"Enregistrer\",cancel:\"Annuler\",\n    rvEmpty:\"Aucune rencontre. Sois le premier \ud83d\ude0e\",\n    edit:\"Modifier\",delete:\"Supprimer\",reply:\"R\u00e9pondre\",replyPh:(m)=>`R\u00e9pondre (max ${m})`,loginToReply:\"Connecte-toi pour r\u00e9pondre\",noReplies:\"Aucune r\u00e9ponse.\",\n    spotHeader:\"Spots nuit\",close:\"Fermer\",\n    spotCamping:\"Camping \/ Aire\",spotFree:\"Parking libre\",\n    spotNamePh:\"Nom du spot (optionnel)\",spotNameHint:\"Optionnel (max 50).\",\n    spotAddressPh:\"Adresse (clique la carte)\",spotPick:\"\ud83d\udccc Choisir sur la carte\",spotPickOn:\"\ud83d\udfe3 Clique la carte...\",spotHint:\"Clique \u201cChoisir sur la carte\u201d, puis clique un point.\",\n    spotContactPh:\"Contact (email \/ t\u00e9l\u00e9phone \/ site)\",spotContactHint:\"Optionnel.\",\n    spotPricePh:\"Prix\/nuit (ex 12\u20ac \/ gratuit)\",spotPriceHint:\"Optionnel.\",\n    spotDescPh:\"Description (max 100)\",spotDescHint:\"Max 100.\",\n    amenTitle:\"Services (tape les ic\u00f4nes)\",amenHint:\"Ces ic\u00f4nes appara\u00eetront sur la carte et la fiche.\",\n    spotPhotoHint:\"Photo max 300\u00d7300 (auto).\",\n    addPlace:\"Ajouter lieu\"\n  ,lyFree:\"En libre\",lyOsm:\"Aires + campings (OSM)\",lyBike:\"BikeTool\"}\n};\n\n\/* \u2705 FIX TRADUZIONI (completo + coerente) *\/\n(function(){\n  try{\n    \/\/ helper: aggiunge chiavi mancanti senza rompere quelle gi\u00e0 presenti\n    function ensure(lang, key, val){\n      if(!i18n) return;\n      if(!i18n[lang]) i18n[lang] = {};\n      if(typeof i18n[lang][key] === \"undefined\") i18n[lang][key] = val;\n    }\n    function set(lang, key, val){\n      if(!i18n) return;\n      if(!i18n[lang]) i18n[lang] = {};\n      i18n[lang][key] = val;\n    }\n\n    \/\/ --- IT: sistemiamo le stringhe profilo che erano finite in FR\n    set(\"it\",\"profile\",\"\ud83d\udc64 Profilo\");\n    set(\"it\",\"profTitle\",\"\ud83d\udc64 Profilo\");\n    set(\"it\",\"profPhotoHint\",\"Foto profilo (max 300\u00d7300, se pi\u00f9 grande la ridimensiono)\");\n    set(\"it\",\"profPreviewAlt\",\"Anteprima profilo\");\n    set(\"it\",\"profNickHint\",\"Nick\");\n    set(\"it\",\"profBioHint\",\"Presentazione (max 300 caratteri)\");\n    set(\"it\",\"profBioPh\",\"Scrivi due cose su di te.\");\n    set(\"it\",\"profHobbyHint\",\"Hobby (tocca le icone)\");\n    set(\"it\",\"profSave\",\"Salva\");\n    set(\"it\",\"profReset\",\"Reset\");\n    set(\"it\",\"hob_walk\",\"Trekking\");\n    set(\"it\",\"hob_bike\",\"Bici\");\n    set(\"it\",\"hob_kayak\",\"Kayak \/ Windsurf\");\n    set(\"it\",\"hob_climb\",\"Arrampicata\");\n    set(\"it\",\"hob_fish\",\"Pesca\");\n    set(\"it\",\"hob_yoga\",\"Yoga\");\n    set(\"it\",\"profLoginReq\",\"Devi essere loggato.\");\n    set(\"it\",\"profPhotoErr\",\"Errore foto. Riprova.\");\n    set(\"it\",\"profSaved\",\"Profilo salvato \u2705\");\n    set(\"it\",\"profResetAsk\",\"Resettare il profilo?\");\n\n    \/\/ --- EN\n    ensure(\"en\",\"profile\",\"\ud83d\udc64 Profile\");\n    ensure(\"en\",\"profTitle\",\"\ud83d\udc64 Profile\");\n    ensure(\"en\",\"profPhotoHint\",\"Profile photo (max 300\u00d7300, auto-resize if bigger)\");\n    ensure(\"en\",\"profPreviewAlt\",\"Profile preview\");\n    ensure(\"en\",\"profNickHint\",\"Nickname\");\n    ensure(\"en\",\"profBioHint\",\"About you (max 300 chars)\");\n    ensure(\"en\",\"profBioPh\",\"Tell us two things about you.\");\n    ensure(\"en\",\"profHobbyHint\",\"Hobbies (tap the icons)\");\n    ensure(\"en\",\"profSave\",\"Save\");\n    ensure(\"en\",\"profReset\",\"Reset\");\n    ensure(\"en\",\"hob_walk\",\"Hiking\");\n    ensure(\"en\",\"hob_bike\",\"Bike\");\n    ensure(\"en\",\"hob_kayak\",\"Kayak \/ Windsurf\");\n    ensure(\"en\",\"hob_climb\",\"Climbing\");\n    ensure(\"en\",\"hob_fish\",\"Fishing\");\n    ensure(\"en\",\"hob_yoga\",\"Yoga\");\n    ensure(\"en\",\"profLoginReq\",\"You must be logged in.\");\n    ensure(\"en\",\"profPhotoErr\",\"Photo error. Try again.\");\n    ensure(\"en\",\"profSaved\",\"Profile saved \u2705\");\n    ensure(\"en\",\"profResetAsk\",\"Reset the profile?\");\n\n    \/\/ --- DE\n    ensure(\"de\",\"profile\",\"\ud83d\udc64 Profil\");\n    ensure(\"de\",\"profTitle\",\"\ud83d\udc64 Profil\");\n    ensure(\"de\",\"profPhotoHint\",\"Profilfoto (max 300\u00d7300, automatisch verkleinert)\");\n    ensure(\"de\",\"profPreviewAlt\",\"Profilvorschau\");\n    ensure(\"de\",\"profNickHint\",\"Spitzname\");\n    ensure(\"de\",\"profBioHint\",\"\u00dcber dich (max 300 Zeichen)\");\n    ensure(\"de\",\"profBioPh\",\"Schreib zwei Dinge \u00fcber dich.\");\n    ensure(\"de\",\"profHobbyHint\",\"Hobbys (Icons antippen)\");\n    ensure(\"de\",\"profSave\",\"Speichern\");\n    ensure(\"de\",\"profReset\",\"Zur\u00fccksetzen\");\n    ensure(\"de\",\"hob_walk\",\"Wandern\");\n    ensure(\"de\",\"hob_bike\",\"Fahrrad\");\n    ensure(\"de\",\"hob_kayak\",\"Kajak \/ Windsurf\");\n    ensure(\"de\",\"hob_climb\",\"Klettern\");\n    ensure(\"de\",\"hob_fish\",\"Angeln\");\n    ensure(\"de\",\"hob_yoga\",\"Yoga\");\n    ensure(\"de\",\"profLoginReq\",\"Du musst eingeloggt sein.\");\n    ensure(\"de\",\"profPhotoErr\",\"Foto-Fehler. Bitte erneut versuchen.\");\n    ensure(\"de\",\"profSaved\",\"Profil gespeichert \u2705\");\n    ensure(\"de\",\"profResetAsk\",\"Profil zur\u00fccksetzen?\");\n\n    \/\/ --- ES\n    ensure(\"es\",\"profile\",\"\ud83d\udc64 Perfil\");\n    ensure(\"es\",\"profTitle\",\"\ud83d\udc64 Perfil\");\n    ensure(\"es\",\"profPhotoHint\",\"Foto de perfil (m\u00e1x 300\u00d7300, auto-redimensiona)\");\n    ensure(\"es\",\"profPreviewAlt\",\"Vista previa del perfil\");\n    ensure(\"es\",\"profNickHint\",\"Nick\");\n    ensure(\"es\",\"profBioHint\",\"Sobre ti (m\u00e1x 300)\");\n    ensure(\"es\",\"profBioPh\",\"Cu\u00e9ntanos dos cosas sobre ti.\");\n    ensure(\"es\",\"profHobbyHint\",\"Hobbies (toca iconos)\");\n    ensure(\"es\",\"profSave\",\"Guardar\");\n    ensure(\"es\",\"profReset\",\"Reset\");\n    ensure(\"es\",\"hob_walk\",\"Senderismo\");\n    ensure(\"es\",\"hob_bike\",\"Bici\");\n    ensure(\"es\",\"hob_kayak\",\"Kayak \/ Windsurf\");\n    ensure(\"es\",\"hob_climb\",\"Escalada\");\n    ensure(\"es\",\"hob_fish\",\"Pesca\");\n    ensure(\"es\",\"hob_yoga\",\"Yoga\");\n    ensure(\"es\",\"profLoginReq\",\"Debes iniciar sesi\u00f3n.\");\n    ensure(\"es\",\"profPhotoErr\",\"Error de foto. Int\u00e9ntalo de nuevo.\");\n    ensure(\"es\",\"profSaved\",\"Perfil guardado \u2705\");\n    ensure(\"es\",\"profResetAsk\",\"\u00bfResetear el perfil?\");\n\n    \/\/ --- FR\n    ensure(\"fr\",\"profile\",\"\ud83d\udc64 Profil\");\n    ensure(\"fr\",\"profTitle\",\"\ud83d\udc64 Profil\");\n    ensure(\"fr\",\"profPhotoHint\",\"Photo de profil (max 300\u00d7300, redimension auto)\");\n    ensure(\"fr\",\"profPreviewAlt\",\"Aper\u00e7u du profil\");\n    ensure(\"fr\",\"profNickHint\",\"Pseudo\");\n    ensure(\"fr\",\"profBioHint\",\"Pr\u00e9sentation (max 300)\");\n    ensure(\"fr\",\"profBioPh\",\"Dis deux choses sur toi.\");\n    ensure(\"fr\",\"profHobbyHint\",\"Hobbies (tape les ic\u00f4nes)\");\n    ensure(\"fr\",\"profSave\",\"Enregistrer\");\n    ensure(\"fr\",\"profReset\",\"Reset\");\n    ensure(\"fr\",\"hob_walk\",\"Randonn\u00e9e\");\n    ensure(\"fr\",\"hob_bike\",\"V\u00e9lo\");\n    ensure(\"fr\",\"hob_kayak\",\"Kayak \/ Windsurf\");\n    ensure(\"fr\",\"hob_climb\",\"Escalade\");\n    ensure(\"fr\",\"hob_fish\",\"P\u00eache\");\n    ensure(\"fr\",\"hob_yoga\",\"Yoga\");\n    ensure(\"fr\",\"profLoginReq\",\"Tu dois \u00eatre connect\u00e9.\");\n    ensure(\"fr\",\"profPhotoErr\",\"Erreur photo. R\u00e9essaie.\");\n    ensure(\"fr\",\"profSaved\",\"Profil enregistr\u00e9 \u2705\");\n    ensure(\"fr\",\"profResetAsk\",\"R\u00e9initialiser le profil ?\");\n\n    \/\/ --- BikeTool + profilo dislivello + barra mappa (tutte le lingue)\n    var bt = {\n      it:{btCloseTitle:\"Chiudi\",btPlanBtn:\"\ud83e\udded Pianifica tour sulla mappa\",btPlanHint:\"Tocca\/clicca sulla mappa per aggiungere punti. (Minimo 2)\",btSaveBtn:\"Salva\",btCancelBtn:\"Annulla\",btPublicTours:\"Tour pubblici\"},\n      en:{btCloseTitle:\"Close\",btPlanBtn:\"\ud83e\udded Plan a tour on the map\",btPlanHint:\"Tap\/click the map to add points. (Minimum 2)\",btSaveBtn:\"Save\",btCancelBtn:\"Cancel\",btPublicTours:\"Public tours\"},\n      de:{btCloseTitle:\"Schlie\u00dfen\",btPlanBtn:\"\ud83e\udded Tour auf der Karte planen\",btPlanHint:\"Tippe\/klicke auf die Karte, um Punkte hinzuzuf\u00fcgen. (Mindestens 2)\",btSaveBtn:\"Speichern\",btCancelBtn:\"Abbrechen\",btPublicTours:\"\u00d6ffentliche Touren\"},\n      es:{btCloseTitle:\"Cerrar\",btPlanBtn:\"\ud83e\udded Planificar tour en el mapa\",btPlanHint:\"Toca\/haz clic en el mapa para a\u00f1adir puntos. (M\u00ednimo 2)\",btSaveBtn:\"Guardar\",btCancelBtn:\"Cancelar\",btPublicTours:\"Tours p\u00fablicos\"},\n      fr:{btCloseTitle:\"Fermer\",btPlanBtn:\"\ud83e\udded Planifier un tour sur la carte\",btPlanHint:\"Touche\/clique la carte pour ajouter des points. (Minimum 2)\",btSaveBtn:\"Enregistrer\",btCancelBtn:\"Annuler\",btPublicTours:\"Tours publics\"}\n    };\n    var elev = {\n      it:{elevTitle:\"Profilo dislivello\",elevDelete:\"Cancella tour\",elevStart:\"Avvia tour\",elevStop:\"Stop\",elevClose:\"Chiudi\",elevLen:\"Lunghezza\",elevGain:\"Salita\",elevLoss:\"Discesa\",elevAlt:\"Altitudine (m)\",elevYou:\"Tu\",elevDist:\"Distanza (km)\"},\n      en:{elevTitle:\"Elevation profile\",elevDelete:\"Delete tour\",elevStart:\"Start tour\",elevStop:\"Stop\",elevClose:\"Close\",elevLen:\"Length\",elevGain:\"Ascent\",elevLoss:\"Descent\",elevAlt:\"Elevation (m)\",elevYou:\"You\",elevDist:\"Distance (km)\"},\n      de:{elevTitle:\"H\u00f6henprofil\",elevDelete:\"Tour l\u00f6schen\",elevStart:\"Tour starten\",elevStop:\"Stop\",elevClose:\"Schlie\u00dfen\",elevLen:\"L\u00e4nge\",elevGain:\"Anstieg\",elevLoss:\"Abstieg\",elevAlt:\"H\u00f6he (m)\",elevYou:\"Du\",elevDist:\"Distanz (km)\"},\n      es:{elevTitle:\"Perfil de elevaci\u00f3n\",elevDelete:\"Borrar tour\",elevStart:\"Iniciar tour\",elevStop:\"Stop\",elevClose:\"Cerrar\",elevLen:\"Longitud\",elevGain:\"Subida\",elevLoss:\"Bajada\",elevAlt:\"Altitud (m)\",elevYou:\"T\u00fa\",elevDist:\"Distancia (km)\"},\n      fr:{elevTitle:\"Profil d\u2019altitude\",elevDelete:\"Supprimer le tour\",elevStart:\"D\u00e9marrer le tour\",elevStop:\"Stop\",elevClose:\"Fermer\",elevLen:\"Longueur\",elevGain:\"Mont\u00e9e\",elevLoss:\"Descente\",elevAlt:\"Altitude (m)\",elevYou:\"Toi\",elevDist:\"Distance (km)\"}\n    };\n    var nav = {\n      it:{navRecenterTitle:\"Ricentra su di me\",navCityPh:\"Citt\u00e0 (es. Verona)\",navGo:\"Vai\"},\n      en:{navRecenterTitle:\"Recenter on me\",navCityPh:\"City (e.g. Verona)\",navGo:\"Go\"},\n      de:{navRecenterTitle:\"Zu mir zentrieren\",navCityPh:\"Stadt (z.B. Verona)\",navGo:\"Los\"},\n      es:{navRecenterTitle:\"Centrar en m\u00ed\",navCityPh:\"Ciudad (ej. Verona)\",navGo:\"Ir\"},\n      fr:{navRecenterTitle:\"Recentrer sur moi\",navCityPh:\"Ville (ex. V\u00e9rone)\",navGo:\"Aller\"}\n    };\n\n    [\"it\",\"en\",\"de\",\"es\",\"fr\"].forEach(function(lang){\n      var k;\n      var obj = bt[lang];\n      for(k in obj){ ensure(lang, k, obj[k]); }\n      obj = elev[lang];\n      for(k in obj){ ensure(lang, k, obj[k]); }\n      obj = nav[lang];\n      for(k in obj){ ensure(lang, k, obj[k]); }\n    });\n  }catch(e){\n    console.warn(\"i18n fix:\", e);\n  }\n})();\n\nconst getLang=()=>localStorage.getItem(LANG_KEY)||\"it\";\nconst t=()=>i18n[getLang()]||i18n.it;\n\nfunction trText(key,fallback){\n  var obj = t();\n  var v = null;\n  if(obj!=null){ v = obj[key]; }\n  if(!v){ v = fallback; }\n  return v;\n}\n\nfunction setLang(l){localStorage.setItem(LANG_KEY,l);applyLang(l)}\nfunction setLangOpen(on){\n  langOpen=!!on;\n  const d=$(\"langDrop\"); if(!d) return;\n  d.classList.toggle(\"open\",langOpen);\n  d.setAttribute(\"aria-hidden\",langOpen?\"false\":\"true\");\n}\nfunction setText(id,val){const el=$(id); if(el!=null){ if(val!=null) el.textContent=val; }}\nfunction setPH(id,val){const el=$(id); if(el!=null){ if(val!=null) el.placeholder=val; }}\nfunction setAttr(id,a,val){const el=$(id); if(el!=null){ if(val!=null) el.setAttribute(a,val); }}\n\nfunction applyLang(l){\n  const tr=i18n[l]||i18n.it;\n  const flag=LANGS[l]?.flag||\"\ud83c\uddee\ud83c\uddf9\";\n  if($(\"langBtn\")) $(\"langBtn\").textContent=flag;\n\n  setText(\"userInfoLabel\",tr.logged);\n  setText(\"nickLabel\",tr.nick);\n  setPH(\"nickname\",tr.nickPh);\n  setPH(\"email\",tr.email);\n  setPH(\"password\",tr.pass);\n  setText(\"loginBtn\",tr.login);\n  setText(\"signupBtn\",tr.signup);\n  setText(\"changeNickBtn\",tr.change);\n\n  if($(\"menuBtn\")) $(\"menuBtn\").textContent=tr.menu+\" \u25be\";\n  setText(\"refreshBtn\",tr.refresh);\n  setText(\"logoutBtn\",tr.logout);\n  setText(\"rvToggle\",tr.ritrovi);\n  setText(\"spotToggle\",tr.spot);\n  setText(\"chatToggle\",tr.chat);\n  setText(\"profileToggle\",tr.profile);\n\n  \/\/ \u2705 traduci bottone \"Cerca spot\" (barra mappa)\n  try{\n    var lblSpot = tr.searchSpot;\n    if(!lblSpot){ lblSpot = \"Cerca spot\"; }\n    var btns = document.querySelectorAll(\".cpOsmSearchBtn\");\n    if(btns!=null){\n      btns.forEach(function(b){\n        if(b!=null){\n          b.textContent = lblSpot;\n          b.setAttribute(\"title\", lblSpot);\n          b.setAttribute(\"aria-label\", lblSpot);\n        }\n      });\n    }\n  }catch(e){}\n\n  \/\/ \u2705 se la scheda spot \u00e8 aperta, ritraduci rigenerando l'HTML\n  try{\n    var sm = $(\"spotModal\");\n    if(sm!=null){\n      if(sm.classList.contains(\"open\")){\n        renderSpotModal();\n      }\n    }\n  }catch(e){}\n\n  setPH(\"msgInput\",tr.writeMsg);\n  setText(\"sendBtn\",tr.send);\n  if(!activePeer) setText(\"chatHeader\",tr.selChat);\n\n  setText(\"rvListTitle\",tr.rvListTitle);\n  setText(\"rvAddBtn\",tr.rvAdd);\n  setPH(\"rvAddress\",tr.rvAddressPh);\n  setText(\"rvHint\",tr.rvHint);\n  setPH(\"rvContact\",tr.rvContactPh);\n  setPH(\"rvText\",tr.rvTextPh);\n  setText(\"rvDalAlHint\",tr.rvDalAlHint);\n\n  setText(\"spotHeaderTitle\",tr.spotHeader);\n  setText(\"spotClose\",tr.close);\n  setText(\"spTypeCampingLbl\",tr.spotCamping);\n  setText(\"spTypeFreeLbl\",tr.spotFree);\n  setPH(\"spotName\",tr.spotNamePh);\n  setText(\"spotNameHint\",tr.spotNameHint);\n  setPH(\"spotAddress\",tr.spotAddressPh);\n  setText(\"spotHint\",tr.spotHint);\n  setPH(\"spotContact\",tr.spotContactPh);\n  setText(\"spotContactHint\",tr.spotContactHint);\n  setPH(\"spotPrice\",tr.spotPricePh);\n  setText(\"spotPriceHint\",tr.spotPriceHint);\n  setPH(\"spotDesc\",tr.spotDescPh);\n  setText(\"spotDescHint\",tr.spotDescHint);\n  setText(\"amenTitle\",tr.amenTitle);\n  setText(\"amenHint\",tr.amenHint);\n  setText(\"spotPhotoHint\",tr.spotPhotoHint);\n\n  \/\/ profilo\n  setText(\"profileModalTitle\",tr.profTitle);\n  setText(\"profPhotoHint\",tr.profPhotoHint);\n  setText(\"profNickHint\",tr.profNickHint);\n  setText(\"profBioHint\",tr.profBioHint);\n  setPH(\"profBio\",tr.profBioPh);\n  setText(\"profHobbyHint\",tr.profHobbyHint);\n  setText(\"profSaveBtn\",tr.profSave);\n  setText(\"profResetBtn\",tr.profReset);\n  setAttr(\"profPreview\",\"alt\",tr.profPreviewAlt);\n  setAttr(\"hob_walk\",\"title\",tr.hob_walk);\n  setAttr(\"hob_bike\",\"title\",tr.hob_bike);\n  setAttr(\"hob_kayak\",\"title\",tr.hob_kayak);\n  setAttr(\"hob_climb\",\"title\",tr.hob_climb);\n  setAttr(\"hob_fish\",\"title\",tr.hob_fish);\n  setAttr(\"hob_yoga\",\"title\",tr.hob_yoga);\n\n  updateRadarBtn();\n  updateNotifBtn();\n  updatePickBtnsText();\n  updateSpotPrimaryBtnText();\n  updateRVPrimaryBtnText();\n}\n\nfunction hookLangUI(){\n  const btn=$(\"langBtn\"),drop=$(\"langDrop\");\n  if(!btn||!drop) return;\n  btn.onclick=()=>setLangOpen(!langOpen);\n  document.addEventListener(\"click\",(e)=>{\n    if(!langOpen) return;\n    if(btn.contains(e.target)||drop.contains(e.target)) return;\n    setLangOpen(false);\n  });\n  document.addEventListener(\"keydown\",(e)=>{if(e.key===\"Escape\")setLangOpen(false)});\n  [\"it\",\"en\",\"de\",\"es\",\"fr\"].forEach(k=>{\n    const b=$(\"lang_\"+k);\n    if(b) b.onclick=()=>{setLangOpen(false);setLang(k)}\n  });\n}\n\n\/* =========================\n   \u2705 ADD-ON PROFILO (semplice)\n   - apre modal da menu\n   - nickname auto\n   - bio 300\n   - hobby toggle\n   - foto resize 300\n   - salva su localStorage (e opzionale su DB dopo)\n========================= *\/\nconst PROFILE_KEY=\"cp_profile_v1\";\nconst HOB_KEYS=[\"walk\",\"bike\",\"kayak\",\"climb\",\"fish\",\"yoga\"];\nlet profileState={bio:\"\",hobbies:{walk:false,bike:false,kayak:false,climb:false,fish:false,yoga:false},photoDataUrl:\"\"};\n\nfunction readProfile(){\n  try{\n    const j=JSON.parse(localStorage.getItem(PROFILE_KEY)||\"null\");\n    if(j){ if(typeof j===\"object\") profileState={...profileState,...j,hobbies:{...profileState.hobbies,...(j.hobbies||{})}}; }\n  }catch{}\n}\nfunction writeProfile(){\n  try{localStorage.setItem(PROFILE_KEY,JSON.stringify(profileState))}catch{}\n}\nfunction setProfNick(){\n  const nick=(typeof myDisplayNick===\"function\")?myDisplayNick():(getNick?getNick():\"\");\n  if($(\"profNick\")) $(\"profNick\").value=nick||\"\";\n}\nfunction renderProfileUI(){\n  setProfNick();\n  if($(\"profBio\")) $(\"profBio\").value=profileState.bio||\"\";\n  if($(\"profPreview\")){\n    if(profileState.photoDataUrl){\n      $(\"profPreview\").src=profileState.photoDataUrl;\n      $(\"profPreview\").style.display=\"block\";\n    }else{\n      $(\"profPreview\").style.display=\"none\";\n      $(\"profPreview\").src=\"\";\n    }\n  }\n  HOB_KEYS.forEach(k=>{\n    const el=$(\"hob_\"+k);\n    if(el) el.classList.toggle(\"on\",!!profileState.hobbies?.[k]);\n  });\n}\nfunction toggleHobby(k){\n  profileState.hobbies[k]=!profileState.hobbies[k];\n  renderProfileUI();\n}\nasync function resizeImageTo300(file,max){\n  const dataUrl=await new Promise((resolve,reject)=>{\n    const r=new FileReader();\n    r.onload=()=>resolve(r.result);\n    r.onerror=reject;\n    r.readAsDataURL(file);\n  });\n  const img=new Image();\n  await new Promise((resolve,reject)=>{img.onload=resolve;img.onerror=reject;img.src=dataUrl});\n  const w=img.width,h=img.height;\n  const scale=Math.min(1,max\/Math.max(w,h));\n  const nw=Math.max(1,Math.round(w*scale)),nh=Math.max(1,Math.round(h*scale));\n  const canvas=document.createElement(\"canvas\");\n  canvas.width=nw;canvas.height=nh;\n  canvas.getContext(\"2d\").drawImage(img,0,0,nw,nh);\n  return canvas.toDataURL(\"image\/jpeg\",0.88);\n}\n\nfunction openProfileModal(){\n  if(!user) return alert(t().profLoginReq);\n  setMenuOpen(false);\n  modalClose(\"spotModal\");modalClose(\"rvListModal\");modalClose(\"rvFormModal\");\n  if(typeof closeChatModal===\"function\") try{closeChatModal()}catch{}\n  readProfile();\n  renderProfileUI();\n  modalOpen(\"profileModal\");\n  lockMapInteractions(true);\n}\nfunction closeProfileModal(){\n  modalClose(\"profileModal\");\n  lockMapInteractions(false);\n}\n\nfunction hookProfileUI(){\n  \/\/ bottone menu\n  if($(\"profileToggle\")) $(\"profileToggle\").onclick=()=>openProfileModal();\n\n  \/\/ chiudi\n  bindModal(\"profileModal\",\"profileModalCloseBtn\",closeProfileModal);\n\n  \/\/ hobby buttons\n  HOB_KEYS.forEach(k=>{\n    const el=$(\"hob_\"+k);\n    if(el) el.onclick=()=>toggleHobby(k);\n  });\n\n  \/\/ bio\n  if($(\"profBio\")) $(\"profBio\").addEventListener(\"input\",()=>{\n    profileState.bio=($(\"profBio\").value||\"\").slice(0,300);\n  });\n\n  \/\/ foto\n  if($(\"profPhoto\")) $(\"profPhoto\").addEventListener(\"change\",async(e)=>{\n    const file=e.target.files?.[0];\n    if(!file){profileState.photoDataUrl=\"\";renderProfileUI();return;}\n    try{\n      const out=await resizeImageTo300(file,300);\n      profileState.photoDataUrl=out;\n      renderProfileUI();\n    }catch(err){\n      console.warn(\"profile resize:\",err);\n      alert(t().profPhotoErr);\n    }\n  });\n\n  \/\/ salva\/reset\n  if($(\"profSaveBtn\")) $(\"profSaveBtn\").onclick=()=>{\n    writeProfile();\n    alert(t().profSaved);\n  };\n  if($(\"profResetBtn\")) $(\"profResetBtn\").onclick=()=>{\n    if(!confirm(t().profResetAsk)) return;\n    profileState={bio:\"\",hobbies:{walk:false,bike:false,kayak:false,climb:false,fish:false,yoga:false},photoDataUrl:\"\"};\n    writeProfile();\n    renderProfileUI();\n  };\n\n  document.addEventListener(\"keydown\",(e)=>{if(e.key===\"Escape\"){ var pm=$(\"profileModal\"); if(pm){ if(pm.classList.contains(\"open\")) closeProfileModal(); } }});\n}\n\n\/* PUSH helpers *\/\nfunction urlBase64ToUint8Array(s){\n  const str=(s||\"\").trim();\n  const isHex=(\/^[0-9a-fA-F]+$\/.test(str) ? ((str.length%2===0)) : false);\n  if(isHex){\n    const bytes=new Uint8Array(str.length\/2);\n    for(let i=0;i<bytes.length;i++) bytes[i]=parseInt(str.substr(i*2,2),16);\n    return bytes;\n  }\n  const pad=\"=\".repeat((4-(str.length%4))%4);\n  const b64=(str+pad).replace(\/\\-\/g,\"+\").replace(\/_\/g,\"\/\");\n  const raw=atob(b64);\n  const out=new Uint8Array(raw.length);\n  for(let i=0;i<raw.length;i++) out[i]=raw.charCodeAt(i);\n  return out;\n}\n\nasync function ensureServiceWorker(){\n  if(!(\"serviceWorker\" in navigator)) throw new Error(\"Service Worker non supportato su questo browser.\");\n  const reg=await navigator.serviceWorker.getRegistration(\"\/\");\n  return reg || await navigator.serviceWorker.register(SW_PATH,{scope:\"\/\"});\n}\nasync function getPushSubscription(){const reg=await ensureServiceWorker(); return reg.pushManager.getSubscription()}\nasync function subscribePush(){\n  const reg = await ensureServiceWorker();\n  const appKey = urlBase64ToUint8Array(VAPID_PUBLIC_KEY);\n\n  \/\/ \u2705 RESET totale: se esiste gi\u00e0 una subscription sul device, la stacco sempre\n  try{\n    const oldSub = await reg.pushManager.getSubscription();\n    if(oldSub) await oldSub.unsubscribe();\n  }catch(e){}\n\n  \/\/ \u2705 creo SEMPRE una nuova subscription\n  const sub = await reg.pushManager.subscribe({\n    userVisibleOnly:true,\n    applicationServerKey: appKey\n  });\n\n  return sub;\n}\n\n\/* STATE *\/\nlet user=null,profiles={},chats={},activePeer=null,map=null,myMarker=null,geoWatch=null;\nconst RADAR_KEY=\"cb_radar_started\",RADAR_DURATION=86400000;let radarOn=false;\nconst NICK_KEY=\"cb_nickname\",NOTIF_KEY=\"cb_notifications_enabled\";let notifOn=false,unreadCount=0;\nconst PEERS_KEY=\"cb_chat_peers\";let peersSet=new Set();\nconst RV_REPLIES_TABLE=\"rv_replies\",RV_REPLY_MAX=100;\nconst SPOTS_TABLE=\"spots\",SPOT_VOTES_TABLE=\"spot_votes\",SPOT_BUCKET=\"spot_photos\",SPOT_COMMENTS_TABLE=\"spot_comments\",OSM_VOTES_TABLE=\"osm_place_votes\",OSM_COMMENTS_TABLE=\"osm_place_comments\",SPOT_DESC_MAX=100,SPOT_MAX_SIZE=300,SPOT_NAME_MAX=50,SPOT_COMMENT_MAX=100;\nlet pickMode=false,pickedLatLng=null,editingRVId=null,spotPickMode=false,pickedSpotLatLng=null,editingSpotId=null,spotType=\"camping\",currentSpotPhotoBlob=null,currentSpotPhotoExt=\"jpg\",currentSpotExistingPhotoPath=null;\nlet spotAmenities={power:false,water:false,dump:false,toilet:false,pets:false};\nconst AMEN_KEYS=[\"power\",\"water\",\"dump\",\"toilet\",\"pets\"],AMEN_EL={power:\"amen_power\",water:\"amen_water\",dump:\"amen_dump\",toilet:\"amen_toilet\",pets:\"amen_pets\"};\nlet clusterUsers=null,clusterSpotCamping=null,clusterSpotFree=null,layerControl=null,spotById={},spotCommentsById={},markerBySpotId={},spotPopupOpen=false,spotModalSpotId=null,lastOpenedSpotId=null;\n\n\/* \u2705 ADD-ON: OSM schede identiche (voti + recensioni) *\/\nvar osmFeedbackMode = null; \/\/ \"spot\" | \"osm\" | \"none\"\n\nfunction isOsmSpotId(spotId){\n  if(!spotId) return false;\n  return String(spotId).indexOf(\"osm:\") === 0;\n}\n\nasync function _detectOsmFeedbackMode(){\n  if(osmFeedbackMode) return osmFeedbackMode;\n\n  \/\/ \u2705 STRADA A: usa SOLO tabelle dedicate OSM (evita query su spot_votes con spot_id UUID)\n  osmFeedbackMode = \"osm\";\n  return osmFeedbackMode;\n}\n\nfunction _tablesForSpotId(spotId){\n  var sid = String(spotId || \"\");\n  if(!isOsmSpotId(sid)){\n    return { votes: SPOT_VOTES_TABLE, comments: SPOT_COMMENTS_TABLE };\n  }\n\n  \/\/ se non ho ancora rilevato, provo comunque le tabelle standard (verr\u00e0 gestito dagli errori)\n  if(!osmFeedbackMode){\n    return { votes: SPOT_VOTES_TABLE, comments: SPOT_COMMENTS_TABLE };\n  }\n  if(osmFeedbackMode === \"spot\"){\n    return { votes: SPOT_VOTES_TABLE, comments: SPOT_COMMENTS_TABLE };\n  }\n  if(osmFeedbackMode === \"osm\"){\n    return { votes: OSM_VOTES_TABLE, comments: OSM_COMMENTS_TABLE };\n  }\n  return { votes: null, comments: null };\n}\n\nasync function ensureSpotFeedbackLoaded(spotId){\n  if(!spotId) return;\n  var sid = String(spotId);\n\n  if(isOsmSpotId(sid)){\n    await _detectOsmFeedbackMode();\n  }\n\n  var tabs = _tablesForSpotId(sid);\n  if(!tabs.votes) return;\n  if(!tabs.comments) return;\n\n  \/\/ votes -> avg_rating, votes_count, my_rating\n  try{\n    var vr = await sb.from(tabs.votes).select(\"rating,user_id\").eq(\"spot_id\", sid);\n    if(!vr.error){\n      var arr = vr.data;\n      var sum = 0;\n      var cnt = 0;\n      var my = 0;\n\n      if(arr){\n        if(Array.isArray(arr)){\n          for(var i=0; i<arr.length; i++){\n            var row = arr[i];\n            if(!row) continue;\n            var r = Number(row.rating) || 0;\n            if(r > 0){\n              sum += r;\n              cnt += 1;\n            }\n            if(user){\n              if(row.user_id === user.id){\n                my = r;\n              }\n            }\n          }\n        }\n      }\n\n      var s1 = spotById[sid];\n      if(s1){\n        s1.votes_count = cnt;\n        s1.avg_rating = (cnt ? (sum \/ cnt) : 0);\n        s1.my_rating = my;\n      }\n    }\n  }catch(eV){}\n\n  \/\/ comments -> spotCommentsById[sid]\n  try{\n    var cr = await sb.from(tabs.comments).select(\"id,spot_id,user_id,username,body,created_at\").eq(\"spot_id\", sid).order(\"created_at\", { ascending:false });\n    if(!cr.error){\n      spotCommentsById[sid] = cr.data || [];\n    }else{\n      if(!spotCommentsById[sid]) spotCommentsById[sid] = [];\n    }\n  }catch(eC){\n    if(!spotCommentsById[sid]) spotCommentsById[sid] = [];\n  }\n}\n\n\/* \u2705 ADD-ON: Layer OSM \"Aree sosta + Campeggi\" (Overpass) \u2014 WP-safe (NO AND) *\/\nvar osmPlacesOn = false;\nvar osmPlacesLayer = null;\nvar osmPlacesBound = false;\nvar osmPlacesLastKey = \"\";\nvar osmPlacesTimer = null;\n\nvar osmPlacesCache = {};\nvar OSM_CACHE_MS = 90000; \/\/ \u2705 cache Overpass 90s\n\n\/\/ Se vuoi usare le tue icone, incolla qui gli URL (Media WordPress)\nvar OSM_ICON_CAMPSITE_URL = \"\";     \/\/ (opzionale) URL icona campeggio\nvar OSM_ICON_CARAVANSITE_URL = \"\";  \/\/ (opzionale) URL icona area sosta\n\nfunction _osmIconEmoji(emoji){\n  var html = '<div class=\"osmIcoWrap\"><span class=\"osmIcoEmoji\">' + emoji + '<\/span><\/div>';\n  return L.divIcon({ className:\"osmIco\", html: html, iconSize:[34,34], iconAnchor:[17,17] });\n}\nfunction _osmIconUrl(url, emojiFallback){\n  if(url){\n    return L.icon({ iconUrl:url, iconSize:[34,34], iconAnchor:[17,34], popupAnchor:[0,-34] });\n  }\n  return _osmIconEmoji(emojiFallback);\n}\nfunction _osmIconFor(tags){\n  var t = null;\n  if(tags){\n    if(tags.tourism) t = tags.tourism;\n  }\n\n  \/\/ \u2705 Usa le stesse icone dei campeggi anche per le aree sosta (richiesta)\n  if(typeof spotIcon === \"function\"){\n    if(t === \"camp_site\") return spotIcon(\"camping\");\n    if(t === \"caravan_site\") return spotIcon(\"camping\");\n  }\n\n  \/\/ fallback: usa la stessa icona \"camping\" anche per le aree sosta\n  if(t === \"camp_site\") return _osmIconUrl(OSM_ICON_CAMPSITE_URL, \"\ud83c\udfd5\ufe0f\");\n  if(t === \"caravan_site\") return _osmIconUrl(OSM_ICON_CAMPSITE_URL, \"\ud83c\udfd5\ufe0f\");\n  return _osmIconEmoji(\"\ud83d\udccd\");\n}\n\n\nfunction _initOsmPlacesLayer(){\n  if(osmPlacesLayer) return;\n  \/\/ cluster se disponibile\n  if(window.L){\n    if(L.markerClusterGroup){\n      osmPlacesLayer = L.markerClusterGroup({\n        spiderfyOnMaxZoom:true,\n        showCoverageOnHover:false,\n        zoomToBoundsOnClick:true,\n        disableClusteringAtZoom:18,\n        maxClusterRadius:55,\n        iconCreateFunction:function(c){\n          if(typeof makeClusterIcon === \"function\") return makeClusterIcon(\"camping\", c.getChildCount());\n          \/\/ fallback semplice\n          var n = c.getChildCount();\n          return L.divIcon({className:\"osmClusterFallback\",html:'<div style=\"width:34px;height:34px;border-radius:14px;display:grid;place-items:center;background:rgba(5,7,13,.92);border:2px solid rgba(0,229,255,.85);box-shadow:0 0 14px rgba(0,229,255,.25);font-size:14px\">' + n + '<\/div>',iconSize:[34,34],iconAnchor:[17,17]});\n        }\n      });\n    }else{\n      osmPlacesLayer = L.layerGroup();\n    }\n  }\n}\n\nfunction setOsmPlaces(on){\n  osmPlacesOn = (on ? true : false);\n  _initOsmPlacesLayer();\n  if(!map) return;\n  if(!osmPlacesLayer) return;\n\n  if(osmPlacesOn){\n    if(!map.hasLayer(osmPlacesLayer)) map.addLayer(osmPlacesLayer);\n    \/\/ \u2705 Overpass SOLO su bottone: niente auto-bind \/ niente auto-refresh\n  }else{\n    _osmPlacesUnbind();\n    if(map.hasLayer(osmPlacesLayer)) map.removeLayer(osmPlacesLayer);\n    if(osmPlacesLayer){\n      if(typeof osmPlacesLayer.clearLayers === \"function\") osmPlacesLayer.clearLayers();\n    }\n  }\n}\n\n\nfunction osmPlacesSearchNow(){\n  \/\/ \u2705 chiamata manuale: Overpass solo quando premi il bottone\n  if(!osmPlacesOn) return;\n  _osmPlacesRefresh(true);\n}\n\nfunction _osmPlacesBind(){\n  if(osmPlacesBound) return;\n  osmPlacesBound = true;\n  map.on(\"moveend\", _osmPlacesDebounced);\n  map.on(\"zoomend\", _osmPlacesDebounced);\n}\nfunction _osmPlacesUnbind(){\n  if(!osmPlacesBound) return;\n  osmPlacesBound = false;\n  map.off(\"moveend\", _osmPlacesDebounced);\n  map.off(\"zoomend\", _osmPlacesDebounced);\n  if(osmPlacesTimer){\n    clearTimeout(osmPlacesTimer);\n    osmPlacesTimer = null;\n  }\n}\n\nfunction _osmPlacesDebounced(){\n  if(osmPlacesTimer){\n    clearTimeout(osmPlacesTimer);\n    osmPlacesTimer = null;\n  }\n  osmPlacesTimer = setTimeout(function(){\n    _osmPlacesRefresh(false);\n  }, 450);\n}\n\nasync function _osmPlacesRefresh(force){\n  if(!osmPlacesOn) return;\n  if(!map) return;\n  if(!osmPlacesLayer) return;\n\n  var z = map.getZoom ? map.getZoom() : 0;\n  if(z < 6){\n    \/\/ troppo lontano: se NON \u00e8 una chiamata manuale, non caricare (evita query inutili)\n    if(!force){\n      if(typeof osmPlacesLayer.clearLayers === \"function\") osmPlacesLayer.clearLayers();\n      if(typeof toast === \"function\") toast(\"Zooma un po' di pi\u00f9 oppure premi Cerca spot per forzare.\");\n      return;\n    }\n  }\n\n  \/\/ \u2705 raggio fisso intorno a me (100km) \u2014 pi\u00f9 veloce e meno timeout\n  var centerLat = null;\n  var centerLng = null;\n\n  \/\/ 1) posizione reale (marker rosso)\n  if(myMarker){\n    var ll = myMarker.getLatLng ? myMarker.getLatLng() : null;\n    if(ll){\n      if(typeof ll.lat === \"number\") centerLat = ll.lat;\n      if(typeof ll.lng === \"number\") centerLng = ll.lng;\n    }\n  }\n\n  \/\/ 2) fallback: centro mappa (posizione stimata \/ ultima vista)\n  if(centerLat === null){\n    var c = map.getCenter ? map.getCenter() : null;\n    if(c){\n      if(typeof c.lat === \"number\") centerLat = c.lat;\n      if(typeof c.lng === \"number\") centerLng = c.lng;\n    }\n  }\n\n  if(centerLat === null) return;\n  if(centerLng === null) return;\n\n  var radiusKm = 20;\n  var radiusM = radiusKm * 1000;\n\n  \/\/ chiave arrotondata per evitare richieste continue\n  var kLat = Math.round(centerLat * 1000) \/ 1000;\n  var kLng = Math.round(centerLng * 1000) \/ 1000;\n\n  var key = kLat + \",\" + kLng + \",\" + radiusKm + \",\" + z;\n  if(!force){\n    if(osmPlacesLastKey === key) return;\n  }\n  osmPlacesLastKey = key;\n\n  \/\/ \u2705 cache key (senza zoom) per riuso risultati 90s\n  var cacheKey = kLat + \",\" + kLng + \",\" + radiusKm;\n\n  var query = \"\";\n  query += \"[out:json][timeout:25];\";\n  query += \"(\";\n  query += 'nwr[\"tourism\"=\"camp_site\"](around:' + radiusM + \",\" + centerLat + \",\" + centerLng + \");\";\n  query += 'nwr[\"tourism\"=\"caravan_site\"](around:' + radiusM + \",\" + centerLat + \",\" + centerLng + \");\";\n  query += \");\";\n  query += \"out center tags;\";\n\n  try{\n    \/\/ \u2705 Overpass: mirror + retry (riduce 504 Gateway Timeout)\n    function _cpSleep(ms){\n      return new Promise(function(resolve){\n        setTimeout(function(){ resolve(); }, ms);\n      });\n    }\n\n    var endpoints = [\n      \"\/overpass-proxy.php\"\n    ];\n\n    var body = \"data=\" + encodeURIComponent(query);\n\n    var js = null;\n    var lastErr = null;\n\n    \/\/ \u2705 cache 90s: se gi\u00e0 abbiamo risposta recente per questa zona, evita Overpass\n    try{\n      var nowMs = (Date.now ? Date.now() : (new Date()).getTime());\n      if(!force){\n        if(osmPlacesCache){\n          var ce = osmPlacesCache[cacheKey];\n          if(ce){\n            if(typeof ce.ts === \"number\"){\n              if((nowMs - ce.ts) < OSM_CACHE_MS){\n                if(ce.js) js = ce.js;\n              }\n            }\n          }\n        }\n      }\n    }catch(eCache){}\n\n    for(var ei=0; ei<endpoints.length; ei++){\n      var endpoint = endpoints[ei];\n\n      for(var attempt=0; attempt<2; attempt++){\n        try{\n          var ctrl = null;\n          var timerId = null;\n\n          var opt = {\n            method: \"POST\",\n            headers: { \"Content-Type\": \"application\/x-www-form-urlencoded;charset=UTF-8\" },\n            body: body\n          };\n\n          \/\/ timeout client-side (se disponibile)\n          if(typeof AbortController !== \"undefined\"){\n            ctrl = new AbortController();\n            opt.signal = ctrl.signal;\n\n            timerId = setTimeout(function(){\n              try{\n                if(ctrl) ctrl.abort();\n              }catch(e){}\n            }, 25000);\n          }\n\n          var res = null;\n\n          \/\/ 1) prova standard (x-www-form-urlencoded)\n          try{\n            res = await fetch(endpoint, opt);\n          }catch(eFetch){\n            lastErr = eFetch;\n            res = null;\n          }\n\n          if(timerId) clearTimeout(timerId);\n\n          \/\/ 2) se usi il proxy in root e la prima prova fallisce, riprova con body RAW (text\/plain)\n          if(!res){\n            if(typeof endpoint === \"string\"){\n              if(endpoint.indexOf(\"overpass-proxy.php\") !== -1){\n                try{\n                  var opt2 = { method:\"POST\", headers:{ \"Content-Type\":\"text\/plain;charset=UTF-8\" }, body: query };\n\n                  if(typeof AbortController !== \"undefined\"){\n                    var ctrl2 = new AbortController();\n                    opt2.signal = ctrl2.signal;\n\n                    var t2 = setTimeout(function(){\n                      try{ ctrl2.abort(); }catch(e){}\n                    }, 25000);\n\n                    res = await fetch(endpoint, opt2);\n                    if(t2) clearTimeout(t2);\n                  }else{\n                    res = await fetch(endpoint, opt2);\n                  }\n                }catch(eFetch2){\n                  lastErr = eFetch2;\n                  res = null;\n                }\n              }\n            }\n          }\n\n          \/\/ 3) se il proxy risponde ma NON ok (400\/405\/415), prova comunque RAW\n          if(res){\n            if(!res.ok){\n              if(typeof endpoint === \"string\"){\n                if(endpoint.indexOf(\"overpass-proxy.php\") !== -1){\n                  var stTry = res.status;\n                  if((stTry === 400) || (stTry === 405) || (stTry === 415)){\n                    try{\n                      var opt3 = { method:\"POST\", headers:{ \"Content-Type\":\"text\/plain;charset=UTF-8\" }, body: query };\n\n                      if(typeof AbortController !== \"undefined\"){\n                        var ctrl3 = new AbortController();\n                        opt3.signal = ctrl3.signal;\n\n                        var t3 = setTimeout(function(){\n                          try{ ctrl3.abort(); }catch(e){}\n                        }, 25000);\n\n                        res = await fetch(endpoint, opt3);\n                        if(t3) clearTimeout(t3);\n                      }else{\n                        res = await fetch(endpoint, opt3);\n                      }\n                    }catch(eFetch3){\n                      lastErr = eFetch3;\n                    }\n                  }\n                }\n              }\n            }\n          }\n\n          if(!res) throw new Error(\"Overpass: no response\");\n\n          if(!res.ok){\n            var st = res.status;\n\n            \/\/ 429\/502\/503\/504: server saturo o timeout -> riprova\/usa mirror\n            if(st === 429){\n              lastErr = new Error(\"Overpass HTTP 429 @ \" + endpoint);\n              await _cpSleep(800 + attempt*900);\n              continue;\n            }\n            if(st === 502){\n              lastErr = new Error(\"Overpass HTTP 502 @ \" + endpoint);\n              await _cpSleep(800 + attempt*900);\n              continue;\n            }\n            if(st === 503){\n              lastErr = new Error(\"Overpass HTTP 503 @ \" + endpoint);\n              await _cpSleep(800 + attempt*900);\n              continue;\n            }\n            if(st === 504){\n              lastErr = new Error(\"Overpass HTTP 504 @ \" + endpoint);\n              await _cpSleep(900 + attempt*1000);\n              continue;\n            }\n\n            throw new Error(\"Overpass HTTP \" + st + \" @ \" + endpoint);\n          }\n\n          js = await res.json();\n\n          if(js){\n            break;\n          }else{\n            lastErr = new Error(\"Overpass: json vuoto @ \" + endpoint);\n            await _cpSleep(700 + attempt*800);\n          }\n\n        }catch(e){\n          lastErr = e;\n          await _cpSleep(800 + attempt*900);\n        }\n      }\n\n      if(js) break;\n    }\n\n    if(!js){\n      if(lastErr) throw lastErr;\n      throw new Error(\"Overpass: nessuna risposta dai mirror\");\n    }\n    if(!js) throw new Error(\"Overpass: json vuoto\");\n\n\n    \/\/ \u2705 salva in cache (90s)\n    try{\n      var nowSave = (Date.now ? Date.now() : (new Date()).getTime());\n      if(osmPlacesCache){\n        osmPlacesCache[cacheKey] = { ts: nowSave, js: js };\n      }\n    }catch(eSave){}\n\n    var els = js.elements;\n    if(!els || !Array.isArray(els)){\n      \/\/ niente risultati\n      if(typeof osmPlacesLayer.clearLayers === \"function\") osmPlacesLayer.clearLayers();\n      return;\n    }\n\n    if(typeof osmPlacesLayer.clearLayers === \"function\") osmPlacesLayer.clearLayers();\n\n    for(var i=0; i<els.length; i++){\n      var el = els[i];\n      if(!el) continue;\n\n      var lat = null;\n      var lng = null;\n\n      if(typeof el.lat === \"number\") lat = el.lat;\n      if(typeof el.lon === \"number\") lng = el.lon;\n\n      if((lat === null) || (lng === null)){\n        if(el.center){\n          if(typeof el.center.lat === \"number\") lat = el.center.lat;\n          if(typeof el.center.lon === \"number\") lng = el.center.lon;\n        }\n      }\n\n      if(lat === null) continue;\n      if(lng === null) continue;\n\n      var tags = el.tags || {};\n      var name = (tags.name ? tags.name : \"\");\n      if(!name){\n        if(tags.tourism === \"camp_site\") name = \"Campeggio\";\n        else if(tags.tourism === \"caravan_site\") name = \"Area sosta\";\n        else name = \"Punto\";\n      }\n\n      var ico = _osmIconFor(tags);\n      var mk = L.marker([lat, lng], { icon: ico });\n\n      var info = \"<b>\" + _escHtml(name) + \"<\/b>\";\n      if(tags.website) info += \"<div style='opacity:.9'>\ud83c\udf10 \" + _escHtml(tags.website) + \"<\/div>\";\n      if(tags.phone) info += \"<div style='opacity:.9'>\u260e\ufe0f \" + _escHtml(tags.phone) + \"<\/div>\";\n\n      \/\/ link OSM\n      if(el.type){\n        if(el.id){\n        }\n      }\n\n      \/\/ \u2705 invece del popup Leaflet, apri la finestra Spot di CamperPulse\n      mk._cpOsmPayload = {\n        osmType: el.type,\n        osmId: el.id,\n        lat: lat,\n        lng: lng,\n        name: name,\n        tags: tags\n      };\n      mk.on(\"click\", function(){\n        try{\n          var p = null;\n          if(this){ p = this._cpOsmPayload; }\n          if(p){ openOsmInSpotModal(p); }\n        }catch(_e){}\n      });\n      osmPlacesLayer.addLayer(mk);\n    }\n\n  }catch(e){\n    console.warn(\"OSM places:\", e);\n  }\n}\n\n\/\/ escape base (usa eventuale escapeHtml gi\u00e0 presente, se non c'\u00e8 usa questa)\nfunction _escHtml(s){\n  if(typeof escapeHtml === \"function\"){\n    try{ return escapeHtml(s); }catch(_e){}\n  }\n  s = String(s==null ? \"\" : s);\n  return s.replace(\/&\/g,\"&amp;\").replace(\/<\/g,\"&lt;\").replace(\/>\/g,\"&gt;\").replace(\/\"\/g,\"&quot;\").replace(\/'\/g,\"&#39;\");\n}\n\n\/* \u2705 OSM -> SpotModal (usa la finestra standard di CamperPulse) *\/\nfunction openOsmInSpotModal(p){\n  if(!p) return;\n  var osmType = p.osmType ? String(p.osmType) : \"node\";\n  var osmId = p.osmId;\n  if(!osmId) return;\n\n  var key = \"osm:\" + osmType + \":\" + osmId;\n\n  \/\/ crea\/aggiorna spot \"virtuale\" in cache\n  var s = spotById[key];\n  if(!s){\n    s = {};\n    spotById[key] = s;\n  }\n\n  s.id = key;\n  s.source = \"osm\";\n  s.type = \"camping\"; \/\/ stessa icona\/stile dei campeggi (come richiesto)\n\n  var nm = p.name ? String(p.name) : \"\";\n  s.spot_name = nm;\n\n  var lat = p.lat;\n  var lng = p.lng;\n  if(typeof lat !== \"number\") lat = parseFloat(lat);\n  if(typeof lng !== \"number\") lng = parseFloat(lng);\n  s.lat = lat;\n  s.lng = lng;\n\n  s.osm_type = osmType;\n  s.osm_id = osmId;\n\n  var url = \"\";\n  if(osmType){\n    url = \"https:\/\/www.openstreetmap.org\/\" + osmType + \"\/\" + osmId;\n  }\n  s.osm_url = url;\n\n  var tags = p.tags;\n  if(!tags) tags = {};\n  s.osm_tags = tags;\n\n  \/\/ campi utili per UI (solo se presenti)\n  \/\/ (pulizia campi che possono cambiare per evitare duplicati)\n  s.website = \"\";\n  s.phone = \"\";\n  s.opening_hours = \"\";\n  s.fee = \"\";\n  s.capacity = \"\";\n  s.contact = \"\";\n  s.price_per_night = \"\";\n\n  var web = (tags[\"contact:website\"] ? tags[\"contact:website\"] : (tags.website ? tags.website : \"\"));\n  var ph  = (tags[\"contact:phone\"] ? tags[\"contact:phone\"] : (tags.phone ? tags.phone : \"\"));\n  var em  = (tags[\"contact:email\"] ? tags[\"contact:email\"] : (tags.email ? tags.email : \"\"));\n\n  if(web) s.website = String(web);\n  if(ph) s.phone = String(ph);\n  if(tags.opening_hours) s.opening_hours = String(tags.opening_hours);\n  if(tags.fee!=null){\n    if(String(tags.fee).trim()) s.fee = String(tags.fee);\n  }\n  if(tags.capacity!=null){\n    if(String(tags.capacity).trim()) s.capacity = String(tags.capacity);\n  }\n  if(em) s.contact = String(em);\n\n  \/\/ descrizione (se OSM la fornisce)\n  var d = \"\";\n  if(tags.description) d = String(tags.description);\n  if(!d){\n    if(tags.note) d = String(tags.note);\n  }\n  s.description = d;\n\n  \/\/ indirizzo (addr:*)\n  var addr = \"\";\n  if(tags[\"addr:street\"]){\n    addr += String(tags[\"addr:street\"]);\n    if(tags[\"addr:housenumber\"]) addr += \" \" + String(tags[\"addr:housenumber\"]);\n  }\n  if(tags[\"addr:city\"]){\n    if(addr) addr += \", \";\n    addr += String(tags[\"addr:city\"]);\n  }\n  if(tags[\"addr:postcode\"]){\n    if(addr) addr += \" \";\n    addr += String(tags[\"addr:postcode\"]);\n  }\n  if(tags[\"addr:country\"]){\n    if(addr) addr += \", \";\n    addr += String(tags[\"addr:country\"]);\n  }\n  s.address = addr;\n\n  \/\/ servizi (boolean \"s\u00ec\/no\" se disponibili)\n  s.has_power = _osmTagYes(tags.power_supply);\n  s.has_water = _osmTagYes(tags.drinking_water);\n  s.has_toilet = _osmTagYes(tags.toilets);\n  s.has_shower = _osmTagYes(tags.shower);\n\n  \/\/ scarico: ci sono tag diversi, proviamo i pi\u00f9 comuni\n  var dump = false;\n  if(_osmTagYes(tags.sanitary_dump_station)) dump = true;\n  if(!dump){\n    if(_osmTagYes(tags[\"waste_disposal:chemical_toilet\"])) dump = true;\n  }\n  s.has_dump = dump;\n\n  \/\/ pets\n  s.pets_allowed = _osmTagPets(tags);\n\n  \/\/ \u2705 normalizza campi per usare la scheda standard (identica agli spot interni)\n  s.username = \"OpenStreetMap\";\n  s.created_at = null;\n\n  \/\/ riusa i campi standard\n  \/\/ - contact: solo email (se presente) per evitare duplicati con Telefono\/Sito\n  \/\/ - prezzo: usa fee (anche \"yes\/no\" verr\u00e0 tradotto in UI)\n  if(!s.price_per_night){\n    if(s.fee!=null){\n      if(String(s.fee).trim()) s.price_per_night = String(s.fee);\n    }\n  }\n\n  \/\/ \u2705 prepara voti\/recensioni anche per OSM (se supportato dal DB)\n  try{ Promise.resolve(ensureSpotFeedbackLoaded(key)); }catch(_e){} \n  \/\/ apri la finestra spot standard\n  openSpotModal(key);\n}\n\n\nfunction _osmTagYes(v){\n  if(!v) return false;\n  var s = String(v).toLowerCase().trim();\n  if(s === \"yes\") return true;\n  if(s === \"y\") return true;\n  if(s === \"true\") return true;\n  if(s === \"1\") return true;\n  return false;\n}\n\nfunction _osmTagPets(tags){\n  if(!tags) return false;\n  \/\/ cani ammessi: dogs=yes \/ dogs=leashed ecc.\n  if(tags.dogs){\n    var s = String(tags.dogs).toLowerCase().trim();\n    if(s === \"no\") return false;\n    return true;\n  }\n  return false;\n}\n\n\nlet chatModalOpen=false,chatBodyHome=null;\n\nconst safeName=(uid)=>profiles[uid]?.display_name||profiles[uid]?.username||uid;\nconst getNick=()=> (localStorage.getItem(NICK_KEY)||\"\").trim();\nconst setNick=(n)=>localStorage.setItem(NICK_KEY,(n||\"\").trim());\nfunction myDisplayNick(){\n  var n = \"\";\n  \/\/ prova prima dal profilo Supabase\n  if(typeof profiles !== \"undefined\"){\n    if(profiles){\n      if(typeof user !== \"undefined\"){\n        if(user){\n          if(user.id){\n            var row = profiles[user.id];\n            if(row){\n              if(row.display_name){ n = row.display_name; }\n              else if(row.username){ n = row.username; }\n            }\n          }\n        }\n      }\n    }\n  }\n\n  \/\/ fallback: nickname locale\n  if(!n){\n    if(typeof getNick === \"function\"){\n      n = getNick();\n    }\n  }\n\n  \/\/ fallback: email\n  if(!n){\n    if(typeof user !== \"undefined\"){\n      if(user){\n        if(user.email){ n = user.email; }\n      }\n    }\n  }\n\n  if(!n){ n = \"Camperista\"; }\n  return String(n).trim();\n}\n\n\/\/ alias retro-compatibilit\u00e0 (alcune versioni chiamano safeMyNick())\nfunction safeMyNick(){\n  return myDisplayNick();\n}\nfunction toLocalInputValue(iso){\n  if(!iso) return \"\";\n  const d=new Date(iso),p=n=>String(n).padStart(2,\"0\");\n  return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;\n}\nasync function reverseGeocode(lat,lng){\n  try{\n    const url=`https:\/\/nominatim.openstreetmap.org\/reverse?format=jsonv2&lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lng)}&zoom=18&addressdetails=1`;\n    const res=await fetch(url,{headers:{Accept:\"application\/json\"}});\n    const js=await res.json();\n    return js?.display_name||`${lat.toFixed(6)}, ${lng.toFixed(6)}`;\n  }catch{\n    return `${lat.toFixed(6)}, ${lng.toFixed(6)}`;\n  }\n}\n\n\/* \u2705 ADDED: MAP TOOLS (ricentra + cerca citt\u00e0) *\/\nasync function flyToCity(query){\n  const q=(query||\"\").trim();\n  if(!q) return;\n  try{\n    const url=`https:\/\/nominatim.openstreetmap.org\/search?format=jsonv2&limit=1&q=${encodeURIComponent(q)}`;\n    const res=await fetch(url,{headers:{Accept:\"application\/json\"}});\n    const js=await res.json();\n    const hit=js?.[0];\n    if(!hit) return alert(\"Citt\u00e0 non trovata.\");\n    const lat=Number(hit.lat),lng=Number(hit.lon);\n    if(!isFinite(lat)||!isFinite(lng)) return alert(\"Risultato non valido.\");\n    map.setView([lat,lng],12,{animate:true});\n  }catch(e){\n    console.warn(\"flyToCity:\",e);\n    alert(\"Errore ricerca citt\u00e0.\");\n  }\n}\nfunction recenterMe(){\n  try{\n    if(myMarker){\n      const ll=myMarker.getLatLng();\n      map.setView([ll.lat,ll.lng],Math.max(map.getZoom(),15),{animate:true});\n      return;\n    }\n    if(!navigator.geolocation) return alert(\"Geolocalizzazione non supportata.\");\n    navigator.geolocation.getCurrentPosition(\n      (p)=>{\n        const {latitude:lat,longitude:lng}=p.coords||{};\n        if(!isFinite(lat)||!isFinite(lng)) return alert(\"Posizione non disponibile.\");\n        map.setView([lat,lng],Math.max(map.getZoom(),15),{animate:true});\n      },\n      (err)=>alert(\"Non posso leggere la posizione: \"+(err?.message||err)),\n      {enableHighAccuracy:true,timeout:12000,maximumAge:2000}\n    );\n  }catch(e){console.warn(\"recenterMe:\",e)}\n}\nfunction initMapToolsControl(){\n  if(!map||!window.L) return;\n  const C=L.Control.extend({\n    options:{position:\"bottomleft\"},\n    onAdd:function(){\n      const div=L.DomUtil.create(\"div\",\"cpNavCtrl\");\n      div.innerHTML=`\n\n        <div class=\"cpLeftBtns\">\n          <button type=\"button\" class=\"cpNavBtn cpRecenterBtn\" title=\"Ricentra su di me\">\ud83c\udfaf<\/button>\n        <\/div>\n        <div class=\"cpCityWrap\">\n          <input class=\"cpCityInput\" type=\"text\" placeholder=\"Citt\u00e0 (es. Verona)\">\n        <\/div>\n        <button type=\"button\" class=\"cpGoBtn cpCityGoBtn\" title=\"Vai\">Vai<\/button>\n`;\n      L.DomEvent.disableClickPropagation(div);\n      L.DomEvent.disableScrollPropagation(div);\n\n      const btn=div.querySelector(\".cpRecenterBtn\");\n      const inp=div.querySelector(\".cpCityInput\");\n      const go=div.querySelector(\".cpCityGoBtn\");\n      const cityWrap=div.querySelector('.cpCityWrap');\n      try{\n        if(cityWrap){\n          cityWrap.style.display='flex';\n          cityWrap.style.alignItems='center';\n          cityWrap.style.gap='8px';\n          cityWrap.style.flex='1 1 auto';\n          cityWrap.style.minWidth='0';\n          cityWrap.style.marginRight='auto';\n        }\n        if(inp){\n          inp.style.flex='1 1 auto';\n          inp.style.minWidth='0';\n          inp.style.width='100%';\n        }\n        div.style.width='calc(100vw - 26px - env(safe-area-inset-left) - env(safe-area-inset-right))';\n        div.style.maxWidth='720px';\n        div.style.boxSizing='border-box';\n      }catch(e){}\n      const osmBtn=div.querySelector(\".cpOsmSearchBtn\");\n\n      btn.addEventListener(\"click\",(e)=>{e.preventDefault();e.stopPropagation();recenterMe()});\n      go.addEventListener(\"click\",(e)=>{e.preventDefault();e.stopPropagation();flyToCity(inp.value)});\n      inp.addEventListener(\"keydown\",(e)=>{if(e.key===\"Enter\"){e.preventDefault();flyToCity(inp.value)}});\n\n      return div;\n    }\n  });\n  new C().addTo(map);\n}\nfunction moveLayersIntoCpBar(){\n  try{\n    \/\/ prende il controllo layers creato da Leaflet\n    const layersEl = document.querySelector(\"#map .leaflet-control-layers\");\n    if(!layersEl) return;\n\n    \/\/ prende la tua barra (quella con \ud83c\udfaf + input citt\u00e0 + Vai)\n    const barEl = document.querySelector(\"#map .cpNavCtrl\");\n    if(!barEl) return;\n\n    \/\/ evita di spostarlo due volte\n    if(layersEl.dataset.moved === \"1\") return;\n    layersEl.dataset.moved = \"1\";\n\n    \/\/ evita che Leaflet lo metta \u201ca blocco\u201d grande\n    layersEl.style.margin = \"0\";\n    layersEl.style.borderRadius = \"16px\";\n\n    \/\/ inserisce il layers come primo elemento della barra\n    var left = barEl.querySelector(\".cpLeftBtns\");\n    if(left){\n      left.insertBefore(layersEl, left.firstChild);\n    }else{\n      barEl.insertBefore(layersEl, barEl.firstChild);\n    }\n  }catch(e){\n    console.warn(\"moveLayersIntoCpBar:\", e);\n  }\n\n  \/* \u2705 Traduzioni extra: BikeTool + Profilo dislivello + Barra mappa *\/\n  try{\n    \/\/ BikeTool panel\n    if($(\"btClose\")) $(\"btClose\").title = tr.btCloseTitle || $(\"btClose\").title || \"Chiudi\";\n    var p = $(\"bikeToolPanel\");\n    if($(\"btPlanBtn\")) $(\"btPlanBtn\").textContent = tr.btPlanBtn || $(\"btPlanBtn\").textContent;\n    var hintEl = document.getElementById(\"btPlanHint\");\n    if(!hintEl){\n      if(p){\n        var h = p.querySelector(\".btPlanBox .hint\");\n        if(h) hintEl = h;\n      }\n    }\n    if(hintEl) hintEl.textContent = tr.btPlanHint || hintEl.textContent;\n    if($(\"btSaveBtn\")) $(\"btSaveBtn\").textContent = tr.btSaveBtn || $(\"btSaveBtn\").textContent;\n    if($(\"btCancelBtn\")) $(\"btCancelBtn\").textContent = tr.btCancelBtn || $(\"btCancelBtn\").textContent;\n    var subEl = document.querySelector(\"#bikeToolPanel .btSub\");\n    if(subEl) subEl.textContent = tr.btPublicTours || subEl.textContent;\n\n    \/\/ Elevation card buttons\n    if($(\"elevDelete\")) $(\"elevDelete\").textContent = tr.elevDelete || $(\"elevDelete\").textContent;\n    if($(\"elevStart\")) $(\"elevStart\").textContent = tr.elevStart || $(\"elevStart\").textContent;\n    if($(\"elevStop\")) $(\"elevStop\").textContent = tr.elevStop || $(\"elevStop\").textContent;\n    if($(\"elevClose\")) $(\"elevClose\").textContent = tr.elevClose || $(\"elevClose\").textContent;\n\n    \/\/ Map bottom bar (recenter + search)\n    var bar = document.querySelector(\"#map .cpNavCtrl\");\n    if(bar){\n      var navBtn = bar.querySelector(\".cpNavBtn\");\n      if(navBtn){\n        navBtn.title = tr.navRecenterTitle || navBtn.title || \"Ricentra su di me\";\n      }\n      var cityIn = bar.querySelector(\".cpCityInput\");\n      if(cityIn){\n        cityIn.placeholder = tr.navCityPh || cityIn.placeholder || \"Citt\u00e0 (es. Verona)\";\n      }\n      var goBtn = bar.querySelector(\".cpGoBtn:not(.cpOsmSearchBtn)\");\n      if(goBtn){\n        goBtn.textContent = tr.navGo || goBtn.textContent || \"Vai\";\n      }\n    }\n  }catch(e){\n    console.warn(\"applyLang extra:\", e);\n  }\n\n}\n\n\/* \u2705 ADD-ON: unica barra con Layers + Ricentra + Cerca citt\u00e0 *\/\nfunction initBottomBarControl(){\n  if(!map||!window.L) return;\n\n  \/\/ Se esiste ancora il vecchio layerControl, lo tolgo\n  try{ if(layerControl) map.removeControl(layerControl) }catch{}\n\n  const C=L.Control.extend({\n    options:{position:\"bottomleft\"},\n    onAdd:function(){\n      const div=L.DomUtil.create(\"div\",\"cpNavCtrl\");\n\n      div.innerHTML=`\n        <button type=\"button\" class=\"cpSpotMini cpGoBtn cpOsmSearchBtn\" title=\"${trText('searchSpot','Cerca spot')}\">${trText('searchSpot','Cerca spot')}<\/button>\n\n        <div class=\"cpLeftBtns\">\n          <div class=\"cpLayersWrap\">\n            <button type=\"button\" class=\"cpNavBtn cpLayersBtn\" title=\"${trText('layersTitle','Layers')}\">\ud83d\uddfa\ufe0f<\/button>\n            <div class=\"cpLayersDrop\" aria-hidden=\"true\">\n              <label class=\"cpLayRow\"><input type=\"checkbox\" data-ly=\"users\" checked> \ud83d\udc65 Ospiti (Radar)<\/label>\n              <label class=\"cpLayRow\"><input type=\"checkbox\" data-ly=\"camping\" checked> \ud83c\udfd5\ufe0f Spot Camping \/ Area sosta<\/label>\n              <label class=\"cpLayRow\"><input type=\"checkbox\" data-ly=\"free\" checked> \ud83c\udd7f\ufe0f <span id=\"lyFreeLbl\">Spot in libera<\/span><\/label>\n              <label class=\"cpLayRow\"><input type=\"checkbox\" data-ly=\"osmplaces\" checked> \ud83d\uddfa\ufe0f <span id=\"lyOsmLbl\">Aree sosta + Campeggi (OSM)<\/span><\/label>\n              <label class=\"cpLayRow\"><input type=\"checkbox\" data-ly=\"biketool\"> \ud83d\udeb2 <span id=\"lyBikeLbl\">BikeTool<\/span><\/label>\n            <\/div>\n          <\/div>\n\n          <button type=\"button\" class=\"cpNavBtn cpRecenterBtn\" title=\"${trText('navRecenterTitle','Ricentra su di me')}\">\ud83c\udfaf<\/button>\n        <\/div>\n\n        <div class=\"cpCityWrap\">\n          <input class=\"cpCityInput\" type=\"text\" placeholder=\"${trText('cityPh','Citt\u00e0 (es. Verona)')}\">\n        <\/div>\n\n        <button type=\"button\" class=\"cpGoBtn cpCityGoBtn\" title=\"${trText('goBtn','Vai')}\">${trText('goBtn','Vai')}<\/button>\n      `;\n\n      L.DomEvent.disableClickPropagation(div);\n      L.DomEvent.disableScrollPropagation(div);\n\n      const layersBtn=div.querySelector(\".cpLayersBtn\");\n      const drop=div.querySelector(\".cpLayersDrop\");\n      const recBtn=div.querySelector(\".cpRecenterBtn\");\n      const inp=div.querySelector(\".cpCityInput\");\n      const go=div.querySelector(\".cpCityGoBtn\");\n      const osmBtn=div.querySelector(\".cpOsmSearchBtn\");\n\n      const setDrop=(on)=>{\n        drop.classList.toggle(\"open\",!!on);\n        drop.setAttribute(\"aria-hidden\",on?\"false\":\"true\");\n      };\n\n      layersBtn.addEventListener(\"click\",(e)=>{\n        e.preventDefault(); e.stopPropagation();\n        setDrop(!drop.classList.contains(\"open\"));\n      });\n\n      \/\/ chiudi dropdown cliccando fuori\n      document.addEventListener(\"click\",(e)=>{\n        if(!drop.classList.contains(\"open\")) return;\n        if(div.contains(e.target)) return;\n        setDrop(false);\n      });\n\n      \/\/ toggle layers\n      drop.querySelectorAll(\"input[type=checkbox]\").forEach(ch=>{\n        ch.addEventListener(\"change\",()=>{\n          const k=ch.dataset.ly;\n          const on=ch.checked;\n\n          if(k===\"users\"){ if(clusterUsers) on ? map.addLayer(clusterUsers) : map.removeLayer(clusterUsers); }\n          if(k===\"camping\"){ if(clusterSpotCamping) on ? map.addLayer(clusterSpotCamping) : map.removeLayer(clusterSpotCamping); }\n          if(k===\"free\"){ if(clusterSpotFree) on ? map.addLayer(clusterSpotFree) : map.removeLayer(clusterSpotFree); }\n          if(k===\"osmplaces\"){\n            setOsmPlaces(on);\n          }\n          if(k===\"bike\" || k===\"biketool\"){\n          setBikeTool(on);\n        }\n\n        });\n      });\n\n\n      \/\/ \u2705 init layer stato iniziale (OSM sempre acceso)\n      var _osmCh = drop.querySelector('input[type=\"checkbox\"][data-ly=\"osmplaces\"]');\n      if(_osmCh){\n        if(_osmCh.checked){\n          setOsmPlaces(true);\n        }\n      }\n\n      if(osmBtn){\n        osmBtn.addEventListener(\"click\",(e)=>{\n          e.preventDefault(); e.stopPropagation();\n\n          \/\/ feedback chiaro: su mobile sembra \"non cliccabile\" se non succede niente subito\n          if(osmBtn.disabled) return;\n\n          try{\n            osmBtn.disabled = true;\n            var oldTxt = osmBtn.textContent;\n            osmBtn.textContent = trText(\"searchingSpot\",\"Cercando...\");\n\n            if(typeof toast === \"function\") toast(\"Ricerca OSM in corso...\");\n\n            Promise.resolve(loadSpots())\n                .catch(function(err){\n                console.warn(\"loadSpots:\", err);\n                if(typeof toast === \"function\") toast(\"Errore ricerca OSM (vedi console).\");\n              })\n              .finally(function(){\n                osmBtn.disabled = false;\n                osmBtn.textContent = oldTxt || trText(\"searchSpot\",\"Cerca spot\");\n              });\n\n          }catch(ex){\n            console.warn(\"osm search click:\", ex);\n            osmBtn.disabled = false;\n            osmBtn.textContent = trText(\"searchSpot\",\"Cerca spot\");\n          }\n        });\n      }\n\n      recBtn.addEventListener(\"click\",(e)=>{e.preventDefault();e.stopPropagation();recenterMe()});\n      go.addEventListener(\"click\",(e)=>{e.preventDefault();e.stopPropagation();flyToCity(inp.value)});\n      inp.addEventListener(\"keydown\",(e)=>{if(e.key===\"Enter\"){e.preventDefault();flyToCity(inp.value)}});\n\n      return div;\n    }\n  });\n\n  new C().addTo(map);\n}\n\nfunction openNavigator(lat,lng,label){\n  try{\n    const dest=`${Number(lat)},${Number(lng)}`;\n    const nice=(label||\"\").trim();\n    const q=nice?`${dest} (${nice})`:dest;\n    const web=`https:\/\/www.google.com\/maps\/dir\/?api=1&destination=${encodeURIComponent(dest)}&travelmode=driving`;\n    const ua=navigator.userAgent||\"\";\n    const isIOS=\/iPad|iPhone|iPod\/i.test(ua);\n    const isAndroid=\/Android\/i.test(ua);\n    if(isIOS){\n      const ios=`maps:\/\/?daddr=${encodeURIComponent(dest)}&dirflg=d`;\n      location.href=ios;\n      setTimeout(()=>{try{location.href=web}catch{}},700);\n      return;\n    }\n    if(isAndroid){\n      const geo=`geo:0,0?q=${encodeURIComponent(q)}`;\n      location.href=geo;\n      setTimeout(()=>{try{location.href=web}catch{}},700);\n      return;\n    }\n    window.open(web,\"_blank\",\"noopener\");\n  }catch(e){\n    console.warn(\"openNavigator:\",e);\n  }\n}\nwindow.__navTo=openNavigator;\n\nfunction loadPeers(){\n  try{peersSet=new Set((JSON.parse(localStorage.getItem(PEERS_KEY)||\"[]\")||[]).filter(Boolean))}\n  catch{peersSet=new Set()}\n}\nfunction savePeers(){try{localStorage.setItem(PEERS_KEY,JSON.stringify([...peersSet]))}catch{}}\nfunction addPeer(pid){\n  if(!pid) return;\n  if(!peersSet.has(pid)){peersSet.add(pid);savePeers()}\n  chats[activePeer]=chats[activePeer]||[];\n}\nfunction removePeer(pid){\n  if(!pid) return;\n  if(peersSet.has(pid)){peersSet.delete(pid);savePeers()}\n  delete chats[pid];\n  if(activePeer===pid) activePeer=null;\n}\n\nfunction syncNickUI(){\n  const nick=getNick();\n  if(!nick){\n    show($(\"nickRow\"));\n    hide($(\"nickDisplayRow\"));\n    $(\"nickname\").value=\"\";\n  }else{\n    hide($(\"nickRow\"));\n    show($(\"nickDisplayRow\"),\"flex\");\n    $(\"nickDisplay\").innerText=nick;\n    $(\"nickname\").value=nick;\n  }\n}\n\n\/* MENU *\/\nlet menuOpen=false,menuListenersOn=false,menuHooked=false;\nconst getViewportMetrics=()=>{const vv=window.visualViewport;return{w:vv?vv.width:innerWidth,h:vv?vv.height:innerHeight,oTop:vv?vv.offsetTop:0,oLeft:vv?vv.offsetLeft:0}};\nfunction positionMenuDrop(){\n  if(!menuOpen) return;\n  const btn=$(\"menuBtn\"),drop=$(\"menuDrop\"); if(!btn||!drop) return;\n  const r=btn.getBoundingClientRect();\n  const{oTop,oLeft}=getViewportMetrics();\n  const gap=8;\n  const minW=Math.max(240,r.width);\n  const maxW=Math.max(240,(window.visualViewport?window.visualViewport.width:innerWidth)-20);\n  drop.style.width=`${Math.min(maxW,minW)}px`;\n  const left=Math.max(10,r.left+oLeft);\n  drop.style.left=`${left}px`;\n  drop.style.right=\"auto\";\n  drop.style.visibility=\"hidden\";\n  const h=drop.offsetHeight||drop.scrollHeight||0;\n  drop.style.visibility=\"\";\n  const vh=(window.visualViewport?window.visualViewport.height:innerHeight);\n  const spaceBelow=vh-r.bottom-gap-10;\n  const spaceAbove=r.top-gap-10;\n  let top;\n  if(spaceBelow>=h||spaceBelow>=spaceAbove) top=r.bottom+gap;\n  else{ top=r.top-gap-h; if(top<10) top=10; }\n  drop.style.top=`${top+oTop}px`;\n}\nfunction enableMenuReposition(){\n  if(menuListenersOn) return;\n  menuListenersOn=true;\n  addEventListener(\"resize\",positionMenuDrop,{passive:true});\n  addEventListener(\"scroll\",positionMenuDrop,true);\n  if(window.visualViewport){\n    window.visualViewport.addEventListener(\"resize\",positionMenuDrop,{passive:true});\n    window.visualViewport.addEventListener(\"scroll\",positionMenuDrop,{passive:true});\n  }\n}\nfunction disableMenuReposition(){\n  if(!menuListenersOn) return;\n  menuListenersOn=false;\n  removeEventListener(\"resize\",positionMenuDrop);\n  removeEventListener(\"scroll\",positionMenuDrop,true);\n  if(window.visualViewport){\n    window.visualViewport.removeEventListener(\"resize\",positionMenuDrop);\n    window.visualViewport.removeEventListener(\"scroll\",positionMenuDrop);\n  }\n}\nfunction setMenuOpen(on){\n  menuOpen=!!on;\n  const drop=$(\"menuDrop\");\n  drop.classList.toggle(\"open\",menuOpen);\n  drop.setAttribute(\"aria-hidden\",menuOpen?\"false\":\"true\");\n  $(\"menuBtn\").textContent=menuOpen?`${t().menu} \u25b4`:`${t().menu} \u25be`;\n  if(menuOpen){\n    enableMenuReposition();\n    requestAnimationFrame(positionMenuDrop);\n  }else{\n    drop.style.top=drop.style.left=drop.style.right=drop.style.width=drop.style.visibility=\"\";\n    disableMenuReposition();\n  }\n}\nfunction hookMenuUI(){\n  if(menuHooked) return;\n  menuHooked=true;\n  $(\"menuBtn\").onclick=()=>setMenuOpen(!menuOpen);\n  document.addEventListener(\"click\",(e)=>{\n    if(!menuOpen) return;\n    const btn=$(\"menuBtn\"),drop=$(\"menuDrop\");\n    if(btn.contains(e.target)||drop.contains(e.target)) return;\n    setMenuOpen(false);\n  });\n  document.addEventListener(\"keydown\",(e)=>{if(e.key===\"Escape\")setMenuOpen(false)});\n}\n\n\/* UI state *\/\nfunction updateRadarBtn(){const tr=t(); if($(\"radarBtn\")) $(\"radarBtn\").innerText=radarOn?tr.radarOn:tr.radarOff}\nfunction updateNotifBtn(){const tr=t(); if($(\"notifBtn\")) $(\"notifBtn\").innerText=notifOn?tr.notifOn:tr.notifOff}\nfunction updatePickBtnsText(){\n  const tr=t();\n  if($(\"rvPickBtn\")) $(\"rvPickBtn\").textContent=pickMode?tr.rvPickOn:tr.rvPick;\n  if($(\"spotPickBtn\")) $(\"spotPickBtn\").textContent=spotPickMode?tr.spotPickOn:tr.spotPick;\n}\nfunction updateRVPrimaryBtnText(){\n  const tr=t();\n  if(!$(\"rvPublishBtn\")) return;\n  $(\"rvPublishBtn\").textContent=editingRVId?tr.saveEdits:tr.publish;\n  if($(\"rvFormTitle\")) $(\"rvFormTitle\").textContent=editingRVId?tr.rvEditTitle:tr.rvAddTitle;\n  if($(\"rvCancelEditBtn\")) $(\"rvCancelEditBtn\").textContent=tr.cancel;\n}\nfunction updateSpotPrimaryBtnText(){\n  const tr=t();\n  if($(\"spotPublishBtn\")) $(\"spotPublishBtn\").textContent=editingSpotId?tr.saveEdits:tr.addPlace;\n  if($(\"spotCancelEditBtn\")) $(\"spotCancelEditBtn\").textContent=tr.cancel;\n}\n\n\/* PUSH state *\/\nfunction restoreNotifState(){notifOn=localStorage.getItem(NOTIF_KEY)===\"1\";updateNotifBtn()}\nasync function upsertPushToDb(sub){\n  if(!user) return;\n  try{\n    const js=sub.toJSON?sub.toJSON():JSON.parse(JSON.stringify(sub));\n    const payload={user_id:user.id,endpoint:js.endpoint||null,subscription:js};\n    const {error}=await sb.from(PUSH_TABLE).upsert(payload,{onConflict:\"user_id\"});\n    if(error) console.warn(\"push upsert:\",error.message);\n  }catch(e){console.warn(\"upsertPushToDb:\",e)}\n}\nasync function deletePushFromDb(){\n  if(!user) return;\n  try{\n    const {error}=await sb.from(PUSH_TABLE).delete().eq(\"user_id\",user.id);\n    if(error) console.warn(\"push delete:\",error.message);\n  }catch(e){console.warn(\"deletePushFromDb:\",e)}\n}\nasync function enableRealPush(){\n  \/\/ \u2705 Firebase FCM: abilita notifiche e salva il token su Supabase\n  if(typeof window.enablePushFCM === \"function\"){\n    return await window.enablePushFCM();\n  }\n  toast(\"Push: Firebase non inizializzato\");\n}\nasync function disableRealPush(){\n  \/\/ \u2705 Firebase FCM: disabilita notifiche e rimuove il token su Supabase\n  if(typeof window.disablePushFCM === \"function\"){\n    return await window.disablePushFCM();\n  }\n  toast(\"Push: Firebase non inizializzato\");\n  return false;\n}\nasync function callPushEdgeFunction(toUserId, fromName, bodyText){\n  try{\n    if(!window.sb){ console.warn(\"push-fcm: sb non esiste\"); return; }\n    const uid = peerIdFromAny(toUserId);\n    if(!uid){ console.warn(\"push-fcm: Missing user_id\"); return; }\n\n    \/\/ \u2705 usa SEMPRE il token di sessione dell\u2019utente (JWT valido)\n    const sess = await sb.auth.getSession();\n    const access = sess?.data?.session?.access_token || null;\n\n    const looksJwt = (v)=> (typeof v===\"string\" ? v.trim().startsWith(\"eyJ\") : false);\n\n    \/\/ \u2705 apikey: se hai la legacy anon (JWT) usala, altrimenti ripiega sul token di sessione (JWT)\n    const anonJwt = (typeof SB_ANON_JWT!==\"undefined\" ? (looksJwt(SB_ANON_JWT) ? SB_ANON_JWT.trim() : null) : null);\n    const apiKeyJwt = anonJwt || (looksJwt(access) ? access : null) || (typeof SB_KEY!==\"undefined\" ? (looksJwt(SB_KEY) ? SB_KEY : null) : null);\n\n    if(!access){\n      console.warn(\"push-fcm: no session access_token\");\n      return;\n    }\n\n    const url = `${SB_URL}\/functions\/v1\/push-fcm`;\n    const payload = {\n      user_id: uid,\n      to_user_id: uid,\n      from_user_id: (typeof user!==\"undefined\" ? (user ? (user.id ? user.id : null) : null) : (sess?.data?.session?.user?.id || null)),\n      from_name: fromName || \"\",\n      body: bodyText || \"\"\n    };\n\n    console.log(\"push-message payload:\", payload);\n\n    const res = await fetch(url,{\n      method:\"POST\",\n      headers:{\n        \"Content-Type\":\"application\/json\",\n        \"Authorization\":\"Bearer \"+access,\n        ...(apiKeyJwt ? {\"apikey\": apiKeyJwt} : {})\n      },\n      body: JSON.stringify(payload)\n    });\n\n    const txt = await res.text();\n    let parsed = null;\n    try{ parsed = JSON.parse(txt); }catch(_){}\n    if(!res.ok){\n      console.warn(\"push-message NON OK:\", res.status, parsed || txt);\n    }else{\n      console.log(\"push-message OK:\", parsed || txt);\n    }\n  }catch(e){\n    console.warn(\"callPushEdgeFunction:\", e);\n  }\n}\n\n\nfunction maybeNotifyMessage(m){\n  try{\n    if(!notifOn||!(\"Notification\" in window)||Notification.permission!==\"granted\") return;\n    const senderName=safeName(m.sender);\n    const shouldNotify=document.hidden||activePeer!==m.sender;\n    if(!shouldNotify) return;\n    unreadCount++;\n    document.title=unreadCount?`(${unreadCount}) ${APP_NAME}`:APP_NAME;\n    if(navigator.vibrate) navigator.vibrate([120,60,120]);\n    new Notification(\"Nuovo messaggio\",{body:`${senderName}: ${(m.body||\"\").slice(0,160)}`});\n  }catch{}\n}\naddEventListener(\"visibilitychange\",()=>{\n  if(!document.hidden){\n    unreadCount=0;\n    document.title=APP_NAME;\n  }\n});\n\n\/* AUTH *\/\nasync function login(){\n  const email=($(\"email\").value||\"\").trim();\n  const password=$(\"password\").value;\n  let nickname=($(\"nickname\").value||\"\").trim()||getNick();\n  if(!nickname) return alert(\"Inserisci il Nick name (solo la prima volta).\");\n  if(!email||!password) return alert(\"Inserisci email e password.\");\n  setNick(nickname);\n  const {error}=await sb.auth.signInWithPassword({email,password});\n  if(error) alert(error.message); else location.reload();\n}\nasync function signup(){\n  const email=($(\"email\").value||\"\").trim();\n  const password=$(\"password\").value;\n  let nickname=($(\"nickname\").value||\"\").trim()||getNick();\n  if(!nickname) return alert(\"Inserisci il Nick name.\");\n  if(!email||!password) return alert(\"Inserisci email e password.\");\n  setNick(nickname);\n  const {error}=await sb.auth.signUp({email,password});\n  if(error) alert(error.message); else alert(\"Registrato. Ora fai login.\");\n}\nasync function logout(){await sb.auth.signOut(); location.reload()}\nasync function ensureMyNickname(){\n  if(!user) return;\n  let nick=getNick();\n  if(!nick){\n    const {data,error}=await sb.from(\"profiles\").select(\"display_name\").eq(\"user_id\",user.id).maybeSingle();\n    if(!error ? (data ? (data.display_name ? true : false) : false) : false){\n      setNick(data.display_name);\n      syncNickUI();\n      return;\n    }\n    const suggested=(user.email||\"\").split(\"@\")[0]||\"Camperista\";\n    const chosen=(prompt(\"Scegli un Nick name (solo la prima volta):\",suggested)||\"\").trim()||suggested;\n    setNick(chosen);\n    nick=chosen;\n    syncNickUI();\n  }\n  const {error:upErr}=await sb.from(\"profiles\").upsert({user_id:user.id,display_name:nick},{onConflict:\"user_id\"});\n  if(upErr) console.warn(\"profiles upsert:\",upErr.message);\n}\n\n\/* AMENITIES *\/\nfunction renderAmenityButtons(){AMEN_KEYS.forEach(k=>$(AMEN_EL[k])?.classList.toggle(\"on\",!!spotAmenities[k]))}\nfunction resetAmenities(){spotAmenities={power:false,water:false,dump:false,toilet:false,pets:false};renderAmenityButtons()}\nfunction hookAmenitiesUI(){\n  AMEN_KEYS.forEach(k=>{\n    const el=$(AMEN_EL[k]);\n    if(el) el.onclick=()=>{spotAmenities[k]=!spotAmenities[k];renderAmenityButtons()}\n  });\n  renderAmenityButtons();\n}\n\n\/* CLUSTERS \/ MAP *\/\nfunction makeClusterIcon(kind,count){\n  let border=\"rgba(0,229,255,.70)\",glow=\"rgba(0,229,255,.22)\",badge=\"\ud83d\udc65\";\n  if(kind===\"camping\"){border=\"rgba(255,43,214,.70)\";glow=\"rgba(255,43,214,.22)\";badge=\"\ud83c\udfd5\ufe0f\"}\n  if(kind===\"free\"){border=\"rgba(255,216,77,.75)\";glow=\"rgba(255,216,77,.22)\";badge=\"\ud83c\udd7f\ufe0f\"}\n  const html=`<div style=\"width:44px;height:44px;border-radius:18px;display:grid;place-items:center;background:rgba(5,7,13,.92);border:2px solid ${border};box-shadow:0 0 16px ${glow};position:relative;font-weight:1000\"><div style=\"font-size:16px;line-height:1\">${badge}<\/div><div style=\"position:absolute;right:-6px;bottom:-6px;width:26px;height:26px;border-radius:999px;display:grid;place-items:center;background:rgba(5,7,13,.92);border:2px solid ${border};color:var(--txt);font-size:12px\">${count}<\/div><\/div>`;\n  return L.divIcon({className:\"\",html,iconSize:[44,44],iconAnchor:[22,22]});\n}\nfunction userIcon(){\n  return L.divIcon({className:\"\",html:`<div style=\"width:34px;height:34px;border-radius:14px;display:grid;place-items:center;background:rgba(5,7,13,.92);border:2px solid rgba(0,229,255,.85);box-shadow:0 0 14px rgba(0,229,255,.25);font-size:18px\">\ud83d\ude90<\/div>`,iconSize:[34,34],iconAnchor:[17,17],popupAnchor:[0,-14]});\n}\nfunction initClusters(){\n  clusterUsers=L.markerClusterGroup({spiderfyOnMaxZoom:true,showCoverageOnHover:false,zoomToBoundsOnClick:true,disableClusteringAtZoom:18,maxClusterRadius:55,iconCreateFunction:(c)=>makeClusterIcon(\"users\",c.getChildCount())});\n  clusterSpotCamping=L.markerClusterGroup({spiderfyOnMaxZoom:true,showCoverageOnHover:false,zoomToBoundsOnClick:true,disableClusteringAtZoom:18,maxClusterRadius:55,iconCreateFunction:(c)=>makeClusterIcon(\"camping\",c.getChildCount())});\n  clusterSpotFree=L.markerClusterGroup({spiderfyOnMaxZoom:true,showCoverageOnHover:false,zoomToBoundsOnClick:true,disableClusteringAtZoom:18,maxClusterRadius:55,iconCreateFunction:(c)=>makeClusterIcon(\"free\",c.getChildCount())});\n  map.addLayer(clusterUsers);map.addLayer(clusterSpotCamping);map.addLayer(clusterSpotFree);\n}\nfunction initLayerControl(){\n  try{if(layerControl) map.removeControl(layerControl)}catch{}\n  layerControl=L.control.layers(null,{\n    \"\ud83d\udc65 Ospiti (Radar)\":clusterUsers,\n    \"\ud83c\udfd5\ufe0f Spot Camping \/ Area sosta\":clusterSpotCamping,\n    \"\ud83c\udd7f\ufe0f Spot in libera\":clusterSpotFree\n  },{collapsed:true,position:\"bottomleft\"}).addTo(map);\n}\nfunction lockMapInteractions(lock){\n  if(!map) return;\n  try{\n    [\"dragging\",\"touchZoom\",\"doubleClickZoom\",\"scrollWheelZoom\",\"boxZoom\",\"keyboard\"].forEach(p=>{\n      if(map[p]) lock?map[p].disable():map[p].enable();\n    });\n    if(map.tap) lock?map.tap.disable():map.tap.enable();\n  }catch{}\n}\nfunction installSpotPopupGuards(){\n  const c = map.getContainer();\n  const isInsidePopup = (target)=>{\n    const pop = c.querySelector(\".leaflet-popup\");\n    if(pop) return pop.contains(target);\n  return false;\n  };\n\n  const stopper = (ev)=>{\n    if(!spotPopupOpen) return;\n    if(isInsidePopup(ev.target)) return;\n    ev.preventDefault(); ev.stopPropagation();\n  };\n\n  c.addEventListener(\"touchstart\", stopper, {passive:false, capture:true});\n  c.addEventListener(\"touchmove\",  stopper, {passive:false, capture:true});\n  c.addEventListener(\"mousedown\",  stopper, true);\n}\n\nfunction escHtml(s){\n  return String(s??\"\").replace(\/[&<>\"']\/g, m=>({ \"&\":\"&amp;\",\"<\":\"&lt;\",\">\":\"&gt;\",'\"':\"&quot;\",\"'\":\"&#39;\" }[m]));\n}\n\nfunction hasProfile(u){\n  \/\/ adattabile: considera \u201cha profilo\u201d se ha almeno uno di questi campi\n  return !!(u?.bio || u?.about || u?.description || u?.contact || u?.avatar_url || u?.photo_url);\n}\n\nfunction togglePuMenu(btn){\n  const box = btn.closest(\".pu\");\n  if(box) box.classList.toggle(\"open\");\n}\n\nfunction puChat(uid){\n  const u = profiles?.[uid] || Object.values(profiles||{}).find(x=>x.user_id===uid);\n  if(!u) return;\n  try{ map?.closePopup(); }catch(e){}\n  \/\/ \ud83d\udc47 QUI metti la TUA funzione chat (una di queste due righe)\n  if(typeof openChatWith===\"function\") openChatWith(uid);\n  else if(typeof openChat===\"function\") openChat(uid);\n}\n\nfunction puProfile(uid){\n  const u = profiles?.[uid] || Object.values(profiles||{}).find(x=>x.user_id===uid);\n  if(!u) return;\n  if(!hasProfile(u)) return; \/\/ se non ha profilo, non fa nulla\n  try{ map?.closePopup(); }catch(e){}\n  \/\/ \ud83d\udc47 QUI metti la TUA funzione profilo (una di queste due righe)\n  if(typeof openProfile===\"function\") openProfile(u);\n  else if(typeof openProfileModal===\"function\") openProfileModal(u);\n}\n\nfunction userPopupHtml(u){\n  const uid = u.user_id;\n  const name = escHtml(u.display_name || u.username || uid || \"Utente\");\n  const profOk = hasProfile(u);\n\n  return `\n    <div class=\"pu\">\n      <button class=\"puName\" type=\"button\" onclick=\"togglePuMenu(this)\">${name} \u25be<\/button>\n      <div class=\"puMenu\">\n        <button type=\"button\" onclick=\"puChat('${uid}')\">Chat<\/button>\n        ${profOk\n          ? `<button type=\"button\" onclick=\"puProfile('${uid}')\">Profilo<\/button>`\n          : `<span class=\"puNA\">Profilo: N\/A<\/span>`\n        }\n      <\/div>\n    <\/div>\n  `;\n}\n\nfunction rebuildOtherMarkers(){\n  if(!map || !user || !clusterUsers) return;\n  clearOtherMarkers();\n\n  Object.values(profiles).forEach(u=>{\n    if(!u?.lat || !u?.lng || u.user_id===user.id) return;\n\n    const m = L.marker([u.lat,u.lng], { icon: userIcon() })\n      .bindPopup(userPopupHtml(u), { closeButton:true, autoPan:true });\n\n\n    m.on(\"click\", (ev)=>{\n      try{ if(ev?.originalEvent) L.DomEvent.stopPropagation(ev.originalEvent) }catch{}\n      openUserMenu(u);\n    });\n\n    clusterUsers.addLayer(m);\n  });\n}\n\nfunction clearOtherMarkers(){\n  try{\n    if(clusterUsers) clusterUsers.clearLayers(); \/\/ elimina tutti i marker \u201caltri utenti\u201d nel cluster\n  }catch(e){\n    console.warn(\"clearOtherMarkers:\", e);\n  }\n}\n\nfunction setRVPickSheet(on){\n  const m=$(\"rvFormModal\"); if(!m) return;\n  m.classList.toggle(\"pick\",!!on);\n}\n\nasync function handleMapPick(e){\n  if(pickMode){\n    pickedLatLng=e.latlng;\n    $(\"rvAddress\").value=await reverseGeocode(pickedLatLng.lat,pickedLatLng.lng);\n    pickMode=false;updatePickBtnsText();setRVPickSheet(false);lockMapInteractions(true);\n    return;\n  }\n  if(spotPickMode){\n    pickedSpotLatLng=e.latlng;\n    $(\"spotAddress\").value=await reverseGeocode(pickedSpotLatLng.lat,pickedSpotLatLng.lng);\n    spotPickMode=false;updatePickBtnsText();$(\"spotPanel\").classList.remove(\"pick\");lockMapInteractions(true);\n  }\n}\n\n\/* \u2705 START VIEW: zoom automatico su raggio 100km dalla tua posizione (reale o stimata) *\/\nvar CP_START_RADIUS_M = 100000;\nvar _cpStartRadiusDone = false;\n\nfunction _cpFitRadius(lat,lng,radiusM){\n  try{\n    if(!map) return;\n    if(!window) return;\n    if(!window.L) return;\n\n    if(typeof radiusM !== \"number\") radiusM = parseFloat(radiusM);\n    if(!isFinite(radiusM)) radiusM = CP_START_RADIUS_M;\n\n    if(typeof lat !== \"number\") lat = parseFloat(lat);\n    if(typeof lng !== \"number\") lng = parseFloat(lng);\n\n    if(!isFinite(lat)) return;\n    if(!isFinite(lng)) return;\n\n    var center = L.latLng(lat, lng);\n\n    \/\/ Leaflet: evitiamo L.circle().getBounds() perch\u00e9 senza aggiunta a mappa pu\u00f2 dare errori (_map undefined)\n    var bounds = null;\n    try{\n      \/\/ toBounds(sizeInMeters) => quadrato di \"size\" metri: per un raggio R uso size=R*2\n      bounds = center.toBounds(radiusM * 2);\n    }catch(e2){\n      bounds = null;\n    }\n\n    if(bounds){\n      map.fitBounds(bounds, { padding:[20,20] });\n    }else{\n      map.setView([lat,lng], 9, { animate:false });\n    }\n  }catch(e){\n    console.warn(\"_cpFitRadius:\", e);\n  }\n}\n\nfunction cpStartZoomRadius(){\n  try{\n    if(_cpStartRadiusDone) return;\n    _cpStartRadiusDone = true;\n\n    function fallback(){\n      try{\n        if(!map) return;\n        var c = null;\n        try{ c = map.getCenter(); }catch(e3){ c = null; }\n        if(c){\n          _cpFitRadius(c.lat, c.lng, CP_START_RADIUS_M);\n        }\n      }catch(e4){}\n    }\n\n    if(!navigator){ fallback(); return; }\n    if(!navigator.geolocation){ fallback(); return; }\n\n    navigator.geolocation.getCurrentPosition(\n      function(p){\n        try{\n          var lat = null;\n          var lng = null;\n\n          if(p){\n            if(p.coords){\n              lat = p.coords.latitude;\n              lng = p.coords.longitude;\n            }\n          }\n\n          if(typeof lat !== \"number\") lat = parseFloat(lat);\n          if(typeof lng !== \"number\") lng = parseFloat(lng);\n\n          if(!isFinite(lat)){ fallback(); return; }\n          if(!isFinite(lng)){ fallback(); return; }\n\n          \/\/ Dopo che la mappa ha dimensioni corrette, faccio il fit\n          setTimeout(function(){\n            try{\n              if(map) map.invalidateSize(true);\n            }catch(e5){}\n            _cpFitRadius(lat, lng, CP_START_RADIUS_M);\n          }, 80);\n\n        }catch(e6){\n          fallback();\n        }\n      },\n      function(err){\n        fallback();\n      },\n      { enableHighAccuracy:false, timeout:9000, maximumAge:600000 }\n    );\n  }catch(e){\n    console.warn(\"cpStartZoomRadius:\", e);\n  }\n}\n\n\nfunction initMap(withUsers){\n  \/\/ \u2705 safe re-init (prevents \"Map container is already initialized.\")\n  try{\n    var cont = document.getElementById(\"map\");\n    if(map){\n      try{ map.off(); }catch(e){}\n      try{ map.remove(); }catch(e){}\n      map = null;\n    }\n    if(cont){\n      try{\n        if(cont._leaflet_id){ try{ delete cont._leaflet_id; }catch(e){} try{ cont._leaflet_id=null; }catch(e){} }\n      }catch(e){}\n    }\n  }catch(e){}\n  map=L.map(\"map\",{tap:false,closePopupOnClick:false}).setView([45.4,11],8);\n  map.options.closePopupOnClick=false;\n  L.tileLayer(\"https:\/\/{s}.tile.openstreetmap.org\/{z}\/{x}\/{y}.png\",{maxZoom:19}).addTo(map);\n  initClusters();installSpotPopupGuards();\n\n  \/* \u2705 UI bottom bar (Layers + Center + Search) *\/\n  initBottomBarControl();\n  setTimeout(function(){ try{ map.invalidateSize(true); }catch(e){} },300);\n  setTimeout(function(){ cpStartZoomRadius(); },360);\n  if(withUsers) rebuildOtherMarkers();\n  map.on(\"click\",handleMapPick);\n}\n\n\/* RADAR *\/\nfunction restoreRadarState(){\n  const t0=localStorage.getItem(RADAR_KEY);\n  if((t0 ? (Date.now()-Number(t0)<RADAR_DURATION) : false)){\n    radarOn=true; startGeo();\n  }else radarOn=false;\n  updateRadarBtn();\n}\nasync function toggleRadar(){\n  if(!user) return alert(t().profLoginReq);\n  if(radarOn){\n    radarOn=false; localStorage.removeItem(RADAR_KEY);\n    if(myMarker) map.removeLayer(myMarker);\n    myMarker=null;\n    if(geoWatch) navigator.geolocation.clearWatch(geoWatch);\n    geoWatch=null;\n    try{\n      const {error}=await sb.from(\"profiles\").update({lat:null,lng:null}).eq(\"user_id\",user.id);\n      if(error) console.warn(\"profiles clear lat\/lng:\",error.message);\n    }catch(e){console.warn(e)}\n  }else{\n    radarOn=true; localStorage.setItem(RADAR_KEY,String(Date.now()));\n    startGeo();\n  }\n  updateRadarBtn();\n}\nfunction startGeo(){\n  if(!navigator.geolocation) return;\n  geoWatch=navigator.geolocation.watchPosition(async p=>{\n    const {latitude:lat,longitude:lng}=p.coords;\n    if(!myMarker) myMarker=L.circleMarker([lat,lng],{radius:8,color:\"#ff3333\",fillColor:\"#ff0000\",fillOpacity:1}).addTo(map);\n    else myMarker.setLatLng([lat,lng]);\n    await sb.from(\"profiles\").update({lat,lng}).eq(\"user_id\",user.id);\n  });\n}\n\n\/* MODALS *\/\nfunction modalOpen(modalId){const m=$(modalId); if(!m) return; m.classList.add(\"open\"); setAria(m,true)}\nfunction modalClose(modalId){const m=$(modalId); if(!m) return; m.classList.remove(\"open\"); setAria(m,false)}\nfunction bindModal(modalId,closeBtnId,onClose){\n  const m=$(modalId),btn=$(closeBtnId);\n  if(btn) btn.onclick=()=>{if(onClose) onClose(); modalClose(modalId)};\n  if(m) m.addEventListener(\"click\",(e)=>{if(e.target===m){e.preventDefault();e.stopPropagation()}},{passive:false});\n}\n\n\/* CHAT MODAL *\/\nfunction openChatModal(){\n  if(chatModalOpen) return;\n  if(!user) return alert(t().profLoginReq);\n  $(\"spotPanel\").classList.remove(\"open\");\n  modalClose(\"spotModal\");modalClose(\"rvListModal\");modalClose(\"rvFormModal\");\n  const body=$(\"chatBody\");\n  if(!chatBodyHome) chatBodyHome=body.parentElement;\n  $(\"chatModalBody\").appendChild(body);\n  body.style.display=\"grid\";\n  modalOpen(\"chatModal\");\n  chatModalOpen=true;\n  lockMapInteractions(true);\n  renderChatList();renderMessages();\n}\nfunction closeChatModal(){\n  if(!chatModalOpen) return;\n  const body=$(\"chatBody\");\n  try{if(chatBodyHome) chatBodyHome.appendChild(body)}catch{}\n  body.style.display=\"none\";\n  $(\"chatModalBody\").innerHTML=\"\";\n  modalClose(\"chatModal\");\n  chatModalOpen=false;\n  lockMapInteractions(false);\n}\nfunction hookChatModalUI(){\n  bindModal(\"chatModal\",\"chatModalCloseBtn\",closeChatModal);\n  document.addEventListener(\"keydown\",(e)=>{if(e.key===\"Escape\") closeChatModal()});\n}\n\n\/* CHAT *\/\n\n\/* \u2705 ADD-ON: normalize peer id (string UUID) *\/\nfunction peerIdFromAny(p){\n  if(!p) return null;\n  if(typeof p===\"string\") return p;\n  \/\/ common shapes\n  return p.user_id || p.id || p.uid || p.to_user_id || p.receiver || p.sender || null;\n}\n\nfunction openChatFromMap(pid){\n  const id = peerIdFromAny(pid);\n  if(!id) return;\n  activePeer=id;\n  chats[pid]=chats[pid]||[];\n  renderChatList();renderMessages();\n  unreadCount=0;document.title=APP_NAME;\n  openChatModal();\n}\nfunction openChat(pid){\n  const id = peerIdFromAny(pid);\n  if(!id) return;\n  activePeer=id;\n  renderChatList();renderMessages();\n  unreadCount=0;document.title=APP_NAME;\n  openChatModal();\n}\nasync function deleteChat(pid){\n  if(!confirm(t().delChatAsk)) return;\n  try{\n    const del1=await sb.from(\"messages\").delete().match({sender:user.id,receiver:pid});\n    const del2=await sb.from(\"messages\").delete().match({sender:pid,receiver:user.id});\n    if(del1?.error) console.warn(\"delete out:\",del1.error.message);\n    if(del2?.error) console.warn(\"delete in:\",del2.error.message);\n  }catch(e){console.warn(e)}\n  removePeer(pid);\n  renderChatList();renderMessages();\n}\nfunction renderChatList(){\n  $(\"chatList\").innerHTML=\"\";\n  const peers=[...peersSet];\n  peers.sort((a,b)=>{\n    const la=chats[a]?.at(-1)?.created_at||\"\",lb=chats[b]?.at(-1)?.created_at||\"\";\n    if(la===lb) return String(safeName(a) ?? \"\").localeCompare(String(safeName(b) ?? \"\"), \"it\", { sensitivity:\"base\" });\n    return la<lb?1:-1;\n  });\n  if(!peers.length){\n    const e=document.createElement(\"div\");\n    e.style.color=\"var(--muted)\";\n    e.style.padding=\"10px\";\n    e.textContent=t().noChats;\n    $(\"chatList\").appendChild(e);\n    return;\n  }\n  peers.forEach(pid=>{\n    chats[pid]=chats[pid]||[];\n    const c=chats[pid];\n    const el=document.createElement(\"div\");\n    el.className=\"chatItem\"+(pid===activePeer?\" active\":\"\");\n    el.onclick=()=>openChat(pid);\n    el.innerHTML=`<div class=\"chatText\"><div class=\"chatTopRow\"><div class=\"chatName\">${esc(safeName(pid))}<\/div><\/div><div class=\"chatLast\">${esc(c.at(-1)?.body||\"\")}<\/div><\/div>`;\n    const d=document.createElement(\"button\");\n    d.className=\"chatDel btnRed\";\n    d.type=\"button\";\n    d.textContent=\"\ud83d\uddd1\";\n    d.onclick=async(e)=>{e.stopPropagation(); await deleteChat(pid)};\n    el.querySelector(\".chatTopRow\").appendChild(d);\n    $(\"chatList\").appendChild(el);\n  });\n}\nfunction renderMessages(){\n  $(\"messages\").innerHTML=\"\";\n  if(!activePeer){\n    $(\"chatHeader\").innerText=t().selChat;\n    $(\"msgInput\").disabled=true;\n    $(\"sendBtn\").disabled=true;\n    return;\n  }\n  $(\"chatHeader\").innerText=safeName(activePeer);\n  $(\"msgInput\").disabled=false;\n  $(\"sendBtn\").disabled=false;\n  (chats[activePeer]||[]).forEach(m=>{\n    const d=document.createElement(\"div\");\n    d.className=\"msg \"+(m.sender===user.id?\"me\":\"them\");\n    d.innerText=m.body;\n    $(\"messages\").appendChild(d);\n  });\n  $(\"messages\").scrollTop=$(\"messages\").scrollHeight;\n}\nasync function sendMsg(){\n  const t0=$(\"msgInput\").value.trim();\n  const peerId = peerIdFromAny(activePeer);\n  if(!t0||!peerId) return;\n  activePeer = peerId;\n  addPeer(peerId);\n\n  \/\/ \u2705 Optimistic render (per reattivit\u00e0) + poi allineamento ai valori del DB\n  const m={sender:user.id,receiver:peerId,body:t0,created_at:new Date().toISOString()};\n  chats[peerId]=chats[peerId]||[];\n  chats[peerId].push(m);\n  renderMessages();renderChatList();\n  $(\"msgInput\").value=\"\";\n\n  \/\/ \u2705 Insert + ritorno riga dal DB (cos\u00ec evitiamo il doppio messaggio al polling)\n  const { data: insRow, error: insErr } = await sb\n    .from(\"messages\")\n    .insert({ sender:user.id, receiver:peerId, body:t0 })\n    .select(\"*\")\n    .single();\n\n  if(insErr){\n    console.warn(\"insert message:\", insErr.message || insErr);\n  }else if(insRow){\n    \/\/ sostituisci l'optimistic con la riga DB (created_at\/id definitivi)\n    const arr = chats[peerId]||[];\n    const idx = arr.indexOf(m);\n    if(idx>=0) arr[idx]=insRow;\n    else if(!arr.some(x => (x.id ? (insRow.id ? (x.id===insRow.id) : false) : false) || (x.created_at===insRow.created_at))) arr.push(insRow);\n  }\n\n  \/\/ \u2705 SEMPRE: la push va al destinatario, non dipende dalle notifiche del mittente\n  await callPushEdgeFunction(peerId,myDisplayNick(),t0);\n}\nasync function fetchMyMessages(){\n  if(!user) return [];\n  const {data,error}=await sb.from(\"messages\").select(\"*\").or(`receiver.eq.${user.id},sender.eq.${user.id}`).order(\"created_at\",{ascending:true});\n  if(error){console.warn(\"fetchMyMessages:\",error.message);return []}\n  return data||[];\n}\nfunction startPolling(){\n  setInterval(async()=>{\n    if(!user) return;\n    const data=await fetchMyMessages();\n    if(!data.length) return;\n    data.forEach(m=>{\n      const other=(m.sender===user.id)?m.receiver:m.sender;\n      if(!other) return;\n      if(m.sender===user.id||m.receiver===user.id) addPeer(other);\n      chats[other]=chats[other]||[];\n      if(chats[other].some(x=>(x.id ? (m.id ? (x.id===m.id) : false) : false)||x.created_at===m.created_at)) return;\n      chats[other].push(m);\n      if(m.receiver===user.id) maybeNotifyMessage(m);\n    });\n    renderChatList();\n    if(activePeer) renderMessages();\n  },3000);\n}\n\n\/* REFRESH *\/\nasync function refreshApp(){\n  try{\n    const btn=$(\"refreshBtn\");\n    if(btn){btn.disabled=true;btn.textContent=\"\u23f3\"}\n    await loadProfiles();\n    if(map){ if(user) rebuildOtherMarkers(); }\n\n    if(user){\n      const data=await fetchMyMessages();\n      if(data.length){\n        const newChats={};\n        data.forEach(m=>{\n          const other=(m.sender===user.id)?m.receiver:m.sender;\n          if(!other) return;\n          if(m.receiver===user.id) addPeer(other);\n          if(m.sender===user.id) addPeer(other);\n          if(!peersSet.has(other)) return;\n          newChats[other]=newChats[other]||[];\n          if(newChats[other].some(x=>(x.id ? (m.id ? (x.id===m.id) : false) : false)||x.created_at===m.created_at)) return;\n          newChats[other].push(m);\n        });\n        chats=newChats;\n      }\n    }\n    await loadRV();\n    await loadSpots();\n    if(bikeToolOn) await loadBikeTours();\n    renderChatList();renderMessages();\n  }catch(e){console.warn(\"refreshApp:\",e)}\n  finally{\n    const btn=$(\"refreshBtn\");\n    if(btn){btn.disabled=false;btn.textContent=t().refresh}\n  }\n}\n\n\/* RENDEZVOUS *\/\nfunction openRVListModal(){\n  return (async()=>{\n    if(!user) return alert(t().profLoginReq);\n    setMenuOpen(false);\n    $(\"spotPanel\").classList.remove(\"open\");\n    modalClose(\"spotModal\");\n    if(chatModalOpen) closeChatModal();\n    modalClose(\"rvFormModal\");\n    setRVPickSheet(false);\n    modalOpen(\"rvListModal\");\n    lockMapInteractions(true);\n    await loadRV();\n  })();\n}\nfunction closeRVListModal(){modalClose(\"rvListModal\");lockMapInteractions(false)}\nfunction resetRVForm(){\n  editingRVId=null;updateRVPrimaryBtnText();\n  $(\"rvCancelEditBtn\").style.display=\"none\";\n  $(\"rvAddress\").value=\"\";$(\"rvContact\").value=\"\";$(\"rvText\").value=\"\";$(\"rvStart\").value=\"\";$(\"rvEnd\").value=\"\";\n  pickedLatLng=null;pickMode=false;updatePickBtnsText();setRVPickSheet(false);\n}\nfunction openRVFormModal(mode=\"new\"){\n  if(!user) return alert(t().profLoginReq);\n  setMenuOpen(false);\n  modalClose(\"rvListModal\");\n  modalOpen(\"rvFormModal\");\n  lockMapInteractions(true);\n  if(mode===\"new\") resetRVForm();\n  updateRVPrimaryBtnText();\n}\nfunction closeRVFormModal(){\n  pickMode=false;updatePickBtnsText();setRVPickSheet(false);\n  modalClose(\"rvFormModal\");\n  lockMapInteractions(false);\n}\n\nfunction hookRVUI(){\n  $(\"rvToggle\").onclick=()=>openRVListModal();\n  bindModal(\"rvListModal\",\"rvListCloseBtn\",closeRVListModal);\n  bindModal(\"rvFormModal\",\"rvFormCloseBtn\",closeRVFormModal);\n  $(\"rvAddBtn\").onclick=()=>openRVFormModal(\"new\");\n  $(\"rvPickBtn\").onclick=()=>{\n    if(!user) return alert(t().profLoginReq);\n    pickMode=!pickMode;\n    updatePickBtnsText();\n    setRVPickSheet(pickMode);\n    lockMapInteractions(!pickMode?true:false);\n  };\n  $(\"rvPublishBtn\").onclick=publishOrUpdateRV;\n  $(\"rvCancelEditBtn\").onclick=()=>{resetRVForm()};\n  document.addEventListener(\"keydown\",(e)=>{\n    if(e.key!==\"Escape\") return;\n    if($(\"rvFormModal\").classList.contains(\"open\")) closeRVFormModal();\n    else if($(\"rvListModal\").classList.contains(\"open\")) closeRVListModal();\n  });\n}\n\nasync function publishOrUpdateRV(){\n  if(!user) return alert(t().profLoginReq);\n  const address=$(\"rvAddress\").value.trim(),\n        contact=$(\"rvContact\").value.trim(),\n        text=$(\"rvText\").value.trim(),\n        startLocal=($(\"rvStart\").value||\"\").trim(),\n        endLocal=($(\"rvEnd\").value||\"\").trim();\n  if(!address||!contact||!text||!startLocal||!endLocal) return alert(\"Compila tutto (indirizzo, contatto, messaggio, DAL e AL).\");\n  if(text.length>200) return alert(\"Max 200 caratteri.\");\n  const startIso=new Date(startLocal).toISOString();\n  const endIso=new Date(endLocal).toISOString();\n  if(new Date(endIso)<=new Date(startIso)) return alert(\"La data AL deve essere dopo la data DAL.\");\n  const myNick=profiles?.[user.id]?.display_name||getNick()||user.email;\n  const payload={user_id:user.id,username:myNick,address,contact,text,start_at:startIso,end_at:endIso,lat:pickedLatLng?pickedLatLng.lat:null,lng:pickedLatLng?pickedLatLng.lng:null};\n\n  if(editingRVId){\n    const {error}=await sb.from(\"rendezvous\").update(payload).eq(\"id\",editingRVId).eq(\"user_id\",user.id);\n    if(error) return alert(error.message);\n  }else{\n    const {error}=await sb.from(\"rendezvous\").insert(payload);\n    if(error) return alert(error.message);\n  }\n  resetRVForm();closeRVFormModal();await loadRV();\n  modalOpen(\"rvListModal\");lockMapInteractions(true);\n}\n\nasync function loadRVReplies(rvIds){\n  const out={}; if(!rvIds?.length) return out;\n  try{\n    const {data,error}=await sb.from(RV_REPLIES_TABLE).select(\"*\").in(\"rv_id\",rvIds).order(\"created_at\",{ascending:true});\n    if(error){console.warn(\"load replies:\",error.message);return out}\n    (data||[]).forEach(r=>{(out[r.rv_id]=out[r.rv_id]||[]).push(r)});\n  }catch(e){console.warn(e)}\n  return out;\n}\nasync function addRVReply(rvId,text){\n  if(!user) return alert(t().profLoginReq);\n  const body=(text||\"\").trim();\n  if(!body) return alert(\"Scrivi una risposta.\");\n  if(body.length>RV_REPLY_MAX) return alert(`Max ${RV_REPLY_MAX} caratteri.`);\n  const {error}=await sb.from(RV_REPLIES_TABLE).insert({rv_id:rvId,user_id:user.id,username:myDisplayNick(),body});\n  if(error) return alert(error.message);\n  await loadRV();\n}\nasync function deleteRVReply(replyId){\n  if(!user) return alert(t().profLoginReq);\n  if(!confirm(\"Cancellare il commento?\")) return;\n  const {error}=await sb.from(RV_REPLIES_TABLE).delete().eq(\"id\",replyId).eq(\"user_id\",user.id);\n  if(error) return alert(error.message);\n  await loadRV();\n}\nasync function deleteRV(id){\n  if(!confirm(\"Cancellare l'annuncio?\")) return;\n  const {error}=await sb.from(\"rendezvous\").delete().eq(\"id\",id).eq(\"user_id\",user.id);\n  if(error) return alert(error.message);\n  try{await sb.from(RV_REPLIES_TABLE).delete().eq(\"rv_id\",id)}catch{}\n  await loadRV();\n}\nfunction editRV(rv){\n  editingRVId=rv.id;\n  updateRVPrimaryBtnText();\n  $(\"rvCancelEditBtn\").style.display=\"inline-block\";\n  $(\"rvAddress\").value=rv.address||\"\";\n  $(\"rvContact\").value=rv.contact||\"\";\n  $(\"rvText\").value=rv.text||\"\";\n  $(\"rvStart\").value=toLocalInputValue(rv.start_at);\n  $(\"rvEnd\").value=toLocalInputValue(rv.end_at);\n  pickedLatLng=null; if(rv.lat){ if(rv.lng) pickedLatLng={lat:rv.lat,lng:rv.lng}; }\n  openRVFormModal(\"edit\");\n}\nasync function cleanupExpiredRVs(list){\n  const now=Date.now(),ttl=86400000;\n  const expired=(list||[]).filter(rv=>{\n    if(!rv?.end_at||!rv?.id) return false;\n    const t1=+new Date(rv.end_at);\n    return (isFinite(t1) ? (now>(t1+ttl)) : false);\n  });\n  if(!expired.length) return {cleaned:0,remaining:list||[]};\n  const ids=expired.map(x=>x.id);\n  try{\n    const del=await sb.from(\"rendezvous\").delete().in(\"id\",ids);\n    if(del?.error) console.warn(\"cleanup rendezvous:\",del.error.message);\n    try{await sb.from(RV_REPLIES_TABLE).delete().in(\"rv_id\",ids)}catch{}\n  }catch(e){console.warn(\"cleanupExpiredRVs:\",e)}\n  const remaining=(list||[]).filter(rv=>!ids.includes(rv.id));\n  return {cleaned:ids.length,remaining};\n}\nasync function loadRV(){\n  $(\"rvList\").innerHTML=\"\";\n  const {data,error}=await sb.from(\"rendezvous\").select(\"*\").order(\"created_at\",{ascending:false});\n  if(error){console.warn(error.message);return}\n  const cleaned=await cleanupExpiredRVs(data||[]);\n  const list=cleaned.remaining||[];\n  const rvIds=list.map(x=>x.id).filter(Boolean);\n  const repliesMap=await loadRVReplies(rvIds);\n  const tr=t();\n\n  list.forEach(rv=>{\n    const d=document.createElement(\"div\");d.className=\"rvItem\";\n    const created=rv.created_at?new Date(rv.created_at).toLocaleString(\"it-IT\"):\"\";\n    const startW=rv.start_at?new Date(rv.start_at).toLocaleString(\"it-IT\"):\"\u2014\";\n    const endW=rv.end_at?new Date(rv.end_at).toLocaleString(\"it-IT\"):\"\u2014\";\n\n    var rvLblContact = \"Contatto\";\n    if(tr!=null){\n      if(tr.spotContactLbl) rvLblContact = tr.spotContactLbl;\n    }\n    if(rvLblContact === \"Contatto\"){\n      try{\n        var lk = (typeof getLang===\"function\") ? getLang() : \"it\";\n        if(typeof LANGS!==\"undefined\"){\n          if(LANGS!=null){\n            var ls = null;\n            if(LANGS[lk]) ls = LANGS[lk];\n            if(ls==null) ls = LANGS.it;\n            if(ls!=null){\n              if(ls.spotContactLbl) rvLblContact = ls.spotContactLbl;\n            }\n          }\n        }\n      }catch(e){}\n    }\n\n    const top=document.createElement(\"div\");top.className=\"rvTop\";\n    top.innerHTML=`<div>\n      <div class=\"rvUser\">${esc(rv.username||\"\u2014\")}<\/div>\n      <div class=\"rvMeta\">${esc(created)}<\/div>\n      <div class=\"rvMeta\"><b>Dal:<\/b> ${esc(startW)} &nbsp; <b>Al:<\/b> ${esc(endW)}<\/div>\n      <div class=\"rvMeta\">${esc(rv.address||\"\")}<\/div>\n      <div class=\"rvMeta\"><b>${esc(rvLblContact)}:<\/b> ${esc(rv.contact||\"\")}<\/div>\n    <\/div>`;\n\n    const btns=document.createElement(\"div\");btns.className=\"rvBtns\";\n    if(user ? (rv.user_id===user.id) : false){\n      const bE=document.createElement(\"button\");bE.className=\"btnAlt\";bE.type=\"button\";bE.textContent=tr.edit;bE.onclick=()=>editRV(rv);\n      const bD=document.createElement(\"button\");bD.className=\"btnRed\";bD.type=\"button\";bD.textContent=tr.delete;bD.onclick=()=>deleteRV(rv.id);\n      btns.appendChild(bE);btns.appendChild(bD);\n    }\n    top.appendChild(btns);\n\n    const txt=document.createElement(\"div\");txt.className=\"rvText\";txt.textContent=rv.text||\"\";\n    d.appendChild(top);d.appendChild(txt);\n\n    const repliesWrap=document.createElement(\"div\");repliesWrap.className=\"rvReplies\";\n    const form=document.createElement(\"div\");form.className=\"rvReplyForm\";\n    const ta=document.createElement(\"textarea\");\n    ta.maxLength=RV_REPLY_MAX;\n    ta.placeholder=user?tr.replyPh(RV_REPLY_MAX):tr.loginToReply;\n    ta.disabled=!user;\n\n    const btn=document.createElement(\"button\");\n    btn.className=\"rvReplyBtn btnDark\";\n    btn.type=\"button\";\n    btn.textContent=tr.reply;\n    btn.disabled=!user;\n    btn.onclick=async()=>{await addRVReply(rv.id,ta.value);ta.value=\"\"};\n\n    form.appendChild(ta);form.appendChild(btn);\n    const hint=document.createElement(\"div\");hint.className=\"hint\";hint.textContent=`${tr.reply}: max ${RV_REPLY_MAX}.`;\n    repliesWrap.appendChild(form);repliesWrap.appendChild(hint);\n\n    const rlist=(repliesMap[rv.id]||[]);\n    if(rlist.length){\n      rlist.forEach(r=>{\n        const item=document.createElement(\"div\");item.className=\"rvReplyItem\";\n        const when=r.created_at?new Date(r.created_at).toLocaleString(\"it-IT\"):\"\";\n        const meta=document.createElement(\"div\");meta.className=\"rvReplyMeta\";\n        meta.innerHTML=`<div><b>${esc(r.username||\"\u2014\")}<\/b> \u2022 ${esc(when)}<\/div>`;\n        if(user ? (r.user_id===user.id) : false){\n          const del=document.createElement(\"button\");del.className=\"rvReplyDel btnRed\";del.type=\"button\";del.textContent=tr.delete;del.onclick=()=>deleteRVReply(r.id);\n          meta.appendChild(del);\n        }\n        const body=document.createElement(\"div\");body.className=\"rvReplyBody\";body.textContent=r.body||\"\";\n        item.appendChild(meta);item.appendChild(body);\n        repliesWrap.appendChild(item);\n      });\n    }else{\n      const none=document.createElement(\"div\");\n      none.style.color=\"var(--muted)\";\n      none.style.fontSize=\"12px\";\n      none.textContent=tr.noReplies;\n      repliesWrap.appendChild(none);\n    }\n\n    d.appendChild(repliesWrap);\n    $(\"rvList\").appendChild(d);\n  });\n\n  if(!list.length){\n    const e=document.createElement(\"div\");\n    e.style.color=\"var(--muted)\";\n    e.style.padding=\"8px\";\n    e.textContent=t().rvEmpty;\n    $(\"rvList\").appendChild(e);\n  }\n}\n\n\/* NOTIF TOGGLE *\/\nasync function toggleNotifications(){\n  try{\n    if(!notifOn){\n      await enableRealPush();\n      notifOn=true;localStorage.setItem(NOTIF_KEY,\"1\");\n      updateNotifBtn();\n      try{new Notification(APP_NAME,{body:\"Notifiche attivate \u2705 (push vere)\"})}catch{}\n    }else{\n      await disableRealPush();\n      notifOn=false;localStorage.setItem(NOTIF_KEY,\"0\");\n      updateNotifBtn();\n    }\n  }catch(err){\n    console.warn(err);\n    \/\/ debug: se il server sta servendo HTML\/404 invece del JS, l'eval del SW fallisce\n    try{\n      const r = await fetch(\"\/firebase-messaging-sw.js\", {cache:\"no-store\"});\n      const ct = r.headers.get(\"content-type\") || \"\";\n      const txt = await r.text();\n      console.warn(\"SW fetch status:\", r.status, \"content-type:\", ct, \"first chars:\", txt.slice(0,120));\n    }catch(e){}\n    alert(\"Notifiche push non attivate: \"+(err?.message||err));\n    notifOn=false;localStorage.setItem(NOTIF_KEY,\"0\");\n    updateNotifBtn();\n  }\n}\n\n\/* PROFILES *\/\nasync function loadProfiles(){\n  const {data,error}=await sb.from(\"profiles\").select(\"*\");\n  if(error){console.warn(error.message);return}\n  profiles={};\n  (data||[]).forEach(p=>profiles[p.user_id]=p);\n  if(user ? (!getNick() ? ((profiles[user.id] ? (profiles[user.id].display_name ? true : false) : false) ? true : false) : false) : false){\n    setNick(profiles[user.id].display_name);\n    syncNickUI();\n  }\n}\n\n\/* SPOTS + MODAL *\/\nconst spotEmoji=(t)=>t===\"camping\"?\"\ud83c\udfd5\ufe0f\":\"\ud83c\udd7f\ufe0f\";\nconst spotLabel=function(tp){\n  var tr=t();\n  var a=null;\n  if(tp===\"camping\"){\n    a=\"Camping \/ Area sosta\";\n    if(tr!=null){\n      if(tr.spotCamping) a=tr.spotCamping;\n    }\n    return a;\n  }\n  a=\"Parcheggio in libera\";\n  if(tr!=null){\n    if(tr.spotFree) a=tr.spotFree;\n  }\n  return a;\n};\nfunction buildStarDisplay(avg){\n  const a=Math.round((Number(avg)||0)*10)\/10;\n  const full=Math.round(a);\n  let html=\"\";\n  for(let i=1;i<=5;i++) html+=`<span class=\"${i<=full?\"on\":\"off\"}\">\u2605<\/span>`;\n  return `<span class=\"starDisplay\">${html}<\/span>`;\n}\nfunction amenitiesMiniHtml(s){\n  const p=!!s.has_power,w=!!s.has_water,d=!!s.has_dump,t0=!!s.has_toilet,a=!!s.pets_allowed;\n  return `<div class=\"amenMini\" aria-label=\"Servizi\">\n    <div class=\"amenTag ${p?\"on\":\"\"}\" title=\"Corrente\">\u26a1<\/div>\n    <div class=\"amenTag ${w?\"on\":\"\"}\" title=\"Acqua\">\ud83d\udeb0<\/div>\n    <div class=\"amenTag ${d?\"on\":\"\"}\" title=\"Scarico\">\u267b\ufe0f<\/div>\n    <div class=\"amenTag ${t0?\"on\":\"\"}\" title=\"Bagno\">\ud83d\udebb<\/div>\n    <div class=\"amenTag ${a?\"on\":\"\"}\" title=\"Animali ammessi\">\ud83d\udc3e<\/div>\n  <\/div>`;\n}\nfunction spotIcon(t0){\n  const emoji=spotEmoji(t0);\n  const border=(t0===\"free\")?\"rgba(255,216,77,.85)\":\"rgba(255,43,214,.80)\";\n  const glow=(t0===\"free\")?\"rgba(255,216,77,.22)\":\"rgba(255,43,214,.18)\";\n  return L.divIcon({className:\"\",html:`<div style=\"width:34px;height:34px;border-radius:14px;display:grid;place-items:center;background:rgba(5,7,13,.92);border:2px solid ${border};box-shadow:0 0 14px ${glow};font-size:18px\">${emoji}<\/div>`,iconSize:[34,34],iconAnchor:[17,17],popupAnchor:[0,-14]});\n}\n\nfunction openSpotModal(spotId){\n  if(!spotId) return;\n  spotModalSpotId=spotId;\n  lastOpenedSpotId=spotId;\n  modalOpen(\"spotModal\");\n  spotPopupOpen=true;\n  lockMapInteractions(true);\n  $(\"spotPanel\").classList.remove(\"open\");\n  modalClose(\"rvListModal\");modalClose(\"rvFormModal\");\n\n  \/\/ render subito (poi aggiorna voti\/recensioni se serve)\n  renderSpotModal();\n\n  \/\/ \u2705 OSM: carica voti\/recensioni on-demand per scheda identica\n  try{\n    if(isOsmSpotId(spotId)){\n      Promise.resolve(ensureSpotFeedbackLoaded(spotId))\n        .then(function(){\n          \/\/ se nel frattempo non ho chiuso\/cambiato spot\n          if(spotModalSpotId === spotId){\n            renderSpotModal();\n          }\n        })\n        .catch(function(_e){});\n    }\n  }catch(_e2){}\n}\nfunction closeSpotModal(){\n  modalClose(\"spotModal\");\n  $(\"spotModalBody\").innerHTML=\"\";\n  spotModalSpotId=null;\n  lastOpenedSpotId=null;\n  spotPopupOpen=false;\n  lockMapInteractions(false);\n}\nfunction hookSpotModalUI(){\n  bindModal(\"spotModal\",\"spotModalCloseBtn\",closeSpotModal);\n  document.addEventListener(\"keydown\",(e)=>{if(e.key===\"Escape\") closeSpotModal()});\n}\n\nfunction makeSpotModalHtml(s){\n\n  \/\/ \u2705 OSM: usa esattamente la stessa scheda degli spot interni (voti + recensioni)\n  if(s){\n    if(s.source === \"osm\"){\n      if(!s.username) s.username = \"OpenStreetMap\";\n\n      \/\/ fallback descrizione, se manca\n      if(!s.description){\n        var d = [];\n        s.description = d.join(\"\\n\");\n      }\n\n      \/\/ fallback contact\/price\n      if(!s.contact){\n        if(s.phone) s.contact = String(s.phone);\n      }\n      if(!s.price_per_night){\n        if(s.fee) s.price_per_night = String(s.fee);\n      }\n    }\n  }\n\n  const when=s.created_at?new Date(s.created_at).toLocaleString(\"it-IT\"):\"\";\n  const avg=Math.round((Number(s.avg_rating)||0)*10)\/10;\n  const stars=buildStarDisplay(avg);\n  const votesTxt=`(${s.votes_count||0})`;\n  const myRating=Number(s.my_rating)||0;\n  var trObj = t();\n  var trSpot = null;\n  try{\n    var lk2 = (typeof getLang===\"function\") ? getLang() : \"it\";\n    if(typeof LANGS!==\"undefined\"){\n      if(LANGS!=null){\n        if(LANGS[lk2]) trSpot = LANGS[lk2];\n        if(trSpot==null) trSpot = LANGS.it;\n      }\n    }\n  }catch(e){ trSpot = null; }\n  var lblReviews = \"Recensioni\";\n  var lblNoReviews = \"Nessuna recensione ancora.\";\n  var lblContact = \"Contatto\";\n  var lblCost = \"Costo a notte\";\n  var lblSite = \"Sito\";\n  var lblPhone = \"Telefono\";\n  var lblHours = \"Orari\";\n  var lblCap = \"Capienza\";\n  var lblNav = \"\ud83e\udded Navigatore\";\n  var lblDel = \"Cancella\";\n  var lblLogin = \"Fai login per votare e scrivere recensioni.\";\n  var lblScroll = \"Scorri per leggere tutte le recensioni \u2705\";\n  var lblSend = \"Invia\";\n  var lblPhotoAlt = \"Foto spot\";\n  var yesLbl = \"s\u00ec\";\n  var noLbl = \"no\";\n  if(trObj!=null){\n    if(trObj.spotReviewsLbl) lblReviews = trObj.spotReviewsLbl;\n    if(trObj.spotNoReviews) lblNoReviews = trObj.spotNoReviews;\n    if(trObj.spotContactLbl) lblContact = trObj.spotContactLbl;\n    if(trObj.spotCostLbl) lblCost = trObj.spotCostLbl;\n    if(trObj.spotSiteLbl) lblSite = trObj.spotSiteLbl;\n    if(trObj.spotPhoneLbl) lblPhone = trObj.spotPhoneLbl;\n    if(trObj.spotHoursLbl) lblHours = trObj.spotHoursLbl;\n    if(trObj.spotCapacityLbl) lblCap = trObj.spotCapacityLbl;\n    if(trObj.navigator) lblNav = trObj.navigator;\n    if(trObj.delete) lblDel = trObj.delete;\n    if(trObj.spotLoginToReview) lblLogin = trObj.spotLoginToReview;\n    if(trObj.spotScrollReviews) lblScroll = trObj.spotScrollReviews;\n    if(trObj.send) lblSend = trObj.send;\n    if(trObj.spotPhotoAlt) lblPhotoAlt = trObj.spotPhotoAlt;\n    if(trObj.yes) yesLbl = trObj.yes;\n    if(trObj.no) noLbl = trObj.no;\n  }\n  if(trSpot!=null){\n    if(trSpot.spotReviewsLbl) lblReviews = trSpot.spotReviewsLbl;\n    if(trSpot.spotNoReviews) lblNoReviews = trSpot.spotNoReviews;\n    if(trSpot.spotContactLbl) lblContact = trSpot.spotContactLbl;\n    if(trSpot.spotCostLbl) lblCost = trSpot.spotCostLbl;\n    if(trSpot.spotSiteLbl) lblSite = trSpot.spotSiteLbl;\n    if(trSpot.spotPhoneLbl) lblPhone = trSpot.spotPhoneLbl;\n    if(trSpot.spotHoursLbl) lblHours = trSpot.spotHoursLbl;\n    if(trSpot.spotCapacityLbl) lblCap = trSpot.spotCapacityLbl;\n    if(trSpot.navigator) lblNav = trSpot.navigator;\n    if(trSpot.spotLoginToReview) lblLogin = trSpot.spotLoginToReview;\n    if(trSpot.spotScrollReviews) lblScroll = trSpot.spotScrollReviews;\n    if(trSpot.spotPhotoAlt) lblPhotoAlt = trSpot.spotPhotoAlt;\n    if(trSpot.yes) yesLbl = trSpot.yes;\n    if(trSpot.no) noLbl = trSpot.no;\n  }\n  var phReview = \"Scrivi una recensione (max \" + String(SPOT_COMMENT_MAX) + \")\";\n  if(trObj!=null){\n    if(typeof trObj.spotReviewPh === \"function\") phReview = trObj.spotReviewPh(SPOT_COMMENT_MAX);\n  }\n  if(trSpot!=null){\n    if(typeof trSpot.spotReviewPh === \"function\") phReview = trSpot.spotReviewPh(SPOT_COMMENT_MAX);\n  }\n  function _trYesNo(v){\n    if(v==null) return \"\";\n    var s2 = String(v).trim().toLowerCase();\n    if(s2===\"yes\") return yesLbl;\n    if(s2===\"no\") return noLbl;\n    return String(v);\n  }\n\n  let voteBtns=\"\";\n  for(let i=1;i<=5;i++){\n    voteBtns+=`<button type=\"button\" class=\"${i<=myRating?\"on\":\"off\"}\" ${(!user)?\"disabled\":\"\"} onclick=\"window.__spotModalVote?window.__spotModalVote('${s.id}',${i}):null\">\u2605<\/button>`;\n  }\n\n  const list=(spotCommentsById[s.id]||[]);\n  const commentsHtml=list.length ? list.map(c=>{\n    const cWhen=c.created_at?new Date(c.created_at).toLocaleString(\"it-IT\"):\"\";\n    const canDel=(user ? (c.user_id===user.id) : false);\n    return `<div style=\"border:1px solid #1d2236;border-radius:12px;padding:10px;background:#070a14;margin-top:10px\">\n      <div style=\"font-size:12px;color:var(--muted);display:flex;justify-content:space-between;gap:10px\">\n        <div><b>${esc(c.username||\"\u2014\")}<\/b> \u2022 ${esc(cWhen)}<\/div>\n        ${canDel?`<button type=\"button\" style=\"padding:6px 12px;border-radius:14px;font-size:12px;background:#2a0f12;color:var(--red);border:1px solid var(--red);font-weight:1000\" onclick=\"window.__spotModalDelComment?window.__spotModalDelComment('${c.id}','${s.id}'):null\">${esc(lblDel)}<\/button>`:\"\"}\n      <\/div>\n      <div style=\"font-size:13px;color:var(--txt);margin-top:8px;white-space:pre-wrap\">${linkifyText(c.body||\"\")}<\/div>\n    <\/div>`;\n  }).join(\"\") : `<div class=\"hint\" style=\"margin-top:10px\">${esc(lblNoReviews)}<\/div>`;\n\n  const photoHtml=s.photo_url?`<div style=\"margin-top:12px\"><img decoding=\"async\" src=\"${esc(s.photo_url)}\" alt=\"${esc(lblPhotoAlt)}\" style=\"width:100%;max-width:300px;border-radius:10px;border:1px solid var(--line2)\"><\/div>`:\"\";\n  const niceTitle=(s.spot_name||\"\").trim();\n  const contactLine=(s.contact||\"\").trim()?`<div style=\"font-size:12px;color:var(--muted);margin-top:8px\"><b>${esc(lblContact)}:<\/b> ${linkifyText(s.contact)}<\/div>`:\"\";\n  const priceLine=(s.price_per_night||\"\").trim()?`<div style=\"font-size:12px;color:var(--muted);margin-top:4px\"><b>${esc(lblCost)}:<\/b> ${esc(_trYesNo(s.price_per_night))}<\/div>`:\"\";\n  const siteLine=(s.website||\"\").trim()?`<div style=\"font-size:12px;color:var(--muted);margin-top:4px\"><b>${esc(lblSite)}:<\/b> ${linkifyText(s.website)}<\/div>`:\"\";\n  const phoneLine=(s.phone||\"\").trim()?`<div style=\"font-size:12px;color:var(--muted);margin-top:4px\"><b>${esc(lblPhone)}:<\/b> ${linkifyText(s.phone)}<\/div>`:\"\";\n  const hoursLine=(s.opening_hours||\"\").trim()?`<div style=\"font-size:12px;color:var(--muted);margin-top:4px\"><b>${esc(lblHours)}:<\/b> ${esc(s.opening_hours)}<\/div>`:\"\";\n  const capLine=(s.capacity||\"\").trim()?`<div style=\"font-size:12px;color:var(--muted);margin-top:4px\"><b>${esc(lblCap)}:<\/b> ${esc(s.capacity)}<\/div>`:\"\";\n  const navBtnHtml=(s.lat ? (s.lng ? true : false) : false)?`<button type=\"button\" class=\"btnMain\" style=\"width:100%;margin-top:10px;padding:12px 14px;border-radius:16px\" onclick=\"window.__navTo?window.__navTo(${Number(s.lat)},${Number(s.lng)},'${esc(niceTitle||spotLabel(s.type))}'):null\">${esc(lblNav)}<\/button>`:\"\";\n  const commentFormHtml=user?`<div style=\"display:flex;gap:8px;align-items:flex-start;margin-top:12px\">\n      <textarea id=\"spModalTa_${s.id}\" maxlength=\"${SPOT_COMMENT_MAX}\" placeholder=\"${esc(phReview)}\" style=\"width:100%;min-height:54px;max-height:120px;padding:10px;border-radius:12px;border:1px solid #3a466e;background:var(--bg);color:#fff;box-sizing:border-box\"><\/textarea>\n      <button type=\"button\" class=\"btnDark\" style=\"border-radius:16px;padding:10px 14px;font-weight:1000;white-space:nowrap\" onclick=\"window.__spotModalAddComment?window.__spotModalAddComment('${s.id}'):null\">${esc(lblSend)}<\/button>\n    <\/div><div class=\"hint\">${esc(lblScroll)}<\/div>`\n  : `<div class=\"hint\">${esc(lblLogin)}<\/div>`;\n\n  return `<div>\n    <div style=\"font-weight:1000;margin-bottom:4px\">${niceTitle?`${spotEmoji(s.type)} ${esc(niceTitle)}`:`${spotEmoji(s.type)} ${spotLabel(s.type)}`}<\/div>\n    <div style=\"font-size:12px;color:var(--muted)\"><b>${esc(s.username)}<\/b>${when?(\" \u2022 \"+esc(when)):\"\"}<\/div>\n    <div style=\"font-size:12px;color:var(--muted);margin-top:2px\">${esc(spotLabel(s.type))}<\/div>\n    ${amenitiesMiniHtml(s)}\n    <div style=\"margin-top:10px\">${stars} <span style=\"font-size:12px;color:var(--muted)\">${votesTxt}<\/span><\/div>\n    <div style=\"display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:8px\">${voteBtns}<\/div>\n    <div style=\"font-size:12px;margin-top:10px;color:var(--txt)\">${esc(s.address)}<\/div>\n    ${navBtnHtml}\n    ${contactLine}\n    ${priceLine}\n    ${siteLine}\n    ${phoneLine}\n    ${hoursLine}\n    ${capLine}\n    <div style=\"margin-top:8px;font-size:13px;color:var(--txt);white-space:pre-wrap\">${linkifyText(s.description)}<\/div>\n    ${photoHtml}\n    <div style=\"margin-top:16px;font-weight:1000;color:var(--aqua)\">${esc(lblReviews)}<\/div>\n    ${commentsHtml}\n    ${commentFormHtml}\n  <\/div>`;\n}\nfunction renderSpotModal(){\n  const sid=spotModalSpotId;\n  if(!sid){$(\"spotModalTitle\").innerText=\"Spot\";$(\"spotModalBody\").innerHTML=\"\";return}\n  const s=spotById[sid];\n  if(!s){$(\"spotModalTitle\").innerText=\"Spot\";$(\"spotModalBody\").innerHTML=`<div class=\"hint\">Spot non trovato.<\/div>`;return}\n  const nice=(s.spot_name||\"\").trim();\n  $(\"spotModalTitle\").innerText=nice?`${spotEmoji(s.type)} ${nice}`:`${spotEmoji(s.type)} ${spotLabel(s.type)}`;\n  $(\"spotModalBody\").innerHTML=makeSpotModalHtml(s);\n}\n\nfunction clearSpotMarkers(){try{clusterSpotCamping?.clearLayers()}catch{}try{clusterSpotFree?.clearLayers()}catch{}markerBySpotId={}}\nfunction rebuildSpotMarkers(spots){\n  if(!map||!clusterSpotCamping||!clusterSpotFree) return;\n  clearSpotMarkers();\n  (spots||[]).forEach(s=>{\n    if(!s?.lat||!s?.lng) return;\n    const m=L.marker([s.lat,s.lng],{icon:spotIcon(s.type)});\n    m.on(\"click\",(ev)=>{try{if(ev?.originalEvent) L.DomEvent.stopPropagation(ev.originalEvent)}catch{} openSpotModal(s.id)});\n    markerBySpotId[s.id]=m;\n    (s.type===\"free\"?clusterSpotFree:clusterSpotCamping).addLayer(m);\n  });\n\n  window.__spotModalVote=async(spotId,rating)=>{await setSpotVote(spotId,rating);spotModalSpotId=spotId;renderSpotModal()};\n  window.__spotModalAddComment=async(spotId)=>{const ta=document.getElementById(`spModalTa_${spotId}`),txt=(ta?.value||\"\").trim();await addSpotComment(spotId,txt);if(ta) ta.value=\"\";spotModalSpotId=spotId;renderSpotModal()};\n  window.__spotModalDelComment=async(commentId,spotId)=>{await deleteSpotComment(commentId,spotId);spotModalSpotId=spotId;renderSpotModal()};\n}\n\nfunction setSpotType(t0){\n  spotType=(t0===\"free\")?\"free\":\"camping\";\n  $(\"spType_camping\").classList.toggle(\"active\",spotType===\"camping\");\n  $(\"spType_free\").classList.toggle(\"active\",spotType===\"free\");\n}\n\nfunction resetSpotForm(){\n  editingSpotId=null;updateSpotPrimaryBtnText();\n  $(\"spotCancelEditBtn\").style.display=\"none\";\n  $(\"spotName\").value=\"\";$(\"spotAddress\").value=\"\";$(\"spotContact\").value=\"\";$(\"spotPrice\").value=\"\";$(\"spotDesc\").value=\"\";\n  $(\"spotPhoto\").value=\"\";hide($(\"spotPreview\"));\n  currentSpotPhotoBlob=null;currentSpotExistingPhotoPath=null;\n  pickedSpotLatLng=null;setSpotType(\"camping\");resetAmenities();\n}\nfunction guessExt(file){\n  const t0=(file.type||\"\").toLowerCase();\n  if(t0.includes(\"png\")) return \"png\";\n  if(t0.includes(\"webp\")) return \"webp\";\n  return \"jpg\";\n}\nasync function resizeTo300(file,max){\n  const ext=guessExt(file);\n  const dataUrl=await new Promise((resolve,reject)=>{\n    const r=new FileReader();\n    r.onload=()=>resolve(r.result);\n    r.onerror=reject;\n    r.readAsDataURL(file);\n  });\n  const img=new Image();\n  await new Promise((resolve,reject)=>{img.onload=resolve;img.onerror=reject;img.src=dataUrl});\n  const w=img.width,h=img.height;\n  const scale=Math.min(1,max\/Math.max(w,h));\n  const nw=Math.max(1,Math.round(w*scale)),nh=Math.max(1,Math.round(h*scale));\n  const canvas=document.createElement(\"canvas\");\n  canvas.width=nw;canvas.height=nh;\n  canvas.getContext(\"2d\").drawImage(img,0,0,nw,nh);\n  const outMime=ext===\"png\"?\"image\/png\":ext===\"webp\"?\"image\/webp\":\"image\/jpeg\";\n  const blob=await new Promise((resolve)=>canvas.toBlob(resolve,outMime,0.88));\n  return {blob,dataUrl:canvas.toDataURL(outMime,0.88),ext};\n}\nasync function uploadSpotPhoto(spotId){\n  if(!currentSpotPhotoBlob) return {path:null,url:null};\n  const filename=`${spotId}.${currentSpotPhotoExt||\"jpg\"}`;\n  const path=`public\/${spotId}\/${filename}`;\n  const up=await sb.storage.from(SPOT_BUCKET).upload(path,currentSpotPhotoBlob,{upsert:true,contentType:`image\/${currentSpotPhotoExt||\"jpeg\"}`});\n  if(up.error) throw new Error(up.error.message);\n  const pub=sb.storage.from(SPOT_BUCKET).getPublicUrl(path);\n  return {path,url:pub?.data?.publicUrl||null};\n}\nasync function deleteSpotPhoto(path){if(!path) return; try{await sb.storage.from(SPOT_BUCKET).remove([path])}catch{}}\n\n\/* \u2705 FAILSAFE: se WP ti carica una versione tronca senza publishOrUpdateSpot, evito crash *\/\nif(typeof publishOrUpdateSpot===\"undefined\"){\n  function publishOrUpdateSpot(){\n    alert(\"Errore: publishOrUpdateSpot() non \u00e8 presente in questo file (codice troncato \/ non aggiornato sul server).\");\n  }\n}\n\nasync function publishOrUpdateSpot(){\n  if(!user) return alert(t().profLoginReq);\n  const name=($(\"spotName\").value||\"\").trim(),\n        address=($(\"spotAddress\").value||\"\").trim(),\n        contact=($(\"spotContact\").value||\"\").trim(),\n        price=($(\"spotPrice\").value||\"\").trim(),\n        desc=($(\"spotDesc\").value||\"\").trim();\n  if(name.length>SPOT_NAME_MAX) return alert(`Nome spot: max ${SPOT_NAME_MAX} caratteri.`);\n  if(!address||!desc) return alert(\"Compila indirizzo e descrizione.\");\n  if(desc.length>SPOT_DESC_MAX) return alert(`Max ${SPOT_DESC_MAX} caratteri.`);\n  if(!pickedSpotLatLng){ if(!editingSpotId) return alert(\"Seleziona lo spot dalla mappa (per salvare lat\/lng).\"); }\n\n  const myNick=profiles?.[user.id]?.display_name||getNick()||user.email;\n  const amenPayload={has_power:!!spotAmenities.power,has_water:!!spotAmenities.water,has_dump:!!spotAmenities.dump,has_toilet:!!spotAmenities.toilet,pets_allowed:!!spotAmenities.pets};\n\n  if(!editingSpotId){\n    const payload={user_id:user.id,username:myNick,type:spotType,spot_name:name||null,address,description:desc,contact:contact||null,price_per_night:price||null,lat:pickedSpotLatLng?.lat||null,lng:pickedSpotLatLng?.lng||null,photo_url:null,photo_path:null,...amenPayload};\n    const ins=await sb.from(SPOTS_TABLE).insert(payload).select(\"*\").maybeSingle();\n    if(ins.error) return alert(ins.error.message);\n    const spot=ins.data;\n    if(!spot?.id) return alert(\"Errore: ID spot non trovato.\");\n    if(currentSpotPhotoBlob){\n      try{\n        const {path,url}=await uploadSpotPhoto(spot.id);\n        const up2=await sb.from(SPOTS_TABLE).update({photo_url:url,photo_path:path}).eq(\"id\",spot.id).eq(\"user_id\",user.id);\n        if(up2.error) console.warn(\"update photo_url:\",up2.error.message);\n      }catch{alert(\"Spot salvato, ma foto NON caricata.\")}\n    }\n    resetSpotForm();await loadSpots();return;\n  }\n\n  const updatePayload={type:spotType,spot_name:name||null,address,description:desc,contact:contact||null,price_per_night:price||null,...amenPayload};\n  if(pickedSpotLatLng){updatePayload.lat=pickedSpotLatLng.lat;updatePayload.lng=pickedSpotLatLng.lng}\n  const up=await sb.from(SPOTS_TABLE).update(updatePayload).eq(\"id\",editingSpotId).eq(\"user_id\",user.id);\n  if(up.error) return alert(up.error.message);\n\n  if(currentSpotPhotoBlob){\n    try{\n      const {path,url}=await uploadSpotPhoto(editingSpotId);\n      if(currentSpotExistingPhotoPath){ if(currentSpotExistingPhotoPath!==path) await deleteSpotPhoto(currentSpotExistingPhotoPath); }\n      const up2=await sb.from(SPOTS_TABLE).update({photo_url:url,photo_path:path}).eq(\"id\",editingSpotId).eq(\"user_id\",user.id);\n      if(up2.error) console.warn(\"update new photo:\",up2.error.message);\n    }catch{alert(\"Modifica salvata, ma foto NON caricata.\")}\n  }\n\n  resetSpotForm();await loadSpots();\n}\n\nfunction editSpot(s){\n  editingSpotId=s.id;updateSpotPrimaryBtnText();\n  $(\"spotCancelEditBtn\").style.display=\"inline-block\";\n  $(\"spotName\").value=(s.spot_name||\"\");\n  $(\"spotAddress\").value=s.address||\"\";\n  $(\"spotContact\").value=s.contact||\"\";\n  $(\"spotPrice\").value=s.price_per_night||\"\";\n  $(\"spotDesc\").value=s.description||\"\";\n  setSpotType(s.type===\"free\"?\"free\":\"camping\");\n  pickedSpotLatLng=null; if(s.lat){ if(s.lng) pickedSpotLatLng={lat:s.lat,lng:s.lng}; }\n  currentSpotExistingPhotoPath=s.photo_path||null;\n  currentSpotPhotoBlob=null;\n  $(\"spotPhoto\").value=\"\";\n  if(s.photo_url){$(\"spotPreview\").src=s.photo_url;show($(\"spotPreview\"),\"block\")}else hide($(\"spotPreview\"));\n  spotAmenities={power:!!s.has_power,water:!!s.has_water,dump:!!s.has_dump,toilet:!!s.has_toilet,pets:!!s.pets_allowed};\n  renderAmenityButtons();\n  $(\"spotPanel\").classList.add(\"open\");\n  setAria($(\"spotPanel\"),true);\n  $(\"spotPanel\").classList.remove(\"pick\");\n  lockMapInteractions(true);\n  setMenuOpen(false);\n}\n\nasync function deleteSpot(id,photo_path){\n  if(!confirm(\"Cancellare lo spot?\")) return;\n  const del=await sb.from(SPOTS_TABLE).delete().eq(\"id\",id).eq(\"user_id\",user.id);\n  if(del.error) return alert(del.error.message);\n  await deleteSpotPhoto(photo_path);\n  try{await sb.from(SPOT_VOTES_TABLE).delete().eq(\"spot_id\",id)}catch{}\n  try{await sb.from(SPOT_COMMENTS_TABLE).delete().eq(\"spot_id\",id)}catch{}\n  if(spotModalSpotId===id) closeSpotModal();\n  await loadSpots();\n}\n\nasync function setSpotVote(spotId,rating){\n  if(!user) return alert(t().profLoginReq);\n  if(rating < 1) return;\n  if(rating > 5) return;\n\n  var sid = String(spotId || \"\");\n  var tabs = { votes: SPOT_VOTES_TABLE, comments: SPOT_COMMENTS_TABLE };\n\n  if(isOsmSpotId(sid)){\n    await _detectOsmFeedbackMode();\n    tabs = _tablesForSpotId(sid);\n    if(!tabs.votes) return alert(\"Voti\/recensioni OSM non configurati.\");\n  }\n\n  var up = await sb.from(tabs.votes).upsert({ spot_id: sid, user_id: user.id, rating: rating }, { onConflict:\"spot_id,user_id\" });\n  if(up.error) return alert(up.error.message);\n\n  if(isOsmSpotId(sid)){\n    await ensureSpotFeedbackLoaded(sid);\n  }else{\n    await loadSpots();\n  }\n}\nasync function addSpotComment(spotId,text){\n  if(!user) return alert(t().profLoginReq);\n  var sid = String(spotId || \"\");\n  var body = (text || \"\").trim();\n  if(!body) return alert(\"Scrivi un commento.\");\n  if(body.length > SPOT_COMMENT_MAX) return alert(`Max ${SPOT_COMMENT_MAX} caratteri.`);\n\n  var tabs = { votes: SPOT_VOTES_TABLE, comments: SPOT_COMMENTS_TABLE };\n  if(isOsmSpotId(sid)){\n    await _detectOsmFeedbackMode();\n    tabs = _tablesForSpotId(sid);\n    if(!tabs.comments) return alert(\"Voti\/recensioni OSM non configurati.\");\n  }\n\n  var ins = await sb.from(tabs.comments).insert({ spot_id: sid, user_id: user.id, username: myDisplayNick(), body: body });\n  if(ins.error) return alert(ins.error.message);\n\n  if(isOsmSpotId(sid)){\n    await ensureSpotFeedbackLoaded(sid);\n  }else{\n    await loadSpots();\n  }\n}\nasync function deleteSpotComment(commentId,spotId){\n  if(!user) return alert(t().profLoginReq);\n  if(!confirm(\"Cancellare il commento?\")) return;\n\n  var tabs = { votes: SPOT_VOTES_TABLE, comments: SPOT_COMMENTS_TABLE };\n  var sid = \"\";\n  if(spotId) sid = String(spotId);\n\n  if(sid){\n    if(isOsmSpotId(sid)){\n      await _detectOsmFeedbackMode();\n      tabs = _tablesForSpotId(sid);\n      if(!tabs.comments) return alert(\"Voti\/recensioni OSM non configurati.\");\n    }\n  }\n\n  var del = await sb.from(tabs.comments).delete().eq(\"id\", commentId).eq(\"user_id\", user.id);\n  if(del.error) return alert(del.error.message);\n\n  if(sid){\n    if(isOsmSpotId(sid)){\n      await ensureSpotFeedbackLoaded(sid);\n      return;\n    }\n  }\n  await loadSpots();\n}\n\n\n\nfunction deg2rad(d){\n  return d * Math.PI \/ 180;\n}\nfunction kmBetween(lat1, lng1, lat2, lng2){\n  var R = 6371;\n  var dLat = deg2rad(lat2 - lat1);\n  var dLng = deg2rad(lng2 - lng1);\n  var a =\n    Math.sin(dLat\/2) * Math.sin(dLat\/2) +\n    Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) *\n    Math.sin(dLng\/2) * Math.sin(dLng\/2);\n  var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));\n  return R * c;\n}\n\nasync function loadSpots(centerLatOpt, centerLngOpt, radiusKmOpt){\n  \/\/ \u2705 anti-spam: evita richieste multiple se premi 10 volte il bottone\n  if(typeof window.__spotsLoading === \"undefined\"){ window.__spotsLoading = false; }\n  if(window.__spotsLoading){\n    console.log(\"Cerca spot: gi\u00e0 in caricamento (click ignorato)\");\n    return;\n  }\n  window.__spotsLoading = true;\n  \/\/ \u2705 watchdog: se una richiesta si \"incastra\", sblocca dopo 30s\n  try{\n    if(typeof window.__spotsLoadingTimer !== \"undefined\"){\n      if(window.__spotsLoadingTimer){ clearTimeout(window.__spotsLoadingTimer); }\n    }\n  }catch(e){}\n  window.__spotsLoadingTimer = setTimeout(function(){\n    try{ window.__spotsLoading = false; }catch(e){}\n  }, 30000);\n\n  \/\/ placeholder (cos\u00ec non sembra \u201cmorto\u201d) - SAFE (no crash se spotList manca)\n  try{\n    var __sl = $(\"spotList\");\n    if(__sl) __sl.innerHTML = '<div style=\"padding:8px;color:var(--muted)\">Caricamento\u2026<\/div>';\n  }catch(e){}\n\n  let spots=[];\n  try{\n  \/\/ \u2705 FIX: lblContact locale (evita ReferenceError)\n  var lblContact = \"Contatto\";\n  try{\n    var lk = null;\n    if(typeof getLang !== \"undefined\"){\n      if(typeof getLang === \"function\"){\n        lk = getLang();\n      }\n    }\n    if(lk){\n      if(typeof LANGS !== \"undefined\"){\n        if(LANGS){\n          var tr = LANGS[lk];\n          if(!tr) tr = LANGS.it;\n          if(tr){\n            if(tr.spotContactLbl) lblContact = tr.spotContactLbl;\n          }\n        }\n      }\n    }\n  }catch(e){}\n  var didQuery=false;\n\n\/\/ \u2705 filtro per raggio (default 20 km come OSM)\nvar radiusKm = null;\nif(typeof radiusKmOpt === \"number\"){\n  radiusKm = radiusKmOpt;\n}else{\n  radiusKm = 20;\n}\n\nvar centerLat = null;\nvar centerLng = null;\n\nif(typeof centerLatOpt === \"number\") centerLat = centerLatOpt;\nif(typeof centerLngOpt === \"number\") centerLng = centerLngOpt;\n\n\/\/ se non passati: usa la posizione (myMarker) oppure il centro mappa\nif(centerLat === null || centerLng === null){\n  var ll = null;\n  if(typeof myMarker !== \"undefined\"){\n    if(myMarker){\n      ll = myMarker.getLatLng();\n    }\n  }\n  if(ll){\n    centerLat = ll.lat;\n    centerLng = ll.lng;\n  }else{\n    var c = null;\n    if(typeof map !== \"undefined\"){\n      if(map){\n        c = map.getCenter();\n      }\n    }\n    if(c){\n      centerLat = c.lat;\n      centerLng = c.lng;\n    }\n  }\n}\n\n\/\/ \u2705 carica da Supabase gi\u00e0 filtrando per bbox (evita limite 1000 lontani)\nif(centerLat !== null){\n  if(centerLng !== null){\n    didQuery = true;\n\n    var dLat = radiusKm \/ 111;\n    var rad = centerLat * Math.PI \/ 180;\n    var cosv = Math.cos(rad);\n\n    var dLng = radiusKm \/ 111;\n    if(cosv !== 0){\n      dLng = radiusKm \/ (111 * cosv);\n    }\n\n    var minLat = centerLat - dLat;\n    var maxLat = centerLat + dLat;\n    var minLng = centerLng - dLng;\n    var maxLng = centerLng + dLng;\n\n    const res = await sb.from(SPOTS_TABLE)\n      .select(\"*\")\n      .gte(\"lat\", minLat).lte(\"lat\", maxLat)\n      .gte(\"lng\", minLng).lte(\"lng\", maxLng)\n      .order(\"created_at\",{ascending:false})\n      .limit(2000);\n\n    if(res.error){\n      console.warn(res.error.message);\n      return;\n    }\n    spots = res.data || [];\n  }\n}\n\nif(didQuery === false){\n  \/\/ fallback: se non abbiamo un centro valido, prova la query classica (limit 1000)\n  const res2 = await sb.from(SPOTS_TABLE).select(\"*\").order(\"created_at\",{ascending:false}).limit(1000);\n  if(res2.error){\n    console.warn(res2.error.message);\n    return;\n  }\n  spots = res2.data || [];\n}\n\n\/\/ applica filtro solo se abbiamo un centro valido\nif(centerLat !== null){\n  if(centerLng !== null){\n    var filtered = [];\n    for(var i=0; i<spots.length; i++){\n      var it = spots[i];\n      if(it){\n        var la = it.lat;\n        var ln = it.lng;\n\n        if(la === null || typeof la === \"undefined\"){\n          la = it.latitude;\n        }\n        if(ln === null || typeof ln === \"undefined\"){\n          ln = it.longitude;\n        }\n\n        if(typeof la !== \"number\") la = parseFloat(la);\n        if(typeof ln !== \"number\") ln = parseFloat(ln);\n\n        if(!isNaN(la)){\n          if(!isNaN(ln)){\n            var d = kmBetween(centerLat, centerLng, la, ln);\n            if(d <= radiusKm){\n              filtered.push(it);\n            }\n          }\n        }\n      }\n    }\n    spots = filtered;\n  }\n}\n\n  const spotIds=spots.map(s=>s.id).filter(Boolean);\n  let votes=[],comments=[];\n  if(spotIds.length){\n    const v=await sb.from(SPOT_VOTES_TABLE).select(\"*\").in(\"spot_id\",spotIds);\n    votes=v.error?[]:(v.data||[]);\n    const c=await sb.from(SPOT_COMMENTS_TABLE).select(\"*\").in(\"spot_id\",spotIds).order(\"created_at\",{ascending:true});\n    comments=c.error?[]:(c.data||[]);\n  }\n  spotCommentsById={};\n  comments.forEach(x=>{(spotCommentsById[x.spot_id]=spotCommentsById[x.spot_id]||[]).push(x)});\n  const agg={};\n  votes.forEach(v=>{\n    const sid=v.spot_id;\n    agg[sid]=agg[sid]||{sum:0,count:0,myRating:0};\n    agg[sid].sum+=Number(v.rating)||0;\n    agg[sid].count+=1;\n    if(user){ if(v.user_id===user.id) agg[sid].myRating=Number(v.rating)||0; }\n  });\n  const enriched=spots.map(s=>{\n    const a=agg[s.id]||{sum:0,count:0,myRating:0};\n    const avg=a.count?(a.sum\/a.count):0;\n    return {...s,avg_rating:avg,votes_count:a.count,my_rating:a.myRating};\n  });\n  spotById={};\n  enriched.forEach(s=>spotById[s.id]=s);\n  rebuildSpotMarkers(enriched);\n\n  if(!enriched.length){\n    const e=document.createElement(\"div\");\n    e.style.color=\"var(--muted)\";\n    e.style.padding=\"8px\";\n    e.textContent=\"Nessuno spot ancora. Aggiungine uno tu \ud83d\ude0e\";\n    $(\"spotList\").appendChild(e);\n    return;\n  }\n\n  enriched.forEach(s=>{\n    const wrap=document.createElement(\"div\");wrap.className=\"spItem\";\n    const created=s.created_at?new Date(s.created_at).toLocaleString(\"it-IT\"):\"\";\n    const avg=Math.round((Number(s.avg_rating)||0)*10)\/10;\n    const votesCount=s.votes_count||0;\n    const nice=(s.spot_name||\"\").trim();\n\n    const top=document.createElement(\"div\");top.className=\"spTop\";\n    top.innerHTML=`<div>\n      <div class=\"spUser\">${nice?`${spotEmoji(s.type)} ${esc(nice)}`:`${spotEmoji(s.type)} ${spotLabel(s.type)}`}<\/div>\n      <div class=\"spMeta\">${esc(spotLabel(s.type))}<\/div>\n      <div class=\"spMeta\"><b>${esc(s.username||\"\u2014\")}<\/b> \u2022 ${esc(created)}<\/div>\n      <div class=\"spAddr\">${esc(s.address||\"\")}<\/div>\n      ${(s.contact||\"\").trim()?`<div class=\"spMeta\"><b>${esc(lblContact)}:<\/b> ${linkifyText(s.contact)}<\/div>`:\"\"}\n      ${(s.price_per_night||\"\").trim()?`<div class=\"spMeta\"><b>Costo a notte:<\/b> ${esc(s.price_per_night)}<\/div>`:\"\"}\n      ${amenitiesMiniHtml(s)}\n    <\/div>`;\n\n    const btns=document.createElement(\"div\");btns.className=\"spBtns\";\n    if(user ? (s.user_id===user.id) : false){\n      const bE=document.createElement(\"button\");bE.className=\"spBtnEdit\";bE.type=\"button\";bE.textContent=\"Modifica\";bE.onclick=()=>editSpot(s);\n      const bD=document.createElement(\"button\");bD.className=\"spBtnDel\";bD.type=\"button\";bD.textContent=\"Cancella\";bD.onclick=()=>deleteSpot(s.id,s.photo_path);\n      btns.appendChild(bE);btns.appendChild(bD);\n    }else{\n      const bO=document.createElement(\"button\");bO.className=\"spBtnEdit\";bO.type=\"button\";bO.textContent=\"Apri scheda\";\n      bO.onclick=()=>{if(s.lat){ if(s.lng) map.setView([s.lat,s.lng],Math.max(map.getZoom(),15),{animate:true}); } openSpotModal(s.id)};\n      btns.appendChild(bO);\n    }\n    top.appendChild(btns);\n\n    const desc=document.createElement(\"div\");desc.className=\"spDesc\";desc.textContent=s.description||\"\";\n    wrap.appendChild(top);wrap.appendChild(desc);\n\n    if(s.photo_url){\n      const img=document.createElement(\"img\");\n      img.className=\"spPhoto\";\n      img.src=s.photo_url;\n      img.alt=\"Foto spot\";\n      wrap.appendChild(img);\n    }\n\n    const starRow=document.createElement(\"div\");starRow.className=\"starRow\";\n    const starAvg=document.createElement(\"div\");\n    starAvg.innerHTML=`${buildStarDisplay(avg)} <span style=\"font-size:12px;color:var(--muted)\">(${votesCount})<\/span>`;\n    starRow.appendChild(starAvg);\n    wrap.appendChild(starRow);\n\n    $(\"spotList\").appendChild(wrap);\n  });\n\n  if($(\"spotModal\").classList.contains(\"open\")){ if(spotModalSpotId) renderSpotModal(); }\n  }finally{\n    try{\n      if(typeof window.__spotsLoadingTimer !== \"undefined\"){\n        if(window.__spotsLoadingTimer){ clearTimeout(window.__spotsLoadingTimer); }\n        window.__spotsLoadingTimer = null;\n      }\n    }catch(e){}\n    window.__spotsLoading = false;\n  }\n}\n\n\/* \u2705 FIX: hookSpotUI = robust (no crash se manca qualche elemento) *\/\nfunction hookSpotUI(){\n  const panel = $(\"spotPanel\");\n  if(!panel) return;\n\n  const openSpotPanel = () => {\n    if(!user) return alert(t().profLoginReq);\n    panel.classList.add(\"open\");\n    setAria(panel,true);\n    panel.classList.remove(\"pick\");\n    modalClose(\"rvListModal\"); modalClose(\"rvFormModal\");\n    setRVPickSheet(false);\n    setMenuOpen(false);\n    lockMapInteractions(true);\n  };\n\n  const closeSpotPanel = () => {\n    spotPickMode = false;\n    updatePickBtnsText();\n    panel.classList.remove(\"pick\",\"open\");\n    setAria(panel,false);\n    lockMapInteractions(false);\n  };\n\n  const toggle = $(\"spotToggle\");\n  if(toggle) toggle.onclick = () => panel.classList.contains(\"open\") ? closeSpotPanel() : openSpotPanel();\n\n  const closeBtn = $(\"spotClose\");\n  if(closeBtn) closeBtn.onclick = closeSpotPanel;\n\n  const tCamping = $(\"spType_camping\");\n  if(tCamping) tCamping.onclick = () => setSpotType(\"camping\");\n\n  const tFree = $(\"spType_free\");\n  if(tFree) tFree.onclick = () => setSpotType(\"free\");\n\n  const pickBtn = $(\"spotPickBtn\");\n  if(pickBtn) pickBtn.onclick = () => {\n    if(!user) return alert(t().profLoginReq);\n    spotPickMode = !spotPickMode;\n    updatePickBtnsText();\n    if(spotPickMode){\n      panel.classList.add(\"pick\");\n      lockMapInteractions(false);\n    }else{\n      panel.classList.remove(\"pick\");\n      lockMapInteractions(true);\n    }\n  };\n\n  const pubBtn = $(\"spotPublishBtn\");\n  if(pubBtn) pubBtn.onclick = publishOrUpdateSpot;\n\n  const cancelEditBtn = $(\"spotCancelEditBtn\");\n  if(cancelEditBtn) cancelEditBtn.onclick = resetSpotForm;\n\n  const photoInput = $(\"spotPhoto\");\n  if(photoInput) photoInput.addEventListener(\"change\", async (e)=>{\n    const file = e.target.files?.[0];\n    const preview = $(\"spotPreview\");\n\n    if(!file){\n      currentSpotPhotoBlob = null;\n      if(preview) hide(preview);\n      return;\n    }\n\n    try{\n      const { blob, dataUrl, ext } = await resizeTo300(file, SPOT_MAX_SIZE);\n      currentSpotPhotoBlob = blob;\n      currentSpotPhotoExt = ext || \"jpg\";\n\n      if(preview){\n        preview.src = dataUrl;\n        show(preview, \"block\");\n      }\n    }catch(err){\n      console.warn(\"resize photo:\", err);\n      alert(t().profPhotoErr);\n    }\n  });\n}\n\n\/* START *\/\nasync function start(){\n  await loadProfiles();\n  initMap(true);\n  restoreRadarState();\n  restoreNotifState();\n\n  \/\/ AUTO-RESET PUSH: se l'utente \u00e8 loggato e i permessi sono gi\u00e0 \"granted\"\n  try{\n    if((user ? (user.id ? true : false) : false) ? ((\"Notification\" in window) ? (Notification.permission === \"granted\") : false) : false){\n      await enableRealPush(); \/\/ fa unsubscribe\/subscribe + upsert\n    }\n  }catch(e){\n    console.warn(\"auto push reset:\", e);\n  }\n\n  await refreshApp();\n  startPolling();\n\n  \/\/ \u2705 hook: chiamali pure, ma se una funzione non esiste non deve esplodere\n  try{ hookRVUI?.(); }catch(e){ console.warn(\"hookRVUI:\", e); }\n  try{ hookSpotUI?.(); }catch(e){ console.warn(\"hookSpotUI:\", e); }\n  try{ hookSpotModalUI?.(); }catch(e){ console.warn(\"hookSpotModalUI:\", e); }\n  try{ hookAmenitiesUI?.(); }catch(e){ console.warn(\"hookAmenitiesUI:\", e); }\n  try{ hookChatModalUI?.(); }catch(e){ console.warn(\"hookChatModalUI:\", e); }\n  try{ hookMenuUI?.(); }catch(e){ console.warn(\"hookMenuUI:\", e); }\n  try{ hookLangUI?.(); }catch(e){ console.warn(\"hookLangUI:\", e); }\n  try{ hookProfileUI?.(); }catch(e){ console.warn(\"hookProfileUI:\", e); }\n  try{ hookBikeToolUI?.(); }catch(e){ console.warn(\"hookBikeToolUI:\", e); }\n\n  renderChatList?.();\n  renderMessages?.();\n\n  applyLang(getLang());\n}\n\n\/* BOOT *\/\n(async()=>{\n  try{\n    syncNickUI();\n    loadPeers();\n\n    if(!window.L) throw new Error(\"Leaflet NON caricato (L undefined). WordPress sta bloccando gli script esterni?\");\n    if(!window.supabase||!window.supabase.createClient) throw new Error(\"Supabase JS NON caricato (supabase undefined).\");\n\n    hookLangUI();\n    $(\"changeNickBtn\").onclick=()=>{show($(\"nickRow\"));hide($(\"nickDisplayRow\"));$(\"nickname\").focus()};\n    $(\"loginBtn\").onclick=login;\n    $(\"signupBtn\").onclick=signup;\n\n    $(\"radarBtn\").onclick=()=>{setMenuOpen(false);toggleRadar()};\n    $(\"notifBtn\").onclick=()=>{setMenuOpen(false);toggleNotifications()};\n    $(\"refreshBtn\").onclick=()=>{setMenuOpen(false);refreshApp()};\n    $(\"logoutBtn\").onclick=()=>{setMenuOpen(false);logout()};\n\n    $(\"sendBtn\").onclick=sendMsg;\n    $(\"chatToggle\").onclick=()=>{setMenuOpen(false);openChatModal()};\n\n    const {data,error}=await sb.auth.getSession();\n    if(error) console.warn(\"getSession:\",error.message);\n\n    if(data?.session){\n      user=data.session.user;\n      hide($(\"authForm\"));\n      show($(\"userInfo\"),\"block\");\n      $(\"userEmail\").innerText=user.email;\n      window.__userEmailCache=user.email;\n      await ensureMyNickname();\n      await start();\n    }else{\n      initMap(false);\n      hookRVUI();hookSpotUI();hookSpotModalUI();hookAmenitiesUI();hookChatModalUI();hookMenuUI();hookLangUI();\n      setSpotType(\"camping\");\n      resetAmenities();\n      $(\"spotPanel\").classList.remove(\"open\");\n      setAria($(\"spotPanel\"),false);\n      applyLang(getLang());\n      updatePickBtnsText();\n      updateSpotPrimaryBtnText();\n      updateRVPrimaryBtnText();\n    }\n  }catch(err){\n    console.error(\"BOOT ERROR:\",err);\n      var msg = \"Errore avvio: \";\n    if(err){\n      if(err.message){ msg += err.message; }\n      else { msg += String(err); }\n    }else{\n      msg += \"errore sconosciuto\";\n    }\n    alert(msg);\n    try{\n      initMap(false);\n      hookRVUI();hookSpotUI();hookSpotModalUI();hookAmenitiesUI();hookChatModalUI();hookMenuUI();hookLangUI();hookProfileUI();\n      setSpotType(\"camping\");\n      resetAmenities();\n      applyLang(getLang());\n    }catch(e){console.error(\"Fallback failed:\",e)}\n  }\n})();\n<\/script>\n\n<!-- \u2705 ADD-ON: User action menu + Profile modal -->\n<div id=\"userActMenu\" class=\"uAct\">\n  <div class=\"uActCard\" role=\"dialog\" aria-modal=\"true\">\n    <div class=\"uActTitle\" id=\"uActName\">Utente<\/div>\n    <div class=\"uActBtns\">\n      <button id=\"uActChat\" class=\"uBtnMain\">\ud83d\udcac Chat<\/button>\n      <button id=\"uActProf\" class=\"uBtnAlt\">\ud83d\udc64 Profilo<\/button>\n    <\/div>\n    <button id=\"uActClose\" class=\"uBtnClose\">\u2715<\/button>\n  <\/div>\n<\/div>\n\n<div id=\"profModal\" class=\"uModal\">\n  <div class=\"uModalCard\">\n    <div class=\"uModalHeader\">\n      <div class=\"uModalTitle\" id=\"profTitle\">Profilo<\/div>\n      <button id=\"profClose\" class=\"uBtnClose\">\u2715<\/button>\n    <\/div>\n    <div class=\"uModalBody\" id=\"profBody\"><\/div>\n  <\/div>\n<\/div>\n\n<script>\n\/* \u2705 ADD-ON: User action menu + Profile modal (logic) \u2014 FIX PROFILO OSPITE *\/\nlet selectedPeer = null;\n\nfunction escapeHtml(str){\n  return String(str ?? \"\").replace(\/[&<>\"']\/g, (c)=>(\n    {\"&\":\"&amp;\",\"<\":\"&lt;\",\">\":\"&gt;\",\"\\\"\":\"&quot;\",\"'\":\"&#039;\"}[c]\n  ));\n}\n\nfunction openUserMenu(peerObj){\n  selectedPeer = peerObj || null;\n\n  const name = peerObj?.nickname || peerObj?.display_name || peerObj?.name || peerObj?.username || \"Utente\";\n  const nameEl = $(\"uActName\");\n  if(nameEl) nameEl.textContent = name;\n\n  const wrap = $(\"userActMenu\");\n  if(wrap) wrap.classList.add(\"open\");\n}\nwindow.openUserMenu = openUserMenu;\n\nfunction closeUserMenu(){\n  const wrap = $(\"userActMenu\");\n  if(wrap) wrap.classList.remove(\"open\");\n  selectedPeer = null;\n}\n\nfunction openProfModal(){\n  const m = $(\"profModal\");\n  if(m) m.classList.add(\"open\");\n}\nfunction closeProfModal(){\n  const m = $(\"profModal\");\n  if(m) m.classList.remove(\"open\");\n}\n\n\n\n\/* \u2705 FIX: apri profilo dell'utente cliccato (guest profile) *\/\nasync function openProfile(peer){\n  \/\/ accetta oggetto peer oppure string uid\n  const pObj = (typeof peer === \"string\") ? { user_id: peer } : (peer || {});\n  const uid = pObj.user_id || pObj.uid || null;\n  const rowId = (uid ? null : (pObj.id ?? null));\n\n  \/\/ titolo + modal\n  const title = pObj.nickname || pObj.display_name || pObj.name || pObj.username || \"Profilo\";\n  const tEl = document.getElementById(\"profTitle\");\n  if(tEl) tEl.textContent = title;\n\n  const bodyEl = document.getElementById(\"profBody\");\n  if(bodyEl) bodyEl.innerHTML = `<div class=\"uProfMuted\">Caricamento...<\/div>`;\n  openProfModal();\n\n  \/\/ niente DB? mostra solo N\/A\n  if(!window.sb || (!uid ? (rowId==null) : false)){\n    if(bodyEl) bodyEl.innerHTML = `<div class=\"uProfMuted\">Profilo: N\/A<\/div>`;\n    return;\n  }\n\n  try{\n    let q = sb.from(\"profiles\").select(\"*\");\n    if(uid) q = q.eq(\"user_id\", uid);\n    else q = q.eq(\"id\", rowId);\n\n    const { data, error } = await q.maybeSingle();\n    if(error || !data){\n      if(bodyEl) bodyEl.innerHTML = `<div class=\"uProfMuted\">Profilo: N\/A<\/div>`;\n      return;\n    }\n\n    \/\/ render semplice (adatta campi se cambiano)\n    const esc = (s)=> (typeof escapeHtml===\"function\" ? escapeHtml(String(s??\"\")) : String(s??\"\").replace(\/[&<>\"']\/g, m=>({ \"&\":\"&amp;\",\"<\":\"&lt;\",\">\":\"&gt;\",\"\\\"\":\"&quot;\",\"'\":\"&#39;\" }[m])));\n    const name = data.display_name || data.nickname || title || \"Utente\";\n    const bio  = data.bio || data.about || \"\";\n    const hobbies = Array.isArray(data.hobbies) ? data.hobbies.join(\", \") : (data.hobbies || \"\");\n    const avatar = data.avatar_url || data.avatar || \"\";\n\n    let html = \"\";\n    if(avatar) html += `<div style=\"display:flex;justify-content:center;margin:10px 0 14px\">\n      <img decoding=\"async\" src=\"${esc(avatar)}\" alt=\"avatar\" style=\"width:92px;height:92px;border-radius:50%;object-fit:cover;border:1px solid var(--line2);box-shadow:var(--glow)\">\n    <\/div>`;\n    html += `<div style=\"font-weight:900;font-size:18px;text-align:center;margin-bottom:8px\">${esc(name)}<\/div>`;\n    if(bio) html += `<div style=\"margin:10px 0 0\">${esc(bio)}<\/div>`;\n    if(hobbies) html += `<div style=\"margin:10px 0 0\"><span class=\"uProfMuted\">Hobbies:<\/span> ${esc(hobbies)}<\/div>`;\n\n    if(bodyEl) bodyEl.innerHTML = html || `<div class=\"uProfMuted\">Profilo: N\/A<\/div>`;\n  }catch(e){\n    console.warn(\"openProfile error:\", e);\n    if(bodyEl) bodyEl.innerHTML = `<div class=\"uProfMuted\">Profilo: N\/A<\/div>`;\n  }\n}\nwindow.openProfile = openProfile;\n(function bindUserActionMenu(){\n  \/\/ chiusura menu cliccando fuori card\n  var uAct = document.getElementById(\"userActMenu\");\n  if(uAct){\n    uAct.addEventListener(\"click\", function(e){ if(e.target===uAct) closeUserMenu(); });\n  }\n  var closeBtn = document.getElementById(\"uActClose\");\n  if(closeBtn) closeBtn.onclick = closeUserMenu;\n\n  \/\/ chiusura profilo cliccando fuori card\n  var profModal = document.getElementById(\"profModal\");\n  if(profModal){\n    profModal.addEventListener(\"click\", function(e){ if(e.target===profModal) closeProfModal(); });\n  }\n  var profClose = document.getElementById(\"profClose\");\n  if(profClose) profClose.onclick = closeProfModal;\n\n  \/\/ \u2705 CHAT\n  var chatBtn = document.getElementById(\"uActChat\");\n  if(chatBtn){\n    chatBtn.onclick = function(){\n      if(!selectedPeer) return;\n      var uid = selectedPeer.user_id || selectedPeer.uid || null;\n      closeUserMenu();\n      if(uid){ if(typeof openChatFromMap === \"function\") openChatFromMap(uid); }\n      else if(uid){ if(typeof openChat === \"function\") openChat(uid); }\n      else if(typeof toast === \"function\") toast(\"Chat: utente non valido\");\n    };\n  }\n\n  \/\/ \u2705 PROFILO (FIX: deve aprire quello dell'utente cliccato)\n  var profBtn = document.getElementById(\"uActProf\");\n  if(profBtn){\n    profBtn.onclick = async function(){\n      if(!selectedPeer) return;\n\n      var peer = selectedPeer; \/\/ snapshot\n      closeUserMenu();\n\n      var uid = peer.user_id || peer.uid || null; \/\/ \u2705 auth uid dell'utente cliccato\n      document.getElementById(\"profTitle\").textContent = peer.nickname || peer.display_name || peer.name || \"Profilo\";\n      document.getElementById(\"profBody\").innerHTML = `<div class=\"uProfMuted\">Caricamento...<\/div>`;\n      openProfModal();\n\n      try{\n        var p = null;\n\n        if(window.sb){\n          var q = sb.from(\"profiles\").select(\"*\");\n          \/\/ \u2705 prima prova con user_id (pi\u00f9 stabile nel tuo codice)\n          if(uid) q = q.eq(\"user_id\", uid);\n          \/\/ fallback su id (se esiste solo quello)\n          else if(peer.id != null) q = q.eq(\"id\", peer.id);\n\n          var { data, error } = await q.maybeSingle();\n          if(error) console.warn(\"profilo read:\", error.message);\n          else p = data;\n        }\n\n        if(!p){\n          document.getElementById(\"profBody\").innerHTML = `<div class=\"uProfMuted\">N\/A<\/div>`;\n          return;\n        }\n\n        var displayName = p.display_name || p.nickname || peer.nickname || peer.display_name || peer.name || \"Utente\";\n        var bio = p.bio || p.about || \"\";\n        var hobbies = Array.isArray(p.hobbies) ? p.hobbies.join(\", \") : (p.hobbies || \"\");\n        var avatar = p.avatar_url || \"\";\n\n        document.getElementById(\"profTitle\").textContent = displayName;\n\n        document.getElementById(\"profBody\").innerHTML = `\n          <div class=\"uProfRow\">\n            ${avatar ? `<img decoding=\"async\" class=\"uProfAvatar\" src=\"${escapeHtml(avatar)}\" alt=\"avatar\">` : \"\"}\n            <div class=\"uProfCol\">\n              ${bio ? `<div class=\"uProfBio\">${escapeHtml(bio)}<\/div>` : `<div class=\"uProfMuted\">Nessuna descrizione.<\/div>`}\n              ${hobbies ? `<div class=\"uProfTags\">${escapeHtml(hobbies)}<\/div>` : \"\"}\n            <\/div>\n          <\/div>`;\n      }catch(e){\n        console.warn(e);\n        document.getElementById(\"profBody\").innerHTML = `<div class=\"uProfMuted\">N\/A<\/div>`;\n      }\n    };\n  }\n})();;\n<\/script>\n<script type=\"module\">\n  import { initializeApp } from \"https:\/\/www.gstatic.com\/firebasejs\/10.12.5\/firebase-app.js\";\n  import { getMessaging, getToken, onMessage } from \"https:\/\/www.gstatic.com\/firebasejs\/10.12.5\/firebase-messaging.js\";\n\n  const firebaseConfig = {\n    apiKey: \"AIzaSyCmxER3vEFeZjbNvXa709_LPD8shX9dTEI\",\n    authDomain: \"camperpulse.firebaseapp.com\",\n    projectId: \"camperpulse\",\n    storageBucket: \"camperpulse.firebasestorage.app\",\n    messagingSenderId: \"890126077970\",\n    appId: \"1:890126077970:web:d63374500888468c9ac8cb\"\n  };\n\n  const VAPID_KEY = \"BPU5fNky-_vCcMPpVsSx8cdoNGdSHL8Sse7sK9s9aOc2vzPZ_yYyzwvzugzTJNbOPQJxOyD4ho6yB3MlhLFB6pI\";\n\n  const app = initializeApp(firebaseConfig);\n  const messaging = getMessaging(app);\n\n  async function enablePushFCM() {\n    if (!(\"serviceWorker\" in navigator)) {\n      console.warn(\"Service Worker non supportato\");\n      return;\n    }\n\n    \/\/ registra il TUO sw.js in root\n    const reg = await navigator.serviceWorker.register(\"\/firebase-messaging-sw.js\");\n    try{ await reg.update(); }catch(e){ console.warn(\"SW update failed:\", e); }\n    const perm = await Notification.requestPermission();\n    console.log(\"Notification permission:\", perm);\n    if (perm !== \"granted\") return;\n\n    const token = await getToken(messaging, {\n      vapidKey: VAPID_KEY,\n      serviceWorkerRegistration: reg\n    });\n\n        console.log(\"\u2705 FCM TOKEN:\", token || \"(vuoto)\");\n    if (!token) return;\n\n    \/\/ \u2705 salva token su Supabase (tabella: fcm_tokens)\n    const { data: u, error: uErr } = await sb.auth.getUser();\n    if (uErr) { console.error(\"getUser error:\", uErr); return; }\n    const uid = u?.user?.id;\n    if (!uid) { console.warn(\"No user id (non loggato?)\"); return; }\n\n    const { error: upErr } = await sb\n      .from(\"fcm_tokens\")\n      .upsert(\n        { user_id: uid, token, updated_at: new Date().toISOString() },\n        { onConflict: \"user_id\" }\n      );\n\n    if (upErr) console.error(\"upsert fcm_tokens error:\", upErr);\n    else console.log(\"\u2705 Token salvato su Supabase per user:\", uid);\n  }\n\nasync function disablePushFCM(){\n  try{\n    const sb = window.sb;\n    if(!sb || !sb.auth){ console.warn(\"disablePushFCM: sb non disponibile\"); return false; }\n\n    const { data:{ user } = {} } = await sb.auth.getUser();\n    const uid = user?.id;\n    if(!uid){ console.warn(\"disablePushFCM: user non loggato\"); return false; }\n\n    \/\/ prova a cancellare anche il token localmente (best effort)\n    try{\n      const reg = await navigator.serviceWorker.getRegistration();\n      await getToken(messaging, { vapidKey: VAPID_KEY, serviceWorkerRegistration: reg || undefined });\n      await deleteToken(messaging);\n    }catch(e){ console.warn(\"disablePushFCM deleteToken:\", e); }\n\n    const { error } = await sb.from(TOKENS_TABLE).delete().eq(\"user_id\", uid);\n    if(error) console.warn(\"disablePushFCM db delete:\", error.message);\n\n    console.log(\"\u2705 FCM disabilitato (token rimosso) per user:\", uid);\n    return true;\n  }catch(e){\n    console.warn(\"disablePushFCM:\", e);\n    return false;\n  }\n}\n\n\n  onMessage(messaging, (payload) => {\n  const title = payload?.notification?.title || \"Camperisti in Liberta\";\n  const body  = payload?.notification?.body  || \"Hai un nuovo messaggio\";\n  const url   = payload?.data?.url || \"https:\/\/camperistiinliberta.it\/?page_id=77\";\n\n  \/\/ \u2705 usa la tua notifica in-page (se esiste)\n  if (typeof notify === \"function\") {\n    notify(`${title}: ${body}`);\n  } else {\n    \/\/ fallback\n    console.log(\"\ud83d\udd14 NOTIF:\", title, body, url);\n    alert(`${title}\\n\\n${body}`);\n  }\n\n  \/\/ \u2705 (opzionale) click sulla notifica in-page: apri la pagina\n  \/\/ se hai gi\u00e0 un sistema click, dimmelo e lo leghiamo bene\n});\n\n  \/\/ \u2705 espone le funzioni al resto del codice\n  window.enablePushFCM = enablePushFCM;\n  window.disablePushFCM = disablePushFCM;\n\n  \/\/ \u2705 se l'utente aveva gi\u00e0 attivato le notifiche, prova ad abilitarle dopo il load\n  window.addEventListener(\"load\", async ()=>{\n    try{\n      if(localStorage.getItem(\"cp_notif_on\")===\"1\"){\n        await enablePushFCM();\n      }\n    }catch(e){ console.warn(\"enablePushFCM autostart:\", e); }\n  });\n\n\/* \u2705 FIX: handler globale (delegation) per click su \"Cerca spot\" *\/\n(function(){\n  try{\n    if(window.__cpOsmBtnDelegated) return;\n    window.__cpOsmBtnDelegated = true;\n\n    \/\/ \u2705 helper: evita doppio trigger mobile (touchend + click) e timeout anti-blocco\n    if(typeof window.__lastSearchTouchTs === \"undefined\") window.__lastSearchTouchTs = 0;\n    function __runLoadSpotsSafe(){\n      var p = null;\n      try{ p = Promise.resolve(loadSpots()); }catch(e){ p = Promise.reject(e); }\n      return new Promise(function(resolve,reject){\n        var finished = false;\n        var timer = setTimeout(function(){\n          if(finished) return;\n          finished = true;\n          try{ window.__spotsLoading = false; }catch(e){}\n          reject(new Error(\"loadSpots timeout\"));\n        }, 30000);\n        p.then(function(v){\n          if(finished) return;\n          finished = true;\n          clearTimeout(timer);\n          resolve(v);\n        }).catch(function(err){\n          if(finished) return;\n          finished = true;\n          clearTimeout(timer);\n          reject(err);\n        });\n      });\n    }\n    function __handleSearchTap(t, isTouch){\n      try{\n        if(isTouch){\n          window.__lastSearchTouchTs = Date.now();\n        }else{\n          if(typeof window.__lastSearchTouchTs === \"number\"){\n            var dt = Date.now() - window.__lastSearchTouchTs;\n            if(dt < 900) return; \/\/ ghost-click dopo touch\n          }\n        }\n        if(!t) return;\n        if(t.disabled) return;\n        t.disabled = true;\n        var oldTxt = t.textContent;\n        t.textContent = \"Cercando...\";\n        __runLoadSpotsSafe()\n          .catch(function(err){\n            console.warn(\"loadSpots:\", err);\n            if(typeof toast === \"function\") toast(\"Errore ricerca spot (vedi console).\");\n          })\n          .finally(function(){\n            t.disabled = false;\n            t.textContent = oldTxt || trText(\"searchSpot\",\"Cerca spot\");\n          });\n      }catch(e){\n        console.warn(\"__handleSearchTap:\", e);\n      }\n    }\n\n    document.addEventListener(\"click\", function(ev){\n      try{\n        var t = ev.target;\n        if(!t) return;\n\n        \/\/ se clicchi su un'icona dentro il bottone, risali al button\n        if(t.tagName){\n          if(t.tagName.toLowerCase() !== \"button\"){\n            var p = t.closest ? t.closest(\"button\") : null;\n            if(p) t = p;\n          }\n        }\n\n        if(!t) return;\n        if(!t.classList) return;\n        if(!t.classList.contains(\"cpOsmSearchBtn\")) return;\n\n        ev.preventDefault();\n        ev.stopPropagation();\n\n        \/\/ feedback immediato\n        if(typeof toast === \"function\") toast(\"Ricerca spot avviata\u2026\");\n        __handleSearchTap(t, false);\n\n      }catch(e){\n        console.warn(\"delegated cpOsmSearchBtn click:\", e);\n      }\n    }, true); \/\/ capture: prende il tap anche se qualche overlay prova a fermarlo\n    \/\/ \u2705 FIX MOBILE: su alcuni telefoni il \"click\" non parte; intercetta anche touchend\n    document.addEventListener(\"touchend\", function(ev){\n      try{\n        var t = ev.target;\n        if(!t) return;\n\n        if(t.tagName){\n          if(t.tagName.toLowerCase() !== \"button\"){\n            var p = t.closest ? t.closest(\"button\") : null;\n            if(p) t = p;\n          }\n        }\n\n        if(!t) return;\n        if(!t.classList) return;\n        if(!t.classList.contains(\"cpOsmSearchBtn\")) return;\n\n        ev.preventDefault();\n        ev.stopPropagation();\n\n        if(typeof toast === \"function\") toast(\"Ricerca spot avviata\u2026\");\n        __handleSearchTap(t, true);\n\n      }catch(e){\n        console.warn(\"delegated cpOsmSearchBtn touchend:\", e);\n      }\n    }, true);\n  }catch(e){\n    console.warn(\"cpOsmBtnDelegation init:\", e);\n  }\n})();\n\n<\/script>\n\n\n<!-- \u2705 ADD-ON: Elevation profile modal -->\n<div id=\"elevModal\" aria-hidden=\"true\">\n  <div id=\"elevCard\" role=\"dialog\" aria-modal=\"true\">\n    <div id=\"elevHead\">\n      <div id=\"elevTitle\">Profilo dislivello<\/div>\n      <div id=\"elevBtns\">\n        <button id=\"elevDelete\" type=\"button\">Cancella tour<\/button>\n        <button id=\"elevStart\" type=\"button\">Avvia tour<\/button>\n        <button id=\"elevStop\" type=\"button\" style=\"display:none\">Stop<\/button>\n        <button id=\"elevClose\" type=\"button\">Chiudi<\/button>\n      <\/div>\n    <\/div>\n    <div id=\"elevBody\">\n      <div id=\"elevMeta\"><\/div>\n      <div id=\"elevLive\" style=\"display:none\"><span id=\"elevLiveProg\">0.0 km<\/span><span id=\"elevLiveAlt\">0 m<\/span><span id=\"elevLiveSpeed\">0 km\/h<\/span><\/div>\n<div id=\"elevCanvasWrap\">\n        <canvas id=\"elevChart\" height=\"160\"><\/canvas>\n      <\/div>\n      <div id=\"elevLoading\">Caricamento altimetria\u2026<\/div>\n    <\/div>\n  <\/div>\n<\/div>\n\n<\/body>\n<\/html>\n\n\n\n<div class=\"wp-block-uagb-buttons uagb-buttons__outer-wrap uagb-btn__default-btn uagb-btn-tablet__default-btn uagb-btn-mobile__default-btn uagb-block-42601cac\"><div class=\"uagb-buttons__wrap uagb-buttons-layout-wrap \">\n<div class=\"wp-block-uagb-buttons-child uagb-buttons__outer-wrap uagb-block-0bc20764 wp-block-button\"><div class=\"uagb-button__wrapper\"><a class=\"uagb-buttons-repeater wp-block-button__link\" aria-label=\"\" href=\"#\" rel=\"follow noopener\" target=\"_self\" role=\"button\"><div class=\"uagb-button__link\">Click Here<\/div><\/a><\/div><\/div>\n<\/div><\/div>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>CamperPulse \ud83d\udeb2 BikeTool \u2715 \ud83e\udded Pianifica tour sulla mappa Tocca\/clicca sulla mappa per aggiungere punti. (Minimo 2) Salva Annulla Tour [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"_uag_custom_page_level_css":"","site-sidebar-layout":"default","site-content-layout":"","ast-site-content-layout":"default","site-content-style":"default","site-sidebar-style":"default","ast-global-header-display":"","ast-banner-title-visibility":"","ast-main-header-display":"","ast-hfb-above-header-display":"","ast-hfb-below-header-display":"","ast-hfb-mobile-header-display":"","site-post-title":"disabled","ast-breadcrumbs-content":"","ast-featured-img":"","footer-sml-layout":"","ast-disable-related-posts":"","theme-transparent-header-meta":"","adv-header-id-meta":"","stick-header-meta":"","header-above-stick-meta":"","header-main-stick-meta":"","header-below-stick-meta":"","astra-migrate-meta-layouts":"default","ast-page-background-enabled":"default","ast-page-background-meta":{"desktop":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"ast-content-background-meta":{"desktop":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"footnotes":""},"class_list":["post-77","page","type-page","status-publish","hentry"],"uagb_featured_image_src":{"full":false,"thumbnail":false,"medium":false,"medium_large":false,"large":false,"1536x1536":false,"2048x2048":false},"uagb_author_info":{"display_name":"admin5729","author_link":"https:\/\/camperistiinliberta.it\/?author=1"},"uagb_comment_info":0,"uagb_excerpt":"CamperPulse \ud83d\udeb2 BikeTool \u2715 \ud83e\udded Pianifica tour sulla mappa Tocca\/clicca sulla mappa per aggiungere punti. (Minimo 2) Salva Annulla Tour [&hellip;]","_links":{"self":[{"href":"https:\/\/camperistiinliberta.it\/index.php?rest_route=\/wp\/v2\/pages\/77","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/camperistiinliberta.it\/index.php?rest_route=\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/camperistiinliberta.it\/index.php?rest_route=\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/camperistiinliberta.it\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/camperistiinliberta.it\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=77"}],"version-history":[{"count":420,"href":"https:\/\/camperistiinliberta.it\/index.php?rest_route=\/wp\/v2\/pages\/77\/revisions"}],"predecessor-version":[{"id":633,"href":"https:\/\/camperistiinliberta.it\/index.php?rest_route=\/wp\/v2\/pages\/77\/revisions\/633"}],"wp:attachment":[{"href":"https:\/\/camperistiinliberta.it\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=77"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}