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 && (
+
+
+
-
- {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" : ""
}`}
>
-
+
+
- {match.privacy === "open" && (
-
-
- OPEN
-
- )}
- {match.privacy === "private" && (
-
- PRIVATE
-
- )}
- {isHiddenListing && (
-
-
- LINK-ONLY
-
- )}
- {statusValue === "draft" && (
-
- DRAFT
-
- )}
- {statusValue === "cancelled" && (
-
- CANCELLED
-
- )}
- {isArchived && (
-
- ARCHIVED
+
+ {isPrivate ? : }
+ {isPrivate ? "Private" : "Open"}
+
+ {isHosted && (
+
+ Hosting
)}
- {isHosted && (
-
- HOSTING
+ {isJoined && !isHosted && (
+
+ Joined
)}
- {isJoined && (
-
- PLAYING
+ {match.verifiedOnly && (
+
+
+ Verified
)}
- {(isHosted || isJoined) && !isArchived && (
-
+ {timeLabel && (
+
{timeLabel}
)}
- {hasLowOccupancyAlert && (
-
-
-
-
-
-
- Need {spotsNeeded}{" "}
- {spotsNeeded === 1 ? "player" : "players"}{" "}
- {timeUntilStartLabel || "soon"}
-
-
- Roster: {rosterCount}
- {playerLimit ? ` / ${playerLimit}` : ""} players confirmed
-
-
-
-
-
-
- {showRecommendations && (
-
- {recommendationStatus === "loading" ? (
-
- Finding likely substitutes…
-
- ) : recommendationStatus === "error" ? (
-
{recommendationError}
- ) : recommendationStatus === "empty" ? (
-
- We couldn't find ready-made recommendations. Try the full invite tool for more options.
-
- ) : (
-
- )}
-
- )}
-
-
+
+
+ {match.format || "Match"}
+ · {skillRangeLabel}
+ {genderLabel !== "Any" && (
+
+ {genderSymbol && `${genderSymbol} `}
+ {genderLabel}
+
+ )}
+
+
+
+ {match.location || "Location TBA"}
+ {distanceLabel && · {distanceLabel}}
+
+
+
+ {!isMine && (
+
+
+ {getAvatarInitials(hostName).slice(0, 2)}
+
+
+ Hosted by {hostName}
+
+ {hostNtrp && (
+
+ NTRP {hostNtrp}
+
+ )}
)}
-
-
-
-
-
-
-
- {formatDateTime(match.dateTime)}
-
-
-
-
-
-
-
-
-
- {match.location ? (
- match.mapUrl ? (
-
- {match.location}
-
- ) : (
- match.location
- )
- ) : (
- Location details coming soon
- )}
-
-
{locationSubtitle}
-
-
-
-
-
-
-
-
{playerCapacityLabel}
- {match.spotsAvailable !== null && (
-
- {match.spotsAvailable > 0
- ? `${match.spotsAvailable} spot${
- match.spotsAvailable === 1 ? "" : "s"
- } available`
- : "Roster is full"}
-
+
+
+
+ {participantStack.map((player) => (
+
+ {getAvatarInitials(player.name).slice(0, 2)}
+
+ ))}
+ {extraParticipantCount > 0 && (
+
+ +{extraParticipantCount}
+
)}
+
+ {playerCapacityLabel}
+
- {match.skillLevel && (
-
-
-
-
-
-
- Skill level: {match.skillLevel}
-
- {getNTRPDisplay(match.skillLevel) && (
-
- Suggested NTRP {getNTRPDisplay(match.skillLevel)}
-
- )}
-
-
- )}
-
-
-
-
Actions
-
-
- {isHosted && !isArchived && (
-
- )}
- {match.type === "available" && isUpcoming && !isArchived && (
-
- )}
- {isArchived && (
-
- Archived matches are read-only
-
- )}
-
+
+ {rosterStatusLabel}
+
-
+
);
};
const MatchMenu = ({ type, matchId, onClose }) => {
@@ -7862,7 +7298,7 @@ const TennisMatchApp = () => {
goToInvites={goToInvites}
goToBrowse={() => goToBrowse()}
goToPlayers={goToPlayers}
- onOpenProfile={() => setShowProfileManager(true)}
+ onOpenProfile={() => openProfileManager("profile")}
onLogout={handleLogout}
onOpenSignIn={() => setShowSignInModal(true)}
setShowPreview={setShowPreview}
@@ -7870,7 +7306,20 @@ const TennisMatchApp = () => {
/>
{currentScreen === "browse" && BrowseScreen()}
- {currentScreen === "create" && CreateMatchScreen()}
+ {currentScreen === "create" && (
+
goToBrowse()}
+ onReturnHome={() => {
+ goToBrowse();
+ fetchMatches();
+ }}
+ onMatchCreated={() => {
+ fetchMatches();
+ fetchPendingInvites();
+ }}
+ />
+ )}
{currentScreen === "invite" && (
{
formatDateTime={formatDateTime}
/>
)}
+ {currentScreen === "groups" && (
+ openGroupDetail("new")}
+ onOpenGroup={openGroupDetail}
+ />
+ )}
+ {currentScreen === "group-detail" && (
+
+ )}
>
)}
@@ -7933,8 +7397,12 @@ const TennisMatchApp = () => {
{Toast()}
setShowProfileManager(false)}
+ onClose={() => {
+ setShowProfileManager(false);
+ setProfileManagerSection("profile");
+ }}
onProfileUpdate={mergeProfileDetails}
+ initialSection={profileManagerSection}
/>
);
diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx
index 56ddda7..0e5b8e1 100644
--- a/src/components/AppHeader.jsx
+++ b/src/components/AppHeader.jsx
@@ -150,6 +150,43 @@ const AppHeader = ({
/>
)}
+ ) : currentScreen === "groups" || currentScreen === "group-detail" ? (
+
+
+
+ 🎾
+
+
+ Matchplay
+
+
+ {currentUser && (
+
+
+
+ )}
+
) : (