From c5466731480dfe2e8e9fbf6e9dcd309ae68c39ff Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Thu, 12 Nov 2026 16:30:46 +0530 Subject: [PATCH] Share the dashboard's mail building blocks from email-blocks Hosts such as CourseLit were re-implementing pieces of the dashboard and drifting from it (a host's preview kept the "View in browser" link after the setting was turned off). Move them into @sendlit/email-blocks and use them from the dashboard too: - SequenceAnalytics takes onRefresh and shows a refresh button - sequenceStatsMetrics maps SequenceStats to analytics metrics - ViewInBrowserSetting is the shared "View in browser" switch - footer exports marketingPreviewContext and DEFAULT_FOOTER_LABELS; createFooterBlock() defaults to those labels --- .changeset/sequence-analytics-actions.md | 10 +++ .../docs/email-blocks/email-editor.mdx | 20 +++++- .../docs/email-blocks/sequence-analytics.mdx | 43 +++++++++++++ .../broadcasts/[sequenceId]/page.tsx | 8 ++- .../sequences/[sequenceId]/page.tsx | 3 +- .../dashboard/email-editor-screen.tsx | 38 +----------- .../dashboard/view-in-browser-setting.tsx | 54 ++++------------ .../email-blocks/src/footer/footer.test.tsx | 34 ++++++++++- packages/email-blocks/src/footer/index.tsx | 45 +++++++++++++- packages/email-blocks/src/footer/types.ts | 3 +- packages/email-blocks/src/index.ts | 3 + .../src/sequence-analytics.test.tsx | 32 ++++++++++ .../email-blocks/src/sequence-analytics.tsx | 46 +++++++++++++- .../email-blocks/src/sequence-stats.test.ts | 32 ++++++++++ .../email-blocks/src/sequence-stats.ts | 5 +- .../src/view-in-browser-setting.tsx | 61 +++++++++++++++++++ 16 files changed, 347 insertions(+), 90 deletions(-) create mode 100644 .changeset/sequence-analytics-actions.md create mode 100644 packages/email-blocks/src/sequence-analytics.test.tsx create mode 100644 packages/email-blocks/src/sequence-stats.test.ts rename apps/web/lib/stats.ts => packages/email-blocks/src/sequence-stats.ts (88%) create mode 100644 packages/email-blocks/src/view-in-browser-setting.tsx diff --git a/.changeset/sequence-analytics-actions.md b/.changeset/sequence-analytics-actions.md new file mode 100644 index 00000000..45a81949 --- /dev/null +++ b/.changeset/sequence-analytics-actions.md @@ -0,0 +1,10 @@ +--- +"@sendlit/email-blocks": minor +--- + +Share more of the dashboard's mail building blocks so hosts don't re-implement them: + +- `SequenceAnalytics` takes `onRefresh` and shows a refresh button in its header. +- `sequenceStatsMetrics` maps `SequenceStats` to `SequenceAnalytics` metrics. +- `ViewInBrowserSetting` is the "View in browser" switch for a broadcast or sequence. +- `@sendlit/email-blocks/footer` exports `marketingPreviewContext`, which leaves the "View in browser" link out of previews when the setting is off, and `DEFAULT_FOOTER_LABELS`; `createFooterBlock()` uses those labels when none are given. diff --git a/apps/docs/content/docs/email-blocks/email-editor.mdx b/apps/docs/content/docs/email-blocks/email-editor.mdx index 92acc948..7cdb8f83 100644 --- a/apps/docs/content/docs/email-blocks/email-editor.mdx +++ b/apps/docs/content/docs/email-blocks/email-editor.mdx @@ -219,7 +219,8 @@ final block and provide the real address and unsubscribe URL: import { EmailEditor } from "@sendlit/email-editor"; import { createFooterBlock } from "@sendlit/email-blocks/footer"; -const footer = createFooterBlock({ labels }); +// English labels by default; pass `{ labels }` to translate them. +const footer = createFooterBlock(); ; ``` +Before an email is sent there's no real address or link, so editors and +previews can use `marketingPreviewContext()` from the same entry point. Pass +the broadcast's or sequence's `viewInBrowser` setting so the preview shows the +"View in browser" link only when sent emails will have it: + +```tsx +import { marketingPreviewContext } from "@sendlit/email-blocks/footer"; + +; +``` + The independently hosted editor remains generic: omit this add-on and context for ordinary or transactional designs. diff --git a/apps/docs/content/docs/email-blocks/sequence-analytics.mdx b/apps/docs/content/docs/email-blocks/sequence-analytics.mdx index a97de1b8..624d24b7 100644 --- a/apps/docs/content/docs/email-blocks/sequence-analytics.mdx +++ b/apps/docs/content/docs/email-blocks/sequence-analytics.mdx @@ -50,3 +50,46 @@ function Example() { return ; } ``` + +## Building the metrics + +`sequenceStatsMetrics` turns a sequence's or broadcast's `SequenceStats` into +these metrics, with the same labels and help text SendLit uses. Pass the number +of contacts waiting for their next email to add a "Waiting" metric. + +```tsx +import { SequenceAnalytics, sequenceStatsMetrics } from "@sendlit/email-blocks"; + +; +``` + +## Refreshing + +Pass `onRefresh` to show a refresh button in the header. It spins until the +promise you return settles, so the numbers update without reloading the page. + +```tsx + setStats(await getSequenceStats(sequenceId))} +/>; +``` + +## View in browser + +`ViewInBrowserSetting` is the switch for a broadcast's or sequence's +`viewInBrowser` setting, with its explanation. It saves as soon as it's +flipped: give it an async `onCheckedChange` that saves the value, and a +rejection is shown under the switch. + +```tsx +import { ViewInBrowserSetting } from "@sendlit/email-blocks"; + + { + setSequence(await updateSequence(sequence.sequenceId, { viewInBrowser })); + }} +/>; +``` diff --git a/apps/web/app/(dashboard)/broadcasts/[sequenceId]/page.tsx b/apps/web/app/(dashboard)/broadcasts/[sequenceId]/page.tsx index 8b56c488..06e8cb59 100644 --- a/apps/web/app/(dashboard)/broadcasts/[sequenceId]/page.tsx +++ b/apps/web/app/(dashboard)/broadcasts/[sequenceId]/page.tsx @@ -8,6 +8,7 @@ import { type Sequence, SequenceAnalytics, type SequenceStats, + sequenceStatsMetrics, } from "@sendlit/email-blocks"; import { Archive, @@ -84,7 +85,6 @@ import { presentDeliverySourceError, sendingOptionFor, } from "@/lib/delivery-source"; -import { sequenceStatsMetrics } from "@/lib/stats"; import { useSegments } from "@/lib/use-segments"; interface BroadcastMeta { @@ -544,7 +544,11 @@ export default function BroadcastEditorPage({ )} {stats && ( - + setStats(await getSequenceStats(sequenceId))} + /> )}
diff --git a/apps/web/app/(dashboard)/sequences/[sequenceId]/page.tsx b/apps/web/app/(dashboard)/sequences/[sequenceId]/page.tsx index 158f042a..33d8aecb 100644 --- a/apps/web/app/(dashboard)/sequences/[sequenceId]/page.tsx +++ b/apps/web/app/(dashboard)/sequences/[sequenceId]/page.tsx @@ -12,6 +12,7 @@ import { type SequenceEmail, SequenceEmailList, type SequenceStats, + sequenceStatsMetrics, TagEditor, } from "@sendlit/email-blocks"; import { Check, Pause, Pencil, Play, Trash2 } from "lucide-react"; @@ -74,7 +75,6 @@ import { isDeletedDeliverySource, presentDeliverySourceError, } from "@/lib/delivery-source"; -import { sequenceStatsMetrics } from "@/lib/stats"; interface SequenceMeta { title: string; @@ -406,6 +406,7 @@ export default function SequenceEditorPage({ setStats(await getSequenceStats(sequenceId))} /> )} diff --git a/apps/web/components/dashboard/email-editor-screen.tsx b/apps/web/components/dashboard/email-editor-screen.tsx index 7a257ebc..abb23688 100644 --- a/apps/web/components/dashboard/email-editor-screen.tsx +++ b/apps/web/components/dashboard/email-editor-screen.tsx @@ -5,6 +5,7 @@ import type { TemplatePurpose } from "@sendlit/api-contract"; import { type Email, EmailEditor } from "@sendlit/email-blocks"; import { createFooterBlock, + marketingPreviewContext, type SendLitEmailRenderContext, } from "@sendlit/email-blocks/footer"; import { normalizeEmail } from "@sendlit/email-editor"; @@ -105,24 +106,7 @@ const BASE_EMAIL_EDITOR_BLOCKS = [ HtmlSnippet, ]; -const SENDLIT_FOOTER_BLOCK = createFooterBlock({ - labels: { - displayName: "Footer", - description: "Required mailing address and unsubscribe footer", - unsubscribe: "Unsubscribe", - viewInBrowser: "View in browser", - alignment: "Alignment", - alignmentLeft: "Left", - alignmentCenter: "Center", - alignmentRight: "Right", - foregroundColor: "Text color", - backgroundColor: "Background color", - fontSize: "Font size", - paddingTop: "Top padding", - paddingBottom: "Bottom padding", - paddingX: "Horizontal padding", - }, -}); +const SENDLIT_FOOTER_BLOCK = createFooterBlock(); // Conditional sections read the subscriber's tags, which only marketing sends have. export const MARKETING_EMAIL_EDITOR_BLOCKS = [ @@ -132,23 +116,7 @@ export const MARKETING_EMAIL_EDITOR_BLOCKS = [ ]; export const TRANSACTIONAL_EMAIL_EDITOR_BLOCKS = BASE_EMAIL_EDITOR_BLOCKS; -/** What a marketing email's managed footer shows in previews. The "View in - * browser" link is left out when the sequence has it turned off. */ -export function marketingPreviewContext({ - mailingAddress = "Your workspace mailing address", - viewInBrowser = true, -}: { - mailingAddress?: string; - viewInBrowser?: boolean; -} = {}): SendLitEmailRenderContext { - return { - footer: { - mailingAddress, - unsubscribeUrl: "#unsubscribe-preview", - ...(viewInBrowser && { viewInBrowserUrl: "#view-in-browser-preview" }), - }, - }; -} +export { marketingPreviewContext }; /** * Full-screen email editing screen — the only place `EmailEditor` is rendered, diff --git a/apps/web/components/dashboard/view-in-browser-setting.tsx b/apps/web/components/dashboard/view-in-browser-setting.tsx index 1b4fc3a0..d9506a0e 100644 --- a/apps/web/components/dashboard/view-in-browser-setting.tsx +++ b/apps/web/components/dashboard/view-in-browser-setting.tsx @@ -1,18 +1,12 @@ "use client"; -import type { Sequence } from "@sendlit/email-blocks"; -import { useState } from "react"; -import { Label } from "@/components/ui/codelit/label"; -import { Switch } from "@/components/ui/codelit/switch"; +import { + type Sequence, + ViewInBrowserSetting as ViewInBrowserSwitch, +} from "@sendlit/email-blocks"; import { updateSequence } from "@/lib/api"; -import { ApiError } from "@/lib/api-client"; -/** - * The broadcast's or sequence's "View in browser" switch. It saves as soon - * as it's flipped, separately from the rest of the form, because it stays - * changeable after sending: turning it off takes down the web versions - * behind links already sent. - */ +/** Saves the shared "View in browser" switch through the dashboard API. */ export function ViewInBrowserSetting({ sequence, onSaved, @@ -20,38 +14,12 @@ export function ViewInBrowserSetting({ sequence: Sequence; onSaved: (sequence: Sequence) => void; }) { - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - async function toggle(viewInBrowser: boolean) { - setSaving(true); - setError(null); - try { - onSaved(await updateSequence(sequence.sequenceId, { viewInBrowser })); - } catch (err) { - setError(err instanceof ApiError ? err.message : "Failed to save"); - } finally { - setSaving(false); - } - } - return ( -
- -
- -

