diff --git a/src/api/config.ts b/src/api/config.ts index 918ad01e..7d262620 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -5,4 +5,4 @@ export const API_BASE_URL = import.meta.env.VITE_API_URL || DEFAULT_API_BASE_URL; -export const DEFAULT_AUTH_SCHEME = "token"; +export const DEFAULT_AUTH_SCHEME = "Bearer"; diff --git a/src/api/http.ts b/src/api/http.ts index cc21dcdf..dc9b3f49 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -1,4 +1,5 @@ import { API_BASE_URL, DEFAULT_AUTH_SCHEME } from "./config"; +import { getStoredAuthToken, normalizeAuthToken } from "../services/authToken"; export type AuthScheme = "token" | "Bearer" | (string & {}); @@ -9,6 +10,10 @@ export interface RequestQuery { export interface RequestOptions { method?: string; token?: string; + /** + * Alias for `token` used by legacy callers; if provided, `token` takes precedence. + */ + authToken?: string; authScheme?: AuthScheme; headers?: Record; query?: RequestQuery; @@ -41,6 +46,8 @@ const buildQueryString = (query?: RequestQuery) => { const normalizeAuthHeader = (token?: string, scheme: AuthScheme = DEFAULT_AUTH_SCHEME) => { if (!token) return undefined; + const normalized = normalizeAuthToken(token, { preferScheme: scheme, defaultScheme: scheme }); + if (normalized) return normalized; if (/^\s*([A-Za-z]+)\s+/i.test(token)) { // Token already includes scheme return token; @@ -61,6 +68,7 @@ export async function request( { method = "GET", token, + authToken, authScheme = DEFAULT_AUTH_SCHEME, headers = {}, query, @@ -74,7 +82,11 @@ export async function request( ? path : `${API_BASE_URL}${path.startsWith("/") ? "" : "/"}${path.replace(/^\//, "")}`; const queryString = buildQueryString(query); - const authHeader = normalizeAuthHeader(token, authScheme); + const storedToken = + token ?? + authToken ?? + getStoredAuthToken({ preferScheme: authScheme, defaultScheme: authScheme }); + const authHeader = normalizeAuthHeader(storedToken, authScheme); const finalHeaders: Record = { Accept: "application/json", diff --git a/src/api/matches.ts b/src/api/matches.ts index 0d657fef..4d461408 100644 --- a/src/api/matches.ts +++ b/src/api/matches.ts @@ -1,4 +1,5 @@ import { request, type RequestQuery } from "./http"; +import { getStoredAuthToken, normalizeAuthToken } from "../services/authToken"; export type TruthyLike = boolean | string | number; @@ -184,6 +185,13 @@ const deriveShareLink = (source: unknown): string | undefined => { return shareCandidate; }; +const resolveAuthToken = (token?: string | null) => { + const normalized = normalizeAuthToken(token ?? getStoredAuthToken({ preferScheme: "Bearer" }), { + preferScheme: "Bearer", + }); + return normalized ?? undefined; +}; + const persistCreatedMatch = (response: unknown) => { const match = extractMatchDetail(response) as Record | undefined; const matchId = deriveMatchId(match ?? response); @@ -1242,7 +1250,7 @@ export const listMatches = async ({ token, signal, ...params }: ListMatchesParam const query = buildMatchesQuery(params); const response = await request("/matches", { query, - token: token ?? undefined, + token: resolveAuthToken(token), signal, }); @@ -1254,14 +1262,152 @@ export const listMatches = async ({ token, signal, ...params }: ListMatchesParam export const getMatchById = async ( id: string | number, - { token, signal, ...params }: Omit = {}, + { + token, + signal, + includeHidden = false, + include_hidden, + ...params + }: Omit = {}, ) => { - const query = buildMatchesQuery(params); + const query = buildMatchesQuery({ includeHidden, include_hidden, ...params }); const response = await request(`/matches/${id}`, { query, - token: token ?? undefined, + token: resolveAuthToken(token), signal, }); return response; }; +export const updateMatch = async ( + id: string | number, + { + startDate, + startTime, + locationText, + matchFormat, + skillLevel, + playerLimit, + notes, + token, + signal, + }: { + startDate?: string; + startTime?: string; + locationText?: string; + matchFormat?: string | null; + skillLevel?: string | number | null; + playerLimit?: number | null; + notes?: string | null; + token?: string | null; + signal?: AbortSignal; + } = {}, +) => { + const startIso = startDate && startTime ? toIsoString(`${startDate}T${startTime}`) : undefined; + const payload: Record = {}; + + if (startIso) { + payload.start_date_time = startIso; + payload.dateTime = startIso; + } + + if (locationText !== undefined) { + payload.location_text = locationText; + payload.location = locationText; + } + + if (matchFormat !== undefined) { + payload.match_format = matchFormat; + payload.format = matchFormat; + } + + if (skillLevel !== undefined) { + payload.skill_level_min = skillLevel; + payload.skillLevel = skillLevel; + } + + if (playerLimit !== undefined) { + payload.player_limit = playerLimit; + payload.playerCount = playerLimit; + } + + if (notes !== undefined) { + payload.notes = notes; + } + + return request(`/matches/${id}`, { + method: "PUT", + token: resolveAuthToken(token), + authScheme: "Bearer", + signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +}; + +export const joinMatch = async ( + id: string | number, + { token, signal }: { token?: string | null; signal?: AbortSignal } = {}, +) => + request(`/matches/${id}/join`, { + method: "POST", + token: resolveAuthToken(token), + authScheme: "Bearer", + signal, + }); + +export const leaveMatch = async ( + id: string | number, + { token, signal }: { token?: string | null; signal?: AbortSignal } = {}, +) => + request(`/matches/${id}/leave`, { + method: "POST", + token: resolveAuthToken(token), + authScheme: "Bearer", + signal, + }); + +export const removeMatchParticipant = async ( + matchId: string | number, + playerId: string | number, + { token, signal }: { token?: string | null; signal?: AbortSignal } = {}, +) => + request(`/matches/${matchId}/participants/${playerId}`, { + method: "DELETE", + token: resolveAuthToken(token), + authScheme: "Bearer", + signal, + }); + +export const sendMatchInvites = async ( + matchId: string | number, + { + playerIds = [], + phoneNumbers = [], + token, + signal, + }: { playerIds?: Array; phoneNumbers?: string[]; token?: string | null; signal?: AbortSignal } = {}, +) => + request(`/matches/${matchId}/invites`, { + method: "POST", + token: resolveAuthToken(token), + authScheme: "Bearer", + signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + playerIds, + phoneNumbers, + }), + }); + +export const getMatchShareLink = async ( + id: string | number, + { token, signal }: { token?: string | null; signal?: AbortSignal } = {}, +) => + request(`/matches/${id}/share-link`, { + method: "GET", + token: resolveAuthToken(token), + authScheme: "Bearer", + signal, + }); + diff --git a/src/pages/MatchDetailsPage.css b/src/pages/MatchDetailsPage.css index 9ce8fa0a..e89e0f92 100644 --- a/src/pages/MatchDetailsPage.css +++ b/src/pages/MatchDetailsPage.css @@ -1,31 +1,59 @@ .match-details-page { display: flex; flex-direction: column; - gap: 20px; + gap: 16px; padding-bottom: 48px; color: #0f172a; } -.match-details-page--compact { - align-items: center; -} - .match-details-card { - width: min(720px, 100%); background: #ffffff; border: 1px solid #e2e8f0; border-radius: 18px; box-shadow: 0 16px 42px -24px rgba(15, 23, 42, 0.35); - padding: 22px 24px 18px; + padding: 22px 24px; display: flex; flex-direction: column; - gap: 16px; + gap: 20px; } .match-details-card__header { display: flex; flex-direction: column; - gap: 10px; + gap: 12px; +} + +.match-details-card__headline { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.match-details-card__eyebrow { + margin: 0; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #475569; + font-size: 12px; +} + +.match-details-card__title { + margin: 0; + font-size: 28px; + line-height: 1.2; +} + +.match-details-card__meta { + margin: 0; + color: #475569; + font-weight: 600; +} + +.match-details-card__badges { + display: flex; + gap: 8px; } .match-details-card__pills { @@ -59,109 +87,364 @@ color: #6d28d9; } -.match-details-card__title { - margin: 0; - font-size: 26px; - line-height: 1.2; +.match-details-card__toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; } -.match-details-card__meta { - margin: 0; +.match-details-card__status { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: #f8fafc; + border-radius: 12px; + color: #0f172a; + font-weight: 700; +} + +.match-details-card__actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.match-details-card__button, +.match-primary, +.match-secondary, +.match-link { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-radius: 12px; + border: 1px solid transparent; + font-weight: 700; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; + text-decoration: none; +} + +.match-details-card__button, +.match-primary { + background: linear-gradient(135deg, #22c55e, #16a34a); + color: #ffffff; + border-color: #22c55e; +} + +.match-details-card__button:hover, +.match-details-card__button:focus-visible, +.match-primary:hover, +.match-primary:focus-visible { + transform: translateY(-1px); + box-shadow: 0 12px 28px rgba(22, 163, 74, 0.35); + outline: none; +} + +.match-details-card__button--secondary, +.match-secondary { + background: #ffffff; + color: #0f172a; + border-color: #e2e8f0; + box-shadow: none; +} + +.match-link { + background: transparent; + border: none; + color: #0ea5e9; + padding: 6px 0; +} + +.match-banner { + border-radius: 12px; + padding: 12px 14px; + font-weight: 700; +} + +.match-banner--success { + background: rgba(34, 197, 94, 0.1); + color: #166534; + border: 1px solid rgba(34, 197, 94, 0.5); +} + +.match-banner--error { + background: rgba(239, 68, 68, 0.12); + color: #991b1b; + border: 1px solid rgba(239, 68, 68, 0.35); +} + +.match-badge { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 10px; + font-weight: 800; + font-size: 12px; + letter-spacing: 0.02em; +} + +.match-badge--warning { + background: rgba(250, 204, 21, 0.2); + color: #854d0e; +} + +.match-badge--muted { + background: #e2e8f0; color: #475569; - font-weight: 600; } -.match-details-card__body { +.match-section { border: 1px solid #e2e8f0; - border-radius: 14px; - padding: 14px 16px; + border-radius: 16px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 14px; } -.match-details-card__list { - list-style: none; +.match-section__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.match-section__header h2 { margin: 0; - padding: 0; + font-size: 18px; +} + +.match-section__helper { + margin: 0; + color: #475569; +} + +.match-section__hint { + display: inline-flex; + align-items: center; + gap: 6px; + color: #92400e; + font-weight: 700; +} + +.match-form { display: flex; flex-direction: column; - gap: 12px; + gap: 14px; } -.match-details-card__item { +.match-form__grid { display: grid; - grid-template-columns: auto 1fr; - gap: 10px 12px; - align-items: center; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px 16px; } -.match-details-card__icon { - width: 34px; - height: 34px; - border-radius: 12px; - display: inline-flex; - align-items: center; - justify-content: center; - background: rgba(15, 23, 42, 0.04); +.match-field { + display: flex; + flex-direction: column; + gap: 6px; + font-weight: 700; color: #0f172a; } -.match-details-card__text { +.match-field--wide { + grid-column: 1 / -1; +} + +.match-field input, +.match-field select, +.match-field textarea { + padding: 10px 12px; + border: 1px solid #cbd5e1; + border-radius: 10px; + font-size: 14px; +} + +.match-field small { + color: #475569; + font-weight: 500; +} + +.match-form__actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.match-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px 16px; +} + +.match-summary__item { + background: #f8fafc; + border-radius: 12px; + padding: 12px; display: flex; flex-direction: column; - gap: 2px; + gap: 4px; } -.match-details-card__primary { +.match-summary__label { margin: 0; font-weight: 700; + color: #475569; +} + +.match-summary__value { + margin: 0; + font-weight: 800; color: #0f172a; } -.match-details-card__secondary { +.match-summary__helper { margin: 0; color: #475569; } -.match-details-card__footer { +.match-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 14px; +} + +.match-column__title { + margin: 0 0 8px; + font-weight: 800; +} + +.match-list { + list-style: none; + margin: 0; + padding: 0; display: flex; - justify-content: flex-end; + flex-direction: column; + gap: 8px; } -.match-details-card__actions { +.match-list__item { display: flex; + align-items: center; + justify-content: space-between; gap: 10px; - flex-wrap: wrap; + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 12px; +} + +.match-list__name { + margin: 0; + font-weight: 800; } -.match-details-card__button { +.match-list__meta { + margin: 2px 0 0; + color: #475569; +} + +.match-list__empty { + padding: 10px 12px; + color: #475569; + background: #f8fafc; + border-radius: 10px; +} + +.match-chip { display: inline-flex; align-items: center; + padding: 6px 10px; + background: rgba(14, 165, 233, 0.1); + color: #0369a1; + border-radius: 999px; + font-weight: 700; +} + +.match-chip--muted { + background: #e2e8f0; + color: #475569; +} + +.match-column--panel { + border: 1px solid #e2e8f0; + border-radius: 14px; + padding: 14px; + background: #f8fafc; +} + +.match-panel__header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.match-panel__eyebrow { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 12px; + color: #475569; + font-weight: 700; +} + +.match-panel__helper { + margin: 4px 0 0; + color: #475569; +} + +.match-share { + margin-top: 10px; + display: flex; + flex-direction: column; gap: 8px; - padding: 10px 16px; +} + +.match-share input { + width: 100%; + padding: 10px 12px; + border: 1px solid #cbd5e1; + border-radius: 10px; +} + +.match-share__actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.match-invite { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.match-alert { + display: flex; + gap: 10px; + align-items: center; + border: 1px solid rgba(251, 191, 36, 0.6); + background: rgba(251, 191, 36, 0.12); + padding: 12px 14px; border-radius: 12px; - border: 1px solid #22c55e; - background: linear-gradient(135deg, #22c55e, #16a34a); - color: #ffffff; - font-weight: 700; - cursor: pointer; - transition: transform 0.15s ease, box-shadow 0.15s ease; } -.match-details-card__button:hover, -.match-details-card__button:focus-visible { - transform: translateY(-1px); - box-shadow: 0 12px 28px rgba(22, 163, 74, 0.35); - outline: none; +.match-alert__title { + margin: 0; + font-weight: 800; } -.match-details-card__button--secondary { - background: #ffffff; - color: #0f172a; - border-color: #e2e8f0; - box-shadow: none; +.match-alert__body { + margin: 2px 0 0; + color: #92400e; } .match-details-state { - width: min(640px, 100%); + width: min(760px, 100%); border: 1px dashed #cbd5e1; background: #f8fafc; color: #0f172a; @@ -196,20 +479,17 @@ cursor: pointer; } -@media (max-width: 640px) { - .match-details-card__title { - font-size: 22px; - } - - .match-details-card__body { - padding: 12px; +@media (max-width: 768px) { + .match-details-card__headline { + flex-direction: column; } - .match-details-card__item { - grid-template-columns: auto 1fr; + .match-details-card__toolbar { + flex-direction: column; + align-items: flex-start; } - .match-details-card__footer { - justify-content: center; + .match-form__actions { + justify-content: flex-start; } } diff --git a/src/pages/MatchDetailsPage.tsx b/src/pages/MatchDetailsPage.tsx index 69acd6df..9023b258 100644 --- a/src/pages/MatchDetailsPage.tsx +++ b/src/pages/MatchDetailsPage.tsx @@ -1,22 +1,173 @@ 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, + BadgeInfo, + Calendar, + Copy, + MapPin, + 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, + updateMatch, + type NormalizedMatch, +} from "../api/matches"; import MainLayout from "../components/MainLayout"; +import { useAuth } from "../context/AuthContext"; import { getStoredAuthToken } from "../services/authToken"; import "./MatchDetailsPage.css"; +type BannerState = { type: "success" | "error"; message: string } | null; + +type Invitation = { + id?: string; + name?: string; + contact?: string; + status?: string; +}; + +const formatDateInput = (value?: string) => { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toISOString().slice(0, 10); +}; + +const formatTimeInput = (value?: string) => { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toISOString().slice(11, 16); +}; + +const normalizeInvitation = (record: unknown): Invitation | null => { + if (!record || typeof record !== "object") return null; + const data = record as Record; + const profile = (data.profile as Record | undefined) ?? undefined; + + const name = [ + data.name, + data.full_name, + data.fullName, + data.display_name, + data.displayName, + data.player_name, + profile?.name, + profile?.full_name, + profile?.display_name, + ].find((value) => typeof value === "string" && value.trim()) as string | undefined; + + const contactCandidate = [ + data.phone, + data.phone_number, + data.phoneNumber, + data.email, + profile?.phone, + profile?.phone_number, + profile?.phoneNumber, + profile?.email, + ].find((value) => typeof value === "string" && value.trim()) as string | undefined; + + const status = [data.status, data.invitation_status, data.response_status] + .map((value) => (typeof value === "string" ? value.toLowerCase() : "")) + .find((value) => value); + + const id = [ + data.id, + data.uuid, + data.identity_id, + data.profile_id, + data.player_id, + data.user_id, + ] + .map((value) => (value === undefined || value === null ? undefined : String(value))) + .find(Boolean); + + if (!id && !name && !contactCandidate) return null; + + return { + id, + name, + contact: contactCandidate, + status, + }; +}; + +const splitInvitations = (records?: Invitation[]) => { + if (!records || records.length === 0) { + return { hasStatuses: false, accepted: [], waiting: [], declined: [] } as { + hasStatuses: boolean; + accepted: Invitation[]; + waiting: Invitation[]; + declined: Invitation[]; + }; + } + + const accepted: Invitation[] = []; + const waiting: Invitation[] = []; + const declined: Invitation[] = []; + + records.forEach((invite) => { + const status = invite.status ?? ""; + if (!status) { + waiting.push(invite); + return; + } + if (["accepted", "joined", "yes"].includes(status)) { + accepted.push(invite); + return; + } + if (["declined", "rejected", "no"].includes(status)) { + declined.push(invite); + return; + } + waiting.push(invite); + }); + + const hasStatuses = accepted.length + declined.length > 0; + return { hasStatuses, accepted, waiting, declined }; +}; + const MatchDetailsPage = () => { const navigate = useNavigate(); const { id } = useParams(); const { user } = useAuth() as { user?: unknown }; const [match, setMatch] = useState(null); + const [rawMatch, setRawMatch] = useState | null>(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [refreshIndex, setRefreshIndex] = useState(0); + const [banner, setBanner] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [shareLink, setShareLink] = useState(""); + const [saving, setSaving] = useState(false); + const [joining, setJoining] = useState(false); + const [leaving, setLeaving] = useState(false); + const [sendingInvites, setSendingInvites] = useState(false); + const [formState, setFormState] = useState({ + date: "", + time: "", + location: "", + matchFormat: "", + skillLevel: "", + playerLimit: "", + notes: "", + }); + const [inviteInputs, setInviteInputs] = useState({ players: "", phones: "" }); + + // Normalize the stored auth token from localStorage into a Bearer header value for all requests. + const token = useMemo(() => getStoredAuthToken({ preferScheme: "Bearer" }), []); const loadMatch = useMemo(() => { return async (signal: AbortSignal) => { @@ -25,14 +176,16 @@ const MatchDetailsPage = () => { 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 }); + const raw = (response as Record)?.match ?? (response as Record); + setRawMatch(raw as Record); setMatch(normalized); + setShareLink((raw as Record)?.share_link as string); + setBanner(null); } catch (loadError) { if (signal.aborted) return; console.error("Failed to load match details", loadError); @@ -43,7 +196,7 @@ const MatchDetailsPage = () => { } } }; - }, [id, user]); + }, [id, token, user]); useEffect(() => { const controller = new AbortController(); @@ -51,6 +204,24 @@ const MatchDetailsPage = () => { return () => controller.abort(); }, [loadMatch, refreshIndex]); + useEffect(() => { + if (!match) return; + setFormState({ + date: formatDateInput(match.startDateTimeIso), + time: formatTimeInput(match.startDateTimeIso), + location: (rawMatch?.location_text as string) || match.location || "", + matchFormat: (rawMatch?.match_format as string) || match.format || "", + skillLevel: (rawMatch?.skill_level_min as string) || (rawMatch?.skillLevel as string) || "", + playerLimit: + (rawMatch?.player_limit as string | number) !== undefined + ? String(rawMatch?.player_limit ?? "") + : match.totalSpots + ? String(match.totalSpots) + : "", + notes: (rawMatch?.notes as string) || "", + }); + }, [match, rawMatch]); + const playersJoined = match?.playersJoined ?? 0; const totalSpots = match?.totalSpots ?? playersJoined; const spotsAvailable = Math.max((match?.playersNeeded ?? 0) || totalSpots - playersJoined, 0); @@ -78,48 +249,212 @@ const MatchDetailsPage = () => { return values; }, [match]); - const handlePrimaryAction = () => { - if (!match) return; - navigate(`/matches/${match.id}`); - }; + const invitations = useMemo(() => { + const invitesArray = [ + (rawMatch?.invites as unknown[] | undefined), + (rawMatch?.invitations as unknown[] | undefined), + (rawMatch?.invitees as unknown[] | undefined), + ].find((value) => Array.isArray(value)); + + const normalized = invitesArray?.map((invite) => normalizeInvitation(invite)).filter(Boolean) as Invitation[] | undefined; + return splitInvitations(normalized); + }, [rawMatch]); + + const isHost = match?.relationship === "host"; + const isOpenMatch = match?.access === "Open"; + const isArchived = rawMatch?.archived === true || rawMatch?.status === "archived"; + const isCancelled = rawMatch?.cancelled === true || rawMatch?.status === "cancelled"; const detailItems = [ { - icon: