diff --git a/src/pages/MyCoachesPage.css b/src/pages/MyCoachesPage.css index e6c2bb40..f292835f 100644 --- a/src/pages/MyCoachesPage.css +++ b/src/pages/MyCoachesPage.css @@ -1,113 +1,286 @@ .my-coaches { - padding: 32px 24px 64px; - max-width: 1100px; + min-height: calc(100vh - 48px); + display: flex; + flex-direction: column; + gap: 24px; + padding: 32px 20px 64px; + background: #f4f7fb; +} + +.my-coaches__inner { + width: 100%; + max-width: 1200px; margin: 0 auto; + display: flex; + flex-direction: column; + gap: 24px; +} + +@media (min-width: 1024px) { + .my-coaches { + padding: 48px 64px 80px; + gap: 32px; + } + + .my-coaches__inner { + gap: 32px; + } } -.my-coaches__header { +.my-booking-hero { + position: relative; + display: grid; + gap: 20px; + grid-template-columns: 1fr; + padding: 32px 28px; + border-radius: 28px; + background: linear-gradient(120deg, #ede9fe 0%, #e0f2fe 45%, #f0fdf4 100%); + border: 1px solid #e5e7eb; + box-shadow: 0 18px 36px rgba(0, 0, 0, 0.06); +} + +@media (min-width: 960px) { + .my-booking-hero { + grid-template-columns: 2fr 1fr; + align-items: stretch; + padding: 40px 44px; + } +} + +.my-booking-hero__content { display: flex; - align-items: flex-start; - justify-content: space-between; + flex-direction: column; gap: 16px; - margin-bottom: 20px; } -.my-coaches__eyebrow { - letter-spacing: 0.08em; +.coach-hero-eyebrow { text-transform: uppercase; + letter-spacing: 0.2em; font-size: 12px; - color: #6b7280; - margin: 0 0 6px; font-weight: 600; + color: #5b5fc7; + margin: 0; } -.my-coaches__title { +.my-booking-hero h1 { margin: 0; - font-size: 28px; + font-size: clamp(32px, 4vw, 44px); + font-weight: 700; color: #0f172a; } -.my-coaches__subtitle { - margin: 6px 0 0; - color: #475569; - max-width: 640px; +.coach-hero-subtitle { + margin: 0; + font-size: 18px; + line-height: 1.6; + color: #1f2937; } -.my-coaches__icon-button { - border: 1px solid #e2e8f0; - background: #fff; +.my-booking-hero__chips { + display: inline-flex; + gap: 10px; + padding: 6px; + background: rgba(91, 95, 199, 0.08); + border-radius: 999px; + width: fit-content; +} + +.coach-tab { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + border-radius: 999px; + border: none; + background: transparent; + font-weight: 600; + font-size: 15px; color: #0f172a; - width: 40px; - height: 40px; - border-radius: 10px; - display: grid; - place-items: center; cursor: pointer; - transition: border-color 0.2s ease, transform 0.15s ease; + transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease; + text-decoration: none; } -.my-coaches__icon-button:hover { - border-color: #cbd5e1; - transform: translateY(-1px); +.coach-tab.active { + background: #5b5fc7; + color: #ffffff; + box-shadow: 0 12px 24px rgba(91, 95, 199, 0.22); } -.my-coaches__filters { +.my-booking-hero__meta { display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 12px; - margin: 12px 0 18px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + padding: 14px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.75); + border: 1px solid #e5e7eb; } -.my-coaches__input { - display: flex; +.my-booking-hero__meta .label { + margin: 0 0 6px; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6b7280; +} + +.my-booking-hero__meta-value { + margin: 0; + font-size: 26px; + font-weight: 700; + color: #5b5fc7; +} + +.my-booking-hero__cta { + align-self: stretch; + padding: 18px; + border-radius: 20px; + background: #0f172a; + color: #f8fafc; + display: grid; + gap: 10px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2); +} + +.my-booking-hero__badge { + display: inline-flex; align-items: center; + justify-content: center; + width: fit-content; + padding: 6px 10px; + border-radius: 10px; + background: #5b5fc7; + color: #ffffff; + font-weight: 700; + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.my-booking-toolbar { + background: #fff; + border-radius: 16px; + border: 1px solid #e5e7eb; + padding: 14px 16px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.05); + display: grid; + gap: 8px; +} + +.my-booking-toolbar__controls { + display: flex; + flex-direction: column; gap: 8px; +} + +@media (min-width: 720px) { + .my-booking-toolbar__controls { + flex-direction: row; + align-items: center; + } +} + +.coach-search { + display: flex; + align-items: center; + gap: 10px; padding: 10px 12px; - background: #fff; - border: 1px solid #e2e8f0; + background: #f8fafc; + border: 1px solid #e5e7eb; border-radius: 12px; - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05); + flex: 1; } -.my-coaches__input input { +.coach-search input { flex: 1; border: none; + background: transparent; outline: none; font-size: 14px; color: #0f172a; } +.coach-search-clear { + background: none; + border: none; + cursor: pointer; + font-size: 16px; + color: #6b7280; +} + +.refresh-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: 12px; + border: 1px solid #e5e7eb; + background: #0f172a; + color: #ffffff; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.refresh-button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.my-booking-toolbar__hint { + margin: 0; + color: #6b7280; + font-size: 14px; +} + .my-coaches__grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 14px; - margin-top: 12px; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 18px; } -.my-coaches__card { - background: #fff; - border: 1px solid #e2e8f0; +.my-coaches__skeleton { + height: 260px; border-radius: 16px; - padding: 14px; + background: linear-gradient(90deg, #f4f4f5 25%, #e5e7eb 37%, #f4f4f5 63%); + background-size: 400% 100%; + animation: shimmer 1.4s ease infinite; +} + +@keyframes shimmer { + 0% { + background-position: 100% 0; + } + 100% { + background-position: -100% 0; + } +} + +.my-coach-card { display: flex; flex-direction: column; - gap: 12px; - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05); + gap: 14px; + padding: 16px; + border-radius: 16px; + border: 1px solid #e5e7eb; + background: #ffffff; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.05); } -.my-coaches__card-top { +.my-coach-card__header { display: flex; justify-content: space-between; gap: 12px; } -.my-coaches__card-left { +.my-coach-card__identity { display: flex; gap: 12px; min-width: 0; } -.my-coaches__avatar { +.my-coach-card__avatar { width: 72px; height: 72px; border-radius: 14px; @@ -115,167 +288,381 @@ background: linear-gradient(135deg, #e0f2fe, #f8fafc); } -.my-coaches__identity { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} - -.my-coaches__name-row { +.my-coach-card__title-row { display: flex; align-items: center; - gap: 10px; + gap: 8px; + margin: 0 0 4px; } -.my-coaches__name { +.my-coach-card__title-row h3 { margin: 0; font-size: 18px; color: #0f172a; } -.my-coaches__status { - background: #ecfeff; - color: #0e7490; - border: 1px solid #cce6ea; - border-radius: 999px; - padding: 4px 10px; +.my-coach-card__rating { + display: inline-flex; + align-items: center; + gap: 4px; + background: #fffbeb; + color: #b45309; + border-radius: 8px; + padding: 4px 8px; + font-weight: 700; font-size: 12px; - font-weight: 600; } -.my-coaches__rating { +.my-coach-card__location { display: inline-flex; align-items: center; gap: 6px; - font-weight: 600; - color: #0f172a; + margin: 0; + color: #475569; + font-size: 14px; } -.my-coaches__rating-icon { - color: #f59e0b; - fill: #fcd34d; +.my-coach-card__badge, +.my-coach-card__pill { + align-self: flex-start; + padding: 6px 10px; + border-radius: 10px; + background: #e0e7ff; + color: #312e81; + font-weight: 700; + font-size: 12px; } -.my-coaches__meta { +.my-coach-card__upcoming { display: inline-flex; align-items: center; - gap: 6px; + gap: 8px; + padding: 10px 12px; + border-radius: 12px; + font-weight: 600; + font-size: 14px; +} + +.my-coach-card__upcoming.private { + background: rgba(91, 95, 199, 0.12); + color: #312e81; +} + +.my-coach-card__upcoming.group { + background: rgba(16, 185, 129, 0.12); + color: #065f46; +} + +.my-coach-card__section { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + border-radius: 12px; + border: 1px solid #e5e7eb; + background: #f8fafc; +} + +.my-coach-card__section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.my-coach-card__section-header .label { + margin: 0 0 4px; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #5b5fc7; +} + +.my-coach-card__section-header p { + margin: 0; color: #475569; font-size: 14px; } -.my-coaches__distance { - color: #94a3b8; +.my-coach-card__section-header.group .label { + color: #0f9d58; +} + +.my-coach-card__slots { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; +} + +.my-coach-card__slot { + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 10px 12px; + background: #fff; + display: grid; + gap: 6px; + align-items: center; + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease; +} + +.my-coach-card__slot:hover { + border-color: #cbd5e1; + transform: translateY(-1px); +} + +.my-coach-card__slot.selected { + border-color: #5b5fc7; + box-shadow: 0 8px 24px rgba(91, 95, 199, 0.18); +} + +.my-coach-card__slot-day { font-size: 13px; + color: #6b7280; + letter-spacing: 0.08em; + text-transform: uppercase; } -.my-coaches__rate { +.my-coach-card__slot-time { + font-size: 16px; font-weight: 700; color: #0f172a; - font-size: 20px; +} + +.my-coach-card__muted { + margin: 0; + color: #9ca3af; + font-size: 14px; +} + +.my-coach-card__link { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 700; + color: #5b5fc7; + text-decoration: none; + width: fit-content; +} + +.my-coach-card__link.group { + color: #10b981; +} + +.my-coach-card__classes { + display: grid; + gap: 10px; +} + +.my-coach-card__class { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid #d1fae5; + background: #ecfdf3; + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.my-coach-card__class.selected { + border-color: #10b981; + box-shadow: 0 8px 20px rgba(16, 185, 129, 0.14); +} + +.my-coach-card__class-title { + margin: 0 0 6px; + font-weight: 700; + color: #065f46; +} + +.my-coach-card__class-meta { + display: inline-flex; + align-items: center; + gap: 6px; + color: #065f46; + font-size: 14px; +} + +.my-coach-card__class-price { + font-weight: 700; + color: #065f46; white-space: nowrap; - padding: 6px 10px; +} + +.my-coach-card__loading { + display: inline-flex; + align-items: center; + gap: 8px; + color: #6b7280; + font-size: 14px; +} + +.my-coach-card__cta { + width: 100%; + padding: 12px 14px; border-radius: 12px; - background: linear-gradient(135deg, #ecfeff, #f8fafc); - border: 1px solid #cce6ea; - height: fit-content; + border: none; + font-weight: 700; + font-size: 15px; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; } -.my-coaches__details { +.my-coach-card__cta.primary { + background: #5b5fc7; + color: #ffffff; + box-shadow: 0 10px 24px rgba(91, 95, 199, 0.25); +} + +.my-coach-card__cta.group { + background: #10b981; + color: #ffffff; + box-shadow: 0 10px 24px rgba(16, 185, 129, 0.2); +} + +.my-coach-card__cta:disabled, +.my-coach-card__cta[aria-disabled="true"] { + background: #e5e7eb; + color: #6b7280; + box-shadow: none; + cursor: not-allowed; +} + +.my-coach-card__error { + margin: 0; + color: #b91c1c; + font-weight: 600; +} + +.pending-coaches { display: flex; flex-direction: column; - gap: 8px; + gap: 12px; + padding: 12px; + border-radius: 16px; + border: 1px solid #e5e7eb; + background: #ffffff; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.05); +} + +.coach-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; } -.my-coaches__about { +.coach-section-header h3 { margin: 0; - color: #475569; - line-height: 1.5; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; + font-size: 18px; + color: #0f172a; } -.my-coaches__tags { - display: flex; - flex-wrap: wrap; - gap: 6px; +.coach-section-header p { + margin: 0; + color: #6b7280; + font-size: 14px; +} + +.pending-coaches__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 10px; } -.my-coaches__tag { +.pending-coach-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid #e5e7eb; background: #f8fafc; - color: #0f172a; - border: 1px solid #e2e8f0; - border-radius: 999px; - padding: 6px 10px; - font-size: 12px; - font-weight: 600; } -.my-coaches__actions { +.pending-coach-row__identity { display: flex; align-items: center; + gap: 10px; } -.my-coaches__button { - background: #0f172a; - color: #fff; - padding: 10px 14px; - border-radius: 10px; - text-decoration: none; - font-weight: 600; - transition: transform 0.15s ease, box-shadow 0.2s ease; +.pending-coach-row__avatar { + width: 44px; + height: 44px; + border-radius: 12px; + background: linear-gradient(135deg, #e0f2fe, #f8fafc); + display: grid; + place-items: center; + font-weight: 700; + color: #0f172a; + overflow: hidden; } -.my-coaches__button:hover { - transform: translateY(-1px); - box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12); +.pending-coach-row__avatar img { + width: 100%; + height: 100%; + object-fit: cover; } -.my-coaches__skeleton { - height: 120px; - border-radius: 16px; - background: linear-gradient(90deg, #f1f5f9, #e2e8f0, #f1f5f9); - background-size: 200% 100%; - animation: my-coaches-pulse 1.4s ease-in-out infinite; +.pending-coach-row__name { + margin: 0; + font-weight: 700; + color: #0f172a; } -@keyframes my-coaches-pulse { - 0% { - background-position: 200% 0; - } - 100% { - background-position: -200% 0; - } +.pending-coach-row__status { + margin: 2px 0 0; + color: #6b7280; + font-size: 14px; } -@media (max-width: 640px) { - .my-coaches { - padding: 24px 16px 48px; - } +.pending-coach-row__actions { + display: inline-flex; + gap: 8px; +} - .my-coaches__card { - flex-direction: column; - align-items: flex-start; - } +.pending-coach-row__actions .ghost { + border: 1px solid #e5e7eb; + background: #fff; + padding: 8px 10px; + border-radius: 10px; + cursor: pointer; + font-weight: 600; + color: #0f172a; +} - .my-coaches__card-top { - width: 100%; - flex-direction: column; - } +.pending-coach-row__actions .text { + border: none; + background: transparent; + color: #5b5fc7; + font-weight: 700; + cursor: pointer; +} - .my-coaches__rate { - align-self: flex-start; - } +.spin { + animation: spin 1s linear infinite; +} - .my-coaches__actions { - width: 100%; +@keyframes spin { + to { + transform: rotate(360deg); } +} - .my-coaches__button { - width: 100%; - text-align: center; - } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } diff --git a/src/pages/MyCoachesPage.tsx b/src/pages/MyCoachesPage.tsx index a3ac2696..341519da 100644 --- a/src/pages/MyCoachesPage.tsx +++ b/src/pages/MyCoachesPage.tsx @@ -1,23 +1,77 @@ -import { RefreshCcw, Search, Star, MapPin } from "lucide-react"; +import { + ArrowRight, + Calendar, + Clock3, + Loader2, + MapPin, + RefreshCcw, + Search, + Star, + Users2, +} from "lucide-react"; +import moment from "moment"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; +import { fetchAvailableLessons, fetchCoachLessonsByDate, fetchCoachSchedule } from "../api/playerLessons"; import { getPlayerCoaches, type PlayerCoach } from "../api/playerCalendar"; import MainLayout from "../components/MainLayout"; import StateBanner from "../components/coaches/StateBanner"; +import { useAuth } from "../context/AuthContext"; import useDebouncedValue from "../hooks/useDebouncedValue"; import "./MyCoachesPage.css"; +const AVAILABILITY_LOOKAHEAD_DAYS = 16; +const PRIVATE_SLOT_LIMIT = 4; +const AVAILABILITY_WINDOW_DAYS = 12; + type CoachStatusBadgeProps = { status?: string | number; }; +type LessonSelection = + | { type: "private"; slot: PrivateSlot } + | { type: "group"; lesson: GroupLesson } + | null; + +type PrivateSlot = { + id: string; + time: string; + dayLabel: string; + lessonType: "private"; + duration: string; + price: string; + isoDate: string; + spotsRemaining: number; + location?: string | null; + scheduleMeta?: { + startDateTime: string; + endDateTime: string; + startDateTimeTz: string; + endDateTimeTz: string; + locationId?: number | null; + court?: string | null; + }; +}; + +type GroupLesson = { + id: string | number; + title: string; + start: moment.Moment; + end: moment.Moment; + duration: string; + spotsRemaining: number | null; + price: number | null; +}; + const CoachStatusBadge = ({ status }: CoachStatusBadgeProps) => { if (status === null || status === undefined || status === "") return null; return {String(status)}; }; +const postalRegex = /\b\d{5}(?:-\d{4})?\b/; + const pickCoachId = (coach: PlayerCoach) => (coach as Record).coach_id ?? coach.id ?? @@ -64,34 +118,26 @@ const resolveRating = (coach: PlayerCoach) => { return Number.isFinite(numeric) ? numeric.toFixed(1) : null; }; -const resolveLocation = (coach: PlayerCoach) => { +const pickCoachLocationLabel = (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 candidates = [ + ...(Array.isArray(record.locationPlaces) + ? (record.locationPlaces as Array<{ label?: string | null }>).map((item) => item?.label) + : []), + ...(Array.isArray(record.locationList) ? (record.locationList as Array) : []), + record.location, + record.location_name, + record.facility, + record.city && record.state ? `${record.city}, ${record.state}` : null, + record.city, + record.state, + ]; - return ""; -}; + const label = candidates.find( + (entry) => typeof entry === "string" && entry.trim() && !postalRegex.test(entry.trim()), + ); -const resolveDistance = (coach: PlayerCoach) => { - const record = coach as Record; - const value = record.distance ?? record.distance_miles ?? record.distanceMiles; - const numeric = typeof value === "number" ? value : Number.parseFloat(String(value ?? "")); - return Number.isFinite(numeric) ? `${numeric.toFixed(1)} mi away` : ""; + return label?.toString().trim() || "Location TBD"; }; const resolveStatus = (coach: PlayerCoach) => { @@ -125,115 +171,457 @@ const resolveHourlyRate = (coach: PlayerCoach) => { 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 `$${numeric.toFixed(0)}`; + return `$${numeric.toFixed(0)}/hr`; }; -const resolveAbout = (coach: PlayerCoach) => { - const record = coach as Record; - const about = - record.about_me ?? record.about ?? record.bio ?? record.description ?? record.summary; - if (typeof about !== "string") return ""; - return about.trim(); +const buildScheduleMoment = (isoDate: string, time: string) => { + if (!isoDate || !time) return null; + const normalizedTime = time.length === 5 ? `${time}:00` : time; + const combined = moment( + `${isoDate}T${normalizedTime}`, + ["YYYY-MM-DDTHH:mm:ss", "YYYY-MM-DDTHH:mm"], + true, + ); + return combined.isValid() ? combined : null; }; -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); +const resolveScheduleLocation = (entry: Record) => { + const candidates = [entry.location, entry.location_name, entry.court_name, entry.court]; + const label = candidates.find( + (candidate) => typeof candidate === "string" && candidate.trim() && !postalRegex.test(candidate.trim()), + ); + return label?.trim(); +}; + +const buildSlotsFromScheduleEntry = ( + entry: Record, + isoDate: string, + priceLabel = "$0", + fallbackIndex = 0, +): PrivateSlot[] => { + const startMoment = buildScheduleMoment(isoDate, entry.from as string); + if (!startMoment) return []; + const endMoment = buildScheduleMoment(isoDate, entry.to as string); + const slotIdBase = + entry.id !== undefined && entry.id !== null ? String(entry.id) : `${fallbackIndex}`; + const slots: PrivateSlot[] = []; + + if (endMoment && endMoment.isAfter(startMoment)) { + let cursor = startMoment.clone(); + let segmentIndex = 0; + while (cursor.isBefore(endMoment)) { + const segmentEnd = cursor.clone().add(1, "hour"); + if (segmentEnd.isAfter(endMoment)) break; + slots.push({ + id: `${isoDate}-${slotIdBase}-seg-${segmentIndex}`, + time: cursor.format("h:mm A"), + lessonType: "private", + duration: `${segmentEnd.diff(cursor, "minutes")} min`, + price: priceLabel, + spotsRemaining: 1, + isoDate, + dayLabel: moment(isoDate).format("ddd"), + location: resolveScheduleLocation(entry), + scheduleMeta: { + startDateTime: cursor.clone().utc().toISOString(), + endDateTime: segmentEnd.clone().utc().toISOString(), + startDateTimeTz: cursor.toISOString(), + endDateTimeTz: segmentEnd.toISOString(), + locationId: entry.location_id as number | null, + court: (entry.court as string) ?? null, + }, + }); + cursor = segmentEnd; + segmentIndex += 1; + } } - return []; + + if (slots.length) return slots; + + const durationMinutes = endMoment ? Math.max(endMoment.diff(startMoment, "minutes"), 0) : null; + const computedDuration = + durationMinutes && Number.isFinite(durationMinutes) && durationMinutes > 0 + ? durationMinutes + : 60; + const derivedEndMoment = endMoment ?? startMoment.clone().add(computedDuration, "minutes"); + + return [ + { + id: `${isoDate}-${slotIdBase}`, + time: startMoment.format("h:mm A"), + lessonType: "private", + duration: `${computedDuration} min`, + price: priceLabel, + spotsRemaining: 1, + isoDate, + dayLabel: moment(isoDate).format("ddd"), + location: resolveScheduleLocation(entry), + scheduleMeta: { + startDateTime: startMoment.clone().utc().toISOString(), + endDateTime: derivedEndMoment.clone().utc().toISOString(), + startDateTimeTz: startMoment.toISOString(), + endDateTimeTz: derivedEndMoment.toISOString(), + locationId: entry.location_id as number | null, + court: (entry.court as string) ?? null, + }, + }, + ]; }; -const MyCoachCard = ({ coach }: { coach: PlayerCoach }) => { +const resolveUpcomingLesson = (coach: PlayerCoach) => { + const record = coach as Record; + const dateCandidate = record.next_lesson_date ?? record.nextLessonDate ?? record.next_lesson_day ?? record.nextLessonDay; + const timeCandidate = record.next_lesson_time ?? record.nextLessonTime; + const typeCandidate = (record.next_lesson_type ?? record.nextLessonType ?? "").toString().toLowerCase(); + const isGroup = typeCandidate.includes("group"); + const isPrivate = typeCandidate.includes("private") || (!typeCandidate && !isGroup); + if (!dateCandidate && !timeCandidate) return null; + return { + date: String(dateCandidate ?? ""), + time: String(timeCandidate ?? ""), + label: [dateCandidate, timeCandidate].filter(Boolean).join(" · "), + tone: isGroup ? "group" : isPrivate ? "private" : "info", + } as const; +}; + +const isPendingCoach = (coach: PlayerCoach) => { + const status = resolveStatus(coach)?.toString().toLowerCase(); + return status === "pending" || status === "0"; +}; + +const MyCoachBookingCard = ({ coach, authToken }: { coach: PlayerCoach; authToken: string | null }) => { + const [privateSlots, setPrivateSlots] = useState([]); + const [groupClasses, setGroupClasses] = useState([]); + const [selection, setSelection] = useState(null); + const [loadingSlots, setLoadingSlots] = useState(false); + const [error, setError] = useState(null); + const navigate = useNavigate(); + const coachId = pickCoachId(coach); - const name = resolveName(coach); - const avatar = resolveAvatar(coach); - const rating = resolveRating(coach); - const location = resolveLocation(coach); - const distance = resolveDistance(coach); - const status = resolveStatus(coach); - const hourlyRate = resolveHourlyRate(coach); - const about = resolveAbout(coach); - const locations = resolveLocationTags(coach); + const numericCoachId = Number(coachId); + const coachSlug = (coach as Record).slug as string | undefined; + const coachName = resolveName(coach); + const locationLabel = pickCoachLocationLabel(coach); + const hourlyRateLabel = resolveHourlyRate(coach); + const upcomingLesson = useMemo(() => resolveUpcomingLesson(coach), [coach]); + + useEffect(() => { + if (!coachId || !Number.isFinite(numericCoachId)) return; + let cancelled = false; + const loadAvailability = async () => { + setLoadingSlots(true); + setError(null); + const collectedSlots: PrivateSlot[] = []; + const hourlyLabel = hourlyRateLabel || "$0"; + + for (let offset = 0; offset < AVAILABILITY_LOOKAHEAD_DAYS; offset += 1) { + if (collectedSlots.length >= PRIVATE_SLOT_LIMIT) break; + const dateMoment = moment().add(offset, "days"); + const isoDate = dateMoment.format("YYYY-MM-DD"); + const weekday = dateMoment.format("dddd").toUpperCase(); + let scheduleEntries: any[] = []; + try { + scheduleEntries = await fetchCoachSchedule({ + token: authToken ?? "", + coachId: numericCoachId, + day: weekday, + }); + } catch (err) { + scheduleEntries = []; + } + + if (!scheduleEntries.length) continue; + + let bookedLessons: any[] = []; + try { + bookedLessons = await fetchCoachLessonsByDate({ + token: authToken ?? undefined, + coachId: numericCoachId, + date: isoDate, + }); + } catch (err) { + bookedLessons = []; + } + + const bookedTimes = new Set( + bookedLessons + .map((lesson) => moment(lesson.start_date_time).format("HH:mm")) + .filter(Boolean), + ); + + const dailySlots = scheduleEntries + .flatMap((entry, index) => buildSlotsFromScheduleEntry(entry, isoDate, hourlyLabel, index)) + .filter((slot) => { + const slotStart = slot.scheduleMeta?.startDateTimeTz + ? moment(slot.scheduleMeta.startDateTimeTz).format("HH:mm") + : moment(`${isoDate} ${slot.time}`, ["YYYY-MM-DD h:mm A", "YYYY-MM-DD HH:mm"]).format("HH:mm"); + return slotStart ? !bookedTimes.has(slotStart) : true; + }); + + collectedSlots.push( + ...dailySlots.map((slot) => ({ + ...slot, + dayLabel: dateMoment.format("ddd"), + })), + ); + } + + if (!cancelled) { + setPrivateSlots(collectedSlots.slice(0, PRIVATE_SLOT_LIMIT)); + } + + try { + const startIso = moment().format("YYYY-MM-DD"); + const endIso = moment().add(AVAILABILITY_WINDOW_DAYS, "days").format("YYYY-MM-DD"); + const lessonsResponse = await fetchAvailableLessons({ + token: authToken ?? "", + start_date: startIso, + end_date: endIso, + coach_id: numericCoachId, + }); + const lessonData = Array.isArray((lessonsResponse as any)?.data) + ? (lessonsResponse as any).data + : []; + const groups: GroupLesson[] = lessonData + .filter((lesson: any) => { + const typeLabel = (lesson.lesson_type_name ?? lesson.metadata?.title ?? "") + .toString() + .toLowerCase(); + return typeLabel.includes("group") || (lesson.player_limit ?? 1) > 1; + }) + .map((lesson: any) => ({ + id: lesson.id, + title: lesson.metadata?.title ?? lesson.metadata_title ?? lesson.lesson_type_name ?? "Group Class", + start: moment(lesson.start_date_time), + end: moment(lesson.end_date_time), + duration: lesson.end_date_time + ? `${moment(lesson.end_date_time).diff(moment(lesson.start_date_time), "minutes")} min` + : "60 min", + spotsRemaining: Math.max((lesson.player_limit ?? 0) - (lesson.current_player_count ?? 0), 0) || null, + price: lesson.price_per_person ?? null, + })); + if (!cancelled) { + setGroupClasses(groups.slice(0, 3)); + } + } catch (err) { + if (!cancelled) { + setError("Could not load class availability"); + } + } finally { + if (!cancelled) { + setLoadingSlots(false); + } + } + }; + + loadAvailability(); + return () => { + cancelled = true; + }; + }, [authToken, coach, coachId, hourlyRateLabel, numericCoachId]); + + const handleBook = () => { + if (!selection || !coachId) return; + const scheduleState = selection.type === "private" ? { slot: selection.slot } : { lesson: selection.lesson }; + const url = `/coaches/${coachSlug || coachId}`; + navigate(url, { state: { quickBook: scheduleState } }); + }; + + const buttonTone = selection?.type === "group" ? "group" : "primary"; + const buttonLabel = selection + ? selection.type === "private" + ? `Book Private — ${selection.slot.dayLabel} at ${selection.slot.time} — ${selection.slot.price}` + : `Book ${selection.lesson.title} — $${selection.lesson.price ?? ""}` + : "Select a time to book"; return ( -
-
-
- {`Portrait -
-
-

{name}

- {status && } +
+
+
+ {`Portrait +
+
+

{coachName}

+ {resolveRating(coach) ? ( + + + {resolveRating(coach)} + + ) : null}
- {rating && ( -
- - {rating} -
- )} - {location && ( -
- - {location} -
- )} - {distance &&
{distance}
} +

+ + {locationLabel} +

- {hourlyRate &&
{hourlyRate}
} -
+ {hourlyRateLabel ? {hourlyRateLabel} : null} + - {(about || locations.length > 0) && ( -
- {about &&

{about}

} - {locations.length > 0 && ( -
- {locations.map((loc) => ( - - {loc} - - ))} -
- )} + {upcomingLesson ? ( +
+ + Upcoming: {upcomingLesson.label}
- )} + ) : null} -
- {coachId && ( - - View profile +
+
+
+
Private lessons
+

Select a time to book quickly.

+
+ {hourlyRateLabel ? {hourlyRateLabel} : null} +
+
+ {loadingSlots && !privateSlots.length ? ( +
+ Loading times +
+ ) : null} + {privateSlots.slice(0, PRIVATE_SLOT_LIMIT).map((slot) => { + const isSelected = selection?.type === "private" && selection.slot.id === slot.id; + return ( + + ); + })} + {!loadingSlots && !privateSlots.length ? ( +

No upcoming private slots in the next two weeks.

+ ) : null} +
+ + All times + +
+ + {groupClasses.length ? ( +
+
+
+
Group classes
+

Join a class with available spots.

+
+
+
+ {groupClasses.map((lesson) => { + const isSelected = selection?.type === "group" && selection.lesson.id === lesson.id; + return ( + + ); + })} +
+ + All classes - )} -
+ + ) : null} + + {error ?

{error}

: null} + +
); }; -const buildQueryParams = (search: string, location: string) => ({ +const PendingCoachRow = ({ coach }: { coach: PlayerCoach }) => { + const initials = resolveName(coach).slice(0, 2).toUpperCase(); + const record = coach as Record; + const requestDate = record.requested_at || record.request_date || record.created_at || record.createdAt || null; + const requestLabel = requestDate ? moment(requestDate).format("MMM D, YYYY") : "Recently requested"; + const avatar = resolveAvatar(coach); + + return ( +
  • +
    +
    + {avatar ? : {initials}} +
    +
    +

    {resolveName(coach)}

    +

    Awaiting approval · Requested {requestLabel}

    +
    +
    +
    + + +
    +
  • + ); +}; + +const buildQueryParams = (search: string) => ({ search: search.trim(), - location: location.trim(), }); const MyCoachesPage = () => { + const { user } = useAuth(); + const playerToken = + user?.session?.access_token ?? user?.access_token ?? user?.token ?? null; const [coaches, setCoaches] = useState([]); const [search, setSearch] = useState(""); - const [location, setLocation] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const debouncedSearch = useDebouncedValue(search, 300); - const debouncedLocation = useDebouncedValue(location, 300); - - const hasFilters = useMemo( - () => Boolean(debouncedSearch.trim() || debouncedLocation.trim()), - [debouncedLocation, debouncedSearch], - ); const fetchCoaches = useCallback(async () => { setLoading(true); @@ -242,7 +630,7 @@ const MyCoachesPage = () => { const data = await getPlayerCoaches({ perPage: 25, page: 1, - ...buildQueryParams(debouncedSearch, debouncedLocation), + ...buildQueryParams(debouncedSearch), }); setCoaches(data); } catch (err) { @@ -252,85 +640,131 @@ const MyCoachesPage = () => { } finally { setLoading(false); } - }, [debouncedLocation, debouncedSearch]); + }, [debouncedSearch]); useEffect(() => { fetchCoaches(); }, [fetchCoaches]); - const showEmpty = !loading && !error && coaches.length === 0; + const confirmedCoaches = useMemo( + () => coaches.filter((coach) => !isPendingCoach(coach)), + [coaches], + ); + const pendingCoaches = useMemo( + () => coaches.filter((coach) => isPendingCoach(coach)), + [coaches], + ); + + const filteredConfirmed = useMemo(() => { + const query = debouncedSearch.trim().toLowerCase(); + if (!query) return confirmedCoaches; + return confirmedCoaches.filter((coach) => resolveName(coach).toLowerCase().includes(query)); + }, [confirmedCoaches, debouncedSearch]); + + const showEmpty = !loading && !error && confirmedCoaches.length === 0 && pendingCoaches.length === 0; return (
    -
    -
    -

    Roster

    -

    My coaches

    -

    - Keep track of the coaches you are working with and jump to their profiles quickly. +

    +
    +
    +

    My Coaches

    +

    Book your next lesson fast

    +

    + Tap a slot on a coach card to prefill the booking button instantly.

    +
    + + + Browse all coaches + +
    +
    +
    +

    Connected coaches

    +

    {confirmedCoaches.length}

    +
    +
    +

    Pending approvals

    +

    {pendingCoaches.length}

    +
    +
    - -
    - -
    -
    - - setSearch(event.target.value)} - /> -
    -
    - - setLocation(event.target.value)} +
    +
    Lightning-fast booking
    +

    Skip discovery mode—this page is built to confirm your next lesson quickly.

    +
    +
    + +
    +
    +
    event.preventDefault()}> + + setSearch(event.target.value)} + placeholder="Search your coaches…" + aria-label="Search my coaches by name" + /> + {search ? ( + + ) : null} + + +
    +

    Select a time on any card to activate the booking button.

    +
    + + {error && } + + {loading && ( +
    + {Array.from({ length: 4 }).map((_, index) => ( +
    + ))} +
    + )} + + {showEmpty && ( + -
    -
    + )} - {error && } + {!loading && !error && filteredConfirmed.length > 0 && ( +
    + {filteredConfirmed.map((coach) => ( + + ))} +
    + )} - {loading && ( -
    - {Array.from({ length: 4 }).map((_, index) => ( -
    - ))} -
    - )} - - {showEmpty && ( - - )} - - {!loading && !error && coaches.length > 0 && ( -
    - {coaches.map((coach) => ( - - ))} -
    - )} + {!loading && !error && pendingCoaches.length > 0 && ( +
    +
    +

    Pending Approval

    +

    Requests that are awaiting coach confirmation.

    +
    +
      + {pendingCoaches.map((coach) => ( + + ))} +
    +
    + )} +
    ); diff --git a/src/pages/PlayerCoachListPage.css b/src/pages/PlayerCoachListPage.css index 2c72ef5b..1458ff20 100644 --- a/src/pages/PlayerCoachListPage.css +++ b/src/pages/PlayerCoachListPage.css @@ -26,6 +26,92 @@ overflow: hidden; } +.my-booking-hero { + position: relative; + display: grid; + gap: 20px; + grid-template-columns: 1fr; + padding: 32px 28px; + border-radius: 28px; + background: linear-gradient(120deg, #ede9fe 0%, #e0f2fe 45%, #f0fdf4 100%); + border: 1px solid #e5e7eb; + box-shadow: 0 18px 36px rgba(0, 0, 0, 0.06); +} + +@media (min-width: 960px) { + .my-booking-hero { + grid-template-columns: 2fr 1fr; + align-items: stretch; + padding: 40px 44px; + } +} + +.my-booking-hero__content { + display: flex; + flex-direction: column; + gap: 16px; +} + +.my-booking-hero__chips { + display: inline-flex; + gap: 10px; + padding: 6px; + background: rgba(91, 95, 199, 0.08); + border-radius: 999px; + width: fit-content; +} + +.my-booking-hero__meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + padding: 14px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.75); + border: 1px solid #e5e7eb; +} + +.my-booking-hero__meta .label { + margin: 0 0 6px; + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6b7280; +} + +.my-booking-hero__meta-value { + margin: 0; + font-size: 26px; + font-weight: 700; + color: #5b5fc7; +} + +.my-booking-hero__cta { + align-self: stretch; + padding: 18px; + border-radius: 20px; + background: #0f172a; + color: #f8fafc; + display: grid; + gap: 10px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2); +} + +.my-booking-hero__badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + padding: 6px 10px; + border-radius: 10px; + background: #5b5fc7; + color: #ffffff; + font-weight: 700; + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + @media (min-width: 1024px) { .coach-hero { flex-direction: row; @@ -157,6 +243,36 @@ gap: 16px; } +.my-booking-toolbar { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border-radius: 16px; + background: #ffffff; + border: 1px solid #e5e7eb; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.05); +} + +.my-booking-toolbar__controls { + display: grid; + gap: 12px; + grid-template-columns: 1fr; +} + +@media (min-width: 768px) { + .my-booking-toolbar__controls { + grid-template-columns: minmax(260px, 1fr) auto; + align-items: center; + } +} + +.my-booking-toolbar__hint { + margin: 0; + color: #6b7280; + font-size: 14px; +} + .coach-controls-bar { display: grid; gap: 12px; @@ -1136,3 +1252,397 @@ padding: 20px; } } + +.my-coaches-grid { + display: grid; + gap: 20px; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); +} + +.my-coach-card { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 14px; + padding: 18px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04); + display: flex; + flex-direction: column; + gap: 14px; +} + +.my-coach-card__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.my-coach-card__identity { + display: flex; + align-items: center; + gap: 12px; +} + +.my-coach-card__avatar { + width: 52px; + height: 52px; + border-radius: 50%; + background: #eef2ff; + display: grid; + place-items: center; + font-weight: 700; + color: #4b5563; + overflow: hidden; +} + +.my-coach-card__avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.my-coach-card__eyebrow { + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #6b7280; + margin: 0; +} + +.my-coach-card__name { + margin: 2px 0 4px; + font-size: 18px; + font-weight: 700; + color: #1f2937; +} + +.my-coach-card__location { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #4b5563; +} + +.my-coach-card__rate { + background: #eef2ff; + color: #4338ca; + padding: 8px 12px; + border-radius: 8px; + font-weight: 700; + font-size: 13px; +} + +.my-coach-card__banner { + border-radius: 12px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.my-coach-card__banner.private { + background: #f5f3ff; + color: #4338ca; +} + +.my-coach-card__banner.group { + background: #ecfdf3; + color: #047857; +} + +.my-coach-card__banner-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.my-coach-card__banner-content { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; +} + +.my-coach-card__pill { + background: rgba(91, 95, 199, 0.12); + color: #1f2937; + padding: 4px 8px; + border-radius: 8px; + font-size: 12px; +} + +.my-coach-card__section { + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 12px; + background: #f9fafb; + display: flex; + flex-direction: column; + gap: 12px; +} + +.my-coach-card__section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.my-coach-card__section-header.group { + border-bottom: 1px solid #e5e7eb; + padding-bottom: 6px; +} + +.my-coach-card__section-title .label { + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6b7280; + margin-bottom: 4px; +} + +.my-coach-card__section-title p { + margin: 0; + color: #4b5563; + font-size: 14px; +} + +.my-coach-card__badge { + background: #5b5fc7; + color: #ffffff; + padding: 6px 10px; + border-radius: 8px; + font-weight: 700; + font-size: 12px; +} + +.my-coach-card__slots { + display: grid; + gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); +} + +.my-coach-card__slot { + border: 1px solid #d1d5db; + border-radius: 10px; + padding: 10px; + background: #ffffff; + text-align: left; + cursor: pointer; + transition: box-shadow 150ms ease, border-color 150ms ease, transform 150ms ease; +} + +.my-coach-card__slot:hover { + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); +} + +.my-coach-card__slot.selected { + border-color: #5b5fc7; + background: #eef2ff; +} + +.my-coach-card__slot-day { + font-size: 12px; + color: #6b7280; +} + +.my-coach-card__slot-time { + font-weight: 700; + color: #1f2937; +} + +.my-coach-card__muted { + color: #6b7280; + font-size: 14px; + margin: 4px 0 0; +} + +.my-coach-card__loading { + display: inline-flex; + align-items: center; + gap: 6px; + color: #4b5563; + font-size: 14px; +} + +.my-coach-card__link { + background: none; + border: none; + color: #5b5fc7; + font-weight: 700; + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 13px; +} + +.my-coach-card__link.group { + color: #047857; +} + +.my-coach-card__classes { + display: flex; + flex-direction: column; + gap: 8px; +} + +.my-coach-card__class { + border: 1px solid #d1d5db; + background: #ffffff; + border-radius: 10px; + padding: 10px; + display: flex; + justify-content: space-between; + gap: 10px; + text-align: left; + cursor: pointer; +} + +.my-coach-card__class.selected { + border-color: #10b981; + background: #ecfdf3; +} + +.my-coach-card__class-title { + font-weight: 700; + color: #065f46; + margin-bottom: 4px; +} + +.my-coach-card__class-meta { + display: flex; + align-items: center; + gap: 6px; + color: #4b5563; + font-size: 13px; +} + +.my-coach-card__class-price { + font-weight: 700; + color: #065f46; + align-self: center; +} + +.my-coach-card__error { + color: #b45309; + font-size: 13px; + margin: 0; +} + +.my-coach-card__cta { + width: 100%; + padding: 12px; + border-radius: 12px; + border: none; + font-weight: 700; + font-size: 15px; + cursor: pointer; + transition: transform 120ms ease, box-shadow 120ms ease; +} + +.my-coach-card__cta.private { + background: #5b5fc7; + color: #ffffff; + box-shadow: 0 8px 24px rgba(91, 95, 199, 0.25); +} + +.my-coach-card__cta.group { + background: #10b981; + color: #ffffff; + box-shadow: 0 8px 24px rgba(16, 185, 129, 0.25); +} + +.my-coach-card__cta.disabled { + background: #e5e7eb; + color: #6b7280; + cursor: not-allowed; +} + +.my-coach-card__cta:disabled { + background: #e5e7eb; + color: #9ca3af; + cursor: not-allowed; +} + +.pending-coaches { + margin-top: 16px; +} + +.pending-coaches__list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.pending-coach-row { + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 12px; + display: flex; + justify-content: space-between; + align-items: center; + background: #ffffff; +} + +.pending-coach-row__identity { + display: flex; + align-items: center; + gap: 12px; +} + +.pending-coach-row__avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: #f3f4f6; + display: grid; + place-items: center; + font-weight: 700; + color: #4b5563; + overflow: hidden; +} + +.pending-coach-row__avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.pending-coach-row__name { + margin: 0; + font-weight: 700; + color: #111827; +} + +.pending-coach-row__status { + margin: 2px 0 0; + color: #6b7280; + font-size: 13px; +} + +.pending-coach-row__actions { + display: flex; + gap: 10px; +} + +.pending-coach-row__actions .ghost { + border: 1px solid #e5e7eb; + background: #ffffff; + border-radius: 10px; + padding: 8px 12px; + font-weight: 600; + color: #1f2937; +} + +.pending-coach-row__actions .text { + background: none; + border: none; + color: #b45309; + font-weight: 700; +} diff --git a/src/pages/PlayerCoachListPage.jsx b/src/pages/PlayerCoachListPage.jsx index 783e925a..23f8094b 100644 --- a/src/pages/PlayerCoachListPage.jsx +++ b/src/pages/PlayerCoachListPage.jsx @@ -8,10 +8,11 @@ import { } from "react"; import Autocomplete from "react-google-autocomplete"; import { createPortal } from "react-dom"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { ArrowRight, Calendar, + Clock3, Loader2, MapPin, RefreshCcw, @@ -20,14 +21,23 @@ import { Star, Users2, } from "lucide-react"; +import moment from "moment"; import api, { unwrap } from "../services/api"; import { API_BASE_URL } from "../api/config"; import { useAuth } from "../context/AuthContext"; import useDebouncedValue from "../hooks/useDebouncedValue"; +import { + fetchAvailableLessons, + fetchCoachLessonsByDate, + fetchCoachSchedule, +} from "../api/playerLessons"; import "./PlayerCoachListPage.css"; const PER_PAGE = 10; const DEFAULT_RADIUS = 10; +const AVAILABILITY_WINDOW_DAYS = 12; +const PRIVATE_SLOT_LIMIT = 4; +const AVAILABILITY_LOOKAHEAD_DAYS = 16; const DYNAMIC_FILTERS_ENDPOINT = import.meta.env.VITE_PLAYER_FILTERS_ENDPOINT ?? "/player/filters"; const ENABLE_DYNAMIC_FILTERS = @@ -48,6 +58,111 @@ const sanitizeLocationSearch = (location) => { return label; }; +const normalizeScheduleDay = (day) => (day ?? "").trim().toUpperCase(); + +const buildScheduleMoment = (isoDate, time) => { + if (!isoDate || !time) return null; + const normalizedTime = time.length === 5 ? `${time}:00` : time; + const combined = moment( + `${isoDate}T${normalizedTime}`, + ["YYYY-MM-DDTHH:mm:ss", "YYYY-MM-DDTHH:mm"], + true, + ); + return combined.isValid() ? combined : null; +}; + +const resolveScheduleLocation = (entry) => { + const candidates = [entry.location, entry.location_name, entry.court_name, entry.court]; + const postalRegex = /\b\d{5}(?:-\d{4})?\b/; + const label = candidates.find( + (candidate) => typeof candidate === "string" && candidate.trim() && !postalRegex.test(candidate.trim()), + ); + return label?.trim(); +}; + +const buildSlotsFromScheduleEntry = (entry, isoDate, priceLabel = "$0", fallbackIndex = 0) => { + const startMoment = buildScheduleMoment(isoDate, entry.from); + if (!startMoment) return []; + const endMoment = buildScheduleMoment(isoDate, entry.to); + const slotIdBase = + entry.id !== undefined && entry.id !== null ? String(entry.id) : `${fallbackIndex}`; + const slots = []; + + if (endMoment && endMoment.isAfter(startMoment)) { + let cursor = startMoment.clone(); + let segmentIndex = 0; + while (cursor.isBefore(endMoment)) { + const segmentEnd = cursor.clone().add(1, "hour"); + if (segmentEnd.isAfter(endMoment)) break; + slots.push({ + id: `${isoDate}-${slotIdBase}-seg-${segmentIndex}`, + time: cursor.format("h:mm A"), + lessonType: "private", + duration: `${segmentEnd.diff(cursor, "minutes")} min`, + price: priceLabel, + spotsRemaining: 1, + location: resolveScheduleLocation(entry), + scheduleMeta: { + startDateTime: cursor.clone().utc().toISOString(), + endDateTime: segmentEnd.clone().utc().toISOString(), + startDateTimeTz: cursor.toISOString(), + endDateTimeTz: segmentEnd.toISOString(), + locationId: entry.location_id, + court: entry.court ?? null, + }, + }); + cursor = segmentEnd; + segmentIndex += 1; + } + } + + if (slots.length) return slots; + + const durationMinutes = endMoment ? Math.max(endMoment.diff(startMoment, "minutes"), 0) : null; + const computedDuration = + durationMinutes && Number.isFinite(durationMinutes) && durationMinutes > 0 ? durationMinutes : 60; + const derivedEndMoment = endMoment ?? startMoment.clone().add(computedDuration, "minutes"); + + return [ + { + id: `${isoDate}-${slotIdBase}`, + time: startMoment.format("h:mm A"), + lessonType: "private", + duration: `${computedDuration} min`, + price: priceLabel, + spotsRemaining: 1, + location: resolveScheduleLocation(entry), + scheduleMeta: { + startDateTime: startMoment.clone().utc().toISOString(), + endDateTime: derivedEndMoment.clone().utc().toISOString(), + startDateTimeTz: startMoment.toISOString(), + endDateTimeTz: derivedEndMoment.toISOString(), + locationId: entry.location_id, + court: entry.court ?? null, + }, + }, + ]; +}; + +const pickCoachLocationLabel = (coach) => { + if (!coach) return ""; + const postalRegex = /\b\d{5}(?:-\d{4})?\b/; + const candidates = [ + ...(Array.isArray(coach.locationPlaces) ? coach.locationPlaces.map((item) => item?.label) : []), + ...(Array.isArray(coach.locationList) ? coach.locationList : []), + coach.location, + coach.location_name, + coach.facility, + coach.city && coach.state ? `${coach.city}, ${coach.state}` : null, + coach.city, + coach.state, + ]; + const label = candidates.find( + (entry) => typeof entry === "string" && entry.trim() && !postalRegex.test(entry.trim()), + ); + return label?.trim() ?? "Location TBD"; +}; + const parseCoachList = (payload) => { if (!payload) return []; if (Array.isArray(payload)) return payload; @@ -1264,11 +1379,341 @@ const CoachCard = ({ coach, variant = "standard" }) => { ); }; +const MyCoachBookingCard = ({ coach, authToken }) => { + const [privateSlots, setPrivateSlots] = useState([]); + const [groupClasses, setGroupClasses] = useState([]); + const [selection, setSelection] = useState(null); + const [loadingSlots, setLoadingSlots] = useState(false); + const [error, setError] = useState(null); + const navigate = useNavigate(); + + const coachName = coach?.name ?? "Coach"; + const locationLabel = pickCoachLocationLabel(coach); + const hourlyRateLabel = coach?.hourlyRate || (coach?.hourlyRateValue ? `$${coach.hourlyRateValue.toFixed(0)}/hr` : ""); + + const upcomingLesson = useMemo(() => { + const dateCandidate = + coach?.next_lesson_date || + coach?.nextLessonDate || + coach?.next_lesson_day || + coach?.nextLessonDay; + const timeCandidate = coach?.next_lesson_time || coach?.nextLessonTime; + const typeCandidate = (coach?.next_lesson_type || coach?.nextLessonType || "").toString().toLowerCase(); + const isGroup = typeCandidate.includes("group"); + const isPrivate = typeCandidate.includes("private") || (!typeCandidate && !isGroup); + if (!dateCandidate && !timeCandidate) return null; + return { + date: dateCandidate, + time: timeCandidate, + label: [dateCandidate, timeCandidate].filter(Boolean).join(" · "), + tone: isGroup ? "group" : isPrivate ? "private" : "info", + }; + }, [coach?.nextLessonDate, coach?.nextLessonDay, coach?.nextLessonTime, coach?.nextLessonType, coach?.next_lesson_date, coach?.next_lesson_day, coach?.next_lesson_time, coach?.next_lesson_type]); + + useEffect(() => { + if (!coach?.id) return; + let cancelled = false; + const loadAvailability = async () => { + setLoadingSlots(true); + setError(null); + const collectedSlots = []; + const hourlyLabel = hourlyRateLabel || "$0"; + + for (let offset = 0; offset < AVAILABILITY_LOOKAHEAD_DAYS; offset += 1) { + if (collectedSlots.length >= PRIVATE_SLOT_LIMIT) break; + const dateMoment = moment().add(offset, "days"); + const isoDate = dateMoment.format("YYYY-MM-DD"); + const weekday = dateMoment.format("dddd").toUpperCase(); + let scheduleEntries = []; + try { + scheduleEntries = await fetchCoachSchedule({ + token: authToken ?? "", + coachId: coach.id, + day: weekday, + }); + } catch (err) { + scheduleEntries = []; + } + + if (!scheduleEntries.length) continue; + + let bookedLessons = []; + try { + bookedLessons = await fetchCoachLessonsByDate({ + token: authToken ?? undefined, + coachId: coach.id, + date: isoDate, + }); + } catch (err) { + bookedLessons = []; + } + + const bookedTimes = new Set( + bookedLessons + .map((lesson) => moment(lesson.start_date_time).format("HH:mm")) + .filter(Boolean), + ); + + const dailySlots = scheduleEntries + .flatMap((entry, index) => buildSlotsFromScheduleEntry(entry, isoDate, hourlyLabel, index)) + .filter((slot) => { + const slotStart = slot.scheduleMeta?.startDateTimeTz + ? moment(slot.scheduleMeta.startDateTimeTz).format("HH:mm") + : moment(`${isoDate} ${slot.time}`, ["YYYY-MM-DD h:mm A", "YYYY-MM-DD HH:mm"]).format("HH:mm"); + return slotStart ? !bookedTimes.has(slotStart) : true; + }) + .map((slot) => ({ + ...slot, + isoDate, + dayLabel: dateMoment.format("ddd"), + })); + + collectedSlots.push(...dailySlots); + } + + if (!cancelled) { + setPrivateSlots(collectedSlots.slice(0, PRIVATE_SLOT_LIMIT)); + } + + try { + const startIso = moment().format("YYYY-MM-DD"); + const endIso = moment().add(AVAILABILITY_WINDOW_DAYS, "days").format("YYYY-MM-DD"); + const lessonsResponse = await fetchAvailableLessons({ + token: authToken ?? "", + start_date: startIso, + end_date: endIso, + coach_id: Number(coach.id), + }); + const lessonData = Array.isArray(lessonsResponse?.data) ? lessonsResponse.data : []; + const groups = lessonData + .filter((lesson) => { + const typeLabel = (lesson.lesson_type_name ?? lesson.metadata?.title ?? "").toString().toLowerCase(); + return typeLabel.includes("group") || (lesson.player_limit ?? 1) > 1; + }) + .map((lesson) => ({ + id: lesson.id, + title: lesson.metadata?.title ?? lesson.metadata_title ?? lesson.lesson_type_name ?? "Group Class", + start: moment(lesson.start_date_time), + end: moment(lesson.end_date_time), + duration: lesson.end_date_time + ? `${moment(lesson.end_date_time).diff(moment(lesson.start_date_time), "minutes")} min` + : "60 min", + spotsRemaining: + Math.max((lesson.player_limit ?? 0) - (lesson.current_player_count ?? 0), 0) || null, + price: lesson.price_per_person ?? null, + location: lesson.location_name, + })); + if (!cancelled) { + setGroupClasses(groups); + } + } catch (err) { + if (!cancelled) { + setGroupClasses([]); + setError("Unable to load availability right now."); + } + } finally { + if (!cancelled) { + setLoadingSlots(false); + } + } + }; + + void loadAvailability(); + + return () => { + cancelled = true; + }; + }, [authToken, coach.id, hourlyRateLabel]); + + const buttonLabel = useMemo(() => { + if (!selection) return "Select a time to book"; + if (selection.type === "group") { + const pricePart = selection.lesson.price ? ` — $${selection.lesson.price}` : ""; + return `Book ${selection.lesson.title}${pricePart}`; + } + return `Book Private — ${selection.slot.dayLabel} at ${selection.slot.time} — ${selection.slot.price}`; + }, [selection]); + + const buttonTone = selection?.type === "group" ? "group" : selection ? "private" : "disabled"; + + const handleBook = useCallback(() => { + if (!selection) return; + const destination = `/coaches/${coach.slug || coach.id}`; + + if (selection.type === "group") { + navigate(`${destination}?lessonId=${selection.lesson.id}&type=group`, { + state: { + preselectedLesson: selection.lesson, + coach, + }, + }); + return; + } + + const start = selection.slot.scheduleMeta?.startDateTimeTz; + const end = selection.slot.scheduleMeta?.endDateTimeTz; + const query = [ + "bookingType=private", + start ? `start=${encodeURIComponent(start)}` : null, + end ? `end=${encodeURIComponent(end)}` : null, + ] + .filter(Boolean) + .join("&"); + + navigate(query ? `${destination}?${query}` : destination, { + state: { + preselectedSlot: selection.slot, + coach, + }, + }); + }, [coach, navigate, selection]); + + return ( +
    +
    +
    +
    {coach?.avatar ? : {coachName.slice(0, 1)}}
    +
    +

    My Coach

    +

    {coachName}

    +
    + + {locationLabel} +
    +
    +
    + {hourlyRateLabel ? {hourlyRateLabel} : null} +
    + + {upcomingLesson ? ( +
    +
    Upcoming lesson
    +
    + + {upcomingLesson.label} + {upcomingLesson.tone === "group" ? "Group" : "Private"} +
    +
    + ) : null} + +
    +
    +
    +
    Private lessons
    +

    Select a time to book quickly.

    +
    + {hourlyRateLabel ? {hourlyRateLabel} : null} +
    +
    + {loadingSlots && !privateSlots.length ? ( +
    + Loading times +
    + ) : null} + {privateSlots.slice(0, PRIVATE_SLOT_LIMIT).map((slot) => { + const isSelected = selection?.type === "private" && selection.slot.id === slot.id; + return ( + + ); + })} + {!loadingSlots && !privateSlots.length ? ( +

    No upcoming private slots in the next two weeks.

    + ) : null} +
    + + All times + +
    + + {groupClasses.length ? ( +
    +
    +
    +
    Group classes
    +

    Join a class with available spots.

    +
    +
    +
    + {groupClasses.map((lesson) => { + const isSelected = selection?.type === "group" && selection.lesson.id === lesson.id; + return ( + + ); + })} +
    + + All classes + +
    + ) : null} + + {error ?

    {error}

    : null} + + +
    + ); +}; + const PlayerCoachListPage = () => { const { user } = useAuth(); const playerToken = user?.session?.access_token ?? user?.access_token ?? user?.token ?? null; - const [activeTab, setActiveTab] = useState("all"); + const [activeTab, setActiveTab] = useState("my"); const [allCoachPlayers, setAllCoachPlayers] = useState([]); const [addedCoachPlayers, setAddedCoachPlayers] = useState([]); const [allCoachesPage, setAllCoachesPage] = useState(1); @@ -1930,13 +2375,27 @@ const PlayerCoachListPage = () => { const isActiveLoading = activeTab === "all" ? allMiniLoader : addedMiniLoader; const activeListEnd = activeTab === "all" ? isAllCoachesListEnd : isMyCoachesListEnd; const activeSentinelRef = activeTab === "all" ? allListSentinelRef : myListSentinelRef; + const pendingCoaches = useMemo( + () => addedCoachPlayers.filter((coach) => normalizeRosterStatus(coach) === "pending"), + [addedCoachPlayers], + ); + const confirmedMyCoaches = useMemo( + () => addedCoachPlayers.filter((coach) => normalizeRosterStatus(coach) !== "pending"), + [addedCoachPlayers], + ); const featuredCoaches = activeTab === "all" ? activeList.slice(0, 2) : []; - const remainingCoaches = activeTab === "all" ? activeList.slice(2) : activeList; + const remainingCoaches = activeTab === "all" ? activeList.slice(2) : confirmedMyCoaches; const resultsHeading = activeTab === "all" ? "All Coaches" : "My Coaches"; const resultsDescription = activeTab === "all" ? "Browse certified coaches tailored to your goals." - : "Coaches you have already connected with."; + : "Book time with coaches you already work with—no discovery required."; + const heroTitle = + activeTab === "my" ? "Book your next lesson in seconds" : "Find Your Perfect Coach"; + const heroSubtitle = + activeTab === "my" + ? "See real availability, pick a time, and confirm without leaving this page." + : "Get matched with certified tennis professionals in your area."; const showInitialLoader = isActiveLoading && !activeList.length; const showEmptyState = !isActiveLoading && !activeList.length; @@ -2030,174 +2489,259 @@ const PlayerCoachListPage = () => { return (
    -
    -
    -

    Player Experience

    -

    Find Your Perfect Coach

    -

    - Get matched with certified tennis professionals in your area. -

    -
    + {activeTab === "my" ? ( +
    +
    +

    My Coaches

    +

    Book your next lesson in seconds

    +

    + See real availability, pick a time, and confirm without leaving this page. +

    +
    + + +
    +
    +
    +

    Connected coaches

    +

    {confirmedMyCoaches.length}

    +
    +
    +

    Pending approvals

    +

    {pendingCoaches.length}

    +
    +
    +
    +
    +
    Lightning-fast booking
    +

    Tap a slot on a coach card to prefill the booking button instantly.

    +
    +
    + ) : ( +
    +
    +

    Player Experience

    +

    {heroTitle}

    +

    {heroSubtitle}

    +
    + + +
    +
    +
    +
    +
    Available Coaches
    +
    {heroStats.available.toLocaleString()}
    +
    +
    +
    Avg Rating
    +
    {heroStats.avgRating ?? "—"}
    +
    +
    +
    Avg Hourly Rate
    +
    {heroStats.avgHourlyRate ?? "—"}
    +
    +
    +
    Lessons Booked
    +
    {heroStats.lessons ?? "—"}
    +
    +
    +
    + )} + + {activeTab === "my" ? ( +
    +
    +
    + + + {nameDraft ? ( + + ) : null} + + +
    +

    Select a time on any card to activate the booking button.

    +
    + ) : ( +
    +
    +
    + + + {nameDraft ? ( + + ) : null} + +
    + + +
    -
    -
    -
    -
    Available Coaches
    -
    {heroStats.available.toLocaleString()}
    -
    -
    -
    Avg Rating
    -
    {heroStats.avgRating ?? "—"}
    -
    -
    -
    Avg Hourly Rate
    -
    {heroStats.avgHourlyRate ?? "—"}
    -
    -
    -
    Lessons Booked
    -
    {heroStats.lessons ?? "—"}
    -
    -
    -
    - -
    -
    - -
    - - - {nameDraft ? ( - - ) : null} - - -
    - - -
    -
    - - {dynamicFilterPills.some((pill) => pill.isActive) ? ( -
    - {dynamicFilterPills - .filter((pill) => pill.isActive) - .map((pill) => ( + {specialtyChips.map((chip) => { + const isSelected = specialtySelection.includes(chip.value); + return ( - ))} + ); + })}
    - ) : null} -
    + {dynamicFilterPills.some((pill) => pill.isActive) ? ( +
    + {dynamicFilterPills + .filter((pill) => pill.isActive) + .map((pill) => ( + + ))} +
    + ) : null} + + )}
    @@ -2279,27 +2823,83 @@ const PlayerCoachListPage = () => { ) : null} -
    -
    -

    {resultsHeading}

    -

    {resultsDescription}

    -
    -
    - {remainingCoaches.map((coach) => ( - - ))} -
    -
    - {isActiveLoading && activeList.length ? ( - - ) : null} - {activeListEnd ? End of results : null} -
    -
    + {activeTab === "my" ? ( +
    +
    +

    My Coaches

    +

    Tap a slot to lock in your next lesson.

    +
    +
    + {remainingCoaches.map((coach) => ( + + ))} +
    +
    + {isActiveLoading && activeList.length ? ( + + ) : null} + {activeListEnd ? End of results : null} +
    +
    + ) : ( +
    +
    +

    {resultsHeading}

    +

    {resultsDescription}

    +
    +
    + {remainingCoaches.map((coach) => ( + + ))} +
    +
    + {isActiveLoading && activeList.length ? ( + + ) : null} + {activeListEnd ? End of results : null} +
    +
    + )} + + {activeTab === "my" && pendingCoaches.length ? ( +
    +
    +

    Pending Approval

    +

    Requests that are awaiting coach confirmation.

    +
    +
      + {pendingCoaches.map((coach) => { + const initials = (coach.name ?? "Coach").slice(0, 2).toUpperCase(); + const requestDate = + coach.requested_at || coach.request_date || coach.created_at || coach.createdAt || null; + const requestLabel = requestDate + ? moment(requestDate).format("MMM D, YYYY") + : "Recently requested"; + return ( +
    • +
      +
      + {coach.avatar ? : {initials}} +
      +
      +

      {coach.name ?? "Coach"}

      +

      Awaiting approval · Requested {requestLabel}

      +
      +
      +
      + + +
      +
    • + ); + })} +
    +
    + ) : null} ) : null}