From 0122a6d99e14395c0965e320d28bda4c2ea13bc9 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Fri, 13 Nov 2026 08:57:07 +0530 Subject: [PATCH] Share the Tags screen from email-blocks as TagManager Hosts such as CourseLit need tag management (counts, rename, delete) and would otherwise re-implement the dashboard's Tags page. Move it into @sendlit/email-blocks as TagManager and use it from the dashboard: - The host passes functions for SendLit's /tags endpoints, an optional sequenceHref and link component for the "still used in" list, a loading indicator and an error-message mapper - Tag types are declared in the package (with a test that they match the contract) so the published types don't point at the private api-contract - Docs page and changeset --- .changeset/shared-tag-manager.md | 5 + apps/docs/content/docs/email-blocks/meta.json | 1 + .../content/docs/email-blocks/tag-manager.mdx | 53 ++ .../app/(dashboard)/contacts/tags/page.tsx | 365 +------------ packages/email-blocks/src/index.ts | 14 + packages/email-blocks/src/tag-manager-text.ts | 53 ++ .../src/tag-manager-types.test.ts | 21 + .../email-blocks/src/tag-manager-types.ts | 45 ++ packages/email-blocks/src/tag-manager.test.ts | 81 +++ packages/email-blocks/src/tag-manager.tsx | 480 ++++++++++++++++++ 10 files changed, 767 insertions(+), 351 deletions(-) create mode 100644 .changeset/shared-tag-manager.md create mode 100644 apps/docs/content/docs/email-blocks/tag-manager.mdx create mode 100644 packages/email-blocks/src/tag-manager-text.ts create mode 100644 packages/email-blocks/src/tag-manager-types.test.ts create mode 100644 packages/email-blocks/src/tag-manager-types.ts create mode 100644 packages/email-blocks/src/tag-manager.test.ts create mode 100644 packages/email-blocks/src/tag-manager.tsx diff --git a/.changeset/shared-tag-manager.md b/.changeset/shared-tag-manager.md new file mode 100644 index 00000000..a42f54fe --- /dev/null +++ b/.changeset/shared-tag-manager.md @@ -0,0 +1,5 @@ +--- +"@sendlit/email-blocks": minor +--- + +Add `TagManager`, the dashboard's Tags screen as a block, so hosts don't re-implement it. It lists every tag with its contact count, renames a tag everywhere it's used, and deletes one after showing the segments, broadcasts, sequences and signup forms that still name it. Hosts pass functions that call SendLit's `/tags` endpoints through their own backend. `TagSummary`, `TagReferences`, `RenameTagResult` and `DeleteTagResult` are exported for typing them. diff --git a/apps/docs/content/docs/email-blocks/meta.json b/apps/docs/content/docs/email-blocks/meta.json index ba74e5e8..a1dfc456 100644 --- a/apps/docs/content/docs/email-blocks/meta.json +++ b/apps/docs/content/docs/email-blocks/meta.json @@ -7,6 +7,7 @@ "contact-filter-builder", "trigger-picker", "tag-editor", + "tag-manager", "sequence-analytics", "subscriber-list", "sequence-email-list", diff --git a/apps/docs/content/docs/email-blocks/tag-manager.mdx b/apps/docs/content/docs/email-blocks/tag-manager.mdx new file mode 100644 index 00000000..61223f87 --- /dev/null +++ b/apps/docs/content/docs/email-blocks/tag-manager.mdx @@ -0,0 +1,53 @@ +--- +title: TagManager +description: List, rename and delete the tags your contacts carry. +--- + +`TagManager` lists every tag on your contacts with how many carry it. People +can rename a tag everywhere it's used, or delete it from every contact. Before +a delete it shows the segments, broadcasts, sequences and signup forms that +still name the tag, since those keep the old name. It's the same screen as +SendLit's **Contacts → Tags** page. + +## Usage + +Give it one function for each of SendLit's tag endpoints. Hosts call them +through their own backend, which holds the team's API key. + +```tsx +import { TagManager } from "@sendlit/email-blocks"; +import Link from "next/link"; + +function TagsPage() { + return ( + (await api.get("/tags")).items} + getTagReferences={(tag) => api.get(`/tags/${encodeURIComponent(tag)}/references`)} + renameTag={(tag, name) => + api.patch(`/tags/${encodeURIComponent(tag)}`, { name }) + } + deleteTag={(tag) => api.delete(`/tags/${encodeURIComponent(tag)}`)} + sequenceHref={(sequence) => + `/${sequence.type === "broadcast" ? "broadcasts" : "sequences"}/${sequence.sequenceId}` + } + linkComponent={Link} + /> + ); +} +``` + +## Props + +| Prop | Description | +| --- | --- | +| `listTags` | Returns every tag with its `contactCount` (`GET /tags`). | +| `getTagReferences` | Returns what still names a tag (`GET /tags/:tag/references`). Called when the delete dialog opens. | +| `renameTag` | Renames a tag everywhere (`PATCH /tags/:tag`). Renaming to an existing tag merges the two. | +| `deleteTag` | Removes a tag from every contact (`DELETE /tags/:tag`). | +| `sequenceHref` | Optional. Where a broadcast or sequence that uses the tag is edited. Without it they aren't linked. | +| `linkComponent` | Optional. The link to render those with, such as Next.js `Link`. A plain anchor by default. | +| `loadingIndicator` | Optional. Shown while tags or references load. Skeleton rows by default. | +| `errorMessage` | Optional. `(error, fallback) => string` for failed calls. Defaults to the error's message. | + +The `TagSummary`, `TagReferences`, `RenameTagResult` and `DeleteTagResult` +types are exported too, for typing those functions. diff --git a/apps/web/app/(dashboard)/contacts/tags/page.tsx b/apps/web/app/(dashboard)/contacts/tags/page.tsx index 74692c6b..32c1cf11 100644 --- a/apps/web/app/(dashboard)/contacts/tags/page.tsx +++ b/apps/web/app/(dashboard)/contacts/tags/page.tsx @@ -1,374 +1,37 @@ "use client"; -import type { TagReferences, TagSummary } from "@sendlit/api-contract"; -import { Pencil, Trash2, TriangleAlert } from "lucide-react"; +import { TagManager } from "@sendlit/email-blocks"; import Link from "next/link"; -import { useEffect, useState } from "react"; -import { Banner } from "@/components/dashboard/banner"; import { useSetBreadcrumb } from "@/components/dashboard/breadcrumb-context"; -import { DeleteConfirmationDialog } from "@/components/dashboard/delete-confirmation-dialog"; import { Loading } from "@/components/dashboard/loading"; import { PageHeader } from "@/components/dashboard/page-header"; import { ScrollablePage } from "@/components/dashboard/scrollable-page"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent } from "@/components/ui/card"; -import { Button } from "@/components/ui/codelit/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/codelit/dialog"; -import { IconButton } from "@/components/ui/codelit/icon-button"; -import { Input } from "@/components/ui/codelit/input"; -import { Label } from "@/components/ui/codelit/label"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { deleteTag, getTagReferences, listTags, renameTag } from "@/lib/api"; import { ApiError } from "@/lib/api-client"; -const plural = (count: number, word: string, words = `${word}s`) => - `${count.toLocaleString()} ${count === 1 ? word : words}`; - -/** "2 contacts and 1 segment", leaving out the zeros. */ -function listCounts(counts: [number, string, string?][]) { - const parts = counts - .filter(([count]) => count > 0) - .map(([count, word, words]) => plural(count, word, words)); - if (parts.length <= 1) return parts[0] ?? "nothing"; - return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`; -} - export default function TagsPage() { - const [tags, setTags] = useState(null); - const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); - // The selected tag outlives `open`, so the dialogs keep their content - // while they animate closed. - const [renaming, setRenaming] = useState(null); - const [renameOpen, setRenameOpen] = useState(false); - const [deleting, setDeleting] = useState(null); - const [deleteOpen, setDeleteOpen] = useState(false); - useSetBreadcrumb([{ label: "Contacts", href: "/contacts" }, { label: "Tags" }]); - async function load() { - try { - setTags(await listTags()); - } catch (err) { - setError(err instanceof ApiError ? err.message : "Failed to load tags"); - } - } - - // biome-ignore lint/correctness/useExhaustiveDependencies: load once on mount - useEffect(() => { - load(); - }, []); - return ( - - {error && {error}} - {notice && ( -

