ChallengeFormDialog.tsx
4,301 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 { ApiError, api, jsonBody } from "../lib/api"; |
| 5 | import { useModalDialog } from "../lib/useModalDialog"; |
| 6 | import type { Challenge, CreateChallengeInput, Destination } from "../types"; |
| 7 | |
| 8 | function dateAfter(days: number) { |
| 9 | const date = new Date(); |
| 10 | date.setDate(date.getDate() + days); |
| 11 | return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 10); |
| 12 | } |
| 13 | |
| 14 | export function ChallengeFormDialog({ onClose, onCreated }: { |
| 15 | onClose: () => void; |
| 16 | onCreated: (challenge: Challenge) => void; |
| 17 | }) { |
| 18 | const dialogRef = useModalDialog(onClose); |
| 19 | const queryClient = useQueryClient(); |
| 20 | const [title, setTitle] = useState(""); |
| 21 | const [destinationCode, setDestinationCode] = useState(""); |
| 22 | const [startsOn, setStartsOn] = useState(dateAfter(0)); |
| 23 | const [endsOn, setEndsOn] = useState(dateAfter(30)); |
| 24 | const [error, setError] = useState(""); |
| 25 | const destinations = useQuery({ |
| 26 | queryKey: ["destinations"], |
| 27 | queryFn: () => api<Destination[]>("/api/v1/catalog/destinations") |
| 28 | }); |
| 29 | |
| 30 | const selectedDestinationCode = destinationCode || destinations.data?.[0]?.code || ""; |
| 31 | |
| 32 | const create = useMutation({ |
| 33 | mutationFn: () => { |
| 34 | const body: CreateChallengeInput = { title: title.trim(), destinationCode: selectedDestinationCode, startsOn, endsOn }; |
| 35 | return api<Challenge>("/api/v1/challenges", { method: "POST", ...jsonBody(body) }); |
| 36 | }, |
| 37 | onSuccess: async challenge => { |
| 38 | await queryClient.invalidateQueries({ queryKey: ["challenges"] }); |
| 39 | onCreated(challenge); |
| 40 | }, |
| 41 | onError: caught => setError(caught instanceof ApiError ? caught.message : "The challenge could not be created.") |
| 42 | }); |
| 43 | |
| 44 | function submit(event: FormEvent) { |
| 45 | event.preventDefault(); |
| 46 | setError(""); |
| 47 | create.mutate(); |
| 48 | } |
| 49 | |
| 50 | return ( |
| 51 | <div className="dialog-backdrop" role="presentation" onMouseDown={event => { |
| 52 | if (event.currentTarget === event.target) onClose(); |
| 53 | }}> |
| 54 | <section ref={dialogRef} className="small-dialog" role="dialog" aria-modal="true" aria-labelledby="challenge-dialog-title"> |
| 55 | <header className="dialog-header"> |
| 56 | <div> |
| 57 | <span className="eyebrow">Shared food map</span> |
| 58 | <h2 id="challenge-dialog-title">Start a tasting challenge</h2> |
| 59 | </div> |
| 60 | <button className="icon-button" type="button" onClick={onClose} aria-label="Close"><X size={20} /></button> |
| 61 | </header> |
| 62 | <form className="dialog-form" onSubmit={submit}> |
| 63 | <label> |
| 64 | <span>Challenge name</span> |
| 65 | <input value={title} onChange={event => setTitle(event.target.value)} placeholder="Tokyo table quest" maxLength={100} required data-initial-focus /> |
| 66 | </label> |
| 67 | <label> |
| 68 | <span>Food culture</span> |
| 69 | <select value={selectedDestinationCode} onChange={event => setDestinationCode(event.target.value)} required> |
| 70 | {destinations.data?.map(item => <option key={item.code} value={item.code}>{item.name}</option>)} |
| 71 | </select> |
| 72 | </label> |
| 73 | <div className="form-grid form-grid-two"> |
| 74 | <label> |
| 75 | <span>Starts</span> |
| 76 | <input type="date" value={startsOn} onChange={event => setStartsOn(event.target.value)} required /> |
| 77 | </label> |
| 78 | <label> |
| 79 | <span>Ends</span> |
| 80 | <input type="date" value={endsOn} min={startsOn} onChange={event => setEndsOn(event.target.value)} required /> |
| 81 | </label> |
| 82 | </div> |
| 83 | <p className="form-hint">Everyone contributes their own logged dishes. The group fills one shared map.</p> |
| 84 | {error && <p className="form-error" role="alert">{error}</p>} |
| 85 | <footer className="dialog-actions"> |
| 86 | <button className="button button-ghost" type="button" onClick={onClose}>Cancel</button> |
| 87 | <button className="button button-primary" type="submit" disabled={create.isPending || destinations.isLoading}> |
| 88 | {create.isPending && <LoaderCircle className="spin" size={17} />} Create challenge |
| 89 | </button> |
| 90 | </footer> |
| 91 | </form> |
| 92 | </section> |
| 93 | </div> |
| 94 | ); |
| 95 | } |
| 96 | |