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 | |