RegistryUI recipes
Sequence builder
Each sequence's email, wait and task steps in order, with its enrollment counts, an Add step dialog and an Activate or Pause button.
It loads its data on the server through the block's client; the sequences block must be installed (npx better-supabase add sequences). Its page on bettersupabase.com is /recipes/sequence-builder.
Web, with shadcn components:
npx shadcn add alert badge button card dialog empty field input select skeletonconst statusVariants = {
draft: "secondary",
active: "success-light",
paused: "warning-light",
archived: "outline",
} as const satisfies Record<SequenceStatus, string>;
const stepIcons = {
email: MailIcon,
wait: ClockIcon,
task: ListChecksIcon,
} as const satisfies Record<SequenceStep["kind"], LucideIcon>;
/** Render inside `<Suspense>`: the organization's sequences and their steps. */
export async function SequenceBuilder() {
const [view, session, t] = await Promise.all([
getSequences(),
getSession(),
getExtracted("sequences"),
]);
if (view.kind === "no-organization") {
return null;
}
if (view.kind === "error") {
return (
<Alert variant="destructive">
<TriangleAlertIcon />
<AlertTitle>{t("The sequences didn't load")}</AlertTitle>
<AlertDescription>{t("Try again in a moment.")}</AlertDescription>
<AlertAction>
<form action={retry}>
<Button size="sm" variant="outline" type="submit">
{t("Retry")}
</Button>
</form>
</AlertAction>
</Alert>
);
}
const manage = can(session, "sequences.manage");
if (view.data.length === 0) {
return (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SendIcon />
</EmptyMedia>
<EmptyTitle>{t("No sequences yet")}</EmptyTitle>
<EmptyDescription>
{manage
? t(
"Create a sample sequence, then activate it to enroll contacts.",
)
: t(
"An admin sets up the sequences your team enrolls contacts in.",
)}
</EmptyDescription>
</EmptyHeader>
{manage ? (
<EmptyContent>
<SampleSequenceButton />
</EmptyContent>
) : null}
</Empty>
);
}
const statusLabels: Record<SequenceStatus, string> = {
draft: t("Draft"),
active: t("Active"),
paused: t("Paused"),
archived: t("Archived"),
};
const stepLabel = (step: SequenceStep): string => {
switch (step.kind) {
case "email": {
return step.subject;
}
case "wait": {
return t("Wait {minutes} minutes", { minutes: String(step.minutes) });
}
case "task": {
return step.title;
}
default: {
const unknown: never = step;
return unknown;
}
}
};
return (
<div className="grid gap-4 lg:grid-cols-2">
{view.data.map((sequence) => (
<Card key={sequence.id}>
<CardHeader>
<div className="flex items-center gap-2">
<CardTitle>{sequence.name}</CardTitle>
<Badge variant={statusVariants[sequence.status]}>
{statusLabels[sequence.status]}
</Badge>
</div>
{manage && sequence.status !== "archived" ? (
<CardAction className="flex gap-2">
<AddStepDialog sequenceId={sequence.id} />
<SequenceStatusButton
sequenceId={sequence.id}
active={sequence.status === "active"}
/>
</CardAction>
) : null}
</CardHeader>
<CardContent>
{sequence.steps.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("No steps yet.")}
</p>
) : (
<ol className="flex flex-col gap-2">
{sequence.steps.map((step, index) => {
const Icon = stepIcons[step.kind];
return (
<li
key={step.id}
className="flex items-center gap-2 text-sm"
>
<span className="w-5 text-xs text-muted-foreground tabular-nums">
{index + 1}
</span>
<Icon
aria-hidden
className="size-4 text-muted-foreground"
/>
<span className="truncate">{stepLabel(step)}</span>
</li>
);
})}
</ol>
)}
</CardContent>
<CardFooter className="gap-4 text-xs text-muted-foreground tabular-nums">
<span>
{t("{count} active", { count: String(sequence.counts.active) })}
</span>
<span>
{t("{count} completed", {
count: String(sequence.counts.completed),
})}
</span>
<span>
{t("{count} replied", { count: String(sequence.counts.replied) })}
</span>
</CardFooter>
</Card>
))}
</div>
);
}Last updated on