diff --git a/src/TennisMatchApp.jsx b/src/TennisMatchApp.jsx index fd18736e..a63bc27f 100644 --- a/src/TennisMatchApp.jsx +++ b/src/TennisMatchApp.jsx @@ -70,6 +70,8 @@ import { MessageCircle, Phone, AlertCircle, + Compass, + Navigation, ArrowRight, Zap, Trophy, @@ -746,6 +748,8 @@ const TennisMatchApp = () => { const totalSelectedInvitees = selectedPlayers.size + manualContacts.size; const lastInviteLoadRef = useRef(null); const autoDetectAttemptedRef = useRef(false); + const locationToolsRef = useRef(null); + const matchListingsRef = useRef(null); const hydratedProfileIdsRef = useRef(new Set()); const notificationSummaryErrorLoggedRef = useRef(false); const inviteSummaryErrorLoggedRef = useRef(false); @@ -3228,390 +3232,1027 @@ 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 personalScheduleMatches = useMemo(() => { + if (!Array.isArray(matches) || matches.length === 0) return []; - {currentUser ? ( - <> -
-
-
-
-
- - - {hasLocationFilter - ? activeLocationLabel - : "Showing matches from every location"} - + const now = Date.now(); + const normalizeString = (value) => { + if (typeof value !== "string") return ""; + const trimmed = value.trim(); + return trimmed; + }; + + const pickString = (...candidates) => { + for (const candidate of candidates) { + const normalized = normalizeString(candidate); + if (normalized) return normalized; + } + return ""; + }; + + return matches + .filter((match) => { + if (!match) return false; + const membership = normalizeString(match.type).toLowerCase(); + if (membership !== "hosted" && membership !== "joined") return false; + const startTs = getMatchTimestamp(match); + if (startTs === null) return false; + // Keep matches that are upcoming or started in the last two hours + return startTs >= now - 1000 * 60 * 60 * 2; + }) + .map((match) => { + const start = parseDateValue(match.dateTime); + const locationLabel = pickString( + match.location, + match.location_text, + match.locationText, + match.venue, + match.court_name, + match.courtName, + ); + const formatLabel = pickString( + match.match_format, + match.matchFormat, + match.format, + match.title, + match.name, + ); + const skillLabel = pickString(match.skill_level, match.skillLevel, match.skill); + const startLabel = start ? formatDateTime(start) : "Date TBA"; + return { + id: match.id, + match, + start, + startLabel, + relativeTime: formatRelativeTimeFromNow(start), + locationLabel, + formatLabel, + skillLabel, + membership: normalizeString(match.type).toLowerCase(), + status: normalizeString(match.status).toLowerCase(), + }; + }) + .sort((a, b) => { + const aTime = a.start instanceof Date ? a.start.getTime() : Number.POSITIVE_INFINITY; + const bTime = b.start instanceof Date ? b.start.getTime() : Number.POSITIVE_INFINITY; + return aTime - bTime; + }) + .slice(0, 5); + }, [ + matches, + formatDateTime, + formatRelativeTimeFromNow, + getMatchTimestamp, + parseDateValue, + ]); + + const nearbyMatchesPreview = useMemo(() => { + if (!Array.isArray(displayedMatches) || displayedMatches.length === 0) return []; + + const normalizeString = (value) => { + if (typeof value !== "string") return ""; + const trimmed = value.trim(); + return trimmed; + }; + + const pickString = (...candidates) => { + for (const candidate of candidates) { + const normalized = normalizeString(candidate); + if (normalized) return normalized; + } + return ""; + }; + + return displayedMatches.slice(0, 4).map((match) => { + const start = parseDateValue(match.dateTime); + const formatLabel = pickString( + match.match_format, + match.matchFormat, + match.format, + match.title, + match.name, + ); + const locationLabel = pickString( + match.location, + match.location_text, + match.locationText, + match.venue, + match.court_name, + match.courtName, + ); + const distanceLabel = Number.isFinite(match.distanceMiles) + ? `${match.distanceMiles.toFixed(match.distanceMiles < 10 ? 1 : 0)} mi` + : ""; + return { + id: match.id, + match, + start, + startLabel: start ? formatDateTime(start) : "Date TBA", + relativeTime: formatRelativeTimeFromNow(start), + locationLabel, + formatLabel, + distanceLabel, + }; + }); + }, [ + displayedMatches, + formatDateTime, + formatRelativeTimeFromNow, + parseDateValue, + ]); + + const BrowseScreen = () => { + const statCards = [ + { + label: "Upcoming matches", + value: personalScheduleMatches.length, + icon: Calendar, + }, + { + label: "Open nearby", + value: getMatchCount("open") || displayedMatches.length, + icon: Users, + }, + { + label: "Invites waiting", + value: pendingInvites.length, + icon: Mail, + }, + { + label: "Needs players", + value: matchesNeedingAttention.length, + icon: AlertCircle, + }, + ]; + + const handleBrowseMatches = useCallback(() => { + setActiveFilter("open"); + setMatchSearch(""); + setTimeout(() => { + if (matchListingsRef.current) { + matchListingsRef.current.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + } + }, 0); + }, [matchListingsRef, setActiveFilter, setMatchSearch]); + + const handleLocationToolsFocus = useCallback(() => { + setShowLocationPicker(true); + setGeoError(""); + setTimeout(() => { + if (locationToolsRef.current) { + locationToolsRef.current.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + } + }, 0); + }, [locationToolsRef, setGeoError, setShowLocationPicker]); + + const handleFindCourts = useCallback(() => { + handleLocationToolsFocus(); + }, [handleLocationToolsFocus]); + + const quickActions = [ + { + id: "browse", + label: "Browse matches", + description: "See what's open nearby and plan ahead.", + details: [ + "Filter by format, day, or skill level.", + "Save options to revisit later.", + ], + icon: Search, + onClick: handleBrowseMatches, + ctaLabel: "Browse matches", + }, + { + id: "players", + label: "Find players", + description: "Match with partners at your pace.", + details: [ + "Search by level or availability.", + "Check shared connections before inviting.", + ], + icon: Users, + onClick: () => goToPlayers(), + ctaLabel: "Find players", + }, + { + id: "ai-match", + label: "AI Match Me", + description: "Let the assistant suggest matchups.", + details: [ + "Share your availability and skill level.", + "Preview smart recommendations before joining.", + ], + icon: Sparkles, + onClick: () => { + displayToast( + "AI Match Me is coming soon. We'll surface smart suggestions here shortly.", + "info", + ); + }, + ctaLabel: "Preview AI Match Me", + }, + { + id: "create", + label: "Create a match", + description: "Host a new meetup with your preferred format.", + details: [ + "Pick the format, skill range, and roster size.", + "Send invites or open it up to the community.", + ], + icon: Plus, + onClick: () => { + if (!currentUser) { + setShowSignInModal(true); + } else { + navigate("/create"); + } + }, + ctaLabel: "Create a match", + }, + { + id: "courts", + label: "Find courts", + description: "Dial in the best places to play.", + details: [ + "Search clubs, parks, and favorite venues.", + "Adjust your radius for the ideal drive time.", + ], + icon: MapPin, + onClick: handleFindCourts, + ctaLabel: "Adjust location tools", + }, + { + id: "invites", + label: "Review invites", + description: "Confirm spots and respond to requests.", + details: [ + "See who’s waiting on your reply.", + "Accept, decline, or message hosts in one place.", + ], + icon: Bell, + onClick: () => goToInvites(), + ctaLabel: "View invites", + }, + ]; + + const heroActionIds = new Set(["browse", "players", "ai-match", "courts"]); + const heroActions = quickActions.filter((action) => heroActionIds.has(action.id)); + const secondaryActions = quickActions.filter((action) => !heroActionIds.has(action.id)); + + const filterDefinitions = [ + { + id: "my", + label: "My Matches", + gradient: "linear-gradient(135deg, rgb(16 185 129), rgb(5 150 105))", + icon: "⭐", + }, + { + id: "open", + label: "Open Matches", + gradient: "linear-gradient(135deg, rgb(56 189 248), rgb(99 102 241))", + icon: "🔥", + }, + { + id: "today", + label: "Today", + gradient: "linear-gradient(135deg, rgb(245 158 11), rgb(234 88 12))", + icon: "📅", + }, + { + id: "tomorrow", + label: "Tomorrow", + gradient: "linear-gradient(135deg, rgb(244 114 182), rgb(168 85 247))", + icon: "⏰", + }, + { + id: "weekend", + label: "Weekend", + gradient: "linear-gradient(135deg, rgb(59 130 246), rgb(124 58 237))", + icon: "🎉", + }, + { + id: "draft", + label: "Drafts", + gradient: "linear-gradient(135deg, rgb(148 163 184), rgb(100 116 139))", + icon: "📝", + }, + { + id: "archived", + label: "Archived", + gradient: "linear-gradient(135deg, rgb(113 113 122), rgb(82 82 91))", + icon: "🗂️", + }, + ]; + + return ( +
+
+
+
+
+
+
+ Welcome + {currentUser?.name && ( + + {currentUser.name.split(" ")[0]} + + )}
- -
-
- {distanceOptions.map((distance) => ( +

+ Rally up for your next play date +

+

+ Plan matches, find partners, and keep tabs on what's happening in your tennis circle. +

+
+ +
+
+
+ {heroActions.map((action) => ( +
+
+ + + +
+

{action.label}

+

{action.description}

+
+
+ {Array.isArray(action.details) && action.details.length > 0 && ( +
    + {action.details.map((detail) => ( +
  • + + {detail} +
  • + ))} +
+ )} +
+ +
+
))}
- {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", - ], - }} - /> -
+
+
+
+ + + +
+

+ Location focus +

+

+ {hasLocationFilter ? activeLocationLabel : "All locations"} +

+

+ {hasLocationFilter + ? `Within ${distanceFilter} miles` + : "Set a home base to personalize recommendations."} +

+
+ {hasLocationFilter && ( + + )} + +
+
+
+
+
+ {statCards.map((card) => ( +
+
+
+ +
+
+

+ {card.label} +

+

{card.value}

+
+
+
+ ))} +
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+

+ Location focus +

+

+ {hasLocationFilter ? activeLocationLabel : "All locations"} +

+

+ {hasLocationFilter + ? `Within ${distanceFilter} miles` + : "Set a location to prioritize nearby matches"} +

+
+
+
+ {hasLocationFilter && ( + + )} -
- {hasLocationFilter && ( +
+
+ +
+ {distanceOptions.map((distance) => { + const isActive = distanceFilter === distance; + return ( + + ); + })} +
+ + {hasLocationFilter && ( +

+ Showing matches within {distanceFilter} miles of {activeLocationLabel}. +

+ )} + + {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"], + }} + /> +
+ +
+
+
+ {geoError &&

{geoError}

} + {!import.meta.env.VITE_GOOGLE_API_KEY && ( +

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

+ )} +
+ )} +
+ +
+
+
+

+ Your quick actions +

+

Jump back in

+
+
+
+ {secondaryActions.map((action) => ( +
+
+ + + +
+

{action.label}

+

{action.description}

+
+
+ {Array.isArray(action.details) && action.details.length > 0 && ( +
    + {action.details.map((detail) => ( +
  • + + {detail} +
  • + ))} +
)} - +
+ +
+ ))} +
+
+ +
+
+
+

+ Personal schedule +

+

+ {personalScheduleMatches.length > 0 + ? "Coming up for you" + : "Save time slots" + } +

- {geoError && ( -

{geoError}

+ +
+
+ {personalScheduleMatches.length === 0 ? ( +
+ No upcoming matches yet. Create one or join an open match to fill your calendar. +
+ ) : ( +
+ {personalScheduleMatches.map((entry) => ( +
+
+

+ {entry.membership === "hosted" ? "Hosting" : "Playing"} +

+

{entry.formatLabel || "Match"}

+
+ {entry.startLabel && ( + + + {entry.startLabel} + + )} + {entry.locationLabel && ( + + + {entry.locationLabel} + + )} + {entry.relativeTime && ( + + + {entry.relativeTime} + + )} +
+
+
+ + {entry.membership === "hosted" && ( + + )} +
+
+ ))} +
)} - {!import.meta.env.VITE_GOOGLE_API_KEY && ( -

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

+
+ +
+
+
+

+ Nearby highlights

+

Matches worth a look

+
+ +
+
+ {nearbyMatchesPreview.length > 0 ? ( + nearbyMatchesPreview.map((preview) => ( +
+
+

{preview.formatLabel || "Match"}

+
+ {preview.startLabel && ( + + + {preview.startLabel} + + )} + {preview.locationLabel && ( + + + {preview.locationLabel} + + )} + {preview.distanceLabel && ( + + + {preview.distanceLabel} + + )} +
+
+
+ {isOpenMatch(preview.match) && ( + + Open match + + )} + +
+
+ )) + ) : ( +
+ We’ll highlight nearby matches here once you set a location or new events are posted. +
)}
- )} -
- - 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" - /> -
+
+
+
+
+ {filterDefinitions.map((filter) => { + const isActive = activeFilter === filter.id; + return ( + + ); + })} +
+
+ setMatchSearch(event.target.value)} + className="w-full rounded-full border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 shadow-sm focus:border-emerald-400 focus:outline-none focus:ring-2 focus:ring-emerald-200" + /> +
+
+
- {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) => ( + + ))} +
-
- {displayedMatches.map((match) => ( - - ))} -
+ {displayedMatches.length === 0 && ( +
+
+ {hasLocationFilter + ? `No matches within ${distanceFilter} miles just yet. Try widening your search radius or check back soon.` + : "No matches match your filters right now. Try a different filter or refresh shortly."} +
+
+ )} - {matchPagination && !hasLocationFilter && ( -
- - - Page {matchPagination.page} of - {" "} - {Math.max( - 1, - Math.ceil( - getMatchCount(activeFilter) / - matchPagination.perPage - ) - )} - - + {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";