// Orchestrates the trip page: a compact nav-bar trip identity block, a // Trip/Money/Checklist tab bar, and three always-mounted panels. Owns the // shared trip context passed to the calendar, map, summary, costs, expenses, // checklist and day-editor sub-views: { state, navigate, navTripSlot, // tripId, trip, route, costs, refreshTrip, refreshCosts, openDay, // mapInvalidate }. After any mutation, refreshTrip() re-fetches the trip and // its derived /route data so all panels (including the nav identity block) // stay in sync. import { api } from '../api.js'; import { el, clear, mount, loading, errorBox, toast } from '../dom.js'; import { formatRange, daysBetweenInclusive, pluralize, groupCode } from '../format.js'; import { renderCalendar } from './calendar.js'; import { renderMap } from './map.js'; import { renderSummary } from './summary.js'; import { renderCosts } from './costs.js'; import { renderExpenses } from './expenses.js'; import { renderChecklist } from './checklist.js'; import { renderCountdown } from './flipclock.js'; import { openDayEditor } from './dayEditor.js'; const TABS = [ { id: 'trip', icon: '🗓️', label: 'Trip' }, { id: 'money', icon: '💰', label: 'Money' }, { id: 'checklist', icon: '🧳', label: 'Checklist' }, ]; const TAB_IDS = TABS.map((t) => t.id); // The nav trip-menu dropdown attaches document-level listeners (outside // click, Escape, hashchange) while open. Only one trip page is ever mounted // at a time, so — same convention as calendar.js's now-slider ticker — a // module-level closer lets a fresh renderNavTripContext() tear down a // still-open dropdown's listeners before it rebuilds the slot (refreshTrip // re-draws the nav without navigating away, which would otherwise leak them). let closeActiveDropdown = () => {}; export function renderTripDetail(container, ctx, id, tab) { const tctx = { ...ctx, tripId: id, trip: null, route: null, costs: null, _onModalRefresh: null, }; let activeTab = TAB_IDS.includes(tab) ? tab : 'trip'; // The mounted Costs section node, so refreshCosts() can swap it in place // without redrawing the whole page (self-fetching cards like Expenses // trigger this instead of tctx.refreshTrip()). let costsSection = null; let tabButtons = []; let tabPanels = []; mount(container, loading('Loading trip…')); init(); async function load() { const [trip, route, costs] = await Promise.all([ api.trips.get(id), api.trips.route(id), api.trips.costs(id), ]); tctx.trip = trip; tctx.route = route; tctx.costs = costs; } tctx.refreshTrip = async () => { try { await load(); draw(); if (typeof tctx._onModalRefresh === 'function') tctx._onModalRefresh(); } catch (err) { toast(err.message); } }; // Lighter-weight than refreshTrip(): re-fetches only /costs and swaps the // Costs section in place, so a self-fetching card (Expenses) can keep the // settle-up numbers current without re-fetching the trip/route or // re-mounting every other panel. tctx.refreshCosts = async () => { try { tctx.costs = await api.trips.costs(id); if (costsSection && costsSection.isConnected) { const next = renderCosts(tctx); costsSection.replaceWith(next); costsSection = next; } } catch (err) { toast(err.message); } }; tctx.openDay = (date) => openDayEditor(tctx, date); async function init() { try { await load(); draw(); } catch (err) { clear(container); if (err.status === 404) { toast('Trip not found (or you are not a member).'); ctx.navigate('#/trips'); return; } mount(container, errorBox(err.message, init)); } } function draw() { renderNavTripContext(); const page = el('div', { class: 'page' }); page.appendChild(renderTabBar()); const tripPanel = el('section', { class: 'tab-panel', id: 'tab-panel-trip', role: 'tabpanel', 'aria-labelledby': 'tab-trip', }); tripPanel.appendChild(renderCountdown(tctx)); tripPanel.appendChild(renderCalendar(tctx)); tripPanel.appendChild( el( 'div', { class: 'detail-grid' }, renderMap(tctx), el('div', { class: 'detail-side' }, renderSummary(tctx)), ), ); costsSection = renderCosts(tctx); const moneyPanel = el('section', { class: 'tab-panel', id: 'tab-panel-money', role: 'tabpanel', 'aria-labelledby': 'tab-money', }); moneyPanel.appendChild(el('div', { class: 'money-grid' }, costsSection, renderExpenses(tctx))); const checklistPanel = el('section', { class: 'tab-panel', id: 'tab-panel-checklist', role: 'tabpanel', 'aria-labelledby': 'tab-checklist', }); checklistPanel.appendChild(el('div', { class: 'checklist-wrap' }, renderChecklist(tctx))); tabPanels = [tripPanel, moneyPanel, checklistPanel]; page.appendChild(tripPanel); page.appendChild(moneyPanel); page.appendChild(checklistPanel); mount(container, page); updateTabUI(); } // ---------- Tab bar ---------- function renderTabBar() { tabButtons = TABS.map((t) => el( 'button', { class: 'tab-btn', type: 'button', id: `tab-${t.id}`, role: 'tab', 'aria-controls': `tab-panel-${t.id}`, 'aria-selected': 'false', tabindex: '-1', onClick: () => setActiveTab(t.id), }, el('span', { class: 'tab-icon' }, t.icon), el('span', { class: 'tab-label' }, t.label), )); const bar = el('div', { class: 'tab-bar', role: 'tablist', 'aria-label': 'Trip sections' }, ...tabButtons); bar.addEventListener('keydown', (e) => { if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; e.preventDefault(); const idx = TAB_IDS.indexOf(activeTab); const dir = e.key === 'ArrowRight' ? 1 : -1; const next = TABS[(idx + dir + TABS.length) % TABS.length]; setActiveTab(next.id); document.getElementById(`tab-${next.id}`).focus(); }); return bar; } // Switching is NOT navigation: toggles panel visibility and updates the // address bar via history.replaceState — never location.hash (a // hashchange would close the day-editor overlay and stop the calendar's // now-ticker / countdown, see docs/API.md). function setActiveTab(nextTab) { activeTab = TAB_IDS.includes(nextTab) ? nextTab : 'trip'; updateTabUI(); history.replaceState(null, '', `#/trip/${tctx.tripId}/${activeTab}`); if (activeTab === 'trip' && typeof tctx.mapInvalidate === 'function') tctx.mapInvalidate(); } function updateTabUI() { for (const btn of tabButtons) { const isActive = btn.id === `tab-${activeTab}`; btn.classList.toggle('active', isActive); btn.setAttribute('aria-selected', String(isActive)); btn.tabIndex = isActive ? 0 : -1; } for (const panel of tabPanels) { if (panel.id === `tab-panel-${activeTab}`) panel.removeAttribute('hidden'); else panel.setAttribute('hidden', ''); } } // ---------- Nav trip-identity block ---------- function renderNavTripContext() { closeActiveDropdown(); const slot = tctx.navTripSlot; if (!slot) return; const { trip, members } = tctx.trip; const isOwner = trip.owner_id === tctx.state.user.id; const dayCount = daysBetweenInclusive(trip.start_date, trip.end_date); const menuBtn = el('button', { class: 'icon-btn nav-trip-menu-btn', type: 'button', title: 'Trip menu', 'aria-haspopup': 'true', 'aria-expanded': 'false', }, '⋯'); let outsideHandler = null; let escHandler = null; let hashHandler = null; function closeMenu() { dropdown.classList.remove('open'); menuBtn.setAttribute('aria-expanded', 'false'); if (outsideHandler) { document.removeEventListener('click', outsideHandler); outsideHandler = null; } if (escHandler) { document.removeEventListener('keydown', escHandler); escHandler = null; } if (hashHandler) { window.removeEventListener('hashchange', hashHandler); hashHandler = null; } } function openMenu() { dropdown.classList.add('open'); menuBtn.setAttribute('aria-expanded', 'true'); outsideHandler = (e) => { if (!dropdown.contains(e.target) && e.target !== menuBtn) closeMenu(); }; escHandler = (e) => { if (e.key === 'Escape') closeMenu(); }; hashHandler = closeMenu; document.addEventListener('click', outsideHandler); document.addEventListener('keydown', escHandler); window.addEventListener('hashchange', hashHandler); } const dropdown = buildDropdown(trip, members, isOwner, closeMenu); menuBtn.addEventListener('click', (e) => { e.stopPropagation(); if (dropdown.classList.contains('open')) closeMenu(); else openMenu(); }); closeActiveDropdown = closeMenu; mount( slot, el('a', { class: 'nav-trip-back', href: '#/trips', title: 'All trips', 'aria-label': 'Back to all trips' }, '←'), el( 'div', { class: 'nav-trip-id' }, el('span', { class: 'nav-trip-name', title: trip.name }, trip.name), el( 'span', { class: 'nav-trip-sub muted' }, formatRange(trip.start_date, trip.end_date), ' · ', pluralize(dayCount, 'day', 'days'), ' · ', trip.currency || 'USD', ), ), el('div', { class: 'nav-trip-menu' }, menuBtn, dropdown), ); } function buildDropdown(trip, members, isOwner, closeMenu) { const membersList = el( 'div', { class: 'trip-menu-members' }, el('div', { class: 'trip-menu-label' }, 'Members'), ...members.map((m) => el( 'div', { class: 'trip-menu-member' }, el('span', { class: 'member-avatar' }, (m.display_name || '?').charAt(0).toUpperCase()), el('span', { class: 'trip-menu-member-name' }, m.display_name), m.role === 'owner' ? el('span', { class: 'role-badge role-owner' }, 'Owner') : null, isOwner && m.role !== 'owner' ? el('button', { class: 'member-remove', type: 'button', title: `Remove ${m.display_name}`, onClick: () => { closeMenu(); onRemoveMember(m); }, }, '×') : null, )), ); const grouped = groupCode(trip.join_code, 4); const copyBtn = el('button', { class: 'btn btn-sm', type: 'button', title: 'Copy join code' }, '📋 Copy'); copyBtn.addEventListener('click', async () => { try { await navigator.clipboard.writeText(grouped); toast('Join code copied', 'success'); } catch { toast('Copy failed — select and copy it manually'); } }); const joinRow = el( 'div', { class: 'trip-menu-joincode' }, el('span', { class: 'joincode-chip' }, grouped), copyBtn, isOwner ? el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: () => { closeMenu(); onRegenerate(); }, }, '↻ Regenerate') : null, ); const actions = el( 'div', { class: 'trip-menu-actions' }, el('button', { class: 'btn btn-sm', type: 'button', onClick: () => { closeMenu(); openEditModal(); }, }, '✎ Edit trip'), isOwner ? el('button', { class: 'btn btn-sm btn-danger-ghost', type: 'button', onClick: () => { closeMenu(); onDelete(); }, }, 'Delete trip') : null, ); return el('div', { class: 'trip-menu-dropdown', role: 'menu' }, membersList, joinRow, actions); } // ---------- Edit-trip modal ---------- function openEditModal() { const { trip } = tctx.trip; const overlay = el('div', { class: 'overlay overlay-center' }); const modal = el('div', { class: 'modal', role: 'dialog', 'aria-modal': 'true' }); overlay.appendChild(modal); document.body.appendChild(overlay); document.body.classList.add('no-scroll'); function close() { document.body.classList.remove('no-scroll'); overlay.remove(); document.removeEventListener('keydown', onKey); } function onKey(e) { if (e.key === 'Escape') close(); } document.addEventListener('keydown', onKey); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); const nameInput = el('input', { class: 'input', type: 'text', maxlength: '120', value: trip.name }); const startInput = el('input', { class: 'input', type: 'date', value: trip.start_date }); const endInput = el('input', { class: 'input', type: 'date', value: trip.end_date }); const currencyInput = el('input', { class: 'input input-currency', type: 'text', maxlength: '3', value: trip.currency || 'USD', 'aria-label': 'Currency code', }); const errorEl = el('p', { class: 'form-error' }); const saveBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Save changes'); const cancelBtn = el('button', { class: 'btn btn-ghost', type: 'button', onClick: close }, 'Cancel'); async function onSubmit(e) { e.preventDefault(); errorEl.textContent = ''; const name = nameInput.value.trim(); const start_date = startInput.value; const end_date = endInput.value; const currency = currencyInput.value.trim().toUpperCase() || 'USD'; if (!name) return (errorEl.textContent = 'Name cannot be empty.'); if (end_date < start_date) return (errorEl.textContent = 'End date must be on or after the start date.'); if (!/^[A-Z]{3}$/.test(currency)) return (errorEl.textContent = 'Currency must be a 3-letter code, e.g. USD.'); saveBtn.disabled = true; saveBtn.textContent = 'Saving…'; try { await api.trips.update(tctx.tripId, { name, start_date, end_date, currency }); toast('Trip updated', 'success'); close(); await tctx.refreshTrip(); } catch (err) { errorEl.textContent = err.message; saveBtn.disabled = false; saveBtn.textContent = 'Save changes'; } } mount( modal, el( 'div', { class: 'slideover-head' }, el('h2', {}, '✎ Edit trip'), el('button', { class: 'icon-btn', type: 'button', title: 'Close', onClick: close }, '×'), ), el( 'form', { class: 'trip-edit', onSubmit }, el( 'div', { class: 'form-row' }, el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Name'), nameInput), ), el( 'div', { class: 'form-row' }, el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Start date'), startInput), el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'End date'), endInput), el('label', { class: 'field field-currency' }, el('span', { class: 'field-label' }, 'Currency'), currencyInput), ), el('p', { class: 'hint muted' }, 'Changing the range regenerates the calendar; entries outside the new range are kept and flagged.'), errorEl, el('div', { class: 'form-actions' }, cancelBtn, saveBtn), ), ); nameInput.focus(); } // ---------- Member / trip mutations ---------- async function onRemoveMember(member) { if (!window.confirm(`Remove ${member.display_name} from this trip?`)) return; try { await api.trips.removeMember(tctx.tripId, member.id); toast(`${member.display_name} removed`, 'success'); await tctx.refreshTrip(); } catch (err) { toast(err.message); } } async function onRegenerate() { if (!window.confirm('Regenerate the join code? The old code will stop working immediately.')) return; try { await api.trips.regenerateJoinCode(tctx.tripId); toast('Join code regenerated', 'success'); await tctx.refreshTrip(); } catch (err) { toast(err.message); } } async function onDelete() { const { trip } = tctx.trip; if (!window.confirm(`Delete "${trip.name}"? This removes all its entries and cannot be undone.`)) return; try { await api.trips.remove(tctx.tripId); toast('Trip deleted', 'success'); ctx.navigate('#/trips'); } catch (err) { toast(err.message); } } }