diff --git a/.env b/.env index ef21de0..f5afc7d 100644 --- a/.env +++ b/.env @@ -1 +1,3 @@ -VITE_API_URL=http://localhost:3000/api \ No newline at end of file +VITE_API_URL=http://localhost:3000/api +# VITE_API_URL=https://ttp-api.codemymobile.com/api +VITE_GOOGLE_API_KEY=AIzaSyAkoVxf_uqgIUTm0wJUWXqNyx4qRxe9534 \ No newline at end of file diff --git a/src/TennisMatchApp.jsx b/src/TennisMatchApp.jsx index 285344e..c118375 100644 --- a/src/TennisMatchApp.jsx +++ b/src/TennisMatchApp.jsx @@ -2,10 +2,10 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from "react" import { useLocation, useNavigate } from "react-router-dom"; import { listMatches, + listAttentionMatches, createMatch, updateMatch, cancelMatch, - joinMatch, leaveMatch, removeParticipant, searchPlayers, @@ -20,12 +20,9 @@ import NotificationsFeed, { buildNotificationPresentation, buildInviteNotification, } from "./components/NotificationsFeed"; -import ActivityFeed from "./components/ActivityFeed"; import { getInviteByToken, listInvites, - acceptInvite, - rejectInvite, } from "./services/invites"; import { login, signup, forgotPassword, getPersonalDetails } from "./services/auth"; import { @@ -50,7 +47,6 @@ import { Menu, Bell, BellRing, - Star, TrendingUp, Award, Edit3, @@ -76,13 +72,19 @@ import { Trophy, Sparkles, Target, + RefreshCw, + Lock, + Globe2, } from "lucide-react"; import Autocomplete from "react-google-autocomplete"; import AppHeader from "./components/AppHeader"; import InviteScreen from "./components/InviteScreen"; import MatchDetailsModal from "./components/MatchDetailsModal"; +import MatchCreatorFlow from "./components/MatchCreatorFlow"; import LandingPage from "./pages/LandingPage.jsx"; import PlayerConnectionsPage from "./pages/PlayerConnectionsPage.jsx"; +import MyGroupsPage from "./pages/MyGroupsPage.jsx"; +import GroupDetailPage from "./pages/GroupDetailPage.jsx"; import { formatPhoneNumber, normalizePhoneValue, @@ -96,7 +98,6 @@ import { } from "./utils/archive"; import { countUniqueMatchOccupants, - getParticipantPhone, idsMatch, pruneParticipantFromMatchData, uniqueAcceptedInvitees, @@ -105,16 +106,14 @@ import { } from "./utils/participants"; import { collectMemberIds, - collectMatchHostIds, memberIsMatchHost, memberMatchesAnyId, memberMatchesInvite, memberMatchesParticipant, } from "./utils/memberIdentity"; -import { getMatchPrivacy, isOpenMatch } from "./utils/matchPrivacy"; +import { getMatchPrivacy } from "./utils/matchPrivacy"; import { deriveListingVisibility, - isLinkOnlyVisibility, normalizeListingVisibility, } from "./utils/listingVisibility"; import { getAvatarInitials, getAvatarUrlFromPlayer } from "./utils/avatar"; @@ -127,6 +126,27 @@ import { const DEFAULT_SKILL_LEVEL = "2.5 - Beginner"; +const NTRP_LEVELS = ["2.5", "3.0", "3.5", "4.0", "4.5+"]; +const DISCOVERY_SCOPE_FILTERS = [ + { id: "all", label: "All matches" }, + { id: "my", label: "My matches" }, + { id: "discover", label: "Discover" }, +]; +const DISCOVERY_FORMAT_FILTERS = [ + "Any", + "Singles", + "Doubles", + "Round Robin", + "Dingles", + "Other", +]; +const DISCOVERY_GENDER_FILTERS = ["Any", "Men's", "Women's", "Mixed"]; +const FALLBACK_DAY_MATCH_COUNTS = { + today: "today", + tomorrow: "tomorrow", + weekend: "weekend", +}; + const matchFormatOptions = [ "Singles", "Doubles", @@ -151,6 +171,8 @@ const getInitialPath = () => { const deriveScreenFromPath = (path) => { if (path === "/invites") return "invites"; if (path === "/players") return "players"; + if (path === "/groups") return "groups"; + if (/^\/groups\/[^/]+$/.test(path)) return "group-detail"; if (/^\/matches\/[^/]+\/invite$/.test(path)) return "invite"; return "browse"; }; @@ -162,6 +184,180 @@ const deriveInviteMatchId = (path) => { return Number.isFinite(numeric) ? numeric : null; }; +const deriveGroupIdFromPath = (path) => { + const match = path.match(/^\/groups\/([^/]+)$/); + return match ? decodeURIComponent(match[1]) : null; +}; + +const normalizeNtrpLevel = (value) => { + if (value === undefined || value === null) return ""; + const trimmed = String(value).trim(); + if (!trimmed) return ""; + if (trimmed === "4.5") return "4.5+"; + return trimmed.replace(/\s*-\s*.*$/, ""); +}; + +const pickMatchSkillRange = (match = {}) => { + const min = normalizeNtrpLevel( + match.skill_level_min ?? + match.skillLevelMin ?? + match.levelMin ?? + match.skill_level ?? + match.skillLevel, + ); + const max = normalizeNtrpLevel( + match.skill_level_max ?? + match.skillLevelMax ?? + match.levelMax ?? + match.skill_level ?? + match.skillLevel, + ); + return { + min, + max: max || min, + }; +}; + +const formatSkillRange = (match = {}) => { + const { min, max } = pickMatchSkillRange(match); + if (min && max && min !== max) return `${min} - ${max}`; + return min || max || ""; +}; + +const getMatchWhenParam = (selectedDayKey) => { + if (!selectedDayKey) return "upcoming"; + return selectedDayKey; +}; + +const getMatchLevelParam = (selectedLevelFilter) => { + if (!selectedLevelFilter || selectedLevelFilter === "Any") return undefined; + return selectedLevelFilter === "4.5+" ? "4.5" : selectedLevelFilter; +}; + +const pickMatchGender = (match = {}) => + ( + match.gender ?? + match.category ?? + match.match_gender ?? + match.matchGender ?? + "Any" + ) + .toString() + .trim() || "Any"; + +const pickMatchBalls = (match = {}) => + ( + match.balls ?? + match.ball_policy ?? + match.ballPolicy ?? + match.balls_policy ?? + match.ballsPolicy ?? + "" + ) + .toString() + .trim(); + +const matchRequiresVerifiedRating = (match = {}) => + Boolean( + match.verifiedOnly ?? + match.verified_only ?? + match.require_verified_rating ?? + match.requireVerifiedRating ?? + match.verified_rating_required ?? + match.verifiedRatingRequired, + ); + +const formatDayKey = (date) => { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) return ""; + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart( + 2, + "0", + )}-${String(date.getDate()).padStart(2, "0")}`; +}; + +const getMatchStartDate = (match = {}) => { + const value = + match.dateTime ?? + match.start_date_time ?? + match.startDateTime ?? + match.startsAt; + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +}; + +const buildDayStripOptions = () => { + const today = new Date(); + return Array.from({ length: 7 }, (_, index) => { + const date = new Date(today); + date.setDate(today.getDate() + index); + const fallbackCountKey = + index === 0 + ? FALLBACK_DAY_MATCH_COUNTS.today + : index === 1 + ? FALLBACK_DAY_MATCH_COUNTS.tomorrow + : index === 2 || index === 3 + ? FALLBACK_DAY_MATCH_COUNTS.weekend + : null; + return { + key: formatDayKey(date), + fallbackCountKey, + eyebrow: + index === 0 + ? "Today" + : index === 1 + ? "Tomorrow" + : date.toLocaleDateString("en-US", { weekday: "short" }), + label: date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }), + }; + }); +}; + +const formatMatchTimeLabel = (match = {}) => { + const date = getMatchStartDate(match); + if (!date) return ""; + return date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + }); +}; + +const formatMatchDayHeading = (match = {}) => { + const date = getMatchStartDate(match); + if (!date) return { dayLabel: "DATE TBA", dateLabel: "" }; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const target = new Date(date); + target.setHours(0, 0, 0, 0); + const diffDays = Math.round((target - today) / (24 * 60 * 60 * 1000)); + const dayLabel = + diffDays === 0 + ? "TODAY" + : diffDays === 1 + ? "TOMORROW" + : date + .toLocaleDateString("en-US", { weekday: "long" }) + .toUpperCase(); + + return { + dayLabel, + dateLabel: date.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }), + }; +}; + +const formatDistanceLabel = (distanceMiles) => { + if (!Number.isFinite(distanceMiles)) return ""; + return `${Number.isInteger(distanceMiles) ? distanceMiles : distanceMiles.toFixed(1)} mi`; +}; + const buildMapsUrl = (lat, lng, address) => { if (lat && lng) { return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`; @@ -710,10 +906,15 @@ const TennisMatchApp = () => { const [currentScreen, setCurrentScreen] = useState(() => deriveScreenFromPath(initialPath), ); - const [activeFilter, setActiveFilter] = useState("my"); + const [activeFilter, setActiveFilter] = useState("all"); + const [selectedDayKey, setSelectedDayKey] = useState(""); + const [selectedLevelFilter, setSelectedLevelFilter] = useState("Any"); + const [selectedFormatFilter, setSelectedFormatFilter] = useState("Any"); + const [selectedGenderFilter, setSelectedGenderFilter] = useState("Any"); const [showSignInModal, setShowSignInModal] = useState(false); const [showToast, setShowToast] = useState(null); const [showProfileManager, setShowProfileManager] = useState(false); + const [profileManagerSection, setProfileManagerSection] = useState("profile"); const [createStep, setCreateStep] = useState(1); const [showPreview, setShowPreview] = useState(false); const [selectedPlayers, setSelectedPlayers] = useState(new Map()); @@ -725,7 +926,6 @@ const TennisMatchApp = () => { const [showEditModal, setShowEditModal] = useState(false); const [showParticipantsModal, setShowParticipantsModal] = useState(false); const [participantsMatchId, setParticipantsMatchId] = useState(null); - const [showMatchMenu, setShowMatchMenu] = useState(null); const [signInStep, setSignInStep] = useState("initial"); const [password, setPassword] = useState(""); const [formData, setFormData] = useState({ @@ -755,6 +955,7 @@ const TennisMatchApp = () => { }); const [matches, setMatches] = useState([]); + const [attentionMatches, setAttentionMatches] = useState([]); const [matchCounts, setMatchCounts] = useState({ my: 0, open: 0, @@ -764,7 +965,7 @@ const TennisMatchApp = () => { draft: 0, archived: 0, }); - const [matchPagination, setMatchPagination] = useState(null); + const [, setMatchPagination] = useState(null); const [matchPage, setMatchPage] = useState(1); const [matchSearch, setMatchSearch] = useState(""); // Track players already part of the match (participants or previously invited) @@ -798,12 +999,11 @@ const TennisMatchApp = () => { }); const [notificationsSupported, setNotificationsSupported] = useState(true); const [lastSeenNotificationAt, setLastSeenNotificationAt] = useState(null); - const [invitesLoading, setInvitesLoading] = useState(false); - const [invitesError, setInvitesError] = useState(""); - const [homeFeedNotifications, setHomeFeedNotifications] = useState([]); - const [homeFeedLoading, setHomeFeedLoading] = useState(false); - const [homeFeedError, setHomeFeedError] = useState(""); - const [sharingMatchIds, setSharingMatchIds] = useState(() => new Set()); + const [, setInvitesLoading] = useState(false); + const [, setInvitesError] = useState(""); + const [, setHomeFeedNotifications] = useState([]); + const [, setHomeFeedLoading] = useState(false); + const [, setHomeFeedError] = useState(""); const [locationFilter, setLocationFilter] = useState(() => { if (typeof window === "undefined") return null; try { @@ -819,13 +1019,7 @@ const TennisMatchApp = () => { const stored = Number(window.localStorage.getItem("matchDistanceFilter")); return Number.isFinite(stored) && stored > 0 ? stored : 5; }); - const [showLocationPicker, setShowLocationPicker] = useState(false); - const [isDetectingLocation, setIsDetectingLocation] = useState(false); - const [geoError, setGeoError] = useState(""); - const [locationSearchTerm, setLocationSearchTerm] = useState( - () => locationFilter?.label || "", - ); - + const [recentLocations, setRecentLocations] = useState(() => loadStoredLocations()); const totalSelectedInvitees = useMemo(() => { const normalizedExistingIds = existingPlayerIds instanceof Set @@ -981,8 +1175,24 @@ const TennisMatchApp = () => { }, [distanceFilter]); useEffect(() => { - setLocationSearchTerm(locationFilter?.label || ""); - }, [locationFilter]); + const syncRecentLocations = () => { + setRecentLocations(loadStoredLocations()); + }; + + syncRecentLocations(); + + if (typeof window === "undefined") { + return undefined; + } + + window.addEventListener("storage", syncRecentLocations); + window.addEventListener(RECENT_LOCATIONS_EVENT, syncRecentLocations); + + return () => { + window.removeEventListener("storage", syncRecentLocations); + window.removeEventListener(RECENT_LOCATIONS_EVENT, syncRecentLocations); + }; + }, []); useEffect(() => { const storedUser = localStorage.getItem("user"); @@ -1395,13 +1605,9 @@ const TennisMatchApp = () => { const detectCurrentLocation = useCallback(() => { if (typeof navigator === "undefined" || !navigator.geolocation) { - setGeoError("Geolocation is not supported in this browser."); return; } - setIsDetectingLocation(true); - setGeoError(""); - navigator.geolocation.getCurrentPosition( (position) => { const { latitude, longitude } = position?.coords || {}; @@ -1416,38 +1622,9 @@ const TennisMatchApp = () => { lat: latitude, lng: longitude, }); - setShowLocationPicker(false); - } else { - setGeoError("Could not determine your location. Try searching manually."); } - setIsDetectingLocation(false); - }, - (error) => { - const defaultMessage = - "We couldn't access your location. Please enable location permissions or search manually."; - const messageFromCode = (() => { - if (!error || typeof error !== "object") return ""; - const numericCode = Number.isFinite(error.code) - ? error.code - : Number.parseInt(error.code, 10); - switch (numericCode) { - case 1: - return "Please enable location permissions for your browser to use this feature."; - case 2: - return "We couldn't determine your location right now. Please try again or search manually."; - case 3: - return "Locating timed out. Try again or search for a location manually."; - default: - return ""; - } - })(); - const fallbackMessage = - typeof error?.message === "string" && error.message.trim() - ? error.message.trim() - : ""; - setGeoError(messageFromCode || fallbackMessage || defaultMessage); - setIsDetectingLocation(false); }, + () => {}, { enableHighAccuracy: true, timeout: 10000, @@ -1469,6 +1646,16 @@ const TennisMatchApp = () => { detectCurrentLocation(); }, [currentUser, detectCurrentLocation, locationFilter]); + const handleUseBrowseLocation = useCallback((entry) => { + if (!entry?.label) return; + setLocationFilter({ + label: entry.label, + lat: typeof entry.latitude === "number" ? entry.latitude : null, + lng: typeof entry.longitude === "number" ? entry.longitude : null, + }); + setMatchPage(1); + }, []); + // Match loading helpers const fetchMatches = useCallback(async () => { if (!currentUser) { @@ -1480,6 +1667,8 @@ const TennisMatchApp = () => { try { const apiFilter = (() => { + if (activeFilter === "all") return undefined; + if (activeFilter === "discover") return "open"; if (activeFilter === "draft") return "my"; if (activeFilter === "archived") return ARCHIVE_FILTER_VALUE; return activeFilter; @@ -1507,11 +1696,25 @@ const TennisMatchApp = () => { : {}; const includeHidden = apiFilter === "my" || activeFilter === "archived" || activeFilter === "draft"; + const when = getMatchWhenParam(selectedDayKey); + const level = getMatchLevelParam(selectedLevelFilter); + const format = + selectedFormatFilter && selectedFormatFilter !== "Any" + ? selectedFormatFilter + : undefined; + const gender = + selectedGenderFilter && selectedGenderFilter !== "Any" + ? selectedGenderFilter + : undefined; const data = await listMatches(apiFilter, { status, search: matchSearch, page: matchPage, perPage: 10, + when, + level, + format, + gender, includeHidden, ...locationParams, }); @@ -1817,6 +2020,28 @@ const TennisMatchApp = () => { rosterSpotsRemaining > 0 && (m.status || "upcoming")?.toString().toLowerCase() === "upcoming"; + const hostParticipant = activeParticipants.find((participant) => { + if (typeof participant?.status === "string") { + const status = participant.status.trim().toLowerCase(); + if (status === "hosting" || status === "host") return true; + } + return memberIsMatchHost(participant?.profile, m); + }); + const hostProfile = + m.host || + m.host_profile || + m.hostProfile || + hostParticipant?.profile || + {}; + const hostName = + hostProfile.full_name || + hostProfile.fullName || + hostProfile.name || + m.host_name || + m.hostName || + "Host"; + const skillRange = pickMatchSkillRange(m); + return { id: matchId, type: isHost ? "hosted" : isJoined ? "joined" : "available", @@ -1844,12 +2069,25 @@ const TennisMatchApp = () => { m.distanceMiles ?? m.distance_miles ?? m.distance; - const numeric = - typeof raw === "string" ? Number.parseFloat(raw) : raw; - return Number.isFinite(numeric) ? numeric : null; - })(), + const numeric = + typeof raw === "string" ? Number.parseFloat(raw) : raw; + return Number.isFinite(numeric) ? numeric : null; + })(), format: m.match_format, - skillLevel: m.skill_level_min, + skillLevel: formatSkillRange(m), + skillLevelMin: skillRange.min, + skillLevelMax: skillRange.max, + gender: pickMatchGender(m), + balls: pickMatchBalls(m), + verifiedOnly: matchRequiresVerifiedRating(m), + hostName, + hostProfile, + hostNtrp: + hostProfile.usta_rating || + hostProfile.uta_rating || + hostProfile.ntrp || + hostProfile.rating || + "", notes: m.notes, listingVisibility, isLinkOnly, @@ -1936,8 +2174,181 @@ const TennisMatchApp = () => { matchSearch, memberIdentityIds, currentUser, + selectedDayKey, + selectedFormatFilter, + selectedGenderFilter, + selectedLevelFilter, ]); + const fetchAttentionMatches = useCallback(async () => { + if (!currentUser) { + setAttentionMatches([]); + return; + } + + try { + let data; + try { + data = await listAttentionMatches({ + limit: 3, + withinHours: 48, + }); + } catch (error) { + const status = Number(error?.status ?? error?.response?.status); + if (status && ![404, 405].includes(status)) { + throw error; + } + data = await listMatches("my", { + perPage: 50, + includeHidden: true, + }); + } + const attentionApiItems = Array.isArray(data?.items) ? data.items : null; + const rawMatches = attentionApiItems + ? attentionApiItems.map((item) => ({ + ...(item?.match || {}), + alerts: { + lowOccupancy: { + active: true, + spotsNeeded: item?.alert?.spotsNeeded, + rosterCount: item?.alert?.confirmed, + playerLimit: item?.alert?.limit, + hoursUntilStart: item?.alert?.hoursUntilStart, + startTime: item?.match?.start_date_time || null, + }, + }, + })) + : Array.isArray(data?.matches) + ? data.matches + : []; + const now = Date.now(); + const memberIds = memberIdentityIds; + + const attentionItems = rawMatches + .map((match) => { + const isHost = memberIsMatchHost(currentUser, match, memberIds); + const status = (match?.status || "upcoming") + .toString() + .trim() + .toLowerCase(); + const startDate = match?.start_date_time + ? new Date(match.start_date_time) + : null; + const startTimestamp = + startDate && !Number.isNaN(startDate.getTime()) + ? startDate.getTime() + : null; + const hoursUntilStartRaw = + startTimestamp !== null + ? (startTimestamp - now) / (1000 * 60 * 60) + : null; + const isUpcomingSoon = + hoursUntilStartRaw !== null && + hoursUntilStartRaw >= 0 && + hoursUntilStartRaw <= 48; + + if ( + !isHost || + !["upcoming", "open"].includes(status) || + !isUpcomingSoon + ) { + return null; + } + + const capacityInfo = + match && typeof match.capacity === "object" ? match.capacity : {}; + const limitFromCapacity = Number( + capacityInfo.limit ?? capacityInfo.max ?? capacityInfo.capacity, + ); + const confirmedFromCapacity = Number( + capacityInfo.confirmed ?? capacityInfo.players, + ); + const openFromCapacity = Number(capacityInfo.open); + const activeParticipants = uniqueActiveParticipants(match.participants); + const playerLimit = Number.isFinite(limitFromCapacity) && limitFromCapacity > 0 + ? limitFromCapacity + : (() => { + const raw = match.player_limit ?? match.playerLimit; + const numeric = + typeof raw === "string" ? Number.parseInt(raw, 10) : Number(raw); + return Number.isFinite(numeric) && numeric > 0 ? numeric : null; + })(); + const rosterCount = + Number.isFinite(confirmedFromCapacity) && confirmedFromCapacity >= 0 + ? confirmedFromCapacity + : activeParticipants.length; + const alertInfo = match?.alerts?.lowOccupancy || {}; + const openFromAlert = Number(alertInfo.spotsNeeded); + const spotsNeeded = + Number.isFinite(openFromAlert) && openFromAlert >= 0 + ? openFromAlert + : Number.isFinite(openFromCapacity) && openFromCapacity >= 0 + ? openFromCapacity + : playerLimit !== null + ? Math.max(playerLimit - rosterCount, 0) + : 0; + + if (spotsNeeded <= 0) { + return null; + } + + const skillRange = pickMatchSkillRange(match); + const matchId = match.match_id || match.id; + + return { + id: matchId, + type: "hosted", + status: match.status || "upcoming", + privacy: getMatchPrivacy(match), + dateTime: match.start_date_time, + location: match.location_text, + format: match.match_format, + skillLevel: formatSkillRange(match), + skillLevelMin: skillRange.min, + skillLevelMax: skillRange.max, + gender: pickMatchGender(match), + balls: pickMatchBalls(match), + verifiedOnly: matchRequiresVerifiedRating(match), + notes: match.notes, + invitees: match.invitees || [], + participants: match.participants || [], + playerLimit, + occupied: rosterCount, + rosterCount, + rosterSpotsRemaining: spotsNeeded, + spotsAvailable: spotsNeeded, + capacity: capacityInfo, + alerts: { + lowOccupancy: { + active: true, + spotsNeeded, + rosterCount: alertInfo.rosterCount ?? rosterCount, + playerLimit: alertInfo.playerLimit ?? playerLimit, + hoursUntilStart: + alertInfo.hoursUntilStart ?? (hoursUntilStartRaw !== null + ? Math.max(Math.round(hoursUntilStartRaw * 10) / 10, 0) + : null), + startTime: alertInfo.startTime || (startDate ? startDate.toISOString() : null), + }, + }, + }; + }) + .filter(Boolean) + .sort((a, b) => { + const aTime = new Date(a.dateTime).getTime(); + const bTime = new Date(b.dateTime).getTime(); + return (Number.isFinite(aTime) ? aTime : Infinity) - + (Number.isFinite(bTime) ? bTime : Infinity); + }) + .slice(0, 3); + + setAttentionMatches(attentionItems); + } catch (error) { + console.error("Failed to load attention matches", error); + setAttentionMatches([]); + } + }, [currentUser, memberIdentityIds]); + const deriveInviteStatus = useCallback((invite = {}) => { if (invite?.accepted) return "accepted"; if (invite?.rejected) return "rejected"; @@ -2218,6 +2629,17 @@ const TennisMatchApp = () => { notificationSummaryRetryAtRef.current = 0; setNotificationsSupported(true); setHomeFeedError(""); + } else if (statusCode === 404) { + const fallbackResult = await loadInviteSummary(); + if (!fallbackResult.success) { + setHomeFeedNotifications([]); + setHomeFeedError(""); + } else { + setHomeFeedError(""); + } + notificationSummaryErrorLoggedRef.current = false; + setNotificationsSupported(false); + notificationSummaryRetryAtRef.current = Date.now() + 60 * 60 * 1000; } else { const fallbackResult = await loadInviteSummary(); if (!fallbackResult.success) { @@ -2249,57 +2671,25 @@ const TennisMatchApp = () => { const refreshMatchesAndInvites = useCallback(async () => { await Promise.all([ fetchMatches(), + fetchAttentionMatches(), fetchPendingInvites(), fetchNotificationSummary(), ]); - }, [fetchMatches, fetchPendingInvites, fetchNotificationSummary]); - - const respondToInvite = useCallback( - async (token, action) => { - if (!token) return; - try { - if (action === "accept") { - await acceptInvite(token); - displayToast("Invite accepted! See you on the court. 🎾"); - } else { - await rejectInvite(token); - displayToast("Invite declined", "info"); - } - fetchPendingInvites(); - fetchMatches(); - fetchNotificationSummary(); - } catch (err) { - const errorCode = err?.response?.data?.error || err?.data?.error; - if (isMatchArchivedError(err) || errorCode === MATCH_ARCHIVED_ERROR) { - displayToast( - "This match has been archived. Invites can no longer be updated.", - "error", - ); - fetchPendingInvites(); - fetchMatches(); - fetchNotificationSummary(); - } else { - displayToast( - err?.response?.data?.message || - err?.message || - "Failed to update invite", - "error", - ); - } - } - }, - [ - displayToast, - fetchMatches, - fetchPendingInvites, - fetchNotificationSummary, - ], - ); + }, [ + fetchMatches, + fetchAttentionMatches, + fetchPendingInvites, + fetchNotificationSummary, + ]); useEffect(() => { fetchMatches(); }, [fetchMatches]); + useEffect(() => { + fetchAttentionMatches(); + }, [fetchAttentionMatches]); + useEffect(() => { fetchPendingInvites(); }, [fetchPendingInvites]); @@ -2340,6 +2730,30 @@ const TennisMatchApp = () => { } }, [location.pathname, navigate]); + const goToGroups = useCallback(() => { + setCurrentScreen("groups"); + if (location.pathname !== "/groups") { + navigate("/groups"); + } + }, [location.pathname, navigate]); + + const openGroupDetail = useCallback( + (groupId = "new") => { + const normalizedGroupId = String(groupId || "new").trim() || "new"; + const path = `/groups/${encodeURIComponent(normalizedGroupId)}`; + setCurrentScreen("group-detail"); + if (location.pathname !== path) { + navigate(path); + } + }, + [location.pathname, navigate], + ); + + const openProfileManager = useCallback((section = "profile") => { + setProfileManagerSection(section); + setShowProfileManager(true); + }, []); + useEffect(() => { if (currentScreen !== "invites") return; if (!notificationSummary.latest) return; @@ -2580,78 +2994,6 @@ const TennisMatchApp = () => { [displayToast, goToBrowse, navigate], ); - const handleShareMatch = useCallback( - async (matchId) => { - const numericMatchId = Number(matchId); - if (!Number.isFinite(numericMatchId) || numericMatchId <= 0) { - displayToast("Match not found", "error"); - return; - } - - const shareKey = String(matchId); - let shouldFetch = false; - setSharingMatchIds((previous) => { - if (previous.has(shareKey)) { - return previous; - } - shouldFetch = true; - const next = new Set(previous); - next.add(shareKey); - return next; - }); - - if (!shouldFetch) return; - - try { - const { shareUrl } = await getShareLink(numericMatchId); - const link = typeof shareUrl === "string" ? shareUrl.trim() : ""; - if (!link) { - displayToast("Share link unavailable right now", "error"); - return; - } - - const clipboard = - typeof navigator !== "undefined" ? navigator.clipboard : null; - if (clipboard?.writeText) { - try { - await clipboard.writeText(link); - displayToast("Share link copied!"); - return; - } catch (clipboardError) { - console.warn("Failed to copy share link", clipboardError); - } - } - - if (typeof window !== "undefined") { - try { - window.prompt("Copy this share link:", link); - } catch (promptError) { - console.warn("Prompt for share link failed", promptError); - } - } - - displayToast("Share link ready to send.", "success"); - } catch (error) { - console.error("Failed to generate share link", error); - const message = - error?.response?.data?.message || - error?.message || - "Failed to generate share link"; - displayToast(message, "error"); - } finally { - setSharingMatchIds((previous) => { - if (!previous.has(shareKey)) { - return previous; - } - const next = new Set(previous); - next.delete(shareKey); - return next; - }); - } - }, - [displayToast], - ); - const closeMatchDetailsModal = useCallback(() => { setShowMatchDetailsModal(false); setViewMatch(null); @@ -2691,6 +3033,22 @@ const TennisMatchApp = () => { return; } + if (path === "/groups") { + lastInviteLoadRef.current = null; + if (currentScreen !== "groups") { + setCurrentScreen("groups"); + } + return; + } + + if (/^\/groups\/[^/]+$/.test(path)) { + lastInviteLoadRef.current = null; + if (currentScreen !== "group-detail") { + setCurrentScreen("group-detail"); + } + return; + } + const inviteRouteMatch = path.match(/^\/matches\/(\d+)\/invite$/); if (inviteRouteMatch) { const matchIdFromPath = Number(inviteRouteMatch[1]); @@ -2708,7 +3066,12 @@ const TennisMatchApp = () => { lastInviteLoadRef.current = null; // Do not override other in-app screens (e.g., create) when the URL // doesn't explicitly target a special route. - if (currentScreen !== "browse" && currentScreen !== "create") { + if ( + currentScreen !== "browse" && + currentScreen !== "create" && + currentScreen !== "groups" && + currentScreen !== "group-detail" + ) { setCurrentScreen("browse"); } }, [currentScreen, location.pathname, openInviteScreen]); @@ -2946,25 +3309,37 @@ const TennisMatchApp = () => { }, [hasLocationFilter, locationFilter, matches]); const displayedMatches = useMemo(() => { - const baseMatches = hasLocationFilter - ? matchesWithDistance.filter((match) => { - if (!Number.isFinite(match.distanceMiles)) return false; - return match.distanceMiles <= distanceFilter; - }) - : matchesWithDistance; - - return sortMatchesByRecency(baseMatches); - }, [ - distanceFilter, - hasLocationFilter, - matchesWithDistance, - sortMatchesByRecency, - ]); + return sortMatchesByRecency(matchesWithDistance); + }, [matchesWithDistance, sortMatchesByRecency]); const distanceOptions = useMemo(() => [5, 10, 20, 50], []); - const activeLocationLabel = hasLocationFilter - ? locationFilter?.label || "Saved location" - : ""; + const dayStripOptions = useMemo(() => buildDayStripOptions(), []); + const matchCountsByDay = useMemo(() => { + const counts = new Map(); + matchesWithDistance.forEach((match) => { + const date = getMatchStartDate(match); + const key = formatDayKey(date); + if (!key) return; + counts.set(key, (counts.get(key) || 0) + 1); + }); + return counts; + }, [matchesWithDistance]); + const groupedDisplayedMatches = useMemo(() => { + const groups = []; + const groupMap = new Map(); + displayedMatches.forEach((match) => { + const date = getMatchStartDate(match); + const key = formatDayKey(date) || "unscheduled"; + if (!groupMap.has(key)) { + const heading = formatMatchDayHeading(match); + const group = { key, ...heading, matches: [] }; + groupMap.set(key, group); + groups.push(group); + } + groupMap.get(key).matches.push(match); + }); + return groups; + }, [displayedMatches]); const matchesNeedingAttention = useMemo(() => { const getTimestamp = (match) => { @@ -2982,1605 +3357,666 @@ const TennisMatchApp = () => { return Number.POSITIVE_INFINITY; }; - return matches + return attentionMatches .filter((match) => match?.alerts?.lowOccupancy?.active) .sort((a, b) => getTimestamp(a) - getTimestamp(b)); - }, [matches]); - - const parseDateValue = useCallback((value) => { - if (!value) return null; - if (value instanceof Date) { - return Number.isNaN(value.getTime()) ? null : value; - } - if (typeof value === "number") { - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date; - } - if (typeof value === "string") { - const trimmed = value.trim(); - if (!trimmed) return null; - const date = new Date(trimmed); - if (!Number.isNaN(date.getTime())) return date; - } - return null; - }, []); - - const formatRelativeTimeFromNow = useCallback((date) => { - if (!(date instanceof Date)) return ""; - const now = Date.now(); - const diffSeconds = Math.round((date.getTime() - now) / 1000); - const absSeconds = Math.abs(diffSeconds); - const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); - const units = [ - { limit: 60, unit: "second", divisor: 1 }, - { limit: 3600, unit: "minute", divisor: 60 }, - { limit: 86400, unit: "hour", divisor: 3600 }, - { limit: 604800, unit: "day", divisor: 86400 }, - { limit: 2629800, unit: "week", divisor: 604800 }, - { limit: 31557600, unit: "month", divisor: 2629800 }, - ]; + }, [attentionMatches]); - for (const { limit, unit, divisor } of units) { - if (absSeconds < limit) { - const value = Math.round(diffSeconds / divisor); - return formatter.format(value, unit); + const getMatchCount = useCallback( + (filterId) => { + if (!matchCounts) return 0; + if (filterId === "archived") { + return ( + matchCounts.archived ?? + matchCounts.archieve ?? + matchCounts.archive ?? + 0 + ); } - } + return matchCounts[filterId] ?? 0; + }, + [matchCounts], + ); - const years = Math.round(diffSeconds / 31557600); - return formatter.format(years, "year"); - }, []); + const BrowseScreen = () => { + const topAttentionMatches = matchesNeedingAttention.slice(0, 3); + + const renderFilterChip = ({ + key, + active, + onClick, + children, + disabled = false, + size = "default", + }) => ( + + ); - const activityFeedItems = useMemo(() => { - if (!currentUser) return []; + return ( +
+ {currentUser ? ( +
+
+
+

+ Match Play +

+

+ Find a match. Host a match. +

+

+ Create a private or open match, invite players, and get on court. + The Tennis Plan keeps your roster, messages, and groups in one place. +

+
- const items = []; - - const pickString = (...candidates) => { - for (const candidate of candidates) { - if (!candidate) continue; - if (typeof candidate === "string") { - const trimmed = candidate.trim(); - if (trimmed) return trimmed; - } - } - return ""; - }; - - const pickNumber = (...candidates) => { - for (const candidate of candidates) { - if (candidate === undefined || candidate === null) continue; - const numeric = Number(candidate); - if (Number.isFinite(numeric)) return numeric; - } - return null; - }; - - pendingInvites.forEach((invite) => { - const match = invite?.match || {}; - const matchId = - match?.id ?? match?.match_id ?? match?.matchId ?? invite?.match_id ?? invite?.matchId; - const formatLabel = - pickString( - match.match_format, - match.matchFormat, - match.format, - match.title, - match.name, - ) || "Match invite"; - const locationLabel = pickString( - match.location_text, - match.locationText, - match.location, - match.venue, - match.court_name, - match.courtName, - ); - const hostLabel = pickString( - match.host_name, - match.hostName, - invite?.inviter?.full_name, - invite?.inviter?.fullName, - invite?.inviter?.name, - ); - const startDate = - parseDateValue(match.start_date_time) || - parseDateValue(match.startDateTime) || - parseDateValue(match.start_time) || - parseDateValue(match.dateTime); - const updatedAt = - parseDateValue(invite?.updated_at) || - parseDateValue(invite?.updatedAt) || - parseDateValue(invite?.created_at) || - parseDateValue(invite?.createdAt) || - parseDateValue(invite?.sent_at) || - startDate; - const relativeTime = formatRelativeTimeFromNow(updatedAt || startDate); - const playerLimit = pickNumber( - match.player_limit, - match.playerLimit, - match.player_cap, - match.max_players, - match.capacity, - ); - const rosterCount = pickNumber( - match.roster_count, - match.rosterCount, - match.player_count, - match.playerCount, - match.occupied, - ); - const capacityLabel = (() => { - if (Number.isFinite(playerLimit) && Number.isFinite(rosterCount)) { - return `${rosterCount}/${playerLimit} players`; - } - if (Number.isFinite(playerLimit)) { - return `${playerLimit} player cap`; - } - return ""; - })(); - - const meta = []; - if (startDate) { - meta.push({ icon: Calendar, label: formatDateTime(startDate) }); - } - if (locationLabel) { - meta.push({ icon: MapPin, label: locationLabel }); - } - if (capacityLabel) { - meta.push({ icon: Users, label: capacityLabel }); - } - - const inviteStatus = deriveInviteStatus(invite) || "pending"; - const tone = inviteStatus === "pending" || inviteStatus === "sent" ? "pending" : "info"; - const statusLabel = inviteStatus === "sent" ? "Invite Sent" : "Pending Invite"; - - const actions = [ - { - label: "Accept", - onClick: () => respondToInvite(invite.token, "accept"), - variant: "success", - }, - { - label: "Decline", - onClick: () => respondToInvite(invite.token, "reject"), - variant: "danger", - }, - ]; - - if (matchId) { - actions.push({ - label: "View match", - onClick: () => handleViewDetails(matchId, { pendingInvite: invite }), - variant: "outline", - }); - } else { - actions.push({ - label: "Review invites", - onClick: () => goToInvites(), - variant: "outline", - }); - } - - const title = formatLabel || locationLabel || "Match invite"; - - items.push({ - id: `invite-${invite.token || invite.id}`, - statusLabel, - tone, - icon: Mail, - title, - description: hostLabel ? `Hosted by ${hostLabel}` : "Respond to secure your spot.", - meta, - timestamp: updatedAt || startDate || null, - timestampLabel: - (updatedAt || startDate)?.toLocaleString?.() || - (startDate ? startDate.toLocaleString() : ""), - relativeTime, - actions, - }); - }); - - const notificationTypeMap = { - invite_accepted: { statusLabel: "Player Accepted", tone: "success", icon: UserCheck }, - invite_declined: { statusLabel: "Invite Declined", tone: "danger", icon: UserX }, - invite_sent: { statusLabel: "Invite Sent", tone: "info", icon: CheckCircle2 }, - match_created: { statusLabel: "Match Created", tone: "info", icon: Sparkles }, - match_updated: { statusLabel: "Match Updated", tone: "info", icon: Edit3 }, - match_full: { statusLabel: "Match Full", tone: "warning", icon: Users }, - match_cancelled: { statusLabel: "Match Cancelled", tone: "danger", icon: AlertCircle }, - player_joined: { statusLabel: "Player Joined", tone: "success", icon: UserPlus }, - player_left: { statusLabel: "Player Left", tone: "neutral", icon: UserMinus }, - general: { statusLabel: "Update", tone: "neutral", icon: BellRing }, - }; - - homeFeedNotifications.forEach((notification) => { - if ( - isLinkOnlyVisibility( - notification.listingVisibility, - notification.raw?.match, - notification.raw, - notification.raw?.context, - notification.raw?.meta, - ) - ) { - return; - } - const styles = notificationTypeMap[notification.canonicalType] || notificationTypeMap.general; - const meta = []; - if (notification.matchLabel) { - meta.push({ icon: Calendar, label: notification.matchLabel }); - } - if (notification.startLabel) { - meta.push({ icon: Clock, label: notification.startLabel }); - } - if (Array.isArray(notification.tags)) { - notification.tags - .map((tag) => (typeof tag === "string" ? tag.trim() : "")) - .filter(Boolean) - .forEach((tag) => { - meta.push({ icon: null, label: tag }); - }); - } - - const actions = []; - if (notification.matchId) { - actions.push({ - label: "View match", - onClick: () => handleViewDetails(notification.matchId), - variant: "outline", - }); - } - - items.push({ - id: `notification-${notification.id}`, - statusLabel: styles.statusLabel, - tone: styles.tone, - icon: styles.icon, - title: notification.title || styles.statusLabel, - description: notification.body || "", - meta, - timestamp: notification.createdAt || null, - timestampLabel: notification.createdAtLabel || "", - relativeTime: notification.relativeTime || "", - actions, - }); - }); - - matchesNeedingAttention.forEach((match) => { - const lowOccupancy = match?.alerts?.lowOccupancy || {}; - const spotsNeeded = Number(lowOccupancy.spotsNeeded ?? match.rosterSpotsRemaining ?? 0); - const matchId = match?.id; - const formatLabel = - pickString( - match.match_format, - match.matchFormat, - match.format, - match.title, - match.name, - ) || "Match"; - const locationLabel = pickString( - match.location, - match.location_text, - match.locationText, - match.venue, - match.court_name, - match.courtName, - ) || "Location TBA"; - const startDate = parseDateValue(match.dateTime); - const alertStart = parseDateValue(lowOccupancy.startTime); - const timestamp = alertStart || startDate || null; - const relativeTime = (() => { - if (Number.isFinite(lowOccupancy.hoursUntilStart)) { - return formatHoursUntilStart(lowOccupancy.hoursUntilStart); - } - return formatRelativeTimeFromNow(timestamp); - })(); - - const playerLimit = pickNumber(lowOccupancy.playerLimit, match.playerLimit); - const rosterCount = pickNumber(lowOccupancy.rosterCount, match.rosterCount, match.occupied); - const capacityLabel = (() => { - if (Number.isFinite(playerLimit) && Number.isFinite(rosterCount)) { - return `${rosterCount}/${playerLimit} confirmed`; - } - if (Number.isFinite(rosterCount)) { - return `${rosterCount} confirmed`; - } - return ""; - })(); - - const meta = []; - if (startDate) { - meta.push({ icon: Calendar, label: formatDateTime(startDate) }); - } - if (locationLabel) { - meta.push({ icon: MapPin, label: locationLabel }); - } - if (capacityLabel) { - meta.push({ icon: Users, label: capacityLabel }); - } - - const actions = []; - if (matchId) { - const shareKey = String(matchId); - const shareInProgress = sharingMatchIds.has(shareKey); - if (isOpenMatch(match)) { - actions.push({ - label: shareInProgress ? "Copying..." : "Share match", - onClick: () => handleShareMatch(matchId), - variant: "success", - disabled: shareInProgress, - }); - } else { - actions.push({ - label: "Manage invites", - onClick: () => openInviteScreen(matchId), - variant: "danger", - }); - } - actions.push({ - label: "View match", - onClick: () => handleViewDetails(matchId), - variant: "outline", - }); - } - - items.push({ - id: `attention-${matchId}`, - statusLabel: "Needs Players", - tone: "danger", - icon: Users, - title: - Number.isFinite(spotsNeeded) && spotsNeeded > 0 - ? `Need ${spotsNeeded} more ${spotsNeeded === 1 ? "player" : "players"}` - : "Help fill this match", - description: `${formatLabel} at ${locationLabel}`, - meta, - timestamp, - timestampLabel: timestamp?.toLocaleString?.() || "", - relativeTime, - actions, - }); - }); - - return items - .sort((a, b) => { - const aTime = a.timestamp instanceof Date ? a.timestamp.getTime() : -Infinity; - const bTime = b.timestamp instanceof Date ? b.timestamp.getTime() : -Infinity; - return bTime - aTime; - }) - .slice(0, HOME_FEED_ITEM_LIMIT); - }, [ - currentUser, - deriveInviteStatus, - formatDateTime, - formatHoursUntilStart, - formatRelativeTimeFromNow, - goToInvites, - handleViewDetails, - handleShareMatch, - homeFeedNotifications, - matchesNeedingAttention, - openInviteScreen, - sharingMatchIds, - parseDateValue, - pendingInvites, - respondToInvite, - ]); - - const getMatchCount = useCallback( - (filterId) => { - if (!matchCounts) return 0; - if (filterId === "archived") { - return ( - matchCounts.archived ?? - matchCounts.archieve ?? - matchCounts.archive ?? - 0 - ); - } - return matchCounts[filterId] ?? 0; - }, - [matchCounts], - ); - - const BrowseScreen = () => ( -
- {/* Hero Section with Action Button */} -
-
-
-
-

- {currentUser ? "Browse Local Matches" : "Find Your Next Match"} -

-

- {currentUser - ? "See what's nearby and jump back in." - : "Discover active players around North County."} -

-
-
- - -
-
-
-
- - {currentUser ? ( - <> -
-
-
-
-
+ + {distanceOptions.map((distance) => ( + + ))} +
+
+ + {topAttentionMatches.length > 0 && ( +
+
+
+ + Needs your attention
-
- {distanceOptions.map((distance) => ( - - ))} +
+ {topAttentionMatches.map((match) => { + const lowOccupancy = match.alerts?.lowOccupancy || {}; + const spotsNeeded = Number( + lowOccupancy.spotsNeeded ?? match.rosterSpotsRemaining ?? 0, + ); + const rosterCount = + lowOccupancy.rosterCount ?? match.rosterCount ?? match.occupied; + const playerLimit = lowOccupancy.playerLimit ?? match.playerLimit; + return ( +
handleViewDetails(match.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleViewDetails(match.id); + } + }} + className="cursor-pointer rounded-[14px] border border-amber-100 border-l-[3px] border-l-amber-500 bg-white p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md" + > +
+ + + Needs players + + + {formatHoursUntilStart(lowOccupancy.hoursUntilStart) || "Soon"} + +
+

+ Need {spotsNeeded} more {spotsNeeded === 1 ? "player" : "players"} +

+

+ {match.format || "Match"} at {match.location || "Location TBA"} +

+
+
+ + {formatDateTime(match.dateTime)} +
+
+ + {rosterCount} + {playerLimit ? `/${playerLimit}` : ""} confirmed +
+
+
+ + +
+
+ ); + })}
-
- {hasLocationFilter && ( -

- Showing matches within {distanceFilter} miles of your selected location. -

- )} - {showLocationPicker && ( -
- setLocationSearchTerm(event.target.value)} - onPlaceSelected={(place) => { - if (!place) { - setGeoError("Please choose a location from the suggestions."); - return; - } - const lat = place.geometry?.location?.lat?.(); - const lng = place.geometry?.location?.lng?.(); - const label = - place.formatted_address || place.name || locationSearchTerm || "Custom location"; - if ( - typeof lat === "number" && - !Number.isNaN(lat) && - typeof lng === "number" && - !Number.isNaN(lng) - ) { - setLocationFilter({ label, lat, lng }); - setGeoError(""); - setShowLocationPicker(false); - } else { - setGeoError( - "We couldn't read that location's coordinates. Try another search.", - ); - } - }} - options={{ - types: ["geocode", "establishment"], - fields: [ - "formatted_address", - "geometry", - "name", - "address_components", - ], - }} - /> -
- -
- {hasLocationFilter && ( - - )} +
+ )} + +
+
+
+ {DISCOVERY_SCOPE_FILTERS.map((filter) => { + const isActive = activeFilter === filter.id; + return ( -
-
- {geoError && ( -

{geoError}

- )} - {!import.meta.env.VITE_GOOGLE_API_KEY && ( -

- Tip: Provide a Google Places API key to enable location search suggestions. -

- )} + ); + })}
- )} -
- - refreshMatchesAndInvites()} - onViewAll={goToInvites} - pendingInviteCount={pendingInvites.length} - unreadUpdateCount={Number(notificationSummary.unread ?? 0)} - /> -
- - {/* Filter Tabs */} -
-
-
- {[ - { - id: "my", - label: "My Matches", - count: getMatchCount("my"), - color: "violet", - icon: "⭐", - }, - { - id: "open", - label: "Open Matches", - count: getMatchCount("open"), - color: "green", - icon: "🔥", - }, - { - id: "today", - label: "Today", - count: getMatchCount("today"), - color: "blue", - icon: "📅", - }, - { - id: "tomorrow", - label: "Tomorrow", - count: getMatchCount("tomorrow"), - color: "amber", - icon: "⏰", - }, - { - id: "weekend", - label: "Weekend", - count: getMatchCount("weekend"), - color: "purple", - icon: "🎉", - }, - { - id: "draft", - label: "Drafts", - count: getMatchCount("draft"), - color: "gray", - icon: "📝", - }, - { - id: "archived", - label: "Archived", - count: getMatchCount("archived"), - color: "slate", - icon: "🗂️", - }, - ].map((filter) => ( - - ))} -
-
-
- - {/* Match Cards */} -
-
- setMatchSearch(e.target.value)} - className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:ring-2 focus:ring-green-500 focus:border-green-500 font-semibold text-gray-800" - /> -
- - {hasLocationFilter && displayedMatches.length === 0 && ( -
- No matches within {distanceFilter} miles of your location yet. Try expanding the distance filter or check back soon! -
- )} - -
- {displayedMatches.map((match) => ( - - ))} -
- - {matchPagination && !hasLocationFilter && ( -
- - - Page {matchPagination.page} of - {" "} - {Math.max( - 1, - Math.ceil( - getMatchCount(activeFilter) / - matchPagination.perPage - ) - )} - - -
- )} -
- - ) : ( -
-

- Sign up or log in to view available matches. -

- -
- )} - - ); - - const MatchCard = ({ match }) => { - const isHosted = match.type === "hosted"; - const isJoined = match.type === "joined"; - const isLinkOnly = - match.listingVisibility === "link_only" || - match.isLinkOnly === true || - match.isHidden === true || - match.is_hidden === true || - match.hidden === true; - const isHiddenListing = isLinkOnly && match.privacy === "open"; - const statusValue = typeof match.status === "string" ? match.status.toLowerCase() : match.status; - const isArchived = statusValue === "archived"; - const isUpcoming = statusValue === "upcoming"; - const playerCapacityLabel = Number.isFinite(match.playerLimit) - ? `${match.occupied}/${match.playerLimit} players` - : `${match.occupied} players`; - - const lowOccupancy = match?.alerts?.lowOccupancy; - const hasLowOccupancyAlert = Boolean(lowOccupancy?.active); - const rosterCount = lowOccupancy?.rosterCount ?? match.rosterCount ?? match.occupied; - const playerLimit = lowOccupancy?.playerLimit ?? match.playerLimit ?? null; - const spotsNeeded = lowOccupancy?.spotsNeeded ?? match.rosterSpotsRemaining ?? 0; - const hoursUntilStart = lowOccupancy?.hoursUntilStart ?? null; - const timeUntilStartLabel = useMemo( - () => formatHoursUntilStart(hoursUntilStart), - [formatHoursUntilStart, hoursUntilStart], - ); - - const existingPlayerIds = useMemo(() => { - const ids = new Set(); - uniqueActiveParticipants(match.participants || []).forEach((participant) => { - const candidate = Number( - participant?.player_id ?? - participant?.user_id ?? - participant?.id ?? - participant?.profile?.player_id ?? - participant?.profile?.id, - ); - if (Number.isFinite(candidate) && candidate > 0) { - ids.add(candidate); - } - }); - uniqueInvitees(match.invitees || []).forEach((invite) => { - const candidate = Number( - invite?.invitee_id ?? - invite?.player_id ?? - invite?.user_id ?? - invite?.id ?? - invite?.profile?.player_id ?? - invite?.profile?.id, - ); - if (Number.isFinite(candidate) && candidate > 0) { - ids.add(candidate); - } - }); - return ids; - }, [match.invitees, match.participants]); - - const hostIdentityIds = useMemo(() => { - if (!isHosted) return []; - try { - return collectMatchHostIds(match) || []; - } catch (error) { - console.error("Failed to collect host identity ids", error); - return []; - } - }, [isHosted, match]); - - const participantPhoneRecipients = useMemo(() => { - if (!isHosted) return []; - const hostIds = Array.isArray(hostIdentityIds) ? hostIdentityIds : []; - const recipients = []; - const seen = new Set(); - const participants = uniqueActiveParticipants(match.participants || []); - - const participantIdentityCandidates = (participant) => { - if (!participant || typeof participant !== "object") return []; - const profile = participant.profile || {}; - const player = participant.player || {}; - const user = participant.user || {}; - const member = participant.member || {}; - const contact = participant.contact || {}; - return [ - participant.match_participant_id, - participant.matchParticipantId, - participant.participant_id, - participant.participantId, - participant.player_id, - participant.playerId, - participant.invitee_id, - participant.inviteeId, - participant.user_id, - participant.userId, - participant.member_id, - participant.memberId, - participant.id, - profile.id, - profile.user_id, - profile.userId, - profile.player_id, - profile.playerId, - profile.member_id, - profile.memberId, - player.id, - player.user_id, - player.userId, - player.player_id, - player.playerId, - player.member_id, - player.memberId, - user.id, - user.user_id, - user.userId, - user.player_id, - user.playerId, - user.member_id, - user.memberId, - member.id, - member.user_id, - member.userId, - member.player_id, - member.playerId, - member.member_id, - member.memberId, - contact.id, - contact.user_id, - contact.userId, - contact.player_id, - contact.playerId, - contact.member_id, - contact.memberId, - ]; - }; - - for (const participant of participants) { - if (!participant || typeof participant !== "object") continue; - const isHostParticipant = (() => { - if (typeof participant.status === "string") { - const status = participant.status.trim().toLowerCase(); - if (status === "hosting" || status === "host") { - return true; - } - } - return participantIdentityCandidates(participant).some((candidate) => - hostIds.some((hostId) => idsMatch(candidate, hostId)), - ); - })(); - if (isHostParticipant) { - continue; - } - - const phoneRaw = getParticipantPhone(participant); - const normalized = normalizePhoneValue(phoneRaw); - if (!normalized || seen.has(normalized)) { - continue; - } - seen.add(normalized); - recipients.push(normalized); - } - - return recipients; - }, [hostIdentityIds, isHosted, match.participants]); - - const canMessageGroup = participantPhoneRecipients.length > 0; - const messageGroupLabel = participantPhoneRecipients.length === 1 ? "Message player" : "Message group"; - const messageGroupDescription = canMessageGroup - ? participantPhoneRecipients.length === 1 - ? "Start a text thread with the confirmed player." - : `Start a group text with ${participantPhoneRecipients.length} players.` - : "Add player phone numbers to enable group texts."; - - const handleMessageGroup = useCallback( - (event) => { - event?.stopPropagation?.(); - if (!canMessageGroup) { - displayToast(messageGroupDescription, "info"); - return; - } - try { - const recipients = participantPhoneRecipients; - const ua = - typeof navigator !== "undefined" && navigator.userAgent - ? navigator.userAgent - : ""; - const isAndroid = /Android/i.test(ua); - const isAppleMobile = /(iPad|iPhone|iPod)/i.test(ua); - - let url = "sms:"; - if (recipients.length > 0) { - if (isAndroid) { - const path = recipients.map((value) => encodeURIComponent(value)).join(";"); - const addresses = encodeURIComponent(recipients.join(";")); - url = `smsto:${path}?addresses=${addresses}`; - } else if (isAppleMobile) { - const addresses = encodeURIComponent(recipients.join(",")); - url = `sms:&addresses=${addresses}`; - } else { - const path = recipients.map((value) => encodeURIComponent(value)).join(","); - url = `sms:${path}`; - } - } - const toastMessage = isAppleMobile ? "Opening Messages..." : "Opening messages..."; - displayToast(toastMessage, "info"); - if (typeof window !== "undefined") { - window.location.href = url; - } - } catch (error) { - console.error(error); - displayToast("We couldn't open messages", "error"); - } - }, - [canMessageGroup, displayToast, messageGroupDescription, participantPhoneRecipients], - ); - - const [showRecommendations, setShowRecommendations] = useState(false); - const [recommendationStatus, setRecommendationStatus] = useState("idle"); - const [recommendationError, setRecommendationError] = useState(""); - const [recommendations, setRecommendations] = useState([]); - const [inviteProgress, setInviteProgress] = useState({}); - - const loadRecommendations = useCallback(async () => { - if (!hasLowOccupancyAlert) return; - if (!currentUser) { - setRecommendationStatus("empty"); - setRecommendationError(""); - return; - } - setRecommendationStatus("loading"); - setRecommendationError(""); - - const suggestionMeta = new Map(); - let suggestedIds = []; - - try { - const history = await listMatches("my", { perPage: 25, includeHidden: true }); - const matches = Array.isArray(history?.matches) ? history.matches : []; - const suggestions = buildRecentPartnerSuggestions({ - matches, - currentUser, - memberIdentities: memberIdentityIds, - }); - suggestedIds = suggestions - .map((player) => Number(player.user_id)) - .filter( - (id) => Number.isFinite(id) && id > 0 && !existingPlayerIds.has(id), - ); - suggestions.forEach((suggestion) => { - const id = Number(suggestion.user_id); - if (Number.isFinite(id) && id > 0) { - suggestionMeta.set(id, suggestion); - } - }); - } catch (historyError) { - console.error("Failed to load match history for suggestions", historyError); - } - - try { - let players = []; - if (suggestedIds.length > 0) { - const limitedIds = suggestedIds.slice(0, 12); - const data = await searchPlayers({ ids: limitedIds, perPage: limitedIds.length }); - players = Array.isArray(data?.players) ? data.players : []; - } - - if (!players.length) { - const fallbackTerm = (() => { - if (typeof match.skillLevel === "string" && match.skillLevel.trim()) { - const [ntrp] = match.skillLevel.split(" - "); - return ntrp || match.skillLevel; - } - if (typeof match.format === "string" && match.format.trim()) { - return match.format; - } - return "tennis"; - })(); - const fallback = await searchPlayers({ search: fallbackTerm, perPage: 12 }); - players = Array.isArray(fallback?.players) ? fallback.players : []; - } - - const filtered = players.filter((player) => { - const pid = Number( - player?.user_id ?? - player?.id ?? - player?.player_id ?? - player?.playerId ?? - player?.profile?.player_id ?? - player?.profile?.id, - ); - if (!Number.isFinite(pid) || pid <= 0) return false; - if (existingPlayerIds.has(pid)) return false; - if (currentUser && memberMatchesAnyId(currentUser, pid, memberIdentityIds)) { - return false; - } - return true; - }); - - if (filtered.length === 0) { - setRecommendations([]); - setRecommendationStatus("empty"); - return; - } - - const limitedFiltered = filtered.slice(0, 6).map((player) => { - const pid = Number( - player?.user_id ?? - player?.id ?? - player?.player_id ?? - player?.playerId ?? - player?.profile?.player_id ?? - player?.profile?.id, - ); - const meta = Number.isFinite(pid) ? suggestionMeta.get(pid) : null; - if (meta && meta.lastPlayedAt) { - return { ...player, lastPlayedAt: meta.lastPlayedAt }; - } - return player; - }); - - setRecommendations(limitedFiltered); - setRecommendationStatus("ready"); - } catch (error) { - console.error("Failed to load recommendations", error); - setRecommendationError( - error?.response?.data?.message || - error?.message || - "Failed to load recommendations", - ); - setRecommendationStatus("error"); - } - }, [ - currentUser, - existingPlayerIds, - hasLowOccupancyAlert, - match.format, - match.skillLevel, - memberIdentityIds, - ]); - - useEffect(() => { - if (showRecommendations && recommendationStatus === "idle") { - loadRecommendations(); - } - }, [loadRecommendations, recommendationStatus, showRecommendations]); +
+
+

+ Location +

+
+ + { + setLocationFilter({ + label: e.target.value, + lat: null, + lng: null, + }); + setMatchPage(1); + }} + onPlaceSelected={(place) => { + const placeName = + typeof place?.name === "string" ? place.name.trim() : ""; + const formattedAddress = + typeof place?.formatted_address === "string" + ? place.formatted_address.trim() + : ""; + const locationLabel = + placeName || formattedAddress || locationFilter?.label || ""; + const lat = place.geometry?.location?.lat?.(); + const lng = place.geometry?.location?.lng?.(); + setLocationFilter({ + label: locationLabel, + lat: typeof lat === "number" ? lat : null, + lng: typeof lng === "number" ? lng : null, + }); + setMatchPage(1); + }} + options={{ + types: ["establishment"], + fields: ["formatted_address", "geometry", "name"], + }} + className="h-14 w-full rounded-[18px] border border-slate-200 bg-white pl-14 pr-5 text-[13px] font-semibold text-slate-700 outline-none transition focus:border-violet-300 focus:ring-2 focus:ring-violet-100" + placeholder="e.g., Oceanside Tennis Center" + /> +
+ {recentLocations.length > 0 && ( +
+ {recentLocations.slice(0, 4).map((entry) => { + const isActive = + (locationFilter?.label || "").trim().toLowerCase() === + entry.label.toLowerCase(); + return ( + + ); + })} +
+ )} +
- useEffect(() => { - setShowRecommendations(false); - setRecommendationStatus("idle"); - setRecommendationError(""); - setRecommendations([]); - setInviteProgress({}); - }, [match.id]); - - const handleQuickInvite = useCallback( - async (player) => { - const pid = Number( - player?.user_id ?? - player?.id ?? - player?.player_id ?? - player?.playerId ?? - player?.profile?.player_id ?? - player?.profile?.id, - ); - if (!Number.isFinite(pid) || pid <= 0) { - displayToast("We couldn't determine this player's account", "error"); - return; - } +
+

+ Search +

+
+ + { + setMatchSearch(e.target.value); + setMatchPage(1); + }} + className="h-14 w-full rounded-[18px] border border-slate-200 bg-white pl-14 pr-5 text-[13px] font-semibold text-slate-700 outline-none transition focus:border-violet-300 focus:ring-2 focus:ring-violet-100" + /> +
+
+
+ - setInviteProgress((prev) => ({ ...prev, [pid]: "sending" })); +
+
+

When

+
+ + {dayStripOptions.map((day, index) => { + const count = + matchCountsByDay.get(day.key) ?? + (day.fallbackCountKey ? getMatchCount(day.fallbackCountKey) : 0); + const isActive = selectedDayKey === day.key; + const disabled = index > 3 && count === 0; + return ( + + ); + })} +
+
- try { - await sendInvites(match.id, { playerIds: [pid] }); - setInviteProgress((prev) => ({ ...prev, [pid]: "sent" })); - setRecommendations((prev) => - prev.filter((candidate) => { - const candidateId = Number( - candidate?.user_id ?? - candidate?.id ?? - candidate?.player_id ?? - candidate?.playerId ?? - candidate?.profile?.player_id ?? - candidate?.profile?.id, - ); - return candidateId !== pid; - }), - ); - displayToast( - `Invite sent to ${ - player?.full_name || player?.name || `Player ${pid}` - }!`, - ); - fetchMatches(); - } catch (error) { - console.error("Failed to send invite", error); - setInviteProgress((prev) => ({ ...prev, [pid]: "error" })); - displayToast( - error?.response?.data?.message || - error?.message || - "Failed to send invite", - "error", - ); - } - }, - [displayToast, fetchMatches, match.id], - ); +
+
+ Level + {["Any", ...NTRP_LEVELS].map((level) => + renderFilterChip({ + key: `level-${level}`, + active: selectedLevelFilter === level, + onClick: () => { + setSelectedLevelFilter(level); + setMatchPage(1); + }, + children: level, + size: "compact", + }), + )} +
+
+
+ Format + {DISCOVERY_FORMAT_FILTERS.map((format) => + renderFilterChip({ + key: `format-${format}`, + active: selectedFormatFilter === format, + onClick: () => { + setSelectedFormatFilter(format); + setMatchPage(1); + }, + children: format, + size: "compact", + }), + )} +
+
- const getNTRPDisplay = (skillLevel) => { - if (!skillLevel || skillLevel === "Any Level") return null; - const ntrp = skillLevel.split(" - ")[0]; - return ntrp; - }; +
+ Gender + {DISCOVERY_GENDER_FILTERS.map((gender) => + renderFilterChip({ + key: `gender-${gender}`, + active: selectedGenderFilter === gender, + onClick: () => { + setSelectedGenderFilter(gender); + setMatchPage(1); + }, + children: gender, + size: "compact", + }), + )} +
+
+ - const formatDistance = (value) => { - if (!Number.isFinite(value)) return null; - const normalized = Math.round(value * 10) / 10; - const display = Number.isInteger(normalized) - ? normalized.toString() - : normalized.toFixed(1); - const plural = Math.abs(normalized - 1) < 0.05 ? "" : "s"; - return `${display} mile${plural} away`; - }; + {displayedMatches.length === 0 ? ( +
+ +
+ {hasLocationFilter + ? `No matches within ${distanceFilter} miles of your location yet.` + : "No matches found"} +
+
+ Try different filters or create one. +
+ +
+ ) : ( +
+ {groupedDisplayedMatches.map((group) => ( +
+
+

+ {group.dayLabel} +

+ {group.dateLabel && ( + + · {group.dateLabel} + + )} +
+ + {group.matches.length} {group.matches.length === 1 ? "match" : "matches"} + +
+
+ {group.matches.map((match) => ( + + ))} +
+
+ ))} +
+ )} + + ) : ( +
+

+ Sign up or log in to view available matches. +

+ +
+ )} +
+ ); + }; - const distanceLabel = formatDistance(match.distanceMiles); - const locationSubtitle = distanceLabel - ? distanceLabel - : hasLocationFilter - ? "Distance unavailable" - : match.mapUrl - ? "Tap for directions" - : "Location details coming soon"; + const MatchCard = ({ match }) => { + const isHosted = match.type === "hosted"; + const isJoined = match.type === "joined"; + const statusValue = + typeof match.status === "string" ? match.status.toLowerCase() : match.status; + const isArchived = statusValue === "archived"; + const isPrivate = match.privacy === "private"; + const isMine = isHosted || isJoined; + const skillRangeLabel = match.skillLevel || "All levels"; + const genderLabel = match.gender || "Any"; + const playerCapacityLabel = Number.isFinite(match.playerLimit) + ? `${match.occupied}/${match.playerLimit} players` + : `${match.occupied} players`; + const rosterStatusLabel = (() => { + if (Number.isFinite(match.spotsAvailable)) { + if (match.spotsAvailable <= 0) return "Full"; + return `${match.spotsAvailable} ${match.spotsAvailable === 1 ? "spot" : "spots"}`; + } + return playerCapacityLabel; + })(); + const timeLabel = formatMatchTimeLabel(match); + const distanceLabel = formatDistanceLabel(match.distanceMiles); + const hostName = + match.hostName || + match.hostProfile?.full_name || + match.hostProfile?.fullName || + match.hostProfile?.name || + "Host"; + const hostNtrp = match.hostNtrp || match.hostProfile?.usta_rating || ""; + const participantStack = uniqueActiveParticipants(match.participants || []) + .slice(0, 5) + .map((participant, index) => { + const profile = participant?.profile || {}; + const name = + profile.full_name || + profile.fullName || + participant.full_name || + participant.fullName || + profile.name || + participant.name || + `Player ${index + 1}`; + return { + key: + participant.id || + participant.player_id || + participant.user_id || + `${match.id}-participant-${index}`, + name, + }; + }); + const extraParticipantCount = Math.max((Number(match.occupied) || 0) - participantStack.length, 0); + const genderSymbol = + genderLabel === "Men's" + ? "♂" + : genderLabel === "Women's" + ? "♀" + : genderLabel === "Mixed" + ? "⚥" + : ""; + const rosterTone = + rosterStatusLabel === "Full" + ? "bg-emerald-100 text-emerald-700" + : rosterStatusLabel.startsWith("1 ") || rosterStatusLabel.startsWith("2 ") + ? "bg-amber-100 text-amber-600" + : "bg-slate-100 text-slate-500"; return ( -
handleViewDetails(match.id)} + className={`relative flex min-h-[214px] flex-col overflow-hidden rounded-[18px] border border-slate-200 bg-white p-[22px] text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-lg ${ + isArchived ? "opacity-80" : "" }`} > -
+