RegistryUI recipes
Activity timeline
Calls, emails and meetings with a contact, newest first.
It loads its data on the server through the block's client; the engagements block must be installed (npx better-supabase add engagements). Its page on bettersupabase.com is /recipes/activity-timeline.
Web, with shadcn components:
npx shadcn add badge card empty separator skeletonconst ICONS = new Map<string, LucideIcon>([
["call", PhoneIcon],
["email", MailIcon],
["meeting", CalendarIcon],
["note", StickyNoteIcon],
["task", CircleCheckIcon],
]);
/**
* Render inside `<Suspense>`: the calls, emails, meetings and notes logged on a
* contact, newest first, with a form to log the next one.
*/
export async function ActivityTimeline({
subject,
}: {
subject: { type: ContactKind; id: string };
}) {
const [session, t, format] = await Promise.all([
getSession(),
getExtracted("crm"),
getFormatter(),
]);
if (!can(session, "engagements.read")) {
return null;
}
const timeline = await settle(() => loadTimeline(subject.type, subject.id));
if (timeline.kind === "no-organization") {
return null;
}
const canLog = can(session, "engagements.write");
const types = new Map([
["call", t("Call")],
["email", t("Email")],
["meeting", t("Meeting")],
["note", t("Note")],
["task", t("Task")],
]);
return (
<Card data-recipe="activity-timeline">
<CardHeader>
<CardTitle>{t("Activity")}</CardTitle>
<CardDescription>
{t("Calls, emails and meetings with this contact, newest first.")}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
{timeline.kind === "error" ? (
<CrmLoadError title={t("The activity didn't load")} />
) : timeline.data.length === 0 ? (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<HistoryIcon />
</EmptyMedia>
<EmptyTitle>{t("Nothing logged yet")}</EmptyTitle>
<EmptyDescription>
{t("Log the first call, email, meeting or note.")}
</EmptyDescription>
</EmptyHeader>
{canLog ? (
<EmptyContent>
<LogActivityForm subject={subject} />
</EmptyContent>
) : null}
</Empty>
) : (
<>
{canLog ? (
<>
<LogActivityForm subject={subject} />
<Separator />
</>
) : null}
<ol className="flex flex-col gap-4">
{timeline.data.map((entry) => {
const Icon = ICONS.get(entry.type) ?? StickyNoteIcon;
return (
<li key={entry.id} className="flex gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Icon className="size-4" aria-hidden />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">
{entry.title}
</span>
<Badge variant="secondary">
{types.get(entry.type) ?? entry.type}
</Badge>
{entry.overdue ? (
<Badge variant="warning-light">{t("Overdue")}</Badge>
) : null}
</div>
{entry.body ? (
<p className="text-sm whitespace-pre-line text-muted-foreground">
{entry.body}
</p>
) : null}
<time
dateTime={entry.createdAt}
className="text-xs text-muted-foreground"
>
{format.dateTime(new Date(entry.createdAt), {
dateStyle: "medium",
timeStyle: "short",
})}
</time>
</div>
</li>
);
})}
</ol>
</>
)}
</CardContent>
</Card>
);
}Last updated on