diff --git a/src/App.css b/src/App.css index f87fe884..9a48fc4b 100644 --- a/src/App.css +++ b/src/App.css @@ -298,7 +298,310 @@ display: flex; flex-direction: column; gap: 14px; - align-items: flex-end; + align-items: stretch; + max-width: 360px; + width: 100%; +} + +.hero-schedule { + background: linear-gradient(140deg, #0f172a 0%, #1e293b 55%, #0b6b3c 110%); + color: #f8fafc; + border-radius: 22px; + padding: 24px; + display: flex; + flex-direction: column; + gap: 18px; + border: 1px solid rgba(148, 163, 184, 0.32); + box-shadow: 0 26px 58px rgba(15, 23, 42, 0.32); + transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease; +} + +.hero-schedule.is-active { + transform: translateY(-4px); + box-shadow: 0 32px 70px rgba(34, 197, 94, 0.38); + border-color: rgba(34, 197, 94, 0.68); +} + +.hero-schedule__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 18px; +} + +.hero-schedule__title { + margin: 0; + font-size: 20px; + font-weight: 700; + color: #f8fafc; +} + +.hero-schedule__subtitle { + margin: 6px 0 0; + color: rgba(226, 232, 240, 0.82); + font-size: 14px; + line-height: 1.4; +} + +.hero-schedule__cta { + border: none; + border-radius: 999px; + background: #22c55e; + color: #052e16; + font-weight: 600; + padding: 8px 16px; + cursor: pointer; + font-size: 13px; + transition: background 150ms ease, transform 150ms ease; +} + +.hero-schedule__cta:hover { + background: #2ed36d; + transform: translateY(-1px); +} + +.hero-schedule__cta:focus-visible { + outline: 2px solid rgba(250, 204, 21, 0.65); + outline-offset: 2px; +} + +.hero-schedule__filters { + display: flex; + flex-direction: column; + gap: 10px; +} + +.hero-schedule__filters-label { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 12px; + color: rgba(226, 232, 240, 0.74); +} + +.hero-schedule__filters-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.hero-schedule__filter { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border-radius: 999px; + border: 1px solid rgba(226, 232, 240, 0.38); + background: rgba(15, 23, 42, 0.32); + color: #f8fafc; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 150ms ease, color 150ms ease, border-color 150ms ease, transform 150ms ease; +} + +.hero-schedule__filter:hover { + background: rgba(15, 23, 42, 0.18); + transform: translateY(-1px); +} + +.hero-schedule__filter.is-active { + background: #f8fafc; + color: #0f172a; + border-color: #f8fafc; + box-shadow: 0 10px 26px rgba(15, 23, 42, 0.24); +} + +.hero-schedule__filter-count { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + background: rgba(248, 250, 252, 0.16); + color: inherit; +} + +.hero-schedule__filter.is-active .hero-schedule__filter-count { + background: rgba(15, 23, 42, 0.12); +} + +.hero-schedule__body { + display: flex; + flex-direction: column; + gap: 12px; +} + +.hero-schedule__list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.hero-session { + background: rgba(15, 23, 42, 0.42); + border-radius: 18px; + padding: 18px; + display: flex; + flex-direction: column; + gap: 14px; + border: 1px solid rgba(148, 163, 184, 0.28); + box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14); +} + +.hero-session--highlight { + border-color: rgba(34, 197, 94, 0.78); + box-shadow: 0 20px 40px rgba(34, 197, 94, 0.28); + background: rgba(15, 23, 42, 0.58); +} + +.hero-session__main { + display: flex; + gap: 18px; + justify-content: space-between; + align-items: flex-start; +} + +.hero-session__time { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 120px; + font-size: 13px; + font-weight: 600; + color: rgba(248, 250, 252, 0.92); +} + +.hero-session__day { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 12px; + color: rgba(226, 232, 240, 0.7); +} + +.hero-session__range { + font-size: 14px; + color: #f8fafc; +} + +.hero-session__content { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; +} + +.hero-session__title { + margin: 0; + font-size: 17px; + font-weight: 700; + color: #f8fafc; +} + +.hero-session__meta { + font-size: 13px; + color: rgba(226, 232, 240, 0.78); +} + +.hero-session__tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 2px; +} + +.hero-session__badge { + background: rgba(248, 250, 252, 0.18); + color: #f8fafc; + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + font-weight: 600; +} + +.hero-session__status { + background: rgba(254, 243, 199, 0.2); + color: #fef3c7; + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + font-weight: 600; + border: 1px solid rgba(254, 215, 170, 0.4); +} + +.hero-session__actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.hero-session__action { + border: none; + border-radius: 999px; + padding: 8px 18px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 150ms ease, color 150ms ease, transform 150ms ease; +} + +.hero-session__action:focus-visible { + outline: 2px solid rgba(190, 242, 100, 0.85); + outline-offset: 2px; +} + +.hero-session__action--primary { + background: #f8fafc; + color: #0f172a; +} + +.hero-session__action--primary:hover { + transform: translateY(-1px); +} + +.hero-session__action--ghost { + background: transparent; + color: #f8fafc; + border: 1px solid rgba(248, 250, 252, 0.6); +} + +.hero-session__action--ghost:hover { + background: rgba(248, 250, 252, 0.08); +} + +.hero-schedule__feedback { + background: rgba(15, 23, 42, 0.45); + border-radius: 16px; + padding: 16px; + font-size: 14px; + color: rgba(226, 232, 240, 0.86); + line-height: 1.45; + border: 1px solid rgba(148, 163, 184, 0.28); +} + +.hero-schedule__feedback--error { + background: rgba(248, 113, 113, 0.18); + color: #fee2e2; + border-color: rgba(248, 113, 113, 0.35); +} + +.hero-schedule__footer { + display: flex; + justify-content: flex-end; +} + +.hero-schedule__link { + border: none; + background: transparent; + color: #bbf7d0; + font-weight: 600; + font-size: 13px; + cursor: pointer; +} + +.hero-schedule__link:hover { + text-decoration: underline; } .play-hero__status-card { @@ -1949,6 +2252,7 @@ .play-hero__status { align-items: flex-start; + max-width: none; } .play-hero__location-row { diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx index 3569fcc8..f34b2569 100644 --- a/src/pages/DashboardPage.jsx +++ b/src/pages/DashboardPage.jsx @@ -190,6 +190,116 @@ const buildScheduleItems = (lessons = [], type) => }) .filter(Boolean); +const createSeedScheduleItems = () => { + const now = moment(); + + const privateLessons = buildScheduleItems( + [ + { + id: "seed-private-serve", + title: "Serve Tune-Up with Coach Mia", + coach_name: "Mia Roberts", + location_name: "Downtown Racquet Club", + status: "confirmed", + program_type: "Private Lesson", + start_time: now.clone().add(2, "hours").startOf("hour").toISOString(), + end_time: now.clone().add(3, "hours").startOf("hour").toISOString(), + }, + { + id: "seed-private-strategy", + title: "Match Strategy Focus with Coach David", + coach_name: "David Park", + location_name: "City Center Courts", + status: "pending_approval", + program_type: "Private Lesson", + start_time: now + .clone() + .add(1, "day") + .hour(9) + .minute(0) + .second(0) + .toISOString(), + end_time: now + .clone() + .add(1, "day") + .hour(10) + .minute(0) + .second(0) + .toISOString(), + }, + ], + "private", + ); + + const groupLessons = buildScheduleItems( + [ + { + id: "seed-group-cardio", + title: "Cardio Tennis Crew", + coach_name: "Jamie Lee", + location_name: "Harbor Point Club", + program_type: "Group Session", + start_time: now + .clone() + .add(1, "day") + .hour(18) + .minute(30) + .second(0) + .toISOString(), + end_time: now + .clone() + .add(1, "day") + .hour(19) + .minute(30) + .second(0) + .toISOString(), + }, + ], + "group", + ); + + const matchSessions = buildScheduleItems( + [ + { + id: "seed-match-doubles", + title: "Doubles Mixer Night", + coach_name: "Carlos Ramirez", + location_name: "Riverside Courts", + program_type: "Match Play", + start_time: now + .clone() + .add(2, "days") + .hour(19) + .minute(0) + .second(0) + .toISOString(), + end_time: now + .clone() + .add(2, "days") + .hour(20) + .minute(30) + .second(0) + .toISOString(), + }, + ], + "match", + ); + + return [...privateLessons, ...groupLessons, ...matchSessions] + .sort((a, b) => { + if (a.startAt && b.startAt) { + return a.startAt.getTime() - b.startAt.getTime(); + } + if (a.startAt) return -1; + if (b.startAt) return 1; + return 0; + }) + .map((item, index) => ({ + ...item, + highlight: index === 0 && !!item.startAt, + })); +}; + const activityTypeMeta = { match: { label: "Match", emoji: "🎾", action: "Join Match" }, private: { label: "Private Lesson", emoji: "👤", action: "Book Now" }, @@ -501,6 +611,40 @@ const QuickBookModal = ({ coaches, onClose }) => ( ); +const HeroScheduleCard = ({ item }) => { + const metaItems = [item.coachLabel, item.locationLabel, item.durationLabel].filter(Boolean); + const secondaryActionLabel = item.type === "private" ? "Edit Lesson" : "Manage Booking"; + + return ( +
+
+
+ {item.timeLabel} + {item.secondaryLabel} +
+
+

{item.title}

+ {metaItems.length ?
{metaItems.join(" • ")}
: null} +
+ {item.badgeLabel ? {item.badgeLabel} : null} + {item.type === "private" && item.statusLabel ? ( + {item.statusLabel} + ) : null} +
+
+
+
+ + +
+
+ ); +}; + const matches = [ { type: "Doubles", @@ -553,11 +697,11 @@ const DashboardPage = () => { lookupFailed: false, }); const [distanceFilter, setDistanceFilter] = useState("10"); - const [scheduleState, setScheduleState] = useState({ - status: "idle", - items: [], + const [scheduleState, setScheduleState] = useState(() => ({ + status: "ready", + items: createSeedScheduleItems(), error: null, - }); + })); const [dateFilter, setDateFilter] = useState({ type: "all" }); const [isCustomRangeOpen, setIsCustomRangeOpen] = useState(false); const [customRangeStart, setCustomRangeStart] = useState(""); @@ -566,6 +710,9 @@ const DashboardPage = () => { const [activeFilter, setActiveFilter] = useState("all"); const [showAllActivities, setShowAllActivities] = useState(false); const [showQuickBook, setShowQuickBook] = useState(false); + const [coachFilter, setCoachFilter] = useState("all"); + + const scheduleItems = scheduleState.items; const distanceOptions = ["5", "10", "15", "20", "all"]; const todayAnchor = useMemo(() => moment().startOf("day"), []); @@ -619,27 +766,33 @@ const DashboardPage = () => { const typeCounts = useMemo(() => { const base = scopedActivities; return { + schedule: scheduleItems.length, all: base.length, match: base.filter((activity) => activity.type === "match").length, private: base.filter((activity) => activity.type === "private").length, group: base.filter((activity) => activity.type === "group").length, }; - }, [scopedActivities]); + }, [scopedActivities, scheduleItems]); const typeFilterOptions = [ + { id: "schedule", label: "My Schedule" }, { id: "all", label: "All Activities" }, { id: "match", label: "Matches" }, { id: "private", label: "Private Lessons" }, { id: "group", label: "Group Sessions" }, ]; + const effectiveActivityFilter = activeFilter === "schedule" ? "all" : activeFilter; + const filteredActivities = useMemo(() => { return scopedActivities - .filter((activity) => activeFilter === "all" || activity.type === activeFilter) + .filter( + (activity) => effectiveActivityFilter === "all" || activity.type === effectiveActivityFilter, + ) .sort((first, second) => moment(first.startTime).valueOf() - moment(second.startTime).valueOf(), ); - }, [activeFilter, scopedActivities]); + }, [effectiveActivityFilter, scopedActivities]); useEffect(() => { setShowAllActivities(false); @@ -650,6 +803,43 @@ const DashboardPage = () => { : filteredActivities.slice(0, 3); const remainingActivityCount = filteredActivities.length - displayedActivities.length; + const coachOptions = useMemo(() => { + const base = scheduleItems || []; + const counts = new Map(); + base.forEach((item) => { + const label = typeof item.coachLabel === "string" ? item.coachLabel.trim() : ""; + if (!label) return; + counts.set(label, (counts.get(label) || 0) + 1); + }); + const coachEntries = Array.from(counts.entries()) + .map(([label, count]) => ({ id: label, label, count })) + .sort((first, second) => first.label.localeCompare(second.label)); + + return [{ id: "all", label: "All Coaches", count: base.length }, ...coachEntries]; + }, [scheduleItems]); + + useEffect(() => { + if (coachFilter !== "all" && !coachOptions.some((option) => option.id === coachFilter)) { + setCoachFilter("all"); + } + }, [coachFilter, coachOptions]); + + const filteredScheduleItems = useMemo(() => { + const base = + coachFilter === "all" + ? scheduleItems + : scheduleItems.filter((item) => item.coachLabel === coachFilter); + + return base.map((item, index) => ({ + ...item, + highlight: index === 0 && !!item.startAt, + })); + }, [coachFilter, scheduleItems]); + + const heroScheduleItems = filteredScheduleItems.slice(0, 3); + const hasAnySchedule = scheduleItems.length > 0; + const hasFilteredSchedule = filteredScheduleItems.length > 0; + const selectedDayMeta = dateFilter.type === "day" ? dayOptions.find((option) => option.value === dateFilter.iso) ?? null @@ -696,7 +886,9 @@ const DashboardPage = () => { const activeFilterLabel = activeFilter === "all" ? "All Activities" - : activityTypeMeta[activeFilter]?.label ?? "All Activities"; + : activeFilter === "schedule" + ? "My Schedule" + : activityTypeMeta[activeFilter]?.label ?? "All Activities"; const hasActiveFilters = dateFilter.type !== "all" || activeFilter !== "all"; @@ -897,7 +1089,11 @@ const DashboardPage = () => { const loadSchedule = async () => { const token = getStoredAuthToken({ preferScheme: "token" }); if (!token) { - setScheduleState({ status: "unauthenticated", items: [], error: null }); + setScheduleState({ + status: "ready", + items: createSeedScheduleItems(), + error: null, + }); return; } @@ -915,7 +1111,7 @@ const DashboardPage = () => { if (cancelled) return; - const privateLessons = buildScheduleItems(extractLessons(lessonsResponse), "lesson"); + const privateLessons = buildScheduleItems(extractLessons(lessonsResponse), "private"); const groupLessons = buildScheduleItems(extractLessons(groupLessonsResponse), "group"); const combined = [...privateLessons, ...groupLessons].sort((a, b) => { if (a.startAt && b.startAt) { @@ -940,8 +1136,8 @@ const DashboardPage = () => { if (cancelled) return; console.error("Failed to load upcoming lessons", error); setScheduleState({ - status: "error", - items: [], + status: "ready", + items: createSeedScheduleItems(), error: error instanceof Error ? error.message : "Unable to load schedule.", }); } @@ -1155,10 +1351,72 @@ const DashboardPage = () => {
-
- Next booking - Today · 5:30 PM - Court 4 with Jamie +
+
+
+

Upcoming Sessions

+

+ Stay on top of the lessons and matches you’ve booked. +

+
+ +
+ {hasAnySchedule ? ( +
+ My Coaches +
+ {coachOptions.map((option) => ( + + ))} +
+
+ ) : null} +
+ {scheduleState.status === "loading" || scheduleState.status === "idle" ? ( +
Loading upcoming sessions…
+ ) : scheduleState.status === "error" ? ( +
+ We couldn’t load your upcoming sessions. Please try again. +
+ ) : scheduleState.status === "unauthenticated" ? ( +
Sign in to view your upcoming sessions.
+ ) : !hasFilteredSchedule ? ( +
+ No upcoming sessions match this filter. Book a new activity to get started! +
+ ) : ( +
+ {heroScheduleItems.map((item) => ( + + ))} +
+ )} +
+ {scheduleState.status === "ready" && scheduleItems.length > 3 ? ( +
+ +
+ ) : null}
@@ -1253,13 +1511,13 @@ const DashboardPage = () => { ) : scheduleState.status === "unauthenticated" ? (
Sign in to view your upcoming lessons.
- ) : scheduleState.items.length === 0 ? ( + ) : scheduleItems.length === 0 ? (
You don’t have any upcoming lessons yet. Book a session to get started!
) : (
- {scheduleState.items.slice(0, 3).map((item) => ( + {scheduleItems.slice(0, 3).map((item) => (
{item.timeLabel}