profileShare

rasmusjy / tasteprint

Read-only snapshot

No repository description.

main default branch 169 files Expires Sep 13, 2026, 9:06 AM
TasteModal.tsx 10,260 bytes
1 import { useEffect, useMemo, useState, type FormEvent } from "react";
2 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3 import { Camera, Check, LoaderCircle, MapPin, X } from "lucide-react";
4 import { api, ApiError, jsonBody } from "../lib/api";
5 import { todayInput } from "../lib/format";
6 import { useAuth } from "../lib/auth";
7 import { useModalDialog } from "../lib/useModalDialog";
8 import type { Destination, DestinationDetails, SaveTastingInput, Tasting } from "../types";
9 import type { TastePayload } from "./QuickTaste";
10
11 interface TasteModalProps {
12 payload: TastePayload;
13 onClose: () => void;
14 }
15
16 export function TasteModal({ payload, onClose }: TasteModalProps) {
17 const dialogRef = useModalDialog(onClose);
18 const { user } = useAuth();
19 const queryClient = useQueryClient();
20 const editing = payload.tasting;
21 const initialDish = editing?.dish ?? payload.dish;
22 const [destinationCode, setDestinationCode] = useState(initialDish?.destinationCode ?? "");
23 const [dishSlug, setDishSlug] = useState(initialDish?.slug ?? "");
24 const [restaurantName, setRestaurantName] = useState(editing?.restaurantName ?? "");
25 const [city, setCity] = useState(editing?.city ?? user?.homeCity ?? "");
26 const [countryCode, setCountryCode] = useState(editing?.countryCode ?? user?.homeCountryCode ?? "");
27 const [tastedOn, setTastedOn] = useState(editing?.tastedOn ?? todayInput());
28 const [rating, setRating] = useState(editing?.rating ?? 4);
29 const [note, setNote] = useState(editing?.note ?? "");
30 const [photoUrl, setPhotoUrl] = useState(editing?.photoUrl ?? "");
31 const [photo, setPhoto] = useState<File | null>(null);
32 const [locationLoading, setLocationLoading] = useState(false);
33 const [coordinates, setCoordinates] = useState<{ latitude: number; longitude: number } | null>(
34 editing?.latitude != null && editing.longitude != null
35 ? { latitude: editing.latitude, longitude: editing.longitude }
36 : null
37 );
38 const [error, setError] = useState("");
39
40 const destinations = useQuery({
41 queryKey: ["destinations"],
42 queryFn: () => api<Destination[]>("/api/v1/catalog/destinations")
43 });
44 const selectedDestinationCode = destinationCode
45 || destinations.data?.find(item => item.code === user?.homeCountryCode)?.code
46 || destinations.data?.[0]?.code
47 || "";
48 const destination = useQuery({
49 queryKey: ["destination", selectedDestinationCode],
50 queryFn: () => api<DestinationDetails>(`/api/v1/catalog/destinations/${selectedDestinationCode}`),
51 enabled: Boolean(selectedDestinationCode)
52 });
53 const selectedDishSlug = destination.data?.dishes.some(dish => dish.slug === dishSlug)
54 ? dishSlug
55 : destination.data?.dishes[0]?.slug ?? "";
56 const selectedCountryCode = countryCode || selectedDestinationCode;
57
58 const preview = useMemo(() => photo ? URL.createObjectURL(photo) : photoUrl || null, [photo, photoUrl]);
59 useEffect(() => () => {
60 if (preview?.startsWith("blob:")) URL.revokeObjectURL(preview);
61 }, [preview]);
62
63 const save = useMutation({
64 mutationFn: async () => {
65 let storedPhotoUrl = photoUrl || null;
66 if (photo) {
67 const data = new FormData();
68 data.append("file", photo);
69 storedPhotoUrl = (await api<MediaUpload>("/api/v1/media", { method: "POST", body: data })).url;
70 }
71 const body: SaveTastingInput = {
72 dishSlug: selectedDishSlug,
73 restaurantName: restaurantName.trim() || null,
74 city: city.trim(),
75 countryCode: selectedCountryCode.trim().toUpperCase(),
76 tastedOn,
77 rating,
78 note: note.trim() || null,
79 photoUrl: storedPhotoUrl,
80 latitude: coordinates?.latitude ?? null,
81 longitude: coordinates?.longitude ?? null
82 };
83 return api<Tasting>(editing ? `/api/v1/tastings/${editing.id}` : "/api/v1/tastings", {
84 method: editing ? "PUT" : "POST",
85 ...jsonBody(body)
86 });
87 },
88 onSuccess: async () => {
89 await queryClient.invalidateQueries();
90 onClose();
91 },
92 onError: caught => setError(caught instanceof ApiError ? caught.message : "The tasting could not be saved.")
93 });
94
95 function submit(event: FormEvent) {
96 event.preventDefault();
97 setError("");
98 if (!selectedDishSlug || !city.trim() || !selectedCountryCode.trim()) {
99 setError("Choose a dish and add where you tasted it.");
100 return;
101 }
102 save.mutate();
103 }
104
105 function useLocation() {
106 if (!navigator.geolocation) {
107 setError("Location is not available in this browser.");
108 return;
109 }
110 setLocationLoading(true);
111 navigator.geolocation.getCurrentPosition(
112 position => {
113 setCoordinates({ latitude: position.coords.latitude, longitude: position.coords.longitude });
114 setLocationLoading(false);
115 },
116 () => {
117 setError("Location permission was not granted. You can still save the tasting.");
118 setLocationLoading(false);
119 },
120 { enableHighAccuracy: false, timeout: 8000 }
121 );
122 }
123
124 return (
125 <div className="dialog-backdrop" role="presentation" onMouseDown={event => {
126 if (event.currentTarget === event.target) onClose();
127 }}>
128 <section ref={dialogRef} className="taste-dialog" role="dialog" aria-modal="true" aria-labelledby="taste-dialog-title">
129 <header className="dialog-header">
130 <div>
131 <span className="eyebrow">New passport mark</span>
132 <h2 id="taste-dialog-title">{editing ? "Edit this taste" : "What did you taste?"}</h2>
133 </div>
134 <button className="icon-button" type="button" onClick={onClose} aria-label="Close">
135 <X size={20} />
136 </button>
137 </header>
138
139 <form onSubmit={submit} className="dialog-form">
140 <div className="form-grid form-grid-two">
141 <label>
142 <span>Food culture</span>
143 <select data-initial-focus value={selectedDestinationCode} onChange={event => {
144 setDestinationCode(event.target.value);
145 setCountryCode(event.target.value);
146 setDishSlug("");
147 }} required>
148 <option value="">Choose a country</option>
149 {destinations.data?.map(item => <option key={item.code} value={item.code}>{item.name}</option>)}
150 </select>
151 </label>
152 <label>
153 <span>Dish</span>
154 <select value={selectedDishSlug} onChange={event => setDishSlug(event.target.value)} required disabled={!destination.data}>
155 <option value="">Choose a dish</option>
156 {destination.data?.dishes.map(dish => <option key={dish.slug} value={dish.slug}>{dish.name}</option>)}
157 </select>
158 </label>
159 </div>
160
161 <div className="form-grid form-grid-two">
162 <label>
163 <span>City</span>
164 <input value={city} onChange={event => setCity(event.target.value)} maxLength={100} placeholder="Tallinn" required />
165 </label>
166 <label>
167 <span>Country code</span>
168 <input value={selectedCountryCode} onChange={event => setCountryCode(event.target.value.toUpperCase())}
169 maxLength={2} pattern="[A-Za-z]{2}" placeholder="EE" required />
170 </label>
171 </div>
172
173 <div className="form-grid form-grid-two">
174 <label>
175 <span>Date</span>
176 <input type="date" value={tastedOn} max={todayInput()} onChange={event => setTastedOn(event.target.value)} required />
177 </label>
178 <label>
179 <span>Place, optional</span>
180 <input value={restaurantName} onChange={event => setRestaurantName(event.target.value)}
181 maxLength={160} placeholder="Restaurant or market" />
182 </label>
183 </div>
184
185 <fieldset className="rating-field">
186 <legend>Your rating</legend>
187 <div className="rating-options">
188 {[1, 2, 3, 4, 5].map(value => (
189 <button key={value} className={rating === value ? "rating-active" : ""} type="button"
190 onClick={() => setRating(value)} aria-label={`${value} out of 5`}>
191 {value}
192 </button>
193 ))}
194 </div>
195 </fieldset>
196
197 <label>
198 <span>Taste note, optional</span>
199 <textarea value={note} onChange={event => setNote(event.target.value)} maxLength={500}
200 rows={3} placeholder="What will you remember about it?" />
201 </label>
202
203 <div className="photo-location-row">
204 <div className="photo-control">
205 <label className="photo-picker">
206 {preview ? <img src={preview} alt="Selected tasting" /> : <Camera size={24} />}
207 <span>{preview ? "Change photo" : "Add a photo"}</span>
208 <input type="file" accept="image/jpeg,image/png,image/webp" onChange={event => setPhoto(event.target.files?.[0] ?? null)} />
209 </label>
210 {preview && <button className="remove-photo-button" type="button" onClick={() => { setPhoto(null); setPhotoUrl(""); }}>Remove photo</button>}
211 </div>
212 <button className={`location-button ${coordinates ? "location-set" : ""}`} type="button" onClick={useLocation}>
213 {locationLoading ? <LoaderCircle className="spin" size={18} /> : coordinates ? <Check size={18} /> : <MapPin size={18} />}
214 {coordinates ? "Location attached" : "Attach location"}
215 </button>
216 </div>
217
218 {error && <p className="form-error" role="alert">{error}</p>}
219
220 <footer className="dialog-actions">
221 <button className="button button-ghost" type="button" onClick={onClose}>Cancel</button>
222 <button className="button button-primary" type="submit" disabled={save.isPending}>
223 {save.isPending && <LoaderCircle className="spin" size={17} />}
224 {editing ? "Save changes" : "Stamp this dish"}
225 </button>
226 </footer>
227 </form>
228 </section>
229 </div>
230 );
231 }
232
233 interface MediaUpload {
234 url: string;
235 contentType: string;
236 size: number;
237 }
238