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.
+