"use client"; import { useState, type ReactNode } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "@multica/core/api"; import { workspaceKeys, workspaceListOptions, } from "@multica/core/workspace/queries"; import { paths } from "@multica/core/paths"; import { useNavigation } from "../navigation"; import { useLogout } from "../auth"; import { Button } from "@multica/ui/components/ui/button"; import { Card, CardContent } from "@multica/ui/components/ui/card"; import { Skeleton } from "@multica/ui/components/ui/skeleton"; import { ArrowLeft, LogOut, Users, Check, X } from "lucide-react"; export interface InvitePageProps { invitationId: string; /** * Optional "go back" handler. Caller passes it only when there's a * sensible destination (user has at least one workspace, or arrived * from an in-app flow). Omitted on first-invite/zero-workspace paths * where Back would have nowhere to go — Log out is then the only exit. */ onBack?: () => void; } /** * Full-page shell for the "accept invitation" transition. Shared between * web (Next.js route `/invite/[id]`) and desktop (window-overlay). * Top-bar affordances (Back, Log out) live here so both platforms get * identical UX. Platform chrome (window drag region, immersive mode) is * layered on by the desktop overlay; web just renders the page directly. */ export function InvitePage({ invitationId, onBack }: InvitePageProps) { const { push } = useNavigation(); const qc = useQueryClient(); const [accepting, setAccepting] = useState(false); const [declining, setDeclining] = useState(false); const [error, setError] = useState(null); const [done, setDone] = useState<"accepted" | "declined" | null>(null); const { data: invitation, isLoading, error: fetchError } = useQuery({ queryKey: ["invitation", invitationId], queryFn: () => api.getInvitation(invitationId), }); // Workspace list for the fallback "Go to dashboard" destinations. The invite // page is a pre-workspace global route so we can't rely on WorkspaceSlugProvider. const { data: wsList = [] } = useQuery(workspaceListOptions()); const fallbackDest = wsList[0] ? paths.workspace(wsList[0].slug).issues() : paths.newWorkspace(); const handleAccept = async () => { setAccepting(true); setError(null); try { await api.acceptInvitation(invitationId); setDone("accepted"); // Fetch the refreshed workspace list so we know the joined workspace's slug. const nextList = await qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0, }); const joined = nextList.find((w) => w.id === invitation?.workspace_id); qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() }); // Navigate into the joined workspace. The [workspaceSlug]/layout will // sync api client, stores, and the last_workspace_slug cookie from the URL. const dest = joined ? paths.workspace(joined.slug).issues() : fallbackDest; setTimeout(() => push(dest), 1000); } catch (e) { setError(e instanceof Error ? e.message : "Failed to accept invitation"); } finally { setAccepting(false); } }; const handleDecline = async () => { setDeclining(true); setError(null); try { await api.declineInvitation(invitationId); setDone("declined"); qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() }); } catch (e) { setError(e instanceof Error ? e.message : "Failed to decline invitation"); } finally { setDeclining(false); } }; if (isLoading) { return ( ); } if (fetchError || !invitation) { return (

Invitation not found

This invitation may have expired, been revoked, or doesn't belong to your account.

); } if (done === "accepted") { return (

You joined {invitation.workspace_name}!

Redirecting to workspace...

); } if (done === "declined") { return (

Invitation declined

You won't be added to this workspace.

); } const isExpired = invitation.status !== "pending"; const isAlreadyHandled = invitation.status === "accepted" || invitation.status === "declined"; return (

Join {invitation.workspace_name ?? "workspace"}

{invitation.inviter_name || invitation.inviter_email}{" "} invited you to join as {invitation.role === "admin" ? "an admin" : "a member"}.

{isAlreadyHandled ? (
This invitation has already been {invitation.status}.
) : isExpired ? (
This invitation has expired.
) : (
)} {error && (

{error}

)}
); } /** * Shared chrome for every InvitePage render state (loading, error, * default, accepted, declined). Keeps Back + Log out buttons in a * consistent position across all branches and across platforms. */ function InviteShell({ onBack, children, }: { onBack?: () => void; children: ReactNode; }) { const logout = useLogout(); return (
{onBack && ( )} {children}
); }