profileShare

rasmusjy / tasteprint

Read-only snapshot

No repository description.

main default branch 169 files Expires Sep 13, 2026, 9:06 AM
TripFormDialog.tsx 4,461 bytes
1 import { useState, type FormEvent } from "react";
2 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3 import { LoaderCircle, X } from "lucide-react";
4 import { api, ApiError, jsonBody } from "../lib/api";
5 import { todayInput } from "../lib/format";
6 import { useModalDialog } from "../lib/useModalDialog";
7 import type { Destination, SaveTripInput, Trip } from "../types";
8
9 function dateAfter(days: number) {
10 const date = new Date();
11 date.setDate(date.getDate() + days);
12 const offset = date.getTimezoneOffset();
13 return new Date(date.getTime() - offset * 60_000).toISOString().slice(0, 10);
14 }
15
16 export function TripFormDialog({ trip, onClose }: { trip?: Trip; onClose: () => void }) {
17 const dialogRef = useModalDialog(onClose);
18 const queryClient = useQueryClient();
19 const [destinationCode, setDestinationCode] = useState(trip?.destination.code ?? "");
20 const [city, setCity] = useState(trip?.city ?? "");
21 const [startsOn, setStartsOn] = useState(trip?.startsOn ?? dateAfter(14));
22 const [endsOn, setEndsOn] = useState(trip?.endsOn ?? dateAfter(20));
23 const [error, setError] = useState("");
24 const destinations = useQuery({
25 queryKey: ["destinations"],
26 queryFn: () => api<Destination[]>("/api/v1/catalog/destinations")
27 });
28
29 const selectedDestinationCode = destinationCode || destinations.data?.[0]?.code || "";
30
31 const save = useMutation({
32 mutationFn: () => {
33 const body: SaveTripInput = { destinationCode: selectedDestinationCode, city: city.trim(), startsOn, endsOn };
34 return api<Trip>(trip ? `/api/v1/trips/${trip.id}` : "/api/v1/trips", {
35 method: trip ? "PUT" : "POST",
36 ...jsonBody(body)
37 });
38 },
39 onSuccess: async () => {
40 await queryClient.invalidateQueries();
41 onClose();
42 },
43 onError: caught => setError(caught instanceof ApiError ? caught.message : "The trip could not be saved.")
44 });
45
46 function submit(event: FormEvent) {
47 event.preventDefault();
48 setError("");
49 save.mutate();
50 }
51
52 return (
53 <div className="dialog-backdrop" role="presentation" onMouseDown={event => {
54 if (event.currentTarget === event.target) onClose();
55 }}>
56 <section ref={dialogRef} className="small-dialog" role="dialog" aria-modal="true" aria-labelledby="trip-dialog-title">
57 <header className="dialog-header">
58 <div>
59 <span className="eyebrow">Food mission</span>
60 <h2 id="trip-dialog-title">{trip ? "Edit trip" : "Where are you going?"}</h2>
61 </div>
62 <button className="icon-button" type="button" onClick={onClose} aria-label="Close"><X size={20} /></button>
63 </header>
64 <form className="dialog-form" onSubmit={submit}>
65 <label>
66 <span>Destination</span>
67 <select data-initial-focus={!trip ? "true" : undefined} value={selectedDestinationCode} onChange={event => setDestinationCode(event.target.value)} disabled={Boolean(trip)} required>
68 {destinations.data?.map(item => <option key={item.code} value={item.code}>{item.name}</option>)}
69 </select>
70 {trip && <small>A new destination gets a new mission.</small>}
71 </label>
72 <label>
73 <span>City or route name</span>
74 <input data-initial-focus={trip ? "true" : undefined} value={city} onChange={event => setCity(event.target.value)} placeholder="Osaka" maxLength={100} required />
75 </label>
76 <div className="form-grid form-grid-two">
77 <label>
78 <span>Starts</span>
79 <input type="date" value={startsOn} onChange={event => setStartsOn(event.target.value)} required />
80 </label>
81 <label>
82 <span>Ends</span>
83 <input type="date" value={endsOn} min={startsOn || todayInput()} onChange={event => setEndsOn(event.target.value)} required />
84 </label>
85 </div>
86 {error && <p className="form-error" role="alert">{error}</p>}
87 <footer className="dialog-actions">
88 <button className="button button-ghost" type="button" onClick={onClose}>Cancel</button>
89 <button className="button button-primary" type="submit" disabled={save.isPending}>
90 {save.isPending && <LoaderCircle className="spin" size={17} />}
91 {trip ? "Save trip" : "Create mission"}
92 </button>
93 </footer>
94 </form>
95 </section>
96 </div>
97 );
98 }
99