- Links each email to a hosted copy of it. Turn off for emails that - shouldn't be on the web, such as onboarding or trial emails; copies - linked from emails already sent stop working too. -

- {error &&

{error}

} -
-
+ { + onSaved(await updateSequence(sequence.sequenceId, { viewInBrowser })); + }} + /> ); } diff --git a/packages/email-blocks/src/footer/footer.test.tsx b/packages/email-blocks/src/footer/footer.test.tsx index 0e9c9bbf..5d79c2a3 100644 --- a/packages/email-blocks/src/footer/footer.test.tsx +++ b/packages/email-blocks/src/footer/footer.test.tsx @@ -1,6 +1,11 @@ import { describe, expect, it } from "bun:test"; import { defaultEmail, type Email, renderEmailToHtml } from "@sendlit/email-editor"; -import { createFooterBlock, createFooterEmailBlock } from "."; +import { + createFooterBlock, + createFooterEmailBlock, + DEFAULT_FOOTER_LABELS, + marketingPreviewContext, +} from "."; function labelsFor(viewInBrowser?: string) { return { @@ -173,3 +178,30 @@ describe("SendLit footer block", () => { }); }); }); + +describe("marketingPreviewContext", () => { + it("shows placeholder footer values with the view-in-browser link", () => { + expect(marketingPreviewContext().footer).toEqual({ + mailingAddress: "Your mailing address", + unsubscribeUrl: "#unsubscribe-preview", + viewInBrowserUrl: "#view-in-browser-preview", + }); + }); + + it("leaves the link out when view in browser is off", () => { + const footer = marketingPreviewContext({ + mailingAddress: "1 Main St", + viewInBrowser: false, + }).footer; + expect(footer?.viewInBrowserUrl).toBeUndefined(); + expect(footer?.mailingAddress).toBe("1 Main St"); + }); +}); + +describe("createFooterBlock defaults", () => { + it("uses the English labels when none are given", () => { + expect(createFooterBlock().metadata.displayName).toBe( + DEFAULT_FOOTER_LABELS.displayName, + ); + }); +}); diff --git a/packages/email-blocks/src/footer/index.tsx b/packages/email-blocks/src/footer/index.tsx index daa5103a..c06a441a 100644 --- a/packages/email-blocks/src/footer/index.tsx +++ b/packages/email-blocks/src/footer/index.tsx @@ -4,10 +4,51 @@ import { FooterBlock } from "./block"; import { FooterSettingsPanel } from "./settings"; import type { FooterBlockConfig, + FooterLabels, FooterSettings, SendLitEmailRenderContext, } from "./types"; +/** English labels for the footer block and its settings panel. */ +export const DEFAULT_FOOTER_LABELS: FooterLabels = { + displayName: "Footer", + description: "Required mailing address and unsubscribe footer", + unsubscribe: "Unsubscribe", + viewInBrowser: "View in browser", + alignment: "Alignment", + alignmentLeft: "Left", + alignmentCenter: "Center", + alignmentRight: "Right", + foregroundColor: "Text color", + backgroundColor: "Background color", + fontSize: "Font size", + paddingTop: "Top padding", + paddingBottom: "Bottom padding", + paddingX: "Horizontal padding", +}; + +/** + * What a marketing email's managed footer shows in editors and previews, + * where there's no real address or links yet. Pass the broadcast's or + * sequence's `viewInBrowser` setting: off leaves the link out, as it is + * from the emails that get sent. + */ +export function marketingPreviewContext({ + mailingAddress = "Your mailing address", + viewInBrowser = true, +}: { + mailingAddress?: string; + viewInBrowser?: boolean; +} = {}): SendLitEmailRenderContext { + return { + footer: { + mailingAddress, + unsubscribeUrl: "#unsubscribe-preview", + ...(viewInBrowser && { viewInBrowserUrl: "#view-in-browser-preview" }), + }, + }; +} + export const DEFAULT_FOOTER_SETTINGS: FooterSettings = { alignment: "center", fontSize: "12px", @@ -28,8 +69,8 @@ export function createFooterEmailBlock( } export function createFooterBlock({ - labels, -}: FooterBlockConfig): BlockComponent { + labels = DEFAULT_FOOTER_LABELS, +}: FooterBlockConfig = {}): BlockComponent { return { metadata: { name: "footer", diff --git a/packages/email-blocks/src/footer/types.ts b/packages/email-blocks/src/footer/types.ts index 930c8207..12933536 100644 --- a/packages/email-blocks/src/footer/types.ts +++ b/packages/email-blocks/src/footer/types.ts @@ -39,5 +39,6 @@ export interface FooterLabels { } export interface FooterBlockConfig { - labels: FooterLabels; + /** Defaults to `DEFAULT_FOOTER_LABELS` (English). */ + labels?: FooterLabels; } diff --git a/packages/email-blocks/src/index.ts b/packages/email-blocks/src/index.ts index d3b63410..556e4029 100644 --- a/packages/email-blocks/src/index.ts +++ b/packages/email-blocks/src/index.ts @@ -28,6 +28,7 @@ export type { export { SequenceAnalytics } from "./sequence-analytics"; export type { SequenceEmailListProps } from "./sequence-email-list"; export { SequenceEmailList } from "./sequence-email-list"; +export { sequenceStatsMetrics } from "./sequence-stats"; export type { SubscriberListItem, SubscriberListProps, @@ -52,3 +53,5 @@ export { defaultTriggerPickerLabels, TriggerPicker } from "./trigger-picker"; export * from "./types"; export type { ValueComboboxProps } from "./value-combobox"; export { ValueCombobox } from "./value-combobox"; +export type { ViewInBrowserSettingProps } from "./view-in-browser-setting"; +export { ViewInBrowserSetting } from "./view-in-browser-setting"; diff --git a/packages/email-blocks/src/sequence-analytics.test.tsx b/packages/email-blocks/src/sequence-analytics.test.tsx new file mode 100644 index 00000000..46709896 --- /dev/null +++ b/packages/email-blocks/src/sequence-analytics.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test"; +import { isValidElement, type ReactNode } from "react"; +import { RefreshButton, SequenceAnalytics } from "./sequence-analytics"; + +/** Every element in a rendered tree, depth first. */ +function elementsIn( + node: ReactNode, +): Array<{ type: unknown; props: Record }> { + if (Array.isArray(node)) return node.flatMap(elementsIn); + if (!isValidElement(node)) return []; + const props = node.props as Record; + return [{ type: node.type, props }, ...elementsIn(props.children as ReactNode)]; +} + +describe("SequenceAnalytics", () => { + it("shows a refresh button wired to onRefresh when one is given", () => { + const onRefresh = async () => {}; + const elements = elementsIn( + SequenceAnalytics({ metrics: [], title: "Performance", onRefresh }), + ); + const refresh = elements.find((element) => element.type === RefreshButton); + expect(refresh?.props.onRefresh).toBe(onRefresh); + }); + + it("has no refresh button without onRefresh", () => { + const elements = elementsIn( + SequenceAnalytics({ metrics: [], title: "Performance" }), + ); + expect(elements.some((element) => element.type === "h3")).toBe(true); + expect(elements.some((element) => element.type === RefreshButton)).toBe(false); + }); +}); diff --git a/packages/email-blocks/src/sequence-analytics.tsx b/packages/email-blocks/src/sequence-analytics.tsx index ab986272..d33b8cd2 100644 --- a/packages/email-blocks/src/sequence-analytics.tsx +++ b/packages/email-blocks/src/sequence-analytics.tsx @@ -1,6 +1,8 @@ "use client"; -import { HelpCircle } from "lucide-react"; +import { HelpCircle, RefreshCw } from "lucide-react"; +import { useState } from "react"; +import { Button } from "./components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "./components/ui/tooltip"; import { cn } from "./lib/utils"; @@ -13,6 +15,9 @@ export interface SequenceAnalyticsMetric { export interface SequenceAnalyticsProps { metrics: SequenceAnalyticsMetric[]; title?: string; + /** Reloads the stats. When set, the header shows a refresh button that + * spins until the returned promise settles. */ + onRefresh?: () => Promise | void; className?: string; } @@ -20,11 +25,15 @@ export interface SequenceAnalyticsProps { export function SequenceAnalytics({ metrics, title = "Email Performance", + onRefresh, className, }: SequenceAnalyticsProps) { return (
-

{title}

+
+

{title}

+ {onRefresh ? : null} +
{metrics.map((metric, index) => (
); } + +/** Reloads the numbers without reloading the page. */ +export function RefreshButton({ + onRefresh, +}: { + onRefresh: () => Promise | void; +}) { + const [refreshing, setRefreshing] = useState(false); + + async function refresh() { + setRefreshing(true); + try { + await onRefresh(); + } finally { + setRefreshing(false); + } + } + + return ( + + ); +} diff --git a/packages/email-blocks/src/sequence-stats.test.ts b/packages/email-blocks/src/sequence-stats.test.ts new file mode 100644 index 00000000..ce9bf494 --- /dev/null +++ b/packages/email-blocks/src/sequence-stats.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test"; +import { sequenceStatsMetrics } from "./sequence-stats"; + +const stats = { + sent: 10, + openRate: 0.5, + clickThroughRate: 0.123, + subscribersCount: 12, +}; + +describe("sequenceStatsMetrics", () => { + it("formats counts and rates", () => { + expect( + sequenceStatsMetrics(stats).map(({ label, value }) => [label, value]), + ).toEqual([ + ["Sent", 10], + ["Recipients", 12], + ["Open rate", "50%"], + ["Click rate", "12%"], + ]); + }); + + it("adds waiting contacts and deliverability rates when known", () => { + const labels = sequenceStatsMetrics( + { ...stats, bounceRate: 0.015, complaintRate: 0.0004 }, + 3, + ).map(({ label, value }) => [label, value]); + expect(labels).toContainEqual(["Waiting", 3]); + expect(labels).toContainEqual(["Bounce rate", "1.5%"]); + expect(labels).toContainEqual(["Spam complaints", "0.04%"]); + }); +}); diff --git a/apps/web/lib/stats.ts b/packages/email-blocks/src/sequence-stats.ts similarity index 88% rename from apps/web/lib/stats.ts rename to packages/email-blocks/src/sequence-stats.ts index 3e7bc072..6cb9c851 100644 --- a/apps/web/lib/stats.ts +++ b/packages/email-blocks/src/sequence-stats.ts @@ -1,6 +1,7 @@ -import type { SequenceAnalyticsMetric, SequenceStats } from "@sendlit/email-blocks"; +import type { SequenceAnalyticsMetric } from "./sequence-analytics"; +import type { SequenceStats } from "./types"; -/** Maps sequence/broadcast stats to `SequenceAnalytics` metrics. Pass +/** Maps sequence or broadcast stats to `SequenceAnalytics` metrics. Pass * `waiting` (from the schedule) to show contacts still waiting for an email. */ export function sequenceStatsMetrics( stats: SequenceStats, diff --git a/packages/email-blocks/src/view-in-browser-setting.tsx b/packages/email-blocks/src/view-in-browser-setting.tsx new file mode 100644 index 00000000..a4bcee28 --- /dev/null +++ b/packages/email-blocks/src/view-in-browser-setting.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useId, useState } from "react"; +import { Label } from "./components/ui/label"; +import { Switch } from "./components/ui/switch"; + +export interface ViewInBrowserSettingProps { + /** The broadcast's or sequence's `viewInBrowser` setting. */ + checked: boolean; + /** Saves the new value; a rejection is shown under the switch. */ + onCheckedChange: (viewInBrowser: boolean) => Promise; + disabled?: boolean; +} + +/** + * The broadcast's or sequence's "View in browser" switch. It saves as soon + * as it's flipped, separately from the rest of the form, because it stays + * changeable after sending: turning it off takes down the web versions + * behind links already sent. + */ +export function ViewInBrowserSetting({ + checked, + onCheckedChange, + disabled, +}: ViewInBrowserSettingProps) { + const id = useId(); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function toggle(viewInBrowser: boolean) { + setSaving(true); + setError(null); + try { + await onCheckedChange(viewInBrowser); + } catch (err) { + setError(err instanceof Error && err.message ? err.message : "Failed to save"); + } finally { + setSaving(false); + } + } + + return ( +
+ +
+ +

+ Links each email to a hosted copy of it. Turn off for emails that + shouldn't be on the web, such as onboarding or trial emails; copies + linked from emails already sent stop working too. +

+ {error &&

{error}

} +
+
+ ); +}