diff --git a/lib/emails/__tests__/buildWelcomeEmail.test.ts b/lib/emails/__tests__/buildWelcomeEmail.test.ts
index ef4cdc9f..e6229298 100644
--- a/lib/emails/__tests__/buildWelcomeEmail.test.ts
+++ b/lib/emails/__tests__/buildWelcomeEmail.test.ts
@@ -42,15 +42,16 @@ describe("buildWelcomeEmail", () => {
expect(html).not.toMatch(/[–—]/);
});
- it("walks the five onboarding steps in order", () => {
+ it("walks the app's four derived checkpoints in order", () => {
const { html } = buildWelcomeEmail();
+ // Four, not five: the app derives exactly these checkpoints, and the
+ // baseline valuation is the payoff rather than a numbered step (chat#1889).
const order = [
"1. Confirm your artists",
"2. Verify their socials",
"3. Claim your catalog",
- "4. See your baseline valuation",
- "5. Automate with tasks",
+ "4. Automate with tasks",
];
let cursor = -1;
for (const label of order) {
@@ -75,6 +76,16 @@ describe("buildWelcomeEmail", () => {
}
});
+ it("renders inside the shared house layout (consistency pass)", () => {
+ const { html } = buildWelcomeEmail();
+
+ // Shared wrapper markers: the house footer tagline + shadow-as-border card.
+ expect(html).toContain("the AI agent platform for the music industry");
+ expect(html).toContain("box-shadow: 0px 0px 0px 1px #e8e8e8");
+ // The email no longer ships its own outer page/card chrome.
+ expect(html).not.toContain("background:#f7f7f7;padding:24px 0");
+ });
+
it("uses only durable image hosts (no expiring social CDNs)", () => {
const { html } = buildWelcomeEmail();
diff --git a/lib/emails/__tests__/processAndSendEmail.test.ts b/lib/emails/__tests__/processAndSendEmail.test.ts
index f4940b13..774998a3 100644
--- a/lib/emails/__tests__/processAndSendEmail.test.ts
+++ b/lib/emails/__tests__/processAndSendEmail.test.ts
@@ -94,6 +94,24 @@ describe("processAndSendEmail", () => {
);
});
+ it("wraps the email in the shared house-style layout (weekly-report consistency pass)", async () => {
+ mockSendEmailWithResend.mockResolvedValue({ id: "email-layout" });
+
+ await processAndSendEmail({
+ to: ["user@example.com"],
+ subject: "Weekly report",
+ html: "
This week
",
+ });
+
+ const sent = mockSendEmailWithResend.mock.calls[0][0] as { html: string };
+ // Body preserved…
+ expect(sent.html).toContain("This week
");
+ // …inside the shared layout: Recoup wordmark header + shadow-as-border card.
+ expect(sent.html).toContain("Recoup");
+ expect(sent.html).toContain("box-shadow");
+ expect(sent.html).toContain("Plus Jakarta Sans");
+ });
+
it("returns error when Resend fails", async () => {
const errorResponse = NextResponse.json(
{ error: { message: "Rate limited" } },
diff --git a/lib/emails/__tests__/renderEmailLayout.test.ts b/lib/emails/__tests__/renderEmailLayout.test.ts
new file mode 100644
index 00000000..9ae510b7
--- /dev/null
+++ b/lib/emails/__tests__/renderEmailLayout.test.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect } from "vitest";
+import { renderEmailLayout } from "@/lib/emails/renderEmailLayout";
+
+describe("renderEmailLayout", () => {
+ it("wraps the body HTML inside the layout", () => {
+ const html = renderEmailLayout({ bodyHtml: "Hello world
" });
+ expect(html).toContain("Hello world
");
+ });
+
+ it("includes the footer HTML when provided", () => {
+ const html = renderEmailLayout({
+ bodyHtml: "Body
",
+ footerHtml: "Footer bits
",
+ });
+ expect(html).toContain("Footer bits
");
+ });
+
+ it("omits the footer region when no footer is provided", () => {
+ const html = renderEmailLayout({ bodyHtml: "Body
" });
+ expect(html).not.toContain("Footer bits");
+ });
+
+ it("renders a CTA button with the given label and url when provided", () => {
+ const html = renderEmailLayout({
+ bodyHtml: "Body
",
+ cta: { label: "Open Recoup", url: "https://chat.recoupable.dev" },
+ });
+ expect(html).toContain("Open Recoup");
+ expect(html).toContain('href="https://chat.recoupable.dev"');
+ });
+
+ it("does not render a CTA when none is provided", () => {
+ const html = renderEmailLayout({ bodyHtml: "Body
" });
+ expect(html).not.toContain(" {
+ const html = renderEmailLayout({ bodyHtml: "Body
" });
+ // Header wordmark.
+ expect(html).toContain("Recoup");
+ // Achromatic near-black brand ink (DESIGN.md --foreground).
+ expect(html).toContain("#0a0a0a");
+ // Shadow-as-border card outline (DESIGN.md), not a CSS `border` on the card.
+ expect(html).toContain("box-shadow");
+ // Brand font stack (Plus Jakarta Sans for UI, per DESIGN.md).
+ expect(html).toContain("Plus Jakarta Sans");
+ // Constrained, centered container.
+ expect(html).toContain("max-width");
+ });
+
+ it("never breaks a style attribute with quotes in the font stack", () => {
+ const html = renderEmailLayout({ bodyHtml: "Body
" });
+
+ // Regression: double-quoted font names inside a double-quoted style="…"
+ // attribute terminate it early, silently dropping the font (clients fall
+ // back to serif) and every declaration after it. Font names must be
+ // single-quoted so the attribute stays intact.
+ expect(html).not.toContain('"Plus Jakarta Sans"');
+ expect(html).toContain("'Plus Jakarta Sans'");
+ // No style attribute may contain a stray double quote before its close.
+ for (const style of html.match(/style="[^"]*"/g) ?? []) {
+ expect(style).not.toContain('font-family:"');
+ }
+ });
+
+ it("returns a single HTML string", () => {
+ expect(typeof renderEmailLayout({ bodyHtml: "x
" })).toBe("string");
+ });
+});
diff --git a/lib/emails/buildWelcomeEmail.ts b/lib/emails/buildWelcomeEmail.ts
index 642295ca..4ef52c1d 100644
--- a/lib/emails/buildWelcomeEmail.ts
+++ b/lib/emails/buildWelcomeEmail.ts
@@ -1,19 +1,22 @@
-import { CHAT_APP_URL, RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const";
+import { CHAT_APP_URL } from "@/lib/const";
import { getEmailFooter } from "@/lib/emails/getEmailFooter";
+import { renderEmailLayout } from "@/lib/emails/renderEmailLayout";
+import { renderValuationPayoff } from "@/lib/emails/welcome/renderValuationPayoff";
import { renderWelcomeSteps } from "@/lib/emails/welcome/renderWelcomeSteps";
-const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif";
-
/**
* Builds the welcome email sent to every new account. Instead of a generic
- * greeting, it walks the signup through Recoup's onboarding flow in five steps
- * (mirroring chat's onboarding sequence: confirm artists, verify socials, claim
- * catalog, see baseline valuation, automate with tasks), illustrated with art
- * from the house cast of artists (PFPs + album covers, stable Spotify CDN URLs).
+ * greeting, it walks the signup through Recoup's onboarding flow in four steps
+ * (mirroring chat's four derived checkpoints: confirm artists, verify socials,
+ * claim catalog, automate with tasks), illustrated with art from the house cast
+ * of artists (PFPs + album covers, stable Spotify CDN URLs), then presents the
+ * baseline valuation as the payoff those steps unlock (chat#1889) rather than as
+ * a fifth chore the product can't count.
*
- * House style follows DESIGN.md / the valuation email: achromatic chrome
- * (#0a0a0a on #ffffff, #e8e8e8 borders, #6b6b6b muted), tables + inline styles
- * only, system font stack. Copy avoids em/en dashes.
+ * Chrome comes from the shared `renderEmailLayout` wrapper (consistency pass,
+ * chat#1885) — the same header / card / CTA / footer the valuation and
+ * weekly-report emails use — so this builder only owns its body content, CTA
+ * target, and footer. Copy avoids em/en dashes.
*
* Deep links use the fixed `CHAT_APP_URL` (never a derived deployment URL, same
* as the valuation email) so `/setup/*` always resolves to the real chat app,
@@ -24,22 +27,17 @@ const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif";
export function buildWelcomeEmail(): { subject: string; html: string } {
const footer = getEmailFooter();
- const html = `
-
-
-
-|
- Welcome to Recoup
-You're in. Let's build your catalog value.
- |
- |
-
-Recoup is your AI team for the music business. Here is the five step path from a new account to a catalog you measure, grow, and automate.
+ const bodyHtml = `Welcome to Recoup
+You're in. Let's build your catalog value.
+Recoup is your AI team for the music business. Here is the four step path from a new account to a catalog you measure, grow, and automate.
${renderWelcomeSteps(CHAT_APP_URL)}
-
-${footer}
- |
- |
`;
+${renderValuationPayoff(CHAT_APP_URL)}`;
+
+ const html = renderEmailLayout({
+ bodyHtml,
+ cta: { label: "Confirm your roster →", url: `${CHAT_APP_URL}/setup` },
+ footerHtml: footer,
+ });
return { subject: "Welcome to Recoup", html };
}
diff --git a/lib/emails/processAndSendEmail.ts b/lib/emails/processAndSendEmail.ts
index 16402cd5..f3e4a445 100644
--- a/lib/emails/processAndSendEmail.ts
+++ b/lib/emails/processAndSendEmail.ts
@@ -1,5 +1,6 @@
import { sendEmailWithResend } from "@/lib/emails/sendEmail";
import { getEmailFooter } from "@/lib/emails/getEmailFooter";
+import { renderEmailLayout } from "@/lib/emails/renderEmailLayout";
import { selectRoomWithArtist } from "@/lib/supabase/rooms/selectRoomWithArtist";
import { RECOUP_FROM_EMAIL } from "@/lib/const";
import { NextResponse } from "next/server";
@@ -43,14 +44,19 @@ export async function processAndSendEmail(
const roomData = room_id ? await selectRoomWithArtist(room_id) : null;
const footer = getEmailFooter(room_id, roomData?.artist_name || undefined);
const bodyHtml = html || (text ? await marked(text) : "");
- const htmlWithFooter = `${bodyHtml}\n\n${footer}`;
+ // Wrap in the shared house-style layout so every outbound email — including
+ // the live weekly-report send that flows through here — shares one visual
+ // language with the welcome/valuation emails (recoupable/chat#1885
+ // consistency pass): Recoup wordmark header, achromatic shadow-as-border
+ // card, DESIGN.md font stack, and the existing footer as the layout footer.
+ const htmlWithLayout = renderEmailLayout({ bodyHtml, footerHtml: footer });
const result = await sendEmailWithResend({
from: RECOUP_FROM_EMAIL,
to,
cc: cc.length > 0 ? cc : undefined,
subject,
- html: htmlWithFooter,
+ html: htmlWithLayout,
headers,
});
diff --git a/lib/emails/renderEmailLayout.ts b/lib/emails/renderEmailLayout.ts
new file mode 100644
index 00000000..4d911a12
--- /dev/null
+++ b/lib/emails/renderEmailLayout.ts
@@ -0,0 +1,88 @@
+export type EmailLayoutCta = {
+ /** Button label. */
+ label: string;
+ /** Absolute URL the button links to. */
+ url: string;
+};
+
+export type RenderEmailLayoutParams = {
+ /** The email's main content, already rendered to HTML. */
+ bodyHtml: string;
+ /** Optional footer HTML (reply note, chat link, artist attribution). */
+ footerHtml?: string;
+ /** Optional primary call-to-action rendered as a button below the body. */
+ cta?: EmailLayoutCta;
+};
+
+// Brand tokens (DESIGN.md §3) — kept literal because email clients can't read
+// CSS custom properties. Achromatic chrome; color comes from content.
+const INK = "#0a0a0a"; // --foreground
+const MUTED_INK = "#6b6b6b"; // --muted-foreground
+const PAGE_BG = "#f7f7f7"; // --muted (page canvas)
+const CARD_BG = "#ffffff"; // --card
+const BORDER = "#e8e8e8"; // --border
+const CTA_BG = "#0a0a0a"; // --primary
+const CTA_FG = "#ffffff"; // --primary-foreground
+
+// UI font stack — Plus Jakarta Sans (DESIGN.md four-font system) with system
+// fallbacks for clients that can't load a webfont.
+//
+// Font names are SINGLE-quoted on purpose: this stack is interpolated into
+// double-quoted `style="…"` attributes, so double quotes here would terminate
+// the attribute early — silently dropping the font (clients fall back to
+// serif) and every declaration after it.
+const FONT_STACK =
+ "'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
+
+// Shadow-as-border (DESIGN.md) — a hairline outline + subtle elevation via
+// box-shadow instead of a CSS `border` on the card.
+const CARD_SHADOW = `box-shadow: 0px 0px 0px 1px ${BORDER}, 0px 2px 4px rgba(0,0,0,0.04);`;
+
+/**
+ * Shared house-style wrapper for all onboarding emails (welcome, valuation,
+ * weekly report — recoupable/chat#1885 consistency pass). Wraps a rendered
+ * body in one header + footer + CTA structure so every automated email reads
+ * as one family: achromatic chrome, shadow-as-border card, the Recoup wordmark
+ * header, and the DESIGN.md font stack.
+ *
+ * Email-client-safe: all styles inline, a centered fixed-max-width container,
+ * literal hex tokens (no CSS variables), and webfonts degrade to system fonts.
+ */
+export function renderEmailLayout({ bodyHtml, footerHtml, cta }: RenderEmailLayoutParams): string {
+ const header = `
+
+ Recoup
+
`.trim();
+
+ const ctaBlock = cta
+ ? `
+`.trim()
+ : "";
+
+ const footerBlock = footerHtml
+ ? `
+
+ ${footerHtml}
+
`.trim()
+ : "";
+
+ return `
+
+
+ ${header}
+
+ ${bodyHtml}
+
+ ${ctaBlock}
+ ${footerBlock}
+
+
+ Recoup, the AI agent platform for the music industry
+
+
`.trim();
+}
diff --git a/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts b/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts
index bcde9497..38fa59c9 100644
--- a/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts
+++ b/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts
@@ -106,4 +106,14 @@ describe("renderValuationReportHtml", () => {
expect(subject).not.toMatch(/[–—]/);
expect(html).not.toMatch(/[–—]/);
});
+
+ it("renders inside the shared house layout (consistency pass)", () => {
+ const { html } = renderValuationReportHtml(baseParams);
+
+ // Shared wrapper markers: the house footer tagline + shadow-as-border card.
+ expect(html).toContain("the AI agent platform for the music industry");
+ expect(html).toContain("box-shadow: 0px 0px 0px 1px #e8e8e8");
+ // The email no longer ships its own outer page/card chrome.
+ expect(html).not.toContain("background:#f7f7f7;padding:24px 0");
+ });
});
diff --git a/lib/emails/valuationReport/renderValuationReportHtml.ts b/lib/emails/valuationReport/renderValuationReportHtml.ts
index 3325f7e0..60cd5188 100644
--- a/lib/emails/valuationReport/renderValuationReportHtml.ts
+++ b/lib/emails/valuationReport/renderValuationReportHtml.ts
@@ -1,5 +1,5 @@
-import { RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const";
import { escapeHtml } from "@/lib/emails/escapeHtml";
+import { renderEmailLayout } from "@/lib/emails/renderEmailLayout";
import { renderArtistHeader } from "@/lib/emails/valuationReport/renderArtistHeader";
import { renderValuationBlock } from "@/lib/emails/valuationReport/renderValuationBlock";
import { renderStatRow } from "@/lib/emails/valuationReport/renderStatRow";
@@ -7,17 +7,19 @@ import { renderReleasesTable } from "@/lib/emails/valuationReport/renderReleases
import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/valuationReportTypes";
const SUBJECT = "Your catalog valuation is ready";
-const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif";
/**
* Deterministic house-style renderer for the valuation-report email
* (recoupable/chat#1867, enriched per chat#1881): reproduces the marketing /
* chat catalog-report result so the email reinforces the same numbers a signup
* already saw — artist header, estimated value band, measured-scope stats, and
- * a per-release table with album art + proportional-share value. Styling
- * follows DESIGN.md: achromatic chrome (#0a0a0a on #ffffff, #e8e8e8 borders,
- * #6b6b6b muted), tables + inline styles only, system font stack, fixed
- * CHAT_APP_URL-based deep link (never a derived deployment URL). Copy avoids
+ * a per-release table with album art + proportional-share value.
+ *
+ * Chrome comes from the shared `renderEmailLayout` wrapper (consistency pass,
+ * chat#1885) — the same header / card / CTA / footer every automated email
+ * uses — so this renderer only owns its body content, CTA target, and footer
+ * note. Deep link is the fixed CHAT_APP_URL (never a derived deployment URL).
+ * Copy avoids
* em/en dashes and uses "to" for ranges. Per-album value is a proportional
* share of the single headline band (value = mid x streams/total), so the rows
* sum to the headline and never diverge from the funnel.
@@ -32,25 +34,22 @@ export function renderValuationReportHtml(params: ValuationReportEmailParams): {
? `Directional model, not an appraisal. Based on live Spotify play counts measured today, an annual run-rate from your catalog's lifetime average, and a master-side net royalty share times a 10 to 16x market multiple. Real statements collapse the range.
`
: "";
- const html = `
-
-
-
-|
- Catalog valuation
-${name}
- |
- |
-
+ const bodyHtml = `Catalog valuation
+${name}
${renderArtistHeader(params.artist)}
${renderValuationBlock(params.valuation)}
${renderStatRow(params)}
${renderReleasesTable(params.releases)}
-${disclaimer}
-
-You're receiving this because you ran a catalog valuation on Recoup.
- |
- |
`;
+${disclaimer}`;
+
+ const html = renderEmailLayout({
+ bodyHtml,
+ cta: {
+ label: "Get the full report with Recoup →",
+ url: params.deepLinkUrl,
+ },
+ footerHtml: `You're receiving this because you ran a catalog valuation on Recoup.
`,
+ });
return { subject: SUBJECT, html };
}
diff --git a/lib/emails/welcome/__tests__/renderValuationPayoff.test.ts b/lib/emails/welcome/__tests__/renderValuationPayoff.test.ts
new file mode 100644
index 00000000..f113c8bb
--- /dev/null
+++ b/lib/emails/welcome/__tests__/renderValuationPayoff.test.ts
@@ -0,0 +1,27 @@
+import { describe, it, expect } from "vitest";
+import { renderValuationPayoff } from "../renderValuationPayoff";
+
+const BASE = "https://chat.example.com";
+
+describe("renderValuationPayoff", () => {
+ it("links the payoff at /setup/valuation", () => {
+ const html = renderValuationPayoff(BASE);
+
+ expect(html).toContain(`href="${BASE}/setup/valuation"`);
+ });
+
+ it("frames the valuation as the reward, not a numbered step", () => {
+ const html = renderValuationPayoff(BASE);
+
+ expect(html).toContain("Then: your baseline valuation");
+ // No leading "5." — the count belongs to the four derived checkpoints.
+ expect(html).not.toMatch(/>\s*5\./);
+ });
+
+ it("contains no em or en dashes in outward-facing copy", () => {
+ const html = renderValuationPayoff(BASE);
+
+ expect(html).not.toContain("—");
+ expect(html).not.toContain("–");
+ });
+});
diff --git a/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts b/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts
index cc6f318b..9891dd66 100644
--- a/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts
+++ b/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts
@@ -27,14 +27,19 @@ describe("renderWelcomeSteps", () => {
it("points every step at its /setup route", () => {
const html = renderWelcomeSteps(BASE);
- for (const path of [
- "/setup/artists",
- "/setup/socials",
- "/setup/catalog",
- "/setup/valuation",
- "/setup/tasks",
- ]) {
+ for (const path of ["/setup/artists", "/setup/socials", "/setup/catalog", "/setup/tasks"]) {
expect(html).toContain(`href="${BASE}${path}"`);
}
});
+
+ it("does not number the baseline valuation among the steps", () => {
+ const html = renderWelcomeSteps(BASE);
+
+ // The payoff is rendered separately by renderValuationPayoff (chat#1889),
+ // so it must not appear in the numbered list.
+ expect(html).not.toContain("/setup/valuation");
+ // A numbered label only, not any "5." (the Blob host contains one).
+ expect(html).not.toMatch(/>\s*5\.\s/);
+ expect(html).not.toContain("baseline valuation");
+ });
});
diff --git a/lib/emails/welcome/__tests__/welcomeOnboardingSteps.test.ts b/lib/emails/welcome/__tests__/welcomeOnboardingSteps.test.ts
new file mode 100644
index 00000000..ec1f385a
--- /dev/null
+++ b/lib/emails/welcome/__tests__/welcomeOnboardingSteps.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect } from "vitest";
+import { WELCOME_ONBOARDING_STEPS } from "../welcomeOnboardingSteps";
+
+/**
+ * The email's step list is a deliberate MIRROR of chat's `ONBOARDING_STEP_IDS`
+ * (chat and api share no package, so it cannot be imported). This test is what
+ * keeps the mirror honest: the app derives 4 checkpoints — artists, socials,
+ * catalog, task — and the email must number exactly those, in that order.
+ *
+ * The baseline valuation is deliberately NOT a numbered step (chat#1889): it
+ * isn't a derivable checkpoint (`catalogs` persists no valuation; the number is
+ * computed live), and framing the payoff as a chore was the wrong model. It
+ * stays in the email as the reward, just not in the count.
+ */
+const APP_CHECKPOINT_TITLES = [
+ "Confirm your artists",
+ "Verify their socials",
+ "Claim your catalog",
+ "Automate with tasks",
+];
+
+describe("WELCOME_ONBOARDING_STEPS", () => {
+ it("numbers exactly the app's four derived checkpoints, in order", () => {
+ expect(WELCOME_ONBOARDING_STEPS.map(s => s.title)).toEqual(APP_CHECKPOINT_TITLES);
+ });
+
+ it("does not number the baseline valuation as a step", () => {
+ const paths = WELCOME_ONBOARDING_STEPS.map(s => s.linkPath);
+ expect(paths).not.toContain("/setup/valuation");
+ for (const step of WELCOME_ONBOARDING_STEPS) {
+ expect(step.title.toLowerCase()).not.toContain("valuation");
+ }
+ });
+
+ it("keeps every step pointed at its canonical /setup route", () => {
+ expect(WELCOME_ONBOARDING_STEPS.map(s => s.linkPath)).toEqual([
+ "/setup/artists",
+ "/setup/socials",
+ "/setup/catalog",
+ "/setup/tasks",
+ ]);
+ });
+});
diff --git a/lib/emails/welcome/renderValuationPayoff.ts b/lib/emails/welcome/renderValuationPayoff.ts
new file mode 100644
index 00000000..d24db8e2
--- /dev/null
+++ b/lib/emails/welcome/renderValuationPayoff.ts
@@ -0,0 +1,29 @@
+import { escapeHtml } from "@/lib/emails/escapeHtml";
+
+const COVER = "https://i.scdn.co/image/ab67616d00001e024aafdbad18bc27d7c429cdf1";
+
+/**
+ * The baseline valuation, framed as the payoff the four steps unlock rather than
+ * a fifth chore (chat#1889). It sits after the numbered list, visually distinct,
+ * and links to `/setup/valuation`.
+ *
+ * Why it is not a numbered step: the app derives exactly four checkpoints from
+ * account state, and "has a valuation" is not derivable — `catalogs` persists no
+ * valuation, the number is computed live from catalog measurements. Numbering it
+ * gave the email a 5-step count the product could never agree with.
+ *
+ * @param baseUrl - Frontend base URL the link is built on.
+ */
+export function renderValuationPayoff(baseUrl: string): string {
+ const href = escapeHtml(`${baseUrl}/setup/valuation`);
+
+ return `
+
+ |
+
+ Then: your baseline valuation
+What your catalog is worth today. It is the number every weekly report moves. See your baseline valuation.
+ |
+
+
`;
+}
diff --git a/lib/emails/welcome/welcomeOnboardingSteps.ts b/lib/emails/welcome/welcomeOnboardingSteps.ts
index a1ab6d2b..3d3a2aa7 100644
--- a/lib/emails/welcome/welcomeOnboardingSteps.ts
+++ b/lib/emails/welcome/welcomeOnboardingSteps.ts
@@ -1,12 +1,20 @@
/**
- * The five onboarding steps mirrored in the welcome email, using the product's
- * real flow language (chat `lib/onboarding/getOnboardingStepContent.ts`:
- * Confirm artists, Verify socials, Claim catalog, Schedule report) plus the
- * baseline valuation. Each step links into the matching `/setup/*` route and is
- * illustrated with art:
+ * The four onboarding steps mirrored in the welcome email — a deliberate MIRROR
+ * of chat's `ONBOARDING_STEP_IDS` (artists, socials, catalog, task). chat and
+ * api share no package, so the list cannot be imported; `__tests__` asserts the
+ * titles and order match the app's derived checkpoints, which is what keeps the
+ * mirror honest.
+ *
+ * The baseline valuation is deliberately NOT one of the numbered steps
+ * (chat#1889). It is not a derivable checkpoint — `catalogs` persists no
+ * valuation and the number is computed live at read time — and numbering the
+ * payoff alongside the chores was the wrong model. It stays in the email as the
+ * reward the steps unlock.
+ *
+ * Each step links into the matching `/setup/*` route and is illustrated with art:
* - step 1: an overlapping stack of the house cast's PFPs (social proof),
* - step 2: a real Instagram post thumbnail with an IG badge,
- * - steps 3-5: album covers from house artists.
+ * - steps 3-4: album covers from house artists.
*
* The step 1 + step 2 images are pre-composed PNGs on Vercel Blob (overlap and
* badge overlay can't be done reliably in email HTML), stored durably so they
@@ -57,16 +65,6 @@ export const WELCOME_ONBOARDING_STEPS: WelcomeStep[] = [
imageStyle: "square",
imageAlt: "Album cover",
},
- {
- title: "See your baseline valuation",
- description:
- "Get what your catalog is worth today. It is the number every weekly report moves.",
- linkText: "See your baseline valuation.",
- linkPath: "/setup/valuation",
- imageUrl: "https://i.scdn.co/image/ab67616d00001e024aafdbad18bc27d7c429cdf1",
- imageStyle: "square",
- imageAlt: "Album cover",
- },
{
title: "Automate with tasks",
description: "Schedule a recurring report and Recoup keeps working your catalog every week.",