190 lines
9.3 KiB
TypeScript
190 lines
9.3 KiB
TypeScript
"use client";
|
|
|
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
import { useState } from "react";
|
|
import { toast } from "sonner";
|
|
import type { EventRole } from "@album/contracts";
|
|
import { api } from "@/trpc/react";
|
|
import { PendingInvites } from "@/components/pending-invites";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { MailIcon } from "lucide-react";
|
|
import { effectiveEvent } from "@/lib/event-lifecycle";
|
|
import { galleryIsPublic } from "@/lib/publishing";
|
|
import { EmailHistory } from "./email-history";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
|
|
const roles: EventRole[] = ["owner", "manager", "moderator", "viewer"];
|
|
const roleLabels: Record<EventRole, string> = { owner: "Owner", manager: "Manager", moderator: "Moderator", viewer: "Viewer" };
|
|
|
|
export function EventPeople({
|
|
eventId,
|
|
canManage,
|
|
canGrantOwner,
|
|
}: {
|
|
eventId: string;
|
|
canManage: boolean;
|
|
canGrantOwner: boolean;
|
|
}) {
|
|
const utils = api.useUtils();
|
|
const members = api.manager.members.useQuery({ eventId });
|
|
const guests = api.manager.guests.useQuery({ eventId }, { refetchInterval: 5000 });
|
|
const [guestSearch, setGuestSearch] = useState("");
|
|
const [preview, setPreview] = useState(false);
|
|
const emailPreview = api.manager.emailPreview.useQuery({ eventId }, { enabled: preview });
|
|
const notify = api.manager.notifyGuests.useMutation({
|
|
onSuccess: async (result) => { toast.success(`${result.queued} emails queued`); setPreview(false); await utils.manager.guests.invalidate({ eventId }); },
|
|
onError: (error) => toast.error(error.message),
|
|
});
|
|
const previouslyContacted = new Set((guests.data ?? []).filter((guest) => guest.email && (guest.notifiedAt || guest.notificationClaimedAt)).map((guest) => guest.email!.toLowerCase()));
|
|
const eligible = new Set((guests.data ?? []).filter((guest) => guest.email && guest.notifyWhenReady && !previouslyContacted.has(guest.email.toLowerCase())).map((guest) => guest.email!.toLowerCase())).size;
|
|
const [email, setEmail] = useState("");
|
|
const [role, setRole] = useState<EventRole>("manager");
|
|
const setMember = api.manager.setMember.useMutation({
|
|
onSuccess: async () => {
|
|
toast.success("Member updated");
|
|
setEmail("");
|
|
await utils.manager.members.invalidate({ eventId });
|
|
},
|
|
onError: (error) => toast.error(error.message),
|
|
});
|
|
const invite = api.group.inviteEmail.useMutation({
|
|
onSuccess: async () => { toast.success("Invite emailed"); setEmail(""); await utils.group.pendingInvites.invalidate(); },
|
|
onError: (error) => toast.error(error.message),
|
|
});
|
|
const event = api.manager.event.useQuery({ eventId });
|
|
const remove = api.manager.removeMember.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.manager.members.invalidate({ eventId });
|
|
},
|
|
onError: (error) => toast.error(error.message),
|
|
});
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>People</CardTitle>
|
|
<CardDescription>
|
|
Guests attend or receive gallery notifications. Accounts have permission to manage this event; attending does not grant account access.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4">
|
|
<h3 className="text-lg font-semibold">Guests</h3>
|
|
<Input aria-label="Search guests" placeholder="Search guests by name or email" value={guestSearch} onChange={(e) => setGuestSearch(e.target.value)} />
|
|
{guests.isLoading ? <p>Loading guests…</p> : guests.isError ? <p role="alert">Could not load guests.</p> : !guests.data?.length ? <p className="text-muted-foreground">No guests yet. Guests appear when they register their details or contribute.</p> : null}
|
|
<ul className="flex flex-col gap-3">
|
|
{(guests.data ?? []).filter((guest) => `${guest.displayName ?? ""} ${guest.email ?? ""}`.toLowerCase().includes(guestSearch.toLowerCase())).map((guest) => <li key={guest.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3">
|
|
<div><p className="font-medium">{guest.displayName ?? "Anonymous guest"}</p><p className="text-xs text-muted-foreground">{guest.email ?? "No email provided"}</p></div>
|
|
<Badge variant="secondary">{guest.notifiedAt ? `Emailed ${new Date(guest.notifiedAt).toLocaleDateString()}` : guest.notificationClaimedAt ? "Delivery pending / needs review" : guest.email && guest.notifyWhenReady ? "Opted into gallery email" : "Not subscribed"}</Badge>
|
|
</li>)}
|
|
</ul>
|
|
{event.data?.permissions.includes("gallery.release") ? <div className="flex flex-col gap-3">
|
|
<Button type="button" variant="outline" onClick={() => setPreview(!preview)}>
|
|
<MailIcon data-icon="inline-start" aria-hidden="true" />Preview completion email ({eligible})
|
|
</Button>
|
|
<p className="text-xs text-muted-foreground">Complete the event and make the gallery public first. Only opted-in guests who have not already been emailed are eligible; repeated addresses are deduplicated.</p>
|
|
{preview ? <div className="flex flex-col gap-3 rounded-lg border p-4">
|
|
<p className="font-medium">The gallery for {event.data.title} is ready</p>
|
|
{emailPreview.data ? <iframe title="Gallery email preview" sandbox="" srcDoc={emailPreview.data.html} className="h-[36rem] w-full rounded-lg border" /> : <p>{emailPreview.isError ? "Could not load the preview." : "Loading preview…"}</p>}
|
|
<p className="text-sm">Send to {eligible} opted-in email addresses.</p>
|
|
<Button type="button" disabled={notify.isPending || !eligible || effectiveEvent(event.data).status !== "closed" || !galleryIsPublic(event.data.galleryPolicy, event.data.galleryReleasedAt, event.data.galleryVisibleAt)} onClick={() => notify.mutate({ eventId })}><MailIcon data-icon="inline-start" />{notify.isPending ? "Queueing…" : "Queue completion emails"}</Button>
|
|
</div> : null}
|
|
</div> : null}
|
|
{event.data?.permissions.includes("gallery.release") ? <EmailHistory eventId={eventId} /> : null}
|
|
<h3 className="text-lg font-semibold">Accounts with access</h3>
|
|
<ul className="flex flex-col gap-2">
|
|
{(members.data ?? []).map((member) => (
|
|
<li key={member.id} className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<p className="font-medium">{member.name}</p>
|
|
<p className="text-xs text-muted-foreground">{member.email}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="secondary">{roleLabels[member.role]}</Badge>
|
|
{canManage ? (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() =>
|
|
remove.mutate({ eventId, userId: member.userId })
|
|
}
|
|
>
|
|
Remove
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{canManage ? (
|
|
<form
|
|
className="flex flex-wrap items-end gap-2"
|
|
onSubmit={(formEvent) => {
|
|
formEvent.preventDefault();
|
|
if (role === "owner" && !canGrantOwner) {
|
|
toast.error("Only an owner can add another owner");
|
|
return;
|
|
}
|
|
setMember.mutate({ eventId, email, role });
|
|
}}
|
|
>
|
|
<Input
|
|
type="email"
|
|
required
|
|
placeholder="email@example.com"
|
|
value={email}
|
|
onChange={(event) => setEmail(event.target.value)}
|
|
className="max-w-xs"
|
|
/>
|
|
<Select value={role} onValueChange={(value) => setRole(value as EventRole)}>
|
|
<SelectTrigger aria-label="Account role" className="min-w-36">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent position="popper" align="start">
|
|
<SelectGroup>
|
|
{roles
|
|
.filter((value) => value !== "owner" || canGrantOwner)
|
|
.map((value) => (
|
|
<SelectItem key={value} value={value}>
|
|
{roleLabels[value]}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button type="submit" disabled={setMember.isPending}>
|
|
Add existing user
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={invite.isPending || !event.data?.groupId}
|
|
onClick={() => {
|
|
if (!event.data?.groupId) return;
|
|
invite.mutate({
|
|
email,
|
|
groupId: event.data.groupId,
|
|
eventId,
|
|
eventRole: role,
|
|
groupRole: "member",
|
|
});
|
|
}}
|
|
>
|
|
Email invite
|
|
</Button>
|
|
</form>
|
|
) : null}
|
|
{canManage && event.data?.groupId ? <PendingInvites groupId={event.data.groupId} eventId={eventId} canRevoke /> : null}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|