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.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
/* Split-flap flip-clock countdown. Cards stay dark in the light theme — that
|
||||
is the airport/clock aesthetic. */
|
||||
|
||||
.countdown-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0 0.2rem;
|
||||
}
|
||||
.countdown-caption {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.flipclock {
|
||||
--w: 46px;
|
||||
--h: 66px;
|
||||
--fs: 46px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.fc-group { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; }
|
||||
.fc-digits { display: flex; gap: 3px; }
|
||||
.fc-label {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.fc-sep {
|
||||
font-size: calc(var(--fs) * 0.7);
|
||||
font-weight: 800;
|
||||
color: var(--text-faint);
|
||||
line-height: var(--h);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* One flip card = two static halves + two animating flip halves. */
|
||||
.fc-card {
|
||||
position: relative;
|
||||
width: var(--w);
|
||||
height: var(--h);
|
||||
perspective: 320px;
|
||||
filter: drop-shadow(0 3px 5px rgba(15, 23, 42, 0.28));
|
||||
}
|
||||
.fc-face {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(var(--h) / 2);
|
||||
overflow: hidden;
|
||||
background: #1e2430;
|
||||
}
|
||||
.fc-face b {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--h);
|
||||
line-height: var(--h);
|
||||
text-align: center;
|
||||
font-size: var(--fs);
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.fc-top { top: 0; border-radius: 7px 7px 0 0; border-bottom: 1px solid rgba(0, 0, 0, 0.45); }
|
||||
.fc-top b { top: 0; }
|
||||
.fc-bottom { bottom: 0; border-radius: 0 0 7px 7px; }
|
||||
.fc-bottom b { bottom: 0; }
|
||||
|
||||
.fc-flip { display: none; backface-visibility: hidden; }
|
||||
.fc-flip-top { top: 0; border-radius: 7px 7px 0 0; border-bottom: 1px solid rgba(0, 0, 0, 0.45); transform-origin: bottom; }
|
||||
.fc-flip-top b { top: 0; }
|
||||
.fc-flip-bottom { bottom: 0; border-radius: 0 0 7px 7px; transform-origin: top; transform: rotateX(90deg); }
|
||||
.fc-flip-bottom b { bottom: 0; }
|
||||
|
||||
.fc-flipping .fc-flip { display: block; }
|
||||
.fc-flipping .fc-flip-top { animation: fc-top 0.28s ease-in forwards; }
|
||||
.fc-flipping .fc-flip-bottom { animation: fc-bottom 0.28s ease-in 0.28s forwards; }
|
||||
@keyframes fc-top { to { transform: rotateX(-90deg); } }
|
||||
@keyframes fc-bottom { from { transform: rotateX(90deg); } to { transform: rotateX(0deg); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fc-flipping .fc-flip-top,
|
||||
.fc-flipping .fc-flip-bottom { animation: none; }
|
||||
.fc-flip { display: none !important; }
|
||||
}
|
||||
|
||||
/* Ongoing / completed badge (no clock) */
|
||||
.countdown-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.countdown-badge.ongoing { background: linear-gradient(135deg, var(--brand), var(--accent)); color: #fff; }
|
||||
.countdown-badge.done { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); }
|
||||
.cd-badge-icon { font-size: 1.1rem; }
|
||||
|
||||
/* Responsive: shrink digits on narrow screens */
|
||||
@media (max-width: 640px) {
|
||||
.flipclock { --w: 34px; --h: 50px; --fs: 34px; gap: 0.35rem; }
|
||||
}
|
||||
@media (max-width: 380px) {
|
||||
.flipclock { --w: 26px; --h: 40px; --fs: 26px; }
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
/* Trip Plan — SPA styles. Light theme, CSS custom-property palette. */
|
||||
|
||||
:root {
|
||||
--brand: #2563eb;
|
||||
--brand-dark: #1d4ed8;
|
||||
--brand-soft: #eff4ff;
|
||||
--accent: #0ea5e9;
|
||||
|
||||
--bg: #f4f6fb;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f8fafc;
|
||||
--border: #e5e9f2;
|
||||
--border-strong: #d4dbe8;
|
||||
|
||||
--text: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--text-faint: #94a3b8;
|
||||
|
||||
--danger: #dc2626;
|
||||
--danger-soft: #fef2f2;
|
||||
--success: #059669;
|
||||
--warn: #d97706;
|
||||
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.05);
|
||||
--shadow-md: 0 4px 14px rgba(15, 23, 42, 0.08);
|
||||
--shadow-lg: 0 18px 48px rgba(15, 23, 42, 0.22);
|
||||
|
||||
--font: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body.no-scroll { overflow: hidden; }
|
||||
|
||||
h1, h2, h3 { margin: 0; font-weight: 700; line-height: 1.25; }
|
||||
h1 { font-size: 1.6rem; }
|
||||
h2 { font-size: 1.2rem; }
|
||||
h3 { font-size: 1rem; }
|
||||
p { margin: 0; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.muted { color: var(--text-muted); font-size: 0.9rem; }
|
||||
.hint { font-size: 0.82rem; }
|
||||
|
||||
/* ---------- Buttons & inputs ---------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s, transform 0.05s;
|
||||
}
|
||||
.btn:hover { background: var(--surface-2); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.btn-primary { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||
.btn-primary:hover { background: var(--brand-dark); }
|
||||
.btn-ghost { border-color: transparent; background: transparent; }
|
||||
.btn-ghost:hover { background: var(--surface-2); }
|
||||
.btn-danger-ghost { border-color: transparent; background: transparent; color: var(--danger); }
|
||||
.btn-danger-ghost:hover { background: var(--danger-soft); }
|
||||
.btn-block { width: 100%; }
|
||||
.btn-sm { padding: 0.4rem 0.7rem; font-size: 0.85rem; }
|
||||
.link-btn {
|
||||
border: none; background: none; color: var(--brand);
|
||||
font-weight: 600; cursor: pointer; padding: 0; font-size: inherit;
|
||||
}
|
||||
.link-btn:hover { text-decoration: underline; }
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
}
|
||||
.input:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }
|
||||
.input-sm { padding: 0.4rem 0.6rem; font-size: 0.88rem; }
|
||||
textarea.input { resize: vertical; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.field-grow { flex: 1; }
|
||||
.field-label { font-size: 0.78rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.form-row { display: flex; gap: 0.8rem; flex-wrap: wrap; }
|
||||
.form-row > .field { flex: 1; min-width: 140px; }
|
||||
.form-actions { display: flex; gap: 0.6rem; justify-content: flex-end; margin-top: 0.4rem; }
|
||||
.form-error { color: var(--danger); font-size: 0.85rem; min-height: 1rem; margin: 0.2rem 0; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1.1rem 1.2rem;
|
||||
}
|
||||
|
||||
/* ---------- Top nav ---------- */
|
||||
.topnav {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.7rem 1.4rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 0.5rem; font-weight: 800; font-size: 1.1rem; }
|
||||
.brand-mark { font-size: 1.3rem; }
|
||||
.brand-mark-lg { font-size: 2.4rem; }
|
||||
.nav-right { display: flex; align-items: center; gap: 0.8rem; }
|
||||
.nav-user { display: flex; align-items: center; gap: 0.45rem; font-weight: 600; font-size: 0.9rem; }
|
||||
.nav-avatar, .member-avatar {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 1.7rem; height: 1.7rem; border-radius: 50%;
|
||||
background: var(--brand); color: #fff; font-size: 0.8rem; font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---------- Layout ---------- */
|
||||
.view { display: block; }
|
||||
.page { max-width: 1180px; margin: 0 auto; padding: 1.4rem; display: flex; flex-direction: column; gap: 1.2rem; }
|
||||
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.section-head { margin-bottom: 0.8rem; }
|
||||
.form-slot:empty { display: none; }
|
||||
|
||||
/* ---------- Auth ---------- */
|
||||
.auth-wrap { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem;
|
||||
background: radial-gradient(1200px 600px at 50% -10%, #dbe7ff 0%, var(--bg) 55%); }
|
||||
.auth-card { width: 100%; max-width: 400px; padding: 1.8rem; }
|
||||
.auth-head { text-align: center; margin-bottom: 1.3rem; }
|
||||
.auth-head h1 { margin: 0.4rem 0 0.2rem; }
|
||||
.auth-tabs { display: flex; gap: 0.3rem; background: var(--surface-2); padding: 0.25rem; border-radius: var(--radius-sm); margin-bottom: 1.1rem; }
|
||||
.auth-tab { flex: 1; border: none; background: none; padding: 0.5rem; border-radius: var(--radius-sm); cursor: pointer; font-weight: 600; color: var(--text-muted); }
|
||||
.auth-tab.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); }
|
||||
.auth-form { display: flex; flex-direction: column; gap: 0.9rem; }
|
||||
.auth-toggle { text-align: center; margin-top: 1rem; font-size: 0.9rem; color: var(--text-muted); }
|
||||
.auth-panel { display: flex; flex-direction: column; gap: 0.9rem; }
|
||||
.auth-lead { color: var(--text-muted); font-size: 0.92rem; }
|
||||
.token-input { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.token-reveal { gap: 1rem; }
|
||||
.token-warning { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; font-size: 0.85rem; padding: 0.7rem 0.85rem; border-radius: var(--radius-sm); line-height: 1.45; }
|
||||
.token-box { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 1.35rem; font-weight: 700; letter-spacing: 0.1em; text-align: center; padding: 0.9rem; background: var(--surface-2); border: 2px dashed var(--border-strong); border-radius: var(--radius-sm); user-select: all; word-break: break-all; }
|
||||
.token-actions { display: flex; justify-content: center; }
|
||||
|
||||
/* Nav inline name edit */
|
||||
.nav-name { font-weight: 600; }
|
||||
.nav-edit { border: none; background: none; cursor: pointer; color: var(--text-faint); font-size: 0.85rem; padding: 0 0.15rem; }
|
||||
.nav-edit:hover { color: var(--brand); }
|
||||
.nav-name-input { width: 150px; }
|
||||
.page-head-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.empty-actions { display: flex; gap: 0.5rem; justify-content: center; }
|
||||
.trip-card-currency { margin-left: auto; font-weight: 700; font-size: 0.72rem; letter-spacing: 0.03em; color: var(--text-faint); }
|
||||
.join-form { display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
|
||||
/* Join-code row on the trip header */
|
||||
.joincode-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding-top: 0.8rem; border-top: 1px solid var(--border); }
|
||||
.joincode-chip { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-weight: 700; letter-spacing: 0.08em; background: var(--brand-soft); color: var(--brand-dark); padding: 0.25rem 0.6rem; border-radius: var(--radius-sm); user-select: all; }
|
||||
|
||||
/* ---------- Trip list ---------- */
|
||||
.trip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; }
|
||||
.trip-card { display: flex; flex-direction: column; gap: 0.6rem; cursor: pointer; transition: transform 0.12s, box-shadow 0.12s; }
|
||||
.trip-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: var(--border-strong); }
|
||||
.trip-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.6rem; }
|
||||
.trip-card-name { font-size: 1.1rem; }
|
||||
.trip-card-dates { color: var(--text-muted); font-size: 0.9rem; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.trip-card-countdown { font-size: 0.72rem; font-weight: 700; color: var(--brand-dark); background: var(--brand-soft); padding: 0.1rem 0.45rem; border-radius: 999px; }
|
||||
.trip-card-meta { display: flex; gap: 1rem; font-size: 0.85rem; color: var(--text-muted); margin-top: auto; }
|
||||
.role-badge { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; padding: 0.15rem 0.5rem; border-radius: 999px; }
|
||||
.role-owner { background: #fef3c7; color: #92400e; }
|
||||
.role-editor { background: var(--brand-soft); color: var(--brand-dark); }
|
||||
.new-trip-form { display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
|
||||
/* ---------- Empty / loading / error ---------- */
|
||||
.loading { display: flex; align-items: center; gap: 0.6rem; justify-content: center; padding: 3rem; color: var(--text-muted); }
|
||||
.spinner { width: 1.1rem; height: 1.1rem; border: 2px solid var(--border-strong); border-top-color: var(--brand); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.empty-state { text-align: center; padding: 3rem 1.5rem; color: var(--text-muted); display: flex; flex-direction: column; gap: 0.6rem; align-items: center; }
|
||||
.empty-icon { font-size: 2.6rem; }
|
||||
.empty-state h3 { color: var(--text); }
|
||||
.error-box { text-align: center; padding: 2rem; color: var(--danger); display: flex; flex-direction: column; gap: 0.8rem; align-items: center; }
|
||||
|
||||
/* ---------- Trip header ---------- */
|
||||
.trip-header { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.trip-header-top { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.back-link { font-size: 0.85rem; color: var(--brand); font-weight: 600; }
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
.trip-title { margin-top: 0.3rem; }
|
||||
.trip-subtitle { margin-top: 0.2rem; }
|
||||
.trip-header-actions { display: flex; gap: 0.5rem; align-items: flex-start; }
|
||||
.members-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding-top: 0.8rem; border-top: 1px solid var(--border); }
|
||||
.members-label { font-size: 0.82rem; font-weight: 600; color: var(--text-muted); }
|
||||
.member-chip { display: inline-flex; align-items: center; gap: 0.4rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 0.25rem 0.6rem 0.25rem 0.3rem; font-size: 0.85rem; font-weight: 600; }
|
||||
.member-owner { background: #fffbeb; border-color: #fde68a; }
|
||||
.member-remove { border: none; background: none; cursor: pointer; color: var(--text-faint); font-size: 1rem; line-height: 1; padding: 0; }
|
||||
.member-remove:hover { color: var(--danger); }
|
||||
.invite-form { display: flex; gap: 0.4rem; margin-left: auto; }
|
||||
.invite-form .input { width: 180px; }
|
||||
.trip-edit { display: flex; flex-direction: column; gap: 0.8rem; padding-top: 0.9rem; border-top: 1px solid var(--border); }
|
||||
|
||||
/* ---------- Calendar ---------- */
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 0.7rem; margin-bottom: 0.9rem; }
|
||||
.legend-item { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.78rem; color: var(--text-muted); }
|
||||
.legend-dot { width: 0.7rem; height: 0.7rem; border-radius: 50%; display: inline-block; }
|
||||
.calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; }
|
||||
.cal-weekday { text-align: center; font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-faint); padding-bottom: 0.2rem; }
|
||||
.cal-day { min-height: 92px; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0.35rem; display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.cal-day.clickable { cursor: pointer; transition: border-color 0.12s, box-shadow 0.12s; }
|
||||
.cal-day.clickable:hover { border-color: var(--brand); box-shadow: 0 0 0 2px var(--brand-soft); }
|
||||
.cal-day.cal-out { background: repeating-linear-gradient(45deg, #f1f5f9, #f1f5f9 8px, #eef2f7 8px, #eef2f7 16px); color: var(--text-faint); }
|
||||
.cal-day-head { display: flex; align-items: center; gap: 0.3rem; }
|
||||
.cal-daynum { font-weight: 700; font-size: 0.82rem; }
|
||||
.cal-out .cal-daynum { color: var(--text-faint); font-weight: 600; }
|
||||
.cal-month { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; color: var(--brand); }
|
||||
.cal-flag { margin-left: auto; color: var(--warn); font-size: 0.75rem; }
|
||||
.cal-chips { display: flex; flex-direction: column; gap: 3px; overflow: hidden; }
|
||||
.cal-chip { display: flex; align-items: center; gap: 0.25rem; font-size: 0.72rem; padding: 0.12rem 0.35rem; border-radius: 6px; background: color-mix(in srgb, var(--chip) 14%, white); border-left: 3px solid var(--chip); color: #334155; }
|
||||
.chip-icon { flex-shrink: 0; }
|
||||
.chip-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* ---------- Detail grid (map + summary) ---------- */
|
||||
.detail-grid { display: grid; grid-template-columns: 1.7fr 1fr; gap: 1.2rem; align-items: start; }
|
||||
.map-section, .summary-section { display: flex; flex-direction: column; }
|
||||
.leaflet-map { height: 420px; width: 100%; border-radius: var(--radius-sm); overflow: hidden; border: 1px solid var(--border); z-index: 0; }
|
||||
.map-empty { text-align: center; padding: 3rem 1rem; color: var(--text-muted); display: flex; flex-direction: column; gap: 0.4rem; align-items: center; }
|
||||
|
||||
.map-pin-wrap { background: none; border: none; }
|
||||
.map-pin { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; color: #fff; font-weight: 700; font-size: 0.8rem; box-shadow: 0 2px 6px rgba(0,0,0,0.35); border: 2px solid #fff; }
|
||||
.km-label { background: rgba(37, 99, 235, 0.92); color: #fff; font-size: 0.7rem; font-weight: 700; padding: 0.1rem 0.4rem; border-radius: 999px; white-space: nowrap; box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
|
||||
.map-popup strong { font-size: 0.95rem; }
|
||||
.map-popup-sub { color: #64748b; font-size: 0.8rem; margin-top: 0.15rem; }
|
||||
.map-popup-loc { font-size: 0.82rem; margin-top: 0.15rem; }
|
||||
|
||||
.leg-list, .leg-list-empty { margin-top: 1rem; }
|
||||
.leg-list h3 { margin-bottom: 0.5rem; }
|
||||
.leg-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.4rem 0; border-bottom: 1px solid var(--border); font-size: 0.86rem; }
|
||||
.leg-index { flex-shrink: 0; width: 1.4rem; height: 1.4rem; display: inline-flex; align-items: center; justify-content: center; background: var(--brand-soft); color: var(--brand-dark); border-radius: 50%; font-weight: 700; font-size: 0.75rem; }
|
||||
.leg-path { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||
.leg-km { font-weight: 700; color: var(--brand-dark); white-space: nowrap; }
|
||||
|
||||
/* ---------- Summary ---------- */
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.6rem; }
|
||||
.stat-tile { display: flex; flex-direction: column; align-items: center; gap: 0.1rem; padding: 0.7rem 0.4rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); }
|
||||
.stat-icon { font-size: 1.1rem; }
|
||||
.stat-value { font-size: 1.35rem; font-weight: 800; }
|
||||
.stat-label { font-size: 0.72rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.km-block { margin: 1rem 0; }
|
||||
.total-km { padding: 0.9rem; text-align: center; background: linear-gradient(135deg, var(--brand), var(--accent)); color: #fff; border-radius: var(--radius-sm); display: flex; flex-direction: column; }
|
||||
.total-km-value { font-size: 1.8rem; font-weight: 800; }
|
||||
.total-km-label { font-size: 0.78rem; opacity: 0.9; }
|
||||
.km-breakdown { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.6rem; }
|
||||
.km-row { display: flex; align-items: baseline; gap: 0.5rem; font-size: 0.9rem; }
|
||||
.km-row-icon { flex-shrink: 0; }
|
||||
.km-row-value { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.km-row-label { font-size: 0.82rem; }
|
||||
.leg-mode { flex-shrink: 0; font-size: 0.85rem; }
|
||||
.loc-block h3 { margin-bottom: 0.5rem; }
|
||||
.loc-order { margin: 0; padding-left: 1.2rem; display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.9rem; }
|
||||
|
||||
/* ---------- Slide-over day editor ---------- */
|
||||
.overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.45); z-index: 50; display: flex; justify-content: flex-end; animation: fade 0.15s ease; }
|
||||
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
.slideover { width: min(460px, 100%); height: 100%; background: var(--surface); box-shadow: var(--shadow-lg); padding: 1.3rem; overflow-y: auto; display: flex; flex-direction: column; gap: 1rem; animation: slidein 0.2s ease; }
|
||||
@keyframes slidein { from { transform: translateX(30px); opacity: 0.4; } to { transform: none; opacity: 1; } }
|
||||
.slideover-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.6rem; }
|
||||
.icon-btn { border: none; background: var(--surface-2); border-radius: 8px; width: 2rem; height: 2rem; cursor: pointer; font-size: 1rem; display: inline-flex; align-items: center; justify-content: center; color: var(--text-muted); }
|
||||
.icon-btn:hover { background: var(--border); color: var(--text); }
|
||||
.icon-btn.danger:hover { background: var(--danger-soft); color: var(--danger); }
|
||||
|
||||
.entry-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.entry-empty { padding: 0.6rem 0; }
|
||||
.entry-row { display: flex; gap: 0.6rem; align-items: flex-start; padding: 0.6rem; border: 1px solid var(--border); border-left: 4px solid var(--chip); border-radius: var(--radius-sm); background: var(--surface-2); }
|
||||
.entry-icon { font-size: 1.1rem; }
|
||||
.entry-body { flex: 1; min-width: 0; }
|
||||
.entry-title { font-weight: 600; }
|
||||
.entry-sub { font-size: 0.8rem; }
|
||||
.entry-details { font-size: 0.84rem; color: var(--text-muted); margin-top: 0.2rem; white-space: pre-wrap; }
|
||||
.entry-actions { display: flex; gap: 0.25rem; }
|
||||
|
||||
.entry-form { display: flex; flex-direction: column; gap: 0.8rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
||||
.loc-field { position: relative; }
|
||||
.loc-results { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius-sm); margin-top: 0.3rem; overflow: hidden; }
|
||||
.loc-results:empty { display: none; }
|
||||
.loc-result { text-align: left; border: none; background: var(--surface); padding: 0.5rem 0.6rem; cursor: pointer; font-size: 0.85rem; border-bottom: 1px solid var(--border); }
|
||||
.loc-result:last-child { border-bottom: none; }
|
||||
.loc-result:hover { background: var(--brand-soft); }
|
||||
.loc-empty { padding: 0.5rem 0.6rem; font-size: 0.85rem; }
|
||||
.loc-selected:empty { display: none; }
|
||||
.loc-chip { display: inline-flex; align-items: center; gap: 0.4rem; margin-top: 0.4rem; background: var(--brand-soft); color: var(--brand-dark); padding: 0.3rem 0.6rem; border-radius: 999px; font-size: 0.85rem; font-weight: 600; }
|
||||
.loc-clear { border: none; background: none; cursor: pointer; color: var(--brand-dark); font-size: 1rem; line-height: 1; padding: 0; }
|
||||
|
||||
/* ---------- Currency + prices ---------- */
|
||||
.field-currency, .field-price { flex: 0 0 110px; min-width: 90px; }
|
||||
.input-currency { text-transform: uppercase; }
|
||||
.currency-tag { display: inline-block; font-weight: 700; font-size: 0.7rem; letter-spacing: 0.04em; background: var(--surface-2); border: 1px solid var(--border); color: var(--text-muted); padding: 0.05rem 0.4rem; border-radius: 5px; }
|
||||
.chip-price { margin-left: auto; font-size: 0.66rem; font-weight: 700; background: rgba(15,23,42,0.06); color: #334155; padding: 0 0.25rem; border-radius: 4px; white-space: nowrap; }
|
||||
.entry-title-row { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; }
|
||||
.entry-price { font-size: 0.82rem; font-weight: 700; color: var(--brand-dark); white-space: nowrap; }
|
||||
.entry-cost { font-size: 0.78rem; margin-top: 0.15rem; }
|
||||
|
||||
/* ---------- Cost subsection in the day editor ---------- */
|
||||
.cost-heading { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-muted); margin-top: 0.3rem; }
|
||||
.cost-section { display: flex; flex-direction: column; gap: 0.7rem; }
|
||||
.cost-detail { display: flex; flex-direction: column; gap: 0.7rem; transition: opacity 0.15s; }
|
||||
.cost-detail.disabled { opacity: 0.45; pointer-events: none; }
|
||||
.participants { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
.part-item { display: inline-flex; align-items: center; gap: 0.35rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 0.2rem 0.6rem 0.2rem 0.45rem; font-size: 0.85rem; cursor: pointer; }
|
||||
.part-check { accent-color: var(--brand); cursor: pointer; }
|
||||
|
||||
/* ---------- Costs panel ---------- */
|
||||
.detail-side { display: flex; flex-direction: column; gap: 1.2rem; }
|
||||
.costs-section { display: flex; flex-direction: column; }
|
||||
.costs-empty { text-align: center; padding: 2.4rem 1rem; color: var(--text-muted); display: flex; flex-direction: column; gap: 0.3rem; align-items: center; }
|
||||
.costs-total { margin-bottom: 0.9rem; padding: 0.9rem; text-align: center; background: linear-gradient(135deg, #0f766e, #059669); color: #fff; border-radius: var(--radius-sm); display: flex; flex-direction: column; }
|
||||
.costs-total-value { font-size: 1.7rem; font-weight: 800; }
|
||||
.costs-total-label { font-size: 0.78rem; opacity: 0.9; }
|
||||
.costs-warn { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; font-size: 0.82rem; padding: 0.5rem 0.7rem; border-radius: var(--radius-sm); margin-bottom: 0.9rem; }
|
||||
.cost-block { margin-top: 1rem; }
|
||||
.cost-block h3 { margin-bottom: 0.5rem; }
|
||||
.bytype-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; font-size: 0.88rem; }
|
||||
.bytype-dot { width: 0.7rem; height: 0.7rem; border-radius: 3px; flex-shrink: 0; }
|
||||
.bytype-name { flex: 1; }
|
||||
.bytype-amount { font-weight: 700; }
|
||||
.cost-table { width: 100%; border-collapse: collapse; font-size: 0.86rem; }
|
||||
.cost-table th { text-align: left; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-faint); padding: 0.3rem 0.4rem; border-bottom: 1px solid var(--border); }
|
||||
.cost-table td { padding: 0.4rem 0.4rem; border-bottom: 1px solid var(--border); }
|
||||
.cost-table .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.net-pos { color: var(--success); font-weight: 700; }
|
||||
.net-neg { color: var(--danger); font-weight: 700; }
|
||||
.net-zero { color: var(--text-muted); }
|
||||
.settle-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid var(--border); font-size: 0.88rem; }
|
||||
.settle-from { font-weight: 600; }
|
||||
.settle-arrow { color: var(--brand); font-weight: 700; }
|
||||
.settle-to { font-weight: 600; }
|
||||
.settle-amount { margin-left: auto; font-weight: 700; color: var(--brand-dark); white-space: nowrap; }
|
||||
|
||||
/* ---------- Flight route builder (day editor) ---------- */
|
||||
.flight-section { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.seg-quick { display: flex; gap: 0.5rem; align-items: flex-end; }
|
||||
.seg-quick-input { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.seg-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.seg-row { border: 1px solid var(--border); border-left: 3px solid var(--brand); border-radius: var(--radius-sm); padding: 0.6rem; background: var(--surface-2); display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.seg-row-head { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.seg-num { flex-shrink: 0; width: 1.4rem; height: 1.4rem; display: inline-flex; align-items: center; justify-content: center; background: var(--brand-soft); color: var(--brand-dark); border-radius: 50%; font-weight: 700; font-size: 0.75rem; }
|
||||
.seg-flightno { flex: 1; text-transform: uppercase; }
|
||||
.seg-airports { display: flex; align-items: flex-start; gap: 0.4rem; }
|
||||
.seg-ap { flex: 1; position: relative; display: flex; flex-direction: column; gap: 0.15rem; }
|
||||
.seg-code { text-transform: uppercase; letter-spacing: 0.05em; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
|
||||
.seg-ap.ap-unresolved .seg-code { border-color: var(--danger); box-shadow: 0 0 0 2px var(--danger-soft); }
|
||||
.seg-arrow { color: var(--brand); font-weight: 700; align-self: center; padding-top: 0.3rem; }
|
||||
.ap-name { font-size: 0.72rem; color: var(--text-muted); min-height: 0.9rem; padding-left: 0.1rem; }
|
||||
.ap-results { position: absolute; top: 100%; left: 0; right: 0; z-index: 5; background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); box-shadow: var(--shadow-md); overflow: hidden; max-height: 220px; overflow-y: auto; }
|
||||
.ap-results:empty { display: none; }
|
||||
.ap-result { display: flex; gap: 0.5rem; align-items: baseline; width: 100%; text-align: left; border: none; background: var(--surface); padding: 0.45rem 0.6rem; cursor: pointer; border-bottom: 1px solid var(--border); font-size: 0.82rem; }
|
||||
.ap-result:last-child { border-bottom: none; }
|
||||
.ap-result:hover { background: var(--brand-soft); }
|
||||
.ap-code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-weight: 700; color: var(--brand-dark); flex-shrink: 0; }
|
||||
.ap-desc { color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ap-empty { padding: 0.5rem 0.6rem; font-size: 0.82rem; }
|
||||
.seg-times { display: flex; gap: 0.6rem; }
|
||||
.seg-time { display: flex; align-items: center; gap: 0.35rem; font-size: 0.78rem; color: var(--text-muted); }
|
||||
.seg-time input { width: auto; }
|
||||
|
||||
/* Flight segments on the day-editor entry rows */
|
||||
.entry-segments { display: flex; flex-direction: column; gap: 0.1rem; margin-top: 0.25rem; }
|
||||
.entry-seg { display: flex; gap: 0.5rem; font-size: 0.8rem; font-variant-numeric: tabular-nums; }
|
||||
.seg-flight { font-weight: 700; color: var(--brand-dark); }
|
||||
|
||||
.stat-sub { font-size: 0.66rem; color: var(--brand-dark); font-weight: 700; }
|
||||
|
||||
/* ---------- Rental details (day editor) ---------- */
|
||||
.rental-section { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.rental-details { display: flex; flex-direction: column; gap: 0.7rem; }
|
||||
.rental-blocks { display: flex; gap: 0.8rem; flex-wrap: wrap; }
|
||||
.rental-block { flex: 1; min-width: 200px; border: 1px solid var(--border); border-left: 3px solid #0891b2; border-radius: var(--radius-sm); padding: 0.6rem; background: var(--surface-2); display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.rental-block-title { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-muted); }
|
||||
.entry-rental { font-size: 0.8rem; margin-top: 0.2rem; }
|
||||
|
||||
/* Secondary "dropoff" calendar chip */
|
||||
.cal-chip-dropoff { background: transparent; border: 1px dashed var(--chip); border-left: 3px dashed var(--chip); color: var(--text-muted); cursor: pointer; font-style: italic; }
|
||||
.cal-chip-dropoff:hover { background: color-mix(in srgb, var(--chip) 10%, white); }
|
||||
|
||||
/* Over-budget driven distance */
|
||||
.km-row.km-over .km-row-value { color: var(--danger); }
|
||||
.km-row.km-over .km-row-icon { filter: grayscale(0.2); }
|
||||
|
||||
/* ---------- Toasts ---------- */
|
||||
.toast-host { position: fixed; bottom: 1.2rem; right: 1.2rem; z-index: 100; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.toast { padding: 0.7rem 1rem; border-radius: var(--radius-sm); color: #fff; font-size: 0.88rem; font-weight: 600; box-shadow: var(--shadow-md); opacity: 0; transform: translateY(10px); transition: opacity 0.25s, transform 0.25s; max-width: 320px; }
|
||||
.toast.show { opacity: 1; transform: none; }
|
||||
.toast-error { background: var(--danger); }
|
||||
.toast-success { background: var(--success); }
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
@media (max-width: 1024px) {
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.page { padding: 1rem; }
|
||||
.cal-day { min-height: 76px; }
|
||||
.cal-chip .chip-text { display: none; }
|
||||
.cal-chip { justify-content: center; }
|
||||
.stat-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
.invite-form { margin-left: 0; width: 100%; }
|
||||
.invite-form .input { flex: 1; width: auto; }
|
||||
.trip-header-actions { width: 100%; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.calendar-grid { gap: 3px; }
|
||||
.cal-weekday { font-size: 0.6rem; }
|
||||
.cal-day { min-height: 60px; padding: 0.2rem; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>Trip Plan</title>
|
||||
|
||||
<!-- Leaflet (map) from unpkg CDN. Classic scripts run before the deferred
|
||||
ES module below, so window.L is ready by the time app.js executes. -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin=""
|
||||
/>
|
||||
<script
|
||||
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin=""
|
||||
></script>
|
||||
|
||||
<link rel="stylesheet" href="./css/styles.css" />
|
||||
<link rel="stylesheet" href="./css/flipclock.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,83 @@
|
||||
// Thin fetch wrapper for the Trip Plan REST API (see docs/API.md).
|
||||
// Same-origin, cookies included. Server errors ({error}) become thrown Errors
|
||||
// whose message is the server's human-readable text and `.status` the code.
|
||||
|
||||
async function request(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {},
|
||||
};
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(path, opts);
|
||||
} catch (networkErr) {
|
||||
const err = new Error('Network error — is the server running?');
|
||||
err.cause = networkErr;
|
||||
err.status = 0;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (res.status === 204) return null;
|
||||
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const message = (data && data.error) || `Request failed (${res.status})`;
|
||||
const err = new Error(message);
|
||||
err.status = res.status;
|
||||
err.body = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
const get = (p) => request('GET', p);
|
||||
const post = (p, b) => request('POST', p, b);
|
||||
const patch = (p, b) => request('PATCH', p, b);
|
||||
const del = (p) => request('DELETE', p);
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
// Mullvad-style: create an account (server returns the one-time token).
|
||||
createAccount: () => post('/api/auth/account', {}),
|
||||
login: (token) => post('/api/auth/login', { token }),
|
||||
logout: () => post('/api/auth/logout'),
|
||||
me: () => get('/api/auth/me'),
|
||||
updateMe: (patchBody) => patch('/api/auth/me', patchBody),
|
||||
},
|
||||
trips: {
|
||||
list: () => get('/api/trips'),
|
||||
create: (payload) => post('/api/trips', payload),
|
||||
get: (id) => get(`/api/trips/${id}`),
|
||||
update: (id, patchBody) => patch(`/api/trips/${id}`, patchBody),
|
||||
remove: (id) => del(`/api/trips/${id}`),
|
||||
join: (code) => post('/api/trips/join', { code }),
|
||||
regenerateJoinCode: (id) => post(`/api/trips/${id}/join-code`, {}),
|
||||
removeMember: (id, userId) => del(`/api/trips/${id}/members/${userId}`),
|
||||
route: (id) => get(`/api/trips/${id}/route`),
|
||||
costs: (id) => get(`/api/trips/${id}/costs`),
|
||||
},
|
||||
entries: {
|
||||
create: (tripId, payload) => post(`/api/trips/${tripId}/entries`, payload),
|
||||
update: (id, patchBody) => patch(`/api/entries/${id}`, patchBody),
|
||||
remove: (id) => del(`/api/entries/${id}`),
|
||||
},
|
||||
geocode: (q) => get(`/api/geocode?q=${encodeURIComponent(q)}`),
|
||||
airports: (q) => get(`/api/airports?q=${encodeURIComponent(q)}`),
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,171 @@
|
||||
// App bootstrap: auth check, top nav, and a tiny hash router.
|
||||
// Routes: #/login, #/trips, #/trip/:id
|
||||
import { api } from './api.js';
|
||||
import { el, clear, mount, toast, loading } from './dom.js';
|
||||
import { renderAuth } from './views/auth.js';
|
||||
import { renderTrips } from './views/trips.js';
|
||||
import { renderTripDetail } from './views/tripDetail.js';
|
||||
|
||||
const state = { user: null };
|
||||
|
||||
function root() {
|
||||
return document.getElementById('app');
|
||||
}
|
||||
|
||||
function navigate(hash) {
|
||||
if (location.hash === hash) route();
|
||||
else location.hash = hash;
|
||||
}
|
||||
|
||||
// Parse "#/trip/5" -> { name: 'trip', params: { id: '5' } }
|
||||
function parseHash() {
|
||||
const raw = (location.hash || '').replace(/^#/, '');
|
||||
const parts = raw.split('/').filter(Boolean);
|
||||
if (parts.length === 0) return { name: 'trips', params: {} };
|
||||
if (parts[0] === 'login') return { name: 'login', params: {} };
|
||||
if (parts[0] === 'trips') return { name: 'trips', params: {} };
|
||||
if (parts[0] === 'trip' && parts[1]) return { name: 'trip', params: { id: parts[1] } };
|
||||
return { name: 'trips', params: {} };
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
const nav = el(
|
||||
'header',
|
||||
{ class: 'topnav' },
|
||||
el(
|
||||
'a',
|
||||
{ class: 'brand', href: '#/trips' },
|
||||
el('span', { class: 'brand-mark' }, '🧭'),
|
||||
el('span', { class: 'brand-name' }, 'Trip Plan'),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'nav-right' },
|
||||
state.user ? renderUserArea() : null,
|
||||
state.user ? el('button', { class: 'btn btn-ghost', onClick: onLogout }, 'Log out') : null,
|
||||
),
|
||||
);
|
||||
return nav;
|
||||
}
|
||||
|
||||
// Current user's display name with an inline pencil-edit (PATCH /api/auth/me).
|
||||
function renderUserArea() {
|
||||
const area = el('span', { class: 'nav-user' });
|
||||
|
||||
function initial() {
|
||||
return (state.user.display_name || '?').charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
function showDisplay() {
|
||||
mount(
|
||||
area,
|
||||
el('span', { class: 'nav-avatar' }, initial()),
|
||||
el('span', { class: 'nav-name' }, state.user.display_name || 'me'),
|
||||
el('button', { class: 'nav-edit', title: 'Edit name', type: 'button', onClick: showEdit }, '✎'),
|
||||
);
|
||||
}
|
||||
|
||||
function showEdit() {
|
||||
const input = el('input', {
|
||||
class: 'input input-sm nav-name-input',
|
||||
type: 'text',
|
||||
maxlength: '40',
|
||||
value: state.user.display_name || '',
|
||||
'aria-label': 'Display name',
|
||||
});
|
||||
async function save() {
|
||||
const dn = input.value.trim();
|
||||
if (!dn) return showDisplay();
|
||||
try {
|
||||
const data = await api.auth.updateMe({ display_name: dn });
|
||||
state.user = data.user;
|
||||
showDisplay();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
}
|
||||
}
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); save(); }
|
||||
else if (e.key === 'Escape') showDisplay();
|
||||
});
|
||||
mount(
|
||||
area,
|
||||
input,
|
||||
el('button', { class: 'btn btn-sm btn-primary', type: 'button', onClick: save }, 'Save'),
|
||||
el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: showDisplay }, 'Cancel'),
|
||||
);
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
showDisplay();
|
||||
return area;
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
try {
|
||||
await api.auth.logout();
|
||||
} catch (e) {
|
||||
// Even if the request fails, drop local state.
|
||||
}
|
||||
state.user = null;
|
||||
navigate('#/login');
|
||||
}
|
||||
|
||||
function render() {
|
||||
const view = parseHash();
|
||||
|
||||
// Defensively remove any body-level overlay (e.g. an open day editor) so it
|
||||
// can never orphan on top of a freshly rendered view.
|
||||
document.querySelectorAll('.overlay').forEach((n) => n.remove());
|
||||
document.body.classList.remove('no-scroll');
|
||||
|
||||
// Auth guards.
|
||||
if (!state.user && view.name !== 'login') {
|
||||
navigate('#/login');
|
||||
return;
|
||||
}
|
||||
if (state.user && view.name === 'login') {
|
||||
navigate('#/trips');
|
||||
return;
|
||||
}
|
||||
|
||||
const container = root();
|
||||
clear(container);
|
||||
if (state.user) container.appendChild(renderNav());
|
||||
|
||||
const viewEl = el('main', { class: 'view', id: 'view' });
|
||||
container.appendChild(viewEl);
|
||||
|
||||
const ctx = { state, navigate, refresh: render };
|
||||
|
||||
if (view.name === 'login') renderAuth(viewEl, ctx);
|
||||
else if (view.name === 'trips') renderTrips(viewEl, ctx);
|
||||
else if (view.name === 'trip') renderTripDetail(viewEl, ctx, view.params.id);
|
||||
}
|
||||
|
||||
// Exposed so views can update the current user after login/register.
|
||||
function route() {
|
||||
render();
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const container = root();
|
||||
clear(container);
|
||||
container.appendChild(loading('Starting Trip Plan…'));
|
||||
|
||||
try {
|
||||
const data = await api.auth.me();
|
||||
state.user = data && data.user ? data.user : null;
|
||||
} catch (e) {
|
||||
state.user = null; // 401 is expected when logged out.
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', route);
|
||||
|
||||
// render()'s guards redirect a signed-out visitor to #/login and a
|
||||
// signed-in one away from it, so a single render() is enough here.
|
||||
render();
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,91 @@
|
||||
// Tiny DOM helpers — no framework, just ergonomic element creation.
|
||||
|
||||
// el('div', { class: 'x', onClick: fn }, child, child, ...)
|
||||
// Attrs: class, dataset(obj), style(obj), html(innerHTML), on<Event>(fn),
|
||||
// boolean true -> present attribute, anything else -> setAttribute.
|
||||
export function el(tag, attrs = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [key, val] of Object.entries(attrs || {})) {
|
||||
if (val == null || val === false) continue;
|
||||
if (key === 'class') node.className = val;
|
||||
else if (key === 'dataset') Object.assign(node.dataset, val);
|
||||
else if (key === 'style' && typeof val === 'object') {
|
||||
// Custom properties (--x) must go through setProperty; plain assignment
|
||||
// silently drops them.
|
||||
for (const [prop, pv] of Object.entries(val)) {
|
||||
if (pv == null) continue;
|
||||
if (prop.startsWith('--')) node.style.setProperty(prop, pv);
|
||||
else node.style[prop] = pv;
|
||||
}
|
||||
}
|
||||
else if (key === 'html') node.innerHTML = val;
|
||||
else if (key === 'value') node.value = val;
|
||||
else if (key.startsWith('on') && typeof val === 'function')
|
||||
node.addEventListener(key.slice(2).toLowerCase(), val);
|
||||
else if (val === true) node.setAttribute(key, '');
|
||||
else node.setAttribute(key, val);
|
||||
}
|
||||
appendAll(node, children);
|
||||
return node;
|
||||
}
|
||||
|
||||
function appendAll(node, children) {
|
||||
for (const child of children.flat(Infinity)) {
|
||||
if (child == null || child === false || child === true) continue;
|
||||
node.appendChild(
|
||||
typeof child === 'string' || typeof child === 'number'
|
||||
? document.createTextNode(String(child))
|
||||
: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function clear(node) {
|
||||
while (node.firstChild) node.removeChild(node.firstChild);
|
||||
return node;
|
||||
}
|
||||
|
||||
export function mount(container, ...children) {
|
||||
clear(container);
|
||||
appendAll(container, children);
|
||||
return container;
|
||||
}
|
||||
|
||||
export function loading(text = 'Loading…') {
|
||||
return el('div', { class: 'loading' }, el('span', { class: 'spinner' }), text);
|
||||
}
|
||||
|
||||
export function errorBox(message, onRetry) {
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'error-box' },
|
||||
el('p', {}, message || 'Something went wrong.'),
|
||||
onRetry ? el('button', { class: 'btn', onClick: onRetry }, 'Retry') : null,
|
||||
);
|
||||
}
|
||||
|
||||
export function emptyState(title, subtitle, action) {
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'empty-state' },
|
||||
el('div', { class: 'empty-icon' }, '🧭'),
|
||||
el('h3', {}, title),
|
||||
subtitle ? el('p', {}, subtitle) : null,
|
||||
action || null,
|
||||
);
|
||||
}
|
||||
|
||||
let toastHost = null;
|
||||
export function toast(message, type = 'error', ms = 4200) {
|
||||
if (!toastHost) {
|
||||
toastHost = el('div', { class: 'toast-host' });
|
||||
document.body.appendChild(toastHost);
|
||||
}
|
||||
const node = el('div', { class: `toast toast-${type}` }, message);
|
||||
toastHost.appendChild(node);
|
||||
requestAnimationFrame(() => node.classList.add('show'));
|
||||
setTimeout(() => {
|
||||
node.classList.remove('show');
|
||||
setTimeout(() => node.remove(), 300);
|
||||
}, ms);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Shared formatting helpers and the canonical entry-type palette.
|
||||
// The type config here is the single source of truth for icon + color,
|
||||
// reused by the calendar chips, map markers/popups, and the day editor.
|
||||
|
||||
// Order here drives the day-editor type picker and the calendar legend, so it
|
||||
// is roughly most-common-first with Activity as the default for new entries.
|
||||
export const ENTRY_TYPES = {
|
||||
activity: { label: 'Activity', icon: '📍', color: '#059669' },
|
||||
hotel: { label: 'Hotel', icon: '🏨', color: '#db2777' },
|
||||
travel: { label: 'Travel', icon: '🚗', color: '#d97706' },
|
||||
flight: { label: 'Flight', icon: '✈️', color: '#2563eb' },
|
||||
rental: { label: 'Rental car', icon: '🚙', color: '#0891b2' },
|
||||
immigration: { label: 'Immigration', icon: '🛂', color: '#7c3aed' },
|
||||
note: { label: 'Note', icon: '📝', color: '#64748b' },
|
||||
};
|
||||
|
||||
export const ENTRY_TYPE_LIST = Object.entries(ENTRY_TYPES).map(([value, meta]) => ({
|
||||
value,
|
||||
...meta,
|
||||
}));
|
||||
|
||||
export function typeInfo(type) {
|
||||
return ENTRY_TYPES[type] || { label: type || 'Entry', icon: '•', color: '#64748b' };
|
||||
}
|
||||
|
||||
// Split modes with the human labels the day-editor select shows.
|
||||
export const SPLIT_MODES = [
|
||||
{ value: 'equal', label: 'Split equally' },
|
||||
{ value: 'own', label: 'Everyone pays their own (price per person)' },
|
||||
{ value: 'payer', label: "Payer's own expense" },
|
||||
];
|
||||
|
||||
export function splitModeLabel(mode) {
|
||||
const found = SPLIT_MODES.find((m) => m.value === mode);
|
||||
return found ? found.label : mode;
|
||||
}
|
||||
|
||||
// Account tokens / join codes: strip separators + uppercase, or regroup for
|
||||
// display. Works whether the server sends the value raw or already grouped.
|
||||
export function normalizeCode(str) {
|
||||
return String(str || '').replace(/[^A-Za-z0-9]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
export function groupCode(str, size = 4) {
|
||||
const raw = normalizeCode(str);
|
||||
const groups = raw.match(new RegExp(`.{1,${size}}`, 'g'));
|
||||
return groups ? groups.join('-') : raw;
|
||||
}
|
||||
|
||||
// Money as "1,234.56 USD". `compact` drops trailing zeros ("1,200 THB").
|
||||
export function formatMoney(amount, currency = 'USD', { compact = false } = {}) {
|
||||
const n = Number(amount) || 0;
|
||||
const s = n.toLocaleString(undefined, compact
|
||||
? { maximumFractionDigits: 2 }
|
||||
: { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
return `${s} ${currency}`;
|
||||
}
|
||||
|
||||
// Parse a YYYY-MM-DD string as a *local* date (avoid UTC off-by-one).
|
||||
export function parseYMD(str) {
|
||||
const [y, m, d] = String(str).split('-').map(Number);
|
||||
return new Date(y, (m || 1) - 1, d || 1);
|
||||
}
|
||||
|
||||
export function ymd(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export function addDays(date, n) {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + n);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function daysBetweenInclusive(startStr, endStr) {
|
||||
const a = parseYMD(startStr);
|
||||
const b = parseYMD(endStr);
|
||||
return Math.round((b - a) / 86400000) + 1;
|
||||
}
|
||||
|
||||
export function eachDay(startStr, endStr) {
|
||||
const out = [];
|
||||
let d = parseYMD(startStr);
|
||||
const end = parseYMD(endStr);
|
||||
while (d <= end) {
|
||||
out.push(ymd(d));
|
||||
d = addDays(d, 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Monday-based start of the week containing `date`.
|
||||
export function startOfWeekMon(date) {
|
||||
const d = new Date(date);
|
||||
const offset = (d.getDay() + 6) % 7; // 0 = Monday
|
||||
return addDays(d, -offset);
|
||||
}
|
||||
|
||||
export function formatDate(str, opts = { month: 'short', day: 'numeric' }) {
|
||||
return parseYMD(str).toLocaleDateString(undefined, opts);
|
||||
}
|
||||
|
||||
export function formatFullDate(str) {
|
||||
return parseYMD(str).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatRange(startStr, endStr) {
|
||||
const s = parseYMD(startStr);
|
||||
const e = parseYMD(endStr);
|
||||
const sameYear = s.getFullYear() === e.getFullYear();
|
||||
const sOpts = sameYear
|
||||
? { month: 'short', day: 'numeric' }
|
||||
: { month: 'short', day: 'numeric', year: 'numeric' };
|
||||
const eOpts = { month: 'short', day: 'numeric', year: 'numeric' };
|
||||
return `${s.toLocaleDateString(undefined, sOpts)} – ${e.toLocaleDateString(undefined, eOpts)}`;
|
||||
}
|
||||
|
||||
export function formatTimeRange(start, end) {
|
||||
if (start && end) return `${start}–${end}`;
|
||||
return start || end || '';
|
||||
}
|
||||
|
||||
export function pluralize(n, one, many) {
|
||||
return `${n} ${n === 1 ? one : many || one + 's'}`;
|
||||
}
|
||||
|
||||
// "CNX→BKK→DXB→FRA" from a flight entry's segments array.
|
||||
export function flightChain(segments) {
|
||||
if (!Array.isArray(segments) || segments.length === 0) return '';
|
||||
const codes = [segments[0]?.from?.code, ...segments.map((s) => s?.to?.code)].filter(Boolean);
|
||||
return codes.join('→');
|
||||
}
|
||||
|
||||
export function hasSegments(entry) {
|
||||
return entry && Array.isArray(entry.segments) && entry.segments.length > 0;
|
||||
}
|
||||
|
||||
export function hasRental(entry) {
|
||||
return entry && entry.rental && typeof entry.rental === 'object';
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Login view — Mullvad-style account tokens (no username/password).
|
||||
// Two panels: create a new account (one-time token reveal) or log in with an
|
||||
// existing account number.
|
||||
import { api } from '../api.js';
|
||||
import { el, mount, toast } from '../dom.js';
|
||||
import { groupCode, normalizeCode } from '../format.js';
|
||||
|
||||
export function renderAuth(container, ctx) {
|
||||
let mode = 'create'; // 'create' | 'login'
|
||||
|
||||
function draw() {
|
||||
const card = el(
|
||||
'div',
|
||||
{ class: 'auth-card card' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'auth-head' },
|
||||
el('div', { class: 'brand-mark brand-mark-lg' }, '🧭'),
|
||||
el('h1', {}, 'Trip Plan'),
|
||||
el('p', { class: 'muted' }, 'Plan trips together, day by day.'),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'auth-tabs' },
|
||||
tab('Create account', mode === 'create', () => setMode('create')),
|
||||
tab('Log in', mode === 'login', () => setMode('login')),
|
||||
),
|
||||
mode === 'create' ? createPanel() : loginPanel(),
|
||||
);
|
||||
mount(container, el('div', { class: 'auth-wrap' }, card));
|
||||
}
|
||||
|
||||
function setMode(next) {
|
||||
if (mode !== next) {
|
||||
mode = next;
|
||||
draw();
|
||||
}
|
||||
}
|
||||
|
||||
function tab(label, active, onClick) {
|
||||
return el('button', { class: `auth-tab${active ? ' active' : ''}`, type: 'button', onClick }, label);
|
||||
}
|
||||
|
||||
// ----- Create account -----
|
||||
function createPanel() {
|
||||
const errorEl = el('p', { class: 'form-error' });
|
||||
const createBtn = el('button', { class: 'btn btn-primary btn-block', type: 'button' }, 'Create account');
|
||||
|
||||
async function onCreate() {
|
||||
errorEl.textContent = '';
|
||||
createBtn.disabled = true;
|
||||
createBtn.textContent = 'Creating…';
|
||||
try {
|
||||
const data = await api.auth.createAccount();
|
||||
showToken(data.token, data.user);
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
createBtn.disabled = false;
|
||||
createBtn.textContent = 'Create account';
|
||||
}
|
||||
}
|
||||
createBtn.addEventListener('click', onCreate);
|
||||
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'auth-panel' },
|
||||
el('p', { class: 'auth-lead' }, 'No email, no password. We generate a private account number — it is your only key to your trips.'),
|
||||
errorEl,
|
||||
createBtn,
|
||||
);
|
||||
}
|
||||
|
||||
function showToken(token, user) {
|
||||
const grouped = groupCode(token, 4);
|
||||
const copyBtn = el('button', { class: 'btn', type: 'button' }, '📋 Copy');
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(grouped);
|
||||
toast('Account number copied', 'success');
|
||||
} catch {
|
||||
toast('Copy failed — select and copy it manually');
|
||||
}
|
||||
});
|
||||
|
||||
const panel = el(
|
||||
'div',
|
||||
{ class: 'auth-panel token-reveal' },
|
||||
el('div', { class: 'token-warning' },
|
||||
el('strong', {}, 'This is your only credential.'),
|
||||
' Save it now — it will never be shown again. Anyone with it can access your trips.'),
|
||||
el('div', { class: 'token-box' }, grouped),
|
||||
el('div', { class: 'token-actions' }, copyBtn),
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
class: 'btn btn-primary btn-block',
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
ctx.state.user = user;
|
||||
ctx.navigate('#/trips');
|
||||
},
|
||||
},
|
||||
"I've saved it — continue",
|
||||
),
|
||||
);
|
||||
|
||||
mount(container, el('div', { class: 'auth-wrap' }, el('div', { class: 'auth-card card' },
|
||||
el('div', { class: 'auth-head' },
|
||||
el('div', { class: 'brand-mark brand-mark-lg' }, '🎉'),
|
||||
el('h1', {}, 'Account created'),
|
||||
el('p', { class: 'muted' }, `You're ${user.display_name}. You can rename yourself later.`)),
|
||||
panel,
|
||||
)));
|
||||
}
|
||||
|
||||
// ----- Log in -----
|
||||
function loginPanel() {
|
||||
const errorEl = el('p', { class: 'form-error' });
|
||||
const input = el('input', {
|
||||
class: 'input token-input',
|
||||
type: 'text',
|
||||
autocomplete: 'off',
|
||||
autocapitalize: 'characters',
|
||||
spellcheck: 'false',
|
||||
placeholder: 'XXXX-XXXX-XXXX-XXXX',
|
||||
'aria-label': 'Account number',
|
||||
});
|
||||
const submitBtn = el('button', { class: 'btn btn-primary btn-block', type: 'submit' }, 'Log in');
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
errorEl.textContent = '';
|
||||
const token = normalizeCode(input.value);
|
||||
if (token.length < 8) {
|
||||
errorEl.textContent = 'Enter your full account number.';
|
||||
return;
|
||||
}
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Signing in…';
|
||||
try {
|
||||
const data = await api.auth.login(token);
|
||||
ctx.state.user = data.user;
|
||||
ctx.navigate('#/trips');
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Log in';
|
||||
}
|
||||
}
|
||||
|
||||
return el(
|
||||
'form',
|
||||
{ class: 'auth-panel auth-form', onSubmit },
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Account number'), input),
|
||||
el('p', { class: 'hint muted' }, 'Dashes, spaces and letter case do not matter.'),
|
||||
errorEl,
|
||||
submitBtn,
|
||||
);
|
||||
}
|
||||
|
||||
draw();
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Calendar grid for the trip's date range. Real weeks as rows (Mon–Sun
|
||||
// columns); days outside the range are greyed. Each in-range day shows its
|
||||
// entries as compact, type-coloured chips. Clicking a day opens the editor.
|
||||
import { el } from '../dom.js';
|
||||
import {
|
||||
ENTRY_TYPES,
|
||||
typeInfo,
|
||||
parseYMD,
|
||||
ymd,
|
||||
addDays,
|
||||
startOfWeekMon,
|
||||
formatMoney,
|
||||
flightChain,
|
||||
hasSegments,
|
||||
hasRental,
|
||||
} from '../format.js';
|
||||
|
||||
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
export function renderCalendar(tctx) {
|
||||
const { trip, entries } = tctx.trip;
|
||||
const currency = trip.currency || 'USD';
|
||||
|
||||
// Group entries by date for quick per-cell lookup.
|
||||
const byDate = new Map();
|
||||
// Derived "dropoff" chips: a rental whose dropoff day differs from its
|
||||
// (pickup) entry date gets a secondary chip on the dropoff day.
|
||||
const dropoffByDate = new Map();
|
||||
for (const entry of entries) {
|
||||
if (!byDate.has(entry.date)) byDate.set(entry.date, []);
|
||||
byDate.get(entry.date).push(entry);
|
||||
if (hasRental(entry)) {
|
||||
const dropDate = entry.rental.dropoff && entry.rental.dropoff.date;
|
||||
if (dropDate && dropDate !== entry.date) {
|
||||
if (!dropoffByDate.has(dropDate)) dropoffByDate.set(dropDate, []);
|
||||
dropoffByDate.get(dropDate).push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rangeStart = parseYMD(trip.start_date);
|
||||
const rangeEnd = parseYMD(trip.end_date);
|
||||
const gridStart = startOfWeekMon(rangeStart);
|
||||
|
||||
const section = el(
|
||||
'section',
|
||||
{ class: 'card calendar-section' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'section-head' },
|
||||
el('h2', {}, 'Calendar'),
|
||||
el('p', { class: 'muted' }, 'Click a day to add or edit entries.'),
|
||||
),
|
||||
legend(),
|
||||
);
|
||||
|
||||
const grid = el('div', { class: 'calendar-grid' });
|
||||
for (const label of WEEKDAYS) {
|
||||
grid.appendChild(el('div', { class: 'cal-weekday' }, label));
|
||||
}
|
||||
|
||||
// Walk whole weeks from gridStart until we've passed the range end.
|
||||
let cursor = gridStart;
|
||||
let guard = 0;
|
||||
while (cursor <= rangeEnd && guard < 400) {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
grid.appendChild(dayCell(cursor, rangeStart, rangeEnd, byDate, dropoffByDate, tctx, currency));
|
||||
cursor = addDays(cursor, 1);
|
||||
}
|
||||
guard += 7;
|
||||
}
|
||||
|
||||
section.appendChild(grid);
|
||||
return section;
|
||||
}
|
||||
|
||||
function dayCell(date, rangeStart, rangeEnd, byDate, dropoffByDate, tctx, currency) {
|
||||
const key = ymd(date);
|
||||
const inRange = date >= rangeStart && date <= rangeEnd;
|
||||
const dayEntries = byDate.get(key) || [];
|
||||
const dropoffs = dropoffByDate.get(key) || [];
|
||||
const isFirstOfMonth = date.getDate() === 1;
|
||||
|
||||
const cell = el('div', {
|
||||
class: `cal-day${inRange ? '' : ' cal-out'}${dayEntries.length || dropoffs.length ? ' cal-has' : ''}`,
|
||||
});
|
||||
|
||||
cell.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'cal-day-head' },
|
||||
el('span', { class: 'cal-daynum' }, String(date.getDate())),
|
||||
isFirstOfMonth
|
||||
? el('span', { class: 'cal-month' }, date.toLocaleDateString(undefined, { month: 'short' }))
|
||||
: null,
|
||||
!inRange && (dayEntries.length || dropoffs.length)
|
||||
? el('span', { class: 'cal-flag', title: 'Outside the trip date range' }, '⚠')
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
const chips = el('div', { class: 'cal-chips' });
|
||||
for (const entry of dayEntries) chips.appendChild(chip(entry, currency));
|
||||
// Secondary dropoff chips: clicking opens the pickup day where the entry lives.
|
||||
for (const entry of dropoffs) chips.appendChild(dropoffChip(entry, tctx));
|
||||
cell.appendChild(chips);
|
||||
|
||||
// In-range days are always clickable; out-of-range days only when they
|
||||
// hold entries or a derived dropoff chip.
|
||||
if (inRange || dayEntries.length || dropoffs.length) {
|
||||
cell.classList.add('clickable');
|
||||
cell.tabIndex = 0;
|
||||
cell.setAttribute('role', 'button');
|
||||
cell.addEventListener('click', () => tctx.openDay(key));
|
||||
cell.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
tctx.openDay(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
function chip(entry, currency) {
|
||||
const info = typeInfo(entry.type);
|
||||
const hasPrice = entry.price != null;
|
||||
const chain = hasSegments(entry) ? flightChain(entry.segments) : '';
|
||||
const car = hasRental(entry) ? [entry.rental.brand, entry.rental.model].filter(Boolean).join(' ') : '';
|
||||
const label = chain || car || entry.title;
|
||||
return el(
|
||||
'div',
|
||||
{
|
||||
class: 'cal-chip',
|
||||
style: { '--chip': info.color },
|
||||
title: `${info.label}: ${chain ? `${entry.title} (${chain})` : entry.title}${hasPrice ? ` · ${formatMoney(entry.price, currency, { compact: true })}` : ''}`,
|
||||
},
|
||||
el('span', { class: 'chip-icon' }, info.icon),
|
||||
el('span', { class: 'chip-text' }, label),
|
||||
hasPrice
|
||||
? el('span', { class: 'chip-price' }, formatMoney(entry.price, currency, { compact: true }))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
// Secondary, outlined chip shown on a rental's dropoff day. Clicking opens the
|
||||
// PICKUP day's editor (the day the entry actually lives on).
|
||||
function dropoffChip(entry, tctx) {
|
||||
const info = typeInfo(entry.type);
|
||||
const car = [entry.rental.brand, entry.rental.model].filter(Boolean).join(' ');
|
||||
const node = el(
|
||||
'div',
|
||||
{
|
||||
class: 'cal-chip cal-chip-dropoff',
|
||||
style: { '--chip': info.color },
|
||||
role: 'button',
|
||||
tabindex: '0',
|
||||
title: `Rental dropoff${car ? `: ${car}` : ''} — opens the pickup day`,
|
||||
},
|
||||
el('span', { class: 'chip-icon' }, info.icon),
|
||||
el('span', { class: 'chip-text' }, 'dropoff'),
|
||||
);
|
||||
const open = (e) => { e.stopPropagation(); tctx.openDay(entry.date); };
|
||||
node.addEventListener('click', open);
|
||||
node.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); }
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
function legend() {
|
||||
const wrap = el('div', { class: 'legend' });
|
||||
for (const [type, info] of Object.entries(ENTRY_TYPES)) {
|
||||
wrap.appendChild(
|
||||
el(
|
||||
'span',
|
||||
{ class: 'legend-item', 'data-type': type },
|
||||
el('span', { class: 'legend-dot', style: { background: info.color } }),
|
||||
el('span', {}, `${info.icon} ${info.label}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Cost & splitting subsection for the day editor. Self-contained so dayEditor
|
||||
// stays small. read() returns the cost fields for the entry payload:
|
||||
// { price: null } — no cost
|
||||
// { price, paid_by, split_mode, participants } — cost set
|
||||
// { error } — validation message
|
||||
import { el } from '../dom.js';
|
||||
import { SPLIT_MODES } from '../format.js';
|
||||
|
||||
export function createCostForm({ members, currency }) {
|
||||
const priceInput = el('input', { class: 'input', type: 'number', min: '0', step: '0.01', placeholder: '0.00' });
|
||||
const payerSelect = el(
|
||||
'select',
|
||||
{ class: 'input' },
|
||||
el('option', { value: '' }, '— unassigned —'),
|
||||
...members.map((m) => el('option', { value: String(m.id) }, m.display_name)),
|
||||
);
|
||||
const modeSelect = el(
|
||||
'select',
|
||||
{ class: 'input' },
|
||||
...SPLIT_MODES.map((m) => el('option', { value: m.value }, m.label)),
|
||||
);
|
||||
const participantChecks = members.map((m) =>
|
||||
el('input', { type: 'checkbox', class: 'part-check', value: String(m.id), checked: true }),
|
||||
);
|
||||
const participantsBox = el(
|
||||
'div',
|
||||
{ class: 'participants' },
|
||||
...members.map((m, i) =>
|
||||
el('label', { class: 'part-item' }, participantChecks[i], el('span', {}, m.display_name)),
|
||||
),
|
||||
);
|
||||
const costDetail = el(
|
||||
'div',
|
||||
{ class: 'cost-detail' },
|
||||
el('div', { class: 'form-row' },
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Paid by'), payerSelect),
|
||||
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Split'), modeSelect)),
|
||||
el('div', { class: 'field' }, el('span', { class: 'field-label' }, 'Participants'), participantsBox),
|
||||
);
|
||||
const node = el(
|
||||
'div',
|
||||
{ class: 'cost-section' },
|
||||
el('div', { class: 'form-row' },
|
||||
el('label', { class: 'field field-price' }, el('span', { class: 'field-label' }, `Price (${currency})`), priceInput)),
|
||||
costDetail,
|
||||
);
|
||||
|
||||
// Dim the payer/split/participants controls until a price is entered.
|
||||
function syncVisibility() {
|
||||
costDetail.classList.toggle('disabled', priceInput.value.trim() === '');
|
||||
}
|
||||
priceInput.addEventListener('input', syncVisibility);
|
||||
|
||||
function read() {
|
||||
const priceRaw = priceInput.value.trim();
|
||||
if (priceRaw === '') return { price: null };
|
||||
const price = Number(priceRaw);
|
||||
if (!Number.isFinite(price) || price < 0) return { error: 'Price must be a number ≥ 0.' };
|
||||
const split_mode = modeSelect.value;
|
||||
const paid_by = payerSelect.value ? Number(payerSelect.value) : null;
|
||||
if (split_mode === 'payer' && paid_by == null) {
|
||||
return { error: "Choose who paid for a payer's own expense." };
|
||||
}
|
||||
const checked = participantChecks.filter((c) => c.checked).map((c) => Number(c.value));
|
||||
if (checked.length === 0) {
|
||||
return { error: 'Select at least one participant (or clear the price to drop the cost).' };
|
||||
}
|
||||
// [] means "all trip members"; only send an explicit list for a subset.
|
||||
const participants = checked.length === members.length ? [] : checked;
|
||||
return { price, paid_by, split_mode, participants };
|
||||
}
|
||||
|
||||
function prefill(entry) {
|
||||
priceInput.value = entry.price != null ? String(entry.price) : '';
|
||||
payerSelect.value = entry.paid_by != null ? String(entry.paid_by) : '';
|
||||
modeSelect.value = entry.split_mode || 'equal';
|
||||
const parts = Array.isArray(entry.participants) ? entry.participants : [];
|
||||
for (const cb of participantChecks) {
|
||||
cb.checked = parts.length === 0 || parts.includes(Number(cb.value));
|
||||
}
|
||||
syncVisibility();
|
||||
}
|
||||
|
||||
syncVisibility();
|
||||
return { node, read, prefill };
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Costs & splitting panel from GET /api/trips/:id/costs.
|
||||
// Total + currency, breakdown by entry type, a per-user share/paid/net table
|
||||
// (net colored green when owed to them, red when they owe), a settle-up list,
|
||||
// and a hint when some priced entries have no payer assigned.
|
||||
import { el } from '../dom.js';
|
||||
import { typeInfo, formatMoney } from '../format.js';
|
||||
|
||||
export function renderCosts(tctx) {
|
||||
const costs = tctx.costs || {};
|
||||
const currency = costs.currency || (tctx.trip.trip && tctx.trip.trip.currency) || 'USD';
|
||||
const total = costs.totalCost || 0;
|
||||
const perUser = costs.perUser || [];
|
||||
const byType = costs.byType || {};
|
||||
const settlements = costs.settlements || [];
|
||||
const unassigned = costs.unassigned || 0;
|
||||
|
||||
const money = (n) => formatMoney(n, currency);
|
||||
|
||||
const nameById = new Map(perUser.map((u) => [u.userId, u.displayName]));
|
||||
const memberName = (id) => {
|
||||
if (nameById.has(id)) return nameById.get(id);
|
||||
const m = (tctx.trip.members || []).find((x) => x.id === id);
|
||||
return m ? m.display_name : `user ${id}`;
|
||||
};
|
||||
|
||||
const section = el('section', { class: 'card costs-section' });
|
||||
section.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'section-head' },
|
||||
el('h2', {}, 'Costs'),
|
||||
el('p', { class: 'muted' }, 'Who owes whom, split across the trip.'),
|
||||
),
|
||||
);
|
||||
|
||||
const hasCosts = total > 0 || perUser.some((u) => u.share || u.paid);
|
||||
if (!hasCosts) {
|
||||
section.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'costs-empty' },
|
||||
el('div', { class: 'empty-icon' }, '💰'),
|
||||
el('p', {}, 'No costs tracked yet.'),
|
||||
el('p', { class: 'muted' }, 'Add a price to an entry to start splitting expenses.'),
|
||||
),
|
||||
);
|
||||
return section;
|
||||
}
|
||||
|
||||
// Total.
|
||||
section.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'costs-total' },
|
||||
el('span', { class: 'costs-total-value' }, money(total)),
|
||||
el('span', { class: 'costs-total-label' }, 'total trip cost'),
|
||||
),
|
||||
);
|
||||
|
||||
if (unassigned > 0) {
|
||||
section.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'costs-warn' },
|
||||
`⚠ ${money(unassigned)} of priced entries have no payer assigned — assign payers to settle them.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Breakdown by type.
|
||||
const typeKeys = Object.keys(byType);
|
||||
if (typeKeys.length) {
|
||||
const bt = el('div', { class: 'cost-block' }, el('h3', {}, 'By type'));
|
||||
for (const type of typeKeys) {
|
||||
const info = typeInfo(type);
|
||||
bt.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'bytype-row' },
|
||||
el('span', { class: 'bytype-dot', style: { background: info.color } }),
|
||||
el('span', { class: 'bytype-name' }, `${info.icon} ${info.label}`),
|
||||
el('span', { class: 'bytype-amount' }, money(byType[type])),
|
||||
),
|
||||
);
|
||||
}
|
||||
section.appendChild(bt);
|
||||
}
|
||||
|
||||
// Per-user table.
|
||||
if (perUser.length) {
|
||||
const table = el(
|
||||
'table',
|
||||
{ class: 'cost-table' },
|
||||
el(
|
||||
'thead',
|
||||
{},
|
||||
el(
|
||||
'tr',
|
||||
{},
|
||||
el('th', {}, 'Member'),
|
||||
el('th', { class: 'num' }, 'Share'),
|
||||
el('th', { class: 'num' }, 'Paid'),
|
||||
el('th', { class: 'num' }, 'Net'),
|
||||
),
|
||||
),
|
||||
el(
|
||||
'tbody',
|
||||
{},
|
||||
...perUser.map((u) => {
|
||||
const net = u.net || 0;
|
||||
const netClass = net > 0.004 ? 'net-pos' : net < -0.004 ? 'net-neg' : 'net-zero';
|
||||
const netText = `${net > 0 ? '+' : ''}${money(net)}`;
|
||||
return el(
|
||||
'tr',
|
||||
{},
|
||||
el('td', {}, u.displayName),
|
||||
el('td', { class: 'num' }, money(u.share || 0)),
|
||||
el('td', { class: 'num' }, money(u.paid || 0)),
|
||||
el('td', { class: `num ${netClass}` }, netText),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
section.appendChild(el('div', { class: 'cost-block' }, el('h3', {}, 'Per person'), table));
|
||||
}
|
||||
|
||||
// Settle-up list.
|
||||
const settleBlock = el('div', { class: 'cost-block' }, el('h3', {}, 'Settle up'));
|
||||
if (!settlements.length) {
|
||||
settleBlock.appendChild(el('p', { class: 'muted' }, 'All square — no transfers needed.'));
|
||||
} else {
|
||||
for (const s of settlements) {
|
||||
settleBlock.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'settle-row' },
|
||||
el('span', { class: 'settle-from' }, memberName(s.fromUserId)),
|
||||
el('span', { class: 'settle-arrow' }, '→'),
|
||||
el('span', { class: 'settle-to' }, memberName(s.toUserId)),
|
||||
el('span', { class: 'settle-amount' }, money(s.amount)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
section.appendChild(settleBlock);
|
||||
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
// Slide-over panel for a single day: lists existing entries (edit/delete) and
|
||||
// a form to add/update one, including a debounced geocode-backed location
|
||||
// autocomplete. On any change it calls tctx.refreshTrip() so the calendar,
|
||||
// map and summary update; while open it registers tctx._onModalRefresh so
|
||||
// this panel re-renders itself from the freshly fetched trip data too.
|
||||
import { api } from '../api.js';
|
||||
import { el, clear, mount, toast } from '../dom.js';
|
||||
import {
|
||||
ENTRY_TYPE_LIST,
|
||||
splitModeLabel,
|
||||
typeInfo,
|
||||
formatFullDate,
|
||||
formatTimeRange,
|
||||
formatMoney,
|
||||
flightChain,
|
||||
hasSegments,
|
||||
hasRental,
|
||||
} from '../format.js';
|
||||
import { createFlightRoute, renderSegmentLines } from './segments.js';
|
||||
import { createRentalDetails, renderRentalLine } from './rental.js';
|
||||
import { createCostForm } from './costForm.js';
|
||||
|
||||
export function openDayEditor(tctx, date) {
|
||||
// A form-state object for the entry currently being added/edited.
|
||||
let editing = null; // entry id being edited, or null for a new entry
|
||||
let loc = null; // { name, lat, lng } | null
|
||||
|
||||
const members = () => tctx.trip.members || [];
|
||||
const currency = () => (tctx.trip.trip && tctx.trip.trip.currency) || 'USD';
|
||||
const memberName = (id) => {
|
||||
const m = members().find((x) => x.id === id);
|
||||
return m ? m.display_name : `user ${id}`;
|
||||
};
|
||||
|
||||
const overlay = el('div', { class: 'overlay' });
|
||||
const panel = el('aside', { class: 'slideover', role: 'dialog', 'aria-modal': 'true' });
|
||||
overlay.appendChild(panel);
|
||||
document.body.appendChild(overlay);
|
||||
document.body.classList.add('no-scroll');
|
||||
|
||||
function close() {
|
||||
tctx._onModalRefresh = null;
|
||||
document.body.classList.remove('no-scroll');
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('hashchange', close);
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') close();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
// Self-close on any navigation so the overlay never orphans over another view.
|
||||
window.addEventListener('hashchange', close);
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
|
||||
// Re-render this panel whenever the underlying trip data changes.
|
||||
tctx._onModalRefresh = () => draw();
|
||||
|
||||
function entriesForDate() {
|
||||
return (tctx.trip.entries || []).filter((e) => e.date === date);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const dayEntries = entriesForDate();
|
||||
|
||||
const header = el(
|
||||
'div',
|
||||
{ class: 'slideover-head' },
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
el('h2', {}, formatFullDate(date)),
|
||||
el('p', { class: 'muted' }, dayEntries.length
|
||||
? `${dayEntries.length} ${dayEntries.length === 1 ? 'entry' : 'entries'}`
|
||||
: 'No entries yet'),
|
||||
),
|
||||
el('button', { class: 'icon-btn', title: 'Close', onClick: close }, '×'),
|
||||
);
|
||||
|
||||
const list = el('div', { class: 'entry-list' });
|
||||
if (!dayEntries.length) {
|
||||
list.appendChild(el('p', { class: 'muted entry-empty' }, 'Nothing planned for this day yet.'));
|
||||
} else {
|
||||
for (const entry of dayEntries) list.appendChild(entryRow(entry));
|
||||
}
|
||||
|
||||
mount(panel, header, list, formSection(dayEntries));
|
||||
panel.scrollTop = 0;
|
||||
}
|
||||
|
||||
function entryRow(entry) {
|
||||
const info = typeInfo(entry.type);
|
||||
const time = formatTimeRange(entry.start_time, entry.end_time);
|
||||
const hasPrice = entry.price != null;
|
||||
const flight = hasSegments(entry);
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'entry-row', style: { '--chip': info.color } },
|
||||
el('span', { class: 'entry-icon' }, info.icon),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'entry-body' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'entry-title-row' },
|
||||
el('span', { class: 'entry-title' }, entry.title),
|
||||
hasPrice
|
||||
? el('span', { class: 'entry-price' }, formatMoney(entry.price, currency(), { compact: true }))
|
||||
: null,
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'entry-sub muted' },
|
||||
info.label,
|
||||
time ? ` · ${time}` : '',
|
||||
flight ? ` · ✈️ ${flightChain(entry.segments)}` : '',
|
||||
!flight && entry.location_name ? ` · 📍 ${entry.location_name}` : '',
|
||||
),
|
||||
flight ? renderSegmentLines(entry.segments) : null,
|
||||
hasRental(entry) ? renderRentalLine(entry.rental) : null,
|
||||
hasPrice
|
||||
? el(
|
||||
'div',
|
||||
{ class: 'entry-cost muted' },
|
||||
`💰 ${splitModeLabel(entry.split_mode)}`,
|
||||
entry.paid_by != null ? ` · paid by ${memberName(entry.paid_by)}` : ' · no payer set',
|
||||
)
|
||||
: null,
|
||||
entry.details ? el('div', { class: 'entry-details' }, entry.details) : null,
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'entry-actions' },
|
||||
el('button', { class: 'icon-btn', title: 'Edit', onClick: () => startEdit(entry) }, '✎'),
|
||||
el('button', { class: 'icon-btn danger', title: 'Delete', onClick: () => onDelete(entry) }, '🗑'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function startEdit(entry) {
|
||||
editing = entry.id;
|
||||
loc = entry.lat != null && entry.lng != null
|
||||
? { name: entry.location_name || '', lat: entry.lat, lng: entry.lng }
|
||||
: null;
|
||||
draw();
|
||||
// Populate fields from the entry after (re)draw.
|
||||
fields.type.value = entry.type;
|
||||
fields.title.value = entry.title;
|
||||
fields.details.value = entry.details || '';
|
||||
fields.start.value = entry.start_time || '';
|
||||
fields.end.value = entry.end_time || '';
|
||||
fields.cost.prefill(entry);
|
||||
// Flight segments (load() triggers the flight-route onChange -> UI sync).
|
||||
fields.flightRoute.load(Array.isArray(entry.segments) ? entry.segments : []);
|
||||
fields.rentalDetails.load(entry.rental || null);
|
||||
renderLoc();
|
||||
panel.querySelector('.entry-form').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
fields.title.focus();
|
||||
}
|
||||
|
||||
// Field references for the current form render, so edit can populate them.
|
||||
let fields = {};
|
||||
|
||||
function formSection(dayEntries) {
|
||||
const typeSelect = el(
|
||||
'select',
|
||||
{ class: 'input' },
|
||||
...ENTRY_TYPE_LIST.map((t) => el('option', { value: t.value }, `${t.icon} ${t.label}`)),
|
||||
);
|
||||
const titleInput = el('input', { class: 'input', type: 'text', maxlength: '200', placeholder: 'Title (e.g. Flight BKK → CNX)' });
|
||||
const detailsInput = el('textarea', { class: 'input', rows: '2', placeholder: 'Details (optional)' });
|
||||
const startInput = el('input', { class: 'input', type: 'time' });
|
||||
const endInput = el('input', { class: 'input', type: 'time' });
|
||||
|
||||
const locWrap = el('div', { class: 'loc-field' });
|
||||
const locInput = el('input', {
|
||||
class: 'input',
|
||||
type: 'text',
|
||||
placeholder: 'Search a place (OpenStreetMap)…',
|
||||
autocomplete: 'off',
|
||||
});
|
||||
const locResults = el('div', { class: 'loc-results' });
|
||||
const locSelected = el('div', { class: 'loc-selected' });
|
||||
locWrap.append(locInput, locResults, locSelected);
|
||||
const locationField = el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Location'), locWrap);
|
||||
|
||||
// ----- Flight route subsection (shown only for flight entries) -----
|
||||
// The sub-modules fire onChange during construction (load of initial
|
||||
// state), before the section elements below exist — gate until wired.
|
||||
let typeUIReady = false;
|
||||
const flightRoute = createFlightRoute({ onChange: () => { if (typeUIReady) syncTypeUI(); } });
|
||||
const flightSection = el(
|
||||
'div',
|
||||
{ class: 'flight-section' },
|
||||
el('div', { class: 'cost-heading' }, 'Flight route (optional)'),
|
||||
el('p', { class: 'hint muted' }, 'Add legs for a multi-stop flight; airports plot the route on the map.'),
|
||||
flightRoute.node,
|
||||
);
|
||||
|
||||
// ----- Rental details subsection (shown only for rental entries) -----
|
||||
const rentalDetails = createRentalDetails({ entryDate: date, onChange: () => { if (typeUIReady) syncTypeUI(); } });
|
||||
const rentalSection = el(
|
||||
'div',
|
||||
{ class: 'rental-section' },
|
||||
el('div', { class: 'cost-heading' }, 'Rental details'),
|
||||
rentalDetails.node,
|
||||
);
|
||||
|
||||
// Show the flight route for flights and the rental block for rentals; hide
|
||||
// the generic location field only once flight segments exist (route then
|
||||
// comes from the airport coords). Rentals keep the location field — their
|
||||
// pickup/dropoff are plain text and don't feed the route.
|
||||
function syncTypeUI() {
|
||||
const type = typeSelect.value;
|
||||
flightSection.style.display = type === 'flight' ? '' : 'none';
|
||||
rentalSection.style.display = type === 'rental' ? '' : 'none';
|
||||
locationField.style.display = type === 'flight' && flightRoute.hasSegments() ? 'none' : '';
|
||||
}
|
||||
typeSelect.addEventListener('change', syncTypeUI);
|
||||
typeUIReady = true;
|
||||
syncTypeUI();
|
||||
|
||||
// ----- Cost subsection (self-contained module) -----
|
||||
const costForm = createCostForm({ members: members(), currency: currency() });
|
||||
|
||||
fields = {
|
||||
type: typeSelect, title: titleInput, details: detailsInput, start: startInput, end: endInput,
|
||||
locInput, locResults, locSelected,
|
||||
cost: costForm, flightRoute, rentalDetails,
|
||||
};
|
||||
|
||||
wireGeocode(locInput, locResults);
|
||||
|
||||
const errorEl = el('p', { class: 'form-error' });
|
||||
const submitBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, editing ? 'Save entry' : 'Add entry');
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
errorEl.textContent = '';
|
||||
const title = titleInput.value.trim();
|
||||
if (!title) return (errorEl.textContent = 'Title is required.');
|
||||
|
||||
const payload = {
|
||||
date,
|
||||
type: typeSelect.value,
|
||||
title,
|
||||
details: detailsInput.value.trim(),
|
||||
start_time: startInput.value || null,
|
||||
end_time: endInput.value || null,
|
||||
location_name: loc ? loc.name : null,
|
||||
lat: loc ? loc.lat : null,
|
||||
lng: loc ? loc.lng : null,
|
||||
};
|
||||
if (!editing) {
|
||||
payload.sort_order = dayEntries.length;
|
||||
}
|
||||
|
||||
// Flight segments (flight entries only). Clear them otherwise so changing
|
||||
// an entry's type away from flight drops any prior segments.
|
||||
if (typeSelect.value === 'flight') {
|
||||
const res = flightRoute.read();
|
||||
if (res.error) return (errorEl.textContent = res.error);
|
||||
payload.segments = res.segments; // array or null
|
||||
// When segments exist, the route comes from the airports — clear the
|
||||
// generic location so it doesn't add a stray stop.
|
||||
if (res.segments) {
|
||||
payload.location_name = null;
|
||||
payload.lat = null;
|
||||
payload.lng = null;
|
||||
}
|
||||
} else {
|
||||
payload.segments = null;
|
||||
}
|
||||
|
||||
// Rental details (rental entries only). The entry's own date follows the
|
||||
// pickup date so it lives on the pickup day.
|
||||
if (typeSelect.value === 'rental') {
|
||||
const res = rentalDetails.read();
|
||||
if (res.error) return (errorEl.textContent = res.error);
|
||||
payload.rental = res.rental; // object or null
|
||||
if (res.pickupDate) payload.date = res.pickupDate;
|
||||
} else {
|
||||
payload.rental = null;
|
||||
}
|
||||
|
||||
// Cost fields: price is the toggle. When set, send the full cost set;
|
||||
// when blank, send price:null (clears any prior cost) and omit the rest.
|
||||
const costRes = costForm.read();
|
||||
if (costRes.error) return (errorEl.textContent = costRes.error);
|
||||
payload.price = costRes.price != null ? costRes.price : null;
|
||||
if (costRes.price != null) {
|
||||
payload.paid_by = costRes.paid_by;
|
||||
payload.split_mode = costRes.split_mode;
|
||||
payload.participants = costRes.participants;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Saving…';
|
||||
try {
|
||||
if (editing) await api.entries.update(editing, payload);
|
||||
else await api.entries.create(tctx.tripId, payload);
|
||||
toast(editing ? 'Entry updated' : 'Entry added', 'success');
|
||||
editing = null;
|
||||
loc = null;
|
||||
await tctx.refreshTrip(); // triggers _onModalRefresh -> draw()
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = editing ? 'Save entry' : 'Add entry';
|
||||
}
|
||||
}
|
||||
|
||||
const cancelEdit = editing
|
||||
? el('button', {
|
||||
class: 'btn btn-ghost',
|
||||
type: 'button',
|
||||
onClick: () => { editing = null; loc = null; draw(); },
|
||||
}, 'Cancel edit')
|
||||
: null;
|
||||
|
||||
const form = el(
|
||||
'form',
|
||||
{ class: 'entry-form', onSubmit },
|
||||
el('h3', {}, editing ? 'Edit entry' : 'Add entry'),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'form-row' },
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Type'), typeSelect),
|
||||
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Title'), titleInput),
|
||||
),
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Details'), detailsInput),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'form-row' },
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Start time'), startInput),
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'End time'), endInput),
|
||||
),
|
||||
flightSection,
|
||||
rentalSection,
|
||||
locationField,
|
||||
el('div', { class: 'cost-heading' }, 'Cost (optional)'),
|
||||
costForm.node,
|
||||
errorEl,
|
||||
el('div', { class: 'form-actions' }, cancelEdit, submitBtn),
|
||||
);
|
||||
|
||||
renderLoc();
|
||||
syncTypeUI();
|
||||
return form;
|
||||
}
|
||||
|
||||
function renderLoc() {
|
||||
const box = fields.locSelected;
|
||||
if (!box) return;
|
||||
clear(box);
|
||||
if (loc) {
|
||||
box.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'loc-chip' },
|
||||
el('span', {}, `📍 ${loc.name}`),
|
||||
el('button', {
|
||||
class: 'loc-clear',
|
||||
type: 'button',
|
||||
title: 'Clear location',
|
||||
onClick: () => { loc = null; fields.locInput.value = ''; renderLoc(); },
|
||||
}, '×'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function wireGeocode(input, resultsBox) {
|
||||
let timer = null;
|
||||
let seq = 0;
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.trim();
|
||||
clearTimeout(timer);
|
||||
if (q.length < 2) {
|
||||
clear(resultsBox);
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(async () => {
|
||||
const mySeq = ++seq;
|
||||
resultsBox.classList.add('loading');
|
||||
try {
|
||||
const data = await api.geocode(q);
|
||||
if (mySeq !== seq) return; // a newer query superseded this one
|
||||
showResults(data.results || [], resultsBox);
|
||||
} catch (err) {
|
||||
if (mySeq !== seq) return;
|
||||
clear(resultsBox);
|
||||
toast(err.message || 'Location search failed');
|
||||
} finally {
|
||||
resultsBox.classList.remove('loading');
|
||||
}
|
||||
}, 400);
|
||||
});
|
||||
}
|
||||
|
||||
function showResults(results, resultsBox) {
|
||||
clear(resultsBox);
|
||||
if (!results.length) {
|
||||
resultsBox.appendChild(el('div', { class: 'loc-empty muted' }, 'No matches'));
|
||||
return;
|
||||
}
|
||||
for (const r of results) {
|
||||
resultsBox.appendChild(
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
class: 'loc-result',
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
loc = { name: r.name, lat: r.lat, lng: r.lng };
|
||||
fields.locInput.value = '';
|
||||
clear(resultsBox);
|
||||
renderLoc();
|
||||
},
|
||||
},
|
||||
el('span', { class: 'loc-result-name' }, r.name),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(entry) {
|
||||
if (!window.confirm(`Delete "${entry.title}"?`)) return;
|
||||
try {
|
||||
await api.entries.remove(entry.id);
|
||||
toast('Entry deleted', 'success');
|
||||
if (editing === entry.id) { editing = null; loc = null; }
|
||||
await tctx.refreshTrip();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
draw();
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Split-flap flip-clock countdown to a trip's start. Vanilla JS + CSS, no
|
||||
// library. The static top/bottom halves ALWAYS show the current digit (so the
|
||||
// number is correct even if the flip animation is interrupted); the flip
|
||||
// layers are a cosmetic overlay.
|
||||
//
|
||||
// Lifecycle: only one countdown is ever mounted, so the 1s interval id lives in
|
||||
// module state. renderCountdown clears any previous timer before starting a new
|
||||
// one (so refreshTrip's re-render never leaks or double-ticks), and a hashchange
|
||||
// handler stops it on navigation away.
|
||||
import { el, clear } from '../dom.js';
|
||||
import { parseYMD, ymd, daysBetweenInclusive } from '../format.js';
|
||||
|
||||
const UNITS = [
|
||||
{ key: 'days', label: 'Days' },
|
||||
{ key: 'hours', label: 'Hours' },
|
||||
{ key: 'mins', label: 'Min' },
|
||||
{ key: 'secs', label: 'Sec' },
|
||||
];
|
||||
|
||||
let activeTimer = null;
|
||||
let activeHashHandler = null;
|
||||
|
||||
export function stopCountdown() {
|
||||
if (activeTimer) {
|
||||
clearInterval(activeTimer);
|
||||
activeTimer = null;
|
||||
}
|
||||
if (activeHashHandler) {
|
||||
window.removeEventListener('hashchange', activeHashHandler);
|
||||
activeHashHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderCountdown(tctx) {
|
||||
stopCountdown();
|
||||
const trip = tctx.trip.trip;
|
||||
const section = el('section', { class: 'countdown-section' });
|
||||
|
||||
function paint() {
|
||||
const state = computeState(trip);
|
||||
if (state.kind === 'future') {
|
||||
buildClock(section, trip, tctx);
|
||||
} else if (state.kind === 'ongoing') {
|
||||
clear(section);
|
||||
section.appendChild(el('div', { class: 'countdown-badge ongoing' },
|
||||
el('span', { class: 'cd-badge-icon' }, '✈'),
|
||||
el('span', {}, `Day ${state.dayN} of ${state.total}`)));
|
||||
} else {
|
||||
clear(section);
|
||||
section.appendChild(el('div', { class: 'countdown-badge done' },
|
||||
el('span', { class: 'cd-badge-icon' }, '🏁'),
|
||||
el('span', {}, 'Trip completed')));
|
||||
}
|
||||
}
|
||||
|
||||
paint();
|
||||
return section;
|
||||
}
|
||||
|
||||
function computeState(trip) {
|
||||
const today = ymd(new Date());
|
||||
if (today < trip.start_date) return { kind: 'future' };
|
||||
if (today <= trip.end_date) {
|
||||
return {
|
||||
kind: 'ongoing',
|
||||
dayN: daysBetweenInclusive(trip.start_date, today),
|
||||
total: daysBetweenInclusive(trip.start_date, trip.end_date),
|
||||
};
|
||||
}
|
||||
return { kind: 'past' };
|
||||
}
|
||||
|
||||
function buildClock(section, trip, tctx) {
|
||||
const target = parseYMD(trip.start_date); // local midnight of the start day
|
||||
clear(section);
|
||||
|
||||
const groups = {};
|
||||
const row = el('div', { class: 'flipclock' });
|
||||
UNITS.forEach((u, i) => {
|
||||
if (i > 0) row.appendChild(el('div', { class: 'fc-sep' }, ':'));
|
||||
const digitsWrap = el('div', { class: 'fc-digits' });
|
||||
// days can be 3 wide; the rest are 2. Digit cards are created lazily to
|
||||
// match the current width so a 3-digit day count still renders.
|
||||
groups[u.key] = { wrap: digitsWrap, cards: [] };
|
||||
row.appendChild(
|
||||
el('div', { class: 'fc-group' }, digitsWrap, el('div', { class: 'fc-label' }, u.label)),
|
||||
);
|
||||
});
|
||||
|
||||
section.appendChild(el('p', { class: 'countdown-caption' }, 'until departure'));
|
||||
section.appendChild(row);
|
||||
|
||||
function values() {
|
||||
const totalSec = Math.max(0, Math.floor((target.getTime() - Date.now()) / 1000));
|
||||
return {
|
||||
days: Math.floor(totalSec / 86400),
|
||||
hours: Math.floor((totalSec % 86400) / 3600),
|
||||
mins: Math.floor((totalSec % 3600) / 60),
|
||||
secs: totalSec % 60,
|
||||
totalSec,
|
||||
};
|
||||
}
|
||||
|
||||
function update() {
|
||||
const v = values();
|
||||
setGroup(groups.days, v.days, Math.max(2, String(v.days).length));
|
||||
setGroup(groups.hours, v.hours, 2);
|
||||
setGroup(groups.mins, v.mins, 2);
|
||||
setGroup(groups.secs, v.secs, 2);
|
||||
if (v.totalSec <= 0) {
|
||||
// Departure reached — swap to the "ongoing" badge (no network needed).
|
||||
stopCountdown();
|
||||
renderInto(section, tctx);
|
||||
}
|
||||
}
|
||||
|
||||
update();
|
||||
activeTimer = setInterval(update, 1000);
|
||||
activeHashHandler = stopCountdown;
|
||||
window.addEventListener('hashchange', activeHashHandler);
|
||||
}
|
||||
|
||||
// Re-run the whole countdown paint (used at the zero-crossing).
|
||||
function renderInto(section, tctx) {
|
||||
const trip = tctx.trip.trip;
|
||||
const state = computeState(trip);
|
||||
clear(section);
|
||||
if (state.kind === 'ongoing') {
|
||||
section.appendChild(el('div', { class: 'countdown-badge ongoing' },
|
||||
el('span', { class: 'cd-badge-icon' }, '✈'),
|
||||
el('span', {}, `Day ${state.dayN} of ${state.total}`)));
|
||||
} else {
|
||||
section.appendChild(el('div', { class: 'countdown-badge done' },
|
||||
el('span', { class: 'cd-badge-icon' }, '🏁'),
|
||||
el('span', {}, 'Trip completed')));
|
||||
}
|
||||
}
|
||||
|
||||
function setGroup(group, value, width) {
|
||||
const str = String(value).padStart(width, '0');
|
||||
// Rebuild the card set if the digit count changed (e.g. 100 -> 99 days).
|
||||
if (group.cards.length !== str.length) {
|
||||
clear(group.wrap);
|
||||
group.cards = [];
|
||||
for (const ch of str) {
|
||||
const card = makeCard(ch);
|
||||
group.cards.push(card);
|
||||
group.wrap.appendChild(card.node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
str.split('').forEach((ch, i) => setDigit(group.cards[i], ch));
|
||||
}
|
||||
|
||||
function makeCard(digit) {
|
||||
const top = face('fc-top', digit);
|
||||
const bottom = face('fc-bottom', digit);
|
||||
const flipTop = face('fc-flip fc-flip-top', digit);
|
||||
const flipBottom = face('fc-flip fc-flip-bottom', digit);
|
||||
const node = el('div', { class: 'fc-card' }, top, bottom, flipTop, flipBottom);
|
||||
const card = { node, value: digit, top, bottom, flipTop, flipBottom };
|
||||
card.node.addEventListener('animationend', (e) => {
|
||||
if (e.animationName === 'fc-bottom') card.node.classList.remove('fc-flipping');
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function face(cls, digit) {
|
||||
return el('div', { class: `fc-face ${cls}` }, el('b', {}, digit));
|
||||
}
|
||||
|
||||
function setDigit(card, next) {
|
||||
if (card.value === next) return;
|
||||
const prev = card.value;
|
||||
card.value = next;
|
||||
// Static halves show the new digit immediately (correctness guaranteed).
|
||||
digitText(card.top, next);
|
||||
digitText(card.bottom, next);
|
||||
// Cosmetic flip: old top falls, new bottom rises.
|
||||
digitText(card.flipTop, prev);
|
||||
digitText(card.flipBottom, next);
|
||||
card.node.classList.remove('fc-flipping');
|
||||
void card.node.offsetWidth; // restart the animation
|
||||
card.node.classList.add('fc-flipping');
|
||||
}
|
||||
|
||||
function digitText(face, ch) {
|
||||
face.firstChild.textContent = ch;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// Leaflet map: numbered markers for located stops, a polyline through them,
|
||||
// per-leg km labels, and a leg-by-leg list. Graceful empty state when the
|
||||
// trip has no located entries yet. Uses the /route response from tctx.route.
|
||||
import { el } from '../dom.js';
|
||||
import { typeInfo } from '../format.js';
|
||||
|
||||
export function renderMap(tctx) {
|
||||
const route = tctx.route || { stops: [], legs: [], totalKm: 0 };
|
||||
const stops = route.stops || [];
|
||||
|
||||
const section = el('section', { class: 'card map-section' });
|
||||
section.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'section-head' },
|
||||
el('h2', {}, 'Map'),
|
||||
el('p', { class: 'muted' }, stops.length
|
||||
? `${stops.length} located ${stops.length === 1 ? 'stop' : 'stops'} · ${fmtKm(route.totalKm)} km total`
|
||||
: 'Add a location to an entry to see it here.'),
|
||||
),
|
||||
);
|
||||
|
||||
if (!stops.length) {
|
||||
section.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'map-empty' },
|
||||
el('div', { class: 'empty-icon' }, '🗺️'),
|
||||
el('p', {}, 'No located entries yet.'),
|
||||
el('p', { class: 'muted' }, 'Open a day, add an entry, and give it a location to plot it on the map.'),
|
||||
),
|
||||
);
|
||||
return section;
|
||||
}
|
||||
|
||||
const mapDiv = el('div', { class: 'leaflet-map', id: `map-${tctx.tripId}` });
|
||||
section.appendChild(mapDiv);
|
||||
section.appendChild(legList(route, stops));
|
||||
|
||||
// Leaflet needs the container attached with a real size, so init on the
|
||||
// next tick after this section is mounted into the page.
|
||||
setTimeout(() => initMap(mapDiv, route, stops), 0);
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
function initMap(mapDiv, route, stops) {
|
||||
const L = window.L;
|
||||
if (!L) {
|
||||
mapDiv.appendChild(el('p', { class: 'muted' }, 'Map library failed to load.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const map = L.map(mapDiv, { scrollWheelZoom: true });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
// Geometry is driven by stop ORDER, not entryId: a multi-leg flight expands
|
||||
// into several airport stops that all share the same entryId, so keying by
|
||||
// entryId would collapse them.
|
||||
const points = stops.map((s) => [s.lat, s.lng]);
|
||||
stops.forEach((stop, i) => {
|
||||
const info = typeInfo(stop.type);
|
||||
L.marker(points[i], { icon: numberedIcon(L, i + 1, info.color) })
|
||||
.addTo(map)
|
||||
.bindPopup(popupHtml(stop, info));
|
||||
});
|
||||
|
||||
// One polyline per measured leg, styled by mode (air = dashed blue, ground =
|
||||
// solid teal), each with a km label at its midpoint. Legs are matched to
|
||||
// consecutive non-coincident stop pairs (skip <0.05km, the server's rule).
|
||||
const legs = route.legs || [];
|
||||
let li = 0;
|
||||
for (let i = 0; i < stops.length - 1 && li < legs.length; i++) {
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
if (haversineKm(a[0], a[1], b[0], b[1]) < 0.05) continue;
|
||||
const leg = legs[li];
|
||||
li += 1;
|
||||
const air = leg.mode === 'air';
|
||||
L.polyline([a, b], {
|
||||
color: air ? '#2563eb' : '#0f766e',
|
||||
weight: 3,
|
||||
opacity: 0.75,
|
||||
dashArray: air ? '6 6' : null,
|
||||
}).addTo(map);
|
||||
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
L.marker(mid, { icon: kmLabel(L, leg.km), interactive: false }).addTo(map);
|
||||
}
|
||||
|
||||
if (points.length === 1) {
|
||||
map.setView(points[0], 10);
|
||||
} else {
|
||||
map.fitBounds(L.latLngBounds(points).pad(0.2));
|
||||
}
|
||||
map.invalidateSize();
|
||||
}
|
||||
|
||||
// Great-circle km — mirrors the server rule for skipping zero-distance legs.
|
||||
function haversineKm(lat1, lng1, lat2, lng2) {
|
||||
const R = 6371;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
function numberedIcon(L, n, color) {
|
||||
return L.divIcon({
|
||||
className: 'map-pin-wrap',
|
||||
html: `<span class="map-pin" style="background:${color}">${n}</span>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 26],
|
||||
popupAnchor: [0, -24],
|
||||
});
|
||||
}
|
||||
|
||||
function kmLabel(L, km) {
|
||||
return L.divIcon({
|
||||
className: 'km-label-wrap',
|
||||
html: `<span class="km-label">${fmtKm(km)} km</span>`,
|
||||
iconSize: [0, 0],
|
||||
});
|
||||
}
|
||||
|
||||
// Built from server-provided fields; escape to keep the popup injection-safe.
|
||||
function popupHtml(stop, info) {
|
||||
const isAirport = stop.kind === 'airport' && stop.code;
|
||||
const heading = isAirport ? `${info.icon} ${esc(stop.code)}` : `${info.icon} ${esc(stop.title)}`;
|
||||
const sub = isAirport
|
||||
? `${esc(info.label)} · ${esc(stop.date)}${stop.title ? ` · ${esc(stop.title)}` : ''}`
|
||||
: `${esc(info.label)} · ${esc(stop.date)}`;
|
||||
const locIcon = isAirport ? '✈️' : '📍';
|
||||
return (
|
||||
`<div class="map-popup">` +
|
||||
`<strong>${heading}</strong>` +
|
||||
`<div class="map-popup-sub">${sub}</div>` +
|
||||
(stop.location_name ? `<div class="map-popup-loc">${locIcon} ${esc(stop.location_name)}</div>` : '') +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
function stopLabel(stop) {
|
||||
if (stop.kind === 'airport' && stop.code) return stop.code;
|
||||
return stop.location_name || stop.title || '?';
|
||||
}
|
||||
|
||||
function legList(route, stops) {
|
||||
const legs = route.legs || [];
|
||||
if (!legs.length) return el('div', { class: 'leg-list-empty muted' }, 'A single stop — no legs to measure yet.');
|
||||
|
||||
const wrap = el('div', { class: 'leg-list' }, el('h3', {}, 'Legs'));
|
||||
let li = 0;
|
||||
let shown = 0;
|
||||
for (let i = 0; i < stops.length - 1 && li < legs.length; i++) {
|
||||
const a = stops[i];
|
||||
const b = stops[i + 1];
|
||||
if (haversineKm(a.lat, a.lng, b.lat, b.lng) < 0.05) continue;
|
||||
const leg = legs[li++];
|
||||
shown += 1;
|
||||
const air = leg.mode === 'air';
|
||||
wrap.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'leg-row' },
|
||||
el('span', { class: 'leg-index' }, String(shown)),
|
||||
el('span', { class: 'leg-mode', title: air ? 'Flight' : 'Ground' }, air ? '✈️' : '🚗'),
|
||||
el('span', { class: 'leg-path' }, stopLabel(a), ' → ', stopLabel(b)),
|
||||
el('span', { class: 'leg-km' }, `${fmtKm(leg.km)} km`),
|
||||
),
|
||||
);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function fmtKm(n) {
|
||||
return (Math.round((Number(n) || 0) * 10) / 10).toLocaleString();
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
return String(str == null ? '' : str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Rental-car details subsection used inside the day editor for rental entries,
|
||||
// plus a compact one-line renderer for the entry list. Pickup/dropoff use
|
||||
// plain-text location names only (they do not feed the route), so there is no
|
||||
// geocode/lat-lng UI here. Kept separate so dayEditor.js stays small.
|
||||
import { el } from '../dom.js';
|
||||
import { formatDate } from '../format.js';
|
||||
|
||||
// createRentalDetails({ initial, entryDate, onChange }) ->
|
||||
// { node, read(), load(rental) }
|
||||
// read() -> { rental: {...} | null, pickupDate } or { error }.
|
||||
export function createRentalDetails({ initial = null, entryDate, onChange = () => {} } = {}) {
|
||||
const brandI = textInput('Brand (e.g. Toyota)', 60);
|
||||
const modelI = textInput('Model (e.g. Yaris Cross)', 60);
|
||||
const carTypeI = textInput('Type (e.g. SUV)', 40);
|
||||
const bookingI = textInput('Booking ref', 60);
|
||||
const includedI = el('input', { class: 'input', type: 'number', min: '0', step: '1', placeholder: 'e.g. 1500' });
|
||||
|
||||
const pDate = el('input', { class: 'input', type: 'date' });
|
||||
const pTime = el('input', { class: 'input', type: 'time' });
|
||||
const pLoc = textInput('Location (free text)', 120);
|
||||
const dDate = el('input', { class: 'input', type: 'date' });
|
||||
const dTime = el('input', { class: 'input', type: 'time' });
|
||||
const dLoc = textInput('Location (free text)', 120);
|
||||
|
||||
for (const inp of [brandI, modelI, carTypeI, bookingI, includedI, pDate, pTime, pLoc, dDate, dTime, dLoc]) {
|
||||
inp.addEventListener('input', onChange);
|
||||
}
|
||||
|
||||
const node = el(
|
||||
'div',
|
||||
{ class: 'rental-details' },
|
||||
el('div', { class: 'form-row' },
|
||||
field('Brand', brandI), field('Model', modelI)),
|
||||
el('div', { class: 'form-row' },
|
||||
field('Car type', carTypeI), field('Booking ref', bookingI),
|
||||
field('Included km', includedI)),
|
||||
el('div', { class: 'rental-blocks' },
|
||||
rentalBlock('Pickup', pDate, pTime, pLoc),
|
||||
rentalBlock('Dropoff', dDate, dTime, dLoc)),
|
||||
);
|
||||
|
||||
function read() {
|
||||
const brand = brandI.value.trim();
|
||||
const model = modelI.value.trim();
|
||||
const car_type = carTypeI.value.trim();
|
||||
const booking_ref = bookingI.value.trim();
|
||||
const includedRaw = includedI.value.trim();
|
||||
const pickupDate = pDate.value || entryDate;
|
||||
const dropoffDate = dDate.value || pickupDate;
|
||||
|
||||
const anyContent = brand || model || car_type || booking_ref || includedRaw ||
|
||||
pTime.value || pLoc.value.trim() || dTime.value || dLoc.value.trim();
|
||||
if (!anyContent) return { rental: null, pickupDate };
|
||||
|
||||
let included_km = null;
|
||||
if (includedRaw !== '') {
|
||||
const n = Number(includedRaw);
|
||||
if (!Number.isFinite(n) || n < 0) return { error: 'Included km must be a number ≥ 0.' };
|
||||
included_km = n;
|
||||
}
|
||||
|
||||
const rental = {};
|
||||
if (brand) rental.brand = brand;
|
||||
if (model) rental.model = model;
|
||||
if (car_type) rental.car_type = car_type;
|
||||
if (booking_ref) rental.booking_ref = booking_ref;
|
||||
if (included_km != null) rental.included_km = included_km;
|
||||
rental.pickup = { date: pickupDate };
|
||||
if (pTime.value) rental.pickup.time = pTime.value;
|
||||
if (pLoc.value.trim()) rental.pickup.location_name = pLoc.value.trim();
|
||||
rental.dropoff = { date: dropoffDate };
|
||||
if (dTime.value) rental.dropoff.time = dTime.value;
|
||||
if (dLoc.value.trim()) rental.dropoff.location_name = dLoc.value.trim();
|
||||
return { rental, pickupDate };
|
||||
}
|
||||
|
||||
function load(rental) {
|
||||
const r = rental || {};
|
||||
brandI.value = r.brand || '';
|
||||
modelI.value = r.model || '';
|
||||
carTypeI.value = r.car_type || '';
|
||||
bookingI.value = r.booking_ref || '';
|
||||
includedI.value = r.included_km != null ? String(r.included_km) : '';
|
||||
const p = r.pickup || {};
|
||||
pDate.value = p.date || entryDate || '';
|
||||
pTime.value = p.time || '';
|
||||
pLoc.value = p.location_name || '';
|
||||
const d = r.dropoff || {};
|
||||
dDate.value = d.date || p.date || entryDate || '';
|
||||
dTime.value = d.time || '';
|
||||
dLoc.value = d.location_name || '';
|
||||
onChange();
|
||||
}
|
||||
|
||||
// Initial values (defaults: both dates to the entry's day).
|
||||
load(initial || { pickup: { date: entryDate }, dropoff: { date: entryDate } });
|
||||
|
||||
return { node, read, load };
|
||||
}
|
||||
|
||||
// "🚙 Toyota Yaris Cross · pickup 09:00 CNX Airport → dropoff 7 Aug 18:00 · 1,500 km included · RC-889231"
|
||||
export function renderRentalLine(rental) {
|
||||
const car = [rental.brand, rental.model].filter(Boolean).join(' ') || 'Rental car';
|
||||
const p = rental.pickup || {};
|
||||
const d = rental.dropoff || {};
|
||||
const pickup = ['pickup', p.time, p.location_name].filter(Boolean).join(' ');
|
||||
const dropoff = ['dropoff', d.date ? formatDate(d.date) : null, d.time, d.location_name].filter(Boolean).join(' ');
|
||||
const bits = [`🚙 ${car}`, `${pickup} → ${dropoff}`];
|
||||
if (rental.included_km != null) bits.push(`${Number(rental.included_km).toLocaleString()} km included`);
|
||||
if (rental.booking_ref) bits.push(rental.booking_ref);
|
||||
return el('div', { class: 'entry-rental muted' }, bits.join(' · '));
|
||||
}
|
||||
|
||||
function textInput(placeholder, maxlength) {
|
||||
return el('input', { class: 'input', type: 'text', maxlength: String(maxlength), placeholder });
|
||||
}
|
||||
|
||||
function field(label, input) {
|
||||
return el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, label), input);
|
||||
}
|
||||
|
||||
function rentalBlock(title, dateInput, timeInput, locInput) {
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'rental-block' },
|
||||
el('h4', { class: 'rental-block-title' }, title),
|
||||
el('div', { class: 'form-row' },
|
||||
field('Date', dateInput), field('Time', timeInput)),
|
||||
field('Location', locInput),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// Flight-route builder used inside the day editor for flight entries.
|
||||
// Provides a quick "CNX-BKK-DXB-FRA" expander plus editable per-leg rows with
|
||||
// airport-code autocomplete backed by GET /api/airports. Kept as its own
|
||||
// module so dayEditor.js stays small.
|
||||
import { el, clear, toast } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
import { formatTimeRange } from '../format.js';
|
||||
|
||||
const CODE_RE = /^[A-Z0-9]{2,4}$/;
|
||||
|
||||
// Per-segment display lines for an entry row: "TG103 CNX→BKK 10:30–11:45".
|
||||
export function renderSegmentLines(segments) {
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'entry-segments' },
|
||||
...segments.map((s) => {
|
||||
const legTime = formatTimeRange(s.dep_time, s.arr_time);
|
||||
const codes = `${s.from?.code || '?'}→${s.to?.code || '?'}`;
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'entry-seg muted' },
|
||||
s.flight_no ? el('span', { class: 'seg-flight' }, s.flight_no) : null,
|
||||
el('span', {}, codes),
|
||||
legTime ? el('span', {}, legTime) : null,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// createFlightRoute({ initialSegments, onChange }) ->
|
||||
// { node, read(), hasSegments() }
|
||||
// read() returns { segments: [...] | null } or { error: 'message' }.
|
||||
export function createFlightRoute({ initialSegments = null, onChange = () => {} } = {}) {
|
||||
const rows = []; // { state:{flight_no,dep_time,arr_time,from,to}, node, refs }
|
||||
const listEl = el('div', { class: 'seg-list' });
|
||||
|
||||
const quickInput = el('input', {
|
||||
class: 'input seg-quick-input',
|
||||
type: 'text',
|
||||
placeholder: 'CNX-BKK-DXB-FRA',
|
||||
autocomplete: 'off',
|
||||
spellcheck: 'false',
|
||||
'aria-label': 'Airport code chain',
|
||||
});
|
||||
const buildBtn = el('button', { class: 'btn btn-sm', type: 'button' }, 'Build route');
|
||||
buildBtn.addEventListener('click', () => buildFromChain(quickInput.value));
|
||||
|
||||
const addBtn = el('button', { class: 'btn btn-sm btn-ghost', type: 'button' }, '+ Add leg');
|
||||
addBtn.addEventListener('click', () => {
|
||||
const prev = rows[rows.length - 1];
|
||||
addRow(prev ? { from: { ...prev.state.to } } : {});
|
||||
renumber();
|
||||
onChange();
|
||||
});
|
||||
|
||||
const node = el(
|
||||
'div',
|
||||
{ class: 'flight-route' },
|
||||
el('div', { class: 'seg-quick' },
|
||||
el('label', { class: 'field field-grow' },
|
||||
el('span', { class: 'field-label' }, 'Quick route (airport codes)'),
|
||||
quickInput),
|
||||
buildBtn),
|
||||
listEl,
|
||||
addBtn,
|
||||
);
|
||||
|
||||
function emptyAirport() {
|
||||
return { code: '', name: '', lat: null, lng: null };
|
||||
}
|
||||
|
||||
function addRow(seg = {}) {
|
||||
const state = {
|
||||
flight_no: seg.flight_no || '',
|
||||
dep_time: seg.dep_time || '',
|
||||
arr_time: seg.arr_time || '',
|
||||
from: { ...emptyAirport(), ...(seg.from || {}) },
|
||||
to: { ...emptyAirport(), ...(seg.to || {}) },
|
||||
};
|
||||
const { rowNode, refs } = buildRowDom(state);
|
||||
const row = { state, node: rowNode, refs };
|
||||
rows.push(row);
|
||||
listEl.appendChild(rowNode);
|
||||
refreshRowLabels(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function removeRow(row) {
|
||||
const i = rows.indexOf(row);
|
||||
if (i === -1) return;
|
||||
rows.splice(i, 1);
|
||||
row.node.remove();
|
||||
renumber();
|
||||
onChange();
|
||||
}
|
||||
|
||||
function renumber() {
|
||||
rows.forEach((r, i) => { r.refs.num.textContent = String(i + 1); });
|
||||
}
|
||||
|
||||
function buildRowDom(state) {
|
||||
const num = el('span', { class: 'seg-num' }, '1');
|
||||
const flightNo = el('input', { class: 'input input-sm seg-flightno', type: 'text', maxlength: '12', placeholder: 'Flight no.', value: state.flight_no });
|
||||
flightNo.addEventListener('input', () => { state.flight_no = flightNo.value; });
|
||||
|
||||
const from = buildAirportField(state.from, 'From');
|
||||
const to = buildAirportField(state.to, 'To');
|
||||
|
||||
const dep = el('input', { class: 'input input-sm', type: 'time', 'aria-label': 'Departure time' });
|
||||
dep.value = state.dep_time || '';
|
||||
dep.addEventListener('input', () => { state.dep_time = dep.value; });
|
||||
const arr = el('input', { class: 'input input-sm', type: 'time', 'aria-label': 'Arrival time' });
|
||||
arr.value = state.arr_time || '';
|
||||
arr.addEventListener('input', () => { state.arr_time = arr.value; });
|
||||
|
||||
const removeBtn = el('button', { class: 'icon-btn danger', type: 'button', title: 'Remove leg' }, '×');
|
||||
|
||||
const rowNode = el(
|
||||
'div',
|
||||
{ class: 'seg-row' },
|
||||
el('div', { class: 'seg-row-head' }, num, flightNo, removeBtn),
|
||||
el('div', { class: 'seg-airports' }, from.field, el('span', { class: 'seg-arrow' }, '→'), to.field),
|
||||
el('div', { class: 'seg-times' },
|
||||
el('label', { class: 'seg-time' }, el('span', {}, 'Dep'), dep),
|
||||
el('label', { class: 'seg-time' }, el('span', {}, 'Arr'), arr)),
|
||||
);
|
||||
|
||||
const refs = { num, from, to };
|
||||
removeBtn.addEventListener('click', () => {
|
||||
const row = rows.find((r) => r.node === rowNode);
|
||||
if (row) removeRow(row);
|
||||
});
|
||||
return { rowNode, refs };
|
||||
}
|
||||
|
||||
// One airport code input + autocomplete dropdown + resolved-name line.
|
||||
function buildAirportField(airportState, label) {
|
||||
const input = el('input', {
|
||||
class: 'input input-sm seg-code',
|
||||
type: 'text',
|
||||
maxlength: '4',
|
||||
placeholder: label === 'From' ? 'From (e.g. CNX)' : 'To (e.g. BKK)',
|
||||
autocomplete: 'off',
|
||||
spellcheck: 'false',
|
||||
'aria-label': `${label} airport code`,
|
||||
});
|
||||
input.value = airportState.code || '';
|
||||
const results = el('div', { class: 'ap-results' });
|
||||
const nameLine = el('span', { class: 'ap-name' });
|
||||
const field = el('div', { class: 'seg-ap' }, input, results, nameLine);
|
||||
|
||||
let timer = null;
|
||||
let seq = 0;
|
||||
input.addEventListener('input', () => {
|
||||
const val = input.value.toUpperCase();
|
||||
// Manual edit clears any previously resolved coordinates.
|
||||
airportState.code = val;
|
||||
airportState.name = '';
|
||||
airportState.lat = null;
|
||||
airportState.lng = null;
|
||||
setNameLine(nameLine, airportState);
|
||||
clearTimeout(timer);
|
||||
const q = input.value.trim();
|
||||
if (q.length < 2) { clear(results); return; }
|
||||
timer = setTimeout(async () => {
|
||||
const mine = ++seq;
|
||||
try {
|
||||
const data = await api.airports(q);
|
||||
if (mine !== seq) return;
|
||||
showAirportResults(results, data.results || [], (ap) => {
|
||||
applyAirport(airportState, ap);
|
||||
input.value = ap.code;
|
||||
clear(results);
|
||||
setNameLine(nameLine, airportState);
|
||||
onChange();
|
||||
});
|
||||
} catch (err) {
|
||||
if (mine !== seq) return;
|
||||
clear(results);
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
return { field, input, nameLine, state: airportState };
|
||||
}
|
||||
|
||||
function refreshRowLabels(row) {
|
||||
setNameLine(row.refs.from.nameLine, row.state.from);
|
||||
setNameLine(row.refs.to.nameLine, row.state.to);
|
||||
}
|
||||
|
||||
// Quick-build: split codes, make N-1 legs, resolve each code to coords.
|
||||
async function buildFromChain(raw) {
|
||||
const codes = String(raw || '')
|
||||
.split(/[\s,>\-]+/)
|
||||
.map((s) => s.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
if (codes.length < 2) {
|
||||
toast('Enter at least two airport codes, e.g. CNX-BKK');
|
||||
return;
|
||||
}
|
||||
if (codes.length - 1 > 8) {
|
||||
toast('A flight can have at most 8 legs.');
|
||||
return;
|
||||
}
|
||||
// Replace existing rows.
|
||||
rows.slice().forEach((r) => { r.node.remove(); });
|
||||
rows.length = 0;
|
||||
for (let i = 0; i < codes.length - 1; i++) {
|
||||
addRow({ from: { code: codes[i] }, to: { code: codes[i + 1] } });
|
||||
}
|
||||
renumber();
|
||||
onChange();
|
||||
|
||||
// Resolve unique codes once, then apply to every matching field.
|
||||
const unique = [...new Set(codes)];
|
||||
const resolved = {};
|
||||
await Promise.all(unique.map(async (code) => {
|
||||
resolved[code] = await resolveCode(code);
|
||||
}));
|
||||
for (const row of rows) {
|
||||
applyResolved(row.state.from, resolved[row.state.from.code], row.refs.from);
|
||||
applyResolved(row.state.to, resolved[row.state.to.code], row.refs.to);
|
||||
}
|
||||
onChange();
|
||||
}
|
||||
|
||||
function applyResolved(airportState, ap, ref) {
|
||||
if (ap) {
|
||||
applyAirport(airportState, ap);
|
||||
ref.input.value = ap.code;
|
||||
ref.field.classList.remove('ap-unresolved');
|
||||
} else {
|
||||
// Keep the typed code but flag that it has no coordinates.
|
||||
ref.field.classList.add('ap-unresolved');
|
||||
}
|
||||
setNameLine(ref.nameLine, airportState);
|
||||
}
|
||||
|
||||
async function resolveCode(code) {
|
||||
try {
|
||||
const data = await api.airports(code);
|
||||
const list = data.results || [];
|
||||
return list.find((r) => (r.code || '').toUpperCase() === code) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function read() {
|
||||
if (rows.length === 0) return { segments: null };
|
||||
if (rows.length > 8) return { error: 'A flight can have at most 8 legs.' };
|
||||
const segments = [];
|
||||
for (const r of rows) {
|
||||
const fromCode = (r.state.from.code || '').trim().toUpperCase();
|
||||
const toCode = (r.state.to.code || '').trim().toUpperCase();
|
||||
if (!CODE_RE.test(fromCode) || !CODE_RE.test(toCode)) {
|
||||
return { error: 'Every leg needs a valid from and to airport code (2–4 characters).' };
|
||||
}
|
||||
const seg = { from: airportOut(r.state.from, fromCode), to: airportOut(r.state.to, toCode) };
|
||||
if (r.state.flight_no.trim()) seg.flight_no = r.state.flight_no.trim();
|
||||
if (r.state.dep_time) seg.dep_time = r.state.dep_time;
|
||||
if (r.state.arr_time) seg.arr_time = r.state.arr_time;
|
||||
segments.push(seg);
|
||||
}
|
||||
return { segments };
|
||||
}
|
||||
|
||||
// Replace all rows from a segments array (used when editing an entry).
|
||||
function load(segments) {
|
||||
rows.slice().forEach((r) => { r.node.remove(); });
|
||||
rows.length = 0;
|
||||
if (Array.isArray(segments)) {
|
||||
for (const seg of segments) addRow(seg);
|
||||
}
|
||||
renumber();
|
||||
onChange();
|
||||
}
|
||||
|
||||
// Prefill from an existing entry's segments (edit).
|
||||
if (Array.isArray(initialSegments)) {
|
||||
for (const seg of initialSegments) addRow(seg);
|
||||
renumber();
|
||||
}
|
||||
|
||||
return { node, read, load, hasSegments: () => rows.length > 0 };
|
||||
}
|
||||
|
||||
function applyAirport(airportState, ap) {
|
||||
airportState.code = (ap.code || '').toUpperCase();
|
||||
airportState.name = ap.name || '';
|
||||
airportState.lat = ap.lat != null ? ap.lat : null;
|
||||
airportState.lng = ap.lng != null ? ap.lng : null;
|
||||
}
|
||||
|
||||
function airportOut(airportState, code) {
|
||||
const out = { code };
|
||||
if (airportState.name) out.name = airportState.name;
|
||||
if (airportState.lat != null && airportState.lng != null) {
|
||||
out.lat = airportState.lat;
|
||||
out.lng = airportState.lng;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function setNameLine(nameLine, airportState) {
|
||||
if (airportState.name) {
|
||||
nameLine.textContent = airportState.name;
|
||||
nameLine.classList.remove('muted');
|
||||
} else if (airportState.code) {
|
||||
nameLine.textContent = 'no coordinates — pick from the list';
|
||||
nameLine.classList.add('muted');
|
||||
} else {
|
||||
nameLine.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function showAirportResults(box, results, onPick) {
|
||||
clear(box);
|
||||
if (!results.length) {
|
||||
box.appendChild(el('div', { class: 'ap-empty muted' }, 'No airports found'));
|
||||
return;
|
||||
}
|
||||
for (const r of results) {
|
||||
const meta = [r.city, r.country].filter(Boolean).join(', ');
|
||||
box.appendChild(
|
||||
el('button', { class: 'ap-result', type: 'button', onClick: () => onPick(r) },
|
||||
el('span', { class: 'ap-code' }, r.code),
|
||||
el('span', { class: 'ap-desc' }, `${r.name}${meta ? ` — ${meta}` : ''}`)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Summary panel built from the /route response's `summary` block:
|
||||
// days, nights, flights, hotels, travel legs, activities, total km, and the
|
||||
// list of locations in visit order.
|
||||
import { el } from '../dom.js';
|
||||
|
||||
export function renderSummary(tctx) {
|
||||
const route = tctx.route || {};
|
||||
const s = route.summary || {
|
||||
days: 0, nights: 0, flights: 0, flightSegments: 0, hotels: 0, travelLegs: 0, activities: 0, locations: [],
|
||||
};
|
||||
const totalKm = route.totalKm || 0;
|
||||
// Show the leg count under the Flights tile only when a flight has segments.
|
||||
const flightSub = s.flightSegments > 0
|
||||
? `${s.flightSegments} ${s.flightSegments === 1 ? 'leg' : 'legs'}`
|
||||
: '';
|
||||
|
||||
const section = el('section', { class: 'card summary-section' });
|
||||
section.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'section-head' },
|
||||
el('h2', {}, 'Summary'),
|
||||
el('p', { class: 'muted' }, 'Trip at a glance.'),
|
||||
),
|
||||
);
|
||||
|
||||
const tiles = el(
|
||||
'div',
|
||||
{ class: 'stat-grid' },
|
||||
tile('🗓️', s.days, s.days === 1 ? 'Day' : 'Days'),
|
||||
tile('🌙', s.nights, s.nights === 1 ? 'Night' : 'Nights'),
|
||||
tile('✈️', s.flights, 'Flights', flightSub),
|
||||
tile('🏨', s.hotels, 'Hotels'),
|
||||
tile('🚗', s.travelLegs, 'Travel legs'),
|
||||
tile('📍', s.activities, 'Activities'),
|
||||
s.rentals > 0 ? tile('🚙', s.rentals, s.rentals === 1 ? 'Rental' : 'Rentals') : null,
|
||||
);
|
||||
section.appendChild(tiles);
|
||||
|
||||
const kmDriven = s.kmDriven || 0;
|
||||
const kmAir = s.kmAir || 0;
|
||||
const includedKm = s.includedKm != null ? s.includedKm : null;
|
||||
const kmBlock = el(
|
||||
'div',
|
||||
{ class: 'km-block' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'total-km' },
|
||||
el('span', { class: 'total-km-value' }, fmtKm(totalKm)),
|
||||
el('span', { class: 'total-km-label' }, 'km total (great-circle)'),
|
||||
),
|
||||
);
|
||||
const breakdown = el('div', { class: 'km-breakdown' });
|
||||
if (kmDriven > 0) {
|
||||
// Rental allowance (includedKm) is paired with the rough driven figure;
|
||||
// flag red when the rough estimate already exceeds the included allowance.
|
||||
const over = includedKm != null && kmDriven > includedKm;
|
||||
const drivenText = includedKm != null
|
||||
? `≈ ${fmtKm(kmDriven)} km / ${fmtKm(includedKm)} incl.`
|
||||
: `≈ ${fmtKm(kmDriven)} km`;
|
||||
breakdown.appendChild(kmRow('🚗', drivenText, 'driven',
|
||||
over
|
||||
? 'Rough great-circle estimate already exceeds the included allowance — real road km will be higher'
|
||||
: 'Rough great-circle estimate — not routed driving distance',
|
||||
over ? 'km-over' : ''));
|
||||
}
|
||||
if (kmAir > 0) {
|
||||
breakdown.appendChild(kmRow('✈️', `${fmtKm(kmAir)} km`, 'flown',
|
||||
'Great-circle distance between airports'));
|
||||
}
|
||||
if (breakdown.childElementCount) kmBlock.appendChild(breakdown);
|
||||
section.appendChild(kmBlock);
|
||||
|
||||
const locations = s.locations || [];
|
||||
const locBlock = el('div', { class: 'loc-block' }, el('h3', {}, 'Locations in order'));
|
||||
if (!locations.length) {
|
||||
locBlock.appendChild(el('p', { class: 'muted' }, 'No located stops yet.'));
|
||||
} else {
|
||||
const ol = el('ol', { class: 'loc-order' });
|
||||
for (const name of locations) ol.appendChild(el('li', {}, name));
|
||||
locBlock.appendChild(ol);
|
||||
}
|
||||
section.appendChild(locBlock);
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
function kmRow(icon, valueText, label, title, cls = '') {
|
||||
return el(
|
||||
'div',
|
||||
{ class: `km-row${cls ? ` ${cls}` : ''}`, title },
|
||||
el('span', { class: 'km-row-icon' }, icon),
|
||||
el('span', { class: 'km-row-value' }, valueText),
|
||||
el('span', { class: 'km-row-label muted' }, label),
|
||||
);
|
||||
}
|
||||
|
||||
function tile(icon, value, label, sub) {
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'stat-tile' },
|
||||
el('span', { class: 'stat-icon' }, icon),
|
||||
el('span', { class: 'stat-value' }, String(value ?? 0)),
|
||||
el('span', { class: 'stat-label' }, label),
|
||||
sub ? el('span', { class: 'stat-sub' }, sub) : null,
|
||||
);
|
||||
}
|
||||
|
||||
function fmtKm(n) {
|
||||
return (Math.round((Number(n) || 0) * 10) / 10).toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// Orchestrates the trip page: header + calendar, then map + summary.
|
||||
// Owns the shared trip context passed to the calendar, map, summary and
|
||||
// day-editor sub-views: { state, navigate, tripId, trip, route, refreshTrip,
|
||||
// openDay }. After any mutation, refreshTrip() re-fetches the trip and its
|
||||
// derived /route data so all three panels 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 { renderCountdown } from './flipclock.js';
|
||||
import { openDayEditor } from './dayEditor.js';
|
||||
|
||||
export function renderTripDetail(container, ctx, id) {
|
||||
const tctx = {
|
||||
...ctx,
|
||||
tripId: id,
|
||||
trip: null,
|
||||
route: null,
|
||||
costs: null,
|
||||
_onModalRefresh: null,
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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() {
|
||||
const page = el('div', { class: 'page' });
|
||||
page.appendChild(renderHeader());
|
||||
page.appendChild(renderCountdown(tctx));
|
||||
page.appendChild(renderCalendar(tctx));
|
||||
page.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'detail-grid' },
|
||||
renderMap(tctx),
|
||||
el('div', { class: 'detail-side' }, renderSummary(tctx), renderCosts(tctx)),
|
||||
),
|
||||
);
|
||||
mount(container, page);
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
const { trip, members } = tctx.trip;
|
||||
const isOwner = trip.owner_id === tctx.state.user.id;
|
||||
const dayCount = daysBetweenInclusive(trip.start_date, trip.end_date);
|
||||
|
||||
const header = el('div', { class: 'trip-header card' });
|
||||
|
||||
const titleRow = el(
|
||||
'div',
|
||||
{ class: 'trip-header-top' },
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
el(
|
||||
'a',
|
||||
{ class: 'back-link', href: '#/trips' },
|
||||
'← All trips',
|
||||
),
|
||||
el('h1', { class: 'trip-title' }, trip.name),
|
||||
el(
|
||||
'p',
|
||||
{ class: 'trip-subtitle muted' },
|
||||
'📅 ', formatRange(trip.start_date, trip.end_date),
|
||||
' · ', pluralize(dayCount, 'day', 'days'),
|
||||
' · ', el('span', { class: 'currency-tag' }, trip.currency || 'USD'),
|
||||
),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'trip-header-actions' },
|
||||
el('button', { class: 'btn btn-ghost', onClick: () => toggleEdit(header) }, '✎ Edit'),
|
||||
isOwner
|
||||
? el('button', { class: 'btn btn-danger-ghost', onClick: onDelete }, 'Delete')
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
const membersRow = el(
|
||||
'div',
|
||||
{ class: 'members-row' },
|
||||
el('span', { class: 'members-label' }, 'Members:'),
|
||||
...members.map((m) =>
|
||||
el(
|
||||
'span',
|
||||
{ class: `member-chip${m.role === 'owner' ? ' member-owner' : ''}` },
|
||||
el('span', { class: 'member-avatar' }, (m.display_name || '?').charAt(0).toUpperCase()),
|
||||
m.display_name,
|
||||
isOwner && m.role !== 'owner'
|
||||
? el('button', {
|
||||
class: 'member-remove',
|
||||
title: `Remove ${m.display_name}`,
|
||||
onClick: () => onRemoveMember(m),
|
||||
}, '×')
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
header.appendChild(titleRow);
|
||||
header.appendChild(joinCodeRow(trip, isOwner));
|
||||
header.appendChild(membersRow);
|
||||
return header;
|
||||
}
|
||||
|
||||
// Share code for inviting others — display only, not a credential.
|
||||
function joinCodeRow(trip, isOwner) {
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'joincode-row' },
|
||||
el('span', { class: 'members-label' }, 'Join code:'),
|
||||
el('span', { class: 'joincode-chip' }, grouped),
|
||||
copyBtn,
|
||||
isOwner
|
||||
? el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: onRegenerate }, '↻ Regenerate')
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
function toggleEdit(header) {
|
||||
const existing = header.querySelector('.trip-edit');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
return;
|
||||
}
|
||||
const { trip } = tctx.trip;
|
||||
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');
|
||||
|
||||
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');
|
||||
await tctx.refreshTrip();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Save changes';
|
||||
}
|
||||
}
|
||||
|
||||
const editBox = 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' }, saveBtn),
|
||||
);
|
||||
header.appendChild(editBox);
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user