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: ,
- title: match?.startDisplay,
- subtitle: match?.startDateTimeIso,
+ label: "Date & time",
+ value: match?.startDisplay,
+ helper: match?.startDateTimeIso,
},
{
- icon: ,
- title: match?.location,
- subtitle: [match?.locationDetail, match?.distance].filter(Boolean).join(" · ") || undefined,
+ label: "Location",
+ value: match?.location,
+ helper: [match?.locationDetail, match?.distance].filter(Boolean).join(" · ") || undefined,
},
{
- icon: ,
- title: playersLabel,
- subtitle: availabilityLabel,
+ label: "Match format",
+ value: match?.format || "Not set",
},
- match?.level
- ? {
- icon: ,
- title: `Suggested level: ${match.level.summary}`,
- subtitle: match.level.detail,
- }
- : null,
- match?.format
+ isOpenMatch
? {
- icon: ,
- title: `Match format: ${match.format}`,
+ label: "Skill level",
+ value: match?.level?.summary || "Open level",
+ helper: match?.level?.detail,
}
: null,
- ].filter(Boolean) as Array<{ icon: JSX.Element; title?: string; subtitle?: string }>;
+ {
+ label: "Players",
+ value: playersLabel,
+ helper: availabilityLabel,
+ },
+ {
+ label: "Notes",
+ value: (rawMatch?.notes as string) || "No notes yet",
+ },
+ ].filter(Boolean) as Array<{ label: string; value?: string; helper?: string }>;
+
+ const handleFormChange = (
+ field: keyof typeof formState,
+ value: string,
+ ) => setFormState((prev) => ({ ...prev, [field]: value }));
+
+ const handleSave = async () => {
+ if (!match?.id || isArchived || isCancelled) return;
+ setSaving(true);
+ setBanner(null);
+ try {
+ await updateMatch(match.id, {
+ startDate: formState.date,
+ startTime: formState.time,
+ locationText: formState.location,
+ matchFormat: formState.matchFormat,
+ skillLevel: isOpenMatch ? formState.skillLevel || null : null,
+ playerLimit: formState.playerLimit ? Number(formState.playerLimit) : null,
+ notes: formState.notes,
+ token: token ?? undefined,
+ });
+ setBanner({ type: "success", message: "Match updated." });
+ setIsEditing(false);
+ setRefreshIndex((value) => value + 1);
+ } catch (saveError) {
+ console.error("Failed to update match", saveError);
+ setBanner({ type: "error", message: saveError instanceof Error ? saveError.message : "Update failed." });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleJoin = async () => {
+ if (!match?.id) return;
+ setJoining(true);
+ setBanner(null);
+ try {
+ await joinMatch(match.id, { token: token ?? undefined });
+ setBanner({ type: "success", message: "You joined the match." });
+ setRefreshIndex((value) => value + 1);
+ } catch (joinError) {
+ console.error("Failed to join", joinError);
+ setBanner({ type: "error", message: joinError instanceof Error ? joinError.message : "Unable to join." });
+ } finally {
+ setJoining(false);
+ }
+ };
+
+ const handleLeave = async () => {
+ if (!match?.id) return;
+ setLeaving(true);
+ setBanner(null);
+ try {
+ await leaveMatch(match.id, { token: token ?? undefined });
+ setBanner({ type: "success", message: "You left the match." });
+ setRefreshIndex((value) => value + 1);
+ } catch (leaveError) {
+ console.error("Failed to leave", leaveError);
+ setBanner({ type: "error", message: leaveError instanceof Error ? leaveError.message : "Unable to leave." });
+ } finally {
+ setLeaving(false);
+ }
+ };
+
+ const handleRemoveParticipant = async (participantId?: string) => {
+ if (!match?.id || !participantId) return;
+ try {
+ await removeMatchParticipant(match.id, participantId, { token: token ?? undefined });
+ setBanner({ type: "success", message: "Participant removed." });
+ setRefreshIndex((value) => value + 1);
+ } catch (removeError) {
+ console.error("Failed to remove participant", removeError);
+ setBanner({ type: "error", message: removeError instanceof Error ? removeError.message : "Unable to remove." });
+ }
+ };
+
+ const handleSendInvites = async () => {
+ if (!match?.id) return;
+ setSendingInvites(true);
+ setBanner(null);
+ try {
+ const playerIds = inviteInputs.players
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean);
+ const phoneNumbers = inviteInputs.phones
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean);
+ await sendMatchInvites(match.id, { playerIds, phoneNumbers, token: token ?? undefined });
+ setBanner({ type: "success", message: "Invites sent." });
+ setInviteInputs({ players: "", phones: "" });
+ } catch (inviteError) {
+ console.error("Failed to send invites", inviteError);
+ setBanner({ type: "error", message: inviteError instanceof Error ? inviteError.message : "Unable to invite." });
+ } finally {
+ setSendingInvites(false);
+ }
+ };
+
+ const handleShareLink = async () => {
+ if (!match?.id) return;
+ try {
+ const response = (await getMatchShareLink(match.id, { token: token ?? undefined })) as Record;
+ const link =
+ (response?.share_link as string) ||
+ (response?.shareLink as string) ||
+ (response?.url as string) ||
+ "";
+ setShareLink(link);
+ setBanner({ type: "success", message: "Share link ready." });
+ } catch (shareError) {
+ console.error("Failed to fetch share link", shareError);
+ setBanner({ type: "error", message: shareError instanceof Error ? shareError.message : "Unable to load share link." });
+ }
+ };
+
+ const handleCopyLink = async () => {
+ if (!shareLink) return;
+ try {
+ await navigator.clipboard.writeText(shareLink);
+ setBanner({ type: "success", message: "Share link copied." });
+ } catch {
+ setBanner({ type: "error", message: "Could not copy link." });
+ }
+ };
+
+ const handlePrimaryAction = () => {
+ if (!match) return;
+ navigate(`/matches/${match.id}`);
+ };
+
+ const participantGroups = invitations.hasStatuses
+ ? invitations
+ : {
+ hasStatuses: false,
+ accepted: match?.participants ?? [],
+ waiting: [],
+ declined: [],
+ };
+
+ const participantCount =
+ (participantGroups.accepted?.length ?? 0) +
+ (participantGroups.waiting?.length ?? 0) +
+ (participantGroups.declined?.length ?? 0);
return (
-
+
+ {banner ? (
+
+ {banner.message}
+
+ ) : null}
+
{isLoading ? (
- Loading match details…
+ Loading…
) : error ? (
@@ -132,6 +467,19 @@ const MatchDetailsPage = () => {
) : match ? (
+
+
+
Match Details
+
{match.location || "Match"}
+ {match.hostName ?
Hosted by {match.hostName}
: null}
+
+
+ {isCancelled ? (
+ CANCELLED
+ ) : null}
+ {isArchived ? ARCHIVED : null}
+
+
{pills.map((pill) => (
{
))}
- {match.location || "Match"}
- {match.hostName ? (
- Hosted by {match.hostName}
- ) : null}
+
+
+
+ {playersLabel} · {availabilityLabel}
+
+
+
+ {isHost ? (
+
+ ) : null}
+
+
-
-
- {detailItems.map((item) => (
- -
-
{item.icon}
-
-
{item.title}
- {item.subtitle ?
{item.subtitle}
: null}
+
+
+
Details
+ {(isArchived || isCancelled) && (
+
+ This match can no longer be edited.
+
+ )}
+
+
+ {isEditing && isHost ? (
+
+ ) : (
+
+ {detailItems.map((item) => (
+
+
{item.label}
+
{item.value || "Not provided"}
+ {item.helper ?
{item.helper}
: null}
-
- ))}
-
-
-
-
+
+
+
+
+
Participants
+
{participantCount} players
+
+ {match?.relationship === "participant" ? (
+
+ ) : match?.relationship === "viewer" ? (
+
+ ) : null}
+
+
+ {participantGroups.hasStatuses ? (
+
+
+
Accepted
+
+ {participantGroups.accepted.map((participant) => (
+ -
+
+
{participant.name || "Player"}
+ {participant.contact ? (
+
{participant.contact}
+ ) : null}
+
+ {isHost ? (
+
+ ) : null}
+
+ ))}
+ {participantGroups.accepted.length === 0 ? - No accepted players.
: null}
+
+
+
+
Waiting on responses
+
+ {participantGroups.waiting.map((participant) => (
+ -
+
+
{participant.name || "Player"}
+ {participant.contact ? (
+
{participant.contact}
+ ) : null}
+
+ Waiting
+
+ ))}
+ {participantGroups.waiting.length === 0 ? - No pending invites.
: null}
+
+
+
+
Declined
+
+ {participantGroups.declined.map((participant) => (
+ -
+
+
{participant.name || "Player"}
+ {participant.contact ? (
+
{participant.contact}
+ ) : null}
+
+ Declined
+
+ ))}
+ {participantGroups.declined.length === 0 ? - No declines.
: null}
+
+
+
+ ) : (
+
+ {(match.participants ?? []).map((participant) => (
+ -
+
+
{participant.name || "Player"}
+ {participant.hosting ?
Host
: null}
+
+ {isHost && !participant.hosting ? (
+
+ ) : null}
+
+ ))}
+ {(match.participants ?? []).length === 0 ? (
+ - No participants yet.
+ ) : null}
+
+ )}
+
+
+
+
+
Host tools
+
+
+ {isHost ? (
+
+ {isOpenMatch ? (
+
+
+
+
Open match
+
Share this match
+
Send a public link to let players join.
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
Invite-only
+
Invite players
+
Send invites by player ID or phone.
+
+
+
+
+
+ )}
+
+ ) : (
+ Only the host can invite or share.
+ )}
+
+
+ {(isCancelled || isArchived) && (
+
+
+
+
Match unavailable
+
This match is {isCancelled ? "cancelled" : "archived"}. Details are read only.
+
-
+ )}
) : (
- No details available for this match.
+ Match unavailable.
)}
diff --git a/src/services/api.js b/src/services/api.js
index cb3e46b6..089ff9c2 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -1,4 +1,4 @@
-import { API_BASE_URL } from "../api/config";
+import { API_BASE_URL, DEFAULT_AUTH_SCHEME } from "../api/config";
import { getStoredAuthToken, normalizeAuthToken } from "./authToken";
const baseURL = API_BASE_URL.replace(/\/+$/, "");
@@ -8,7 +8,7 @@ const api = (path, options = {}) => {
headers: optionHeaders = {},
body: providedBody,
json,
- authSchemePreference = "token",
+ authSchemePreference = DEFAULT_AUTH_SCHEME,
authToken,
credentials: providedCredentials,
...rest
diff --git a/src/services/auth.js b/src/services/auth.js
index 4b1f6628..35783b25 100644
--- a/src/services/auth.js
+++ b/src/services/auth.js
@@ -55,7 +55,7 @@ export const signup = async ({ email, password, name, phone, user_type = 2 }) =>
export const getPersonalDetails = async () =>
unwrap(
api(`/player/personal_details`, {
- authSchemePreference: "token",
+ authSchemePreference: "Bearer",
}),
);
diff --git a/src/services/authToken.js b/src/services/authToken.js
index ed632323..dfe6d49b 100644
--- a/src/services/authToken.js
+++ b/src/services/authToken.js
@@ -20,15 +20,16 @@ const canonicalizeScheme = (scheme) => {
const pickScheme = (detected, { defaultScheme, preferScheme } = {}) => {
const normalizedDetected = canonicalizeScheme(detected);
const normalizedPrefer = canonicalizeScheme(preferScheme);
- if (normalizedDetected) {
- if (normalizedPrefer && normalizedPrefer === normalizedDetected) {
- return normalizedPrefer;
- }
- return normalizedDetected;
- }
+
+ // When a preference is provided, honor it even if the stored token already has a scheme.
if (normalizedPrefer) {
return normalizedPrefer;
}
+
+ if (normalizedDetected) {
+ return normalizedDetected;
+ }
+
return canonicalizeScheme(defaultScheme);
};
@@ -61,11 +62,49 @@ export const normalizeAuthToken = (
return `${finalScheme} ${credentials}`;
};
-export const getStoredAuthToken = (options) => {
+const readCookieValue = (name) => {
+ if (typeof document === "undefined") return null;
try {
- const stored = localStorage.getItem("authToken");
- return normalizeAuthToken(stored, options);
+ const pattern = new RegExp(`(?:^|; )${name.replace(/([.*+?^${}()|[\]\\])/g, "\\$1")}=([^;]*)`);
+ const match = document.cookie.match(pattern);
+ return match ? decodeURIComponent(match[1]) : null;
} catch {
return null;
}
};
+
+const readFromStorage = (key) => {
+ try {
+ const local = localStorage.getItem(key);
+ if (local) return local;
+ } catch {
+ // ignore
+ }
+ try {
+ const session = sessionStorage.getItem(key);
+ if (session) return session;
+ } catch {
+ // ignore
+ }
+ return null;
+};
+
+const TOKEN_KEYS = ["authToken", "access_token", "accessToken", "token"];
+
+export const getStoredAuthToken = (options) => {
+ for (const key of TOKEN_KEYS) {
+ const storageValue = readFromStorage(key);
+ if (storageValue) {
+ const normalized = normalizeAuthToken(storageValue, options);
+ if (normalized) return normalized;
+ }
+ const cookieValue = readCookieValue(key);
+ if (cookieValue) {
+ const normalized = normalizeAuthToken(cookieValue, options);
+ if (normalized) return normalized;
+ }
+ }
+ return null;
+};
+
+export const getSessionToken = (options) => getStoredAuthToken(options);
diff --git a/src/services/avatar.js b/src/services/avatar.js
index 23ae1c97..2e174090 100644
--- a/src/services/avatar.js
+++ b/src/services/avatar.js
@@ -18,8 +18,8 @@ const normalizeUploadResponse = (data = {}) => {
export const getPlayerAWSUrl = async (token, extension = "jpeg") => {
const authHeader = normalizeAuthToken(token, {
- defaultScheme: "token",
- preferScheme: "token",
+ defaultScheme: "Bearer",
+ preferScheme: "Bearer",
});
if (!authHeader) {
throw new Error("Missing player token");
@@ -29,7 +29,7 @@ export const getPlayerAWSUrl = async (token, extension = "jpeg") => {
api(`/player/profile_picture/upload_url`, {
method: "POST",
authToken: authHeader,
- authSchemePreference: "token",
+ authSchemePreference: "Bearer",
json: {
file_extension: extension,
},
diff --git a/src/services/player.js b/src/services/player.js
index ab11029a..91a9e154 100644
--- a/src/services/player.js
+++ b/src/services/player.js
@@ -13,8 +13,8 @@ export const updatePlayerPersonalDetails = async ({
profile_picture,
}) => {
const authHeader = normalizeAuthToken(player, {
- defaultScheme: "token",
- preferScheme: "token",
+ defaultScheme: "Bearer",
+ preferScheme: "Bearer",
});
if (!authHeader) {
throw new Error("Missing player token");
@@ -43,7 +43,7 @@ export const updatePlayerPersonalDetails = async ({
api(`/player/personal_details/${id}`, {
method: "PATCH",
authToken: authHeader,
- authSchemePreference: "token",
+ authSchemePreference: "Bearer",
json: params,
}),
);