diff --git a/docs/api-probe.md b/docs/api-probe.md new file mode 100644 index 00000000..219f5cef --- /dev/null +++ b/docs/api-probe.md @@ -0,0 +1,11 @@ +# API probe for player match profile + +Tried to authenticate and fetch player match profile data using the provided test account: + +``` +curl -i -H 'Content-Type: application/json' -X POST \ + https://ttp-api.codemymobile.com/api/auth/login \ + -d '{"email":"player27@thetennisplan.com","password":"tennis123"}' +``` + +**Result:** The request was blocked by the outbound proxy with `HTTP/1.1 403 Forbidden` ("CONNECT tunnel failed"). No API token could be retrieved, so follow-up GET requests for the match profile could not be executed from this environment. diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts index 3597c2c4..e30de880 100644 --- a/src/api/playerHome.ts +++ b/src/api/playerHome.ts @@ -330,16 +330,18 @@ export interface FetchPlayerDetailsParams extends PlayerTokenOnlyParams { userId: number | string; } -export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsParams) => - request>( - "/player/surveys/getchecklocation/specific_user", - { - token, - query: { - userId, - }, +const formatUserIdQuery = (userId: number | string) => String(userId).trim(); + +export const fetchPlayerDetails = async ({ token, userId }: FetchPlayerDetailsParams) => { + const formattedUserId = formatUserIdQuery(userId); + + return request>("/player/surveys/getchecklocation/specific_user", { + token, + query: { + userId: formattedUserId, }, - ); + }); +}; export interface SuggestedPlayerCheckLocationParams extends PaginationParams { token: string; diff --git a/src/api/playerProfile.ts b/src/api/playerProfile.ts index 93aae75b..353e2461 100644 --- a/src/api/playerProfile.ts +++ b/src/api/playerProfile.ts @@ -9,11 +9,27 @@ export interface PlayerProfile { [key: string]: unknown; } +export interface PlayerPersonalDetails { + id?: number; + full_name?: string; + email?: string; + phone?: string; + profile_picture?: string; + about_me?: string; + gender?: string; + [key: string]: unknown; +} + export const getPlayerDetails = async (token: string) => request("/player/profile", { token, }); +export const getPlayerPersonalDetails = async (token: string) => + request("/player/personal_details", { + token, + }); + export interface OtherPlayerDetailsParams { token: string; userId: number | string; diff --git a/src/pages/PlayerMatchProfilePage.tsx b/src/pages/PlayerMatchProfilePage.tsx index b54a35e5..d568aa99 100644 --- a/src/pages/PlayerMatchProfilePage.tsx +++ b/src/pages/PlayerMatchProfilePage.tsx @@ -1,52 +1,284 @@ -import { useState } from "react"; -import { Check, Clock, MapPin, Target } from "lucide-react"; +import { + AlertCircle, + Loader2, + MapPin, + ShieldCheck, + Target, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { fetchPlayerDetails } from "../api/playerHome"; +import { getPlayerPersonalDetails, type PlayerPersonalDetails } from "../api/playerProfile"; import MainLayout from "../components/MainLayout"; +import { getStoredAuthToken } from "../services/authToken"; 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 MatchProfileRecord = Record; -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] - ); - }; +type PlayerMatchProfile = { + id: string; + name: string; + initials: string; + email?: string; + phone?: string; + about?: string; + level?: string; + availability: string[]; + lookingFor: string[]; + courts: string[]; + locations: string[]; + gender?: string; + profileImageUrl?: string; + verifiedLevelCount?: number | null; + isLevelConfirmed?: boolean; +}; + +type Status = "loading" | "ready" | "error"; - const toggleFormat = (id: string) => { - setFormats((current) => - current.includes(id) ? current.filter((item) => item !== id) : [...current, id] - ); +const canonicalAvailabilityLabels: Record = { + "weekdays am": "Weekdays AM", + "weekday am": "Weekdays AM", + "weekday morning": "Weekdays AM", + "weekday mornings": "Weekdays AM", + "weekdays pm": "Weekdays PM", + "weekday pm": "Weekdays PM", + "weekday evening": "Weekdays PM", + "weekday evenings": "Weekdays PM", + "weekend": "Weekends", + "weekends": "Weekends", + "weekend mornings": "Weekend mornings", + "weekend morning": "Weekend mornings", + "weekend afternoons": "Weekend afternoons", + "weekend evening": "Weekend evenings", + "weekend evenings": "Weekend evenings", +}; + +const toCanonicalAvailability = (label: string) => { + const normalized = label.trim().toLowerCase(); + return canonicalAvailabilityLabels[normalized] ?? label.trim(); +}; + +const ensureStringArray = (value: unknown, normalizer?: (value: string) => string): string[] => { + if (Array.isArray(value)) { + return value + .map((item) => { + if (typeof item !== "string") { + return ""; + } + const trimmed = item.trim(); + return trimmed.length ? (normalizer ? normalizer(trimmed) : trimmed) : ""; + }) + .filter((item): item is string => item.length > 0); + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return []; + } + return [normalizer ? normalizer(trimmed) : trimmed]; + } + return []; +}; + +const toInitials = (name: string, email?: string) => { + const trimmed = name.trim(); + if (trimmed) { + const parts = trimmed.split(/\s+/).filter(Boolean); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); + } + return parts[0].slice(0, 2).toUpperCase(); + } + if (email) { + return email.slice(0, 2).toUpperCase(); + } + return "MP"; +}; + +const formatPhoneNumber = (value?: string) => { + if (!value) { + return ""; + } + const digits = value.replace(/\D/g, ""); + if (digits.length === 10) { + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; + } + if (digits.length === 11 && digits.startsWith("1")) { + return `+1 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`; + } + return value; +}; + +const extractMatchRecord = (payload: unknown): MatchProfileRecord => { + if (!payload || typeof payload !== "object") { + return {}; + } + const record = payload as Record; + if (record.data && typeof record.data === "object" && !Array.isArray(record.data)) { + return record.data as Record; + } + return record; +}; + +const mapMatchProfileData = ( + record: MatchProfileRecord, + personalDetails: PlayerPersonalDetails | null, +): PlayerMatchProfile => { + const sanitizedRecord = record ?? {}; + const nameFromPersonal = + typeof personalDetails?.full_name === "string" && personalDetails.full_name.trim().length + ? personalDetails.full_name.trim() + : null; + const nameFromRecord = + typeof sanitizedRecord.full_name === "string" && sanitizedRecord.full_name.trim().length + ? (sanitizedRecord.full_name as string).trim() + : null; + const name = nameFromPersonal ?? nameFromRecord ?? "MatchPlay player"; + const email = + (typeof personalDetails?.email === "string" && personalDetails.email) || + (typeof sanitizedRecord.email === "string" ? (sanitizedRecord.email as string) : undefined); + const profileImage = + (typeof personalDetails?.profile_picture === "string" && personalDetails.profile_picture?.trim()) || + (typeof sanitizedRecord.profile_picture === "string" + ? (sanitizedRecord.profile_picture as string).trim() + : undefined); + const phone = + (typeof personalDetails?.phone === "string" && personalDetails.phone) || + (typeof sanitizedRecord.phone === "string" ? (sanitizedRecord.phone as string) : undefined); + const about = + (typeof sanitizedRecord.about_me === "string" && sanitizedRecord.about_me.trim().length + ? (sanitizedRecord.about_me as string).trim() + : undefined) || + (typeof personalDetails?.about_me === "string" && personalDetails.about_me.trim().length + ? personalDetails.about_me.trim() + : undefined); + const availability = ensureStringArray(sanitizedRecord.availability, toCanonicalAvailability); + const lookingFor = ensureStringArray(sanitizedRecord.lookingFor); + const courts = ensureStringArray(sanitizedRecord.playerCourtLocations); + const locations = ensureStringArray(sanitizedRecord.playerLocations); + const gender = + (typeof sanitizedRecord.gender === "string" && sanitizedRecord.gender.trim()) || + (typeof personalDetails?.gender === "string" ? personalDetails.gender : undefined); + const skillLabel = typeof sanitizedRecord.skillLevel === "string" ? sanitizedRecord.skillLevel.trim() : ""; + const levelMatch = skillLabel.match(/NTRP\s*([0-9.]+)/i); + const level = levelMatch?.[1] ?? (skillLabel || undefined); + const verifiedLevelCountRaw = sanitizedRecord.verifiedLevelCount; + const verifiedLevelCount = (() => { + if (typeof verifiedLevelCountRaw === "number") { + return verifiedLevelCountRaw; + } + if (typeof verifiedLevelCountRaw === "string") { + const parsed = Number.parseInt(verifiedLevelCountRaw, 10); + return Number.isNaN(parsed) ? null : parsed; + } + return null; + })(); + const idValue = + sanitizedRecord.userId ?? + sanitizedRecord.id ?? + personalDetails?.id ?? + (name ? name.replace(/\s+/g, "-").toLowerCase() : "profile"); + + return { + id: String(idValue), + name, + initials: toInitials(name, email), + email, + phone, + about, + level, + availability, + lookingFor, + courts, + locations, + gender, + profileImageUrl: profileImage, + verifiedLevelCount, + isLevelConfirmed: Boolean(sanitizedRecord.isLevelConfirmed), }; +}; + +const PlayerMatchProfilePage = () => { + const [profile, setProfile] = useState(null); + const [status, setStatus] = useState("loading"); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + useEffect(() => () => { + mountedRef.current = false; + }, []); + + const loadProfile = useCallback(async () => { + const authToken = getStoredAuthToken({ defaultScheme: "token", preferScheme: "token" }); + if (!authToken) { + setStatus("error"); + setProfile(null); + setError("Sign in to view and share your match profile."); + return; + } + + setStatus("loading"); + setError(null); + + try { + const personalDetails = await getPlayerPersonalDetails(authToken); + const userId = personalDetails?.id ?? (personalDetails as { user_id?: number })?.user_id; + if (!userId) { + throw new Error("We couldn\'t determine your player account. Please refresh the page."); + } + const matchRecord = await fetchPlayerDetails({ + token: authToken, + userId, + }); + if (!mountedRef.current) { + return; + } + const extractedRecord = extractMatchRecord(matchRecord); + setProfile(mapMatchProfileData(extractedRecord, personalDetails)); + setStatus("ready"); + } catch (requestError) { + if (!mountedRef.current) { + return; + } + console.error("Failed to load match profile", requestError); + setError( + requestError instanceof Error + ? requestError.message + : "We couldn\'t load your match profile. Please try again.", + ); + setStatus("error"); + setProfile(null); + } + }, []); + + useEffect(() => { + loadProfile(); + }, [loadProfile]); + + const formattedPhone = useMemo(() => formatPhoneNumber(profile?.phone), [profile?.phone]); + const aboutCopy = + profile?.about?.trim() || + "Share a short intro so local players know what kinds of hits or sessions you enjoy most."; + + const genderAndLevel = useMemo(() => { + if (!profile) { + return ""; + } + if (profile.level && profile.gender) { + return `NTRP ${profile.level} • ${profile.gender}`; + } + if (profile.level) { + return `NTRP ${profile.level}`; + } + return profile.gender ?? ""; + }, [profile]); + + const availabilityChips = profile?.availability ?? []; + const lookingForChips = profile?.lookingFor ?? []; + const courts = profile?.courts ?? []; + const locations = profile?.locations ?? []; + + const showProfileContent = status === "ready"; return ( @@ -59,141 +291,178 @@ const PlayerMatchProfilePage = () => {

