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