// Trip list dashboard: cards, a "new trip" form, and a "join a trip" form. import { api } from '../api.js'; import { el, mount, clear, loading, errorBox, emptyState, toast } from '../dom.js'; import { formatRange, ymd, parseYMD, pluralize, normalizeCode } from '../format.js'; import { enableTripReorder } from './dragdrop.js'; export function renderTrips(container, ctx) { mount(container, loading('Loading your trips…')); load(); async function load() { try { const data = await api.trips.list(); draw(data.trips || []); } catch (err) { mount(container, errorBox(err.message, load)); } } function draw(trips) { const page = el('div', { class: 'page' }); // The form slot is created up front so every button below can capture a // fully-initialized reference (no reliance on declaration ordering). const formSlot = el('div', { class: 'form-slot' }); const newTripBtn = el('button', { class: 'btn btn-primary', onClick: () => openNewTrip(formSlot) }, '+ New trip'); const joinBtn = el('button', { class: 'btn', onClick: () => openJoin(formSlot) }, 'Join a trip'); page.appendChild( el( 'div', { class: 'page-head' }, el( 'div', {}, el('h1', {}, 'Your Trips'), el('p', { class: 'muted' }, trips.length ? pluralize(trips.length, 'trip', 'trips') : 'Start planning your next adventure.'), ), el('div', { class: 'page-head-actions' }, joinBtn, newTripBtn), ), ); page.appendChild(formSlot); if (!trips.length) { page.appendChild( emptyState( 'No trips yet', 'Create your first trip, or join one with a code a friend shared.', el('div', { class: 'empty-actions' }, el('button', { class: 'btn btn-primary', onClick: () => openNewTrip(formSlot) }, '+ New trip'), el('button', { class: 'btn', onClick: () => openJoin(formSlot) }, 'Join a trip'), ), ), ); } else { const grid = el('div', { class: 'trip-grid' }); // Reordering only makes sense with 2+ trips (mirrors the day editor's // entry-row reorder — see dragdrop.js). const reorderable = trips.length > 1; const rows = []; for (const trip of trips) { const { node, handle } = tripCard(trip, reorderable); if (reorderable) rows.push({ node, handle, trip }); grid.appendChild(node); } page.appendChild(grid); if (reorderable) enableTripReorder(rows, load); } mount(container, page); } function tripCard(trip, reorderable) { const today = ymd(new Date()); const future = trip.start_date > today; const daysUntil = future ? Math.round((parseYMD(trip.start_date) - parseYMD(today)) / 86400000) : 0; const handle = reorderable ? el('span', { class: 'trip-drag-handle', title: 'Drag to reorder', 'aria-hidden': 'true', onClick: (e) => e.preventDefault(), }, '⋮⋮') : null; const node = el( 'a', { class: 'trip-card card', href: `#/trip/${trip.id}`, draggable: 'false' }, el( 'div', { class: 'trip-card-top' }, el('h3', { class: 'trip-card-name' }, trip.name), el('div', { class: 'trip-card-top-right' }, handle, el('span', { class: `role-badge role-${trip.role}` }, trip.role)), ), el( 'div', { class: 'trip-card-dates' }, '📅 ', formatRange(trip.start_date, trip.end_date), future ? el('span', { class: 'trip-card-countdown' }, `in ${daysUntil} ${daysUntil === 1 ? 'day' : 'days'}`) : null, ), el( 'div', { class: 'trip-card-meta' }, el('span', {}, '👥 ', pluralize(trip.member_count ?? 1, 'member', 'members')), el('span', {}, '📝 ', pluralize(trip.entry_count ?? 0, 'entry', 'entries')), el('span', { class: 'trip-card-currency' }, trip.currency || 'USD'), ), ); return { node, handle }; } // Render `build()` into the slot, or close it if the same panel is open. function togglePanel(slot, kind, build) { if (slot.dataset.open === kind) { clear(slot); slot.dataset.open = ''; return; } mount(slot, build()); slot.dataset.open = kind; } function openNewTrip(slot) { togglePanel(slot, 'new', () => newTripForm(slot)); } function openJoin(slot) { togglePanel(slot, 'join', () => joinForm(slot)); } function closeSlot(slot) { clear(slot); slot.dataset.open = ''; } function newTripForm(slot) { const today = ymd(new Date()); const nameInput = el('input', { class: 'input', type: 'text', maxlength: '120', placeholder: 'e.g. Northern Thailand' }); const startInput = el('input', { class: 'input', type: 'date', value: today }); const endInput = el('input', { class: 'input', type: 'date', value: today }); const currencyInput = el('input', { class: 'input input-currency', type: 'text', maxlength: '3', value: 'USD', placeholder: 'USD', 'aria-label': 'Currency code' }); const errorEl = el('p', { class: 'form-error' }); const submitBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Create trip'); 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 = 'Please give the trip a name.'); if (!start_date || !end_date) return (errorEl.textContent = 'Pick a start and end date.'); 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.'); submitBtn.disabled = true; submitBtn.textContent = 'Creating…'; try { const data = await api.trips.create({ name, start_date, end_date, currency }); toast('Trip created', 'success'); ctx.navigate(`#/trip/${data.trip.id}`); } catch (err) { errorEl.textContent = err.message; submitBtn.disabled = false; submitBtn.textContent = 'Create trip'; } } const form = el( 'form', { class: 'card new-trip-form', onSubmit }, el('h3', {}, 'New trip'), 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)), errorEl, el('div', { class: 'form-actions' }, el('button', { class: 'btn btn-ghost', type: 'button', onClick: () => closeSlot(slot) }, 'Cancel'), submitBtn), ); setTimeout(() => nameInput.focus(), 0); return form; } function joinForm(slot) { const codeInput = el('input', { class: 'input token-input', type: 'text', autocomplete: 'off', autocapitalize: 'characters', spellcheck: 'false', placeholder: 'XXXX-XXXX', 'aria-label': 'Join code', }); const errorEl = el('p', { class: 'form-error' }); const submitBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Join trip'); async function onSubmit(e) { e.preventDefault(); errorEl.textContent = ''; const code = normalizeCode(codeInput.value); if (code.length < 4) return (errorEl.textContent = 'Enter the join code your friend shared.'); submitBtn.disabled = true; submitBtn.textContent = 'Joining…'; try { const data = await api.trips.join(code); toast('Joined trip', 'success'); ctx.navigate(`#/trip/${data.trip.id}`); } catch (err) { errorEl.textContent = err.status === 404 ? 'No trip found for that code.' : err.message; submitBtn.disabled = false; submitBtn.textContent = 'Join trip'; } } const form = el( 'form', { class: 'card join-form', onSubmit }, el('h3', {}, 'Join a trip'), el('p', { class: 'muted' }, 'Paste the join code from a trip you were invited to.'), el('div', { class: 'form-row' }, el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Join code'), codeInput)), errorEl, el('div', { class: 'form-actions' }, el('button', { class: 'btn btn-ghost', type: 'button', onClick: () => closeSlot(slot) }, 'Cancel'), submitBtn), ); setTimeout(() => codeInput.focus(), 0); return form; } }