141 lines
4.2 KiB
TypeScript
141 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card } from "@/components/ui/card";
|
|
import { toast } from "@/lib/toast";
|
|
import {
|
|
useRevokeInvitation,
|
|
useResendInvitation,
|
|
type Invitation,
|
|
} from "@/hooks/use-api";
|
|
import { Copy, RefreshCw, Trash2 } from "lucide-react";
|
|
|
|
interface InvitationsGridProps {
|
|
invitations: Invitation[];
|
|
onRefetch: () => void;
|
|
}
|
|
|
|
export function InvitationsGrid({
|
|
invitations,
|
|
onRefetch,
|
|
}: InvitationsGridProps) {
|
|
const revokeInvitation = useRevokeInvitation();
|
|
const resendInvitation = useResendInvitation();
|
|
const [actioningId, setActioningId] = useState<string | null>(null);
|
|
|
|
const handleCopyUrl = async (url: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(url);
|
|
toast.success("Invitation link copied to clipboard");
|
|
} catch (error) {
|
|
toast.error("Failed to copy link");
|
|
}
|
|
};
|
|
|
|
const handleRevoke = async (invitation: Invitation) => {
|
|
if (!confirm(`Cancel invitation for ${invitation.emailAddress}?`)) {
|
|
return;
|
|
}
|
|
|
|
setActioningId(invitation.id);
|
|
try {
|
|
await revokeInvitation.mutateAsync(invitation.id);
|
|
toast.success("Invitation cancelled");
|
|
onRefetch();
|
|
} catch (error) {
|
|
toast.error("Failed to cancel invitation");
|
|
} finally {
|
|
setActioningId(null);
|
|
}
|
|
};
|
|
|
|
const handleResend = async (invitation: Invitation) => {
|
|
setActioningId(invitation.id);
|
|
try {
|
|
await resendInvitation.mutateAsync(invitation.id);
|
|
toast.success("Invitation resent");
|
|
onRefetch();
|
|
} catch (error) {
|
|
toast.error("Failed to resend invitation");
|
|
} finally {
|
|
setActioningId(null);
|
|
}
|
|
};
|
|
|
|
if (invitations.length === 0) {
|
|
return (
|
|
<Card className="p-8 text-center text-muted-foreground">
|
|
No pending invitations
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{invitations.map((invitation) => {
|
|
const role = invitation.publicMetadata?.role || "unknown";
|
|
const createdDate = new Date(invitation.createdAt);
|
|
const isActioning = actioningId === invitation.id;
|
|
|
|
return (
|
|
<Card key={invitation.id} className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-medium">{invitation.emailAddress}</h3>
|
|
<span className="text-xs px-2 py-1 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
|
Pending
|
|
</span>
|
|
<span className="text-xs px-2 py-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
|
{role}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Sent {formatDistanceToNow(createdDate, { addSuffix: true })}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{invitation.url && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleCopyUrl(invitation.url!)}
|
|
>
|
|
<Copy className="h-4 w-4 mr-1" />
|
|
Copy Link
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleResend(invitation)}
|
|
disabled={isActioning}
|
|
>
|
|
<RefreshCw
|
|
className={`h-4 w-4 mr-1 ${isActioning ? "animate-spin" : ""}`}
|
|
/>
|
|
Resend
|
|
</Button>
|
|
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => handleRevoke(invitation)}
|
|
disabled={isActioning}
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-1" />
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|