From 0ae74c49965a879772b053cfbb1daa62cd090825 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Fri, 21 Nov 2025 14:17:47 -0800 Subject: [PATCH 1/3] Build match details management UI --- src/api/matches.ts | 106 ++++ src/pages/MatchDetailsPage.css | 404 +++++++++++++- src/pages/MatchDetailsPage.tsx | 974 ++++++++++++++++++++++++++++++--- 3 files changed, 1382 insertions(+), 102 deletions(-) diff --git a/src/api/matches.ts b/src/api/matches.ts index 0d657fef..fb4d34fa 100644 --- a/src/api/matches.ts +++ b/src/api/matches.ts @@ -87,6 +87,27 @@ export interface CreateMatchParams { signal?: AbortSignal; } +export interface UpdateMatchParams { + startDateTime?: string | Date | null; + locationText?: string | null; + matchFormat?: string | null; + skillLevel?: string | number | null; + playerLimit?: number | null; + notes?: string | null; + latitude?: number | null; + longitude?: number | null; +} + +export interface MutateMatchOptions { + token?: string | null; + signal?: AbortSignal; +} + +export interface InvitePayload { + playerIds?: Array; + phoneNumbers?: string[]; +} + const isTruthyFlag = (value: TruthyLike | null | undefined) => { if (value === null || value === undefined) return false; if (typeof value === "boolean") return value; @@ -1265,3 +1286,88 @@ export const getMatchById = async ( return response; }; +export const updateMatch = async ( + id: string | number, + params: UpdateMatchParams, + { token, signal }: MutateMatchOptions = {}, +) => { + const startDateTime = toIsoString(params.startDateTime ?? undefined); + + const payload: Record = { + location_text: params.locationText ?? undefined, + location: params.locationText ?? undefined, + match_format: params.matchFormat ?? undefined, + format: params.matchFormat ?? undefined, + skill_level_min: params.skillLevel ?? undefined, + skillLevel: params.skillLevel ?? undefined, + player_limit: params.playerLimit ?? undefined, + player_limit_override: params.playerLimit ?? undefined, + notes: params.notes ?? undefined, + }; + + if (startDateTime) { + payload.start_date_time = startDateTime; + payload.dateTime = startDateTime; + } + + if (typeof params.latitude === "number") payload.latitude = params.latitude; + if (typeof params.longitude === "number") payload.longitude = params.longitude; + + return request(`/matches/${id}`, { + method: "PUT", + token: token ?? undefined, + signal, + body: payload, + }); +}; + +export const joinMatch = async (id: string | number, { token, signal }: MutateMatchOptions = {}) => + request(`/matches/${id}/join`, { + method: "POST", + token: token ?? undefined, + signal, + }); + +export const leaveMatch = async (id: string | number, { token, signal }: MutateMatchOptions = {}) => + request(`/matches/${id}/leave`, { + method: "POST", + token: token ?? undefined, + signal, + }); + +export const removeMatchParticipant = async ( + matchId: string | number, + playerId: string | number, + { token, signal }: MutateMatchOptions = {}, +) => + request(`/matches/${matchId}/participants/${playerId}`, { + method: "DELETE", + token: token ?? undefined, + signal, + }); + +export const sendMatchInvites = async ( + matchId: string | number, + payload: InvitePayload, + { token, signal }: MutateMatchOptions = {}, +) => + request(`/matches/${matchId}/invites`, { + method: "POST", + token: token ?? undefined, + signal, + body: { + playerIds: payload.playerIds ?? [], + phoneNumbers: payload.phoneNumbers ?? [], + }, + }); + +export const getMatchShareLink = async (id: string | number, { token, signal }: MutateMatchOptions = {}) => { + const response = await request(`/matches/${id}/share-link`, { + method: "GET", + token: token ?? undefined, + signal, + }); + + return { shareLink: deriveShareLink(response), raw: response }; +}; + diff --git a/src/pages/MatchDetailsPage.css b/src/pages/MatchDetailsPage.css index 9ce8fa0a..a546f10a 100644 --- a/src/pages/MatchDetailsPage.css +++ b/src/pages/MatchDetailsPage.css @@ -1,17 +1,123 @@ .match-details-page { display: flex; flex-direction: column; - gap: 20px; - padding-bottom: 48px; + gap: 18px; + padding: 12px 0 48px; color: #0f172a; } -.match-details-page--compact { +.match-details-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.match-details-kicker { + margin: 0; + font-weight: 700; + color: #0ea5e9; + letter-spacing: 0.04em; + text-transform: uppercase; + font-size: 12px; +} + +.match-details-title { + margin: 6px 0; + font-size: clamp(26px, 3vw, 34px); + line-height: 1.2; +} + +.match-details-meta { + margin: 0; + color: #475569; + font-weight: 600; +} + +.match-details-badges { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; +} + +.match-badge { + display: inline-flex; align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + font-weight: 700; + font-size: 13px; + border: 1px solid transparent; +} + +.match-badge--warning { + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; + border-color: rgba(239, 68, 68, 0.32); +} + +.match-badge--neutral { + background: rgba(148, 163, 184, 0.18); + color: #475569; + border-color: rgba(148, 163, 184, 0.45); +} + +.match-badge--outline { + background: #ffffff; + color: #0f172a; + border-color: #e2e8f0; +} + +.match-details-header__actions { + display: flex; + align-items: center; + gap: 8px; +} + +.match-action { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: 12px; + border: 1px solid #16a34a; + background: linear-gradient(135deg, #22c55e, #16a34a); + color: #ffffff; + font-weight: 700; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.match-action:hover, +.match-action:focus-visible { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(22, 163, 74, 0.35); + outline: none; +} + +.match-action:disabled { + cursor: not-allowed; + opacity: 0.7; +} + +.match-action--secondary { + background: #ffffff; + color: #0f172a; + border-color: #e2e8f0; + box-shadow: none; +} + +.match-action--ghost { + background: #f8fafc; + color: #0f172a; + border-color: #e2e8f0; } .match-details-card { - width: min(720px, 100%); + width: min(960px, 100%); background: #ffffff; border: 1px solid #e2e8f0; border-radius: 18px; @@ -19,7 +125,7 @@ padding: 22px 24px 18px; display: flex; flex-direction: column; - gap: 16px; + gap: 18px; } .match-details-card__header { @@ -28,6 +134,14 @@ gap: 10px; } +.match-details-card__header-row { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + .match-details-card__pills { display: flex; gap: 8px; @@ -59,16 +173,16 @@ color: #6d28d9; } -.match-details-card__title { +.section-title { margin: 0; - font-size: 26px; - line-height: 1.2; + font-size: 20px; + font-weight: 800; } -.match-details-card__meta { - margin: 0; +.section-helper { + margin: 2px 0 0; color: #475569; - font-weight: 600; + font-size: 14px; } .match-details-card__body { @@ -121,6 +235,238 @@ color: #475569; } +.match-details-section { + border: 1px solid #e2e8f0; + border-radius: 14px; + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.section-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} + +.match-edit-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px 16px; +} + +.match-field { + display: flex; + flex-direction: column; + gap: 6px; + font-weight: 600; + color: #0f172a; +} + +.match-field input, +.match-field select, +.match-field textarea { + border-radius: 12px; + border: 1px solid #e2e8f0; + padding: 10px 12px; + font-size: 15px; + font-weight: 500; + color: #0f172a; + background: #ffffff; +} + +.match-field textarea { + resize: vertical; +} + +.match-field__label { + font-size: 14px; +} + +.match-field__helper { + font-size: 13px; + color: #475569; +} + +.match-field__error { + color: #b91c1c; + font-size: 13px; +} + +.match-field--wide { + grid-column: span 2; +} + +.match-edit-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.match-summary dl { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px 16px; + margin: 0; +} + +.match-summary__item { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 10px 12px; +} + +.match-summary__item--wide { + grid-column: span 2; +} + +.match-summary dt { + margin: 0 0 4px; + font-weight: 700; + color: #0f172a; +} + +.match-summary dd { + margin: 0; + color: #334155; + font-weight: 500; +} + +.participant-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px 16px; +} + +.participant-title { + margin: 0 0 8px; + font-size: 16px; + font-weight: 800; +} + +.participant-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.participant-list--single .participant-row { + align-items: center; +} + +.participant-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #ffffff; +} + +.participant-name { + margin: 0; + font-weight: 700; +} + +.participant-meta { + margin: 2px 0 0; + color: #475569; + font-size: 14px; +} + +.participant-empty { + padding: 12px; + border: 1px dashed #cbd5e1; + border-radius: 12px; + color: #475569; +} + +.pill-button { + padding: 8px 12px; + border-radius: 999px; + border: 1px solid #e2e8f0; + background: #f8fafc; + color: #0f172a; + font-weight: 700; + cursor: pointer; +} + +.host-tools { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 12px 16px; +} + +.host-tile { + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; + background: #ffffff; +} + +.host-tile--muted { + background: #f8fafc; +} + +.host-tile__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.tile-eyebrow { + text-transform: uppercase; + color: #0ea5e9; + letter-spacing: 0.04em; + margin: 0 0 2px; + font-weight: 800; + font-size: 12px; +} + +.tile-helper { + margin: 4px 0 0; + color: #475569; + font-size: 14px; +} + +.share-row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.share-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: 12px; + border: 1px dashed #cbd5e1; + background: #f8fafc; + min-width: 0; + word-break: break-all; +} + +.invite-form { + display: flex; + flex-direction: column; + gap: 10px; +} + .match-details-card__footer { display: flex; justify-content: flex-end; @@ -160,8 +506,32 @@ box-shadow: none; } +.match-feedback { + padding: 12px 14px; + border-radius: 12px; + font-weight: 700; +} + +.match-feedback--success { + background: rgba(34, 197, 94, 0.1); + color: #166534; + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.match-feedback--error { + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; + border: 1px solid rgba(239, 68, 68, 0.35); +} + +.match-feedback--info { + background: rgba(59, 130, 246, 0.12); + color: #1d4ed8; + border: 1px solid rgba(59, 130, 246, 0.28); +} + .match-details-state { - width: min(640px, 100%); + width: min(720px, 100%); border: 1px dashed #cbd5e1; background: #f8fafc; color: #0f172a; @@ -197,16 +567,18 @@ } @media (max-width: 640px) { - .match-details-card__title { - font-size: 22px; + .match-details-header { + flex-direction: column; + align-items: flex-start; } .match-details-card__body { padding: 12px; } - .match-details-card__item { - grid-template-columns: auto 1fr; + .match-summary__item--wide, + .match-field--wide { + grid-column: span 1; } .match-details-card__footer { diff --git a/src/pages/MatchDetailsPage.tsx b/src/pages/MatchDetailsPage.tsx index 69acd6df..51de46a9 100644 --- a/src/pages/MatchDetailsPage.tsx +++ b/src/pages/MatchDetailsPage.tsx @@ -1,108 +1,537 @@ import { useEffect, useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { Activity, Calendar, MapPin, MessageCircle, Star, Users } from "lucide-react"; +import { + Activity, + Calendar, + Copy, + Link, + MapPin, + MessageCircle, + ShieldAlert, + Star, + Users, +} from "lucide-react"; -import { getMatchById, normalizeMatchDetail, type NormalizedMatch } from "../api/matches"; -import { useAuth } from "../context/AuthContext"; +import { + getMatchById, + getMatchShareLink, + joinMatch, + leaveMatch, + normalizeMatchDetail, + removeMatchParticipant, + sendMatchInvites, + type NormalizedMatch, + updateMatch, +} from "../api/matches"; import MainLayout from "../components/MainLayout"; +import { useAuth } from "../context/AuthContext"; import { getStoredAuthToken } from "../services/authToken"; import "./MatchDetailsPage.css"; +type Participant = { + id?: string; + name?: string; + contact?: string; + status?: string; + hosting?: boolean; + isCurrentUser?: boolean; +}; + +type ParticipantGroups = { + accepted: Participant[]; + waiting: Participant[]; + declined: Participant[]; + everyone: Participant[]; + hasStatuses: boolean; +}; + +type StatusBanner = { + tone: "success" | "error" | "info"; + message: string; +}; + +const pickString = (...values: Array): string | undefined => { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return undefined; +}; + +const pickBoolean = (...values: Array): boolean => { + for (const value of values) { + if (value === true) return true; + if (value === false) return false; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["true", "1", "yes"].includes(normalized)) return true; + if (["false", "0", "no"].includes(normalized)) return false; + } + } + return false; +}; + +const pickNumber = (...values: Array): number | undefined => { + for (const value of values) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number.parseFloat(value); + if (Number.isFinite(parsed)) return parsed; + } + } + return undefined; +}; + +const deriveShareLink = (record?: Record | null) => + pickString( + record?.share_link, + record?.shareLink, + record?.share_url, + record?.shareUrl, + record?.url, + ); + +const toInputDate = (value?: string) => { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toISOString().slice(0, 10); +}; + +const toInputTime = (value?: string) => { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toISOString().slice(11, 16); +}; + +const mergeDateAndTime = (date: string, time: string): string | undefined => { + if (!date) return undefined; + if (!time) return new Date(date).toISOString(); + const iso = new Date(`${date}T${time}`).toISOString(); + if (!iso || iso === "Invalid Date") return undefined; + return iso; +}; + +const normalizeParticipants = (record?: Record | null): ParticipantGroups => { + const source = + (Array.isArray(record?.participants) && record?.participants) || + (Array.isArray(record?.invitees) && record?.invitees) || + (Array.isArray(record?.roster) && record?.roster) || + []; + + const everyone: Participant[] = source + .map((entry) => { + if (!entry || typeof entry !== "object") return null; + const data = entry as Record; + const profile = data.profile as Record | undefined; + const status = pickString( + data.status, + data.response, + data.rsvp_status, + data.rsvpStatus, + data.invite_status, + data.inviteStatus, + ); + const contact = pickString( + data.phone, + data.phone_number, + data.phoneNumber, + data.contact, + data.email, + profile?.phone, + profile?.email, + ); + const id = pickString( + data.id, + data.identity_id, + data.user_id, + data.player_id, + profile?.id, + ); + const name = pickString( + data.name, + data.full_name, + data.fullName, + data.display_name, + data.displayName, + data.player_name, + profile?.name, + profile?.full_name, + profile?.display_name, + ); + return { + id: id ?? undefined, + name: name ?? "Unknown player", + contact: contact ?? undefined, + status: status ?? undefined, + hosting: pickBoolean(data.hosting, data.is_host, data.isHost), + isCurrentUser: pickBoolean(data.is_current_user, data.isCurrentUser), + } as Participant; + }) + .filter(Boolean) as Participant[]; + + const accepted: Participant[] = []; + const waiting: Participant[] = []; + const declined: Participant[] = []; + + const mapStatus = (value?: string) => value?.trim().toLowerCase(); + + everyone.forEach((participant) => { + const normalizedStatus = mapStatus(participant.status); + if (!normalizedStatus) return waiting.push(participant); + if (["accepted", "yes", "confirmed", "joined"].some((flag) => normalizedStatus.includes(flag))) { + accepted.push(participant); + return; + } + if (["declined", "no", "rejected", "cancelled"].some((flag) => normalizedStatus.includes(flag))) { + declined.push(participant); + return; + } + waiting.push(participant); + }); + + const hasStatuses = accepted.length > 0 || declined.length > 0 || waiting.some((p) => Boolean(p.status)); + + return { accepted, waiting, declined, everyone, hasStatuses }; +}; + +const buildFormStateFromMatch = ( + match: NormalizedMatch | null, + record: Record | null, +): { + date: string; + time: string; + location: string; + matchFormat: string; + skillLevel: string; + playerLimit: string; + notes: string; +} => { + const startIso = + match?.startDateTimeIso || + pickString(record?.start_date_time as string, record?.startDateTime as string, record?.start_at as string); + + const locationText = + match?.location || + pickString( + record?.location_text as string, + record?.locationText as string, + record?.location as string, + record?.location_name as string, + ) || + ""; + + const matchFormat = + match?.format || + pickString(record?.match_format as string, record?.matchFormat as string, record?.format as string) || + ""; + + const skillLevel = + match?.level?.summary || + pickString(record?.skill_level as string, record?.skillLevel as string, record?.level as string) || + ""; + + const playerLimit = + pickNumber( + match?.totalSpots, + record?.player_limit, + record?.playerLimit, + record?.player_cap, + record?.max_players, + ); + + const notes = pickString(record?.notes as string, record?.description as string) || ""; + + return { + date: toInputDate(startIso), + time: toInputTime(startIso), + location: locationText, + matchFormat, + skillLevel, + playerLimit: playerLimit !== undefined ? String(playerLimit) : "", + notes, + }; +}; + const MatchDetailsPage = () => { const navigate = useNavigate(); const { id } = useParams(); const { user } = useAuth() as { user?: unknown }; + const [match, setMatch] = useState(null); + const [matchRecord, setMatchRecord] = useState | null>(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [refreshIndex, setRefreshIndex] = useState(0); + const [statusBanner, setStatusBanner] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [formState, setFormState] = useState(() => + buildFormStateFromMatch(null, { + location: "", + }), + ); + const [formErrors, setFormErrors] = useState>({}); + const [shareLink, setShareLink] = useState(undefined); + const [isShareLoading, setIsShareLoading] = useState(false); + const [inviteForm, setInviteForm] = useState({ playerIds: "", phoneNumbers: "" }); - const loadMatch = useMemo(() => { - return async (signal: AbortSignal) => { - if (!id) return; - setIsLoading(true); - setError(null); - - try { - const token = getStoredAuthToken({ preferScheme: "Token" }); - const response = await getMatchById(id, { - token: token ?? undefined, - signal, - includeHidden: true, - }); - const normalized = normalizeMatchDetail(response, { currentUser: user }); - setMatch(normalized); - } catch (loadError) { - if (signal.aborted) return; - console.error("Failed to load match details", loadError); - setError(loadError instanceof Error ? loadError.message : "Unable to load match details."); - } finally { - if (!signal.aborted) { - setIsLoading(false); - } + const statusLabel = pickString( + matchRecord?.status as string, + matchRecord?.match_status as string, + matchRecord?.state as string, + ); + const isCancelled = + pickBoolean(matchRecord?.cancelled, matchRecord?.is_cancelled, matchRecord?.isCancelled) || + statusLabel?.toLowerCase?.().includes("cancel") === true; + const isArchived = + pickBoolean(matchRecord?.archived, matchRecord?.is_archived, matchRecord?.hidden) || + statusLabel?.toLowerCase?.().includes("archive") === true; + + const participantGroups = useMemo(() => normalizeParticipants(matchRecord), [matchRecord]); + + const canEdit = match?.relationship === "host" && !isArchived && !isCancelled; + const isParticipant = match?.relationship === "participant"; + const isHost = match?.relationship === "host"; + const isOpenMatch = (match?.access || "").toLowerCase() === "open"; + + const loadMatch = async (signal: AbortSignal) => { + if (!id) return; + setIsLoading(true); + setError(null); + + try { + const token = getStoredAuthToken({ preferScheme: "Token" }); + const response = await getMatchById(id, { + token: token ?? undefined, + signal, + includeHidden: true, + }); + const normalized = normalizeMatchDetail(response, { currentUser: user }); + setMatch(normalized); + setMatchRecord(response as Record); + setShareLink(deriveShareLink(response as Record)); + setFormState(buildFormStateFromMatch(normalized, response as Record)); + } catch (loadError) { + if (signal.aborted) return; + console.error("Failed to load match details", loadError); + setError(loadError instanceof Error ? loadError.message : "Not found or access denied."); + } finally { + if (!signal.aborted) { + setIsLoading(false); } - }; - }, [id, user]); + } + }; useEffect(() => { const controller = new AbortController(); loadMatch(controller.signal); return () => controller.abort(); - }, [loadMatch, refreshIndex]); - - const playersJoined = match?.playersJoined ?? 0; - const totalSpots = match?.totalSpots ?? playersJoined; - const spotsAvailable = Math.max((match?.playersNeeded ?? 0) || totalSpots - playersJoined, 0); - const playersLabel = totalSpots ? `${playersJoined}/${totalSpots} players` : `${playersJoined} players`; - const availabilityLabel = - totalSpots > 0 - ? spotsAvailable === 0 - ? "Match is full" - : `${spotsAvailable} spot${spotsAvailable === 1 ? "" : "s"} available` - : "Spots available"; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, user, refreshIndex]); const handleRetry = () => setRefreshIndex((value) => value + 1); - const pills = useMemo(() => { - if (!match) return [] as Array<{ label: string; tone: "success" | "warning" | "info" }>; - const values: Array<{ label: string; tone: "success" | "warning" | "info" }> = []; - values.push({ label: match.access, tone: "success" }); - const isInviteOnlyVisibility = - match.visibility === "private" || match.visibilityLabel?.toLowerCase() === "invite only"; - if (match.visibilityLabel && match.visibilityLabel !== "Open" && !isInviteOnlyVisibility) { - values.push({ label: match.visibilityLabel, tone: "warning" }); - } - if (match.relationship === "host") values.push({ label: "Hosting", tone: "info" }); - if (match.relationship === "participant") values.push({ label: "Joined", tone: "info" }); - return values; - }, [match]); - const handlePrimaryAction = () => { if (!match) return; navigate(`/matches/${match.id}`); }; + const validateForm = () => { + const errors: Record = {}; + if (!formState.date) errors.date = "Date is required"; + if (!formState.time) errors.time = "Start time is required"; + if (!formState.location.trim()) errors.location = "Location is required"; + if (formState.playerLimit) { + const parsed = Number.parseInt(formState.playerLimit, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + errors.playerLimit = "Player limit must be a positive number"; + } + } + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSave = async () => { + if (!id) return; + if (!validateForm()) return; + const token = getStoredAuthToken({ preferScheme: "Token" }); + if (!token) { + setStatusBanner({ tone: "error", message: "Please sign in to update this match." }); + return; + } + + try { + const startDateTime = mergeDateAndTime(formState.date, formState.time); + await updateMatch(id, { + startDateTime, + locationText: formState.location, + matchFormat: formState.matchFormat || null, + skillLevel: isOpenMatch ? formState.skillLevel || null : null, + playerLimit: formState.playerLimit ? Number(formState.playerLimit) : null, + notes: formState.notes || null, + }, { + token, + }); + setStatusBanner({ tone: "success", message: "Match updated successfully." }); + setIsEditing(false); + setRefreshIndex((value) => value + 1); + } catch (updateError) { + console.error(updateError); + setStatusBanner({ + tone: "error", + message: + (updateError as Error | undefined)?.message ?? "Unable to save changes. Please try again.", + }); + } + }; + + const handleJoin = async () => { + if (!id) return; + const token = getStoredAuthToken({ preferScheme: "Token" }); + if (!token) { + setStatusBanner({ tone: "error", message: "Please sign in to join this match." }); + return; + } + try { + await joinMatch(id, { token }); + setStatusBanner({ tone: "success", message: "You have joined this match." }); + setRefreshIndex((value) => value + 1); + } catch (joinError) { + console.error(joinError); + setStatusBanner({ tone: "error", message: (joinError as Error | undefined)?.message ?? "Unable to join." }); + } + }; + + const handleLeave = async () => { + if (!id) return; + const token = getStoredAuthToken({ preferScheme: "Token" }); + if (!token) { + setStatusBanner({ tone: "error", message: "Please sign in to leave this match." }); + return; + } + try { + await leaveMatch(id, { token }); + setStatusBanner({ tone: "info", message: "You left this match." }); + setRefreshIndex((value) => value + 1); + } catch (leaveError) { + console.error(leaveError); + setStatusBanner({ tone: "error", message: (leaveError as Error | undefined)?.message ?? "Unable to leave." }); + } + }; + + const handleRemoveParticipant = async (participantId?: string) => { + if (!participantId || !id) return; + const token = getStoredAuthToken({ preferScheme: "Token" }); + if (!token) { + setStatusBanner({ tone: "error", message: "Please sign in to manage participants." }); + return; + } + try { + await removeMatchParticipant(id, participantId, { token }); + setStatusBanner({ tone: "info", message: "Participant removed." }); + setRefreshIndex((value) => value + 1); + } catch (removeError) { + console.error(removeError); + setStatusBanner({ + tone: "error", + message: (removeError as Error | undefined)?.message ?? "Unable to remove participant.", + }); + } + }; + + const handleSendInvites = async () => { + if (!id) return; + const token = getStoredAuthToken({ preferScheme: "Token" }); + if (!token) { + setStatusBanner({ tone: "error", message: "Please sign in to send invites." }); + return; + } + + const playerIds = inviteForm.playerIds + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const phoneNumbers = inviteForm.phoneNumbers + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + + try { + await sendMatchInvites( + id, + { + playerIds, + phoneNumbers, + }, + { token }, + ); + setStatusBanner({ tone: "success", message: "Invites sent." }); + setInviteForm({ playerIds: "", phoneNumbers: "" }); + setRefreshIndex((value) => value + 1); + } catch (inviteError) { + console.error(inviteError); + setStatusBanner({ + tone: "error", + message: (inviteError as Error | undefined)?.message ?? "Unable to send invites.", + }); + } + }; + + const handleGenerateShareLink = async () => { + if (!id) return; + const token = getStoredAuthToken({ preferScheme: "Token" }); + if (!token) { + setStatusBanner({ tone: "error", message: "Please sign in to generate a share link." }); + return; + } + try { + setIsShareLoading(true); + const response = await getMatchShareLink(id, { token }); + if (response.shareLink) setShareLink(response.shareLink); + setStatusBanner({ tone: "success", message: "Share link ready." }); + } catch (shareError) { + console.error(shareError); + setStatusBanner({ tone: "error", message: (shareError as Error | undefined)?.message ?? "Unable to fetch link." }); + } finally { + setIsShareLoading(false); + } + }; + + const handleCopyShareLink = () => { + if (!shareLink) return; + navigator.clipboard.writeText(shareLink).catch(() => { + /* ignore */ + }); + setStatusBanner({ tone: "info", message: "Link copied to clipboard." }); + }; + const detailItems = [ { icon: