diff --git a/src/InvitationPage.jsx b/src/InvitationPage.jsx
index 008f093f..f7f4cc64 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 (
+
You'll be asked to sign in or create a free account to claim your spot.
+{declineInviteMessage}
> )} @@ -1529,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/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 && (