284 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|