Trips
index.jsx 476 lines
Files · 9
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476import React, { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import {
Page,
Header,
Button,
Input,
Modal,
EmptyState,
Row,
} from "../../shared/ui.jsx";
import { useModel } from "../../shared/data.js";
import { Select, ErrorNotice } from "../../shared/ui.jsx";
import { FormActions } from "../../shared/forms.jsx";
import { createModel } from "./model.js";
import { localDate, daysBetween } from "./runtime.js";
import "./style.css";
const model = createModel(window.lattice);
const kindNames = {
flight: "Flight",
lodging: "Lodging",
activity: "Activity",
};
function TripForm({ busy, onClose, onSave }) {
const [form, setForm] = useState({
title: "",
destination: "",
start_date: localDate(),
end_date: localDate(),
});
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
return (
<Modal title="New trip" onClose={onClose}>
<form
onSubmit={(e) => {
e.preventDefault();
onSave(form);
}}
>
<Input
label="Trip name"
required
value={form.title}
onChange={(e) => set("title", e.target.value)}
placeholder="e.g. A few days in Lisbon"
/>
<Input
label="Destination"
required
value={form.destination}
onChange={(e) => set("destination", e.target.value)}
placeholder="City, country"
/>
<div className="ui-form-grid">
<Input
label="Start date"
type="date"
required
value={form.start_date}
onChange={(e) => {
const v = e.target.value;
setForm((f) => ({
...f,
start_date: v,
end_date: f.end_date < v ? v : f.end_date,
}));
}}
/>
<Input
label="End date"
type="date"
min={form.start_date}
required
value={form.end_date}
onChange={(e) => set("end_date", e.target.value)}
/>
</div>
<FormActions busy={busy} onCancel={onClose} label="Create trip" />
</form>
</Modal>
);
}
function ItemForm({ trip, busy, onClose, onSave }) {
const [form, setForm] = useState({
trip_id: trip.id,
kind: "activity",
title: "",
date: trip.start_date,
time: "",
location: "",
confirmation_code: "",
});
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
return (
<Modal title="Add itinerary item" onClose={onClose}>
<form
onSubmit={(e) => {
e.preventDefault();
onSave(form);
}}
>
<Input
label="Title"
required
value={form.title}
onChange={(e) => set("title", e.target.value)}
/>
<Select
label="Type"
value={form.kind}
onChange={(e) => set("kind", e.target.value)}
>
{Object.entries(kindNames).map(([k, n]) => (
<option key={k} value={k}>
{n}
</option>
))}
</Select>
<div className="ui-form-grid">
<Input
label="Date"
type="date"
required
min={trip.start_date}
max={trip.end_date}
value={form.date}
onChange={(e) => set("date", e.target.value)}
/>
<Input
label="Time (optional)"
type="time"
value={form.time}
onChange={(e) => set("time", e.target.value)}
/>
</div>
<Input
label="Location (optional)"
value={form.location}
onChange={(e) => set("location", e.target.value)}
/>
<Input
label="Confirmation code (optional)"
value={form.confirmation_code}
onChange={(e) => set("confirmation_code", e.target.value)}
/>
<p className="ui-muted">
Dates and times are local to the destination.
</p>
<FormActions busy={busy} onCancel={onClose} label="Add item" />
</form>
</Modal>
);
}
function PackingForm({ trip, busy, onClose, onSave }) {
const [label, setLabel] = useState(""),
[quantity, setQuantity] = useState(1);
return (
<Modal title="Add packing item" onClose={onClose}>
<form
onSubmit={(e) => {
e.preventDefault();
onSave({ trip_id: trip.id, label, quantity });
}}
>
<Input
label="Item name"
required
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. Phone charger"
/>
<Input
label="Quantity"
type="number"
min="1"
max="999"
required
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
/>
<FormActions busy={busy} onCancel={onClose} label="Add to checklist" />
</form>
</Modal>
);
}
function App() {
const { data, error, busy, act } = useModel(model),
[selected, setSelected] = useState(null),
[dialog, setDialog] = useState(""),
[removing, setRemoving] = useState(null);
useEffect(() => {
const timer = setInterval(() => act(() => model.tick()), 60000);
return () => clearInterval(timer);
}, []);
const trips = data?.trips ?? [],
trip =
trips.find((t) => t.id === selected) ??
trips.find((t) => t.end_date >= localDate()) ??
trips[0],
items = (data?.items ?? []).filter((i) => i.trip_id === trip?.id),
packing = (data?.packing ?? []).filter((i) => i.trip_id === trip?.id),
packed = packing.filter((i) => i.checked).length;
const close = () => !busy && setDialog("");
return (
<Page className="new-app trips">
<Header
title="Trips"
subtitle="Plans, bookings, and everything to bring."
actions={
<>
<Button variant="primary" onClick={() => setDialog("trip")}>
Add trip
</Button>
</>
}
/>
<ErrorNotice error={error} pending={data?.pending} />
{!data ? (
<EmptyState title="Loading trips…" />
) : !trips.length ? (
<div className="ui-panel">
<EmptyState
title="No trips yet"
action={
<div className="ui-actions">
<Button onClick={() => setDialog("trip")}>Plan a trip</Button>
<Button
disabled={busy}
onClick={() => act(() => model.sample())}
>
Add sample data
</Button>
</div>
}
>
Keep an itinerary and packing list together.
</EmptyState>
</div>
) : (
<div className="ui-master-detail">
<aside>
<p className="ui-caption" style={{ marginBottom: 12 }}>
Your trips · {trips.length}
</p>
<div className="ui-panel">
{trips.map((t) => (
<button
key={t.id}
className="trip-choice"
aria-pressed={t.id === trip.id}
onClick={() => setSelected(t.id)}
>
<strong>{t.title}</strong>
<div className="ui-muted">{t.destination}</div>
<div className="ui-muted" style={{ marginTop: 8 }}>
{t.start_date} → {t.end_date}
</div>
</button>
))}
</div>
</aside>
<section aria-label="Trip details">
<div className="trip-summary">
<div>
<p className="ui-caption">{trip.destination}</p>
<h2 style={{ margin: "6px 0 8px" }}>{trip.title}</h2>
<p className="ui-muted">
{trip.start_date} → {trip.end_date} ·{" "}
{daysBetween(trip.start_date, trip.end_date) + 1} days
</p>
</div>
<span className="ui-badge ui-badge-accent">
{trip.start_date > localDate()
? `In ${daysBetween(localDate(), trip.start_date)} days`
: trip.end_date < localDate()
? "Past trip"
: "Underway"}
</span>
</div>
<div className="ui-list-header">
<h2>Itinerary</h2>
<Button onClick={() => setDialog("item")}>+ Add item</Button>
</div>
<div className="ui-panel">
{!items.length ? (
<EmptyState title="No itinerary items">
Add a flight, a place to stay, or an activity.
</EmptyState>
) : (
items.map((item, index) => (
<React.Fragment key={item.id}>
{(index === 0 || items[index - 1].date !== item.date) && (
<div className="timeline-date">
{new Date(item.date + "T12:00:00").toLocaleDateString(
undefined,
{ weekday: "long", month: "short", day: "numeric" },
)}
</div>
)}
<Row
leading={
<span className="timeline-time">
{item.time || "Any time"}
</span>
}
title={item.title}
subtitle={item.location}
actions={
<Button
variant="ghost"
aria-label={`Remove ${item.title}`}
onClick={() =>
setRemoving({
type: "item",
id: item.id,
label: item.title,
})
}
>
×
</Button>
}
>
<div className="ui-meta">
<span className="ui-badge">{kindNames[item.kind]}</span>
{item.confirmation_code && (
<span className="ui-muted">
Ref. {item.confirmation_code}
</span>
)}
</div>
</Row>
</React.Fragment>
))
)}
</div>
<div className="ui-list-header">
<h2>
Packing{" "}
<span className="ui-muted">
{packed} / {packing.length}
</span>
</h2>
<Button onClick={() => setDialog("packing")}>+ Add item</Button>
</div>
<div
className="progress"
role="progressbar"
aria-label="Packing progress"
aria-valuemin={0}
aria-valuemax={packing.length || 1}
aria-valuenow={packed}
>
<span
style={{
width: `${packing.length ? (packed / packing.length) * 100 : 0}%`,
}}
/>
</div>
<div className="ui-panel">
{!packing.length ? (
<EmptyState title="Packing list is empty">
Add the things you want to bring.
</EmptyState>
) : (
packing.map((item) => (
<Row
key={item.id}
leading={
<input
type="checkbox"
className="ui-check"
aria-label={`Pack ${item.label}`}
checked={!!item.checked}
disabled={busy}
onChange={() => act(() => model.toggle(item))}
/>
}
title={
<span
style={
item.checked
? {
textDecoration: "line-through",
color: "var(--text-secondary)",
}
: {}
}
>
{item.label}
</span>
}
actions={
<>
<span className="ui-badge">× {item.quantity}</span>
<Button
variant="ghost"
aria-label={`Remove ${item.label}`}
onClick={() =>
setRemoving({
type: "packing",
id: item.id,
label: item.label,
})
}
>
×
</Button>
</>
}
/>
))
)}
</div>
</section>
</div>
)}
{dialog === "trip" && (
<TripForm
busy={busy}
onClose={close}
onSave={(f) =>
act(async () => {
const row = await model.addTrip(f);
setSelected(row.id);
}).then((ok) => ok && setDialog(""))
}
/>
)}
{dialog === "item" && trip && (
<ItemForm
trip={trip}
busy={busy}
onClose={close}
onSave={(f) =>
act(() => model.addItem(f)).then((ok) => ok && setDialog(""))
}
/>
)}
{dialog === "packing" && trip && (
<PackingForm
trip={trip}
busy={busy}
onClose={close}
onSave={(f) =>
act(() => model.addPacking(f)).then((ok) => ok && setDialog(""))
}
/>
)}
{removing && (
<Modal title="Remove item?" onClose={() => !busy && setRemoving(null)}>
<p className="ui-muted">Remove {removing.label} from this trip?</p>
<div className="ui-form-actions">
<Button disabled={busy} onClick={() => setRemoving(null)}>
Cancel
</Button>
<Button
variant="danger"
disabled={busy}
onClick={() =>
act(() =>
removing.type === "item"
? model.removeItem(removing.id)
: model.removePacking(removing.id),
).then((ok) => ok && setRemoving(null))
}
>
Remove item
</Button>
</div>
</Modal>
)}
</Page>
);
}
createRoot(document.getElementById("root")).render(<App />);