Files
trip-plan/public/js/views/trips.js
T
grabowski fe89bb2b1c Initial release: collaborative trip planner
Multi-user trip planning web app in a single Docker container. Mullvad-style token accounts, trip sharing via join codes, day-by-day calendar with typed entries (activity, hotel, travel, flight, rental car, immigration, note), multi-leg flight segments with bundled IATA airport dataset, Leaflet/OSM map with per-leg great-circle km (air vs ground), rough km-driven vs rental included-km comparison, cost splitting with settle-up suggestions, flip-clock departure countdown. Node 20 + Express + SQLite (WAL, additive migrations), vanilla JS SPA, 44 API tests.
2026-07-18 23:15:29 +07:00

222 lines
8.1 KiB
JavaScript

// 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';
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' });
for (const trip of trips) grid.appendChild(tripCard(trip));
page.appendChild(grid);
}
mount(container, page);
}
function tripCard(trip) {
const today = ymd(new Date());
const future = trip.start_date > today;
const daysUntil = future
? Math.round((parseYMD(trip.start_date) - parseYMD(today)) / 86400000)
: 0;
return el(
'a',
{ class: 'trip-card card', href: `#/trip/${trip.id}` },
el(
'div',
{ class: 'trip-card-top' },
el('h3', { class: 'trip-card-name' }, trip.name),
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'),
),
);
}
// 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;
}
}