Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions src/api/playerMatchProfile.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) =>
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<PlayerMatchProfileResponse | null>("/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<PlayerMatchProfileResponse>("/player/match_profile", {
method: "PUT",
token,
body: payload,
});
};
40 changes: 33 additions & 7 deletions src/components/players/MatchProfileModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ export type MatchProfileDetails = {
gender: string;
localCourts: string;
availability: string[];
intensity?: string | null;
preferredFormats?: string[];
homeCourt?: string | null;
};

const GENDER_OPTIONS = [
Expand All @@ -89,7 +92,7 @@ const GENDER_OPTIONS = [
type MatchProfileModalProps = {
isOpen: boolean;
onClose: () => void;
onComplete: (profile: MatchProfileDetails) => void;
onComplete: (profile: MatchProfileDetails) => Promise<void> | void;
initialProfile?: MatchProfileDetails | null;
};

Expand Down Expand Up @@ -167,6 +170,8 @@ const MatchProfileModal = ({ isOpen, onClose, onComplete, initialProfile }: Matc
const [availability, setAvailability] = useState<string[]>(EMPTY_PROFILE.availability);
const [touched, setTouched] = useState(false);
const [placesStatus, setPlacesStatus] = useState<PlacesStatus>("idle");
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);

useEffect(() => {
if (!isOpen) {
Expand Down Expand Up @@ -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);
},
[],
);
Expand Down Expand Up @@ -257,10 +264,10 @@ const MatchProfileModal = ({ isOpen, onClose, onComplete, initialProfile }: Matc

const showCompletionError = touched && isSubmitDisabled;

const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setTouched(true);
if (isSubmitDisabled) {
if (isSubmitDisabled || isSubmitting) {
return;
}

Expand All @@ -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(() => {
Expand Down Expand Up @@ -643,13 +664,18 @@ const MatchProfileModal = ({ isOpen, onClose, onComplete, initialProfile }: Matc
<button
type="submit"
className="fc-button fc-button--primary"
disabled={isSubmitDisabled}
aria-disabled={isSubmitDisabled}
disabled={isSubmitDisabled || isSubmitting}
aria-disabled={isSubmitDisabled || isSubmitting}
>
Save profile
{isSubmitting ? "Saving…" : "Save profile"}
</button>
</div>
</div>
{submitError && (
<p className="match-profile-modal__submit-error" role="alert">
{submitError}
</p>
)}
</footer>
</form>
</div>
Expand Down
158 changes: 141 additions & 17 deletions src/pages/FindPlayersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -156,6 +159,19 @@ const ensureStringArray = (value: unknown, normalizer?: (value: string) => strin
return [];
};

const pickStringField = (record: Record<string, unknown>, 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 "";
Expand Down Expand Up @@ -219,14 +235,40 @@ const sanitizeMatchProfile = (value: unknown): StoredMatchProfile | null => {
}

const record = value as Record<string, unknown>;
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 => {
Expand All @@ -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
Expand Down Expand Up @@ -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 = (() => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}
/>
</MainLayout>
);
Expand Down