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.
453 lines
16 KiB
JavaScript
453 lines
16 KiB
JavaScript
// 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);
|
||
}
|
||
}
|
||
}
|