From 8a37ebcb8eb3110971217c8eeac97937b99ba0a8 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Mon, 17 Nov 2025 18:33:02 -0800 Subject: [PATCH 1/5] Replace browse matches mock data with API integration --- src/api/matches.ts | 36 ++++ src/data/mockMatches.ts | 61 ------ src/pages/BrowseMatchesPage.css | 45 +++++ src/pages/BrowseMatchesPage.tsx | 327 ++++++++++++++++++++++++-------- src/types/match.ts | 80 ++++++++ 5 files changed, 404 insertions(+), 145 deletions(-) create mode 100644 src/api/matches.ts delete mode 100644 src/data/mockMatches.ts create mode 100644 src/types/match.ts diff --git a/src/api/matches.ts b/src/api/matches.ts new file mode 100644 index 00000000..a652664c --- /dev/null +++ b/src/api/matches.ts @@ -0,0 +1,36 @@ +import { request } from "./http"; +import type { MatchesResponse } from "../types/match"; + +const MATCHES_ENDPOINT = + import.meta.env.VITE_PLAYER_MATCHES_ENDPOINT ?? "/player/matches"; + +export interface GetMatchesParams { + token?: string | null; + perPage?: number; + page?: number; + signal?: AbortSignal; +} + +export const getBrowseMatches = async ({ + token, + perPage = 20, + page = 1, + signal, +}: GetMatchesParams = {}) => + request(MATCHES_ENDPOINT, { + token: token ?? undefined, + query: { + perPage, + page, + }, + signal, + }); + +export const extractMatches = (payload: MatchesResponse): Record[] => { + if (Array.isArray(payload)) return payload; + if (Array.isArray(payload?.data)) return payload.data; + if (Array.isArray(payload?.results)) return payload.results; + if (Array.isArray(payload?.items)) return payload.items; + if (Array.isArray(payload?.matches)) return payload.matches; + return []; +}; diff --git a/src/data/mockMatches.ts b/src/data/mockMatches.ts deleted file mode 100644 index 711a5fdd..00000000 --- a/src/data/mockMatches.ts +++ /dev/null @@ -1,61 +0,0 @@ -export type MatchRelationship = "host" | "participant" | "viewer"; - -export interface MatchSkillLevel { - summary: string; - detail: string; -} - -export interface MatchEntry { - id: string; - access: "Open" | "Private"; - relationship: MatchRelationship; - startDisplay: string; - location: string; - distance: string; - playersJoined: number; - playersNeeded?: number; - totalSpots: number; - level?: MatchSkillLevel; -} - -export const mockMatches: MatchEntry[] = [ - { - id: "match-1", - access: "Open", - relationship: "host", - startDisplay: "Fri, Nov 14, 6:00 PM", - location: "Griffin Club Los Angeles", - distance: "2.5 miles away", - playersJoined: 1, - playersNeeded: 11, - totalSpots: 12, - level: { - summary: "3.5", - detail: "Suggested NTRP 3.5", - }, - }, - { - id: "match-2", - access: "Private", - relationship: "participant", - startDisplay: "Sat, Nov 16, 8:30 AM", - location: "Franklin Canyon Courts", - distance: "1.2 miles away", - playersJoined: 3, - totalSpots: 4, - }, - { - id: "match-3", - access: "Open", - relationship: "viewer", - startDisplay: "Sun, Nov 17, 10:00 AM", - location: "Echo Park Tennis Center", - distance: "3.6 miles away", - playersJoined: 8, - totalSpots: 8, - level: { - summary: "4.0", - detail: "Suggested NTRP 4.0", - }, - }, -]; diff --git a/src/pages/BrowseMatchesPage.css b/src/pages/BrowseMatchesPage.css index 8813def0..d5bd5421 100644 --- a/src/pages/BrowseMatchesPage.css +++ b/src/pages/BrowseMatchesPage.css @@ -212,6 +212,51 @@ gap: 20px; } +.matches-state { + grid-column: 1 / -1; + background: #ffffff; + border: 1px solid rgba(148, 163, 184, 0.45); + border-radius: 18px; + padding: 24px; + text-align: center; + box-shadow: 0 10px 26px rgba(15, 23, 42, 0.05); +} + +.matches-state__title { + margin: 0 0 8px; + font-weight: 700; + color: var(--matches-text); +} + +.matches-state__subtitle { + margin: 0; + color: var(--matches-muted); + font-size: 14px; +} + +.matches-state__action { + margin-top: 12px; + padding: 10px 18px; + border-radius: 12px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: linear-gradient(135deg, #22c55e, #16a34a); + color: #ffffff; + font-weight: 700; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.matches-state__action:hover, +.matches-state__action:focus-visible { + outline: none; + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08); +} + +.matches-state--error { + border-color: rgba(239, 68, 68, 0.4); +} + .match-card { background: var(--matches-surface); border-radius: 20px; diff --git a/src/pages/BrowseMatchesPage.tsx b/src/pages/BrowseMatchesPage.tsx index 77597705..332c53cf 100644 --- a/src/pages/BrowseMatchesPage.tsx +++ b/src/pages/BrowseMatchesPage.tsx @@ -1,9 +1,11 @@ -import { useMemo, useState, type CSSProperties } from "react"; +import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react"; import { useNavigate } from "react-router-dom"; import { Calendar, Filter, MapPin, MessageCircle, Search, Star, Users } from "lucide-react"; import MainLayout from "../components/MainLayout"; import { colors, typography } from "../lib/theme"; -import { mockMatches } from "../data/mockMatches"; +import { extractMatches, getBrowseMatches } from "../api/matches"; +import type { MatchApiRecord, MatchEntry, MatchRelationship } from "../types/match"; +import { getStoredAuthToken } from "../services/authToken"; import "./BrowseMatchesPage.css"; @@ -15,10 +17,117 @@ const relationshipLabel: Record = { participant: "Joined", }; +const parseRelationship = (value: string | undefined): MatchRelationship => { + const normalized = value?.trim().toLowerCase(); + if (normalized === "host" || normalized === "hosting") return "host"; + if (normalized === "participant" || normalized === "joined" || normalized === "player") return "participant"; + return "viewer"; +}; + +const formatDateLabel = (value?: string | number) => { + if (!value) return "Schedule to be announced"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return String(value); + } + return date.toLocaleString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +}; + +const pickStartTime = (match: MatchApiRecord) => + match.startTime || match.start_time || match.start || match.start_date || match.startDate || match.date || match.schedule; + +const pickLocation = (match: MatchApiRecord) => + match.location_name || + match.locationName || + match.location || + match.venue || + match.court || + match.address || + "Location to be announced"; + +const formatDistance = (value: MatchApiRecord["distance"]) => { + if (value === null || value === undefined) return undefined; + if (typeof value === "number") { + return `${value.toFixed(1)} miles away`; + } + const numeric = Number.parseFloat(value as string); + if (Number.isFinite(numeric)) { + return `${numeric.toFixed(1)} miles away`; + } + return String(value); +}; + +const pickDistance = (match: MatchApiRecord) => + formatDistance(match.distance ?? match.distance_in_miles ?? match.distance_miles ?? match.distance_mi); + +const pickCounts = (match: MatchApiRecord) => { + const playersJoined = + match.players_joined ?? match.players_joined_count ?? match.current_players ?? match.joined_players ?? match.joined ?? 0; + const totalSpots = + match.totalSpots ?? + match.total_players ?? + match.player_limit ?? + match.capacity ?? + (typeof match.playersNeeded === "number" ? playersJoined + match.playersNeeded : playersJoined); + const playersNeeded = match.playersNeeded ?? (totalSpots > playersJoined ? totalSpots - playersJoined : 0); + return { playersJoined, totalSpots: Math.max(totalSpots, playersJoined), playersNeeded }; +}; + +const pickLevel = (match: MatchApiRecord) => { + const summary = + match.level_summary ?? match.level ?? match.rating ?? match.suggested_rating ?? match.level_min ?? match.min_level; + const detail = match.level_detail ?? match.level_max ?? match.max_level; + + if (!summary) return undefined; + const summaryLabel = typeof summary === "number" ? summary.toFixed(1) : String(summary); + const detailLabel = + typeof detail === "number" + ? `Suggested NTRP ${detail.toFixed(1)}` + : detail + ? String(detail) + : `Suggested NTRP ${summaryLabel}`; + return { summary: summaryLabel, detail: detailLabel }; +}; + +const normalizeAccess = (value?: string) => { + const normalized = value?.trim().toLowerCase(); + if (!normalized) return "Open"; + if (normalized.startsWith("priv")) return "Private"; + if (normalized.startsWith("open")) return "Open"; + if (normalized.startsWith("host")) return "Private"; + return "Open"; +}; + +const mapMatchToEntry = (match: MatchApiRecord): MatchEntry => { + const { playersJoined, totalSpots, playersNeeded } = pickCounts(match); + const access = normalizeAccess(match.access ?? match.access_type ?? match.visibility ?? match.status); + return { + id: String(match.id ?? match.match_id ?? crypto.randomUUID()), + access, + relationship: parseRelationship(match.relationship ?? match.user_relationship ?? match.user_role ?? match.role), + startDisplay: formatDateLabel(pickStartTime(match)), + location: pickLocation(match), + distance: pickDistance(match), + playersJoined, + playersNeeded, + totalSpots, + level: pickLevel(match), + }; +}; + const BrowseMatchesPage = () => { const navigate = useNavigate(); const [selectedDistance, setSelectedDistance] = useState(distanceOptions[1]); const [selectedTab, setSelectedTab] = useState(tabs[0]); + const [matches, setMatches] = useState([]); + const [status, setStatus] = useState<"idle" | "loading" | "ready" | "error">("idle"); + const [error, setError] = useState(null); const themeVars = useMemo( () => @@ -38,6 +147,32 @@ const BrowseMatchesPage = () => { [], ); + const loadMatches = useCallback( + async (signal?: AbortSignal) => { + setStatus("loading"); + setError(null); + try { + const token = getStoredAuthToken(); + const response = await getBrowseMatches({ token, signal }); + const matchesPayload = extractMatches(response as MatchApiRecord[] | { [key: string]: unknown }); + const normalized = matchesPayload.map((item) => mapMatchToEntry(item as MatchApiRecord)); + setMatches(normalized); + setStatus("ready"); + } catch (err) { + if (signal?.aborted) return; + setStatus("error"); + setError(err instanceof Error ? err.message : "Unable to load matches."); + } + }, + [], + ); + + useEffect(() => { + const controller = new AbortController(); + void loadMatches(controller.signal); + return () => controller.abort(); + }, [loadMatches]); + return (
@@ -105,102 +240,126 @@ const BrowseMatchesPage = () => {
- {mockMatches.map((match) => { - const isHost = match.relationship === "host"; - const isParticipant = match.relationship === "participant"; - const isFull = match.playersJoined >= match.totalSpots; - const spotsAvailable = Math.max(match.totalSpots - match.playersJoined, 0); - const playersNeeded = match.playersNeeded ?? spotsAvailable; - const availabilityLabel = isFull - ? "Match is full" - : `${spotsAvailable} spot${spotsAvailable === 1 ? "" : "s"} available`; - const playersLabel = `${match.playersJoined}/${match.totalSpots} players`; - const roleLabel = relationshipLabel[match.relationship] ?? null; - - return ( -
-
-
- {match.access} - {roleLabel ? {roleLabel} : null} -
- {!isFull && playersNeeded > 0 ? ( - {playersNeeded} needed - ) : null} -
- -
-
-
-
-
- ); - })} + )} + + + ); + }) + )}
diff --git a/src/types/match.ts b/src/types/match.ts new file mode 100644 index 00000000..1d5b2993 --- /dev/null +++ b/src/types/match.ts @@ -0,0 +1,80 @@ +export type MatchRelationship = "host" | "participant" | "viewer"; + +export interface MatchSkillLevel { + summary: string; + detail?: string; +} + +export interface MatchEntry { + id: string; + access: "Open" | "Private"; + relationship: MatchRelationship; + startDisplay: string; + location: string; + distance?: string; + playersJoined: number; + playersNeeded?: number; + totalSpots: number; + level?: MatchSkillLevel; +} + +export interface MatchApiRecord { + id?: string | number; + match_id?: string | number; + visibility?: string; + access?: string; + access_type?: string; + status?: string; + relationship?: string; + user_relationship?: string; + user_role?: string; + role?: string; + start?: string; + start_time?: string; + startTime?: string; + start_date?: string; + startDate?: string; + date?: string; + schedule?: string; + location?: string; + location_name?: string; + locationName?: string; + venue?: string; + court?: string; + address?: string; + distance?: string | number; + distance_in_miles?: string | number; + distance_miles?: string | number; + distance_mi?: string | number; + players_joined?: number; + players_joined_count?: number; + current_players?: number; + joined_players?: number; + joined?: number; + playersNeeded?: number; + player_limit?: number; + total_players?: number; + totalSpots?: number; + capacity?: number; + level?: string | number; + level_summary?: string; + level_detail?: string; + level_min?: string | number; + level_max?: string | number; + min_level?: string | number; + max_level?: string | number; + rating?: string | number; + suggested_rating?: string; + [key: string]: unknown; +} + +export type MatchesResponse = + | MatchApiRecord[] + | { + data?: MatchApiRecord[]; + results?: MatchApiRecord[]; + items?: MatchApiRecord[]; + matches?: MatchApiRecord[]; + meta?: unknown; + [key: string]: unknown; + }; From b992ca400c880eb4acadb47c364dcdd35ba58a84 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Mon, 17 Nov 2025 18:59:43 -0800 Subject: [PATCH 2/5] Use POST for browse matches endpoint --- src/api/matches.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/api/matches.ts b/src/api/matches.ts index a652664c..4916a0fc 100644 --- a/src/api/matches.ts +++ b/src/api/matches.ts @@ -9,6 +9,10 @@ export interface GetMatchesParams { perPage?: number; page?: number; signal?: AbortSignal; + /** + * Optional payload forwarded to the API for filtering/sorting server-side. + */ + filters?: Record; } export const getBrowseMatches = async ({ @@ -16,13 +20,16 @@ export const getBrowseMatches = async ({ perPage = 20, page = 1, signal, + filters = {}, }: GetMatchesParams = {}) => request(MATCHES_ENDPOINT, { + method: "POST", token: token ?? undefined, query: { perPage, page, }, + body: filters, signal, }); From 619ce34f3d9236496f6fe6368db0a3e32970de72 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Mon, 17 Nov 2025 19:13:25 -0800 Subject: [PATCH 3/5] Handle browse matches fallback when GET errors persist --- src/api/matches.ts | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/api/matches.ts b/src/api/matches.ts index 4916a0fc..119239b4 100644 --- a/src/api/matches.ts +++ b/src/api/matches.ts @@ -3,6 +3,7 @@ import type { MatchesResponse } from "../types/match"; const MATCHES_ENDPOINT = import.meta.env.VITE_PLAYER_MATCHES_ENDPOINT ?? "/player/matches"; +const MATCHES_METHOD = (import.meta.env.VITE_PLAYER_MATCHES_METHOD ?? "POST").toUpperCase(); export interface GetMatchesParams { token?: string | null; @@ -15,23 +16,48 @@ export interface GetMatchesParams { filters?: Record; } +const shouldRetryWithGet = (error: unknown) => { + const status = (error as { status?: number })?.status; + const message = (error as Error)?.message?.toLowerCase(); + return status === 404 || message?.includes("cannot get"); +}; + export const getBrowseMatches = async ({ token, perPage = 20, page = 1, signal, filters = {}, -}: GetMatchesParams = {}) => - request(MATCHES_ENDPOINT, { - method: "POST", +}: GetMatchesParams = {}) => { + const requestOptions = { token: token ?? undefined, query: { perPage, page, }, - body: filters, + body: MATCHES_METHOD === "GET" ? undefined : filters, signal, - }); + } satisfies Omit & { + query: { perPage: number; page: number }; + body?: Record | undefined; + }; + + try { + return await request(MATCHES_ENDPOINT, { + ...requestOptions, + method: MATCHES_METHOD, + }); + } catch (error) { + if (MATCHES_METHOD !== "GET" && shouldRetryWithGet(error)) { + return await request(MATCHES_ENDPOINT, { + ...requestOptions, + method: "GET", + body: undefined, + }); + } + throw error; + } +}; export const extractMatches = (payload: MatchesResponse): Record[] => { if (Array.isArray(payload)) return payload; From afaf1e59ee6fdf9fd8be25c9791b2b1c90f9d784 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Mon, 17 Nov 2025 19:21:09 -0800 Subject: [PATCH 4/5] Add browse matches endpoint fallbacks --- src/api/matches.ts | 52 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/src/api/matches.ts b/src/api/matches.ts index 119239b4..342a6c9a 100644 --- a/src/api/matches.ts +++ b/src/api/matches.ts @@ -1,8 +1,21 @@ import { request } from "./http"; import type { MatchesResponse } from "../types/match"; -const MATCHES_ENDPOINT = - import.meta.env.VITE_PLAYER_MATCHES_ENDPOINT ?? "/player/matches"; +const normalizeEndpoint = (value: string) => `/${value.replace(/^\/+/, "")}`; + +const matchesEndpointCandidates = (() => { + const primary = import.meta.env.VITE_PLAYER_MATCHES_ENDPOINT ?? "/player/matches"; + const fallbacks = (import.meta.env.VITE_PLAYER_MATCHES_FALLBACK_ENDPOINTS ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + + const defaults = ["/player/upcoming_matches", "/matches", "/player/matches/list"]; + + const normalized = [primary, ...fallbacks, ...defaults].map(normalizeEndpoint); + + return Array.from(new Set(normalized)); +})(); const MATCHES_METHOD = (import.meta.env.VITE_PLAYER_MATCHES_METHOD ?? "POST").toUpperCase(); export interface GetMatchesParams { @@ -42,21 +55,34 @@ export const getBrowseMatches = async ({ body?: Record | undefined; }; - try { - return await request(MATCHES_ENDPOINT, { - ...requestOptions, - method: MATCHES_METHOD, - }); - } catch (error) { - if (MATCHES_METHOD !== "GET" && shouldRetryWithGet(error)) { - return await request(MATCHES_ENDPOINT, { + let lastError: unknown; + + for (const endpoint of matchesEndpointCandidates) { + try { + return await request(endpoint, { ...requestOptions, - method: "GET", - body: undefined, + method: MATCHES_METHOD, }); + } catch (error) { + if (MATCHES_METHOD !== "GET" && shouldRetryWithGet(error)) { + try { + return await request(endpoint, { + ...requestOptions, + method: "GET", + body: undefined, + }); + } catch (getError) { + lastError = getError; + } + } else if (!shouldRetryWithGet(error)) { + throw error; + } + + lastError = error; } - throw error; } + + throw lastError ?? new Error("Unable to load matches."); }; export const extractMatches = (payload: MatchesResponse): Record[] => { From 557469ce55eb6f1d8b77e576011afa4d974650bb Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Mon, 17 Nov 2025 20:15:04 -0800 Subject: [PATCH 5/5] Handle browse matches start time and retries --- src/api/matches.ts | 41 +++++++++++++-------------------- src/pages/BrowseMatchesPage.tsx | 9 +++++++- src/types/match.ts | 1 + 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/api/matches.ts b/src/api/matches.ts index 342a6c9a..47e99acb 100644 --- a/src/api/matches.ts +++ b/src/api/matches.ts @@ -29,10 +29,9 @@ export interface GetMatchesParams { filters?: Record; } -const shouldRetryWithGet = (error: unknown) => { +const isAuthError = (error: unknown) => { const status = (error as { status?: number })?.status; - const message = (error as Error)?.message?.toLowerCase(); - return status === 404 || message?.includes("cannot get"); + return status === 401 || status === 403; }; export const getBrowseMatches = async ({ @@ -48,37 +47,29 @@ export const getBrowseMatches = async ({ perPage, page, }, - body: MATCHES_METHOD === "GET" ? undefined : filters, signal, - } satisfies Omit & { + } satisfies Omit & { query: { perPage: number; page: number }; - body?: Record | undefined; }; let lastError: unknown; for (const endpoint of matchesEndpointCandidates) { - try { - return await request(endpoint, { - ...requestOptions, - method: MATCHES_METHOD, - }); - } catch (error) { - if (MATCHES_METHOD !== "GET" && shouldRetryWithGet(error)) { - try { - return await request(endpoint, { - ...requestOptions, - method: "GET", - body: undefined, - }); - } catch (getError) { - lastError = getError; + const methodsToTry = MATCHES_METHOD === "GET" ? ["GET"] : [MATCHES_METHOD, "GET"]; + + for (const method of methodsToTry) { + try { + return await request(endpoint, { + ...requestOptions, + method, + body: method === "GET" ? undefined : filters, + }); + } catch (error) { + if (isAuthError(error)) { + throw error; } - } else if (!shouldRetryWithGet(error)) { - throw error; + lastError = error; } - - lastError = error; } } diff --git a/src/pages/BrowseMatchesPage.tsx b/src/pages/BrowseMatchesPage.tsx index 332c53cf..d55e6f69 100644 --- a/src/pages/BrowseMatchesPage.tsx +++ b/src/pages/BrowseMatchesPage.tsx @@ -40,7 +40,14 @@ const formatDateLabel = (value?: string | number) => { }; const pickStartTime = (match: MatchApiRecord) => - match.startTime || match.start_time || match.start || match.start_date || match.startDate || match.date || match.schedule; + match.start_date_time || + match.startTime || + match.start_time || + match.start || + match.start_date || + match.startDate || + match.date || + match.schedule; const pickLocation = (match: MatchApiRecord) => match.location_name || diff --git a/src/types/match.ts b/src/types/match.ts index 1d5b2993..83c1f48d 100644 --- a/src/types/match.ts +++ b/src/types/match.ts @@ -32,6 +32,7 @@ export interface MatchApiRecord { start?: string; start_time?: string; startTime?: string; + start_date_time?: string; start_date?: string; startDate?: string; date?: string;