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.
222 lines
8.1 KiB
JavaScript
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;
|
|
}
|
|
}
|