// Orchestrates the trip page: header + calendar, then map + summary. // Owns the shared trip context passed to the calendar, map, summary and // day-editor sub-views: { state, navigate, tripId, trip, route, refreshTrip, // openDay }. After any mutation, refreshTrip() re-fetches the trip and its // derived /route data so all three panels 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'; export function renderTripDetail(container, ctx, id) { const tctx = { ...ctx, tripId: id, trip: null, route: null, costs: null, _onModalRefresh: null, }; // 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; 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() { const page = el('div', { class: 'page' }); page.appendChild(renderHeader()); page.appendChild(renderCountdown(tctx)); page.appendChild(renderCalendar(tctx)); costsSection = renderCosts(tctx); page.appendChild( el( 'div', { class: 'detail-grid' }, renderMap(tctx), el( 'div', { class: 'detail-side' }, renderSummary(tctx), costsSection, renderExpenses(tctx), renderChecklist(tctx), ), ), ); mount(container, page); } function renderHeader() { const { trip, members } = tctx.trip; const isOwner = trip.owner_id === tctx.state.user.id; const dayCount = daysBetweenInclusive(trip.start_date, trip.end_date); const header = el('div', { class: 'trip-header card' }); const titleRow = el( 'div', { class: 'trip-header-top' }, el( 'div', {}, el( 'a', { class: 'back-link', href: '#/trips' }, '← All trips', ), el('h1', { class: 'trip-title' }, trip.name), el( 'p', { class: 'trip-subtitle muted' }, '📅 ', formatRange(trip.start_date, trip.end_date), ' · ', pluralize(dayCount, 'day', 'days'), ' · ', el('span', { class: 'currency-tag' }, trip.currency || 'USD'), ), ), el( 'div', { class: 'trip-header-actions' }, el('button', { class: 'btn btn-ghost', onClick: () => toggleEdit(header) }, '✎ Edit'), isOwner ? el('button', { class: 'btn btn-danger-ghost', onClick: onDelete }, 'Delete') : null, ), ); const membersRow = el( 'div', { class: 'members-row' }, el('span', { class: 'members-label' }, 'Members:'), ...members.map((m) => el( 'span', { class: `member-chip${m.role === 'owner' ? ' member-owner' : ''}` }, el('span', { class: 'member-avatar' }, (m.display_name || '?').charAt(0).toUpperCase()), m.display_name, isOwner && m.role !== 'owner' ? el('button', { class: 'member-remove', title: `Remove ${m.display_name}`, onClick: () => onRemoveMember(m), }, '×') : null, ), ), ); header.appendChild(titleRow); header.appendChild(joinCodeRow(trip, isOwner)); header.appendChild(membersRow); return header; } // Share code for inviting others — display only, not a credential. function joinCodeRow(trip, isOwner) { 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'); } }); 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); } } return el( 'div', { class: 'joincode-row' }, el('span', { class: 'members-label' }, 'Join code:'), el('span', { class: 'joincode-chip' }, grouped), copyBtn, isOwner ? el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: onRegenerate }, '↻ Regenerate') : null, ); } function toggleEdit(header) { const existing = header.querySelector('.trip-edit'); if (existing) { existing.remove(); return; } const { trip } = tctx.trip; 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'); 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'); await tctx.refreshTrip(); } catch (err) { errorEl.textContent = err.message; saveBtn.disabled = false; saveBtn.textContent = 'Save changes'; } } const editBox = 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' }, saveBtn), ); header.appendChild(editBox); } 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 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); } } }