From 7bec61ae1d91fc6b68817b327586a6a9e9ba948e Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 16 Nov 2025 07:35:44 -0800 Subject: [PATCH 01/14] feat: connect match profile page to api --- src/pages/PlayerMatchProfilePage.tsx | 639 ++++++++++++++++++++------- src/pages/PlayerSettingsPages.css | 212 +++++++++ 2 files changed, 680 insertions(+), 171 deletions(-) diff --git a/src/pages/PlayerMatchProfilePage.tsx b/src/pages/PlayerMatchProfilePage.tsx index b54a35e5..4ba6ca99 100644 --- a/src/pages/PlayerMatchProfilePage.tsx +++ b/src/pages/PlayerMatchProfilePage.tsx @@ -1,52 +1,479 @@ -import { useState } from "react"; -import { Check, Clock, MapPin, Target } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { Loader2, Mail, MapPin, Phone, ShieldCheck, Target } from "lucide-react"; import MainLayout from "../components/MainLayout"; +import { fetchPlayerDetails } from "../api/playerHome"; +import { getStoredAuthToken } from "../services/authToken"; +import { getPersonalDetails } from "../services/auth"; +import { formatPhoneDisplay } from "../services/phone"; import "./PlayerSettingsPages.css"; -const availabilitySlots = [ - "Early mornings", - "Weekday afternoons", - "Weekday evenings", - "Weekend mornings", - "Weekend afternoons", - "Weekend evenings", -]; - -const matchIntensities = [ - { id: "competitive", label: "Competitive play", description: "USTA league or tournament focused" }, - { id: "balanced", label: "Balanced", description: "Mix of rally sessions and competitive sets" }, - { id: "casual", label: "Casual hits", description: "Easy going hits with rally focus" }, -]; - -const preferredFormats = [ - { id: "singles", label: "Singles" }, - { id: "doubles", label: "Doubles" }, - { id: "mixed", label: "Mixed doubles" }, - { id: "drills", label: "Live-ball drills" }, - { id: "fitness", label: "Cardio tennis" }, -]; +type RawMatchProfileRecord = { + userId?: number; + full_name?: string; + email?: string; + phone?: string; + profile_picture?: string; + skillLevel?: string; + availability?: string[] | string; + playerLocations?: string[] | string; + playerCourtLocations?: string[] | string; + lookingFor?: string[] | string; + gender?: string; + genderAdditionalText?: string; + about_me?: string; + isLevelConfirmed?: boolean; + verifiedLevelCount?: number | string; + matchFrequency?: string; + match_frequency?: string; + playerExperience?: string; + player_experience?: string; + updated_at?: string; + [key: string]: unknown; +}; + +type PersonalDetailsRecord = { + id?: number; + full_name?: string; + email?: string; + phone?: string; + profile_picture?: string; + about_me?: string; +}; + +type MatchProfile = { + id?: string; + name: string; + initials: string; + about?: string; + location?: string; + level?: string; + levelVerified: boolean; + verificationCount?: number; + availability: string[]; + matchGoals: string[]; + localCourts: string[]; + locations: string[]; + matchFrequency?: string; + experience?: string; + gender?: string; + email?: string; + phone?: string; + profileImageUrl?: string; + updatedAtLabel?: string; +}; + +type Status = "idle" | "loading" | "ready" | "error"; + +const ensureStringArray = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter((item): item is string => item.length > 0); + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return []; + } + return trimmed.includes(",") + ? trimmed + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0) + : [trimmed]; + } + return []; +}; + +const pickString = (...values: unknown[]): string | undefined => { + for (const value of values) { + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + } + return undefined; +}; + +const toInitials = (name?: string, email?: string): string => { + if (name) { + const parts = name + .trim() + .split(/\s+/) + .filter(Boolean); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); + } + if (parts.length === 1) { + return parts[0].slice(0, 2).toUpperCase(); + } + } + if (email) { + return email.slice(0, 2).toUpperCase(); + } + return "MP"; +}; + +const parseNumber = (value: unknown): number | undefined => { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return undefined; +}; + +const normalizeGender = (primary?: string, fallback?: string): string | undefined => { + const source = pickString(primary, fallback); + if (!source) { + return undefined; + } + return source + .split(" ") + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase()) + .join(" "); +}; + +const formatDateLabel = (value?: string): string | undefined => { + if (!value) { + return undefined; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return undefined; + } + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }).format(date); +}; + +const normalizeMatchProfile = ( + raw: RawMatchProfileRecord | null, + personal: PersonalDetailsRecord | null, +): MatchProfile | null => { + if (!raw && !personal) { + return null; + } + + const availability = ensureStringArray(raw?.availability); + const matchGoals = ensureStringArray(raw?.lookingFor); + const localCourts = ensureStringArray(raw?.playerCourtLocations); + const locations = ensureStringArray(raw?.playerLocations); + const about = pickString(raw?.about_me, personal?.about_me); + const name = pickString(raw?.full_name, personal?.full_name) ?? "Matchplay player"; + const email = pickString(raw?.email, personal?.email); + const phone = pickString(raw?.phone, personal?.phone); + const level = pickString(raw?.skillLevel); + const matchFrequency = pickString(raw?.matchFrequency, raw?.match_frequency); + const experience = pickString(raw?.playerExperience, raw?.player_experience); + const gender = normalizeGender(raw?.gender, raw?.genderAdditionalText); + const verificationCount = parseNumber(raw?.verifiedLevelCount); + const profileImageUrl = pickString(raw?.profile_picture, personal?.profile_picture); + const location = locations[0]; + const initials = toInitials(name, email); + + return { + id: raw?.userId ? String(raw.userId) : personal?.id ? String(personal.id) : undefined, + name, + initials, + about, + location, + level, + levelVerified: Boolean(raw?.isLevelConfirmed), + verificationCount, + availability, + matchGoals, + localCourts, + locations, + matchFrequency, + experience, + gender, + email, + phone, + profileImageUrl, + updatedAtLabel: formatDateLabel(raw?.updated_at), + }; +}; const PlayerMatchProfilePage = () => { - const [selectedAvailability, setSelectedAvailability] = useState([ - "Weekday evenings", - "Weekend mornings", - ]); - const [intensity, setIntensity] = useState("balanced"); - const [formats, setFormats] = useState(["singles", "doubles"]); - const [homeBase, setHomeBase] = useState("Austin Tennis Center"); - - const toggleAvailability = (slot: string) => { - setSelectedAvailability((current) => - current.includes(slot) ? current.filter((item) => item !== slot) : [...current, slot] - ); + const [profile, setProfile] = useState(null); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [refreshIndex, setRefreshIndex] = useState(0); + + useEffect(() => { + let cancelled = false; + + const loadProfile = async () => { + const token = getStoredAuthToken({ preferScheme: "Bearer" }); + if (!token) { + setStatus("error"); + setError("Please sign in to view your match profile."); + return; + } + + setStatus("loading"); + setError(null); + try { + const personalDetails = (await getPersonalDetails()) as PersonalDetailsRecord | null; + if (!personalDetails?.id) { + throw new Error("We couldn’t determine your player profile ID."); + } + const rawProfile = (await fetchPlayerDetails({ + token, + userId: personalDetails.id, + })) as RawMatchProfileRecord | null; + + if (cancelled) { + return; + } + + const normalized = normalizeMatchProfile(rawProfile, personalDetails); + setProfile(normalized); + setStatus("ready"); + } catch (requestError) { + if (cancelled) { + return; + } + console.error("Failed to load match profile", requestError); + setStatus("error"); + setError( + requestError instanceof Error + ? requestError.message + : "We couldn’t load your match profile. Please try again.", + ); + } + }; + + loadProfile(); + + return () => { + cancelled = true; + }; + }, [refreshIndex]); + + const locationLabel = useMemo(() => { + if (!profile) { + return "Location not shared"; + } + if (profile.location) { + return profile.location; + } + if (profile.locations.length > 0) { + return profile.locations[0]; + } + return "Location not shared"; + }, [profile]); + + const handleRetry = () => { + setRefreshIndex((index) => index + 1); }; - const toggleFormat = (id: string) => { - setFormats((current) => - current.includes(id) ? current.filter((item) => item !== id) : [...current, id] + const availabilityContent = profile?.availability.length ? ( +
    + {profile.availability.map((slot) => ( +
  • + {slot} +
  • + ))} +
+ ) : ( +

No availability shared yet.

+ ); + + const matchGoalsContent = profile?.matchGoals.length ? ( +
    + {profile.matchGoals.map((goal) => ( +
  • + {goal} +
  • + ))} +
+ ) : ( +

Add the sessions you’re hoping to schedule.

+ ); + + const localCourtsContent = profile?.localCourts.length ? ( +
    + {profile.localCourts.map((court) => ( +
  • +
  • + ))} +
+ ) : ( +

Share the courts where you usually meet players.

+ ); + + const contactDetails = ( +
    + {profile?.email ? ( +
  • +
  • + ) : null} + {profile?.phone ? ( +
  • +
  • + ) : null} +
+ ); + + let bodyContent: JSX.Element | null = null; + + if (status === "loading" || status === "idle") { + bodyContent = ( +
+
); - }; + } else if (status === "error") { + bodyContent = ( +
+

{error ?? "We couldn’t load your match profile."}

+ +
+ ); + } else if (profile) { + bodyContent = ( +
+
+
+
+
+ +
+

Match profile owner

+

{profile.name}

+

{locationLabel}

+
+ {profile.level ? ( + + NTRP {profile.level} + + ) : null} +
+

+ {profile.updatedAtLabel ? `Last updated ${profile.updatedAtLabel}` : "Updated recently"} +

+

+ {profile.about ?? "Share a short bio so partners know what you’re looking for."} +

+
+
+
Level verification
+
+ {profile.levelVerified ? ( + + + ) : ( + "Level not verified yet" + )} + {profile.verificationCount ? ( + + {profile.verificationCount} {profile.verificationCount === 1 ? "player" : "players"} confirmed this level. + + ) : null} +
+
+
+
Match frequency
+
{profile.matchFrequency ?? "Not shared"}
+
+
+
Experience
+
{profile.experience ?? "Not shared"}
+
+
+
Gender
+
{profile.gender ?? "Not shared"}
+
+
+
+ +
+
+

Match availability

+

+ These are the time windows you shared while building your profile. +

+
+ {availabilityContent} +
+ +
+
+

Match goals & session vibes

+

+ We use this to suggest compatible partners and session formats. +

+
+ {matchGoalsContent} +
+

Preferred locations

+ {profile.locations.length ? ( +
    + {profile.locations.map((spot) => ( +
  • {spot}
  • + ))} +
+ ) : ( +

No preferred locations shared yet.

+ )} +
+
+
+ + +
+
+ ); + } else { + bodyContent = ( +
+

You haven’t created a match profile yet.

+ +
+ ); + } return ( @@ -63,137 +490,7 @@ const PlayerMatchProfilePage = () => {

-
-
-
-
-
-

-

-

Choose the windows when you're generally open to play.

-
-
- {availabilitySlots.map((slot) => { - const selected = selectedAvailability.includes(slot); - return ( - - ); - })} -
-
- -
-
-

Match intensity

-

Let others know how competitive you'd like sessions to be.

-
-
- {matchIntensities.map((option) => { - const selected = intensity === option.id; - return ( - - ); - })} -
-
- -
-
-

Preferred formats

-

- Highlight the type of play you're hoping to schedule with new connections. -

-
-
- {preferredFormats.map((format) => { - const selected = formats.includes(format.id); - return ( - - ); - })} -
-
-
- - -
-
- -
- -
+ {bodyContent}
diff --git a/src/pages/PlayerSettingsPages.css b/src/pages/PlayerSettingsPages.css index 31344ef1..c6edc6b1 100644 --- a/src/pages/PlayerSettingsPages.css +++ b/src/pages/PlayerSettingsPages.css @@ -189,6 +189,37 @@ box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08); } +.match-card--summary { + gap: 16px; +} + +.match-card--state { + align-items: center; + justify-content: center; + text-align: center; + gap: 12px; +} + +.match-card__spinner { + color: #2563eb; +} + +.match-card__retry { + appearance: none; + border: none; + background: #2563eb; + color: #fff; + font-weight: 600; + padding: 10px 20px; + border-radius: 999px; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.match-card__retry:hover { + opacity: 0.9; +} + .match-card__heading { display: flex; flex-direction: column; @@ -212,6 +243,112 @@ color: #64748b; } +.match-profile-summary { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.match-profile-avatar { + width: 72px; + height: 72px; + border-radius: 20px; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(14, 165, 233, 0.2)); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 20px; + color: #4338ca; + overflow: hidden; +} + +.match-profile-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.match-profile-summary__content h2 { + margin: 0; + font-size: 22px; + color: #0f172a; +} + +.match-profile-summary__content p { + margin: 2px 0 0; + color: #475569; + font-weight: 600; +} + +.match-profile-summary__eyebrow { + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: #64748b; + margin: 0 0 4px; +} + +.match-profile-summary__status { + margin: 0; + font-size: 13px; + color: #475569; +} + +.match-profile-about { + margin: 0; + font-size: 15px; + line-height: 1.7; + color: #0f172a; +} + +.match-profile-pill { + margin-left: auto; + padding: 8px 14px; + border-radius: 999px; + font-size: 13px; + font-weight: 700; + background: rgba(15, 118, 110, 0.1); + color: #0f766e; +} + +.match-profile-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin: 0; +} + +.match-profile-meta dt { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.18em; + color: #94a3b8; + margin-bottom: 4px; +} + +.match-profile-meta dd { + margin: 0; + font-size: 15px; + color: #0f172a; + display: flex; + flex-direction: column; + gap: 4px; +} + +.match-profile-meta__verified { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 700; + color: #047857; +} + +.match-profile-meta small { + color: #64748b; +} + .match-availability { display: grid; gap: 16px; @@ -315,6 +452,55 @@ gap: 12px; } +.match-chip-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.match-chip { + padding: 10px 16px; + border-radius: 999px; + background: #f8fafc; + color: #0f172a; + font-weight: 600; + border: 1px solid rgba(148, 163, 184, 0.5); +} + +.match-chip--accent { + background: rgba(99, 102, 241, 0.12); + border-color: rgba(99, 102, 241, 0.3); + color: #4338ca; +} + +.match-profile-empty { + margin: 0; + font-size: 14px; + color: #94a3b8; +} + +.match-profile-locations { + display: flex; + flex-direction: column; + gap: 10px; +} + +.match-profile-locations h3 { + margin: 0; + font-size: 15px; + color: #0f172a; +} + +.match-profile-locations ul { + margin: 0; + padding-left: 18px; + color: #475569; + font-weight: 600; +} + .match-format-chip { padding: 10px 18px; border-radius: 999px; @@ -357,6 +543,11 @@ gap: 16px; } +.match-sidebar__card--info { + border-color: rgba(99, 102, 241, 0.28); + background: linear-gradient(135deg, rgba(224, 231, 255, 0.6), rgba(199, 210, 254, 0.7)); +} + .match-sidebar__title { margin: 0; display: inline-flex; @@ -432,6 +623,27 @@ color: #475569; } +.match-sidebar__list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.match-sidebar__list li { + display: inline-flex; + align-items: center; + gap: 10px; + color: #0f172a; + font-weight: 600; +} + +.match-sidebar__list svg { + color: #2563eb; +} + /* Payment methods */ .billing-grid { From 340ea46eb02f1486137cc3d03ee3bb5b33a6e114 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 16 Nov 2025 07:52:02 -0800 Subject: [PATCH 02/14] Fix match profile fetch --- src/api/playerHome.ts | 5 +---- src/pages/PlayerMatchProfilePage.tsx | 9 ++++++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts index 3597c2c4..76c63190 100644 --- a/src/api/playerHome.ts +++ b/src/api/playerHome.ts @@ -332,12 +332,9 @@ export interface FetchPlayerDetailsParams extends PlayerTokenOnlyParams { export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsParams) => request>( - "/player/surveys/getchecklocation/specific_user", + `/player/surveys/getchecklocation/specific_user/${encodeURIComponent(userId)}`, { token, - query: { - userId, - }, }, ); diff --git a/src/pages/PlayerMatchProfilePage.tsx b/src/pages/PlayerMatchProfilePage.tsx index 4ba6ca99..0f4ba909 100644 --- a/src/pages/PlayerMatchProfilePage.tsx +++ b/src/pages/PlayerMatchProfilePage.tsx @@ -160,7 +160,7 @@ const normalizeMatchProfile = ( raw: RawMatchProfileRecord | null, personal: PersonalDetailsRecord | null, ): MatchProfile | null => { - if (!raw && !personal) { + if (!raw) { return null; } @@ -244,6 +244,13 @@ const PlayerMatchProfilePage = () => { if (cancelled) { return; } + const statusCode = (requestError as { status?: number })?.status; + if (statusCode === 404) { + setProfile(null); + setStatus("ready"); + setError(null); + return; + } console.error("Failed to load match profile", requestError); setStatus("error"); setError( From 292df0bf41a9baf2113d3da01438af5cfcb89ca5 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 16 Nov 2025 08:00:10 -0800 Subject: [PATCH 03/14] Retry match profile endpoint --- src/api/playerHome.ts | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts index 76c63190..133a82e3 100644 --- a/src/api/playerHome.ts +++ b/src/api/playerHome.ts @@ -330,13 +330,42 @@ export interface FetchPlayerDetailsParams extends PlayerTokenOnlyParams { userId: number | string; } -export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsParams) => - request>( - `/player/surveys/getchecklocation/specific_user/${encodeURIComponent(userId)}`, +export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsParams) => { + const attempts: Array<{ + path: string; + query?: Record; + }> = [ { - token, + path: `/player/surveys/getchecklocation/specific_user/${encodeURIComponent(userId)}`, }, - ); + { + path: "/player/surveys/getchecklocation/specific_user", + query: { userId }, + }, + ]; + + let lastError: Error & { status?: number } | undefined; + + for (const attempt of attempts) { + try { + return await request>(attempt.path, { + token, + query: attempt.query, + }); + } catch (error) { + lastError = error as Error & { status?: number }; + if (lastError?.status !== 404) { + throw lastError; + } + } + } + + if (lastError) { + throw lastError; + } + + throw new Error("Unable to load match profile"); +}; export interface SuggestedPlayerCheckLocationParams extends PaginationParams { token: string; From 9c194a7f24ea9652036de0dbd58caf90adab9e67 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 16 Nov 2025 08:21:13 -0800 Subject: [PATCH 04/14] Allow creating a match profile from settings --- src/api/playerHome.ts | 28 ++++ src/components/players/MatchProfileModal.tsx | 27 +++- src/pages/PlayerMatchProfilePage.tsx | 139 +++++++++++++++++-- src/pages/PlayerSettingsPages.css | 49 +++++-- 4 files changed, 222 insertions(+), 21 deletions(-) diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts index 133a82e3..86c35120 100644 --- a/src/api/playerHome.ts +++ b/src/api/playerHome.ts @@ -367,6 +367,34 @@ export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsPa throw new Error("Unable to load match profile"); }; +export interface PlayerMatchProfilePayload { + about_me?: string; + skillLevel?: string; + lookingFor?: string[]; + availability?: string[]; + playerCourtLocations?: string[]; + gender?: string; +} + +export interface SavePlayerMatchProfileParams extends PlayerTokenOnlyParams { + userId: number | string; + profile: PlayerMatchProfilePayload; +} + +export const savePlayerMatchProfile = async ({ + token, + userId, + profile, +}: SavePlayerMatchProfileParams) => + request>("/player/surveys/getchecklocation", { + method: "POST", + token, + body: buildBody({ + userId, + ...profile, + }), + }); + export interface SuggestedPlayerCheckLocationParams extends PaginationParams { token: string; search?: string; diff --git a/src/components/players/MatchProfileModal.tsx b/src/components/players/MatchProfileModal.tsx index dddf4993..bc24e521 100644 --- a/src/components/players/MatchProfileModal.tsx +++ b/src/components/players/MatchProfileModal.tsx @@ -91,6 +91,9 @@ type MatchProfileModalProps = { onClose: () => void; onComplete: (profile: MatchProfileDetails) => void; initialProfile?: MatchProfileDetails | null; + isSubmitting?: boolean; + submitError?: string | null; + submitLabel?: string; }; const DEFAULT_LEVEL = "3.0"; @@ -147,7 +150,15 @@ const loadGooglePlacesScript = () => { return placesScriptPromise; }; -const MatchProfileModal = ({ isOpen, onClose, onComplete, initialProfile }: MatchProfileModalProps) => { +const MatchProfileModal = ({ + isOpen, + onClose, + onComplete, + initialProfile, + isSubmitting = false, + submitError = null, + submitLabel, +}: MatchProfileModalProps) => { const titleId = useId(); const descriptionId = useId(); const fileInputRef = useRef(null); @@ -255,6 +266,9 @@ const MatchProfileModal = ({ isOpen, onClose, onComplete, initialProfile }: Matc ); }, [about, gender, availability, requiresCourtVerification]); + const isFinalSubmitDisabled = isSubmitDisabled || isSubmitting; + const finalSubmitLabel = isSubmitting ? "Saving…" : submitLabel ?? "Save profile"; + const showCompletionError = touched && isSubmitDisabled; const handleSubmit = (event: FormEvent) => { @@ -636,6 +650,11 @@ const MatchProfileModal = ({ isOpen, onClose, onComplete, initialProfile }: Matc Please complete your full profile before saving.

)} + {submitError ? ( +

+ {submitError} +

+ ) : null}
diff --git a/src/pages/PlayerMatchProfilePage.tsx b/src/pages/PlayerMatchProfilePage.tsx index 0f4ba909..46a74441 100644 --- a/src/pages/PlayerMatchProfilePage.tsx +++ b/src/pages/PlayerMatchProfilePage.tsx @@ -1,7 +1,12 @@ import { useEffect, useMemo, useState } from "react"; import { Loader2, Mail, MapPin, Phone, ShieldCheck, Target } from "lucide-react"; import MainLayout from "../components/MainLayout"; -import { fetchPlayerDetails } from "../api/playerHome"; +import MatchProfileModal, { type MatchProfileDetails } from "../components/players/MatchProfileModal"; +import { + fetchPlayerDetails, + savePlayerMatchProfile, + type PlayerMatchProfilePayload, +} from "../api/playerHome"; import { getStoredAuthToken } from "../services/authToken"; import { getPersonalDetails } from "../services/auth"; import { formatPhoneDisplay } from "../services/phone"; @@ -204,11 +209,59 @@ const normalizeMatchProfile = ( }; }; +const DEFAULT_MODAL_LEVEL = "3.0"; + +const matchProfileToModalDetails = (profile: MatchProfile | null): MatchProfileDetails | null => { + if (!profile) { + return null; + } + return { + about: profile.about ?? "", + level: profile.level ?? DEFAULT_MODAL_LEVEL, + playStyles: [...profile.matchGoals], + gender: profile.gender ?? "", + localCourts: profile.localCourts.join(", "), + availability: [...profile.availability], + }; +}; + +const splitCommaList = (value: string) => + value + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); + +const matchProfileDetailsToPayload = (details: MatchProfileDetails): PlayerMatchProfilePayload => { + const trimmedAbout = details.about.trim(); + const trimmedLevel = details.level?.trim(); + const trimmedGender = details.gender?.trim(); + const playStyles = details.playStyles + .map((style) => style.trim()) + .filter((style) => style.length > 0); + const availability = details.availability + .map((slot) => slot.trim()) + .filter((slot) => slot.length > 0); + const playerCourts = splitCommaList(details.localCourts); + + return { + about_me: trimmedAbout || undefined, + skillLevel: trimmedLevel || undefined, + gender: trimmedGender || undefined, + lookingFor: playStyles.length ? playStyles : undefined, + availability: availability.length ? availability : undefined, + playerCourtLocations: playerCourts.length ? playerCourts : undefined, + }; +}; + const PlayerMatchProfilePage = () => { const [profile, setProfile] = useState(null); + const [personalDetails, setPersonalDetails] = useState(null); const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); const [refreshIndex, setRefreshIndex] = useState(0); + const [isProfileModalOpen, setProfileModalOpen] = useState(false); + const [isSavingProfile, setSavingProfile] = useState(false); + const [modalError, setModalError] = useState(null); useEffect(() => { let cancelled = false; @@ -224,20 +277,21 @@ const PlayerMatchProfilePage = () => { setStatus("loading"); setError(null); try { - const personalDetails = (await getPersonalDetails()) as PersonalDetailsRecord | null; - if (!personalDetails?.id) { + const accountDetails = (await getPersonalDetails()) as PersonalDetailsRecord | null; + if (!accountDetails?.id) { throw new Error("We couldn’t determine your player profile ID."); } + setPersonalDetails(accountDetails); const rawProfile = (await fetchPlayerDetails({ token, - userId: personalDetails.id, + userId: accountDetails.id, })) as RawMatchProfileRecord | null; if (cancelled) { return; } - const normalized = normalizeMatchProfile(rawProfile, personalDetails); + const normalized = normalizeMatchProfile(rawProfile, accountDetails); setProfile(normalized); setStatus("ready"); } catch (requestError) { @@ -285,6 +339,41 @@ const PlayerMatchProfilePage = () => { setRefreshIndex((index) => index + 1); }; + const modalInitialProfile = useMemo(() => matchProfileToModalDetails(profile), [profile]); + + const handleProfileModalComplete = async (details: MatchProfileDetails) => { + if (isSavingProfile) { + return; + } + const userId = personalDetails?.id; + if (!userId) { + setModalError("We couldn’t determine your player profile ID."); + return; + } + const token = getStoredAuthToken({ preferScheme: "Bearer" }); + if (!token) { + setModalError("Please sign in again to save your match profile."); + return; + } + + setSavingProfile(true); + setModalError(null); + + try { + const payload = matchProfileDetailsToPayload(details); + await savePlayerMatchProfile({ token, userId, profile: payload }); + setProfileModalOpen(false); + setRefreshIndex((index) => index + 1); + } catch (saveError) { + console.error("Failed to save match profile", saveError); + setModalError( + saveError instanceof Error ? saveError.message : "We couldn’t save your match profile. Please try again.", + ); + } finally { + setSavingProfile(false); + } + }; + const availabilityContent = profile?.availability.length ? (
    {profile.availability.map((slot) => ( @@ -352,7 +441,7 @@ const PlayerMatchProfilePage = () => { bodyContent = (

    {error ?? "We couldn’t load your match profile."}

    -
    @@ -475,9 +564,25 @@ const PlayerMatchProfilePage = () => { bodyContent = (

    You haven’t created a match profile yet.

    - +

    + Share your level, vibe, and preferred courts so other players know how to connect. +

    +
    + + +
    ); } @@ -500,6 +605,22 @@ const PlayerMatchProfilePage = () => { {bodyContent} + + { + if (isSavingProfile) { + return; + } + setProfileModalOpen(false); + setModalError(null); + }} + initialProfile={modalInitialProfile} + isSubmitting={isSavingProfile} + submitError={modalError} + submitLabel={profile ? "Update profile" : "Save profile"} + onComplete={handleProfileModalComplete} + /> ); }; diff --git a/src/pages/PlayerSettingsPages.css b/src/pages/PlayerSettingsPages.css index c6edc6b1..e8ca5464 100644 --- a/src/pages/PlayerSettingsPages.css +++ b/src/pages/PlayerSettingsPages.css @@ -200,15 +200,9 @@ gap: 12px; } -.match-card__spinner { - color: #2563eb; -} - -.match-card__retry { +.match-card__button { appearance: none; border: none; - background: #2563eb; - color: #fff; font-weight: 600; padding: 10px 20px; border-radius: 999px; @@ -216,10 +210,49 @@ transition: opacity 0.2s ease; } -.match-card__retry:hover { +.match-card__button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.match-card__spinner { + color: #2563eb; +} + +.match-card__retry { + background: #2563eb; + color: #fff; +} + +.match-card__retry:not(:disabled):hover { opacity: 0.9; } +.match-card__action { + background: linear-gradient(120deg, #0f172a, #1d4ed8); + color: #fff; + box-shadow: 0 6px 18px rgba(37, 99, 235, 0.25); +} + +.match-card__action:not(:disabled):hover { + opacity: 0.95; +} + +.match-card__actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 12px; + margin-top: 12px; +} + +.match-card__helper { + margin: 0 auto; + max-width: 460px; + color: #475569; + font-size: 14px; +} + .match-card__heading { display: flex; flex-direction: column; From 9091cc4517a6284fc60e3a5464c162c7e2516c90 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 16 Nov 2025 08:31:00 -0800 Subject: [PATCH 05/14] Harden match profile save endpoint --- src/api/playerHome.ts | 71 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 9 deletions(-) diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts index 86c35120..717d62b2 100644 --- a/src/api/playerHome.ts +++ b/src/api/playerHome.ts @@ -385,15 +385,68 @@ export const savePlayerMatchProfile = async ({ token, userId, profile, -}: SavePlayerMatchProfileParams) => - request>("/player/surveys/getchecklocation", { - method: "POST", - token, - body: buildBody({ - userId, - ...profile, - }), - }); +}: SavePlayerMatchProfileParams) => { + const attempts: Array<{ + path: string; + method?: string; + query?: Record; + includeUserId?: boolean; + }> = [ + { + path: "/player/surveys/getchecklocation", + method: "POST", + includeUserId: true, + }, + { + path: `/player/surveys/getchecklocation/specific_user/${encodeURIComponent(userId)}`, + method: "POST", + includeUserId: true, + }, + { + path: "/player/surveys/getchecklocation/specific_user", + method: "POST", + query: { userId }, + includeUserId: true, + }, + { + path: `/player/surveys/getchecklocation/${encodeURIComponent(userId)}`, + method: "PATCH", + includeUserId: true, + }, + ]; + + let lastError: Error & { status?: number } | undefined; + + for (const attempt of attempts) { + try { + return await request>(attempt.path, { + method: attempt.method ?? "POST", + token, + query: attempt.query, + body: buildBody({ + ...(attempt.includeUserId + ? { + userId, + user_id: userId, + } + : {}), + ...profile, + }), + }); + } catch (error) { + lastError = error as Error & { status?: number }; + if (lastError?.status !== 404) { + throw lastError; + } + } + } + + if (lastError) { + throw lastError; + } + + throw new Error("Unable to save match profile"); +}; export interface SuggestedPlayerCheckLocationParams extends PaginationParams { token: string; From ee2680830b30802b8f6d958882659af45c419872 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 16 Nov 2025 08:37:09 -0800 Subject: [PATCH 06/14] Use existing Google Places key for match profile --- src/components/players/MatchProfileModal.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/players/MatchProfileModal.tsx b/src/components/players/MatchProfileModal.tsx index bc24e521..d121b3bf 100644 --- a/src/components/players/MatchProfileModal.tsx +++ b/src/components/players/MatchProfileModal.tsx @@ -111,7 +111,9 @@ type PlacesStatus = "idle" | "loading" | "ready" | "unavailable"; let placesScriptPromise: Promise | null = null; -const GOOGLE_PLACES_API_KEY = import.meta.env.VITE_GOOGLE_PLACES_API_KEY as string | undefined; +const GOOGLE_PLACES_API_KEY = + (import.meta.env.VITE_GOOGLE_PLACES_API_KEY as string | undefined) ?? + (import.meta.env.VITE_GOOGLE_API_KEY as string | undefined); const loadGooglePlacesScript = () => { if (typeof window === "undefined") { From 0de71e7149fe671bae3a05bfad8cfe8f7927aa03 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 16 Nov 2025 08:53:56 -0800 Subject: [PATCH 07/14] Expand match profile API fallbacks --- src/api/playerHome.ts | 56 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts index 717d62b2..fea0526f 100644 --- a/src/api/playerHome.ts +++ b/src/api/playerHome.ts @@ -331,16 +331,48 @@ export interface FetchPlayerDetailsParams extends PlayerTokenOnlyParams { } export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsParams) => { + const userQuery = { userId } as Record; + const userBody = { userId, user_id: userId }; const attempts: Array<{ path: string; + method?: string; query?: Record; + body?: Record; }> = [ { path: `/player/surveys/getchecklocation/specific_user/${encodeURIComponent(userId)}`, }, { path: "/player/surveys/getchecklocation/specific_user", - query: { userId }, + query: userQuery, + }, + { + path: `/player/surveys/getchecklocation/${encodeURIComponent(userId)}`, + }, + { + path: `/player/getchecklocation/specific_user/${encodeURIComponent(userId)}`, + }, + { + path: "/player/getchecklocation/specific_user", + query: userQuery, + }, + { + path: `/player/getchecklocation/${encodeURIComponent(userId)}`, + }, + { + path: "/player/getchecklocation/specific_user", + method: "POST", + body: userBody, + }, + { + path: "/player/surveys/getchecklocation/specific_user", + method: "POST", + body: userBody, + }, + { + path: "/player/getchecklocation", + method: "POST", + body: userBody, }, ]; @@ -349,8 +381,10 @@ export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsPa for (const attempt of attempts) { try { return await request>(attempt.path, { + method: attempt.method ?? "GET", token, query: attempt.query, + body: attempt.body ? buildBody(attempt.body) : undefined, }); } catch (error) { lastError = error as Error & { status?: number }; @@ -392,6 +426,26 @@ export const savePlayerMatchProfile = async ({ query?: Record; includeUserId?: boolean; }> = [ + { + path: "/player/getchecklocation", + method: "POST", + includeUserId: true, + }, + { + path: `/player/getchecklocation/${encodeURIComponent(userId)}`, + method: "PATCH", + includeUserId: true, + }, + { + path: "/player/getchecklocation/specific_user", + method: "POST", + includeUserId: true, + }, + { + path: `/player/getchecklocation/specific_user/${encodeURIComponent(userId)}`, + method: "POST", + includeUserId: true, + }, { path: "/player/surveys/getchecklocation", method: "POST", From 3db1ee448066e82cca4bcd781ee451702fa172cd Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 16 Nov 2025 09:00:24 -0800 Subject: [PATCH 08/14] Expand match profile endpoint fallbacks --- src/api/playerHome.ts | 62 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts index fea0526f..980f633f 100644 --- a/src/api/playerHome.ts +++ b/src/api/playerHome.ts @@ -331,7 +331,7 @@ export interface FetchPlayerDetailsParams extends PlayerTokenOnlyParams { } export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsParams) => { - const userQuery = { userId } as Record; + const userQuery = { userId, user_id: userId } as Record; const userBody = { userId, user_id: userId }; const attempts: Array<{ path: string; @@ -342,38 +342,75 @@ export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsPa { path: `/player/surveys/getchecklocation/specific_user/${encodeURIComponent(userId)}`, }, + { + path: `/player/surveys/getchecklocation/specific-user/${encodeURIComponent(userId)}`, + }, { path: "/player/surveys/getchecklocation/specific_user", query: userQuery, }, + { + path: "/player/surveys/getchecklocation/specific-user", + query: userQuery, + }, { path: `/player/surveys/getchecklocation/${encodeURIComponent(userId)}`, }, + { + path: "/player/surveys/getchecklocation", + query: userQuery, + }, { path: `/player/getchecklocation/specific_user/${encodeURIComponent(userId)}`, }, + { + path: `/player/getchecklocation/specific-user/${encodeURIComponent(userId)}`, + }, { path: "/player/getchecklocation/specific_user", query: userQuery, }, + { + path: "/player/getchecklocation/specific-user", + query: userQuery, + }, { path: `/player/getchecklocation/${encodeURIComponent(userId)}`, }, + { + path: "/player/getchecklocation", + query: userQuery, + }, { path: "/player/getchecklocation/specific_user", method: "POST", body: userBody, }, + { + path: "/player/getchecklocation/specific-user", + method: "POST", + body: userBody, + }, { path: "/player/surveys/getchecklocation/specific_user", method: "POST", body: userBody, }, + { + path: "/player/surveys/getchecklocation/specific-user", + method: "POST", + body: userBody, + }, { path: "/player/getchecklocation", method: "POST", body: userBody, }, + { + path: "/player/surveys/getchecklocation", + method: "POST", + body: userBody, + }, ]; let lastError: Error & { status?: number } | undefined; @@ -441,11 +478,21 @@ export const savePlayerMatchProfile = async ({ method: "POST", includeUserId: true, }, + { + path: "/player/getchecklocation/specific-user", + method: "POST", + includeUserId: true, + }, { path: `/player/getchecklocation/specific_user/${encodeURIComponent(userId)}`, method: "POST", includeUserId: true, }, + { + path: `/player/getchecklocation/specific-user/${encodeURIComponent(userId)}`, + method: "POST", + includeUserId: true, + }, { path: "/player/surveys/getchecklocation", method: "POST", @@ -459,7 +506,18 @@ export const savePlayerMatchProfile = async ({ { path: "/player/surveys/getchecklocation/specific_user", method: "POST", - query: { userId }, + query: { userId, user_id: userId }, + includeUserId: true, + }, + { + path: "/player/surveys/getchecklocation/specific-user", + method: "POST", + query: { userId, user_id: userId }, + includeUserId: true, + }, + { + path: `/player/surveys/getchecklocation/specific-user/${encodeURIComponent(userId)}`, + method: "POST", includeUserId: true, }, { From cae1d71ccc8abcec737baa7ebed87c6a53b2c812 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 16 Nov 2025 09:34:18 -0800 Subject: [PATCH 09/14] Expand match profile endpoint permutations --- src/api/playerHome.ts | 198 +++++++++++++----------------------------- 1 file changed, 58 insertions(+), 140 deletions(-) diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts index 980f633f..2ae52c32 100644 --- a/src/api/playerHome.ts +++ b/src/api/playerHome.ts @@ -330,88 +330,50 @@ export interface FetchPlayerDetailsParams extends PlayerTokenOnlyParams { userId: number | string; } +const MATCH_PROFILE_ROUTE_PREFIXES = ["/player/surveys", "/player"] as const; +const MATCH_PROFILE_ROUTE_RESOURCES = ["getchecklocation", "get-check-location", "get_check_location"] as const; +const MATCH_PROFILE_SPECIFIC_SEGMENTS = ["specific_user", "specific-user"] as const; + +const MATCH_PROFILE_BASE_ROUTES = MATCH_PROFILE_ROUTE_PREFIXES.flatMap((prefix) => + MATCH_PROFILE_ROUTE_RESOURCES.map((resource) => `${prefix}/${resource}`), +); + +const shouldRetryMatchProfileError = (error?: { status?: number }) => + error?.status === 404 || error?.status === 500; + export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsParams) => { const userQuery = { userId, user_id: userId } as Record; const userBody = { userId, user_id: userId }; + const encodedUserId = encodeURIComponent(userId); const attempts: Array<{ path: string; method?: string; query?: Record; body?: Record; - }> = [ - { - path: `/player/surveys/getchecklocation/specific_user/${encodeURIComponent(userId)}`, - }, - { - path: `/player/surveys/getchecklocation/specific-user/${encodeURIComponent(userId)}`, - }, - { - path: "/player/surveys/getchecklocation/specific_user", - query: userQuery, - }, - { - path: "/player/surveys/getchecklocation/specific-user", - query: userQuery, - }, - { - path: `/player/surveys/getchecklocation/${encodeURIComponent(userId)}`, - }, - { - path: "/player/surveys/getchecklocation", - query: userQuery, - }, - { - path: `/player/getchecklocation/specific_user/${encodeURIComponent(userId)}`, - }, - { - path: `/player/getchecklocation/specific-user/${encodeURIComponent(userId)}`, - }, - { - path: "/player/getchecklocation/specific_user", - query: userQuery, - }, - { - path: "/player/getchecklocation/specific-user", - query: userQuery, - }, - { - path: `/player/getchecklocation/${encodeURIComponent(userId)}`, - }, - { - path: "/player/getchecklocation", - query: userQuery, - }, - { - path: "/player/getchecklocation/specific_user", - method: "POST", - body: userBody, - }, - { - path: "/player/getchecklocation/specific-user", - method: "POST", - body: userBody, - }, - { - path: "/player/surveys/getchecklocation/specific_user", - method: "POST", - body: userBody, - }, - { - path: "/player/surveys/getchecklocation/specific-user", - method: "POST", - body: userBody, - }, - { - path: "/player/getchecklocation", - method: "POST", - body: userBody, - }, - { - path: "/player/surveys/getchecklocation", - method: "POST", - body: userBody, - }, - ]; + }> = []; + + for (const base of MATCH_PROFILE_BASE_ROUTES) { + for (const specific of MATCH_PROFILE_SPECIFIC_SEGMENTS) { + attempts.push({ path: `${base}/${specific}/${encodedUserId}` }); + attempts.push({ path: `${base}/${specific}`, query: userQuery }); + } + attempts.push({ path: `${base}/${encodedUserId}` }); + attempts.push({ path: base, query: userQuery }); + } + + for (const base of MATCH_PROFILE_BASE_ROUTES) { + attempts.push({ path: base, method: "POST", body: userBody }); + for (const specific of MATCH_PROFILE_SPECIFIC_SEGMENTS) { + attempts.push({ path: `${base}/${specific}`, method: "POST", body: userBody }); + attempts.push({ + path: `${base}/${specific}`, + method: "POST", + query: userQuery, + body: userBody, + }); + attempts.push({ path: `${base}/${specific}/${encodedUserId}`, method: "POST", body: userBody }); + } + } let lastError: Error & { status?: number } | undefined; @@ -425,7 +387,7 @@ export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsPa }); } catch (error) { lastError = error as Error & { status?: number }; - if (lastError?.status !== 404) { + if (!shouldRetryMatchProfileError(lastError)) { throw lastError; } } @@ -457,75 +419,31 @@ export const savePlayerMatchProfile = async ({ userId, profile, }: SavePlayerMatchProfileParams) => { + const encodedUserId = encodeURIComponent(userId); + const userQuery = { userId, user_id: userId } as Record; const attempts: Array<{ path: string; method?: string; query?: Record; includeUserId?: boolean; - }> = [ - { - path: "/player/getchecklocation", - method: "POST", - includeUserId: true, - }, - { - path: `/player/getchecklocation/${encodeURIComponent(userId)}`, - method: "PATCH", - includeUserId: true, - }, - { - path: "/player/getchecklocation/specific_user", - method: "POST", - includeUserId: true, - }, - { - path: "/player/getchecklocation/specific-user", - method: "POST", - includeUserId: true, - }, - { - path: `/player/getchecklocation/specific_user/${encodeURIComponent(userId)}`, - method: "POST", - includeUserId: true, - }, - { - path: `/player/getchecklocation/specific-user/${encodeURIComponent(userId)}`, - method: "POST", - includeUserId: true, - }, - { - path: "/player/surveys/getchecklocation", - method: "POST", - includeUserId: true, - }, - { - path: `/player/surveys/getchecklocation/specific_user/${encodeURIComponent(userId)}`, - method: "POST", - includeUserId: true, - }, - { - path: "/player/surveys/getchecklocation/specific_user", - method: "POST", - query: { userId, user_id: userId }, - includeUserId: true, - }, - { - path: "/player/surveys/getchecklocation/specific-user", - method: "POST", - query: { userId, user_id: userId }, - includeUserId: true, - }, - { - path: `/player/surveys/getchecklocation/specific-user/${encodeURIComponent(userId)}`, - method: "POST", - includeUserId: true, - }, - { - path: `/player/surveys/getchecklocation/${encodeURIComponent(userId)}`, - method: "PATCH", - includeUserId: true, - }, - ]; + }> = []; + + for (const base of MATCH_PROFILE_BASE_ROUTES) { + attempts.push({ path: base, method: "POST", includeUserId: true }); + attempts.push({ path: `${base}/${encodedUserId}`, method: "POST", includeUserId: true }); + for (const specific of MATCH_PROFILE_SPECIFIC_SEGMENTS) { + const specificPath = `${base}/${specific}`; + attempts.push({ path: specificPath, method: "POST", includeUserId: true }); + attempts.push({ + path: specificPath, + method: "POST", + query: userQuery, + includeUserId: true, + }); + attempts.push({ path: `${specificPath}/${encodedUserId}`, method: "POST", includeUserId: true }); + } + attempts.push({ path: `${base}/${encodedUserId}`, method: "PATCH", includeUserId: true }); + } let lastError: Error & { status?: number } | undefined; @@ -547,7 +465,7 @@ export const savePlayerMatchProfile = async ({ }); } catch (error) { lastError = error as Error & { status?: number }; - if (lastError?.status !== 404) { + if (!shouldRetryMatchProfileError(lastError)) { throw lastError; } } From 06a0c0ec2347ac303ba719c5136566b5f58ca456 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 16 Nov 2025 09:46:26 -0800 Subject: [PATCH 10/14] Use documented match profile endpoints --- src/api/playerHome.ts | 139 ++++-------------------------------------- 1 file changed, 11 insertions(+), 128 deletions(-) diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts index 2ae52c32..6c34e14d 100644 --- a/src/api/playerHome.ts +++ b/src/api/playerHome.ts @@ -330,75 +330,11 @@ export interface FetchPlayerDetailsParams extends PlayerTokenOnlyParams { userId: number | string; } -const MATCH_PROFILE_ROUTE_PREFIXES = ["/player/surveys", "/player"] as const; -const MATCH_PROFILE_ROUTE_RESOURCES = ["getchecklocation", "get-check-location", "get_check_location"] as const; -const MATCH_PROFILE_SPECIFIC_SEGMENTS = ["specific_user", "specific-user"] as const; - -const MATCH_PROFILE_BASE_ROUTES = MATCH_PROFILE_ROUTE_PREFIXES.flatMap((prefix) => - MATCH_PROFILE_ROUTE_RESOURCES.map((resource) => `${prefix}/${resource}`), -); - -const shouldRetryMatchProfileError = (error?: { status?: number }) => - error?.status === 404 || error?.status === 500; - -export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsParams) => { - const userQuery = { userId, user_id: userId } as Record; - const userBody = { userId, user_id: userId }; - const encodedUserId = encodeURIComponent(userId); - const attempts: Array<{ - path: string; - method?: string; - query?: Record; - body?: Record; - }> = []; - - for (const base of MATCH_PROFILE_BASE_ROUTES) { - for (const specific of MATCH_PROFILE_SPECIFIC_SEGMENTS) { - attempts.push({ path: `${base}/${specific}/${encodedUserId}` }); - attempts.push({ path: `${base}/${specific}`, query: userQuery }); - } - attempts.push({ path: `${base}/${encodedUserId}` }); - attempts.push({ path: base, query: userQuery }); - } - - for (const base of MATCH_PROFILE_BASE_ROUTES) { - attempts.push({ path: base, method: "POST", body: userBody }); - for (const specific of MATCH_PROFILE_SPECIFIC_SEGMENTS) { - attempts.push({ path: `${base}/${specific}`, method: "POST", body: userBody }); - attempts.push({ - path: `${base}/${specific}`, - method: "POST", - query: userQuery, - body: userBody, - }); - attempts.push({ path: `${base}/${specific}/${encodedUserId}`, method: "POST", body: userBody }); - } - } - - let lastError: Error & { status?: number } | undefined; - - for (const attempt of attempts) { - try { - return await request>(attempt.path, { - method: attempt.method ?? "GET", - token, - query: attempt.query, - body: attempt.body ? buildBody(attempt.body) : undefined, - }); - } catch (error) { - lastError = error as Error & { status?: number }; - if (!shouldRetryMatchProfileError(lastError)) { - throw lastError; - } - } - } - - if (lastError) { - throw lastError; - } - - throw new Error("Unable to load match profile"); -}; +export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsParams) => + request>("/player/surveys/getchecklocation/specific_user", { + token, + query: { userId }, + }); export interface PlayerMatchProfilePayload { about_me?: string; @@ -418,65 +354,12 @@ export const savePlayerMatchProfile = async ({ token, userId, profile, -}: SavePlayerMatchProfileParams) => { - const encodedUserId = encodeURIComponent(userId); - const userQuery = { userId, user_id: userId } as Record; - const attempts: Array<{ - path: string; - method?: string; - query?: Record; - includeUserId?: boolean; - }> = []; - - for (const base of MATCH_PROFILE_BASE_ROUTES) { - attempts.push({ path: base, method: "POST", includeUserId: true }); - attempts.push({ path: `${base}/${encodedUserId}`, method: "POST", includeUserId: true }); - for (const specific of MATCH_PROFILE_SPECIFIC_SEGMENTS) { - const specificPath = `${base}/${specific}`; - attempts.push({ path: specificPath, method: "POST", includeUserId: true }); - attempts.push({ - path: specificPath, - method: "POST", - query: userQuery, - includeUserId: true, - }); - attempts.push({ path: `${specificPath}/${encodedUserId}`, method: "POST", includeUserId: true }); - } - attempts.push({ path: `${base}/${encodedUserId}`, method: "PATCH", includeUserId: true }); - } - - let lastError: Error & { status?: number } | undefined; - - for (const attempt of attempts) { - try { - return await request>(attempt.path, { - method: attempt.method ?? "POST", - token, - query: attempt.query, - body: buildBody({ - ...(attempt.includeUserId - ? { - userId, - user_id: userId, - } - : {}), - ...profile, - }), - }); - } catch (error) { - lastError = error as Error & { status?: number }; - if (!shouldRetryMatchProfileError(lastError)) { - throw lastError; - } - } - } - - if (lastError) { - throw lastError; - } - - throw new Error("Unable to save match profile"); -}; +}: SavePlayerMatchProfileParams) => + request>("/player/surveys/getchecklocation/specific_user", { + method: "POST", + token, + body: buildBody({ userId, ...profile }), + }); export interface SuggestedPlayerCheckLocationParams extends PaginationParams { token: string; From 542e3895f01d430f419e2ff140e13f2c3679d2e9 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 16 Nov 2025 10:00:08 -0800 Subject: [PATCH 11/14] Ensure match profile requests send token auth --- src/api/playerHome.ts | 2 ++ src/pages/PlayerMatchProfilePage.tsx | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts index 6c34e14d..ca629435 100644 --- a/src/api/playerHome.ts +++ b/src/api/playerHome.ts @@ -333,6 +333,7 @@ export interface FetchPlayerDetailsParams extends PlayerTokenOnlyParams { export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsParams) => request>("/player/surveys/getchecklocation/specific_user", { token, + authScheme: "token", query: { userId }, }); @@ -358,6 +359,7 @@ export const savePlayerMatchProfile = async ({ request>("/player/surveys/getchecklocation/specific_user", { method: "POST", token, + authScheme: "token", body: buildBody({ userId, ...profile }), }); diff --git a/src/pages/PlayerMatchProfilePage.tsx b/src/pages/PlayerMatchProfilePage.tsx index 46a74441..1125d26d 100644 --- a/src/pages/PlayerMatchProfilePage.tsx +++ b/src/pages/PlayerMatchProfilePage.tsx @@ -267,7 +267,7 @@ const PlayerMatchProfilePage = () => { let cancelled = false; const loadProfile = async () => { - const token = getStoredAuthToken({ preferScheme: "Bearer" }); + const token = getStoredAuthToken({ defaultScheme: "token", preferScheme: "token" }); if (!token) { setStatus("error"); setError("Please sign in to view your match profile."); @@ -350,7 +350,7 @@ const PlayerMatchProfilePage = () => { setModalError("We couldn’t determine your player profile ID."); return; } - const token = getStoredAuthToken({ preferScheme: "Bearer" }); + const token = getStoredAuthToken({ defaultScheme: "token", preferScheme: "token" }); if (!token) { setModalError("Please sign in again to save your match profile."); return; From 472d07f686f75001bd8027a2fb78de7f9540204f Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 16 Nov 2025 10:08:05 -0800 Subject: [PATCH 12/14] Include user_id when loading/saving match profiles --- src/api/playerHome.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts index ca629435..523f3955 100644 --- a/src/api/playerHome.ts +++ b/src/api/playerHome.ts @@ -334,7 +334,7 @@ export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsPa request>("/player/surveys/getchecklocation/specific_user", { token, authScheme: "token", - query: { userId }, + query: { userId, user_id: userId }, }); export interface PlayerMatchProfilePayload { @@ -360,7 +360,8 @@ export const savePlayerMatchProfile = async ({ method: "POST", token, authScheme: "token", - body: buildBody({ userId, ...profile }), + query: { userId }, + body: buildBody({ userId, user_id: userId, ...profile }), }); export interface SuggestedPlayerCheckLocationParams extends PaginationParams { From 4374025c530ca123d5342fc438b5483ab224ed38 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 16 Nov 2025 10:13:35 -0800 Subject: [PATCH 13/14] Normalize match profile auth tokens --- src/api/playerHome.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts index 523f3955..c18a5498 100644 --- a/src/api/playerHome.ts +++ b/src/api/playerHome.ts @@ -330,9 +330,25 @@ export interface FetchPlayerDetailsParams extends PlayerTokenOnlyParams { userId: number | string; } +const extractTokenCredentials = (token?: string | null) => { + if (!token) { + return undefined; + } + const trimmed = token.trim(); + if (!trimmed) { + return undefined; + } + const match = trimmed.match(/^[A-Za-z]+\s+(.+)$/); + if (match) { + const [, credentials] = match; + return credentials?.trim() || undefined; + } + return trimmed; +}; + export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsParams) => request>("/player/surveys/getchecklocation/specific_user", { - token, + token: extractTokenCredentials(token), authScheme: "token", query: { userId, user_id: userId }, }); @@ -358,7 +374,7 @@ export const savePlayerMatchProfile = async ({ }: SavePlayerMatchProfileParams) => request>("/player/surveys/getchecklocation/specific_user", { method: "POST", - token, + token: extractTokenCredentials(token), authScheme: "token", query: { userId }, body: buildBody({ userId, user_id: userId, ...profile }), From 4b864f9cdfb0ab40f34c1c5129a686c31ad3d050 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 16 Nov 2025 10:41:07 -0800 Subject: [PATCH 14/14] Add match profile endpoint fallbacks --- src/api/playerHome.ts | 90 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 18 deletions(-) diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts index c18a5498..1ad6d1e9 100644 --- a/src/api/playerHome.ts +++ b/src/api/playerHome.ts @@ -346,12 +346,57 @@ const extractTokenCredentials = (token?: string | null) => { return trimmed; }; -export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsParams) => - request>("/player/surveys/getchecklocation/specific_user", { - token: extractTokenCredentials(token), - authScheme: "token", - query: { userId, user_id: userId }, - }); +type MatchProfileRoute = { + path: string; + method: string; + includeUserIdInQuery?: boolean; + includeUserIdInBody?: boolean; +}; + +const MATCH_PROFILE_FETCH_ROUTES: MatchProfileRoute[] = [ + { path: "/player/surveys/getchecklocation/specific_user", method: "GET", includeUserIdInQuery: true }, + { path: "/player/surveys/getchecklocation", method: "GET", includeUserIdInQuery: true }, + { path: "/player/getchecklocation/specific_user", method: "GET", includeUserIdInQuery: true }, + { path: "/player/getchecklocation", method: "POST", includeUserIdInBody: true }, +]; + +const MATCH_PROFILE_SAVE_ROUTES: MatchProfileRoute[] = [ + { path: "/player/surveys/getchecklocation/specific_user", method: "POST", includeUserIdInQuery: true, includeUserIdInBody: true }, + { path: "/player/surveys/getchecklocation", method: "POST", includeUserIdInBody: true }, + { path: "/player/getchecklocation/specific_user", method: "POST", includeUserIdInQuery: true, includeUserIdInBody: true }, + { path: "/player/getchecklocation", method: "POST", includeUserIdInBody: true }, +]; + +const buildMatchProfileQuery = (userId: number | string) => ({ userId, user_id: userId }); +const buildMatchProfileBody = (userId: number | string, profile?: PlayerMatchProfilePayload) => + buildBody({ ...buildMatchProfileQuery(userId), ...(profile ?? {}) }); + +const shouldRetryMatchProfileRoute = (error: unknown) => { + const status = (error as { status?: number })?.status; + return status === 404 || status === 500; +}; + +export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsParams) => { + const bareToken = extractTokenCredentials(token); + let lastError: unknown; + for (const route of MATCH_PROFILE_FETCH_ROUTES) { + try { + return await request>(route.path, { + method: route.method, + token: bareToken, + authScheme: "token", + query: route.includeUserIdInQuery ? buildMatchProfileQuery(userId) : undefined, + body: route.includeUserIdInBody ? buildMatchProfileBody(userId) : undefined, + }); + } catch (error) { + lastError = error; + if (!shouldRetryMatchProfileRoute(error)) { + throw error; + } + } + } + throw lastError ?? new Error("Unable to load match profile"); +}; export interface PlayerMatchProfilePayload { about_me?: string; @@ -367,18 +412,27 @@ export interface SavePlayerMatchProfileParams extends PlayerTokenOnlyParams { profile: PlayerMatchProfilePayload; } -export const savePlayerMatchProfile = async ({ - token, - userId, - profile, -}: SavePlayerMatchProfileParams) => - request>("/player/surveys/getchecklocation/specific_user", { - method: "POST", - token: extractTokenCredentials(token), - authScheme: "token", - query: { userId }, - body: buildBody({ userId, user_id: userId, ...profile }), - }); +export const savePlayerMatchProfile = async ({ token, userId, profile }: SavePlayerMatchProfileParams) => { + const bareToken = extractTokenCredentials(token); + let lastError: unknown; + for (const route of MATCH_PROFILE_SAVE_ROUTES) { + try { + return await request>(route.path, { + method: route.method, + token: bareToken, + authScheme: "token", + query: route.includeUserIdInQuery ? buildMatchProfileQuery(userId) : undefined, + body: route.includeUserIdInBody ? buildMatchProfileBody(userId, profile) : buildBody(profile), + }); + } catch (error) { + lastError = error; + if (!shouldRetryMatchProfileRoute(error)) { + throw error; + } + } + } + throw lastError ?? new Error("Unable to save match profile"); +}; export interface SuggestedPlayerCheckLocationParams extends PaginationParams { token: string;