profileShare

rasmusjy / tasteprint

Read-only snapshot

No repository description.

main default branch 169 files Expires Sep 13, 2026, 9:06 AM
ChallengesPage.tsx 4,583 bytes
1 import { useState, type FormEvent } from "react";
2 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3 import { ArrowRight, KeyRound, Plus, UsersRound } from "lucide-react";
4 import { Link, useNavigate } from "react-router-dom";
5 import { ChallengeFormDialog } from "../components/ChallengeFormDialog";
6 import { ProgressRing } from "../components/ProgressRing";
7 import { EmptyState, ErrorState, LoadingScreen, PageHeader, StatusPill } from "../components/Ui";
8 import { ApiError, api, jsonBody } from "../lib/api";
9 import { formatDateRange } from "../lib/format";
10 import type { Challenge } from "../types";
11
12 export function ChallengesPage() {
13 const [creating, setCreating] = useState(false);
14 const [joinCode, setJoinCode] = useState("");
15 const [joinError, setJoinError] = useState("");
16 const navigate = useNavigate();
17 const queryClient = useQueryClient();
18 const challenges = useQuery({
19 queryKey: ["challenges"],
20 queryFn: () => api<Challenge[]>("/api/v1/challenges")
21 });
22 const join = useMutation({
23 mutationFn: () => api<Challenge>("/api/v1/challenges/join", {
24 method: "POST",
25 ...jsonBody({ joinCode: joinCode.trim().toUpperCase() })
26 }),
27 onSuccess: async challenge => {
28 await queryClient.invalidateQueries({ queryKey: ["challenges"] });
29 navigate(`/app/challenges/${challenge.id}`);
30 },
31 onError: caught => setJoinError(caught instanceof ApiError ? caught.message : "The challenge could not be joined.")
32 });
33
34 function submitJoin(event: FormEvent) {
35 event.preventDefault();
36 setJoinError("");
37 join.mutate();
38 }
39
40 if (challenges.isLoading) return <LoadingScreen label="Opening shared maps" />;
41 if (challenges.isError) return <ErrorState message="Your challenges could not be loaded." />;
42
43 return (
44 <div className="page challenges-page">
45 <PageHeader
46 eyebrow="Taste together"
47 title="One destination. Everyone brings a bite."
48 copy="Create a food challenge for a trip, a flat, or a group of friends. Every real tasting moves the same map."
49 action={<button className="button button-primary" type="button" onClick={() => setCreating(true)}><Plus size={18} /> New challenge</button>}
50 />
51
52 <section className="join-strip">
53 <div><KeyRound size={21} /><span><strong>Have an invite code?</strong><small>Codes contain six letters or numbers.</small></span></div>
54 <form onSubmit={submitJoin}>
55 <input aria-label="Challenge code" value={joinCode} onChange={event => setJoinCode(event.target.value.toUpperCase())}
56 placeholder="ABC123" maxLength={6} pattern="[A-Z0-9]{6}" required />
57 <button className="button button-ink" type="submit" disabled={join.isPending}>Join</button>
58 </form>
59 {joinError && <p className="form-error" role="alert">{joinError}</p>}
60 </section>
61
62 {challenges.data?.length ? (
63 <section className="challenge-grid" aria-label="Your challenges">
64 {challenges.data.map(challenge => (
65 <Link className="challenge-card" key={challenge.id} to={`/app/challenges/${challenge.id}`}
66 style={{ borderTopColor: challenge.destination.accentColor }}>
67 <div className="challenge-card-head">
68 <StatusPill status={challenge.status} />
69 <span className="join-code">{challenge.joinCode}</span>
70 </div>
71 <div className="challenge-card-body">
72 <ProgressRing value={challenge.groupCoverage} size="medium" label="group coverage" />
73 <div>
74 <span className="eyebrow">{challenge.destination.name}</span>
75 <h2>{challenge.title}</h2>
76 <p>{formatDateRange(challenge.startsOn, challenge.endsOn)}</p>
77 </div>
78 </div>
79 <footer>
80 <span><UsersRound size={15} /> {challenge.participants.length} {challenge.participants.length === 1 ? "traveler" : "travelers"}</span>
81 <ArrowRight size={17} />
82 </footer>
83 </Link>
84 ))}
85 </section>
86 ) : (
87 <EmptyState title="No shared maps yet" copy="Create one for your next trip or join a friend with their code."
88 action={<button className="button button-primary" onClick={() => setCreating(true)}>Create a challenge</button>} />
89 )}
90
91 {creating && <ChallengeFormDialog onClose={() => setCreating(false)} onCreated={challenge => navigate(`/app/challenges/${challenge.id}`)} />}
92 </div>
93 );
94 }
95