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) => ( +
  • +
  • + ))} +
+ ) : ( +

Share the courts where you usually meet players.

+ ); + + const contactDetails = ( +
    + {profile?.email ? ( +
  • +
  • + ) : null} + {profile?.phone ? ( +
  • +
  • + ) : null} +
+ ); + + let bodyContent: JSX.Element | null = null; + + if (status === "loading" || status === "idle") { + bodyContent = ( +
+
+ ); + } else if (status === "error") { + bodyContent = ( +
+

{error ?? "We couldn’t load your match profile."}

+ +
+ ); + } else if (profile) { + bodyContent = ( +
+
+
+
+
+ +
+

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 ? ( + + + ) : ( + "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. +

+
+ + +
+
+ ); + } + return (
@@ -63,139 +602,25 @@ const PlayerMatchProfilePage = () => {

-
-
-
-
-
-

-

-

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 ( - - ); - })} -
-
-
- - -
-
- -
- -
+ {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 {