Files
trip-plan/public/js/views/tripDetail.js
T
grabowski b361fb2687 Split the trip page into tabs and move the header into the navbar
Three deep-linkable tabs (#/trip/:id/:tab): Trip (countdown, calendar,
map, summary), Money (costs + expenses side by side), Checklist. Panels
stay mounted; switching toggles visibility and updates the URL via
history.replaceState so an open day editor and the live tickers are
undisturbed. The Leaflet map revalidates its size on every trip-tab
activation but only auto-fits once, when its container first gains
real size - later visits keep the user's pan/zoom.

The full-width trip-header card is gone: the navbar now carries a
compact identity (back link, truncated name, date/days/currency) plus
a single overflow menu holding members, join code, edit (now a
centered modal) and delete. Dead header CSS removed; new styles live
in tabs.css.
2026-08-30 13:31:32 +02:00

453 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}
}
}