Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/sequence-analytics-actions.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 19 additions & 1 deletion apps/docs/content/docs/email-blocks/email-editor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

<EmailEditor
email={email}
Expand All @@ -234,5 +235,22 @@ const footer = createFooterBlock({ labels });
/>;
```

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";

<EmailEditor
blocks={[Text, Separator, Link, ImageBlock, footer]}
renderContext={marketingPreviewContext({
viewInBrowser: sequence.viewInBrowser,
})}
{...rest}
/>;
```

The independently hosted editor remains generic: omit this add-on and context
for ordinary or transactional designs.
43 changes: 43 additions & 0 deletions apps/docs/content/docs/email-blocks/sequence-analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,46 @@ function Example() {
return <SequenceAnalytics metrics={analyticsMetrics} />;
}
```

## 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";

<SequenceAnalytics metrics={sequenceStatsMetrics(stats, schedule?.waiting)} />;
```

## 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
<SequenceAnalytics
title="Performance"
metrics={sequenceStatsMetrics(stats)}
onRefresh={async () => 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";

<ViewInBrowserSetting
checked={sequence.viewInBrowser}
onCheckedChange={async (viewInBrowser) => {
setSequence(await updateSequence(sequence.sequenceId, { viewInBrowser }));
}}
/>;
```
8 changes: 6 additions & 2 deletions apps/web/app/(dashboard)/broadcasts/[sequenceId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type Sequence,
SequenceAnalytics,
type SequenceStats,
sequenceStatsMetrics,
} from "@sendlit/email-blocks";
import {
Archive,
Expand Down Expand Up @@ -84,7 +85,6 @@ import {
presentDeliverySourceError,
sendingOptionFor,
} from "@/lib/delivery-source";
import { sequenceStatsMetrics } from "@/lib/stats";
import { useSegments } from "@/lib/use-segments";

interface BroadcastMeta {
Expand Down Expand Up @@ -544,7 +544,11 @@ export default function BroadcastEditorPage({
)}

{stats && (
<SequenceAnalytics className="mb-6" metrics={sequenceStatsMetrics(stats)} />
<SequenceAnalytics
className="mb-6"
metrics={sequenceStatsMetrics(stats)}
onRefresh={async () => setStats(await getSequenceStats(sequenceId))}
/>
)}

<div className="space-y-6">
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/(dashboard)/sequences/[sequenceId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -74,7 +75,6 @@ import {
isDeletedDeliverySource,
presentDeliverySourceError,
} from "@/lib/delivery-source";
import { sequenceStatsMetrics } from "@/lib/stats";

interface SequenceMeta {
title: string;
Expand Down Expand Up @@ -406,6 +406,7 @@ export default function SequenceEditorPage({
<SequenceAnalytics
className="mb-6"
metrics={sequenceStatsMetrics(stats, schedule?.waiting)}
onRefresh={async () => setStats(await getSequenceStats(sequenceId))}
/>
)}

Expand Down
38 changes: 3 additions & 35 deletions apps/web/components/dashboard/email-editor-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = [
Expand All @@ -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,
Expand Down
54 changes: 11 additions & 43 deletions apps/web/components/dashboard/view-in-browser-setting.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,25 @@
"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,
}: {
sequence: Sequence;
onSaved: (sequence: Sequence) => void;
}) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="flex items-start gap-3">
<Switch
id="view-in-browser"
checked={sequence.viewInBrowser}
onCheckedChange={toggle}
disabled={saving}
/>
<div className="space-y-1">
<Label htmlFor="view-in-browser">View in browser</Label>
<p className="text-sm text-muted-foreground">
Links each email to a hosted copy of it. Turn off for emails that
shouldn&apos;t be on the web, such as onboarding or trial emails; copies
linked from emails already sent stop working too.
</p>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
</div>
<ViewInBrowserSwitch
checked={sequence.viewInBrowser}
onCheckedChange={async (viewInBrowser) => {
onSaved(await updateSequence(sequence.sequenceId, { viewInBrowser }));
}}
/>
);
}
34 changes: 33 additions & 1 deletion packages/email-blocks/src/footer/footer.test.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
);
});
});
45 changes: 43 additions & 2 deletions packages/email-blocks/src/footer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -28,8 +69,8 @@ export function createFooterEmailBlock(
}

export function createFooterBlock({
labels,
}: FooterBlockConfig): BlockComponent<FooterSettings, SendLitEmailRenderContext> {
labels = DEFAULT_FOOTER_LABELS,
}: FooterBlockConfig = {}): BlockComponent<FooterSettings, SendLitEmailRenderContext> {
return {
metadata: {
name: "footer",
Expand Down
3 changes: 2 additions & 1 deletion packages/email-blocks/src/footer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@ export interface FooterLabels {
}

export interface FooterBlockConfig {
labels: FooterLabels;
/** Defaults to `DEFAULT_FOOTER_LABELS` (English). */
labels?: FooterLabels;
}
Loading
Loading