Player match profile

- Tell other players how and when you like to compete so we can suggest better partners and session ideas. + This is what other Tennis Plan members see when you opt-in to player matching or send a MatchPlay invite.

-
-
-
-
-

-

-

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 ( - - ); - })} -
-
+ {status === "loading" ? ( +
+
+ ) : null} - -
-
-
- -
+ + + ) : null} +
diff --git a/src/pages/PlayerSettingsPages.css b/src/pages/PlayerSettingsPages.css index 31344ef1..52d95bf7 100644 --- a/src/pages/PlayerSettingsPages.css +++ b/src/pages/PlayerSettingsPages.css @@ -432,6 +432,176 @@ color: #475569; } +.match-card__chips { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.match-card__helper { + margin: 0; + font-size: 14px; + font-weight: 500; + color: #475569; +} + +.match-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + color: #0f172a; + background: #eef2ff; +} + +.match-chip--muted { + background: #e2e8f0; + color: #475569; +} + +.match-chip--success { + background: rgba(16, 185, 129, 0.12); + color: #047857; +} + +.match-profile-overview { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px 18px; + align-items: center; +} + +.match-profile-avatar { + width: 64px; + height: 64px; + border-radius: 999px; + background: linear-gradient(135deg, rgba(99, 102, 241, 0.18), rgba(16, 185, 129, 0.15)); + display: grid; + place-items: center; + font-size: 22px; + font-weight: 700; + color: #312e81; +} + +.match-profile-avatar--image { + object-fit: cover; +} + +.match-profile-overview__identity { + display: flex; + flex-direction: column; + gap: 2px; +} + +.match-profile-overview__name { + margin: 0; + font-size: 22px; + font-weight: 700; + color: #0f172a; +} + +.match-profile-overview__meta { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #475569; +} + +.match-profile-overview__badges { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.match-profile-overview__about { + margin: 6px 0 0; + font-size: 15px; + line-height: 1.6; + color: #1f2937; +} + +.match-sidebar__value { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #475569; +} + +.match-sidebar__list { + margin: 8px 0 0; + padding-left: 18px; + display: grid; + gap: 6px; + font-size: 14px; + font-weight: 600; + color: #1f2937; +} + +.match-profile__state { + display: flex; + align-items: center; + gap: 14px; + padding: 18px 22px; + border-radius: 20px; + border: 1px solid rgba(148, 163, 184, 0.4); + background: #ffffff; + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08); +} + +.match-profile__state--error { + border-color: rgba(239, 68, 68, 0.4); + background: rgba(254, 226, 226, 0.6); +} + +.match-profile__state-icon { + width: 28px; + height: 28px; + color: #475569; +} + +.match-profile__state-icon--spinner { + animation: match-profile-spin 1s linear infinite; +} + +.match-profile__state-title { + margin: 0; + font-size: 15px; + font-weight: 700; + color: #0f172a; +} + +.match-profile__state-message { + margin: 0; + font-size: 14px; + font-weight: 500; + color: #475569; +} + +.match-profile__retry { + margin-left: auto; + padding: 10px 18px; + border-radius: 999px; + border: none; + background: linear-gradient(135deg, #22d3ee, #3b82f6); + color: #ffffff; + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +@keyframes match-profile-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + /* Payment methods */ .billing-grid { diff --git a/src/services/authToken.js b/src/services/authToken.js index ed632323..c739fb9c 100644 --- a/src/services/authToken.js +++ b/src/services/authToken.js @@ -69,3 +69,17 @@ export const getStoredAuthToken = (options) => { return null; } }; + +export const extractTokenCredentials = (token, options) => { + const normalized = normalizeAuthToken(token, options); + if (!normalized) { + return null; + } + + const match = normalized.match(/^[A-Za-z]+\s+(.+)$/); + if (match) { + return match[1].trim(); + } + + return normalized.trim(); +};