From 990c959d967985124774459aea65f611ce468caa Mon Sep 17 00:00:00 2001 From: sahilkashyap64 Date: Wed, 29 Apr 2026 22:45:35 +0530 Subject: [PATCH 01/12] Redesign Match Play dashboard and attention cards --- .env | 4 +- src/TennisMatchApp.jsx | 2574 +++++++++----------------- src/components/MatchCreatorFlow.jsx | 354 +++- src/components/MatchDetailsModal.jsx | 91 +- src/services/matches.js | 19 +- 5 files changed, 1204 insertions(+), 1838 deletions(-) diff --git a/.env b/.env index ef21de05..f5afc7df 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 285344ee..b45557f4 100644 --- a/src/TennisMatchApp.jsx +++ b/src/TennisMatchApp.jsx @@ -5,7 +5,6 @@ import { createMatch, updateMatch, cancelMatch, - joinMatch, leaveMatch, removeParticipant, searchPlayers, @@ -20,12 +19,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 +46,6 @@ import { Menu, Bell, BellRing, - Star, TrendingUp, Award, Edit3, @@ -76,11 +71,13 @@ import { Trophy, Sparkles, Target, + RefreshCw, } 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 { @@ -96,7 +93,6 @@ import { } from "./utils/archive"; import { countUniqueMatchOccupants, - getParticipantPhone, idsMatch, pruneParticipantFromMatchData, uniqueAcceptedInvitees, @@ -105,16 +101,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 +121,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" }, + { 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", @@ -162,6 +177,128 @@ const deriveInviteMatchId = (path) => { return Number.isFinite(numeric) ? numeric : 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 getLevelIndex = (value) => { + const normalized = normalizeNtrpLevel(value); + return NTRP_LEVELS.findIndex((level) => level === normalized); +}; + +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 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 buildMapsUrl = (lat, lng, address) => { if (lat && lng) { return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`; @@ -710,7 +847,11 @@ 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); @@ -725,7 +866,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 +895,7 @@ const TennisMatchApp = () => { }); const [matches, setMatches] = useState([]); + const [attentionMatches, setAttentionMatches] = useState([]); const [matchCounts, setMatchCounts] = useState({ my: 0, open: 0, @@ -764,7 +905,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 +939,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 +959,6 @@ 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 totalSelectedInvitees = useMemo(() => { const normalizedExistingIds = existingPlayerIds instanceof Set @@ -980,10 +1113,6 @@ const TennisMatchApp = () => { window.localStorage.setItem("matchDistanceFilter", String(distanceFilter)); }, [distanceFilter]); - useEffect(() => { - setLocationSearchTerm(locationFilter?.label || ""); - }, [locationFilter]); - useEffect(() => { const storedUser = localStorage.getItem("user"); if (storedUser) { @@ -1395,13 +1524,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 +1541,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, @@ -1480,6 +1576,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; @@ -1817,6 +1915,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 +1964,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, @@ -1938,6 +2071,138 @@ const TennisMatchApp = () => { currentUser, ]); + const fetchAttentionMatches = useCallback(async () => { + if (!currentUser) { + setAttentionMatches([]); + return; + } + + try { + const data = await listMatches("my", { + perPage: 50, + includeHidden: true, + }); + const rawMatches = 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 || status !== "upcoming" || !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 spotsNeeded = + 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, + playerLimit, + hoursUntilStart: + hoursUntilStartRaw !== null + ? Math.max(Math.round(hoursUntilStartRaw * 10) / 10, 0) + : null, + 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"; @@ -2249,57 +2514,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]); @@ -2580,78 +2813,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); @@ -2953,18 +3114,82 @@ const TennisMatchApp = () => { }) : matchesWithDistance; - return sortMatchesByRecency(baseMatches); + const filteredMatches = baseMatches.filter((match) => { + if (selectedDayKey) { + const startDate = getMatchStartDate(match); + if (!startDate || formatDayKey(startDate) !== selectedDayKey) { + return false; + } + } + + if (selectedLevelFilter !== "Any") { + const selectedIndex = getLevelIndex(selectedLevelFilter); + const minIndex = getLevelIndex(match.skillLevelMin || match.skillLevel); + const maxIndex = getLevelIndex(match.skillLevelMax || match.skillLevelMin || match.skillLevel); + if (selectedIndex < 0) return false; + if (minIndex >= 0 && selectedIndex < minIndex) return false; + if (maxIndex >= 0 && selectedIndex > maxIndex) return false; + } + + if (selectedFormatFilter !== "Any") { + const normalizedFormat = (match.format || "").toString().trim(); + if (normalizedFormat !== selectedFormatFilter) return false; + } + + if (selectedGenderFilter !== "Any") { + const gender = (match.gender || "Any").toString().trim(); + if (gender !== "Any" && gender !== selectedGenderFilter) return false; + } + + return true; + }); + + return sortMatchesByRecency(filteredMatches); }, [ distanceFilter, hasLocationFilter, matchesWithDistance, + selectedDayKey, + selectedFormatFilter, + selectedGenderFilter, + selectedLevelFilter, 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 label = date + ? date.toLocaleDateString("en-US", { + weekday: "long", + month: "short", + day: "numeric", + }) + : "Date TBA"; + const group = { key, label, matches: [] }; + groupMap.set(key, group); + groups.push(group); + } + groupMap.get(key).matches.push(match); + }); + return groups; + }, [displayedMatches]); const matchesNeedingAttention = useMemo(() => { const getTimestamp = (match) => { @@ -2982,391 +3207,10 @@ 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 }, - ]; - - for (const { limit, unit, divisor } of units) { - if (absSeconds < limit) { - const value = Math.round(diffSeconds / divisor); - return formatter.format(value, unit); - } - } - - const years = Math.round(diffSeconds / 31557600); - return formatter.format(years, "year"); - }, []); - - const activityFeedItems = useMemo(() => { - if (!currentUser) return []; - - 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, - ]); + }, [attentionMatches]); const getMatchCount = useCallback( (filterId) => { @@ -3384,1203 +3228,468 @@ const TennisMatchApp = () => { [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."} -

-
-
- - -
-
-
-
+ const BrowseScreen = () => { + const topAttentionMatches = matchesNeedingAttention.slice(0, 3); + + const renderFilterChip = ({ key, active, onClick, children, disabled = false }) => ( + + ); - {currentUser ? ( - <> -
-
-
-
-
+ {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. +

+
+ +
+ + {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 ( +
+
+ + + 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; - }); +
+ + setMatchSearch(e.target.value)} + className="h-10 w-full rounded-xl border border-slate-200 bg-white pl-11 pr-4 text-sm font-semibold text-slate-700 outline-none transition focus:border-violet-300 focus:ring-2 focus:ring-violet-100" + /> +
+ - 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, - ]); +
+
+

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 ( + + ); + })} +
+
- useEffect(() => { - if (showRecommendations && recommendationStatus === "idle") { - loadRecommendations(); - } - }, [loadRecommendations, recommendationStatus, showRecommendations]); +
+
+ Level + {["Any", ...NTRP_LEVELS].map((level) => + renderFilterChip({ + key: `level-${level}`, + active: selectedLevelFilter === level, + onClick: () => setSelectedLevelFilter(level), + children: level, + }), + )} +
+
+
+ Format + {DISCOVERY_FORMAT_FILTERS.map((format) => + renderFilterChip({ + key: `format-${format}`, + active: selectedFormatFilter === format, + onClick: () => setSelectedFormatFilter(format), + children: format, + }), + )} +
+
- 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; - } +
+ Gender + {DISCOVERY_GENDER_FILTERS.map((gender) => + renderFilterChip({ + key: `gender-${gender}`, + active: selectedGenderFilter === gender, + onClick: () => setSelectedGenderFilter(gender), + children: gender, + }), + )} +
+
+ - setInviteProgress((prev) => ({ ...prev, [pid]: "sending" })); + {hasLocationFilter && displayedMatches.length === 0 && ( +
+ No matches within {distanceFilter} miles of your location yet. Try expanding the distance filter or check back soon. +
+ )} - 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], +
+ {groupedDisplayedMatches.map((group) => ( +
+
+

+ {group.label.split(",")[0]} +

+ + {group.label.includes(",") ? `· ${group.label.split(",").slice(1).join(",").trim()}` : ""} + +
+ + {group.matches.length} {group.matches.length === 1 ? "match" : "matches"} + +
+
+ {group.matches.map((match) => ( + + ))} +
+
+ ))} +
+ + ) : ( +
+

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

+ +
+ )} +
); + }; - const getNTRPDisplay = (skillLevel) => { - if (!skillLevel || skillLevel === "Any Level") return null; - const ntrp = skillLevel.split(" - ")[0]; - return ntrp; - }; - - 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`; - }; - - 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 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 = (() => { + const date = getMatchStartDate(match); + if (!date) return ""; + return date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + }); + })(); + const distanceLabel = Number.isFinite(match.distanceMiles) + ? `${Number.isInteger(match.distanceMiles) ? match.distanceMiles : match.distanceMiles.toFixed(1)} mi` + : ""; + 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, + }; + }); return ( -
handleViewDetails(match.id)} + className={`relative min-h-[158px] overflow-hidden rounded-2xl border border-slate-200 bg-white p-5 text-left shadow-sm transition hover:-translate-y-0.5 hover:shadow-md ${ + isArchived ? "opacity-80" : "" }`} > -
+
-
+
{ - setContactName(e.target.value); + setContactFirstName(e.target.value); setContactError(""); }} - placeholder="Full name (optional)" + placeholder="First name" + className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-400 focus:border-transparent" + /> + { + setContactLastName(e.target.value); + setContactError(""); + }} + placeholder="Last name" className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-400 focus:border-transparent" />
@@ -1595,9 +1767,32 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser {matchData.format} - {matchData.type === "open" && matchData.skillLevel && ` • NTRP ${matchData.skillLevel}`} + {matchData.type === "open" && + matchData.skillLevelMin && + ` • NTRP ${matchData.skillLevelMin}${ + matchData.skillLevelMax && + matchData.skillLevelMax !== matchData.skillLevelMin + ? `-${matchData.skillLevelMax}` + : "" + }`}
+ {matchData.type === "open" && ( + <> +
+ + + {matchData.gender} category • {matchData.balls} + +
+ {matchData.verifiedOnly && ( +
+ + Verified rating required +
+ )} + + )} {matchData.type === "open" && isLinkOnlyListing && (
@@ -1672,12 +1867,12 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser
{matchData.type === "private" ? ( @@ -1709,7 +1904,7 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser className={`flex-1 px-6 py-4 text-white rounded-xl font-semibold transition-colors flex items-center justify-center gap-2 ${ matchData.type === "private" ? "bg-blue-600 hover:bg-blue-700" - : "bg-green-600 hover:bg-green-700" + : "bg-violet-600 hover:bg-violet-700" } ${creating ? "opacity-70 cursor-not-allowed" : ""}`} > {creating @@ -1729,7 +1924,7 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser
@@ -1859,7 +2054,7 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser className={`w-full px-6 py-4 text-white rounded-xl font-semibold transition-colors ${ matchData.type === "private" ? "bg-blue-600 hover:bg-blue-700" - : "bg-green-600 hover:bg-green-700" + : "bg-violet-600 hover:bg-violet-700" }`} > View Match Details @@ -1936,20 +2131,19 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser : `${totalPlayers} players total`}
- {matchData.type === "open" && matchData.skillLevel && ( + {matchData.type === "open" && matchData.skillLevelMin && (
- NTRP {matchData.skillLevel} + + NTRP {matchData.skillLevelMin} + {matchData.skillLevelMax && + matchData.skillLevelMax !== matchData.skillLevelMin + ? `-${matchData.skillLevelMax}` + : ""} +
)}
-
-
- - Perfect tennis weather -
- 72°F • Sunny -

Hosted by

{currentUser?.name || "You"}

diff --git a/src/components/MatchDetailsModal.jsx b/src/components/MatchDetailsModal.jsx index 7714073f..7b617d79 100644 --- a/src/components/MatchDetailsModal.jsx +++ b/src/components/MatchDetailsModal.jsx @@ -320,6 +320,37 @@ const getSkillLevelDisplay = (match) => { return minDisplay || maxDisplay || ""; }; +const getMatchGenderDisplay = (match) => { + const value = + match?.gender ?? + match?.category ?? + match?.match_gender ?? + match?.matchGender ?? + ""; + return typeof value === "string" ? value.trim() : value ? String(value) : ""; +}; + +const getMatchBallsDisplay = (match) => { + const value = + match?.balls ?? + match?.ball_policy ?? + match?.ballPolicy ?? + match?.balls_policy ?? + match?.ballsPolicy ?? + ""; + return typeof value === "string" ? value.trim() : value ? String(value) : ""; +}; + +const getVerifiedOnlyFlag = (match) => + Boolean( + match?.verifiedOnly ?? + match?.verified_only ?? + match?.require_verified_rating ?? + match?.requireVerifiedRating ?? + match?.verified_rating_required ?? + match?.verifiedRatingRequired, + ); + const buildMatchDescription = ({ match, hostName }) => { if (!match) return "Tennis match"; const parts = []; @@ -857,6 +888,9 @@ const MatchDetailsModal = ({ return isLinkOnlyVisibility(match); }, [listingVisibility, match]); const suggestedSkillLevel = useMemo(() => getSkillLevelDisplay(match), [match]); + const genderDisplay = useMemo(() => getMatchGenderDisplay(match), [match]); + const ballsDisplay = useMemo(() => getMatchBallsDisplay(match), [match]); + const verifiedOnly = useMemo(() => getVerifiedOnlyFlag(match), [match]); const participants = useMemo(() => { if (Array.isArray(matchData?.participants)) { return uniqueParticipants(matchData.participants); @@ -2119,7 +2153,7 @@ const MatchDetailsModal = ({ const canRemove = isHost && !player.isHost && !isArchived && !isCancelled; const phoneLink = - player.phoneDisplay && player.phoneHref ? ( + isHost && player.phoneDisplay && player.phoneHref ? ( - Suggested level: {suggestedSkillLevel} + NTRP {suggestedSkillLevel} + + )} + {isOpenMatch && genderDisplay && genderDisplay !== "Any" && ( + + {genderDisplay} + + )} + {isOpenMatch && verifiedOnly && ( + + Verified-only )} {Number.isFinite(capacityLimit) && ( @@ -2618,22 +2662,41 @@ const MatchDetailsModal = ({
-
-
- +
+ +

Match info

+
+
+
+

Format

+

+ {match.match_format || match.format || "TBA"} +

-
-

Match Type

-

- {match.match_format || match.format || "Details coming soon"} +

+

Level

+

+ {suggestedSkillLevel || "All levels"} +

+
+
+

Category

+

+ {genderDisplay || "Any"} +

+
+
+

Balls

+

+ {ballsDisplay || "Not specified"}

- {suggestedSkillLevel && ( -

- {isOpenMatch ? "Suggested level" : "Skill level"}: {suggestedSkillLevel} -

- )}
+ {verifiedOnly && ( +
+ Verified rating required +
+ )}
diff --git a/src/services/matches.js b/src/services/matches.js index 0f025b31..e983d684 100644 --- a/src/services/matches.js +++ b/src/services/matches.js @@ -256,27 +256,12 @@ export const updateMatch = (id, updates) => }) ); -export const cancelMatch = async (id) => { - try { - return await unwrap( - api(`/matches/${id}/cancel`, { - method: "POST", - json: { match_id: id }, - }), - ); - } catch (error) { - const status = Number(error?.status ?? error?.response?.status); - if (status && ![404, 405].includes(status)) { - throw error; - } - } - - return unwrap( +export const cancelMatch = (id) => + unwrap( api(`/matches/${id}`, { method: "DELETE", }) ); -}; export const joinMatch = (id) => unwrap( From e0b1b175dff0c88a98702941d274fd97ead1aedc Mon Sep 17 00:00:00 2001 From: sahilkashyap64 Date: Thu, 30 Apr 2026 02:00:55 +0530 Subject: [PATCH 02/12] new apis added - GET /matches/attention - POST /matches/:id/cancel with fallback to DELETE /matches/:id - POST /matches/:id/notify - GET /matches/:id/notifications - DELETE /matches/:id/notifications/:notificationId - GET /matches/:id/messages - POST /matches/:id/messages - POST /matches/:id/players/:playerId/dm --- src/TennisMatchApp.jsx | 21 ++++++-- src/components/MatchCreatorFlow.jsx | 2 + src/services/matchGroups.js | 43 +++++++++++++++ src/services/matches.js | 84 +++++++++++++++++++++++++++-- 4 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 src/services/matchGroups.js diff --git a/src/TennisMatchApp.jsx b/src/TennisMatchApp.jsx index b45557f4..35b30851 100644 --- a/src/TennisMatchApp.jsx +++ b/src/TennisMatchApp.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from "react" import { useLocation, useNavigate } from "react-router-dom"; import { listMatches, + listAttentionMatches, createMatch, updateMatch, cancelMatch, @@ -2078,10 +2079,22 @@ const TennisMatchApp = () => { } try { - const data = await listMatches("my", { - perPage: 50, - includeHidden: true, - }); + 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 rawMatches = Array.isArray(data?.matches) ? data.matches : []; const now = Date.now(); const memberIds = memberIdentityIds; diff --git a/src/components/MatchCreatorFlow.jsx b/src/components/MatchCreatorFlow.jsx index 31b90866..7903cebd 100644 --- a/src/components/MatchCreatorFlow.jsx +++ b/src/components/MatchCreatorFlow.jsx @@ -550,6 +550,8 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser status: "upcoming", match_type: matchData.type === "private" ? "private" : "open", start_date_time: isoStart, + durationMinutes: Math.round(parseFloat(matchData.duration || "2") * 60), + duration_minutes: Math.round(parseFloat(matchData.duration || "2") * 60), location_text: matchData.location, latitude: matchData.latitude ?? undefined, longitude: matchData.longitude ?? undefined, diff --git a/src/services/matchGroups.js b/src/services/matchGroups.js new file mode 100644 index 00000000..a0086833 --- /dev/null +++ b/src/services/matchGroups.js @@ -0,0 +1,43 @@ +import api, { unwrap } from "./api"; + +const pickGroups = (data) => { + if (Array.isArray(data?.groups)) return data.groups; + if (Array.isArray(data?.data)) return data.data; + if (Array.isArray(data?.items)) return data.items; + if (Array.isArray(data)) return data; + return []; +}; + +export const listMatchGroups = async () => { + const data = await unwrap(api("/player/match-groups")); + return { + ...(data && typeof data === "object" && !Array.isArray(data) ? data : {}), + groups: pickGroups(data), + }; +}; + +export const getMatchGroup = (id) => + unwrap(api(`/player/match-groups/${id}`)); + +export const createMatchGroup = (payload = {}) => + unwrap( + api("/player/match-groups", { + method: "POST", + json: payload, + }), + ); + +export const updateMatchGroup = (id, payload = {}) => + unwrap( + api(`/player/match-groups/${id}`, { + method: "PATCH", + json: payload, + }), + ); + +export const deleteMatchGroup = (id) => + unwrap( + api(`/player/match-groups/${id}`, { + method: "DELETE", + }), + ); diff --git a/src/services/matches.js b/src/services/matches.js index e983d684..f85d628a 100644 --- a/src/services/matches.js +++ b/src/services/matches.js @@ -256,12 +256,49 @@ export const updateMatch = (id, updates) => }) ); -export const cancelMatch = (id) => - unwrap( +export const listAttentionMatches = ({ + limit, + withinHours, + within_hours, +} = {}) => { + const params = {}; + if (limit !== undefined && limit !== null && limit !== "") { + params.limit = limit; + } + const normalizedWithinHours = + withinHours ?? within_hours; + if ( + normalizedWithinHours !== undefined && + normalizedWithinHours !== null && + normalizedWithinHours !== "" + ) { + params.withinHours = normalizedWithinHours; + params.within_hours = normalizedWithinHours; + } + return unwrap(api(`/matches/attention${qs(params)}`)).then(normalizeMatchesResponse); +}; + +export const cancelMatch = async (id) => { + try { + return await unwrap( + api(`/matches/${id}/cancel`, { + method: "POST", + json: { match_id: id }, + }), + ); + } catch (error) { + const status = Number(error?.status ?? error?.response?.status); + if (status && ![404, 405].includes(status)) { + throw error; + } + } + + return unwrap( api(`/matches/${id}`, { method: "DELETE", - }) + }), ); +}; export const joinMatch = (id) => unwrap( @@ -299,6 +336,47 @@ export const sendInvites = (matchId, { playerIds = [], phoneNumbers = [] } = {}) export const getShareLink = (matchId) => unwrap(api(`/matches/${matchId}/share-link`)); +export const notifyMatchPlayers = (matchId, payload = {}) => + unwrap( + api(`/matches/${matchId}/notify`, { + method: "POST", + json: payload, + }), + ); + +export const listMatchNotifications = (matchId) => + unwrap(api(`/matches/${matchId}/notifications`)); + +export const deleteMatchNotification = (matchId, notificationId) => + unwrap( + api(`/matches/${matchId}/notifications/${notificationId}`, { + method: "DELETE", + }), + ); + +export const listMatchMessages = (matchId) => + unwrap(api(`/matches/${matchId}/messages`)); + +export const createMatchMessage = (matchId, payload = {}) => + unwrap( + api(`/matches/${matchId}/messages`, { + method: "POST", + json: payload, + }), + ); + +export const sendMatchPlayerDirectMessage = ( + matchId, + playerId, + payload = {}, +) => + unwrap( + api(`/matches/${matchId}/players/${playerId}/dm`, { + method: "POST", + json: payload, + }), + ); + export const searchPlayers = ({ search = "", page = 1, perPage = 12, ids } = {}) => { const params = { search, page, perPage }; if (ids && ids.length) params.ids = ids.join(","); From 4d52eff8c2e280d7ba2bd0630a75828ad9ae91be Mon Sep 17 00:00:00 2001 From: sahilkashyap64 Date: Thu, 30 Apr 2026 16:23:58 +0530 Subject: [PATCH 03/12] =?UTF-8?q?TennisMatchApp.jsx=20now=20consumes=20GET?= =?UTF-8?q?=20/api/matches/attention=20correctly,=20including=20the=20new?= =?UTF-8?q?=20{=20items:=20[{=20match,=20alert=20}]=20}=20response=20shape?= =?UTF-8?q?.=20Match=20details=20now=20support=20backend-powered:=20notify?= =?UTF-8?q?ing=20players=20viewing/removing=20notifications=20match=20mess?= =?UTF-8?q?ages=20direct=20player=20messages=20Profile=20modal=20now=20has?= =?UTF-8?q?=20=E2=80=9CMy=20Match=20Groups=E2=80=9D=20UI=20using=20the=20n?= =?UTF-8?q?ew=20group=20APIs.=20Private=20match=20creation=20can=20now=20i?= =?UTF-8?q?nvite=20all=20players=20from=20saved=20groups.=20src/services/m?= =?UTF-8?q?atches.js=20now=20handles=20the=20attention=20API=20response=20?= =?UTF-8?q?properly.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/TennisMatchApp.jsx | 45 ++- src/components/MatchCreatorFlow.jsx | 117 +++++++ src/components/MatchDetailsModal.jsx | 488 +++++++++++++++++++++++---- src/components/ProfileManager.jsx | 284 +++++++++++++++- src/services/matches.js | 2 +- 5 files changed, 850 insertions(+), 86 deletions(-) diff --git a/src/TennisMatchApp.jsx b/src/TennisMatchApp.jsx index 35b30851..f410fd27 100644 --- a/src/TennisMatchApp.jsx +++ b/src/TennisMatchApp.jsx @@ -2095,7 +2095,24 @@ const TennisMatchApp = () => { includeHidden: true, }); } - const rawMatches = Array.isArray(data?.matches) ? data.matches : []; + 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; @@ -2122,7 +2139,11 @@ const TennisMatchApp = () => { hoursUntilStartRaw >= 0 && hoursUntilStartRaw <= 48; - if (!isHost || status !== "upcoming" || !isUpcomingSoon) { + if ( + !isHost || + !["upcoming", "open"].includes(status) || + !isUpcomingSoon + ) { return null; } @@ -2148,8 +2169,12 @@ const TennisMatchApp = () => { Number.isFinite(confirmedFromCapacity) && confirmedFromCapacity >= 0 ? confirmedFromCapacity : activeParticipants.length; + const alertInfo = match?.alerts?.lowOccupancy || {}; + const openFromAlert = Number(alertInfo.spotsNeeded); const spotsNeeded = - Number.isFinite(openFromCapacity) && openFromCapacity >= 0 + Number.isFinite(openFromAlert) && openFromAlert >= 0 + ? openFromAlert + : Number.isFinite(openFromCapacity) && openFromCapacity >= 0 ? openFromCapacity : playerLimit !== null ? Math.max(playerLimit - rosterCount, 0) @@ -2188,14 +2213,14 @@ const TennisMatchApp = () => { alerts: { lowOccupancy: { active: true, - spotsNeeded, - rosterCount, - playerLimit, - hoursUntilStart: - hoursUntilStartRaw !== null + spotsNeeded, + rosterCount: alertInfo.rosterCount ?? rosterCount, + playerLimit: alertInfo.playerLimit ?? playerLimit, + hoursUntilStart: + alertInfo.hoursUntilStart ?? (hoursUntilStartRaw !== null ? Math.max(Math.round(hoursUntilStartRaw * 10) / 10, 0) - : null, - startTime: startDate ? startDate.toISOString() : null, + : null), + startTime: alertInfo.startTime || (startDate ? startDate.toISOString() : null), }, }, }; diff --git a/src/components/MatchCreatorFlow.jsx b/src/components/MatchCreatorFlow.jsx index 7903cebd..d907a168 100644 --- a/src/components/MatchCreatorFlow.jsx +++ b/src/components/MatchCreatorFlow.jsx @@ -50,6 +50,7 @@ import { recordRecentPlayer as persistRecentPlayer, RECENT_PLAYERS_EVENT, } from "../utils/recentPlayers"; +import { getMatchGroup, listMatchGroups } from "../services/matchGroups"; const HOURS_IN_MS = 60 * 60 * 1000; const MAX_PRIVATE_INVITES = 30; @@ -276,6 +277,8 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser const [isFormatManuallySelected, setIsFormatManuallySelected] = useState(false); const [recentLocations, setRecentLocations] = useState(() => loadStoredLocations()); const [recentPlayers, setRecentPlayers] = useState(() => loadStoredRecentPlayers()); + const [matchGroups, setMatchGroups] = useState([]); + const [groupsLoading, setGroupsLoading] = useState(false); useEffect(() => { const syncRecentLocations = () => { @@ -317,6 +320,31 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser }; }, [loadStoredRecentPlayers]); + useEffect(() => { + if (matchData.type !== "private") return; + let cancelled = false; + const loadGroups = async () => { + try { + setGroupsLoading(true); + const data = await listMatchGroups(); + if (!cancelled) { + setMatchGroups(Array.isArray(data?.groups) ? data.groups : []); + } + } catch (error) { + if (!cancelled) { + console.error("Failed to load match groups", error); + setMatchGroups([]); + } + } finally { + if (!cancelled) setGroupsLoading(false); + } + }; + loadGroups(); + return () => { + cancelled = true; + }; + }, [matchData.type]); + const currentUserAvatarUrl = useMemo( () => getAvatarUrlFromPlayer(currentUser), [currentUser], @@ -456,6 +484,46 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser setRecentPlayers(nextRecent); }; + const handleInviteGroup = async (groupId) => { + if (!canInviteMore()) return; + try { + const group = await getMatchGroup(groupId); + const members = Array.isArray(group?.members) ? group.members : []; + const normalizedMembers = members + .map((member) => + normalizePlayer({ + ...member, + id: member.player_id ?? member.user_id ?? member.id, + }), + ) + .filter((member) => Number.isFinite(member.id)); + const existingIds = new Set(invitedPlayers.map((player) => Number(player.id))); + const remainingCapacity = Math.max(MAX_PRIVATE_INVITES - invitedCount, 0); + const toAdd = normalizedMembers + .filter((member) => { + if (currentUserId !== null && Number(member.id) === currentUserId) return false; + return !existingIds.has(Number(member.id)); + }) + .slice(0, remainingCapacity); + if (!toAdd.length) { + showToast("Everyone in that group is already selected.", "info"); + return; + } + setMatchData((prev) => ({ + ...prev, + invitedPlayers: [...(prev.invitedPlayers || []), ...toAdd], + })); + toAdd.forEach((player) => { + persistRecentPlayer(player); + }); + setRecentPlayers(loadStoredRecentPlayers()); + showToast(`Added ${toAdd.length} player${toAdd.length === 1 ? "" : "s"} from group.`); + } catch (error) { + console.error(error); + showToast(error.message || "Failed to invite group", "error"); + } + }; + const handleRemovePlayer = (playerId) => { setMatchData((prev) => ({ ...prev, @@ -1495,6 +1563,55 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser

🎯 Smart strategy: Invite more than {totalPlayers - 1} players to guarantee a full match!

+
+
+
+

+ Your groups +

+

+ Add regular crews from your player profile. +

+
+ {groupsLoading && ( + + Loading... + + )} +
+ {matchGroups.length === 0 && !groupsLoading ? ( +

+ No groups yet. Create groups from My groups in your profile. +

+ ) : ( +
+ {matchGroups.slice(0, 4).map((group) => ( +
+
+
+

+ {group.name} +

+

+ {group.member_count || 0} players +

+
+ +
+
+ ))} +
+ )} +
{quickAddPlayers.length > 0 && (
diff --git a/src/components/MatchDetailsModal.jsx b/src/components/MatchDetailsModal.jsx index 7b617d79..85165c76 100644 --- a/src/components/MatchDetailsModal.jsx +++ b/src/components/MatchDetailsModal.jsx @@ -15,6 +15,7 @@ import { MessageCircle, Phone, Pencil, + Plus, Send, Share2, Sparkles, @@ -26,10 +27,17 @@ import { import PlayerAvatar from "./PlayerAvatar"; import { cancelMatch, + createMatchMessage, + deleteMatchNotification, getShareLink, joinMatch, leaveMatch, + listMatchMessages, + listMatchNotifications, + notifyMatchPlayers, removeParticipant, + searchPlayers, + sendMatchPlayerDirectMessage, updateMatch, } from "../services/matches"; import { rejectInvite } from "../services/invites"; @@ -545,48 +553,6 @@ const getInviteStatus = (invite) => { return ""; }; -const openSmsComposer = (recipients, onToast) => { - if (!Array.isArray(recipients) || recipients.length === 0) { - return; - } - - try { - 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..."; - onToast?.(toastMessage); - - if (typeof window !== "undefined") { - window.location.href = url; - } - } catch (error) { - console.error(error); - onToast?.("We couldn't open messages", "error"); - } -}; - const ACCEPTED_INVITE_STATUSES = new Set([ "accepted", "confirmed", @@ -836,6 +802,16 @@ const MatchDetailsModal = ({ const [decliningInvite, setDecliningInvite] = useState(false); const [recentLocations, setRecentLocations] = useState(() => loadStoredLocations()); const [showSavedLocations, setShowSavedLocations] = useState(false); + const [matchNotifications, setMatchNotifications] = useState([]); + const [notificationsLoading, setNotificationsLoading] = useState(false); + const [notifySearch, setNotifySearch] = useState(""); + const [notifyResults, setNotifyResults] = useState([]); + const [notifySelected, setNotifySelected] = useState([]); + const [notifySending, setNotifySending] = useState(false); + const [messageText, setMessageText] = useState(""); + const [messages, setMessages] = useState([]); + const [messagesLoading, setMessagesLoading] = useState(false); + const [messageSending, setMessageSending] = useState(false); const googleApiKey = import.meta.env.VITE_GOOGLE_API_KEY; const shareCopyTimeoutRef = useRef(null); @@ -1193,6 +1169,217 @@ const MatchDetailsModal = ({ onManageInvites(matchId); }, [canManageInvites, matchId, onManageInvites]); + const normalizePlayerId = useCallback( + (player) => Number(player?.user_id ?? player?.player_id ?? player?.playerId ?? player?.id), + [], + ); + + const loadMatchNotifications = useCallback(async () => { + if (!matchId || !isHost || !isOpenMatch) { + setMatchNotifications([]); + return; + } + try { + setNotificationsLoading(true); + const data = await listMatchNotifications(matchId); + setMatchNotifications(Array.isArray(data?.notifications) ? data.notifications : []); + } catch (error) { + console.error("Failed to load match notifications", error); + setMatchNotifications([]); + } finally { + setNotificationsLoading(false); + } + }, [isHost, isOpenMatch, matchId]); + + const loadMatchMessages = useCallback(async () => { + if (!matchId || !isJoined) { + setMessages([]); + return; + } + try { + setMessagesLoading(true); + const data = await listMatchMessages(matchId); + setMessages(Array.isArray(data?.messages) ? data.messages : []); + } catch (error) { + console.error("Failed to load match messages", error); + setMessages([]); + } finally { + setMessagesLoading(false); + } + }, [isJoined, matchId]); + + useEffect(() => { + loadMatchNotifications(); + }, [loadMatchNotifications]); + + useEffect(() => { + loadMatchMessages(); + }, [loadMatchMessages]); + + useEffect(() => { + if (!isOpen || !isHost || !isOpenMatch) return undefined; + const query = notifySearch.trim(); + if (query.length < 2) { + setNotifyResults([]); + return undefined; + } + + let cancelled = false; + const timeout = setTimeout(async () => { + try { + const data = await searchPlayers({ search: query, perPage: 8 }); + if (cancelled) return; + const selectedIds = new Set(notifySelected.map(normalizePlayerId)); + const notifiedIds = new Set( + matchNotifications.map((item) => Number(item.player_id)), + ); + const participantIds = new Set( + participants.map((item) => Number(item.player_id ?? item.playerId)), + ); + const players = (Array.isArray(data?.players) ? data.players : []).filter((player) => { + const id = normalizePlayerId(player); + return ( + Number.isFinite(id) && + !selectedIds.has(id) && + !notifiedIds.has(id) && + !participantIds.has(id) + ); + }); + setNotifyResults(players); + } catch (error) { + if (!cancelled) { + console.error("Failed to search players to notify", error); + setNotifyResults([]); + } + } + }, 250); + + return () => { + cancelled = true; + clearTimeout(timeout); + }; + }, [ + isHost, + isOpen, + isOpenMatch, + matchNotifications, + normalizePlayerId, + notifySearch, + notifySelected, + participants, + ]); + + const handleAddNotifyPlayer = useCallback( + (player) => { + const id = normalizePlayerId(player); + if (!Number.isFinite(id)) return; + setNotifySelected((prev) => { + if (prev.some((item) => normalizePlayerId(item) === id)) return prev; + return [...prev, player]; + }); + setNotifySearch(""); + setNotifyResults([]); + }, + [normalizePlayerId], + ); + + const handleSendNotifications = useCallback(async () => { + if (!matchId || notifySelected.length === 0) return; + const playerIds = notifySelected + .map(normalizePlayerId) + .filter((id) => Number.isFinite(id)); + if (!playerIds.length) return; + try { + setNotifySending(true); + const response = await notifyMatchPlayers(matchId, { playerIds }); + setNotifySelected([]); + setNotifySearch(""); + setNotifyResults([]); + setMatchNotifications(Array.isArray(response?.notifications) ? response.notifications : []); + onToast?.(response?.message || `Notified ${playerIds.length} player(s).`); + onMatchRefresh?.(); + } catch (error) { + console.error(error); + onToast?.( + error?.response?.data?.message || + error?.response?.data?.error || + error?.message || + "Failed to notify players", + "error", + ); + } finally { + setNotifySending(false); + } + }, [matchId, normalizePlayerId, notifySelected, onMatchRefresh, onToast]); + + const handleDeleteNotification = useCallback( + async (notificationId) => { + if (!matchId || !notificationId) return; + try { + await deleteMatchNotification(matchId, notificationId); + setMatchNotifications((prev) => + prev.filter((item) => Number(item.id) !== Number(notificationId)), + ); + } catch (error) { + console.error(error); + onToast?.("Failed to remove notification", "error"); + } + }, + [matchId, onToast], + ); + + const handleSendGroupMessage = useCallback(async () => { + const body = messageText.trim(); + if (!matchId || !body) return; + try { + setMessageSending(true); + const response = await createMatchMessage(matchId, { body }); + setMessageText(""); + if (response?.message) { + setMessages((prev) => [...prev, response.message]); + } else { + await loadMatchMessages(); + } + onToast?.("Message posted."); + } catch (error) { + console.error(error); + onToast?.( + error?.response?.data?.message || + error?.response?.data?.error || + error?.message || + "Failed to send message", + "error", + ); + } finally { + setMessageSending(false); + } + }, [loadMatchMessages, matchId, messageText, onToast]); + + const handleSendDm = useCallback( + async (player) => { + if (!matchId || !player?.playerId) return; + const body = window.prompt(`Message ${player.name}`); + if (!body || !body.trim()) return; + try { + await sendMatchPlayerDirectMessage(matchId, player.playerId, { + body: body.trim(), + }); + onToast?.(`Message sent to ${player.name}.`); + loadMatchMessages(); + } catch (error) { + console.error(error); + onToast?.( + error?.response?.data?.message || + error?.response?.data?.error || + error?.message || + "Failed to send direct message", + "error", + ); + } + }, + [loadMatchMessages, matchId, onToast], + ); + useEffect(() => { if ((!isHost || isArchived || isCancelled) && isEditing) { setIsEditing(false); @@ -1775,31 +1962,10 @@ const MatchDetailsModal = ({ return Math.max(remainingSpots, 0); }, [remainingSpots]); - const participantPhoneRecipients = useMemo(() => { - if (!isHost) return []; - const seen = new Set(); - return committedParticipants.reduce((numbers, participant) => { - if (!participant) return numbers; - if (match?.host_id && participantMatchesMember(participant, match.host_id)) { - return numbers; - } - const phoneRaw = getParticipantPhone(participant); - const normalized = normalizePhoneValue(phoneRaw); - if (!normalized || seen.has(normalized)) { - return numbers; - } - seen.add(normalized); - numbers.push(normalized); - return numbers; - }, []); - }, [committedParticipants, isHost, match?.host_id]); - - const canMessageParticipants = participantPhoneRecipients.length > 0; + const canMessageParticipants = committedParticipants.length > 1; const messageParticipantsDescription = canMessageParticipants - ? participantPhoneRecipients.length === 1 - ? "Start a group text with the confirmed player." - : "Start a group text with your confirmed players." - : "Add player phone numbers to enable group texts."; + ? "Post a group update to confirmed players." + : "Add another confirmed player to enable group messages."; const pendingInvitesList = useMemo(() => { if (pendingInvitees.length === 0) return []; @@ -2152,6 +2318,12 @@ const MatchDetailsModal = ({ typeof onViewPlayerProfile === "function" || Boolean(player.profileUrl); const canRemove = isHost && !player.isHost && !isArchived && !isCancelled; + const canDm = + isJoined && + !player.isHost && + !idsMatch(player.playerId, currentUser?.id) && + !isArchived && + !isCancelled; const phoneLink = isHost && player.phoneDisplay && player.phoneHref ? ( - {(phoneLink || canRemove) && ( + {(phoneLink || canDm || canRemove) && (
{phoneLink} + {canDm && ( + + )} {canRemove && (
+
+ setMessageText(event.target.value)} + placeholder={canMessageParticipants ? "Write a group update..." : messageParticipantsDescription} + disabled={!canMessageParticipants || messageSending} + className="min-h-10 flex-1 rounded-xl border border-emerald-100 bg-white px-3 py-2 text-sm font-semibold text-gray-700 outline-none focus:border-emerald-300 focus:ring-2 focus:ring-emerald-100 disabled:opacity-60" + />
)}
+ {isHost && isOpenMatch && !isArchived && !isCancelled && ( +
+
+ +

Notify players

+
+

+ Send a heads-up SMS. This does not reserve a spot. +

+
+ setNotifySearch(event.target.value)} + placeholder="Search players to notify" + className="w-full rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm font-semibold text-gray-700 outline-none focus:border-violet-300 focus:ring-2 focus:ring-violet-100" + /> + {notifyResults.length > 0 && ( +
+ {notifyResults.map((player) => { + const id = Number(player.user_id ?? player.id); + const name = player.full_name || player.name || player.email || `Player ${id}`; + return ( + + ); + })} +
+ )} +
+ {notifySelected.length > 0 && ( +
+ {notifySelected.map((player) => { + const id = Number(player.user_id ?? player.id); + const name = player.full_name || player.name || player.email || `Player ${id}`; + return ( + + {name} + + + ); + })} +
+ )} + + +
+

+ Notified {matchNotifications.length} +

+ {notificationsLoading ? ( +

Loading notified players...

+ ) : matchNotifications.length === 0 ? ( +

No heads-up notifications sent yet.

+ ) : ( + matchNotifications.map((notification) => { + const profile = notification.profile || {}; + const name = profile.full_name || `Player ${notification.player_id}`; + return ( +
+
+

{name}

+

+ {notification.status || "delivered"} +

+
+ +
+ ); + }) + )} +
+
+ )} + + {isJoined && ( +
+
+
+ +

Match messages

+
+ +
+ {messagesLoading ? ( +

Loading messages...

+ ) : messages.length === 0 ? ( +

No messages yet.

+ ) : ( +
+ {messages.map((message) => { + const sender = message.sender || {}; + return ( +
+

+ {sender.full_name || sender.name || `Player ${message.sender_id}`} + {message.recipient_id ? " · Direct message" : ""} +

+

+ {message.body} +

+
+ ); + })} +
+ )} +
+ )} + {isHost && matchPrivacy === "private" && pendingInvitesList.length > 0 && ( renderPendingInvites() )} diff --git a/src/components/ProfileManager.jsx b/src/components/ProfileManager.jsx index 76eb2c2b..21818227 100644 --- a/src/components/ProfileManager.jsx +++ b/src/components/ProfileManager.jsx @@ -1,7 +1,13 @@ import { useEffect, useState } from "react"; -import { X, Loader2, UserRound, Info } from "lucide-react"; +import { X, Loader2, UserRound, Info, Users, Plus, Trash2 } from "lucide-react"; import { getPersonalDetails } from "../services/auth"; import { formatPhoneNumber, formatPhoneDisplay } from "../services/phone"; +import { searchPlayers } from "../services/matches"; +import { + createMatchGroup, + deleteMatchGroup, + listMatchGroups, +} from "../services/matchGroups"; import ProfilePhotoUploader from "./ProfilePhotoUploader"; import { updatePlayerPersonalDetails, @@ -61,6 +67,15 @@ const ProfileManager = ({ isOpen, onClose, onProfileUpdate }) => { const [error, setError] = useState(""); const [imagePreview, setImagePreview] = useState(""); const [showRatingGuide, setShowRatingGuide] = useState(false); + const [groups, setGroups] = useState([]); + const [groupsLoading, setGroupsLoading] = useState(false); + const [groupError, setGroupError] = useState(""); + const [groupName, setGroupName] = useState(""); + const [groupDescription, setGroupDescription] = useState(""); + const [groupPlayerSearch, setGroupPlayerSearch] = useState(""); + const [groupPlayerResults, setGroupPlayerResults] = useState([]); + const [selectedGroupPlayers, setSelectedGroupPlayers] = useState([]); + const [savingGroup, setSavingGroup] = useState(false); const accessToken = localStorage.getItem("authToken"); useEffect(() => { @@ -72,9 +87,49 @@ const ProfileManager = ({ isOpen, onClose, onProfileUpdate }) => { setError(""); setImagePreview(""); setShowRatingGuide(false); + setGroupError(""); + setGroupName(""); + setGroupDescription(""); + setGroupPlayerSearch(""); + setGroupPlayerResults([]); + setSelectedGroupPlayers([]); } }, [isOpen]); + useEffect(() => { + if (!isOpen) return; + fetchGroups(); + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return undefined; + const query = groupPlayerSearch.trim(); + if (query.length < 2) { + setGroupPlayerResults([]); + return undefined; + } + + let cancelled = false; + const timeout = setTimeout(async () => { + try { + const data = await searchPlayers({ search: query, perPage: 8 }); + if (cancelled) return; + const players = Array.isArray(data?.players) ? data.players : []; + setGroupPlayerResults(players); + } catch (err) { + if (!cancelled) { + console.error("Failed to search players for group", err); + setGroupPlayerResults([]); + } + } + }, 250); + + return () => { + cancelled = true; + clearTimeout(timeout); + }; + }, [groupPlayerSearch, isOpen]); + const fetchDetails = async ({ showLoader = true } = {}) => { try { if (showLoader) { @@ -109,6 +164,98 @@ const ProfileManager = ({ isOpen, onClose, onProfileUpdate }) => { } }; + const fetchGroups = async () => { + try { + setGroupsLoading(true); + const data = await listMatchGroups(); + setGroups(Array.isArray(data?.groups) ? data.groups : []); + setGroupError(""); + } catch (err) { + console.error(err); + setGroupError( + err?.response?.data?.message || + err?.message || + "Failed to load match groups.", + ); + } finally { + setGroupsLoading(false); + } + }; + + const normalizePlayerId = (player) => Number(player?.user_id ?? player?.id); + + const addGroupPlayer = (player) => { + const id = normalizePlayerId(player); + if (!Number.isFinite(id)) return; + setSelectedGroupPlayers((prev) => { + if (prev.some((item) => normalizePlayerId(item) === id)) return prev; + return [...prev, player]; + }); + setGroupPlayerSearch(""); + setGroupPlayerResults([]); + }; + + const removeGroupPlayer = (playerId) => { + setSelectedGroupPlayers((prev) => + prev.filter((player) => normalizePlayerId(player) !== Number(playerId)), + ); + }; + + const handleCreateGroup = async () => { + const name = groupName.trim(); + if (!name) { + setGroupError("Enter a group name."); + return; + } + const playerIds = selectedGroupPlayers + .map(normalizePlayerId) + .filter((id) => Number.isFinite(id)); + if (!playerIds.length) { + setGroupError("Add at least one player to the group."); + return; + } + + try { + setSavingGroup(true); + await createMatchGroup({ + name, + description: groupDescription.trim() || null, + playerIds, + player_ids: playerIds, + }); + setGroupName(""); + setGroupDescription(""); + setSelectedGroupPlayers([]); + setGroupPlayerSearch(""); + await fetchGroups(); + } catch (err) { + console.error(err); + setGroupError( + err?.response?.data?.message || + err?.response?.data?.detail || + err?.message || + "Failed to save group.", + ); + } finally { + setSavingGroup(false); + } + }; + + const handleDeleteGroup = async (groupId) => { + if (!window.confirm("Delete this match group?")) return; + try { + await deleteMatchGroup(groupId); + await fetchGroups(); + } catch (err) { + console.error(err); + setGroupError( + err?.response?.data?.message || + err?.message || + "Failed to delete group.", + ); + } + }; + const handlePhoneChange = (value) => { const formatted = formatPhoneNumber(value); const digits = formatted.replace(/\D/g, ""); @@ -439,6 +586,141 @@ const ProfileManager = ({ isOpen, onClose, onProfileUpdate }) => { )} + {!loading && ( +
+
+
+
+ +

+ My Match Groups +

+
+

+ Private groups help you quickly notify or invite regular players. +

+
+ +
+ +
+ setGroupName(event.target.value)} + maxLength={60} + /> + setGroupDescription(event.target.value)} + maxLength={160} + /> +
+ setGroupPlayerSearch(event.target.value)} + /> + {groupPlayerResults.length > 0 && ( +
+ {groupPlayerResults.map((player) => { + const id = normalizePlayerId(player); + const name = player.full_name || player.name || player.email || `Player ${id}`; + return ( + + ); + })} +
+ )} +
+ {selectedGroupPlayers.length > 0 && ( +
+ {selectedGroupPlayers.map((player) => { + const id = normalizePlayerId(player); + const name = player.full_name || player.name || player.email || `Player ${id}`; + return ( + + {name} + + + ); + })} +
+ )} + +
+ +
+ {groups.length === 0 && !groupsLoading ? ( +

+ No groups yet. +

+ ) : ( + groups.map((group) => ( +
+
+

{group.name}

+

+ {group.member_count || 0} players + {group.description ? ` • ${group.description}` : ""} +

+
+ +
+ )) + )} +
+
+ )} + + {groupError && ( +
+ {groupError} +
+ )} + {error && (
{error} diff --git a/src/services/matches.js b/src/services/matches.js index f85d628a..25bfbdf1 100644 --- a/src/services/matches.js +++ b/src/services/matches.js @@ -275,7 +275,7 @@ export const listAttentionMatches = ({ params.withinHours = normalizedWithinHours; params.within_hours = normalizedWithinHours; } - return unwrap(api(`/matches/attention${qs(params)}`)).then(normalizeMatchesResponse); + return unwrap(api(`/matches/attention${qs(params)}`)); }; export const cancelMatch = async (id) => { From 2c3080b54605e40192db0e2aff4c680c337502d0 Mon Sep 17 00:00:00 2001 From: sahilkashyap64 Date: Thu, 30 Apr 2026 16:42:03 +0530 Subject: [PATCH 04/12] =?UTF-8?q?Match=20Play=20hero=20spacing,=20typograp?= =?UTF-8?q?hy,=20distance=20chips,=20and=20=E2=80=9CMy=20groups=E2=80=9D?= =?UTF-8?q?=20button.=20=E2=80=9CNeeds=20your=20attention=E2=80=9D=20cards?= =?UTF-8?q?=20styled=20like=20the=20mock=20and=20clickable=20without=20bre?= =?UTF-8?q?aking=20invite=20actions.=20Filter=20rail,=20day=20strip,=20emp?= =?UTF-8?q?ty=20state,=20and=20grouped=20match=20feed=20updated.=20Match?= =?UTF-8?q?=20cards=20now=20include=20mock-style=20pills,=20host=20strip,?= =?UTF-8?q?=20compact=20roster=20avatars,=20distance,=20gender=20marker,?= =?UTF-8?q?=20and=20roster=20status.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/TennisMatchApp.jsx | 269 ++++++++++++++++++++++++++++------------- 1 file changed, 185 insertions(+), 84 deletions(-) diff --git a/src/TennisMatchApp.jsx b/src/TennisMatchApp.jsx index f410fd27..f9f7868f 100644 --- a/src/TennisMatchApp.jsx +++ b/src/TennisMatchApp.jsx @@ -124,7 +124,7 @@ 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" }, + { id: "all", label: "All matches" }, { id: "my", label: "My matches" }, { id: "discover", label: "Discover" }, ]; @@ -300,6 +300,47 @@ const buildDayStripOptions = () => { }); }; +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", { + 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}`; @@ -3213,14 +3254,8 @@ const TennisMatchApp = () => { const date = getMatchStartDate(match); const key = formatDayKey(date) || "unscheduled"; if (!groupMap.has(key)) { - const label = date - ? date.toLocaleDateString("en-US", { - weekday: "long", - month: "short", - day: "numeric", - }) - : "Date TBA"; - const group = { key, label, matches: [] }; + const heading = formatMatchDayHeading(match); + const group = { key, ...heading, matches: [] }; groupMap.set(key, group); groups.push(group); } @@ -3290,16 +3325,16 @@ const TennisMatchApp = () => { 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.

@@ -3309,7 +3344,7 @@ const TennisMatchApp = () => { @@ -3410,8 +3460,8 @@ const TennisMatchApp = () => {
)} -
-
+
+
{DISCOVERY_SCOPE_FILTERS.map((filter) => { const isActive = activeFilter === filter.id; @@ -3423,7 +3473,7 @@ const TennisMatchApp = () => { setActiveFilter(filter.id); setMatchPage(1); }} - className={`h-9 min-w-[126px] rounded-lg px-4 text-sm font-black transition-colors ${ + className={`h-9 min-w-[112px] rounded-lg px-4 text-xs font-black transition-colors ${ isActive ? "bg-white text-slate-950 shadow-sm" : "text-slate-500 hover:text-slate-800" @@ -3435,14 +3485,14 @@ const TennisMatchApp = () => { })}
-
+
setMatchSearch(e.target.value)} - className="h-10 w-full rounded-xl border border-slate-200 bg-white pl-11 pr-4 text-sm font-semibold text-slate-700 outline-none transition focus:border-violet-300 focus:ring-2 focus:ring-violet-100" + className="h-10 w-full rounded-[10px] border border-slate-200 bg-white pl-11 pr-4 text-xs font-semibold text-slate-700 outline-none transition focus:border-violet-300 focus:ring-2 focus:ring-violet-100" />
@@ -3450,11 +3500,11 @@ const TennisMatchApp = () => {

When

-
+
- {hasLocationFilter && displayedMatches.length === 0 && ( -
- No matches within {distanceFilter} miles of your location yet. Try expanding the distance filter or check back soon. + {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.label.split(",")[0]} +
+

+ {group.dayLabel}

- - {group.label.includes(",") ? `· ${group.label.split(",").slice(1).join(",").trim()}` : ""} - + {group.dateLabel && ( + + · {group.dateLabel} + + )}
{group.matches.length} {group.matches.length === 1 ? "match" : "matches"}
-
+
{group.matches.map((match) => ( ))} @@ -3565,6 +3635,7 @@ const TennisMatchApp = () => {

))}
+ )}
) : (
@@ -3590,6 +3661,7 @@ const TennisMatchApp = () => { 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) @@ -3602,17 +3674,15 @@ const TennisMatchApp = () => { } return playerCapacityLabel; })(); - const timeLabel = (() => { - const date = getMatchStartDate(match); - if (!date) return ""; - return date.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - }); - })(); - const distanceLabel = Number.isFinite(match.distanceMiles) - ? `${Number.isInteger(match.distanceMiles) ? match.distanceMiles : match.distanceMiles.toFixed(1)} mi` - : ""; + 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) => { @@ -3634,25 +3704,40 @@ const TennisMatchApp = () => { 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 (
); diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index 56ddda73..0e5b8e18 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -150,6 +150,43 @@ const AppHeader = ({ /> )}
+ ) : currentScreen === "groups" || currentScreen === "group-detail" ? ( +
+
+
+ 🎾 +
+

+ Matchplay +

+
+ {currentUser && ( +
+ +
+ )} +
) : (
@@ -587,7 +606,10 @@ const ProfileManager = ({ isOpen, onClose, onProfileUpdate }) => { )} {!loading && ( -
+
@@ -617,6 +639,7 @@ const ProfileManager = ({ isOpen, onClose, onProfileUpdate }) => { value={groupName} onChange={(event) => setGroupName(event.target.value)} maxLength={60} + autoFocus={initialSection === "groups"} /> { + const id = Number(player?.player_id ?? player?.user_id ?? player?.id); + const name = player?.full_name || player?.name || player?.email || "Player"; + return { + id, + name, + email: player?.email || "", + ntrp: player?.skill_level || player?.ntrp || "", + initials: getAvatarInitials(name, player?.email), + avatarUrl: getAvatarUrlFromPlayer(player), + }; +}; + +function GroupDetailPage({ groupId, onBack, onSaved }) { + const isNew = groupId === "new"; + const [loading, setLoading] = useState(!isNew); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(""); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [members, setMembers] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [searching, setSearching] = useState(false); + const [searchResults, setSearchResults] = useState([]); + + useEffect(() => { + if (isNew) { + setLoading(false); + return; + } + + let cancelled = false; + const loadGroup = async () => { + try { + setLoading(true); + setError(""); + const group = await getMatchGroup(groupId); + if (cancelled) return; + setName(group?.name || ""); + setDescription(group?.description || ""); + setMembers( + Array.isArray(group?.members) + ? group.members.map(normalizePlayer).filter((player) => Number.isFinite(player.id)) + : [], + ); + } catch (loadError) { + if (!cancelled) { + console.error(loadError); + setError(loadError?.message || "Failed to load group."); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + loadGroup(); + return () => { + cancelled = true; + }; + }, [groupId, isNew]); + + useEffect(() => { + const trimmed = searchQuery.trim(); + if (trimmed.length < 2) { + setSearchResults([]); + return undefined; + } + + let cancelled = false; + const timeout = window.setTimeout(async () => { + try { + setSearching(true); + const data = await searchPlayers({ search: trimmed, perPage: 8 }); + if (cancelled) return; + const existingIds = new Set(members.map((member) => Number(member.id))); + const nextResults = Array.isArray(data?.players) + ? data.players + .map(normalizePlayer) + .filter((player) => Number.isFinite(player.id) && !existingIds.has(Number(player.id))) + : []; + setSearchResults(nextResults); + } catch (searchError) { + if (!cancelled) { + console.error(searchError); + setSearchResults([]); + } + } finally { + if (!cancelled) { + setSearching(false); + } + } + }, 250); + + return () => { + cancelled = true; + window.clearTimeout(timeout); + }; + }, [members, searchQuery]); + + const canSave = name.trim() && members.length > 0 && !saving && !deleting; + + const tips = useMemo( + () => [ + "Add more players than you usually need so invites fill faster.", + "Group names stay private to you. Players only see your invite.", + "You can reuse the same group across private matches and notify flows.", + ], + [], + ); + + const addMember = (player) => { + setMembers((current) => { + if (current.some((member) => Number(member.id) === Number(player.id))) return current; + return [...current, player]; + }); + setSearchQuery(""); + setSearchResults([]); + }; + + const removeMember = (playerId) => { + setMembers((current) => current.filter((member) => Number(member.id) !== Number(playerId))); + }; + + const handleSave = async () => { + if (!canSave) return; + try { + setSaving(true); + setError(""); + const playerIds = members.map((member) => Number(member.id)).filter(Number.isFinite); + const payload = { + name: name.trim(), + description: description.trim() || null, + playerIds, + player_ids: playerIds, + }; + if (isNew) { + await createMatchGroup(payload); + } else { + await updateMatchGroup(groupId, payload); + } + onSaved?.(); + } catch (saveError) { + console.error(saveError); + setError(saveError?.message || "Failed to save group."); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (isNew) return; + if (!window.confirm("Delete this match group?")) return; + try { + setDeleting(true); + setError(""); + await deleteMatchGroup(groupId); + onSaved?.(); + } catch (deleteError) { + console.error(deleteError); + setError(deleteError?.message || "Failed to delete group."); + } finally { + setDeleting(false); + } + }; + + return ( +
+ + +
+
+

+ {isNew ? "New group" : "Edit group"} +

+

+ {isNew ? "Create a group" : name || "Untitled group"} +

+
+ {!isNew && ( + + )} +
+ + {loading ? ( +
+ +
+ ) : ( +
+
+
+
+
+ + setName(event.target.value)} + placeholder="e.g. Sat AM 4.0 regulars" + className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-900 outline-none transition focus:border-violet-300 focus:ring-2 focus:ring-violet-100" + /> +
+
+ + setDescription(event.target.value)} + placeholder="e.g. Weekly doubles crew, 8am Penmar" + className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-900 outline-none transition focus:border-violet-300 focus:ring-2 focus:ring-violet-100" + /> +
+
+
+ +
+
+
+ Members ({members.length}) +
+
+
+
+ + setSearchQuery(event.target.value)} + placeholder="Search players to add" + className="w-full rounded-xl border border-slate-200 bg-white py-3 pl-10 pr-4 text-sm font-semibold text-slate-900 outline-none transition focus:border-violet-300 focus:ring-2 focus:ring-violet-100" + /> + {searching && ( + + )} + {searchResults.length > 0 && ( +
+ {searchResults.map((player) => ( + + ))} +
+ )} +
+ + {members.length === 0 ? ( +
+ +
No members yet
+
+ Search for players by name or email. +
+
+ ) : ( +
+ {members.map((member, index) => ( +
0 ? "border-t border-slate-100" : ""}`} + > + +
+
{member.name}
+
+ {member.ntrp ? `NTRP ${member.ntrp}` : "Player"} +
+
+ +
+ ))} +
+ )} +
+
+
+ + +
+ )} +
+ ); +} + +export default GroupDetailPage; diff --git a/src/pages/MyGroupsPage.jsx b/src/pages/MyGroupsPage.jsx new file mode 100644 index 00000000..d36e7077 --- /dev/null +++ b/src/pages/MyGroupsPage.jsx @@ -0,0 +1,235 @@ +import { useEffect, useMemo, useState } from "react"; +import { ChevronLeft, ChevronRight, Loader2, Plus, RefreshCw, Search, Users } from "lucide-react"; +import PlayerAvatar from "../components/PlayerAvatar"; +import { getMatchGroup, listMatchGroups } from "../services/matchGroups"; +import { getAvatarInitials, getAvatarUrlFromPlayer } from "../utils/avatar"; + +const normalizeGroupPlayer = (player) => { + const name = player?.full_name || player?.name || player?.email || "Player"; + return { + id: player?.player_id ?? player?.user_id ?? player?.id ?? name, + name, + initials: getAvatarInitials(name, player?.email), + avatarUrl: getAvatarUrlFromPlayer(player), + }; +}; + +const formatRelativeLabel = (value) => { + if (!value) return "Recently updated"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Recently updated"; + const diffMs = Date.now() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (diffDays <= 0) return "today"; + if (diffDays === 1) return "1 day ago"; + if (diffDays < 7) return `${diffDays} days ago`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) === 1 ? "" : "s"} ago`; + if (diffDays < 365) return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) === 1 ? "" : "s"} ago`; + return `${Math.floor(diffDays / 365)} year${Math.floor(diffDays / 365) === 1 ? "" : "s"} ago`; +}; + +function MyGroupsPage({ onBack, onCreateGroup, onOpenGroup }) { + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(""); + const [query, setQuery] = useState(""); + + const loadGroups = async ({ silent = false } = {}) => { + try { + setError(""); + if (silent) { + setRefreshing(true); + } else { + setLoading(true); + } + const data = await listMatchGroups(); + const baseGroups = Array.isArray(data?.groups) ? data.groups : []; + const detailGroups = await Promise.all( + baseGroups.map(async (group) => { + try { + const detail = await getMatchGroup(group.id); + const members = Array.isArray(detail?.members) + ? detail.members.map(normalizeGroupPlayer) + : []; + return { + ...group, + ...detail, + members, + }; + } catch { + return { + ...group, + members: [], + }; + } + }), + ); + setGroups(detailGroups); + } catch (loadError) { + console.error(loadError); + setError(loadError?.message || "Failed to load match groups."); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + loadGroups(); + }, []); + + const filteredGroups = useMemo(() => { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) return groups; + return groups.filter((group) => { + const name = (group?.name || "").toLowerCase(); + const description = (group?.description || "").toLowerCase(); + return name.includes(trimmed) || description.includes(trimmed); + }); + }, [groups, query]); + + return ( +
+ + +
+
+

+ Profile +

+

+ My groups +

+

+ Save reusable rosters of players you invite often. Groups are private to you and only + visible from your account. +

+
+ +
+ + {groups.length > 3 && ( +
+ + setQuery(event.target.value)} + placeholder="Search groups by name or description..." + className="w-full rounded-[10px] border border-slate-200 bg-white py-[11px] pl-10 pr-4 text-[13px] font-semibold text-slate-900 outline-none transition focus:border-violet-300 focus:ring-2 focus:ring-violet-100" + /> +
+ )} + +
+ {query.trim() ? `${filteredGroups.length} of ${groups.length} groups` : `${groups.length} groups`} + +
+ + {loading ? ( +
+ +
+ ) : error ? ( +
+ {error} +
+ ) : filteredGroups.length === 0 ? ( +
+ +
No groups found
+
+ Try a different search term. +
+
+ ) : ( +
+ {filteredGroups.map((group) => { + const members = Array.isArray(group.members) ? group.members : []; + const memberCount = Number(group.member_count) || members.length || 0; + const lastUsedLabel = formatRelativeLabel( + group.last_used_at || group.updated_at || group.modified_at || group.created_at, + ); + + return ( + + ); + })} +
+ )} +
+ ); +} + +export default MyGroupsPage; From b975d97627dfaf7de647d69f5aa046a946da16c2 Mon Sep 17 00:00:00 2001 From: sahilkashyap64 Date: Thu, 30 Apr 2026 17:11:45 +0530 Subject: [PATCH 06/12] Add player on the create-group screen now works. I changed src/pages/GroupDetailPage.jsx so: - the Add player label is now a real button - clicking it opens a player picker - the picker loads from the new /player/match-groups/players endpoint - search results render as an actual selectable player list - selected players are added using player_id as the saved id source --- src/TennisMatchApp.jsx | 12 +++ src/main.jsx | 2 + src/pages/GroupDetailPage.jsx | 155 +++++++++++++++++++++++----------- src/services/matchGroups.js | 15 ++++ 4 files changed, 136 insertions(+), 48 deletions(-) diff --git a/src/TennisMatchApp.jsx b/src/TennisMatchApp.jsx index 30663aac..f9b374d4 100644 --- a/src/TennisMatchApp.jsx +++ b/src/TennisMatchApp.jsx @@ -2572,6 +2572,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) { @@ -7233,6 +7244,7 @@ const TennisMatchApp = () => { )} {currentScreen === "group-detail" && ( }, { path: "/matches/:id/invite", element: }, { path: "/players", element: }, + { path: "/groups", element: }, + { path: "/groups/:id", element: }, { path: "/create", element: }, { path: "/courts", element: }, diff --git a/src/pages/GroupDetailPage.jsx b/src/pages/GroupDetailPage.jsx index a720af90..fd6cca09 100644 --- a/src/pages/GroupDetailPage.jsx +++ b/src/pages/GroupDetailPage.jsx @@ -1,8 +1,13 @@ import { useEffect, useMemo, useState } from "react"; import { ChevronLeft, Loader2, Plus, Search, Trash2, Users, X } from "lucide-react"; import PlayerAvatar from "../components/PlayerAvatar"; -import { createMatchGroup, deleteMatchGroup, getMatchGroup, updateMatchGroup } from "../services/matchGroups"; -import { searchPlayers } from "../services/matches"; +import { + createMatchGroup, + deleteMatchGroup, + getMatchGroup, + listMatchGroupPlayers, + updateMatchGroup, +} from "../services/matchGroups"; import { getAvatarInitials, getAvatarUrlFromPlayer } from "../utils/avatar"; const normalizePlayer = (player) => { @@ -27,12 +32,21 @@ function GroupDetailPage({ groupId, onBack, onSaved }) { const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [members, setMembers] = useState([]); + const [showPlayerPicker, setShowPlayerPicker] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searching, setSearching] = useState(false); const [searchResults, setSearchResults] = useState([]); useEffect(() => { if (isNew) { + setName(""); + setDescription(""); + setMembers([]); + setError(""); + setShowPlayerPicker(false); + setSearchQuery(""); + setSearchResults([]); + setSearching(false); setLoading(false); return; } @@ -70,17 +84,19 @@ function GroupDetailPage({ groupId, onBack, onSaved }) { }, [groupId, isNew]); useEffect(() => { - const trimmed = searchQuery.trim(); - if (trimmed.length < 2) { + if (!showPlayerPicker) { setSearchResults([]); + setSearching(false); return undefined; } - let cancelled = false; const timeout = window.setTimeout(async () => { try { setSearching(true); - const data = await searchPlayers({ search: trimmed, perPage: 8 }); + const data = await listMatchGroupPlayers({ + search: searchQuery.trim(), + perPage: 8, + }); if (cancelled) return; const existingIds = new Set(members.map((member) => Number(member.id))); const nextResults = Array.isArray(data?.players) @@ -105,7 +121,7 @@ function GroupDetailPage({ groupId, onBack, onSaved }) { cancelled = true; window.clearTimeout(timeout); }; - }, [members, searchQuery]); + }, [members, searchQuery, showPlayerPicker]); const canSave = name.trim() && members.length > 0 && !saving && !deleting; @@ -124,7 +140,6 @@ function GroupDetailPage({ groupId, onBack, onSaved }) { return [...current, player]; }); setSearchQuery(""); - setSearchResults([]); }; const removeMember = (playerId) => { @@ -174,7 +189,7 @@ function GroupDetailPage({ groupId, onBack, onSaved }) { }; return ( -
+
-
- - setSearchQuery(event.target.value)} - placeholder="Search players to add" - className="w-full rounded-xl border border-slate-200 bg-white py-3 pl-10 pr-4 text-sm font-semibold text-slate-900 outline-none transition focus:border-violet-300 focus:ring-2 focus:ring-violet-100" - /> - {searching && ( - - )} - {searchResults.length > 0 && ( -
- {searchResults.map((player) => ( - - ))} + {showPlayerPicker && ( +
+
+ + setSearchQuery(event.target.value)} + placeholder="Search players to add" + className="w-full rounded-xl border border-slate-200 bg-white py-3 pl-10 pr-4 text-sm font-semibold text-slate-900 outline-none transition focus:border-violet-300 focus:ring-2 focus:ring-violet-100" + /> + {searching && ( + + )}
- )} -
+
+ {searchResults.length === 0 && !searching ? ( +
+ {searchQuery.trim() + ? "No players match that search." + : "Start with suggested players or search by name."} +
+ ) : ( + searchResults.map((player, index) => ( + + )) + )} +
+
+ )} {members.length === 0 ? ( -
- -
No members yet
-
- Search for players by name or email. +
+
+ +
+
No members yet
+
+ Add players by name, email, or phone number.
) : ( -
+
{members.map((member, index) => (
0 ? "border-t border-slate-100" : ""}`} + className={`flex items-center gap-3 py-2.5 ${index > 0 ? "border-t border-slate-100" : ""}`} >
-
- Privacy +
+
+ +
+
Privacy

- Groups are private to you. Players do not see the group name, only the invite you - send them. + Groups are private to you. Members don't see the group name, they just see your + match invites. Only you can edit or delete this group.

diff --git a/src/services/matchGroups.js b/src/services/matchGroups.js index a0086833..cd5b0829 100644 --- a/src/services/matchGroups.js +++ b/src/services/matchGroups.js @@ -41,3 +41,18 @@ export const deleteMatchGroup = (id) => method: "DELETE", }), ); + +export const listMatchGroupPlayers = ({ + search = "", + page = 1, + perPage = 12, +} = {}) => + unwrap( + api( + `/player/match-groups/players?${new URLSearchParams({ + ...(search ? { search } : {}), + page: String(page), + perPage: String(perPage), + }).toString()}`, + ), + ); From 31a3f0ea7fe1dda9086a3001564678fcc8eadfd0 Mon Sep 17 00:00:00 2001 From: sahilkashyap64 Date: Fri, 1 May 2026 02:20:17 +0530 Subject: [PATCH 07/12] feat: wire match feed filters and groups flow to backend APIs --- src/TennisMatchApp.jsx | 118 ++++++++++++++++++++-------------------- src/services/matches.js | 10 ++++ 2 files changed, 68 insertions(+), 60 deletions(-) diff --git a/src/TennisMatchApp.jsx b/src/TennisMatchApp.jsx index f9b374d4..aa6fa121 100644 --- a/src/TennisMatchApp.jsx +++ b/src/TennisMatchApp.jsx @@ -195,11 +195,6 @@ const normalizeNtrpLevel = (value) => { return trimmed.replace(/\s*-\s*.*$/, ""); }; -const getLevelIndex = (value) => { - const normalized = normalizeNtrpLevel(value); - return NTRP_LEVELS.findIndex((level) => level === normalized); -}; - const pickMatchSkillRange = (match = {}) => { const min = normalizeNtrpLevel( match.skill_level_min ?? @@ -227,6 +222,16 @@ const formatSkillRange = (match = {}) => { 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 ?? @@ -1657,11 +1662,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, }); @@ -2121,6 +2140,10 @@ const TennisMatchApp = () => { matchSearch, memberIdentityIds, currentUser, + selectedDayKey, + selectedFormatFilter, + selectedGenderFilter, + selectedLevelFilter, ]); const fetchAttentionMatches = useCallback(async () => { @@ -3252,54 +3275,8 @@ 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; - - const filteredMatches = baseMatches.filter((match) => { - if (selectedDayKey) { - const startDate = getMatchStartDate(match); - if (!startDate || formatDayKey(startDate) !== selectedDayKey) { - return false; - } - } - - if (selectedLevelFilter !== "Any") { - const selectedIndex = getLevelIndex(selectedLevelFilter); - const minIndex = getLevelIndex(match.skillLevelMin || match.skillLevel); - const maxIndex = getLevelIndex(match.skillLevelMax || match.skillLevelMin || match.skillLevel); - if (selectedIndex < 0) return false; - if (minIndex >= 0 && selectedIndex < minIndex) return false; - if (maxIndex >= 0 && selectedIndex > maxIndex) return false; - } - - if (selectedFormatFilter !== "Any") { - const normalizedFormat = (match.format || "").toString().trim(); - if (normalizedFormat !== selectedFormatFilter) return false; - } - - if (selectedGenderFilter !== "Any") { - const gender = (match.gender || "Any").toString().trim(); - if (gender !== "Any" && gender !== selectedGenderFilter) return false; - } - - return true; - }); - - return sortMatchesByRecency(filteredMatches); - }, [ - distanceFilter, - hasLocationFilter, - matchesWithDistance, - selectedDayKey, - selectedFormatFilter, - selectedGenderFilter, - selectedLevelFilter, - sortMatchesByRecency, - ]); + return sortMatchesByRecency(matchesWithDistance); + }, [matchesWithDistance, sortMatchesByRecency]); const distanceOptions = useMemo(() => [5, 10, 20, 50], []); const dayStripOptions = useMemo(() => buildDayStripOptions(), []); @@ -3419,7 +3396,10 @@ const TennisMatchApp = () => {
@@ -3569,7 +3552,10 @@ const TennisMatchApp = () => {
)} -
-
-
+
+
+
{DISCOVERY_SCOPE_FILTERS.map((filter) => { const isActive = activeFilter === filter.id; return ( @@ -3519,7 +3531,7 @@ const TennisMatchApp = () => { setActiveFilter(filter.id); setMatchPage(1); }} - className={`h-9 min-w-[112px] rounded-lg px-4 text-xs font-black transition-colors ${ + className={`h-12 min-w-[122px] rounded-[14px] px-5 text-[13px] font-black transition-colors ${ isActive ? "bg-white text-slate-950 shadow-sm" : "text-slate-500 hover:text-slate-800" @@ -3531,8 +3543,8 @@ const TennisMatchApp = () => { })}
-
- +
+ { setMatchSearch(e.target.value); setMatchPage(1); }} - className="h-10 w-full rounded-[10px] border border-slate-200 bg-white pl-11 pr-4 text-xs font-semibold text-slate-700 outline-none transition focus:border-violet-300 focus:ring-2 focus:ring-violet-100" + 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" />
-
+
-

When

-
+

When

+
{dayStripOptions.map((day, index) => { const count = @@ -3580,7 +3592,7 @@ const TennisMatchApp = () => { setMatchPage(1); }} disabled={disabled} - className={`relative min-h-[54px] min-w-[64px] rounded-[10px] border px-3 text-center text-sm font-black transition-colors ${ + className={`relative min-h-[76px] min-w-[126px] rounded-[18px] border px-4 text-center text-sm font-black transition-colors ${ isActive ? "border-violet-500 bg-violet-500 text-white" : disabled @@ -3589,21 +3601,21 @@ const TennisMatchApp = () => { }`} > {count > 0 && ( - + {count} )} - {day.eyebrow} - {day.label} + {day.eyebrow} + {day.label} ); })}
-
+
- Level + Level {["Any", ...NTRP_LEVELS].map((level) => renderFilterChip({ key: `level-${level}`, @@ -3613,12 +3625,13 @@ const TennisMatchApp = () => { setMatchPage(1); }, children: level, + size: "compact", }), )}
-
+
- Format + Format {DISCOVERY_FORMAT_FILTERS.map((format) => renderFilterChip({ key: `format-${format}`, @@ -3628,13 +3641,14 @@ const TennisMatchApp = () => { setMatchPage(1); }, children: format, + size: "compact", }), )}
- Gender + Gender {DISCOVERY_GENDER_FILTERS.map((gender) => renderFilterChip({ key: `gender-${gender}`, @@ -3644,6 +3658,7 @@ const TennisMatchApp = () => { setMatchPage(1); }, children: gender, + size: "compact", }), )}
@@ -3670,28 +3685,28 @@ const TennisMatchApp = () => {
) : ( -
+
{groupedDisplayedMatches.map((group) => (

{group.dayLabel}

{group.dateLabel && ( - + · {group.dateLabel} )}
- + {group.matches.length} {group.matches.length === 1 ? "match" : "matches"}
-
+
{group.matches.map((match) => ( ))} @@ -3788,7 +3803,7 @@ const TennisMatchApp = () => { + ); + })} +
+ )} +
+ +
+

+ 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" + /> +
+
From d363c6bdfccfbc20ee556885a4ac3e41cb6d92ca Mon Sep 17 00:00:00 2001 From: sahilkashyap64 Date: Mon, 4 May 2026 17:08:56 +0530 Subject: [PATCH 10/12] Added the notify-group/player panel to open match creation under Additional Notes --- src/components/MatchCreatorFlow.jsx | 396 +++++++++++++++++++++++++++- 1 file changed, 395 insertions(+), 1 deletion(-) diff --git a/src/components/MatchCreatorFlow.jsx b/src/components/MatchCreatorFlow.jsx index d907a168..97ae6fb4 100644 --- a/src/components/MatchCreatorFlow.jsx +++ b/src/components/MatchCreatorFlow.jsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { ArrowLeft, ArrowRight, + Bell, Calendar, Check, Clock, @@ -29,6 +30,7 @@ import { cancelMatch, createMatch, getShareLink, + notifyMatchPlayers, searchPlayers, sendInvites, updateMatch, @@ -94,6 +96,7 @@ const initialMatchData = () => { notes: "", invitedPlayers: [], manualInvitees: [], + notifyPlayers: [], listingVisibility: "listed", }; }; @@ -279,6 +282,9 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser const [recentPlayers, setRecentPlayers] = useState(() => loadStoredRecentPlayers()); const [matchGroups, setMatchGroups] = useState([]); const [groupsLoading, setGroupsLoading] = useState(false); + const [notifySearch, setNotifySearch] = useState(""); + const [notifyResults, setNotifyResults] = useState([]); + const [notifyLoading, setNotifyLoading] = useState(false); useEffect(() => { const syncRecentLocations = () => { @@ -321,7 +327,7 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser }, [loadStoredRecentPlayers]); useEffect(() => { - if (matchData.type !== "private") return; + if (matchData.type !== "private" && matchData.type !== "open") return; let cancelled = false; const loadGroups = async () => { try { @@ -382,6 +388,11 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser [matchData.invitedPlayers] ); + const notifyPlayers = useMemo( + () => matchData.notifyPlayers || [], + [matchData.notifyPlayers], + ); + const manualInvitees = useMemo( () => matchData.manualInvitees || [], [matchData.manualInvitees] @@ -429,6 +440,16 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser [recentPlayers, currentUserId], ); + const quickNotifyPlayers = useMemo( + () => + quickAddPlayers.filter((player) => { + const normalizedId = Number(player?.id); + if (!Number.isFinite(normalizedId)) return false; + return !notifyPlayers.some((invitee) => invitee.id === normalizedId); + }), + [notifyPlayers, quickAddPlayers], + ); + const invitedCount = combinedInvitees.length; const totalPlayers = matchData.totalPlayers || 4; const isLinkOnlyListing = matchData.listingVisibility === "link_only"; @@ -484,6 +505,91 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser setRecentPlayers(nextRecent); }; + const handleAddNotifyPlayer = useCallback((player) => { + const normalized = normalizePlayer(player); + if (!Number.isFinite(normalized.id)) return; + setMatchData((prev) => { + const existing = prev.notifyPlayers || []; + if (existing.some((item) => item.id === normalized.id)) { + return prev; + } + return { + ...prev, + notifyPlayers: [...existing, normalized], + }; + }); + setNotifySearch(""); + setNotifyResults([]); + const nextRecent = persistRecentPlayer(normalized); + setRecentPlayers(nextRecent); + }, []); + + const handleRemoveNotifyPlayer = useCallback((playerId) => { + setMatchData((prev) => ({ + ...prev, + notifyPlayers: (prev.notifyPlayers || []).filter((player) => player.id !== playerId), + })); + }, []); + + const getGroupNotifyCount = useCallback( + (group) => { + const memberIds = Array.isArray(group?.members) + ? group.members + .map((member) => Number(member?.player_id ?? member?.user_id ?? member?.id)) + .filter((id) => Number.isFinite(id)) + : []; + if (memberIds.length === 0) return 0; + const selectedIds = new Set(notifyPlayers.map((player) => Number(player.id))); + return memberIds.filter((id) => selectedIds.has(id)).length; + }, + [notifyPlayers], + ); + + const handleNotifyGroup = useCallback( + async (groupId) => { + try { + const group = await getMatchGroup(groupId); + const members = Array.isArray(group?.members) ? group.members : []; + const existingIds = new Set(notifyPlayers.map((player) => Number(player.id))); + const toAdd = members + .map((member) => + normalizePlayer({ + ...member, + id: member.player_id ?? member.user_id ?? member.id, + }), + ) + .filter((member) => { + if (!Number.isFinite(member.id)) return false; + if (currentUserId !== null && Number(member.id) === currentUserId) return false; + return !existingIds.has(Number(member.id)); + }) + .map((member) => ({ + ...member, + fromGroupName: group?.name || "", + })); + + if (!toAdd.length) { + showToast("Everyone in that group is already selected.", "info"); + return; + } + + setMatchData((prev) => ({ + ...prev, + notifyPlayers: [...(prev.notifyPlayers || []), ...toAdd], + })); + toAdd.forEach((player) => { + persistRecentPlayer(player); + }); + setRecentPlayers(loadStoredRecentPlayers()); + showToast(`Added ${toAdd.length} player${toAdd.length === 1 ? "" : "s"} to notify.`); + } catch (error) { + console.error(error); + showToast(error.message || "Failed to load group players", "error"); + } + }, + [currentUserId, notifyPlayers, showToast], + ); + const handleInviteGroup = async (groupId) => { if (!canInviteMore()) return; try { @@ -677,6 +783,15 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser const matchId = created?.id || created?.match_id; if (!matchId) throw new Error("Match created but no ID returned"); + if (matchData.type === "open" && notifyPlayers.length > 0) { + const playerIds = notifyPlayers + .map((player) => Number(player.id)) + .filter((id) => Number.isFinite(id)); + if (playerIds.length) { + await notifyMatchPlayers(matchId, { playerIds }); + } + } + if (matchData.type === "private" && invitedCount > 0) { const ids = invitedPlayers .map((p) => Number(p.id)) @@ -910,6 +1025,53 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser return () => clearTimeout(handler); }, [searchQuery]); + + useEffect(() => { + if (currentStep !== 2 || matchData.type !== "open") { + setNotifyResults([]); + setNotifyLoading(false); + return; + } + + const query = notifySearch.trim(); + if (query.length < 2) { + setNotifyResults([]); + setNotifyLoading(false); + return; + } + + let alive = true; + setNotifyLoading(true); + const handler = setTimeout(() => { + searchPlayers({ search: query, page: 1, perPage: 8 }) + .then((data) => { + if (!alive) return; + const selectedIds = new Set(notifyPlayers.map((player) => Number(player.id))); + const players = (data.players || []) + .map(normalizePlayer) + .filter((player) => { + const normalizedId = Number(player.id); + if (!Number.isFinite(normalizedId)) return false; + if (currentUserId !== null && normalizedId === currentUserId) return false; + return !selectedIds.has(normalizedId); + }); + setNotifyResults(players); + }) + .catch((error) => { + console.error(error); + if (!alive) return; + setNotifyResults([]); + }) + .finally(() => { + if (alive) setNotifyLoading(false); + }); + }, 250); + + return () => { + alive = false; + clearTimeout(handler); + }; + }, [currentStep, currentUserId, matchData.type, notifyPlayers, notifySearch]); return (
{toast && ( @@ -1387,6 +1549,238 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser />
+
+
+
+ +
+
+

+ Notify specific players +

+

+ Send a heads-up to players or whole groups. They can still join through the public feed, and no spot is reserved. +

+
+
+ +
+
+
+
+
+

+ From your groups +

+

+ Add your regular crews in one tap. +

+
+ {groupsLoading && ( + Loading... + )} +
+ {matchGroups.length === 0 && !groupsLoading ? ( +

+ No groups yet. Create groups from My groups in your profile. +

+ ) : ( +
+ {matchGroups.slice(0, 4).map((group) => { + const addedCount = getGroupNotifyCount(group); + const memberCount = group.member_count || group.members?.length || 0; + const allAdded = memberCount > 0 && addedCount >= memberCount; + return ( +
+
+

+ {group.name} +

+

+ {memberCount} players + {addedCount > 0 ? ` • ${addedCount} added` : ""} +

+
+ +
+ ); + })} +
+ )} +
+ + {quickNotifyPlayers.length > 0 && ( +
+
+
+

+ Suggested players +

+

+ Based on your recent teammates. +

+
+
+
+ {quickNotifyPlayers.slice(0, 4).map((player) => ( + + ))} +
+
+ )} + +
+ +
+ + setNotifySearch(e.target.value)} + placeholder="Search by name or email..." + className="w-full rounded-xl border border-violet-100 py-3 pl-10 pr-3 text-sm font-medium text-gray-700 focus:border-violet-300 focus:outline-none focus:ring-2 focus:ring-violet-100" + /> +
+ {notifySearch.trim().length > 0 && ( +
+ {notifyLoading && ( +
+ Searching... +
+ )} + {!notifyLoading && notifyResults.length > 0 && ( +
+ {notifyResults.map((player) => ( + + ))} +
+ )} + {!notifyLoading && + notifySearch.trim().length >= 2 && + notifyResults.length === 0 && ( +
+ No matching players found. +
+ )} +
+ )} +
+
+ +
+
+
+

+ Will notify +

+

+ Players selected for the heads-up message. +

+
+ + {notifyPlayers.length} + +
+ {notifyPlayers.length === 0 ? ( +
+ No one selected yet. Add a group or pick players above. +
+ ) : ( +
+ {notifyPlayers.map((player) => ( + + {player.name} + + + ))} +
+ )} +

+ Notified players get a heads-up message with a one-tap join link. This does not reserve a spot. +

+
+
+
+
Date: Mon, 4 May 2026 17:57:56 +0530 Subject: [PATCH 11/12] Restyled the create-match flow --- src/components/MatchCreatorFlow.jsx | 1119 ++++++++++++++++----------- 1 file changed, 647 insertions(+), 472 deletions(-) diff --git a/src/components/MatchCreatorFlow.jsx b/src/components/MatchCreatorFlow.jsx index 97ae6fb4..54e3dedc 100644 --- a/src/components/MatchCreatorFlow.jsx +++ b/src/components/MatchCreatorFlow.jsx @@ -238,24 +238,49 @@ const quickDateOptions = () => { }; return [makeOption(0), makeOption(1), makeOption(2), makeOption(3)]; }; +const stepTitles = { + 1: "Match basics", + 2: "Match details", + 3: "Review & publish", +}; + const ProgressBar = ({ currentStep }) => ( -
- {[1, 2, 3].map((step) => ( - -
- {step < currentStep ? : step} -
- {step < 3 && ( -
- )} - - ))} +
+ {[ + { step: 1, label: "Basics" }, + { step: 2, label: "Details" }, + { step: 3, label: "Review" }, + ].map((item, index, items) => { + const complete = item.step < currentStep; + const active = item.step === currentStep; + return ( + +
+
+ {complete ? : item.step} +
+ + {item.label} + +
+ {index < items.length - 1 && ( +
+
item.step ? "w-full bg-violet-500" : "w-0 bg-violet-500" + }`} + /> +
+ )} + + ); + })}
); @@ -285,6 +310,7 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser const [notifySearch, setNotifySearch] = useState(""); const [notifyResults, setNotifyResults] = useState([]); const [notifyLoading, setNotifyLoading] = useState(false); + const [notifyPanelOpen, setNotifyPanelOpen] = useState(false); useEffect(() => { const syncRecentLocations = () => { @@ -362,6 +388,10 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser ); return Number.isFinite(id) ? id : null; }, [currentUser]); + const currentUserFirstName = useMemo(() => { + const rawName = typeof currentUser?.name === "string" ? currentUser.name.trim() : ""; + return rawName ? rawName.split(/\s+/)[0] : "The host"; + }, [currentUser]); const showToast = useCallback((message, type = "success") => { setToast({ message, type }); @@ -449,6 +479,11 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser }), [notifyPlayers, quickAddPlayers], ); + const visibleNotifyGroups = useMemo(() => matchGroups.slice(0, 3), [matchGroups]); + const visibleSuggestedNotifyPlayers = useMemo( + () => quickNotifyPlayers.slice(0, 4), + [quickNotifyPlayers], + ); const invitedCount = combinedInvitees.length; const totalPlayers = matchData.totalPlayers || 4; @@ -1072,8 +1107,27 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser clearTimeout(handler); }; }, [currentStep, currentUserId, matchData.type, notifyPlayers, notifySearch]); + + useEffect(() => { + if (matchData.type !== "open") { + setNotifyPanelOpen(false); + return; + } + if (notifyPlayers.length > 0) { + setNotifyPanelOpen(true); + } + }, [matchData.type, notifyPlayers.length]); + + const modalTitle = + currentStep === 1 + ? stepTitles[1] + : currentStep === 2 + ? matchData.type === "private" + ? "Invite players" + : stepTitles[2] + : stepTitles[3]; return ( -
+
{toast && (
)} +
+
+
+
+
+ New Match +
+

+ {modalTitle} +

+
+ +
+
+ {currentStep === 1 && ( -
+
-

Create a Match

+
-

+

Match Type

-
+
-

- Date & Time -

- +
{quickDates.map((day) => (
-
+
- + setMatchData((prev) => ({ ...prev, date: e.target.value }))} - className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500 focus:border-transparent text-sm" + className="w-full rounded-2xl border border-slate-200 px-4 py-4 text-[18px] font-bold text-slate-900 focus:border-violet-500 focus:ring-2 focus:ring-violet-100" />
- + handleTimeChange(e.target.value)} onBlur={(e) => handleTimeChange(e.target.value)} - className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500 focus:border-transparent text-sm" + className="w-full rounded-2xl border border-slate-200 px-4 py-4 text-[18px] font-bold text-slate-900 focus:border-violet-500 focus:ring-2 focus:ring-violet-100" />
- +
-
-
- -
-
- {formatDateDisplay(matchData.date)} -
-
- {formatTimeDisplay(matchData.startTime)} for {matchData.duration} hours -
-
-
-
+
-

+

Location

{recentLocations.length > 0 && (
-
-

+

Number of Players

-
+
+
-
-
+
+
{matchData.totalPlayers}
-
Total Players
-
+
Total
+
You + {matchData.totalPlayers - 1} others
@@ -1333,46 +1408,50 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser })) } disabled={matchData.totalPlayers >= MAX_MATCH_PLAYERS} - className="w-14 h-14 rounded-full border-2 border-gray-300 hover:border-gray-400 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-xl font-semibold text-gray-600 transition-colors" + className="flex h-11 w-11 items-center justify-center rounded-full bg-slate-50 text-xl font-bold text-slate-500 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50" > - + +
+
+
-
+
+
Step 1 of 3
+
)} {currentStep === 2 && matchData.type === "open" && ( -
+
-

Match Settings

+
-
-

+
+

NTRP Skill Range

- + REQUIRED
-
+
-

Players can filter open matches by whether their NTRP falls inside this range.

+

+ Players rated {matchData.skillLevelMin}- + {matchData.skillLevelMax || matchData.skillLevelMin} will see this match. +

+
+ +
+
-
+
+
+

+ Match Format +

+
+
+ + +
+
+
+
-

+

Category

-
+
{genderOptions.map((gender) => (
+
+ +
-

+

Balls

-
+
{ballsOptions.map((balls) => ( ))}
+

+ {matchData.balls === "Rotate" + ? "Each player brings a can in rotation." + : matchData.balls === "BYO" + ? "Each player brings their own can." + : "You'll supply balls for the match."} +

-
- -
- -
-
-

- Match Format -

-
-
- - +
+

+ Visibility +

+
+
+
+ +
+
+
Share by link only
+
+ {isLinkOnlyListing ? "Hidden from feed" : "Visible in feed"} +
+
+ +
-

+

Additional Notes