From a5fbd96271dbe3bca42477a523d8f1ba05eb8013 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 19 Oct 2025 17:58:41 -0700 Subject: [PATCH 1/3] feat: add decline invite flow --- src/InvitationPage.jsx | 93 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/src/InvitationPage.jsx b/src/InvitationPage.jsx index 008f093f..a07f4823 100644 --- a/src/InvitationPage.jsx +++ b/src/InvitationPage.jsx @@ -17,6 +17,7 @@ import { getInvitePreview, claimInvite, acceptInvite, + rejectInvite, } from "./services/invites"; import { forgotPassword, login, signup } from "./services/auth"; import { getMatch } from "./services/matches"; @@ -49,6 +50,7 @@ export default function InvitationPage() { const [agreeTerms, setAgreeTerms] = useState(false); const [claiming, setClaiming] = useState(false); const [archivedNotice, setArchivedNotice] = useState(false); + const [declining, setDeclining] = useState(false); const [authMode, setAuthMode] = useState("signIn"); const [signInEmail, setSignInEmail] = useState(""); const [signInPassword, setSignInPassword] = useState(""); @@ -823,6 +825,56 @@ export default function InvitationPage() { } }; + const handleDecline = useCallback(async () => { + if (!token) return; + setError(""); + setToast(null); + setDeclining(true); + try { + await rejectInvite(token); + setPreview((prev) => + prev + ? { + ...prev, + status: "rejected", + } + : prev, + ); + setPhase("preview"); + setAuthMode("signIn"); + setShowForgotPassword(false); + } catch (err) { + if (isMatchArchivedError(err)) { + setArchivedNotice(true); + const archivedMessage = "This match has been archived. Invites are read-only."; + setError(archivedMessage); + setToast({ type: "error", message: archivedMessage }); + } else if ( + err?.status === 404 || + err?.response?.status === 404 || + err?.message === "not_found" + ) { + setPreview(null); + setLoadError({ + emoji: "🔍", + title: "Invite not found", + message: + "This invite is no longer available. Ask the host to send a new link.", + }); + } else { + setToast({ + type: "error", + message: + err?.response?.data?.message || + err?.message || + "We couldn't decline this invite. Try again later.", + }); + } + } finally { + setDeclining(false); + } + }, [token]); + // Render states if (loading) return ( @@ -877,6 +929,20 @@ export default function InvitationPage() { /> ); + if (preview.status === "rejected") { + const hostName = (preview?.inviter?.full_name || "").trim(); + const hostFirstName = hostName.split(" ").filter(Boolean)[0] || ""; + const hostDisplay = hostFirstName || "the host"; + return ( + + + + ); + } const startDate = match.start_date_time ? new Date(match.start_date_time) @@ -1078,6 +1144,14 @@ export default function InvitationPage() { )} + {error && {error}} ); @@ -1304,6 +1378,14 @@ export default function InvitationPage() { )} + {error && {error}} ); @@ -1507,9 +1589,20 @@ export default function InvitationPage() { Join Match & Play +

You'll be asked to sign in or create a free account to claim your spot.

+

