profileShare

rasmusjy / tasteprint

Read-only snapshot

No repository description.

main default branch 169 files Expires Sep 13, 2026, 9:06 AM
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