From 53cddc69dd2ec3d3d2e178d8de4287b3e4525633 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sat, 15 Nov 2025 18:13:24 -0800 Subject: [PATCH] Persist match profile selections --- src/api/playerMatchProfile.ts | 83 ++++++++++ src/components/players/MatchProfileModal.tsx | 40 ++++- src/pages/FindPlayersPage.tsx | 158 +++++++++++++++++-- 3 files changed, 257 insertions(+), 24 deletions(-) create mode 100644 src/api/playerMatchProfile.ts diff --git a/src/api/playerMatchProfile.ts b/src/api/playerMatchProfile.ts new file mode 100644 index 00000000..9f7b87a0 --- /dev/null +++ b/src/api/playerMatchProfile.ts @@ -0,0 +1,83 @@ +import { request } from "./http"; + +export interface PlayerMatchProfileResponse { + about?: string; + about_me?: string; + level?: string; + ntrp_level?: string; + playStyles?: string[] | null; + play_styles?: string[] | null; + matchPreferences?: string[] | null; + match_preferences?: string[] | null; + gender?: string; + localCourts?: string; + local_courts?: string; + homeCourt?: string; + home_court?: string; + availability?: string[] | null; + preferredFormats?: string[] | null; + preferred_formats?: string[] | null; + matchIntensity?: string | null; + match_intensity?: string | null; + [key: string]: unknown; +} + +export interface SavePlayerMatchProfileParams { + token: string; + profile: { + about: string; + level: string; + playStyles: string[]; + gender: string; + localCourts: string; + availability: string[]; + intensity?: string | null; + preferredFormats?: string[]; + homeCourt?: string | null; + }; +} + +const stripEmptyValues = (payload: Record) => + Object.fromEntries( + Object.entries(payload).filter(([, value]) => { + if (value === undefined) return false; + if (value === null) return false; + if (typeof value === "string" && value.trim().length === 0) return false; + if (Array.isArray(value)) { + return value.length > 0; + } + return true; + }), + ); + +export const fetchPlayerMatchProfile = async (token: string) => + request("/player/match_profile", { token }); + +export const savePlayerMatchProfile = async ({ token, profile }: SavePlayerMatchProfileParams) => { + const payload = stripEmptyValues({ + about: profile.about, + about_me: profile.about, + level: profile.level, + ntrp_level: profile.level, + playStyles: profile.playStyles, + play_styles: profile.playStyles, + matchPreferences: profile.playStyles, + match_preferences: profile.playStyles, + gender: profile.gender, + localCourts: profile.localCourts, + local_courts: profile.localCourts, + homeCourt: profile.homeCourt ?? profile.localCourts, + home_court: profile.homeCourt ?? profile.localCourts, + availability: profile.availability, + preferredFormats: profile.preferredFormats, + preferred_formats: profile.preferredFormats, + matchIntensity: profile.intensity, + match_intensity: profile.intensity, + }); + + return request("/player/match_profile", { + method: "PUT", + token, + body: payload, + }); +}; diff --git a/src/components/players/MatchProfileModal.tsx b/src/components/players/MatchProfileModal.tsx index dddf4993..d1134f80 100644 --- a/src/components/players/MatchProfileModal.tsx +++ b/src/components/players/MatchProfileModal.tsx @@ -78,6 +78,9 @@ export type MatchProfileDetails = { gender: string; localCourts: string; availability: string[]; + intensity?: string | null; + preferredFormats?: string[]; + homeCourt?: string | null; }; const GENDER_OPTIONS = [ @@ -89,7 +92,7 @@ const GENDER_OPTIONS = [ type MatchProfileModalProps = { isOpen: boolean; onClose: () => void; - onComplete: (profile: MatchProfileDetails) => void; + onComplete: (profile: MatchProfileDetails) => Promise | void; initialProfile?: MatchProfileDetails | null; }; @@ -167,6 +170,8 @@ const MatchProfileModal = ({ isOpen, onClose, onComplete, initialProfile }: Matc const [availability, setAvailability] = useState(EMPTY_PROFILE.availability); const [touched, setTouched] = useState(false); const [placesStatus, setPlacesStatus] = useState("idle"); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); useEffect(() => { if (!isOpen) { @@ -202,6 +207,8 @@ const MatchProfileModal = ({ isOpen, onClose, onComplete, initialProfile }: Matc setAvailability(Array.isArray(nextProfile.availability) ? [...nextProfile.availability] : []); setLocalCourtPlaceId(null); setTouched(false); + setSubmitError(null); + setIsSubmitting(false); }, [], ); @@ -257,10 +264,10 @@ const MatchProfileModal = ({ isOpen, onClose, onComplete, initialProfile }: Matc const showCompletionError = touched && isSubmitDisabled; - const handleSubmit = (event: FormEvent) => { + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setTouched(true); - if (isSubmitDisabled) { + if (isSubmitDisabled || isSubmitting) { return; } @@ -273,7 +280,21 @@ const MatchProfileModal = ({ isOpen, onClose, onComplete, initialProfile }: Matc availability: [...availability], }; - onComplete(profileDetails); + setSubmitError(null); + setIsSubmitting(true); + try { + await onComplete(profileDetails); + } catch (error) { + console.error("Failed to save match profile", error); + const message = + error instanceof Error && error.message + ? error.message + : "We couldn't save your match profile. Please try again."; + setSubmitError(message); + return; + } finally { + setIsSubmitting(false); + } }; useEffect(() => { @@ -643,13 +664,18 @@ const MatchProfileModal = ({ isOpen, onClose, onComplete, initialProfile }: Matc + {submitError && ( +

+ {submitError} +

+ )} diff --git a/src/pages/FindPlayersPage.tsx b/src/pages/FindPlayersPage.tsx index cc0bcb06..d95a137c 100644 --- a/src/pages/FindPlayersPage.tsx +++ b/src/pages/FindPlayersPage.tsx @@ -15,6 +15,7 @@ import ConnectPlayerModal from "../components/players/ConnectPlayerModal"; import StateBanner from "../components/coaches/StateBanner"; import { colors, typography } from "../lib/theme"; import { getSuggestedPlayerCheckLocation } from "../api/playerHome"; +import { fetchPlayerMatchProfile, savePlayerMatchProfile } from "../api/playerMatchProfile"; import { getStoredAuthToken } from "../services/authToken"; import type { Player } from "../data/mockPlayers"; import usePlayerIdentity from "../hooks/usePlayerIdentity"; @@ -63,6 +64,8 @@ const genderOptions = ["All genders", "Male", "Female", "Other"]; const USER_LOCATION_STORAGE_KEY = "player:web:user-location"; const MATCH_PROFILE_STORAGE_KEY = "player:web:match-profile"; +const MATCH_PROFILE_SUCCESS_MESSAGE = + "Your match profile is live! You agree to share your contact details with other members and accept our terms. You can remove yourself from player matching anytime in settings."; const normalize = (value: string) => value.trim().toLowerCase(); @@ -156,6 +159,19 @@ const ensureStringArray = (value: unknown, normalizer?: (value: string) => strin return []; }; +const pickStringField = (record: Record, keys: string[], fallback = "") => { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + } + return fallback; +}; + const formatCoordinatesLabel = (coords: Coordinates | null) => { if (!coords) { return ""; @@ -219,14 +235,40 @@ const sanitizeMatchProfile = (value: unknown): StoredMatchProfile | null => { } const record = value as Record; - const level = typeof record.level === "string" && record.level.trim().length > 0 ? record.level.trim() : "3.0"; - const about = typeof record.about === "string" ? record.about.trim() : ""; - const gender = typeof record.gender === "string" ? record.gender.trim() : ""; - const localCourts = typeof record.localCourts === "string" ? record.localCourts.trim() : ""; - const playStyles = ensureStringArray(record.playStyles); - const availability = ensureStringArray(record.availability, toCanonicalAvailability); + const level = pickStringField(record, ["level", "ntrp_level", "ntrpLevel"], "3.0") || "3.0"; + const about = pickStringField(record, ["about", "about_me", "bio"], ""); + const gender = pickStringField(record, ["gender", "gender_identity", "genderIdentity"], ""); + const localCourts = pickStringField(record, ["localCourts", "local_courts", "homeCourt", "home_court"], ""); + const playStyles = ensureStringArray( + record.playStyles ?? + record.play_styles ?? + record.matchPreferences ?? + record.match_preferences ?? + record.playPreferences ?? + record.play_preferences, + ); + const availabilitySource = + record.availability ?? + record.matchAvailability ?? + record.match_availability ?? + record.preferredAvailability ?? + record.preferred_availability; + const availability = ensureStringArray(availabilitySource, toCanonicalAvailability); + const intensity = pickStringField(record, ["matchIntensity", "match_intensity", "intensity"], ""); + const preferredFormats = ensureStringArray(record.preferredFormats ?? record.preferred_formats); + const homeCourt = pickStringField(record, ["homeCourt", "home_court"], ""); - return { about, level, playStyles, gender, localCourts, availability }; + return { + about, + level, + playStyles, + gender, + localCourts, + availability, + intensity: intensity || undefined, + preferredFormats, + homeCourt: homeCourt || (localCourts ? localCourts : undefined), + }; }; const getStoredMatchProfile = (): StoredMatchProfile | null => { @@ -245,11 +287,15 @@ const getStoredMatchProfile = (): StoredMatchProfile | null => { } }; -const storeMatchProfile = (profile: StoredMatchProfile) => { +const storeMatchProfile = (profile: StoredMatchProfile | null) => { try { if (typeof window === "undefined" || !window.localStorage) { return; } + if (!profile) { + window.localStorage.removeItem(MATCH_PROFILE_STORAGE_KEY); + return; + } window.localStorage.setItem(MATCH_PROFILE_STORAGE_KEY, JSON.stringify(profile)); } catch { // ignore storage failures @@ -366,6 +412,48 @@ const FindPlayersPage = () => { storedLocation ? formatCoordinatesLabel(storedLocation) : "", ); + useEffect(() => { + if (!playerToken) { + return; + } + + let cancelled = false; + + const loadMatchProfile = async () => { + try { + const response = await fetchPlayerMatchProfile(playerToken); + if (cancelled) { + return; + } + const normalized = sanitizeMatchProfile(response); + if (normalized) { + setMatchProfile(normalized); + storeMatchProfile(normalized); + } else { + setMatchProfile(null); + storeMatchProfile(null); + } + } catch (loadError) { + if (cancelled) { + return; + } + const status = (loadError as { status?: number } | undefined)?.status; + if (status === 404) { + setMatchProfile(null); + storeMatchProfile(null); + return; + } + console.error("Failed to load match profile", loadError); + } + }; + + loadMatchProfile(); + + return () => { + cancelled = true; + }; + }, [playerToken]); + const positionKey = position ? `${position.latitude.toFixed(4)}:${position.longitude.toFixed(4)}` : "none"; const locationQuery = buildLocationSearch(locationFilter); const locationLabel = (() => { @@ -664,6 +752,50 @@ const FindPlayersPage = () => { return `${origin}${normalizedPath}#/settings/match-profile`; }, []); + const handleMatchProfileComplete = useCallback( + async (profileDetails: MatchProfileDetails) => { + const normalizedProfile = sanitizeMatchProfile(profileDetails) ?? profileDetails; + + if (!playerToken) { + setMatchProfile(normalizedProfile); + storeMatchProfile(normalizedProfile); + setProfileModalOpen(false); + window.alert(MATCH_PROFILE_SUCCESS_MESSAGE); + return; + } + + try { + const response = await savePlayerMatchProfile({ + token: playerToken, + profile: { + about: normalizedProfile.about, + level: normalizedProfile.level, + playStyles: normalizedProfile.playStyles, + gender: normalizedProfile.gender, + localCourts: normalizedProfile.localCourts, + availability: normalizedProfile.availability, + intensity: normalizedProfile.intensity ?? null, + preferredFormats: normalizedProfile.preferredFormats, + homeCourt: normalizedProfile.homeCourt ?? normalizedProfile.localCourts, + }, + }); + const persistedProfile = sanitizeMatchProfile(response) ?? normalizedProfile; + setMatchProfile(persistedProfile); + storeMatchProfile(persistedProfile); + setProfileModalOpen(false); + window.alert(MATCH_PROFILE_SUCCESS_MESSAGE); + } catch (saveError) { + console.error("Failed to save match profile", saveError); + const message = + saveError instanceof Error && saveError.message + ? saveError.message + : "We couldn't save your match profile. Please try again."; + throw new Error(message); + } + }, + [playerToken], + ); + const closeConnectModal = useCallback(() => { setConnectModalOpen(false); setConnectModalPlayer(null); @@ -1069,15 +1201,7 @@ const FindPlayersPage = () => { isOpen={isProfileModalOpen} onClose={() => setProfileModalOpen(false)} initialProfile={matchProfile} - onComplete={(profileDetails) => { - const normalizedProfile = sanitizeMatchProfile(profileDetails) ?? profileDetails; - setMatchProfile(normalizedProfile); - storeMatchProfile(normalizedProfile); - setProfileModalOpen(false); - window.alert( - "Your match profile is live! You agree to share your contact details with other members and accept our terms. You can remove yourself from player matching anytime in settings.", - ); - }} + onComplete={handleMatchProfileComplete} /> );