fiddy/apps/web/app/invite/[token]/page.tsx

284 lines
13 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuthContext } from "@/hooks/auth-context";
import useInviteLink from "@/features/groups/hooks/use-invite-link";
export default function InvitePage() {
const params = useParams();
const router = useRouter();
const { checkSession } = useAuthContext();
const token = useMemo(() => {
const raw = params?.token;
if (typeof raw === "string") return raw;
if (Array.isArray(raw)) return raw[0] || "";
return "";
}, [params]);
const { link, loading, accepting, error, result, accept } = useInviteLink(token || null);
const [checkingSession, setCheckingSession] = useState(true);
const [hasSession, setHasSession] = useState(false);
const [redirectOpen, setRedirectOpen] = useState(false);
const [redirectMessage, setRedirectMessage] = useState("");
const [secondsLeft, setSecondsLeft] = useState(3);
const viewerStatus = link?.viewerStatus || "";
const isAlreadyMember = viewerStatus === "ALREADY_MEMBER";
const isPendingViewer = viewerStatus === "PENDING";
const groupPolicy = link?.groupJoinPolicy || link?.policy || "NOT_ACCEPTING";
const isDisabled = groupPolicy === "NOT_ACCEPTING";
const isManual = groupPolicy === "APPROVAL_REQUIRED";
const isRevoked = Boolean(link?.revokedAt);
const isExpired = link?.expiresAt ? new Date(link.expiresAt).getTime() < Date.now() : false;
const isUsed = Boolean(link?.singleUse && link?.usedAt);
const joinBlockedMessage = isAlreadyMember
? "You are already a member of this group."
: isPendingViewer
? "You currently have a pending join request."
: isDisabled
? "Invites are disabled for this group."
: isRevoked
? "This invite link has been revoked."
: isExpired
? "This invite link has expired."
: isUsed
? "This invite link has already been used."
: "";
useEffect(() => {
let active = true;
async function runCheck() {
const user = await checkSession();
if (!active) return;
setHasSession(Boolean(user));
setCheckingSession(false);
}
runCheck();
return () => {
active = false;
};
}, [checkSession]);
useEffect(() => {
if (!link || loading) return;
if (isAlreadyMember) {
setRedirectMessage("You are already a member of this group.");
setRedirectOpen(true);
return;
}
if (isPendingViewer) {
setRedirectMessage("Your join request is pending approval.");
setRedirectOpen(true);
return;
}
if (isRevoked) {
setRedirectMessage("This invite link has been revoked.");
setRedirectOpen(true);
} else if (isExpired) {
setRedirectMessage("This invite link has expired.");
setRedirectOpen(true);
} else if (isUsed) {
setRedirectMessage("This invite link has already been used.");
setRedirectOpen(true);
}
}, [link, loading, isAlreadyMember, isPendingViewer, isRevoked, isExpired, isUsed]);
useEffect(() => {
if (!error) return;
const lower = error.toLowerCase();
if (lower.includes("revoked")) {
setRedirectMessage("This invite link has been revoked.");
setRedirectOpen(true);
return;
}
if (lower.includes("expired")) {
setRedirectMessage("This invite link has expired.");
setRedirectOpen(true);
return;
}
if (lower.includes("used")) {
setRedirectMessage("This invite link has already been used.");
setRedirectOpen(true);
return;
}
if (lower.includes("not found") || lower.includes("invalid invite")) {
setRedirectMessage("This invite link no longer exists.");
setRedirectOpen(true);
}
}, [error]);
useEffect(() => {
if (!result) return;
if (result.status === "JOINED") setRedirectMessage("You have joined the group.");
if (result.status === "PENDING") setRedirectMessage("Your join request is pending approval.");
if (result.status === "ALREADY_MEMBER") setRedirectMessage("You are already a member of this group.");
setRedirectOpen(true);
}, [result]);
useEffect(() => {
if (!redirectOpen) return;
setSecondsLeft(3);
const timer = window.setInterval(() => {
setSecondsLeft(prev => {
if (prev <= 1) {
window.clearInterval(timer);
router.push("/");
return 0;
}
return prev - 1;
});
}, 1000);
return () => window.clearInterval(timer);
}, [redirectOpen, router]);
const actionLabel = result?.status === "JOINED"
? "Joined"
: result?.status === "PENDING"
? "Request sent"
: result?.status === "ALREADY_MEMBER"
? "Already a member"
: "Join group";
return (
<div className="mx-auto max-w-md space-y-4">
<div className="flex items-center gap-2">
<img
src="/icons/navbar-settings.png"
alt=""
className="h-7 w-7 rounded-lg object-cover"
/>
<h1 className="text-2xl font-semibold">Group invite</h1>
</div>
<div className="panel panel-accent p-4 space-y-3">
<div className="card-header">
<div className="card-title">Invite details</div>
</div>
{loading ? (
<div className="text-sm text-muted">Loading invite...</div>
) : error ? (
<div className="text-sm text-red-400">{error}</div>
) : link ? (
<div className="space-y-2 text-sm text-muted">
<div>
<div className="text-xs text-soft">Group</div>
<div className="text-base font-semibold text-[color:var(--color-text)]">{link.groupName}</div>
</div>
{isAlreadyMember ? (
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-xs text-soft">
You are already a member of this group.
</div>
) : isPendingViewer ? (
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-xs text-soft">
Your join request is pending approval.
</div>
) : isDisabled ? (
<div className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-xs text-red-200">
Invites are disabled for this group.
</div>
) : isRevoked ? (
<div className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-xs text-red-200">
This invite link has been revoked.
</div>
) : isExpired ? (
<div className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-xs text-red-200">
This invite link has expired.
</div>
) : isUsed ? (
<div className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-xs text-red-200">
This invite link has already been used.
</div>
) : isManual ? (
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-xs text-soft">
Requests to join require approval.
</div>
) : null}
</div>
) : (
<div className="text-sm text-muted">Invite not found.</div>
)}
</div>
<div className="panel panel-accent p-4 space-y-3">
<div className="card-header">
<div className="card-title">Join this group</div>
</div>
{checkingSession ? (
<div className="text-sm text-muted">Checking session...</div>
) : !hasSession ? (
<div className="space-y-3">
<div className="text-sm text-muted">Sign in to accept this invite.</div>
<div className="flex items-center gap-2">
<button
type="button"
className="flex-1 rounded-lg btn-accent px-3 py-2 text-sm font-semibold"
onClick={() => router.push("/login")}
>
Sign in
</button>
<button
type="button"
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
onClick={() => router.push("/register")}
>
Create account
</button>
</div>
</div>
) : (
<div className="space-y-3">
{result ? (
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm text-muted">
{result.status === "JOINED" && "You joined the group."}
{result.status === "ALREADY_MEMBER" && "You are already in this group."}
{result.status === "PENDING" && "Join request sent for approval."}
</div>
) : joinBlockedMessage ? (
<div className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{joinBlockedMessage}
</div>
) : null}
{!joinBlockedMessage ? (
<button
type="button"
className="w-full rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
disabled={!link || Boolean(result) || accepting}
onClick={accept}
>
{accepting ? "Joining..." : actionLabel}
</button>
) : null}
<button
type="button"
className="w-full rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
onClick={() => router.push("/")}
>
Go to dashboard
</button>
</div>
)}
</div>
{redirectOpen ? (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-4 !mt-0">
<div className="w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5">
<div className="text-lg font-semibold">Redirecting</div>
<p className="mt-2 text-sm text-muted">{redirectMessage}</p>
<p className="mt-2 text-xs text-soft">Taking you to entries in {secondsLeft}s.</p>
<div className="mt-4 flex items-center gap-2">
<button
type="button"
className="flex-1 rounded-lg btn-accent px-3 py-2 text-sm font-semibold"
onClick={() => router.push("/")}
>
Go now
</button>
</div>
</div>
</div>
) : null}
</div>
);
}