From 7e6fcca8a173d93926ee75346887114e8fe00d64 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Mon, 17 Nov 2025 13:15:29 -0800 Subject: [PATCH 1/4] Implement player match profile experience --- src/api/playerHome.ts | 2 +- src/pages/PlayerMatchProfilePage.css | 271 ++++++++++++ src/pages/PlayerMatchProfilePage.tsx | 618 +++++++++++++++++++-------- 3 files changed, 710 insertions(+), 181 deletions(-) create mode 100644 src/pages/PlayerMatchProfilePage.css diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts index 3597c2c4..6de26daf 100644 --- a/src/api/playerHome.ts +++ b/src/api/playerHome.ts @@ -417,7 +417,7 @@ export const getUserVerificationLevel = async ({ token }: PlayerTokenOnlyParams) export interface VerifyUserLevelParams extends PlayerTokenOnlyParams { userId: number | string; - level: string; + level: string | boolean; } export const verifyUserLevel = async ({ token, userId, level }: VerifyUserLevelParams) => diff --git a/src/pages/PlayerMatchProfilePage.css b/src/pages/PlayerMatchProfilePage.css new file mode 100644 index 00000000..d925bd1c --- /dev/null +++ b/src/pages/PlayerMatchProfilePage.css @@ -0,0 +1,271 @@ +.match-profile-page { + padding: 32px; + background: #f5f5f7; + min-height: calc(100vh - 80px); + color: #111; +} + +.match-profile-card { + max-width: 960px; + margin: 0 auto; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 16px; + padding: 24px; + box-shadow: 0 10px 35px rgba(17, 24, 39, 0.05); + display: flex; + flex-direction: column; + gap: 24px; +} + +.match-profile-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.match-profile-person { + display: flex; + align-items: center; + gap: 16px; +} + +.match-profile-avatar { + width: 72px; + height: 72px; + border-radius: 50%; + background: linear-gradient(135deg, #f3f4f6, #e5e7eb); + border: 1px solid #e5e7eb; + display: grid; + place-items: center; + color: #4b5563; + font-weight: 700; + font-size: 18px; + overflow: hidden; +} + +.match-profile-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.match-profile-header h1 { + margin: 0 0 4px; + font-size: 24px; + font-weight: 700; + color: #0f172a; +} + +.match-profile-meta { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; + color: #475467; + font-size: 14px; +} + +.match-profile-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.match-profile-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: 10px; + border: 1px solid #e5e7eb; + background: #fff; + color: #111827; + font-weight: 600; + font-size: 14px; + cursor: pointer; + text-decoration: none; + transition: all 0.2s ease; +} + +.match-profile-button svg.spin { + animation: spin 1s linear infinite; +} + +.match-profile-button--secondary { + background: #f9fafb; +} + +.match-profile-button--primary { + background: #4f46e5; + color: #fff; + border-color: #4338ca; +} + +.match-profile-button--active { + background: #fef2f2; + color: #b91c1c; + border-color: #fecdd3; +} + +.match-profile-button--danger { + background: #fef2f2; + color: #991b1b; + border-color: #fecaca; +} + +.match-profile-button:disabled, +.match-profile-button[aria-disabled="true"] { + opacity: 0.6; + cursor: not-allowed; +} + +.match-profile-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: 12px; + background: #fef3c7; + color: #854d0e; + border: 1px solid #fcd34d; +} + +.match-profile-section { + border-top: 1px solid #f1f5f9; + padding-top: 16px; +} + +.match-profile-section h2 { + margin: 0 0 8px; + font-size: 18px; + color: #0f172a; +} + +.match-profile-section__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.match-profile-level-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: #eef2ff; + color: #3730a3; + border-radius: 999px; + font-weight: 600; + font-size: 14px; +} + +.match-profile-body { + margin: 0 0 10px; + color: #475467; + line-height: 1.6; +} + +.match-profile-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 8px; +} + +.match-profile-list li { + padding: 10px 12px; + border-radius: 10px; + background: #f8fafc; + border: 1px solid #e2e8f0; + color: #0f172a; +} + +.match-profile-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.match-profile-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border-radius: 999px; + background: #f1f5f9; + color: #0f172a; + font-weight: 600; + border: 1px solid #e2e8f0; +} + +.match-profile-section--cta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 16px; +} + +.match-profile-state { + max-width: 600px; + margin: 80px auto 0; + padding: 16px 18px; + border-radius: 12px; + background: #f1f5f9; + color: #0f172a; + border: 1px solid #e2e8f0; + display: inline-flex; + align-items: center; + gap: 12px; +} + +.match-profile-state__title { + margin: 0; + font-weight: 700; + color: #111827; +} + +.match-profile-state__message { + margin: 4px 0 0; + color: #475467; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 640px) { + .match-profile-card { + padding: 18px; + } + + .match-profile-header { + align-items: flex-start; + } + + .match-profile-person { + width: 100%; + } + + .match-profile-actions { + width: 100%; + } + + .match-profile-button, + .match-profile-button--primary, + .match-profile-button--secondary { + justify-content: center; + flex: 1; + } +} diff --git a/src/pages/PlayerMatchProfilePage.tsx b/src/pages/PlayerMatchProfilePage.tsx index b54a35e5..cf263a8e 100644 --- a/src/pages/PlayerMatchProfilePage.tsx +++ b/src/pages/PlayerMatchProfilePage.tsx @@ -1,200 +1,458 @@ -import { useState } from "react"; -import { Check, Clock, MapPin, Target } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useLocation, useParams, useSearchParams } from "react-router-dom"; +import { + AlertCircle, + Ban, + BadgeCheck, + Heart, + Loader2, + MessageCircle, + Phone, + ShieldCheck, + Star, +} from "lucide-react"; import MainLayout from "../components/MainLayout"; +import { addFavorite, blockPlayer, fetchPlayerDetails, removeFavorite, unblockPlayer, verifyUserLevel } from "../api/playerHome"; +import { getStoredAuthToken } from "../services/authToken"; +import { formatPhoneDisplay, getPhoneDigits } from "../services/phone"; +import usePlayerIdentity from "../hooks/usePlayerIdentity"; +import { useAuth } from "../context/AuthContext"; -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" }, -]; +import "./PlayerMatchProfilePage.css"; + +type RawPlayerRecord = { + userId?: number | string; + id?: number | string; + full_name?: string; + skillLevel?: string; + phone?: string; + profile_picture?: string; + about_me?: string; + availability?: unknown; + playerCourtLocations?: unknown; + lookingFor?: unknown; + verifiedLevelCount?: number | string; + is_favorite?: boolean; + is_blocked?: boolean; + [key: string]: unknown; +}; + +type PlayerProfile = { + userId: number | string; + fullName: string; + skillLevel?: string; + phone?: string; + profilePicture?: string; + about?: string; + availability: string[]; + courts: string[]; + lookingFor: string[]; + verifiedLevelCount: number; + isFavorite: boolean; + isBlocked: boolean; +}; + +const knownLookingFor = ["Fun / social", "Casual hitting", "Friendly competition"] as const; + +const toStringArray = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter((item): item is string => Boolean(item)); + } + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + return []; +}; + +const normalizeProfile = (record: RawPlayerRecord | null | undefined): PlayerProfile | null => { + if (!record) return null; + const userId = record.userId ?? record.id; + if (!userId) return null; + + const fullName = typeof record.full_name === "string" && record.full_name.trim() ? record.full_name.trim() : "TTP Player"; + + const verifiedCountRaw = record.verifiedLevelCount; + const verifiedLevelCount = typeof verifiedCountRaw === "number" + ? verifiedCountRaw + : typeof verifiedCountRaw === "string" + ? Number.parseInt(verifiedCountRaw, 10) || 0 + : 0; + + const lookingFor = toStringArray(record.lookingFor).filter((item) => knownLookingFor.includes(item as (typeof knownLookingFor)[number])); + + return { + userId, + fullName, + skillLevel: typeof record.skillLevel === "string" ? record.skillLevel : undefined, + phone: typeof record.phone === "string" ? record.phone : undefined, + profilePicture: typeof record.profile_picture === "string" ? record.profile_picture : undefined, + about: typeof record.about_me === "string" ? record.about_me : undefined, + availability: toStringArray(record.availability), + courts: toStringArray(record.playerCourtLocations), + lookingFor, + verifiedLevelCount, + isFavorite: Boolean(record.is_favorite), + isBlocked: Boolean(record.is_blocked), + }; +}; + +const extractUserId = (user: unknown): number | string | undefined => { + if (!user || typeof user !== "object") return undefined; + const profile = user as Record; + const idFields = ["id", "userId", "user_id", "playerId"] as const; + for (const field of idFields) { + const value = profile[field]; + if (typeof value === "number" || typeof value === "string") { + return value; + } + } + return undefined; +}; + +const extractSurveyAnswers = (user: unknown): unknown[] => { + if (!user || typeof user !== "object") return []; + const profile = user as Record; + const answers = (profile.survey_answers ?? profile.surveyAnswers ?? profile.surveys) as unknown; + return Array.isArray(answers) ? answers : []; +}; + +const deriveViewerLevel = (user: unknown): string | undefined => { + const answers = extractSurveyAnswers(user); + for (const answer of answers) { + if (!answer || typeof answer !== "object") continue; + const entry = answer as Record; + const questionId = entry.questionId ?? entry.question_id ?? entry.questionID; + if (questionId && String(questionId) === "3") { + const value = entry.answer ?? entry.response ?? entry.value ?? entry.answer_text; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + if (Array.isArray(value)) { + const first = value.find((item) => typeof item === "string" && item.trim()); + if (typeof first === "string") { + return first.trim(); + } + } + } + } + return undefined; +}; 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 { id: routeUserId } = useParams(); + const [searchParams] = useSearchParams(); + const location = useLocation(); + const { user } = useAuth() as { user?: unknown }; + const { displayName } = usePlayerIdentity(); + + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [actionError, setActionError] = useState(null); + const [favoriteLoading, setFavoriteLoading] = useState(false); + const [blockLoading, setBlockLoading] = useState(false); + const [verifyLoading, setVerifyLoading] = useState(false); + + const token = getStoredAuthToken({ preferScheme: "token" }); + + const targetUserId = useMemo(() => { + const stateUserId = (location.state as { userId?: number | string } | undefined)?.userId; + return ( + stateUserId || + searchParams.get("userId") || + searchParams.get("id") || + routeUserId || + extractUserId(user) ); + }, [location.state, routeUserId, searchParams, user]); + + const viewerLevel = useMemo(() => deriveViewerLevel(user), [user]); + const currentUserId = useMemo(() => extractUserId(user), [user]); + + const inviteMessage = useMemo(() => { + const playerName = profile?.fullName ?? "there"; + const viewerLevelText = viewerLevel ?? "tennis"; + const profileLinkId = currentUserId ?? "me"; + return `Hi ${playerName}, I'm ${displayName} and I found you on the Tennis Plan App. I'm a ${viewerLevelText} level player and you can check out my profile here: ttp://player/profile/${profileLinkId}. Let me know if you'd be interested in hitting some time.`; + }, [currentUserId, displayName, profile?.fullName, viewerLevel]); + + useEffect(() => { + const fetchProfile = async () => { + if (!token) { + setError("Missing authentication token."); + setLoading(false); + return; + } + if (!targetUserId) { + setError("Missing player identifier."); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + try { + const response = await fetchPlayerDetails({ token, authScheme: "token", userId: targetUserId }); + const firstRecord = Array.isArray(response) + ? (response[0] as RawPlayerRecord | undefined) + : Array.isArray((response as { data?: unknown[] })?.data) + ? ((response as { data?: unknown[] }).data?.[0] as RawPlayerRecord | undefined) + : (response as RawPlayerRecord | undefined); + const normalized = normalizeProfile(firstRecord); + if (!normalized) { + setError("We couldn't load this player's profile."); + } + setProfile(normalized); + } catch (err) { + setError((err as Error).message || "Unable to load player profile."); + } finally { + setLoading(false); + } + }; + + void fetchProfile(); + }, [targetUserId, token]); + + const toggleFavorite = async () => { + if (!profile || !token) return; + setFavoriteLoading(true); + setActionError(null); + try { + if (profile.isFavorite) { + await removeFavorite({ token, followeeId: profile.userId }); + setProfile((current) => (current ? { ...current, isFavorite: false } : current)); + } else { + await addFavorite({ token, followeeId: profile.userId }); + setProfile((current) => (current ? { ...current, isFavorite: true } : current)); + } + } catch (err) { + setActionError((err as Error).message || "Unable to update favorite."); + } finally { + setFavoriteLoading(false); + } }; - const toggleFormat = (id: string) => { - setFormats((current) => - current.includes(id) ? current.filter((item) => item !== id) : [...current, id] - ); + const toggleBlock = async () => { + if (!profile || !token) return; + setBlockLoading(true); + setActionError(null); + try { + if (profile.isBlocked) { + await unblockPlayer({ token, blockedId: profile.userId }); + setProfile((current) => (current ? { ...current, isBlocked: false } : current)); + } else { + await blockPlayer({ token, blockedId: profile.userId }); + setProfile((current) => (current ? { ...current, isBlocked: true } : current)); + } + } catch (err) { + setActionError((err as Error).message || "Unable to update block status."); + } finally { + setBlockLoading(false); + } }; + const verifyLevel = async () => { + if (!profile || !token) return; + setVerifyLoading(true); + setActionError(null); + try { + await verifyUserLevel({ token, userId: profile.userId, level: true }); + setProfile((current) => + current + ? { ...current, verifiedLevelCount: (current.verifiedLevelCount || 0) + 1 } + : current, + ); + } catch (err) { + setActionError((err as Error).message || "Unable to verify level."); + } finally { + setVerifyLoading(false); + } + }; + + const initials = useMemo(() => { + const name = profile?.fullName ?? "Player"; + const parts = name.split(" ").filter(Boolean); + if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + if (parts[0]) return parts[0].slice(0, 2).toUpperCase(); + return "TP"; + }, [profile?.fullName]); + + const smsBody = encodeURIComponent(inviteMessage); + const phoneDigits = getPhoneDigits(profile?.phone); + const smsHref = phoneDigits ? `sms:${phoneDigits}?&body=${smsBody}` : undefined; + return ( -
-
-
- - -

Player match profile

-

- Tell other players how and when you like to compete so we can suggest better partners and session ideas. -

-
- -
-
-
-
-
-

-

-

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 ( - - ); - })} -
-
+
+ {loading ? ( +
+
+ ) : error || !profile ? ( +
+
+ ) : ( +
+
+
+
+ {profile.profilePicture ? ( + {`${profile.fullName} + ) : ( + {initials} + )} +
+
+

{profile.fullName}

+

+

+
+
+
+ + { + if (!smsHref) { + event.preventDefault(); + } + }} + aria-disabled={!smsHref} + > + +
+
- -
-
+ ) : ( +

No play preferences shared yet.

+ )} + -
- -
-
+
+
+

Invite {profile.fullName.split(" ")[0] || "this player"}

+

Send a quick SMS with your profile link and level.

+
+ { + if (!smsHref) { + event.preventDefault(); + } + }} + aria-disabled={!smsHref} + > + +
+ + )}
); From 32bd6fd9b89ca75b70c68f9c43e67766e8c92a6f Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Mon, 17 Nov 2025 17:41:26 -0800 Subject: [PATCH 2/4] Fix auth scheme for player match profile --- src/pages/PlayerMatchProfilePage.tsx | 2 +- src/services/authToken.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/PlayerMatchProfilePage.tsx b/src/pages/PlayerMatchProfilePage.tsx index cf263a8e..a312be0b 100644 --- a/src/pages/PlayerMatchProfilePage.tsx +++ b/src/pages/PlayerMatchProfilePage.tsx @@ -156,7 +156,7 @@ const PlayerMatchProfilePage = () => { const [blockLoading, setBlockLoading] = useState(false); const [verifyLoading, setVerifyLoading] = useState(false); - const token = getStoredAuthToken({ preferScheme: "token" }); + const token = getStoredAuthToken({ defaultScheme: "token", preferScheme: "token" }); const targetUserId = useMemo(() => { const stateUserId = (location.state as { userId?: number | string } | undefined)?.userId; diff --git a/src/services/authToken.js b/src/services/authToken.js index ed632323..15d48dfb 100644 --- a/src/services/authToken.js +++ b/src/services/authToken.js @@ -13,7 +13,7 @@ const canonicalizeScheme = (scheme) => { const lower = scheme.trim().toLowerCase(); if (!lower) return ""; if (lower === "bearer") return "Bearer"; - if (lower === "token") return "Token"; + if (lower === "token") return "token"; return scheme.trim(); }; From afbd3eba1f8047b6f0742b6df53ebc152f47c60d Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Mon, 17 Nov 2025 17:53:50 -0800 Subject: [PATCH 3/4] Handle location permission for match profiles --- src/api/playerHome.ts | 8 ++- src/pages/PlayerMatchProfilePage.tsx | 78 ++++++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts index 6de26daf..b3a9a973 100644 --- a/src/api/playerHome.ts +++ b/src/api/playerHome.ts @@ -1,4 +1,5 @@ import { request } from "./http"; +import type { AuthScheme } from "./http"; import type { PaginatedResponse } from "./player"; export interface CoachSummary { @@ -328,16 +329,21 @@ export const getCheckLocation = async ({ export interface FetchPlayerDetailsParams extends PlayerTokenOnlyParams { userId: number | string; + position?: PositionPayload | null; + authScheme?: AuthScheme; } -export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsParams) => +export const fetchPlayerDetails = async ({ token, userId, position, authScheme }: FetchPlayerDetailsParams) => request>( "/player/surveys/getchecklocation/specific_user", { + method: position ? "POST" : "GET", token, + authScheme, query: { userId, }, + body: position ? buildBody({ position }) : undefined, }, ); diff --git a/src/pages/PlayerMatchProfilePage.tsx b/src/pages/PlayerMatchProfilePage.tsx index a312be0b..0386c380 100644 --- a/src/pages/PlayerMatchProfilePage.tsx +++ b/src/pages/PlayerMatchProfilePage.tsx @@ -13,6 +13,7 @@ import { } from "lucide-react"; import MainLayout from "../components/MainLayout"; import { addFavorite, blockPlayer, fetchPlayerDetails, removeFavorite, unblockPlayer, verifyUserLevel } from "../api/playerHome"; +import type { PositionPayload } from "../api/playerHome"; import { getStoredAuthToken } from "../services/authToken"; import { formatPhoneDisplay, getPhoneDigits } from "../services/phone"; import usePlayerIdentity from "../hooks/usePlayerIdentity"; @@ -37,6 +38,13 @@ type RawPlayerRecord = { [key: string]: unknown; }; +type Coordinates = { + latitude: number; + longitude: number; +}; + +const USER_LOCATION_STORAGE_KEY = "player:web:user-location"; + type PlayerProfile = { userId: number | string; fullName: string; @@ -54,6 +62,29 @@ type PlayerProfile = { const knownLookingFor = ["Fun / social", "Casual hitting", "Friendly competition"] as const; +const parseStoredLocation = (value: unknown): Coordinates | null => { + if (!value || typeof value !== "object") return null; + const record = value as Record; + const latitude = record.latitude; + const longitude = record.longitude; + if (typeof latitude !== "number" || typeof longitude !== "number") { + return null; + } + return { latitude, longitude }; +}; + +const getStoredLocation = (): Coordinates | null => { + try { + if (typeof window === "undefined" || !window.localStorage) return null; + const raw = window.localStorage.getItem(USER_LOCATION_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as unknown; + return parseStoredLocation(parsed); + } catch { + return null; + } +}; + const toStringArray = (value: unknown): string[] => { if (Array.isArray(value)) { return value @@ -155,6 +186,7 @@ const PlayerMatchProfilePage = () => { const [favoriteLoading, setFavoriteLoading] = useState(false); const [blockLoading, setBlockLoading] = useState(false); const [verifyLoading, setVerifyLoading] = useState(false); + const [position, setPosition] = useState(null); const token = getStoredAuthToken({ defaultScheme: "token", preferScheme: "token" }); @@ -179,6 +211,39 @@ const PlayerMatchProfilePage = () => { return `Hi ${playerName}, I'm ${displayName} and I found you on the Tennis Plan App. I'm a ${viewerLevelText} level player and you can check out my profile here: ttp://player/profile/${profileLinkId}. Let me know if you'd be interested in hitting some time.`; }, [currentUserId, displayName, profile?.fullName, viewerLevel]); + useEffect(() => { + const stored = getStoredLocation(); + if (stored) { + setPosition((current) => current ?? { + latitude: stored.latitude, + longitude: stored.longitude, + latitudeDelta: 0.25, + longitudeDelta: 0.25, + }); + return; + } + + if (typeof navigator === "undefined" || !("geolocation" in navigator) || position) { + return; + } + + navigator.geolocation.getCurrentPosition( + (coords) => { + const { latitude, longitude } = coords.coords; + setPosition({ + latitude, + longitude, + latitudeDelta: 0.25, + longitudeDelta: 0.25, + }); + }, + () => { + // If location permission is denied, we simply skip adding position data. + }, + { maximumAge: 5 * 60 * 1000, timeout: 7000 }, + ); + }, [position]); + useEffect(() => { const fetchProfile = async () => { if (!token) { @@ -195,7 +260,7 @@ const PlayerMatchProfilePage = () => { setLoading(true); setError(null); try { - const response = await fetchPlayerDetails({ token, authScheme: "token", userId: targetUserId }); + const response = await fetchPlayerDetails({ token, authScheme: "token", userId: targetUserId, position }); const firstRecord = Array.isArray(response) ? (response[0] as RawPlayerRecord | undefined) : Array.isArray((response as { data?: unknown[] })?.data) @@ -207,14 +272,21 @@ const PlayerMatchProfilePage = () => { } setProfile(normalized); } catch (err) { - setError((err as Error).message || "Unable to load player profile."); + const enrichedError = err as Error & { status?: number; data?: unknown }; + const errorMessage = enrichedError.message || "Unable to load player profile."; + const locationDenied = /location/i.test(errorMessage) || /location/i.test(String(enrichedError.data || "")); + if (locationDenied && (enrichedError.status === 401 || enrichedError.status === 403)) { + setError("We need location access to load this profile. Please enable location permissions and try again."); + } else { + setError(errorMessage); + } } finally { setLoading(false); } }; void fetchProfile(); - }, [targetUserId, token]); + }, [position, targetUserId, token]); const toggleFavorite = async () => { if (!profile || !token) return; From ab55b41158cbf84dcef86cf2332e39d45621e9d9 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Mon, 17 Nov 2025 18:12:19 -0800 Subject: [PATCH 4/4] Handle missing player match profile data --- src/api/playerHome.ts | 9 +++++++- src/pages/PlayerMatchProfilePage.tsx | 34 ++++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts index b3a9a973..2f4841d0 100644 --- a/src/api/playerHome.ts +++ b/src/api/playerHome.ts @@ -342,8 +342,15 @@ export const fetchPlayerDetails = async ({ token, userId, position, authScheme } authScheme, query: { userId, + user_id: userId, }, - body: position ? buildBody({ position }) : undefined, + body: position + ? buildBody({ + position, + userId, + user_id: userId, + }) + : undefined, }, ); diff --git a/src/pages/PlayerMatchProfilePage.tsx b/src/pages/PlayerMatchProfilePage.tsx index 0386c380..7ea9b6d3 100644 --- a/src/pages/PlayerMatchProfilePage.tsx +++ b/src/pages/PlayerMatchProfilePage.tsx @@ -130,6 +130,33 @@ const normalizeProfile = (record: RawPlayerRecord | null | undefined): PlayerPro }; }; +const extractFirstRecord = (response: unknown): RawPlayerRecord | undefined => { + if (Array.isArray(response)) { + return response[0] as RawPlayerRecord | undefined; + } + + if (response && typeof response === "object") { + const record = response as Record; + + const data = record.data; + if (Array.isArray(data)) { + return data[0] as RawPlayerRecord | undefined; + } + + const nestedData = (data as { data?: unknown[] } | undefined)?.data; + if (Array.isArray(nestedData)) { + return nestedData[0] as RawPlayerRecord | undefined; + } + + const user = record.user ?? record.player ?? record.profile; + if (user && typeof user === "object") { + return user as RawPlayerRecord; + } + } + + return undefined; +}; + const extractUserId = (user: unknown): number | string | undefined => { if (!user || typeof user !== "object") return undefined; const profile = user as Record; @@ -194,6 +221,7 @@ const PlayerMatchProfilePage = () => { const stateUserId = (location.state as { userId?: number | string } | undefined)?.userId; return ( stateUserId || + searchParams.get("playerId") || searchParams.get("userId") || searchParams.get("id") || routeUserId || @@ -261,11 +289,7 @@ const PlayerMatchProfilePage = () => { setError(null); try { const response = await fetchPlayerDetails({ token, authScheme: "token", userId: targetUserId, position }); - const firstRecord = Array.isArray(response) - ? (response[0] as RawPlayerRecord | undefined) - : Array.isArray((response as { data?: unknown[] })?.data) - ? ((response as { data?: unknown[] }).data?.[0] as RawPlayerRecord | undefined) - : (response as RawPlayerRecord | undefined); + const firstRecord = extractFirstRecord(response); const normalized = normalizeProfile(firstRecord); if (!normalized) { setError("We couldn't load this player's profile.");