diff --git a/src/api/playerHome.ts b/src/api/playerHome.ts
index 3597c2c4..1ad6d1e9 100644
--- a/src/api/playerHome.ts
+++ b/src/api/playerHome.ts
@@ -330,16 +330,109 @@ 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 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;
+};
+
+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;
+ 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) => {
+ 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;
diff --git a/src/components/players/MatchProfileModal.tsx b/src/components/players/MatchProfileModal.tsx
index dddf4993..d121b3bf 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";
@@ -108,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") {
@@ -147,7 +152,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 +268,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 +652,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 b54a35e5..1125d26d 100644
--- a/src/pages/PlayerMatchProfilePage.tsx
+++ b/src/pages/PlayerMatchProfilePage.tsx
@@ -1,53 +1,592 @@
-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 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";
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) {
+ 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 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 [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 [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;
+
+ const loadProfile = async () => {
+ const token = getStoredAuthToken({ defaultScheme: "token", preferScheme: "token" });
+ if (!token) {
+ setStatus("error");
+ setError("Please sign in to view your match profile.");
+ return;
+ }
+
+ setStatus("loading");
+ setError(null);
+ try {
+ 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: accountDetails.id,
+ })) as RawMatchProfileRecord | null;
+
+ if (cancelled) {
+ return;
+ }
+
+ const normalized = normalizeMatchProfile(rawProfile, accountDetails);
+ setProfile(normalized);
+ setStatus("ready");
+ } catch (requestError) {
+ 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(
+ 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 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({ defaultScheme: "token", preferScheme: "token" });
+ 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) => (
+ -
+ {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) => (
+ -
+
+ {court}
+
+ ))}
+
+ ) : (
+ Share the courts where you usually meet players.
+ );
+
+ const contactDetails = (
+
+ {profile?.email ? (
+ -
+
+ {profile.email}
+
+ ) : null}
+ {profile?.phone ? (
+ -
+
+ {formatPhoneDisplay(profile.phone)}
+
+ ) : null}
+
+ );
+
+ let bodyContent: JSX.Element | null = null;
+
+ if (status === "loading" || status === "idle") {
+ bodyContent = (
+
+
+
Loading your match profile…
+
+ );
+ } else if (status === "error") {
+ bodyContent = (
+
+
{error ?? "We couldn’t load your match profile."}
+
+ Try again
+
+
+ );
+ } else if (profile) {
+ bodyContent = (
+
+
+
+
+
+
+ {profile.profileImageUrl ? (
+

+ ) : (
+
{profile.initials}
+ )}
+
+
+
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 ? (
+
+
+ Verified by the community
+
+ ) : (
+ "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.
+
+ Share your level, vibe, and preferred courts so other players know how to connect.
+
+
+ {
+ setModalError(null);
+ setProfileModalOpen(true);
+ }}
+ disabled={!personalDetails?.id || isSavingProfile}
+ >
+ Build my match profile
+
+
+ Refresh
+
+
+
+ );
+ }
+
return (
@@ -63,139 +602,25 @@ const PlayerMatchProfilePage = () => {
-
-
-
-
-
-
-
- Match availability
-
-
Choose the windows when you're generally open to play.
-
-
- {availabilitySlots.map((slot) => {
- const selected = selectedAvailability.includes(slot);
- return (
- toggleAvailability(slot)}
- className={`match-availability__slot${selected ? " match-availability__slot--selected" : ""}`}
- aria-pressed={selected}
- >
- {slot}
- {selected ? (
-
-
- Selected
-
- ) : null}
-
- );
- })}
-
-
-
-
-
-
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 (
- toggleFormat(format.id)}
- className={`match-format-chip${selected ? " match-format-chip--selected" : ""}`}
- aria-pressed={selected}
- >
- {format.label}
-
- );
- })}
-
-
-
-
-
-
-
-
-
-
- Save match profile
-
-
+ {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 31344ef1..e8ca5464 100644
--- a/src/pages/PlayerSettingsPages.css
+++ b/src/pages/PlayerSettingsPages.css
@@ -189,6 +189,70 @@
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__button {
+ appearance: none;
+ border: none;
+ font-weight: 600;
+ padding: 10px 20px;
+ border-radius: 999px;
+ cursor: pointer;
+ transition: opacity 0.2s ease;
+}
+
+.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;
@@ -212,6 +276,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 +485,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 +576,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 +656,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 {