+ Can't make it? We'll let {inviterFirstName || "the host"} know when you decline. +

)} From d9d250bd94ea5b045d3938f91459143106a4ddd4 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 19 Oct 2025 19:43:47 -0700 Subject: [PATCH 2/3] Surface decline invite control in details modal --- src/InvitationPage.jsx | 11 +++++-- src/components/MatchDetailsModal.jsx | 46 +++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/InvitationPage.jsx b/src/InvitationPage.jsx index a07f4823..f7f4cc64 100644 --- a/src/InvitationPage.jsx +++ b/src/InvitationPage.jsx @@ -991,6 +991,9 @@ export default function InvitationPage() { const inviterFirstName = inviterName.split(" ").filter(Boolean)[0] || ""; const inviterInitials = getAvatarInitials(inviterName || "Matchplay"); const inviterAvatarUrl = getAvatarUrlFromPlayer(preview?.inviter); + const declineInviteMessage = `Can't make it? We'll let ${ + inviterFirstName || "the host" + } know when you decline.`; const maskedIdentifier = preview?.maskedIdentifier; const isInviteeClaim = inviteeRequiresAccountClaim; @@ -1600,9 +1603,7 @@ export default function InvitationPage() {

You'll be asked to sign in or create a free account to claim your spot.

-

- Can't make it? We'll let {inviterFirstName || "the host"} know when you decline. -

+

{declineInviteMessage}

)} @@ -1622,6 +1623,10 @@ export default function InvitationPage() { onToast={handleToast} formatDateTime={formatSuccessDateTime} onManageInvites={() => {}} + onDeclineInvite={handleDecline} + declineInviteLoading={declining} + declineInviteDisabled={isArchivedMatch} + declineInviteHelpText={declineInviteMessage} initialStatus="success" /> )} diff --git a/src/components/MatchDetailsModal.jsx b/src/components/MatchDetailsModal.jsx index 4921342d..2139fe03 100644 --- a/src/components/MatchDetailsModal.jsx +++ b/src/components/MatchDetailsModal.jsx @@ -471,6 +471,11 @@ const MatchDetailsModal = ({ onToast, formatDateTime, onManageInvites, + onDeclineInvite, + declineInviteLabel, + declineInviteLoading = false, + declineInviteDisabled = false, + declineInviteHelpText, initialStatus, onViewPlayerProfile, }) => { @@ -733,6 +738,16 @@ const MatchDetailsModal = ({ }, [capacityInfo, remainingSpots]); const matchId = match?.id ?? null; const canManageInvites = Boolean(onManageInvites) && isHost && matchId; + const showDeclineInvite = typeof onDeclineInvite === "function"; + const declineInviteBusy = Boolean(declineInviteLoading); + const declineInviteDisabledProp = Boolean(declineInviteDisabled); + const declineInviteActionDisabled = + declineInviteBusy || declineInviteDisabledProp || isArchived || isCancelled; + const declineInviteButtonLabel = + declineInviteLabel || + (declineInviteBusy + ? "Declining invite..." + : "Can't make it? Decline invite"); const handleManageInvites = useCallback(() => { if (!canManageInvites || !matchId) return; @@ -1940,13 +1955,30 @@ const MatchDetailsModal = ({ {status === "details" && disabledReason && (

{disabledReason}

)} - {remainingSpots !== null && ( -

- {remainingSpots} spot{remainingSpots === 1 ? "" : "s"} remaining -

- )} - - )} + {remainingSpots !== null && ( +

+ {remainingSpots} spot{remainingSpots === 1 ? "" : "s"} remaining +

+ )} + {showDeclineInvite && ( + <> + + {declineInviteHelpText && ( +

+ {declineInviteHelpText} +

+ )} + + )} + + )} ); From bb97a7cf5ae9fd7c33568cfd1af6a4e7ce9c5a73 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Sun, 19 Oct 2025 20:07:02 -0700 Subject: [PATCH 3/3] feat: enable invite responses from match modal --- src/TennisMatchApp.jsx | 189 ++++++++++++++++++++++++++- src/components/MatchDetailsModal.jsx | 69 +++++++++- 2 files changed, 248 insertions(+), 10 deletions(-) diff --git a/src/TennisMatchApp.jsx b/src/TennisMatchApp.jsx index 2e7e4381..a40dd1bc 100644 --- a/src/TennisMatchApp.jsx +++ b/src/TennisMatchApp.jsx @@ -654,6 +654,11 @@ const TennisMatchApp = () => { const [viewMatch, setViewMatch] = useState(null); const [showMatchDetailsModal, setShowMatchDetailsModal] = useState(false); const [matchDetailsOrigin, setMatchDetailsOrigin] = useState("browse"); + const [viewInviteContext, setViewInviteContext] = useState(null); + const [acceptingInviteFromDetails, setAcceptingInviteFromDetails] = + useState(false); + const [decliningInviteFromDetails, setDecliningInviteFromDetails] = + useState(false); const [pendingInvites, setPendingInvites] = useState([]); const [invitesLoading, setInvitesLoading] = useState(false); const [invitesError, setInvitesError] = useState(""); @@ -1795,6 +1800,9 @@ const TennisMatchApp = () => { const closeMatchDetailsModal = useCallback(() => { setShowMatchDetailsModal(false); setViewMatch(null); + setViewInviteContext(null); + setAcceptingInviteFromDetails(false); + setDecliningInviteFromDetails(false); if (matchDetailsOrigin === "browse") { goToBrowse({ replace: true }); } else if (matchDetailsOrigin === "invites") { @@ -1805,6 +1813,110 @@ const TennisMatchApp = () => { setMatchDetailsOrigin("browse"); }, [goToBrowse, goToInvites, matchDetailsOrigin]); + const handleAcceptInviteFromDetails = useCallback(async () => { + const token = viewInviteContext?.token; + if (!token) return null; + const matchIdFromContext = + viewInviteContext?.matchId || + viewMatch?.match?.id || + viewMatch?.id || + null; + setAcceptingInviteFromDetails(true); + try { + await acceptInvite(token); + displayToast("Invite accepted! See you on the court. 🎾"); + await Promise.all([fetchPendingInvites(), fetchMatches()]); + setViewInviteContext((prev) => + prev + ? { + ...prev, + invite: { + ...(prev.invite || {}), + status: "accepted", + accepted: true, + rejected: false, + }, + } + : prev, + ); + if (matchIdFromContext) { + const updated = await fetchMatchDetailsWithArchivedFallback( + matchIdFromContext, + ); + if (updated) { + setViewMatch(updated); + return updated; + } + } + return null; + } catch (err) { + let message; + if ( + isMatchArchivedError(err) || + err?.response?.data?.error === MATCH_ARCHIVED_ERROR + ) { + message = + "This match has been archived. Invites can no longer be updated."; + } else { + message = + err?.response?.data?.message || + err?.data?.message || + err?.message || + "Failed to accept invite"; + } + displayToast(message, "error"); + const errorObject = new Error(message); + errorObject.silent = true; + throw errorObject; + } finally { + setAcceptingInviteFromDetails(false); + } + }, [ + viewInviteContext, + viewMatch, + displayToast, + fetchPendingInvites, + fetchMatches, + fetchMatchDetailsWithArchivedFallback, + ]); + + const handleDeclineInviteFromDetails = useCallback(async () => { + const token = viewInviteContext?.token; + if (!token) return; + setDecliningInviteFromDetails(true); + try { + await rejectInvite(token); + displayToast("Invite declined", "info"); + await Promise.all([fetchPendingInvites(), fetchMatches()]); + setViewInviteContext(null); + closeMatchDetailsModal(); + } catch (err) { + const message = (() => { + if ( + isMatchArchivedError(err) || + err?.response?.data?.error === MATCH_ARCHIVED_ERROR + ) { + return "This match has been archived. Invites can no longer be updated."; + } + return ( + err?.response?.data?.message || + err?.data?.message || + err?.message || + "Failed to decline invite" + ); + })(); + displayToast(message, "error"); + } finally { + setDecliningInviteFromDetails(false); + } + }, [ + viewInviteContext, + displayToast, + fetchPendingInvites, + fetchMatches, + closeMatchDetailsModal, + ]); + const handleManageInvitesFromDetails = useCallback( (matchId) => { if (!matchId) return; @@ -1907,6 +2019,9 @@ const TennisMatchApp = () => { const data = await fetchMatchDetailsWithArchivedFallback(matchId); if (data) { setMatchDetailsOrigin("browse"); + setViewInviteContext({ token, matchId, invite }); + setAcceptingInviteFromDetails(false); + setDecliningInviteFromDetails(false); setViewMatch(data); setShowMatchDetailsModal(true); } @@ -1915,10 +2030,15 @@ const TennisMatchApp = () => { throw error; } } + } else { + setViewInviteContext({ token, invite }); } }) .catch(() => { displayToast("Failed to open match", "error"); + setViewInviteContext(null); + setAcceptingInviteFromDetails(false); + setDecliningInviteFromDetails(false); }); } }, [ @@ -1927,7 +2047,8 @@ const TennisMatchApp = () => { fetchMatchDetailsWithArchivedFallback, ]); - const handleViewDetails = async (matchId) => { + const handleViewDetails = async (matchId, options = {}) => { + const { inviteToken, invite, origin } = options; try { const data = await fetchMatchDetailsWithArchivedFallback(matchId); if (!data) { @@ -1937,10 +2058,22 @@ const TennisMatchApp = () => { if ((data.match || {}).status === "archived" && activeFilter !== "archived") { setActiveFilter("archived"); } - setMatchDetailsOrigin(currentScreen); + if (inviteToken) { + setViewInviteContext({ token: inviteToken, matchId, invite: invite || null }); + } else { + setViewInviteContext(null); + } + setAcceptingInviteFromDetails(false); + setDecliningInviteFromDetails(false); + setMatchDetailsOrigin(origin ?? currentScreen); setViewMatch(data); setShowMatchDetailsModal(true); } catch (err) { + if (inviteToken) { + setViewInviteContext(null); + } + setAcceptingInviteFromDetails(false); + setDecliningInviteFromDetails(false); if (isMatchArchivedError(err)) { displayToast("This match has been archived.", "error"); } else { @@ -2312,7 +2445,12 @@ const TennisMatchApp = () => { {invite.match?.id && ( {status === "details" && disabledReason && (

{disabledReason}