1class AlilauroTimetable extends HTMLElement { 2 constructor() { 3 super(); 4 this._root = this.attachShadow({ mode: "open" }); 5 this._state = { rows: [], dep: "", arr: "", day: "" }; // day: "", "today", "tomorrow" 6 this._t = null; 7 this._resizeObs = null; 8 this._isCard = false; 9 } 10 11 static get observedAttributes() { return ["src", "dep", "arr", "sid"]; } 12 attributeChangedCallback() { this._schedule(); } 13 connectedCallback() { this._renderSkeleton(); this._schedule(); this._setupResizeObserver(); } 14 disconnectedCallback() { 15 if (this._t) { clearInterval(this._t); this._t = null; } 16 if (this._resizeObs) { this._resizeObs.disconnect(); this._resizeObs = null; } 17 } 18 19 _renderSkeleton() { 20 this._root.innerHTML = ` 21 <style> 22 :host{ 23 display:block; 24 width: 100%; 25 max-width: 100%; 26 margin: var(--tt-gap-top, 24px) auto var(--tt-gap-bottom, 40px) auto; 27 padding: 0 12px; 28 box-sizing: border-box; 29 color:#202857; 30 } 31 .tt-card{ 32 background:#fff; 33 border:1px solid #e5e7eb; 34 border-radius:12px; 35 box-shadow:0 6px 18px rgba(16,24,40,.06); 36 overflow:hidden; 37 } 38 .toolbar{ 39 display:flex; 40 gap:.75rem; 41 flex-wrap:wrap; 42 align-items:end; 43 justify-content:center; 44 margin:0 0 .75rem 0; 45 } 46 label{ font-weight:600; font-size:.9rem; } 47 select{ 48 padding:.45rem .6rem; 49 border:1px solid #d0d7de; 50 border-radius:8px; 51 background:#fff; 52 min-width:220px; 53 font-family:"Aptos Narrow", system-ui,-apple-system, Segoe UI, Roboto, Arial, sans-serif; 54 } 55 .truncate{ 56 display:inline-block; 57 max-width:100%; 58 overflow:hidden; 59 text-overflow:ellipsis; 60 white-space:nowrap; 61 min-width:0; 62 vertical-align:bottom; 63 } 64 .table-wrap{ 65 width:100%; 66 overflow:auto; 67 -webkit-overflow-scrolling:touch 68 } 69 .table{ 70 width:100%; 71 border-collapse:separate; 72 border-spacing:0; 73 margin:0; 74 } 75 thead th{ 76 position:sticky; 77 top:0; 78 z-index:1; 79 background:#f6f8fa; 80 text-align:left; 81 padding:.6rem .7rem; 82 font-weight:700; 83 border-bottom:1px solid #e5e7eb; 84 white-space:nowrap; 85 color:#0a66c2; /* testata blu come richiesto */ 86 } 87 tbody td{ 88 padding:.65rem .7rem; 89 border-bottom:1px solid #f0f2f4; 90 vertical-align:middle; 91 } 92 tbody tr:nth-child(odd){ background:#fafbfc; } 93 .time{ 94 display:inline-block; 95 min-width:64px; 96 text-align:center; 97 padding:.35rem .55rem; 98 border-radius:999px; 99 font-weight:700; 100 background:#0a66c2; /* pill orario blu */ 101 color:#fff; 102 } 103 .dot{ 104 display:inline-block; 105 width: 20px; 106 height: 20px; 107 border-radius:50%; 108 vertical-align:middle; 109 box-shadow:0 0 0 2px rgba(0,0,0,.05); 110 margin-right:.4rem; 111 } 112 .open{ background:#1e9e37; } 113 .pending{ background:#f59e0b; } 114 .closed{ background:#dc3545; } 115 .muted{ color:#6b7280; } 116 .empty{ text-align:center; color:#6b7280; padding:1rem; } 117 tbody td:nth-child(6) .truncate, tbody td:nth-child(7) .truncate{ display:block; } 118 .cards{ 119 display:grid; 120 gap:10px; 121 padding:10px; 122 } 123 .card{ 124 border:1px solid #e5e7eb; 125 border-radius:12px; 126 padding:12px; 127 background:#fff; 128 display:grid; 129 grid-template-columns: 64px 1fr; 130 gap:12px; 131 align-items:center; 132 } 133 .badge-time{ 134 display:flex; 135 flex-direction:column; 136 align-items:center; 137 gap:6px; 138 min-width:0; 139 } 140 .badge-time .time{ min-width:auto; padding:.35rem .55rem; } 141 .status-pill{ 142 display:inline-flex; 143 align-items:center; 144 gap:6px; 145 font-size:.85rem; 146 font-weight:600; 147 min-width:0; 148 } 149 .route{ font-weight:700; min-width:0; } 150 .small{ font-size:.85rem; color:#6b7280; } 151 .meta{ 152 display:grid; 153 grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); 154 gap:8px; 155 font-size:.9rem; 156 color:#374151; 157 min-width:0; 158 } 159 .pill{ 160 display:inline-flex; 161 align-items:center; 162 gap:6px; 163 padding:4px 8px; 164 border:1px dashed #e5e7eb; 165 border-radius:999px; 166 max-width:100%; 167 min-width:0; 168 } 169 @media (max-width: 992px){ 170 select{ min-width:180px; } 171 } 172 @media (max-width: 768px){ 173 thead th:nth-child(7), 174 thead th:nth-child(8), 175 tbody td:nth-child(7), 176 tbody td:nth-child(8){ display:none } 177 .time{ min-width:auto; padding:.3rem .5rem; } 178 } 179 @media (max-width: 480px){ 180 thead th:nth-child(2), 181 tbody td:nth-child(2){ display:none } 182 select{ min-width:150px; } 183 } 184 select:focus{ 185 outline:2px solid #0a66c2; 186 outline-offset:1px; 187 } 188 .btn{ 189 display:inline-block; 190 font-weight:700; 191 text-decoration:none; 192 padding:.45rem .8rem; 193 border-radius:999px; 194 border:1px solid #0a66c2; 195 background:#0a66c2; 196 color:#fff; 197 white-space:nowrap; 198 font-family:"Aptos Narrow", system-ui,-apple-system, Segoe UI, Roboto, Arial, sans-serif; 199 } 200 .btn:focus{ 201 outline:2px solid #0a66c2; 202 outline-offset:2px; 203 } 204 .btn:hover{ filter:brightness(0.95); } 205 206 /* Separatori per cambio data */ 207 .sep-row td{ 208 padding:.4rem .7rem; 209 background:#f1f5f9; 210 border-bottom:1px solid #e5e7eb; 211 text-align:center; 212 } 213 .sep-chip{ 214 display:inline-block; 215 font-weight:700; 216 padding:.25rem .6rem; 217 border-radius:999px; 218 border:1px solid #cbd5e1; 219 background:#fff; 220 text-align:center; 221 color:202857; 222 } 223 .cards-sep{ 224 font-weight:700; 225 padding:8px 10px; 226 background:#f1f5f9; 227 border:1px solid #e5e7eb; 228 border-radius:10px; 229 } 230 </style> 231 232 <style> 233 :host{ 234 display:block; 235 max-width: var(--tt-max, 1200px); 236 margin: var(--tt-gap-top, 24px) auto var(--tt-gap-bottom, 40px) auto; 237 padding: 0 12px; 238 color:#202857; 239 } 240 ... 241 .btn:hover{ filter:brightness(0.95); } 242 243 /* === MODE SIMPLE (senza Arrivo e Durata) === */ 244 :host([mode="simple"]) thead th:nth-child(2), 245 :host([mode="simple"]) tbody td:nth-child(2), 246 :host([mode="simple"]) thead th:nth-child(7), 247 :host([mode="simple"]) tbody td:nth-child(7){ 248 display:none; 249 } 250 251 :host([mode="simple"]) .badge-time .small{ 252 display:none; 253 } 254 255 /* Separatori per cambio data (coerenti con il primo blocco) */ 256 .sep-row td{ 257 padding:.4rem .7rem; 258 background:#f1f5f9; 259 border-bottom:1px solid #e5e7eb; 260 text-align:center; 261 } 262 .sep-chip{ 263 display:inline-block; 264 font-weight:700; 265 padding:.25rem .6rem; 266 border-radius:999px; 267 border:1px solid #cbd5e1; 268 background:#fff; 269 text-align:center; 270 color:#202857 271 } 272 .cards-sep{ 273 font-weight:700; 274 padding:8px 10px; 275 background:#f1f5f9; 276 border:1px solid #e5e7eb; 277 border-radius:10px; 278 } 279 </style> 280 281 <div class="toolbar" part="filters"> 282 <label>Partenza da 283 <select id="dep" aria-label="Filtro porto di partenza"><option value="">Tutti</option></select> 284 </label> 285 <label>Arrivo a 286 <select id="arr" aria-label="Filtro porto di arrivo"><option value="">Tutti</option></select> 287 </label> 288 <label>Data 289 <select id="day" aria-label="Filtro data"> 290 <option value="">Tutte</option> 291 <option value="today">Oggi</option> 292 <option value="tomorrow">Domani</option> 293 </select> 294 </label> 295 </div> 296 297 <div class="tt-card"> 298 <div id="tableWrap" class="table-wrap" role="region" aria-label="Tabella partenze" tabindex="0"> 299 <table class="table" role="table" aria-label="Prossime partenze"> 300 <thead><tr> 301 <th>Orario</th> 302 <th>Arrivo</th> 303 <th>Da</th> 304 <th>A</th> 305 <th>Nave</th> 306 <th>operata da</th> 307 <th>Durata</th> 308 <th>Status</th> 309 <th>Acquista ora</th> 310 </tr></thead> 311 <tbody id="body"><tr><td colspan="9" class="empty">Caricamentoâ¦</td></tr></tbody> 312 </table> 313 </div> 314 <div id="cardsWrap" class="cards" role="list" aria-label="Partenze" hidden></div> 315 </div> 316 `; 317 318 this._root.getElementById("dep").addEventListener("change", (e) => { this._state.dep = e.target.value; this._renderRows(); }); 319 this._root.getElementById("arr").addEventListener("change", (e) => { this._state.arr = e.target.value; this._renderRows(); }); 320 this._root.getElementById("day").addEventListener("change", (e) => { this._state.day = e.target.value; this._renderRows(); }); 321 } 322 323 324 _setupResizeObserver() { 325 const decide = () => { 326 const w = this._root.host.getBoundingClientRect().width || 0; 327 const nextIsCard = w < 768; 328 if (nextIsCard !== this._isCard) { this._isCard = nextIsCard; this._renderRows(); } 329 }; 330 decide(); 331 this._resizeObs = new ResizeObserver(decide); 332 this._resizeObs.observe(this._root.host); 333 } 334 335 _schedule() { 336 const src = this.getAttribute("src"); 337 if (!src) return; 338 if (this._t) { clearInterval(this._t); this._t = null; } 339 this._fetchAndRender(); 340 } 341 342 async _fetchAndRender() { 343 const src = this.getAttribute("src"); 344 if (!src) return; 345 const sid = this.getAttribute("sid") || ""; 346 const u = new URL(src, window.location.href); 347 if (sid) u.searchParams.set("sid", sid); 348 349 try { 350 const r = await fetch(u.toString(), { cache: "no-store" }); 351 if (!r.ok) throw new Error(`HTTP ${r.status}`); 352 const j = await r.json(); 353 const rows = Array.isArray(j.rows) ? j.rows : Array.isArray(j) ? j : []; 354 this._state.rows = rows; 355 this._populateFilters(); 356 this._renderRows(); 357 this._postHeight(); 358 } catch (err) { 359 console.error("[alilauro-timetable] fetch error:", err); 360 this._root.getElementById("body").innerHTML = `<tr><td colspan="9" class="empty">Nessun dato disponibile</td></tr>`; 361 const cardsWrap = this._root.getElementById("cardsWrap"); 362 cardsWrap.innerHTML = `<div role="listitem" class="small">Nessun dato disponibile</div>`; 363 this._postHeight(); 364 } 365 } 366 367 _populateFilters() { 368 const depSel = this._root.getElementById("dep"); 369 const arrSel = this._root.getElementById("arr"); 370 const depAttr = this.getAttribute("dep") || ""; 371 const arrAttr = this.getAttribute("arr") || ""; 372 373 const deps = Array.from(new Set(this._state.rows.map((r) =>
373 r.depName).filter(Boolean))) 374 .sort((a, b) => a.localeCompare(b, "it", { sensitivity: "base" })); 375 const arrs = Array.from(new Set(this._state.rows.map((r) => r.arrName).filter(Boolean))) 376 .sort((a, b) => a.localeCompare(b, "it", { sensitivity: "base" })); 377 378 depSel.innerHTML = `<option value="">Tutti</option>` + deps.map( 379 (d) => `<option value="${this._esc(d)}"${d === depAttr ? " selected" : ""}>${this._esc(this._cap(d))}</option>` 380 ).join(""); 381 arrSel.innerHTML = `<option value="">Tutti</option>` + arrs.map( 382 (a) => `<option value="${this._esc(a)}"${a === arrAttr ? " selected" : ""}>${this._esc(this._cap(a))}</option>` 383 ).join(""); 384 385 this._state.dep = depSel.value; 386 this._state.arr = arrSel.value; 387 } 388 389 _bookingUrl(r) { 390 const dep = r.depCode || r.dep_code || r.dep || ""; 391 const arr = r.arrCode || r.arr_code || r.arr || ""; 392 const now = new Date(); 393 const dd = String(now.getDate()).padStart(2, "0"); 394 const mm = String(now.getMonth() + 1).padStart(2, "0"); 395 const yyyy = String(now.getFullYear()); 396 const dateA = `undefined/undefined/${dd}/${mm}/${yyyy}`; 397 const u = new URL("https://booking.alilauro.it/index.html"); 398 u.searchParams.set("search", "run"); 399 u.searchParams.set("dep", dep); 400 u.searchParams.set("arr", arr); 401 u.searchParams.set("pass", "1"); 402 u.searchParams.set("ar", "0"); 403 u.searchParams.set("dateA", dateA); 404 return u.toString(); 405 } 406 407 _labelForDate(ymd){ 408 const today = this._todayYmd(); 409 const tomorrow = this._tomorrowYmd(); 410 const d = this._formatIt(ymd); 411 if (ymd === today) return `Oggi â ${d}`; 412 if (ymd === tomorrow) return `Domani â ${d}`; 413 return d; 414 } 415 _todayYmd(){ 416 const now = new Date(); 417 const y = now.getFullYear(); 418 const m = String(now.getMonth()+1).padStart(2,'0'); 419 const d = String(now.getDate()).padStart(2,'0'); 420 return `${y}-${m}-${d}`; 421 } 422 _tomorrowYmd(){ 423 const now = new Date(); 424 now.setDate(now.getDate()+1); 425 const y = now.getFullYear(); 426 const m = String(now.getMonth()+1).padStart(2,'0'); 427 const d = String(now.getDate()).padStart(2,'0'); 428 return `${y}-${m}-${d}`; 429 } 430 _formatIt(ymd){ 431 const [Y,M,D] = (ymd||'').split('-'); 432 if (!Y||!M||!D) return ymd||''; 433 return `${D}/${M}/${Y}`; 434 } 435 436 _renderRows() { 437 const dep = this._state.dep; 438 const arr = this._state.arr; 439 const day = this._state.day; 440 const today = this._todayYmd(); 441 const tomorrow = this._tomorrowYmd(); 442 443 const rows = this._state.rows.filter((r) => { 444 if (dep && r.depName !== dep) return false; 445 if (arr && r.arrName !== arr) return false; 446 if (day === "today" && r.date !== today) return false; 447 if (day === "tomorrow" && r.date !== tomorrow) return false; 448 return true; 449 }); 450 451 const tableWrap = this._root.getElementById("tableWrap"); 452 const body = this._root.getElementById("body"); 453 const cardsWrap = this._root.getElementById("cardsWrap"); 454 455 if (!rows.length) { 456 body.innerHTML = `<tr><td colspan="9" class="empty">Nessuna partenza trovata</td></tr>`; 457 cardsWrap.innerHTML = `<div role="listitem" class="small">Nessuna partenza trovata</div>`; 458 tableWrap.hidden = this._isCard; 459 cardsWrap.hidden = !this._isCard; 460 this._postHeight(); 461 return; 462 } 463 464 if (this._isCard) { 465 // Raggruppo per data 466 const groups = []; 467 const map = new Map(); 468 for (const r of rows){ 469 const key = r.date || ""; 470 if (!map.has(key)){ map.set(key, []); groups.push(key); } 471 map.get(key).push(r); 472 } 473 const parts = []; 474 for (const key of groups){ 475 if (key){ 476 parts.push(`<div class="cards-sep" role="heading" aria-level="2" style="text-align:center">${this._labelForDate(key)}</div>`); 477 } 478 for (const r of map.get(key)){ 479 const st = (r.status ?? r.state ?? r.flag ?? "").toString().trim().toUpperCase(); 480 const isOpen = st === "O"; 481 const isIssue = st === "I"; 482 const dotClass = isOpen ? "open" : (isIssue ? "pending" : "closed"); 483 const statusLabel = isOpen ? "Corsa regolare" : (isIssue ? "Corsa in attesa di conferma" : "Corsa sospes
483a"); 484 485 const vesselObj = this._vesselDisplay(r.vessel); 486 const company = this._cap(r.companyName || ""); 487 const durationLabel = this._durationLabel(r.duration); 488 489 parts.push(` 490 <div class="card" role="listitem" aria-label="${this._esc(this._cap(r.depName))} â ${this._esc(this._cap(r.arrName))} alle ${this._esc(r.departure_hm)}"> 491 <div class="badge-time"> 492 <span class="time">${this._esc(r.departure_hm)}</span> 493 <span class="small">${this._esc(r.arrival_hm)} arr.</span> 494 </div> 495 <div> 496 <div class="route">${this._esc(this._cap(r.depName))} â ${this._esc(this._cap(r.arrName))}</div> 497 <div class="status-pill"> 498 <span class="dot ${dotClass}"></span> 499 <span class="${isOpen ? "" : "muted"}">${this._esc(statusLabel)}</span> 500 </div> 501 <div class="meta" aria-hidden="false"> 502 <span class="pill" title="${this._esc(vesselObj.title)}"> 503 ð¥ï¸ <span class="truncate">${vesselObj.html}</span> 504 </span> 505 ${ company ? `<span class="pill" title="${this._esc(company)}">ð¢ <span class="truncate">${this._esc(company)}</span></span>` : "" } 506 ${ durationLabel ? `<span class="pill" title="${this._esc(durationLabel)}">â±ï¸ <span class="truncate">${this._esc(durationLabel)}</span></span>` : "" } 507 </div> 508 ${ isOpen ? ` 509 <div style="margin-top:8px"> 510 <a class="btn" href="${this._bookingUrl(r)}" target="_blank" rel="noopener" 511 aria-label="Prenota ${this._esc(this._cap(r.depName))} ${this._esc(r.departure_hm)} verso ${this._esc(this._cap(r.arrName))}"> 512 Acquista ora 513 </a> 514 </div>` : "" } 515 </div> 516 </div>`); 517 } 518 } 519 cardsWrap.innerHTML = parts.join(""); 520 tableWrap.hidden = true; 521 cardsWrap.hidden = false; 522 } else { 523 // Tabella con separatori per data 524 let lastDate = null; 525 let html = ""; 526 for (const r of rows){ 527 const curDate = r.date || ""; 528 if (curDate && curDate !== lastDate){ 529 html += `<tr class="sep-row"><td colspan="9"><span class="sep-chip">${this._labelForDate(curDate)}</span></td></tr>`; 530 lastDate = curDate; 531 } 532 const st = (r.status ?? r.state ?? r.flag ?? "").toString().trim().toUpperCase(); 533 const isOpen = st === "O"; 534 const isIssue = st === "I"; 535 const dotClass = isOpen ? "open" : (isIssue ? "pending" : "closed"); 536 const statusLabel = isOpen ? "Corsa regolare" : (isIssue ? "Corsa in attesa di conferma" : "Corsa sospesa"); 537 538 const vesselObj = this._vesselDisplay(r.vessel); 539 const companyTitle = this._cap(r.companyName || ""); 540 const durationLabel = this._durationLabel(r.duration); 541 542 html += `<tr> 543 <td><span class="time">${this._esc(r.departure_hm)}</span></td> 544 <td>${this._esc(r.arrival_hm)}</td> 545 <td>${this._esc(this._cap(r.depName))}</td> 546 <td>${this._esc(this._cap(r.arrName))}</td> 547 <td> 548 <span class="truncate" title="${this._esc(vesselObj.title)}"> 549 ${vesselObj.html} 550 </span> 551 </td> 552 <td><span class="truncate" title="${this._esc(companyTitle)}">${this._esc(companyTitle)}</span></td> 553 <td>${durationLabel ? this._esc(durationLabel) : ""}</td> 554 <td><span class="dot ${dotClass}"></span><span class="muted">${this._esc(statusLabel)}</span></td> 555 <td>${ 556 isOpen ? `<a class="btn" href="${this._bookingUrl(r)}" target="_blank" rel="noopener" 557 aria-label="Prenota ${this._esc(this._cap(r.depName))} ${this._esc(r.departure_hm)} verso ${this._esc(this._cap(r.arrName))}">Acquista ora</a>` : "" 558 }</td> 559 </tr>`; 560 } 561 body.innerHTML = html; 562 tableWrap.hidden = false; 563 cardsWrap.hidden = true; 564 } 565 this._postHeight(); 566 } 567 568 _postHeight() { 569 try { 570 this.dispatchEvent(new CustomEvent("alilauro:height", { 571 detail: this._root.host.getBoundingClientRect().height, 572 })); 573 } catch {} 574 } 575 576 _esc(s) { 577 const d = document.createElement("div"); 578 d.textContent = String(s ?? ""); 579 return d.innerHTML; 580 } 581 582 // lowercase + capitalize 583 _cap(s) { 584 const str = String(s ?? "").toLowerCase(); 585 return str.replace(/\b\w/g, (ch) => ch.toUpperCase()); 586 } 587 588 // Durata con "ca." davanti (senza duplicare) 589 _durationLabel(val){ 590 const raw = String(val ?? "").trim(); 591 if (!raw) return ""; 592 if (/ca\./i.test(raw)) return raw; 593 return `ca. ${raw}`; 594 } 595 596 // Gestione nave: 597 // - se vuota / "nessuno" => "-" 598 // - capitalize 599 // - Giove / Celestina / Busnavetta in corsivo 600 // Gestione nave: 601 // - se vuota / "nessuno" => "-" 602 // - capitalize 603 // - TUTTE le navi in corsivo 604 _vesselDisplay(v){ 605 let raw = String(v ?? "").trim(); 606 if (!raw || /^nessuno$/i.test(raw)) { 607 return { html: "-", title: "-" }; 608 } 609 // capitalize 610 const cap = this._cap(raw); 611 // tutto il nome nave in corsivo 612 const html = `<em>${cap}</em>`; 613 const title = cap; // title senza tag 614 return { html, title }; 615 } 616 617} 618customElements.define("alilauro-timetable", AlilauroTimetable);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.