profileShare

rasmusjy / tasteprint

Read-only snapshot

No repository description.

main default branch 169 files Expires Sep 13, 2026, 9:06 AM
ChallengeDetailsPage.tsx 5,147 bytes
1 import { useState } from "react";
2 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3 import { Check, LogOut, Share2, Trash2 } from "lucide-react";
4 import { useNavigate, useParams } from "react-router-dom";
5 import { ProgressRing } from "../components/ProgressRing";
6 import { Avatar, ErrorState, LoadingScreen, PageHeader, StatusPill } from "../components/Ui";
7 import { useAuth } from "../lib/auth";
8 import { api } from "../lib/api";
9 import { formatDateRange } from "../lib/format";
10 import type { Challenge } from "../types";
11
12 export function ChallengeDetailsPage() {
13 const { challengeId } = useParams();
14 const { user } = useAuth();
15 const navigate = useNavigate();
16 const queryClient = useQueryClient();
17 const [copied, setCopied] = useState(false);
18 const challenge = useQuery({
19 queryKey: ["challenge", challengeId],
20 queryFn: () => api<Challenge>(`/api/v1/challenges/${challengeId}`),
21 enabled: Boolean(challengeId)
22 });
23 const remove = useMutation({
24 mutationFn: () => api<void>(user?.id === challenge.data?.ownerId
25 ? `/api/v1/challenges/${challengeId}`
26 : `/api/v1/challenges/${challengeId}/members/me`, { method: "DELETE" }),
27 onSuccess: async () => {
28 await queryClient.invalidateQueries({ queryKey: ["challenges"] });
29 navigate("/app/challenges");
30 }
31 });
32
33 if (challenge.isLoading) return <LoadingScreen label="Opening challenge" />;
34 if (challenge.isError || !challenge.data) return <ErrorState message="This challenge could not be opened." />;
35 const data = challenge.data;
36 const isOwner = user?.id === data.ownerId;
37 const completed = data.dishes.filter(item => item.tried).length;
38
39 async function copyInvite() {
40 await navigator.clipboard.writeText(`${window.location.origin}/join/${data.joinCode}`);
41 setCopied(true);
42 window.setTimeout(() => setCopied(false), 2000);
43 }
44
45 function confirmRemove() {
46 const message = isOwner ? "Delete this challenge for everyone?" : "Leave this challenge?";
47 if (window.confirm(message)) remove.mutate();
48 }
49
50 return (
51 <div className="page challenge-details-page">
52 <PageHeader eyebrow={`${data.destination.name} challenge`} title={data.title}
53 copy={formatDateRange(data.startsOn, data.endsOn)} action={<StatusPill status={data.status} />} />
54
55 <section className="challenge-hero" style={{ backgroundColor: data.destination.accentColor }}>
56 <div>
57 <span className="eyebrow eyebrow-light">Shared culinary coverage</span>
58 <h2>{completed} of {data.dishes.length} core dishes found</h2>
59 <p>Every participant can add to this score by logging a dish during the challenge dates.</p>
60 </div>
61 <ProgressRing value={data.groupCoverage} size="large" label="group coverage" light />
62 </section>
63
64 <div className="challenge-layout">
65 <section className="panel">
66 <div className="section-heading">
67 <div><span className="eyebrow">Group checklist</span><h2>What the table has covered</h2></div>
68 </div>
69 <div className="challenge-dish-list">
70 {data.dishes.map(item => (
71 <div key={item.dish.slug} className={item.tried ? "is-complete" : ""}>
72 <span className="check-mark">{item.tried && <Check size={15} />}</span>
73 <span><strong>{item.dish.name}</strong><small>{item.dish.categoryLabel}</small></span>
74 <span>{item.dish.importance === 3 ? "Essential" : item.dish.importance === 2 ? "Core" : "Discovery"}</span>
75 </div>
76 ))}
77 </div>
78 </section>
79
80 <aside className="challenge-side">
81 <section className="panel invite-panel">
82 <span className="eyebrow">Invite people</span>
83 <div className="invite-code">{data.joinCode}</div>
84 <p>Share the code or a direct join link.</p>
85 <button className="button button-outline button-full" type="button" onClick={() => void copyInvite()}>
86 {copied ? <Check size={17} /> : <Share2 size={17} />} {copied ? "Link copied" : "Copy invite link"}
87 </button>
88 </section>
89 <section className="panel participant-panel">
90 <div className="section-heading"><div><span className="eyebrow">Travelers</span><h2>{data.participants.length} contributing</h2></div></div>
91 <div className="participant-list">
92 {data.participants.map(participant => (
93 <div key={participant.user.id}>
94 <Avatar name={participant.user.displayName} url={participant.user.avatarUrl} size="small" />
95 <span><strong>{participant.user.displayName}</strong><small>{participant.contributedDishes} dishes added</small></span>
96 </div>
97 ))}
98 </div>
99 </section>
100 <button className="danger-link" type="button" onClick={confirmRemove} disabled={remove.isPending}>
101 {isOwner ? <Trash2 size={16} /> : <LogOut size={16} />}{isOwner ? "Delete challenge" : "Leave challenge"}
102 </button>
103 </aside>
104 </div>
105 </div>
106 );
107 }
108