- {notice} -

- )} - - { - setNotice(message); - await load(); - }} - /> - { - setNotice(message); - await load(); - }} + + `/${sequence.type === "broadcast" ? "broadcasts" : "sequences"}/${sequence.sequenceId}` + } + linkComponent={Link} + loadingIndicator={} + errorMessage={(error, fallback) => + error instanceof ApiError ? error.message : fallback + } /> - - - - {tags === null ? ( - - ) : tags.length === 0 ? ( -

- No tags yet. Tags you add to contacts show up here. -

- ) : ( - - - - Tag - Contacts - - - - - {tags.map((tag) => ( - - - - {tag.tag} - - - - {tag.contactCount.toLocaleString()} - - - { - setRenaming(tag); - setRenameOpen(true); - }} - > - - - { - setDeleting(tag); - setDeleteOpen(true); - }} - > - - - - - ))} - -
- )} -
-
); } - -function RenameTagDialog({ - tag, - open, - onOpenChange, - onRenamed, -}: { - tag: TagSummary | null; - open: boolean; - onOpenChange: (open: boolean) => void; - onRenamed: (message: string) => Promise; -}) { - const [name, setName] = useState(""); - const [error, setError] = useState(null); - const [saving, setSaving] = useState(false); - - useEffect(() => { - if (!open) return; - setName(tag?.tag ?? ""); - setError(null); - }, [tag, open]); - - async function submit() { - if (!tag) return; - setSaving(true); - setError(null); - try { - const result = await renameTag(tag.tag, name.trim()); - onOpenChange(false); - await onRenamed( - `Renamed ${result.previousTag} to ${result.tag} on ${listCounts([ - [result.contactsUpdated, "contact"], - [result.segmentsUpdated, "segment"], - [result.sequencesUpdated, "broadcast or sequence", "broadcasts or sequences"], - [result.signupFormsUpdated, "signup form"], - ])}.`, - ); - } catch (err) { - setError(err instanceof ApiError ? err.message : "Failed to rename the tag"); - } finally { - setSaving(false); - } - } - - const unchanged = !name.trim() || name.trim() === tag?.tag; - return ( - - - - - Rename {tag?.tag} - - - The new name replaces it on every contact and in the segments, filters, - triggers, email actions and signup forms that use it. Renaming to a tag that - already exists merges the two. Sent broadcasts keep the old name. - - -
{ - event.preventDefault(); - if (!unchanged) submit(); - }} - > - {error && {error}} - - setName(event.target.value)} - /> -
- - - - -
-
- ); -} - -const usageLabels: Record< - TagReferences["sequences"][number]["usages"][number], - string -> = { - filter: "audience filter", - exclude_filter: "exclusion filter", - trigger: "trigger", - email_action: "email tag action", -}; - -function DeleteTagDialog({ - tag, - open, - onOpenChange, - onDeleted, -}: { - tag: TagSummary | null; - open: boolean; - onOpenChange: (open: boolean) => void; - onDeleted: (message: string) => Promise; -}) { - const [references, setReferences] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - if (!open || !tag) return; - setReferences(null); - setError(null); - let cancelled = false; - getTagReferences(tag.tag) - .then((result) => { - if (!cancelled) setReferences(result); - }) - .catch((err) => { - if (!cancelled) { - setError( - err instanceof ApiError ? err.message : "Failed to check where it's used", - ); - } - }); - return () => { - cancelled = true; - }; - }, [tag, open]); - - const referenceCount = references - ? references.segments.length + - references.sequences.length + - references.signupForms.length - : 0; - - return ( - { - if (!tag) return; - const result = await deleteTag(tag.tag); - await onDeleted( - `Deleted ${result.tag} from ${plural(result.contactsUpdated, "contact")}.`, - ); - }} - > - {error ? ( - {error} - ) : references === null ? ( - - ) : referenceCount === 0 ? ( -

- No segments, broadcasts, sequences or signup forms use this tag. -

- ) : ( -
-

- - Still used in {plural(referenceCount, "place")} -

-

- These keep the tag name: a filter on a deleted tag matches nobody, and a - trigger, email action or signup form can add the tag again. Update them - first if that's not what you want. -

-
    - {references.segments.map((segment) => ( -
  • - Segment {segment.name} -
  • - ))} - {references.sequences.map((sequence) => ( -
  • - - {sequence.type === "broadcast" ? "Broadcast" : "Sequence"}{" "} - {sequence.title || "Untitled"} - {" "} - ({sequence.status}):{" "} - {sequence.usages.map((usage) => usageLabels[usage]).join(", ")} -
  • - ))} - {references.signupForms.map((form) => ( -
  • - Signup form {form.name} -
  • - ))} -
-
- )} -
- ); -} diff --git a/packages/email-blocks/src/index.ts b/packages/email-blocks/src/index.ts index 556e4029..090fefcb 100644 --- a/packages/email-blocks/src/index.ts +++ b/packages/email-blocks/src/index.ts @@ -36,6 +36,20 @@ export type { export { SubscriberList } from "./subscriber-list"; export type { TagEditorProps } from "./tag-editor"; export { TagEditor } from "./tag-editor"; +export type { + TagManagerLinkProps, + TagManagerProps, + TagManagerSequence, +} from "./tag-manager"; +export { TagManager } from "./tag-manager"; +// Hosts implement TagManager's callbacks against their own API with these. +export type { + DeleteTagResult, + RenameTagResult, + TagReferences, + TagSequenceUsage, + TagSummary, +} from "./tag-manager-types"; export type { SystemTemplateSummary, TemplateChooserProps, diff --git a/packages/email-blocks/src/tag-manager-text.ts b/packages/email-blocks/src/tag-manager-text.ts new file mode 100644 index 00000000..42dba44e --- /dev/null +++ b/packages/email-blocks/src/tag-manager-text.ts @@ -0,0 +1,53 @@ +import type { + DeleteTagResult, + RenameTagResult, + TagReferences, +} from "./tag-manager-types"; + +/** "1 contact", "2,500 contacts". */ +export function plural(count: number, word: string, words = `${word}s`): string { + return `${count.toLocaleString("en-US")} ${count === 1 ? word : words}`; +} + +/** "2 contacts and 1 segment", leaving out the zeros. */ +export function listCounts(counts: [number, string, string?][]): string { + const parts = counts + .filter(([count]) => count > 0) + .map(([count, word, words]) => plural(count, word, words)); + if (parts.length <= 1) return parts[0] ?? "nothing"; + return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`; +} + +/** What a rename changed, for the notice under the list. */ +export function renameNotice(result: RenameTagResult): string { + return `Renamed ${result.previousTag} to ${result.tag} on ${listCounts([ + [result.contactsUpdated, "contact"], + [result.segmentsUpdated, "segment"], + [result.sequencesUpdated, "broadcast or sequence", "broadcasts or sequences"], + [result.signupFormsUpdated, "signup form"], + ])}.`; +} + +/** What a delete changed, for the notice under the list. */ +export function deleteNotice(result: DeleteTagResult): string { + return `Deleted ${result.tag} from ${plural(result.contactsUpdated, "contact")}.`; +} + +/** Segments, broadcasts, sequences and signup forms that still name the tag. */ +export function referenceCount(references: TagReferences): number { + return ( + references.segments.length + + references.sequences.length + + references.signupForms.length + ); +} + +export const TAG_USAGE_LABELS: Record< + TagReferences["sequences"][number]["usages"][number], + string +> = { + filter: "audience filter", + exclude_filter: "exclusion filter", + trigger: "trigger", + email_action: "email tag action", +}; diff --git a/packages/email-blocks/src/tag-manager-types.test.ts b/packages/email-blocks/src/tag-manager-types.test.ts new file mode 100644 index 00000000..bb1761ed --- /dev/null +++ b/packages/email-blocks/src/tag-manager-types.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "bun:test"; +import type * as Contract from "@sendlit/api-contract"; +import type * as Local from "./tag-manager-types"; + +/** Compiles only when A and B are mutually assignable. */ +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +const matches = (value: T) => value; + +describe("tag manager types", () => { + it("match the API contract", () => { + // `bun run typecheck` fails here if the published shapes drift. + expect(matches>(true)).toBe(true); + expect(matches>(true)).toBe(true); + expect(matches>(true)).toBe( + true, + ); + expect(matches>(true)).toBe( + true, + ); + }); +}); diff --git a/packages/email-blocks/src/tag-manager-types.ts b/packages/email-blocks/src/tag-manager-types.ts new file mode 100644 index 00000000..262fcfd1 --- /dev/null +++ b/packages/email-blocks/src/tag-manager-types.ts @@ -0,0 +1,45 @@ +/** + * The shapes of SendLit's `/tags` endpoints, declared here rather than + * imported from the private `@sendlit/api-contract` so hosts get complete + * types from the published package. `tag-manager-types.test.ts` checks they + * match the contract. + */ + +/** A tag in use, with how many contacts carry it. */ +export interface TagSummary { + tag: string; + contactCount: number; +} + +/** Where a broadcast or sequence uses a tag. */ +export type TagSequenceUsage = "filter" | "exclude_filter" | "trigger" | "email_action"; + +/** Everything outside contacts that names the tag. */ +export interface TagReferences { + tag: string; + contactCount: number; + segments: { segmentId: string; name: string }[]; + sequences: { + sequenceId: string; + title: string; + type: "broadcast" | "sequence"; + status: string; + usages: TagSequenceUsage[]; + }[]; + signupForms: { formId: string; name: string }[]; +} + +export interface RenameTagResult { + tag: string; + previousTag: string; + contactsUpdated: number; + segmentsUpdated: number; + sequencesUpdated: number; + signupFormsUpdated: number; +} + +export interface DeleteTagResult { + tag: string; + contactsUpdated: number; + references: TagReferences; +} diff --git a/packages/email-blocks/src/tag-manager.test.ts b/packages/email-blocks/src/tag-manager.test.ts new file mode 100644 index 00000000..d462a617 --- /dev/null +++ b/packages/email-blocks/src/tag-manager.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "bun:test"; +import { + deleteNotice, + listCounts, + plural, + referenceCount, + renameNotice, +} from "./tag-manager-text"; + +describe("tag manager text", () => { + it("pluralises counts", () => { + expect(plural(1, "contact")).toBe("1 contact"); + expect(plural(2500, "contact")).toBe("2,500 contacts"); + expect(plural(2, "broadcast or sequence", "broadcasts or sequences")).toBe( + "2 broadcasts or sequences", + ); + }); + + it("lists the non-zero counts", () => { + expect(listCounts([[0, "contact"]])).toBe("nothing"); + expect( + listCounts([ + [3, "contact"], + [0, "segment"], + ]), + ).toBe("3 contacts"); + expect( + listCounts([ + [3, "contact"], + [1, "segment"], + [2, "signup form"], + ]), + ).toBe("3 contacts, 1 segment and 2 signup forms"); + }); + + it("describes a rename and a delete", () => { + expect( + renameNotice({ + tag: "vip", + previousTag: "VIP", + contactsUpdated: 4, + segmentsUpdated: 1, + sequencesUpdated: 0, + signupFormsUpdated: 0, + }), + ).toBe("Renamed VIP to vip on 4 contacts and 1 segment."); + expect( + deleteNotice({ + tag: "old", + contactsUpdated: 1, + references: { + tag: "old", + contactCount: 0, + segments: [], + sequences: [], + signupForms: [], + }, + }), + ).toBe("Deleted old from 1 contact."); + }); + + it("counts places that still name the tag", () => { + expect( + referenceCount({ + tag: "old", + contactCount: 3, + segments: [{ segmentId: "s1", name: "Buyers" }], + sequences: [ + { + sequenceId: "q1", + title: "Welcome", + type: "sequence", + status: "active", + usages: ["trigger"], + }, + ], + signupForms: [], + }), + ).toBe(2); + }); +}); diff --git a/packages/email-blocks/src/tag-manager.tsx b/packages/email-blocks/src/tag-manager.tsx new file mode 100644 index 00000000..0a578fee --- /dev/null +++ b/packages/email-blocks/src/tag-manager.tsx @@ -0,0 +1,480 @@ +"use client"; + +import { Pencil, Trash2, TriangleAlert } from "lucide-react"; +import { type ComponentType, type ReactNode, useEffect, useState } from "react"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "./components/ui/alert-dialog"; +import { Badge } from "./components/ui/badge"; +import { Button } from "./components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./components/ui/dialog"; +import { Input } from "./components/ui/input"; +import { Label } from "./components/ui/label"; +import { Skeleton } from "./components/ui/skeleton"; +import { cn } from "./lib/utils"; +import { + deleteNotice, + plural, + referenceCount, + renameNotice, + TAG_USAGE_LABELS, +} from "./tag-manager-text"; +import type { + DeleteTagResult, + RenameTagResult, + TagReferences, + TagSummary, +} from "./tag-manager-types"; + +export type TagManagerSequence = TagReferences["sequences"][number]; + +export interface TagManagerLinkProps { + href: string; + className?: string; + children: ReactNode; +} + +export interface TagManagerProps { + /** Every tag the contacts carry, with how many carry it. */ + listTags: () => Promise; + /** Segments, broadcasts, sequences and signup forms that name the tag. */ + getTagReferences: (tag: string) => Promise; + /** Renames the tag everywhere it's used; renaming to an existing tag merges them. */ + renameTag: (tag: string, name: string) => Promise; + /** Removes the tag from every contact; references are left as they are. */ + deleteTag: (tag: string) => Promise; + /** Where a broadcast or sequence that uses the tag is edited. Without it, they aren't linked. */ + sequenceHref?: (sequence: TagManagerSequence) => string; + /** The host's link, e.g. Next.js `Link`; a plain anchor by default. */ + linkComponent?: ComponentType; + /** Shown while tags or references load; skeleton rows by default. */ + loadingIndicator?: ReactNode; + /** Turns a failed call into a message. Defaults to the error's message. */ + errorMessage?: (error: unknown, fallback: string) => string; + className?: string; +} + +function defaultErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback; +} + +function PlainLink({ href, className, children }: TagManagerLinkProps) { + return ( + + {children} + + ); +} + +/** + * Lists every tag on the team's contacts with how many carry it, and lets + * people rename a tag everywhere it's used or delete it from every contact. + * The SendLit dashboard and hosts such as CourseLit share it; each passes + * functions that call its own API. + */ +export function TagManager({ + listTags, + getTagReferences, + renameTag, + deleteTag, + sequenceHref, + linkComponent = PlainLink, + loadingIndicator, + errorMessage = defaultErrorMessage, + className, +}: TagManagerProps) { + const [tags, setTags] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + // The selected tag outlives `open`, so the dialogs keep their content + // while they animate closed. + const [renaming, setRenaming] = useState(null); + const [renameOpen, setRenameOpen] = useState(false); + const [deleting, setDeleting] = useState(null); + const [deleteOpen, setDeleteOpen] = useState(false); + + async function load() { + try { + setTags(await listTags()); + setError(null); + } catch (err) { + setError(errorMessage(err, "Failed to load tags")); + } + } + + // biome-ignore lint/correctness/useExhaustiveDependencies: load once on mount + useEffect(() => { + load(); + }, []); + + return ( +
+ {error && {error}} + {notice && ( +

+ {notice} +

+ )} + + { + setNotice(renameNotice(result)); + await load(); + }} + /> + { + setNotice(deleteNotice(result)); + await load(); + }} + /> + +
+ {tags === null ? ( + (loadingIndicator ?? ) + ) : tags.length === 0 ? ( +

+ No tags yet. Tags you add to contacts show up here. +

+ ) : ( + + + + + + + + + + {tags.map((tag) => ( + + + + + + ))} + +
TagContacts + Actions +
+ + {tag.tag} + + + {tag.contactCount.toLocaleString("en-US")} + + + +
+ )} +
+
+ ); +} + +function ErrorText({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function TagRowsSkeleton() { + return ( +
    + {[0, 1, 2].map((row) => ( +
  • + + +
  • + ))} +
+ ); +} + +function RenameTagDialog({ + tag, + open, + onOpenChange, + renameTag, + errorMessage, + onRenamed, +}: { + tag: TagSummary | null; + open: boolean; + onOpenChange: (open: boolean) => void; + renameTag: TagManagerProps["renameTag"]; + errorMessage: NonNullable; + onRenamed: (result: RenameTagResult) => Promise; +}) { + const [name, setName] = useState(""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open) return; + setName(tag?.tag ?? ""); + setError(null); + }, [tag, open]); + + async function submit() { + if (!tag) return; + setSaving(true); + setError(null); + try { + const result = await renameTag(tag.tag, name.trim()); + onOpenChange(false); + await onRenamed(result); + } catch (err) { + setError(errorMessage(err, "Failed to rename the tag")); + } finally { + setSaving(false); + } + } + + const unchanged = !name.trim() || name.trim() === tag?.tag; + return ( + + + + + Rename {tag?.tag} + + + The new name replaces it on every contact and in the segments, filters, + triggers, email actions and signup forms that use it. Renaming to a tag that + already exists merges the two. Sent broadcasts keep the old name. + + +
{ + event.preventDefault(); + if (!unchanged) submit(); + }} + > + {error && {error}} + + setName(event.target.value)} + /> +
+ + + + +
+
+ ); +} + +function DeleteTagDialog({ + tag, + open, + onOpenChange, + getTagReferences, + deleteTag, + sequenceHref, + linkComponent: Link, + loadingIndicator, + errorMessage, + onDeleted, +}: { + tag: TagSummary | null; + open: boolean; + onOpenChange: (open: boolean) => void; + getTagReferences: TagManagerProps["getTagReferences"]; + deleteTag: TagManagerProps["deleteTag"]; + sequenceHref?: TagManagerProps["sequenceHref"]; + linkComponent: ComponentType; + loadingIndicator?: ReactNode; + errorMessage: NonNullable; + onDeleted: (result: DeleteTagResult) => Promise; +}) { + const [references, setReferences] = useState(null); + const [error, setError] = useState(null); + const [deleting, setDeleting] = useState(false); + + // Hosts often pass inline functions; refetch only when the tag or open + // state changes, not whenever the host re-renders. + // biome-ignore lint/correctness/useExhaustiveDependencies: see above + useEffect(() => { + if (!open || !tag) return; + setReferences(null); + setError(null); + let cancelled = false; + getTagReferences(tag.tag) + .then((result) => { + if (!cancelled) setReferences(result); + }) + .catch((err) => { + if (!cancelled) setError(errorMessage(err, "Failed to check where it's used")); + }); + return () => { + cancelled = true; + }; + }, [tag, open]); + + async function confirm() { + if (!tag) return; + setDeleting(true); + setError(null); + try { + const result = await deleteTag(tag.tag); + onOpenChange(false); + await onDeleted(result); + } catch (err) { + setError(errorMessage(err, "Failed to delete the tag")); + } finally { + setDeleting(false); + } + } + + const count = references ? referenceCount(references) : 0; + return ( + { + if (!nextOpen && !deleting) onOpenChange(false); + }} + > + + + Delete the tag "{tag?.tag ?? ""}"? + + {references + ? `It will be removed from ${plural(references.contactCount, "contact")}. The contacts themselves are kept. This can't be undone.` + : "Checking which contacts and settings use it…"} + + + {error ? ( + {error} + ) : references === null ? ( + (loadingIndicator ?? ) + ) : count === 0 ? ( +

+ No segments, broadcasts, sequences or signup forms use this tag. +

+ ) : ( +
+

+ + Still used in {plural(count, "place")} +

+

+ These keep the tag name: a filter on a deleted tag matches nobody, and a + trigger, email action or signup form can add the tag again. Update them + first if that's not what you want. +

+
    + {references.segments.map((segment) => ( +
  • + Segment {segment.name} +
  • + ))} + {references.sequences.map((sequence) => { + const label = ( + <> + {sequence.type === "broadcast" ? "Broadcast" : "Sequence"}{" "} + {sequence.title || "Untitled"} + + ); + return ( +
  • + {sequenceHref ? ( + + {label} + + ) : ( + label + )}{" "} + ({sequence.status}):{" "} + {sequence.usages.map((usage) => TAG_USAGE_LABELS[usage]).join(", ")} +
  • + ); + })} + {references.signupForms.map((form) => ( +
  • + Signup form {form.name} +
  • + ))} +
+
+ )} + + Cancel + + +
+
+ ); +}