From 4c4b6a5d058084b6f3961f009492160d701da210 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Wed, 26 Nov 2025 07:36:47 -0800 Subject: [PATCH 1/8] Refresh My Coaches page layout --- src/pages/MyCoachesPage.css | 342 ++++++++++++++++++++++++++++-------- src/pages/MyCoachesPage.tsx | 243 ++++++++++++++++++++----- 2 files changed, 468 insertions(+), 117 deletions(-) diff --git a/src/pages/MyCoachesPage.css b/src/pages/MyCoachesPage.css index e6c2bb40..0aa1abba 100644 --- a/src/pages/MyCoachesPage.css +++ b/src/pages/MyCoachesPage.css @@ -1,15 +1,23 @@ .my-coaches { - padding: 32px 24px 64px; - max-width: 1100px; + padding: 32px 24px 80px; + max-width: 1200px; margin: 0 auto; + background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%); } -.my-coaches__header { - display: flex; - align-items: flex-start; - justify-content: space-between; +.my-coaches__hero { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); gap: 16px; - margin-bottom: 20px; + margin-bottom: 24px; +} + +.my-coaches__intro { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 16px; + padding: 20px; + box-shadow: 0 20px 40px rgba(15, 23, 42, 0.06); } .my-coaches__eyebrow { @@ -18,19 +26,92 @@ font-size: 12px; color: #6b7280; margin: 0 0 6px; - font-weight: 600; + font-weight: 700; } .my-coaches__title { margin: 0; - font-size: 28px; + font-size: 30px; color: #0f172a; } .my-coaches__subtitle { - margin: 6px 0 0; + margin: 8px 0 0; color: #475569; max-width: 640px; + line-height: 1.6; +} + +.my-coaches__pill-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.my-coaches__hero-card { + background: radial-gradient(circle at 20% 20%, #eef2ff, #f8fafc 60%); + border: 1px solid #e2e8f0; + border-radius: 18px; + padding: 18px; + box-shadow: 0 18px 38px rgba(15, 23, 42, 0.08); + display: flex; + flex-direction: column; + gap: 14px; +} + +.my-coaches__hero-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.my-coaches__hero-label { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #475569; + font-size: 12px; +} + +.my-coaches__hero-title { + margin: 4px 0 0; + font-size: 20px; + color: #0f172a; +} + +.my-coaches__stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; +} + +.my-coaches__stat { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 14px; + padding: 12px; + box-shadow: 0 12px 22px rgba(15, 23, 42, 0.06); +} + +.my-coaches__stat-label { + margin: 0; + color: #475569; + font-size: 13px; +} + +.my-coaches__stat-value { + margin: 6px 0 0; + font-size: 22px; + font-weight: 800; + color: #0f172a; +} + +.my-coaches__stat-hint { + margin: 2px 0 0; + color: #94a3b8; + font-size: 12px; } .my-coaches__icon-button { @@ -53,20 +134,24 @@ .my-coaches__filters { display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; - margin: 12px 0 18px; + margin: 10px 0 20px; + padding: 14px; + border-radius: 16px; + background: #fff; + border: 1px solid #e2e8f0; + box-shadow: 0 18px 32px rgba(15, 23, 42, 0.06); } .my-coaches__input { display: flex; align-items: center; gap: 8px; - padding: 10px 12px; - background: #fff; + padding: 12px; + background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 12px; - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05); } .my-coaches__input input { @@ -75,11 +160,42 @@ outline: none; font-size: 14px; color: #0f172a; + background: transparent; +} + +.my-coaches__section { + margin-top: 18px; +} + +.my-coaches__section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.my-coaches__section-eyebrow { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #6b7280; + font-size: 12px; + font-weight: 700; +} + +.my-coaches__section h3 { + margin: 4px 0 2px; + color: #0f172a; +} + +.my-coaches__section p { + margin: 0; + color: #475569; } .my-coaches__grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; margin-top: 12px; } @@ -87,18 +203,91 @@ .my-coaches__card { background: #fff; border: 1px solid #e2e8f0; - border-radius: 16px; - padding: 14px; + border-radius: 18px; + padding: 16px; display: flex; flex-direction: column; gap: 12px; - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05); + box-shadow: 0 20px 36px rgba(15, 23, 42, 0.08); } .my-coaches__card-top { display: flex; justify-content: space-between; gap: 12px; + align-items: center; +} + +.my-coaches__status-row { + display: flex; + align-items: center; + gap: 8px; +} + +.my-coaches__status { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + border: 1px solid #cbd5e1; + background: #f8fafc; + color: #0f172a; +} + +.my-coaches__status--active { + background: #ecfdf3; + border-color: #bbf7d0; + color: #166534; +} + +.my-coaches__status--pending { + background: #fff7ed; + border-color: #fed7aa; + color: #c2410c; +} + +.my-coaches__status--inactive { + background: #f8fafc; + color: #475569; +} + +.my-coaches__pill { + background: #f1f5f9; + color: #475569; + border: 1px solid #e2e8f0; + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + font-weight: 600; +} + +.my-coaches__pill--brand { + background: #eef2ff; + border-color: #c7d2fe; + color: #312e81; +} + +.my-coaches__rate-block { + display: flex; + align-items: baseline; + gap: 4px; + background: #ecfeff; + border: 1px solid #cce6ea; + padding: 8px 10px; + border-radius: 12px; + color: #0f172a; +} + +.my-coaches__rate { + font-weight: 800; + font-size: 18px; +} + +.my-coaches__rate-caption { + font-size: 12px; + color: #475569; } .my-coaches__card-left { @@ -108,9 +297,9 @@ } .my-coaches__avatar { - width: 72px; - height: 72px; - border-radius: 14px; + width: 80px; + height: 80px; + border-radius: 16px; object-fit: cover; background: linear-gradient(135deg, #e0f2fe, #f8fafc); } @@ -118,14 +307,14 @@ .my-coaches__identity { display: flex; flex-direction: column; - gap: 4px; + gap: 6px; min-width: 0; } .my-coaches__name-row { display: flex; align-items: center; - gap: 10px; + gap: 8px; } .my-coaches__name { @@ -134,21 +323,36 @@ color: #0f172a; } -.my-coaches__status { - background: #ecfeff; - color: #0e7490; - border: 1px solid #cce6ea; - border-radius: 999px; - padding: 4px 10px; +.my-coaches__status-dot { font-size: 12px; - font-weight: 600; +} + +.my-coaches__status-dot--active { + color: #16a34a; +} + +.my-coaches__status-dot--pending { + color: #fb923c; +} + +.my-coaches__status-dot--inactive { + color: #94a3b8; +} + +.my-coaches__meta-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + color: #475569; + font-size: 14px; } .my-coaches__rating { display: inline-flex; align-items: center; gap: 6px; - font-weight: 600; + font-weight: 700; color: #0f172a; } @@ -165,31 +369,8 @@ font-size: 14px; } -.my-coaches__distance { - color: #94a3b8; - font-size: 13px; -} - -.my-coaches__rate { - font-weight: 700; - color: #0f172a; - font-size: 20px; - white-space: nowrap; - padding: 6px 10px; - border-radius: 12px; - background: linear-gradient(135deg, #ecfeff, #f8fafc); - border: 1px solid #cce6ea; - height: fit-content; -} - -.my-coaches__details { - display: flex; - flex-direction: column; - gap: 8px; -} - .my-coaches__about { - margin: 0; + margin: 4px 0 0; color: #475569; line-height: 1.5; display: -webkit-box; @@ -217,25 +398,39 @@ .my-coaches__actions { display: flex; align-items: center; + gap: 8px; } .my-coaches__button { - background: #0f172a; - color: #fff; + display: inline-flex; + justify-content: center; + align-items: center; + gap: 6px; padding: 10px 14px; border-radius: 10px; text-decoration: none; - font-weight: 600; - transition: transform 0.15s ease, box-shadow 0.2s ease; + font-weight: 700; + transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.my-coaches__button.primary { + background: linear-gradient(135deg, #2563eb, #1d4ed8); + color: #fff; + box-shadow: 0 12px 28px rgba(37, 99, 235, 0.25); +} + +.my-coaches__button.secondary { + background: #fff; + color: #0f172a; + border: 1px solid #e2e8f0; } .my-coaches__button:hover { transform: translateY(-1px); - box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12); } .my-coaches__skeleton { - height: 120px; + height: 180px; border-radius: 16px; background: linear-gradient(90deg, #f1f5f9, #e2e8f0, #f1f5f9); background-size: 200% 100%; @@ -251,27 +446,20 @@ } } -@media (max-width: 640px) { - .my-coaches { - padding: 24px 16px 48px; - } - - .my-coaches__card { - flex-direction: column; - align-items: flex-start; - } - - .my-coaches__card-top { - width: 100%; - flex-direction: column; +@media (max-width: 960px) { + .my-coaches__hero { + grid-template-columns: 1fr; } +} - .my-coaches__rate { - align-self: flex-start; +@media (max-width: 640px) { + .my-coaches { + padding: 24px 16px 56px; } .my-coaches__actions { width: 100%; + flex-direction: column; } .my-coaches__button { diff --git a/src/pages/MyCoachesPage.tsx b/src/pages/MyCoachesPage.tsx index a3ac2696..dfd4c950 100644 --- a/src/pages/MyCoachesPage.tsx +++ b/src/pages/MyCoachesPage.tsx @@ -9,13 +9,28 @@ import useDebouncedValue from "../hooks/useDebouncedValue"; import "./MyCoachesPage.css"; +type StatusCategory = "active" | "pending" | "inactive"; + type CoachStatusBadgeProps = { status?: string | number; }; +const getStatusCategory = (status?: string): StatusCategory => { + if (!status) return "active"; + const normalized = status.toLowerCase(); + if (["pending", "requested", "awaiting", "waiting", "approval"].some((token) => normalized.includes(token))) { + return "pending"; + } + if (["cancel", "inactive", "removed", "declined"].some((token) => normalized.includes(token))) { + return "inactive"; + } + return "active"; +}; + const CoachStatusBadge = ({ status }: CoachStatusBadgeProps) => { if (status === null || status === undefined || status === "") return null; - return {String(status)}; + const category = getStatusCategory(String(status)); + return {String(status)}; }; const pickCoachId = (coach: PlayerCoach) => @@ -57,11 +72,16 @@ const resolveAvatar = (coach: PlayerCoach) => { return "https://images.unsplash.com/photo-1521412644187-c49fa049e84d?auto=format&fit=crop&w=256&q=80"; }; -const resolveRating = (coach: PlayerCoach) => { +const resolveRatingValue = (coach: PlayerCoach) => { const record = coach as Record; const value = record.rating ?? record.average_rating ?? record.avg_rating ?? record.score; const numeric = typeof value === "number" ? value : Number.parseFloat(String(value ?? "")); - return Number.isFinite(numeric) ? numeric.toFixed(1) : null; + return Number.isFinite(numeric) ? numeric : null; +}; + +const resolveRating = (coach: PlayerCoach) => { + const value = resolveRatingValue(coach); + return value !== null ? value.toFixed(1) : null; }; const resolveLocation = (coach: PlayerCoach) => { @@ -120,11 +140,16 @@ const resolveStatus = (coach: PlayerCoach) => { return String(raw); }; -const resolveHourlyRate = (coach: PlayerCoach) => { +const resolveHourlyRateValue = (coach: PlayerCoach) => { const record = coach as Record; const value = record.hourly_rate ?? record.rate ?? record.price_per_hour; const numeric = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(numeric)) return null; + return Number.isFinite(numeric) ? numeric : null; +}; + +const resolveHourlyRate = (coach: PlayerCoach) => { + const numeric = resolveHourlyRateValue(coach); + if (numeric === null) return null; return `$${numeric.toFixed(0)}`; }; @@ -157,6 +182,7 @@ const MyCoachCard = ({ coach }: { coach: PlayerCoach }) => { const location = resolveLocation(coach); const distance = resolveDistance(coach); const status = resolveStatus(coach); + const statusCategory = getStatusCategory(status); const hourlyRate = resolveHourlyRate(coach); const about = resolveAbout(coach); const locations = resolveLocationTags(coach); @@ -164,13 +190,28 @@ const MyCoachCard = ({ coach }: { coach: PlayerCoach }) => { return (
-
- {`Portrait -
-
-

{name}

- {status && } -
+
+ + {distance && {distance}} +
+ {hourlyRate && ( +
+ {hourlyRate} + /hr +
+ )} +
+ +
+ {`Portrait +
+
+

{name}

+ + ● + +
+
{rating && (
@@ -183,14 +224,7 @@ const MyCoachCard = ({ coach }: { coach: PlayerCoach }) => { {location}
)} - {distance &&
{distance}
}
-
- {hourlyRate &&
{hourlyRate}
} -
- - {(about || locations.length > 0) && ( -
{about &&

{about}

} {locations.length > 0 && (
@@ -202,13 +236,18 @@ const MyCoachCard = ({ coach }: { coach: PlayerCoach }) => {
)}
- )} +
{coachId && ( - - View profile - + <> + + View profile + + + Book lesson + + )}
@@ -235,6 +274,41 @@ const MyCoachesPage = () => { [debouncedLocation, debouncedSearch], ); + const rosterBreakdown = useMemo(() => { + const ratingValues: number[] = []; + const rateValues: number[] = []; + const categorized: Record = { + active: [], + pending: [], + inactive: [], + }; + + coaches.forEach((coach) => { + const status = resolveStatus(coach); + const category = getStatusCategory(status); + const ratingValue = resolveRatingValue(coach); + const rateValue = resolveHourlyRateValue(coach); + + if (ratingValue !== null) ratingValues.push(ratingValue); + if (rateValue !== null) rateValues.push(rateValue); + + categorized[category].push(coach); + }); + + const averageRating = ratingValues.length + ? (ratingValues.reduce((total, value) => total + value, 0) / ratingValues.length).toFixed(1) + : null; + const averageRate = rateValues.length + ? `$${Math.round(rateValues.reduce((total, value) => total + value, 0) / rateValues.length)}` + : null; + + return { + ...categorized, + averageRating, + averageRate, + }; + }, [coaches]); + const fetchCoaches = useCallback(async () => { setLoading(true); setError(null); @@ -263,22 +337,64 @@ const MyCoachesPage = () => { return (
-
-
-

Roster

+
+
+
Roster

My coaches

- Keep track of the coaches you are working with and jump to their profiles quickly. + See every coach you work with, check their status, and jump into booking without leaving this page.

+
+ + Active coaches · {rosterBreakdown.active.length} + + + Pending approvals · {rosterBreakdown.pending.length} + + + Total in roster · {coaches.length} + +
+
+ +
+
+
+

Roster health

+

Stay connected

+
+ +
+
+
+

Average rating

+

{rosterBreakdown.averageRating ?? "—"}

+

Across coaches with ratings

+
+
+

Typical rate

+

{rosterBreakdown.averageRate ?? "—"}

+

Per hour on average

+
+
+

Confirmed

+

{rosterBreakdown.active.length}

+

Ready for booking

+
+
+

Pending

+

{rosterBreakdown.pending.length}

+

Waiting for confirmation

+
+
-
@@ -325,11 +441,58 @@ const MyCoachesPage = () => { )} {!loading && !error && coaches.length > 0 && ( -
- {coaches.map((coach) => ( - - ))} -
+ <> + {rosterBreakdown.active.length > 0 && ( +
+
+
+

Active

+

Active coaches

+

Confirmed coaches you can book and message right away.

+
+
+
+ {rosterBreakdown.active.map((coach) => ( + + ))} +
+
+ )} + + {rosterBreakdown.pending.length > 0 && ( +
+
+
+

Pending

+

Awaiting approval

+

These coaches still need to confirm before you can book with them.

+
+
+
+ {rosterBreakdown.pending.map((coach) => ( + + ))} +
+
+ )} + + {rosterBreakdown.inactive.length > 0 && ( +
+
+
+

Inactive

+

Archived coaches

+

Coaches with cancelled or inactive connections stay here for reference.

+
+
+
+ {rosterBreakdown.inactive.map((coach) => ( + + ))} +
+
+ )} + )}
From 71fe3dd483f201f731435b9f4b90a5c17919f3b7 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Wed, 26 Nov 2025 07:48:41 -0800 Subject: [PATCH 2/8] Improve coach location display --- src/pages/MyCoachesPage.tsx | 77 +++++++++++++++++++++++++------------ 1 file changed, 53 insertions(+), 24 deletions(-) diff --git a/src/pages/MyCoachesPage.tsx b/src/pages/MyCoachesPage.tsx index dfd4c950..702a9802 100644 --- a/src/pages/MyCoachesPage.tsx +++ b/src/pages/MyCoachesPage.tsx @@ -84,27 +84,52 @@ const resolveRating = (coach: PlayerCoach) => { return value !== null ? value.toFixed(1) : null; }; +const normalizeLocationString = (value: unknown) => + typeof value === "string" ? value.trim() : ""; + +const isPostalCode = (value: string) => /^\s*(?:zip\s*)?\d{5}(?:-\d{4})?\s*$/i.test(value); + +const formatLocationObject = (location: unknown) => { + if (!location || typeof location !== "object") return ""; + const entry = location as Record; + const name = normalizeLocationString(entry.name ?? entry.location_name ?? entry.facility_name ?? entry.facility); + const city = normalizeLocationString(entry.city ?? entry.town ?? entry.municipality); + const state = normalizeLocationString(entry.state ?? entry.region); + const country = normalizeLocationString(entry.country); + const parts = [ + name, + [city, state].filter(Boolean).join(", "), + country, + ].filter(Boolean); + return parts.join(" · ").trim(); +}; + const resolveLocation = (coach: PlayerCoach) => { const record = coach as Record; - const primary = - record.location ?? - record.location_name ?? - record.city ?? - record.state ?? - record.country ?? - record.address ?? - ""; - - if (primary) return primary; - - const coachLocations = record.coach_locations; - if (Array.isArray(coachLocations) && coachLocations.length > 0) { - const first = coachLocations[0]; - if (typeof first === "string" && first.trim()) return first; - if (first !== null && first !== undefined) return String(first); + const primaryCandidates = [ + record.location, + record.location_name, + record.city && record.state ? `${record.city}, ${record.state}` : undefined, + record.city, + record.state, + record.country, + record.address, + ] + .map(normalizeLocationString) + .filter(Boolean); + + const coachLocations = record.coach_locations ?? record.locations ?? record.location_tags ?? record.service_locations; + if (Array.isArray(coachLocations)) { + const formatted = coachLocations + .map((entry) => normalizeLocationString(entry) || formatLocationObject(entry)) + .filter(Boolean); + primaryCandidates.push(...formatted); } - return ""; + if (!primaryCandidates.length) return ""; + + const nonPostal = primaryCandidates.find((entry) => !isPostalCode(entry)); + return nonPostal ?? primaryCandidates[0]; }; const resolveDistance = (coach: PlayerCoach) => { @@ -165,13 +190,17 @@ const resolveLocationTags = (coach: PlayerCoach) => { const record = coach as Record; const rawLocations = record.coach_locations ?? record.locations ?? record.location_tags ?? record.service_locations; - if (Array.isArray(rawLocations)) { - return rawLocations - .map((loc) => (typeof loc === "string" ? loc : String(loc ?? ""))) - .filter(Boolean) - .slice(0, 3); - } - return []; + + if (!Array.isArray(rawLocations)) return []; + + const formatted = rawLocations + .map((loc) => normalizeLocationString(loc) || formatLocationObject(loc)) + .filter(Boolean); + + const hasNonPostal = formatted.some((loc) => !isPostalCode(loc)); + const filtered = hasNonPostal ? formatted.filter((loc) => !isPostalCode(loc)) : formatted; + + return Array.from(new Set(filtered)).slice(0, 3); }; const MyCoachCard = ({ coach }: { coach: PlayerCoach }) => { From 0177b4a3395b0744a9d97d8974db12f4d60234cf Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Wed, 26 Nov 2025 07:58:29 -0800 Subject: [PATCH 3/8] Prefer real locations over postal codes --- src/pages/MyCoachesPage.tsx | 40 +++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/pages/MyCoachesPage.tsx b/src/pages/MyCoachesPage.tsx index 702a9802..7a2a0466 100644 --- a/src/pages/MyCoachesPage.tsx +++ b/src/pages/MyCoachesPage.tsx @@ -92,10 +92,12 @@ const isPostalCode = (value: string) => /^\s*(?:zip\s*)?\d{5}(?:-\d{4})?\s*$/i.t const formatLocationObject = (location: unknown) => { if (!location || typeof location !== "object") return ""; const entry = location as Record; - const name = normalizeLocationString(entry.name ?? entry.location_name ?? entry.facility_name ?? entry.facility); - const city = normalizeLocationString(entry.city ?? entry.town ?? entry.municipality); - const state = normalizeLocationString(entry.state ?? entry.region); - const country = normalizeLocationString(entry.country); + const name = normalizeLocationString( + entry.name ?? entry.location_name ?? entry.facility_name ?? entry.facility ?? entry.court_name ?? entry.label, + ); + const city = normalizeLocationString(entry.city ?? entry.city_name ?? entry.town ?? entry.municipality ?? entry.locality); + const state = normalizeLocationString(entry.state ?? entry.state_code ?? entry.region ?? entry.province); + const country = normalizeLocationString(entry.country ?? entry.country_code); const parts = [ name, [city, state].filter(Boolean).join(", "), @@ -106,17 +108,7 @@ const formatLocationObject = (location: unknown) => { const resolveLocation = (coach: PlayerCoach) => { const record = coach as Record; - const primaryCandidates = [ - record.location, - record.location_name, - record.city && record.state ? `${record.city}, ${record.state}` : undefined, - record.city, - record.state, - record.country, - record.address, - ] - .map(normalizeLocationString) - .filter(Boolean); + const primaryCandidates: string[] = []; const coachLocations = record.coach_locations ?? record.locations ?? record.location_tags ?? record.service_locations; if (Array.isArray(coachLocations)) { @@ -126,10 +118,24 @@ const resolveLocation = (coach: PlayerCoach) => { primaryCandidates.push(...formatted); } + primaryCandidates.push( + ...[ + record.location, + record.location_name, + record.city && record.state ? `${record.city}, ${record.state}` : undefined, + record.city, + record.state, + record.country, + record.address, + ] + .map((value) => normalizeLocationString(value).replace(/^zip\s*/i, "")) + .filter(Boolean), + ); + if (!primaryCandidates.length) return ""; const nonPostal = primaryCandidates.find((entry) => !isPostalCode(entry)); - return nonPostal ?? primaryCandidates[0]; + return nonPostal ?? ""; }; const resolveDistance = (coach: PlayerCoach) => { @@ -198,7 +204,7 @@ const resolveLocationTags = (coach: PlayerCoach) => { .filter(Boolean); const hasNonPostal = formatted.some((loc) => !isPostalCode(loc)); - const filtered = hasNonPostal ? formatted.filter((loc) => !isPostalCode(loc)) : formatted; + const filtered = hasNonPostal ? formatted.filter((loc) => !isPostalCode(loc)) : []; return Array.from(new Set(filtered)).slice(0, 3); }; From fe5fe770da3697c2668b072aa7bc61b7e7faf673 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Wed, 26 Nov 2025 08:07:43 -0800 Subject: [PATCH 4/8] Filter postal codes from coach locations --- src/pages/MyCoachesPage.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/pages/MyCoachesPage.tsx b/src/pages/MyCoachesPage.tsx index 7a2a0466..0e204b8e 100644 --- a/src/pages/MyCoachesPage.tsx +++ b/src/pages/MyCoachesPage.tsx @@ -84,10 +84,20 @@ const resolveRating = (coach: PlayerCoach) => { return value !== null ? value.toFixed(1) : null; }; -const normalizeLocationString = (value: unknown) => - typeof value === "string" ? value.trim() : ""; +const normalizeLocationString = (value: unknown) => { + if (typeof value === "number") return String(value).trim(); + return typeof value === "string" ? value.trim() : ""; +}; + +const isPostalCode = (value: string) => { + const trimmed = value.trim().replace(/^zip\s*/i, ""); + if (/^\d{5}(?:[-\s]?\d{4})?$/.test(trimmed)) return true; -const isPostalCode = (value: string) => /^\s*(?:zip\s*)?\d{5}(?:-\d{4})?\s*$/i.test(value); + const digitsOnly = trimmed.replace(/\D/g, ""); + if ((digitsOnly.length === 5 || digitsOnly.length === 9) && /^\d+$/.test(digitsOnly)) return true; + + return false; +}; const formatLocationObject = (location: unknown) => { if (!location || typeof location !== "object") return ""; @@ -97,8 +107,12 @@ const formatLocationObject = (location: unknown) => { ); const city = normalizeLocationString(entry.city ?? entry.city_name ?? entry.town ?? entry.municipality ?? entry.locality); const state = normalizeLocationString(entry.state ?? entry.state_code ?? entry.region ?? entry.province); + const street = normalizeLocationString( + entry.street ?? entry.street_1 ?? entry.street1 ?? entry.address ?? entry.address_1 ?? entry.address1 ?? entry.line1, + ); const country = normalizeLocationString(entry.country ?? entry.country_code); const parts = [ + street, name, [city, state].filter(Boolean).join(", "), country, From baeade1e0022710badeef2e3fe10678528f473c8 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Wed, 26 Nov 2025 08:11:57 -0800 Subject: [PATCH 5/8] Use API coach locations for roster cards --- src/pages/MyCoachesPage.tsx | 132 +++++++++++++++++++++++++++++------- 1 file changed, 106 insertions(+), 26 deletions(-) diff --git a/src/pages/MyCoachesPage.tsx b/src/pages/MyCoachesPage.tsx index 0e204b8e..30e27237 100644 --- a/src/pages/MyCoachesPage.tsx +++ b/src/pages/MyCoachesPage.tsx @@ -2,7 +2,12 @@ import { RefreshCcw, Search, Star, MapPin } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; -import { getPlayerCoaches, type PlayerCoach } from "../api/playerCalendar"; +import { + getCoachLocation, + getPlayerCoaches, + type CoachLocation, + type PlayerCoach, +} from "../api/playerCalendar"; import MainLayout from "../components/MainLayout"; import StateBanner from "../components/coaches/StateBanner"; import useDebouncedValue from "../hooks/useDebouncedValue"; @@ -54,6 +59,13 @@ const resolveName = (coach: PlayerCoach) => { return parts[0] || "Coach"; }; +const pickLocationCoachId = (location: CoachLocation) => + (location as Record).coach_id ?? + (location as Record).coachId ?? + (location as Record).coach_user_id ?? + (location as Record).user_id ?? + (location as Record).coach?.id; + const resolveAvatar = (coach: PlayerCoach) => { const record = coach as Record; const candidates = [ @@ -102,8 +114,14 @@ const isPostalCode = (value: string) => { const formatLocationObject = (location: unknown) => { if (!location || typeof location !== "object") return ""; const entry = location as Record; - const name = normalizeLocationString( - entry.name ?? entry.location_name ?? entry.facility_name ?? entry.facility ?? entry.court_name ?? entry.label, + const facility = normalizeLocationString( + entry.name ?? + entry.location_name ?? + entry.facility_name ?? + entry.facility ?? + entry.court_name ?? + entry.label ?? + entry.description, ); const city = normalizeLocationString(entry.city ?? entry.city_name ?? entry.town ?? entry.municipality ?? entry.locality); const state = normalizeLocationString(entry.state ?? entry.state_code ?? entry.region ?? entry.province); @@ -112,18 +130,32 @@ const formatLocationObject = (location: unknown) => { ); const country = normalizeLocationString(entry.country ?? entry.country_code); const parts = [ + facility, street, - name, [city, state].filter(Boolean).join(", "), country, - ].filter(Boolean); + ] + .map((value) => value.replace(/^zip\s*/i, "")) + .filter(Boolean); return parts.join(" · ").trim(); }; -const resolveLocation = (coach: PlayerCoach) => { +const formatLocationsFromApi = (entries: CoachLocation[] = []) => { + const formatted = entries + .map((entry) => normalizeLocationString(entry.location ?? entry.location_name) || formatLocationObject(entry)) + .filter(Boolean); + + const nonPostal = formatted.filter((value) => !isPostalCode(value)); + return nonPostal.length ? nonPostal : []; +}; + +const resolveLocation = (coach: PlayerCoach, linkedLocations: CoachLocation[]) => { const record = coach as Record; const primaryCandidates: string[] = []; + const formattedApiLocations = formatLocationsFromApi(linkedLocations); + primaryCandidates.push(...formattedApiLocations); + const coachLocations = record.coach_locations ?? record.locations ?? record.location_tags ?? record.service_locations; if (Array.isArray(coachLocations)) { const formatted = coachLocations @@ -206,35 +238,35 @@ const resolveAbout = (coach: PlayerCoach) => { return about.trim(); }; -const resolveLocationTags = (coach: PlayerCoach) => { +const resolveLocationTags = (coach: PlayerCoach, linkedLocations: CoachLocation[]) => { const record = coach as Record; const rawLocations = record.coach_locations ?? record.locations ?? record.location_tags ?? record.service_locations; - if (!Array.isArray(rawLocations)) return []; + const apiLocations = formatLocationsFromApi(linkedLocations); - const formatted = rawLocations - .map((loc) => normalizeLocationString(loc) || formatLocationObject(loc)) - .filter(Boolean); + const formatted = Array.isArray(rawLocations) + ? rawLocations.map((loc) => normalizeLocationString(loc) || formatLocationObject(loc)).filter(Boolean) + : []; - const hasNonPostal = formatted.some((loc) => !isPostalCode(loc)); - const filtered = hasNonPostal ? formatted.filter((loc) => !isPostalCode(loc)) : []; + const combined = [...apiLocations, ...formatted]; + const filtered = combined.filter((loc) => !isPostalCode(loc)); return Array.from(new Set(filtered)).slice(0, 3); }; -const MyCoachCard = ({ coach }: { coach: PlayerCoach }) => { +const MyCoachCard = ({ coach, linkedLocations }: { coach: PlayerCoach; linkedLocations: CoachLocation[] }) => { const coachId = pickCoachId(coach); const name = resolveName(coach); const avatar = resolveAvatar(coach); const rating = resolveRating(coach); - const location = resolveLocation(coach); + const location = resolveLocation(coach, linkedLocations); const distance = resolveDistance(coach); const status = resolveStatus(coach); const statusCategory = getStatusCategory(status); const hourlyRate = resolveHourlyRate(coach); const about = resolveAbout(coach); - const locations = resolveLocationTags(coach); + const locations = resolveLocationTags(coach, linkedLocations); return (
@@ -310,6 +342,7 @@ const buildQueryParams = (search: string, location: string) => ({ const MyCoachesPage = () => { const [coaches, setCoaches] = useState([]); + const [coachLocations, setCoachLocations] = useState([]); const [search, setSearch] = useState(""); const [location, setLocation] = useState(""); const [loading, setLoading] = useState(false); @@ -323,6 +356,16 @@ const MyCoachesPage = () => { [debouncedLocation, debouncedSearch], ); + const coachLocationsById = useMemo(() => { + return coachLocations.reduce>((acc, location) => { + const coachId = pickLocationCoachId(location); + if (!coachId && coachId !== 0) return acc; + const key = String(coachId); + acc[key] = acc[key] ? [...acc[key], location] : [location]; + return acc; + }, {}); + }, [coachLocations]); + const rosterBreakdown = useMemo(() => { const ratingValues: number[] = []; const rateValues: number[] = []; @@ -377,9 +420,19 @@ const MyCoachesPage = () => { } }, [debouncedLocation, debouncedSearch]); + const fetchCoachLocations = useCallback(async () => { + try { + const data = await getCoachLocation({ page: 1, limit: 100 }); + setCoachLocations(data); + } catch (err) { + console.error("Failed to load coach locations", err); + } + }, []); + useEffect(() => { fetchCoaches(); - }, [fetchCoaches]); + fetchCoachLocations(); + }, [fetchCoachLocations, fetchCoaches]); const showEmpty = !loading && !error && coaches.length === 0; @@ -501,9 +554,18 @@ const MyCoachesPage = () => {
- {rosterBreakdown.active.map((coach) => ( - - ))} + {rosterBreakdown.active.map((coach) => { + const coachId = pickCoachId(coach); + const linkedLocations = coachId ? coachLocationsById[String(coachId)] ?? [] : []; + + return ( + + ); + })}
)} @@ -518,9 +580,18 @@ const MyCoachesPage = () => {
- {rosterBreakdown.pending.map((coach) => ( - - ))} + {rosterBreakdown.pending.map((coach) => { + const coachId = pickCoachId(coach); + const linkedLocations = coachId ? coachLocationsById[String(coachId)] ?? [] : []; + + return ( + + ); + })}
)} @@ -535,9 +606,18 @@ const MyCoachesPage = () => {
- {rosterBreakdown.inactive.map((coach) => ( - - ))} + {rosterBreakdown.inactive.map((coach) => { + const coachId = pickCoachId(coach); + const linkedLocations = coachId ? coachLocationsById[String(coachId)] ?? [] : []; + + return ( + + ); + })}
)} From f29cc90f4c3e3c0b08ad21d01feed359a0bc3c4e Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Wed, 26 Nov 2025 08:17:19 -0800 Subject: [PATCH 6/8] Use coach profile locations on My Coaches --- src/pages/MyCoachesPage.tsx | 106 +++++++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 7 deletions(-) diff --git a/src/pages/MyCoachesPage.tsx b/src/pages/MyCoachesPage.tsx index 30e27237..7c615a8f 100644 --- a/src/pages/MyCoachesPage.tsx +++ b/src/pages/MyCoachesPage.tsx @@ -8,6 +8,7 @@ import { type CoachLocation, type PlayerCoach, } from "../api/playerCalendar"; +import fetchCoachProfile, { type CoachProfileRecord } from "../api/coachProfile"; import MainLayout from "../components/MainLayout"; import StateBanner from "../components/coaches/StateBanner"; import useDebouncedValue from "../hooks/useDebouncedValue"; @@ -101,6 +102,20 @@ const normalizeLocationString = (value: unknown) => { return typeof value === "string" ? value.trim() : ""; }; +const normalizeProfileLocation = (value: unknown) => (typeof value === "string" ? value.trim() : ""); + +const deriveProfileLocations = (profile?: CoachProfileRecord) => { + if (!profile) return [] as string[]; + + const fromProfile = normalizeProfileLocation(profile.location ?? (profile as Record).location_name); + const coachingLocations = Array.isArray(profile.coachingLocations) + ? profile.coachingLocations.map(normalizeProfileLocation).filter(Boolean) + : []; + + const combined = [fromProfile, ...coachingLocations].filter(Boolean); + return Array.from(new Set(combined)); +}; + const isPostalCode = (value: string) => { const trimmed = value.trim().replace(/^zip\s*/i, ""); if (/^\d{5}(?:[-\s]?\d{4})?$/.test(trimmed)) return true; @@ -149,9 +164,13 @@ const formatLocationsFromApi = (entries: CoachLocation[] = []) => { return nonPostal.length ? nonPostal : []; }; -const resolveLocation = (coach: PlayerCoach, linkedLocations: CoachLocation[]) => { +const resolveLocation = ( + coach: PlayerCoach, + linkedLocations: CoachLocation[], + profileLocations: string[], +) => { const record = coach as Record; - const primaryCandidates: string[] = []; + const primaryCandidates: string[] = [...profileLocations]; const formattedApiLocations = formatLocationsFromApi(linkedLocations); primaryCandidates.push(...formattedApiLocations); @@ -238,7 +257,11 @@ const resolveAbout = (coach: PlayerCoach) => { return about.trim(); }; -const resolveLocationTags = (coach: PlayerCoach, linkedLocations: CoachLocation[]) => { +const resolveLocationTags = ( + coach: PlayerCoach, + linkedLocations: CoachLocation[], + profileLocations: string[], +) => { const record = coach as Record; const rawLocations = record.coach_locations ?? record.locations ?? record.location_tags ?? record.service_locations; @@ -249,24 +272,32 @@ const resolveLocationTags = (coach: PlayerCoach, linkedLocations: CoachLocation[ ? rawLocations.map((loc) => normalizeLocationString(loc) || formatLocationObject(loc)).filter(Boolean) : []; - const combined = [...apiLocations, ...formatted]; + const combined = [...profileLocations, ...apiLocations, ...formatted]; const filtered = combined.filter((loc) => !isPostalCode(loc)); return Array.from(new Set(filtered)).slice(0, 3); }; -const MyCoachCard = ({ coach, linkedLocations }: { coach: PlayerCoach; linkedLocations: CoachLocation[] }) => { +const MyCoachCard = ({ + coach, + linkedLocations, + profileLocations, +}: { + coach: PlayerCoach; + linkedLocations: CoachLocation[]; + profileLocations: string[]; +}) => { const coachId = pickCoachId(coach); const name = resolveName(coach); const avatar = resolveAvatar(coach); const rating = resolveRating(coach); - const location = resolveLocation(coach, linkedLocations); + const location = resolveLocation(coach, linkedLocations, profileLocations); const distance = resolveDistance(coach); const status = resolveStatus(coach); const statusCategory = getStatusCategory(status); const hourlyRate = resolveHourlyRate(coach); const about = resolveAbout(coach); - const locations = resolveLocationTags(coach, linkedLocations); + const locations = resolveLocationTags(coach, linkedLocations, profileLocations); return (
@@ -343,6 +374,7 @@ const buildQueryParams = (search: string, location: string) => ({ const MyCoachesPage = () => { const [coaches, setCoaches] = useState([]); const [coachLocations, setCoachLocations] = useState([]); + const [coachProfiles, setCoachProfiles] = useState>({}); const [search, setSearch] = useState(""); const [location, setLocation] = useState(""); const [loading, setLoading] = useState(false); @@ -429,6 +461,54 @@ const MyCoachesPage = () => { } }, []); + useEffect(() => { + const coachIds = Array.from( + new Set( + coaches + .map((coach) => pickCoachId(coach)) + .filter((id) => id !== undefined && id !== null) + .map(String), + ), + ); + + const missingIds = coachIds.filter((id) => !coachProfiles[id] && Number.isFinite(Number(id))); + if (!missingIds.length) return; + + let cancelled = false; + + const loadProfiles = async () => { + const entries = await Promise.all( + missingIds.map(async (id) => { + try { + const profile = await fetchCoachProfile(Number(id)); + return { id, profile }; + } catch (error) { + console.error(`Failed to fetch profile for coach ${id}`, error); + return null; + } + }), + ); + + if (cancelled) return; + + setCoachProfiles((prev) => { + const next = { ...prev }; + entries.forEach((entry) => { + if (entry?.profile) { + next[entry.id] = entry.profile; + } + }); + return next; + }); + }; + + void loadProfiles(); + + return () => { + cancelled = true; + }; + }, [coachProfiles, coaches]); + useEffect(() => { fetchCoaches(); fetchCoachLocations(); @@ -557,12 +637,16 @@ const MyCoachesPage = () => { {rosterBreakdown.active.map((coach) => { const coachId = pickCoachId(coach); const linkedLocations = coachId ? coachLocationsById[String(coachId)] ?? [] : []; + const profileLocations = coachId + ? deriveProfileLocations(coachProfiles[String(coachId)]) + : []; return ( ); })} @@ -583,12 +667,16 @@ const MyCoachesPage = () => { {rosterBreakdown.pending.map((coach) => { const coachId = pickCoachId(coach); const linkedLocations = coachId ? coachLocationsById[String(coachId)] ?? [] : []; + const profileLocations = coachId + ? deriveProfileLocations(coachProfiles[String(coachId)]) + : []; return ( ); })} @@ -609,12 +697,16 @@ const MyCoachesPage = () => { {rosterBreakdown.inactive.map((coach) => { const coachId = pickCoachId(coach); const linkedLocations = coachId ? coachLocationsById[String(coachId)] ?? [] : []; + const profileLocations = coachId + ? deriveProfileLocations(coachProfiles[String(coachId)]) + : []; return ( ); })} From 87c27b5e818d7c59fbb9f3dad0ec0d59dd14005c Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Wed, 26 Nov 2025 08:23:08 -0800 Subject: [PATCH 7/8] Shorten coach location display --- src/pages/MyCoachesPage.tsx | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/pages/MyCoachesPage.tsx b/src/pages/MyCoachesPage.tsx index 7c615a8f..6a0d1a98 100644 --- a/src/pages/MyCoachesPage.tsx +++ b/src/pages/MyCoachesPage.tsx @@ -126,6 +126,14 @@ const isPostalCode = (value: string) => { return false; }; +const shortenLocationDisplay = (value: string) => { + const cleaned = value.replace(/\s+/g, " ").trim(); + if (!cleaned) return ""; + + const [firstSegment] = cleaned.split(/[·,]/); + return firstSegment.trim() || cleaned; +}; + const formatLocationObject = (location: unknown) => { if (!location || typeof location !== "object") return ""; const entry = location as Record; @@ -144,12 +152,7 @@ const formatLocationObject = (location: unknown) => { entry.street ?? entry.street_1 ?? entry.street1 ?? entry.address ?? entry.address_1 ?? entry.address1 ?? entry.line1, ); const country = normalizeLocationString(entry.country ?? entry.country_code); - const parts = [ - facility, - street, - [city, state].filter(Boolean).join(", "), - country, - ] + const parts = [facility, street, [city, state].filter(Boolean).join(", "), country] .map((value) => value.replace(/^zip\s*/i, "")) .filter(Boolean); return parts.join(" · ").trim(); @@ -200,7 +203,7 @@ const resolveLocation = ( if (!primaryCandidates.length) return ""; const nonPostal = primaryCandidates.find((entry) => !isPostalCode(entry)); - return nonPostal ?? ""; + return nonPostal ? shortenLocationDisplay(nonPostal) : ""; }; const resolveDistance = (coach: PlayerCoach) => { @@ -273,7 +276,10 @@ const resolveLocationTags = ( : []; const combined = [...profileLocations, ...apiLocations, ...formatted]; - const filtered = combined.filter((loc) => !isPostalCode(loc)); + const filtered = combined + .filter((loc) => !isPostalCode(loc)) + .map((loc) => shortenLocationDisplay(loc)) + .filter(Boolean); return Array.from(new Set(filtered)).slice(0, 3); }; From 5558635ad82e7207bf09ed713ac18eeb6921c3a2 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Wed, 26 Nov 2025 08:33:30 -0800 Subject: [PATCH 8/8] Fix coach profile lookup for string IDs --- src/api/coachProfile.ts | 7 ++++--- src/pages/MyCoachesPage.tsx | 8 +++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/api/coachProfile.ts b/src/api/coachProfile.ts index fbed4311..a8a74d57 100644 --- a/src/api/coachProfile.ts +++ b/src/api/coachProfile.ts @@ -41,8 +41,9 @@ const extractCoachProfile = (payload: unknown): CoachProfileRecord | undefined = return payload as CoachProfileRecord; }; -export const fetchCoachProfile = async (coachId: number, options?: FetchCoachProfileOptions) => { - if (!coachId) { +export const fetchCoachProfile = async (coachId: number | string, options?: FetchCoachProfileOptions) => { + const id = String(coachId ?? "").trim(); + if (!id) { throw new Error("Coach ID is required to load the profile."); } @@ -56,7 +57,7 @@ export const fetchCoachProfile = async (coachId: number, options?: FetchCoachPro const query = params.toString(); const response = await apiRequest( - `/player/coach/profile/${coachId}${query ? `?${query}` : ""}`, + `/player/coach/profile/${id}${query ? `?${query}` : ""}`, { method: "GET", signal: options?.signal, diff --git a/src/pages/MyCoachesPage.tsx b/src/pages/MyCoachesPage.tsx index 6a0d1a98..f8ebd810 100644 --- a/src/pages/MyCoachesPage.tsx +++ b/src/pages/MyCoachesPage.tsx @@ -473,11 +473,13 @@ const MyCoachesPage = () => { coaches .map((coach) => pickCoachId(coach)) .filter((id) => id !== undefined && id !== null) - .map(String), + .map(String) + .map((value) => value.trim()) + .filter(Boolean), ), ); - const missingIds = coachIds.filter((id) => !coachProfiles[id] && Number.isFinite(Number(id))); + const missingIds = coachIds.filter((id) => !coachProfiles[id]); if (!missingIds.length) return; let cancelled = false; @@ -486,7 +488,7 @@ const MyCoachesPage = () => { const entries = await Promise.all( missingIds.map(async (id) => { try { - const profile = await fetchCoachProfile(Number(id)); + const profile = await fetchCoachProfile(id); return { id, profile }; } catch (error) { console.error(`Failed to fetch profile for coach ${id}`, error);