From 7eee3320cc7e79bd650038e3f9d39f372526cc5a Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 23 Jul 2026 06:28:01 -0500 Subject: [PATCH 1/5] feat(emails): shared house-style email layout wrapper (consistency pass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Welcome, valuation, and weekly-report emails should share one visual language (DESIGN.md — four-font system, shadow-as-border, achromatic chrome), but each outbound email was assembled ad hoc (body + bare footer). This establishes the shared template (recoupable/chat#1885 consistency pass). - `renderEmailLayout` (`lib/emails/`) — one header + footer + optional-CTA wrapper: Recoup wordmark header, achromatic shadow-as-border card, the DESIGN.md font stack, centered fixed-max-width container. Email-client-safe (inline styles, literal hex tokens, webfonts degrade to system fonts). - `processAndSendEmail` — adopts the wrapper as the proof point. This is the path the live weekly-report email flows through (agent `send_email` → `processAndSendEmail`), so the already-live weekly report now renders in the shared house style, with the existing footer carried in as the layout footer. Welcome (api#774) and valuation (api#773) emails adopt the same wrapper once merged — this PR establishes the shared template. TDD: RED→GREEN for the layout renderer (body/footer/CTA + house-style markers) and for the processAndSendEmail adoption. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/processAndSendEmail.test.ts | 18 ++++ .../__tests__/renderEmailLayout.test.ts | 54 ++++++++++++ lib/emails/processAndSendEmail.ts | 10 ++- lib/emails/renderEmailLayout.ts | 83 +++++++++++++++++++ 4 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 lib/emails/__tests__/renderEmailLayout.test.ts create mode 100644 lib/emails/renderEmailLayout.ts 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..90609291 --- /dev/null +++ b/lib/emails/__tests__/renderEmailLayout.test.ts @@ -0,0 +1,54 @@ +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("returns a single HTML string", () => { + expect(typeof renderEmailLayout({ bodyHtml: "

x

" })).toBe("string"); + }); +}); 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..bacfec4c --- /dev/null +++ b/lib/emails/renderEmailLayout.ts @@ -0,0 +1,83 @@ +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. +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 + ? ` +
+ + ${cta.label} + +
`.trim() + : ""; + + const footerBlock = footerHtml + ? ` +
+ ${footerHtml} +
`.trim() + : ""; + + return ` +
+
+ ${header} +
+ ${bodyHtml} +
+ ${ctaBlock} + ${footerBlock} +
+
+ Recoup — the AI agent platform for the music industry +
+
`.trim(); +} From 40df59d96af86f79e541f0b1eb00ae03a001e547 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 23 Jul 2026 20:14:00 -0500 Subject: [PATCH 2/5] feat(emails): adopt the shared layout in welcome + valuation; fix font-stack quoting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the consistency pass — previously the wrapper was only adopted in the weekly-report path, so welcome/valuation still shipped their own chrome. - buildWelcomeEmail + renderValuationReportHtml now render through renderEmailLayout (body + cta + footer), dropping their duplicated outer page/card chrome; each keeps only its own content. - Fix: FONT_STACK used double-quoted font names inside double-quoted style="…" attributes, which terminated the attribute early — silently dropping the font (clients fell back to serif) AND every declaration after it (the CTA button lost its background/padding/radius). Single-quote the font names; add a regression test. - Layout footer tagline: em dash -> comma (house copy rule). - Consistency assertions added to both email tests. 199 lib/emails tests pass; lint + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/buildWelcomeEmail.test.ts | 10 +++++ .../__tests__/renderEmailLayout.test.ts | 15 +++++++ lib/emails/buildWelcomeEmail.ts | 36 +++++++--------- lib/emails/renderEmailLayout.ts | 9 +++- .../renderValuationReportHtml.test.ts | 10 +++++ .../renderValuationReportHtml.ts | 41 +++++++++---------- 6 files changed, 77 insertions(+), 44 deletions(-) diff --git a/lib/emails/__tests__/buildWelcomeEmail.test.ts b/lib/emails/__tests__/buildWelcomeEmail.test.ts index ef4cdc9f..f077a6bf 100644 --- a/lib/emails/__tests__/buildWelcomeEmail.test.ts +++ b/lib/emails/__tests__/buildWelcomeEmail.test.ts @@ -75,6 +75,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__/renderEmailLayout.test.ts b/lib/emails/__tests__/renderEmailLayout.test.ts index 90609291..e6369c0c 100644 --- a/lib/emails/__tests__/renderEmailLayout.test.ts +++ b/lib/emails/__tests__/renderEmailLayout.test.ts @@ -48,6 +48,21 @@ describe("renderEmailLayout", () => { 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..476c3a33 100644 --- a/lib/emails/buildWelcomeEmail.ts +++ b/lib/emails/buildWelcomeEmail.ts @@ -1,9 +1,8 @@ -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 { 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 @@ -11,9 +10,10 @@ const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif"; * catalog, see baseline valuation, automate with tasks), illustrated with art * from the house cast of artists (PFPs + album covers, stable Spotify CDN URLs). * - * 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 +24,16 @@ 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
+ 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 five step path from a new account to a catalog you measure, grow, and automate.

-${renderWelcomeSteps(CHAT_APP_URL)} -
Confirm your roster →
-${footer} -
-
`; +${renderWelcomeSteps(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/renderEmailLayout.ts b/lib/emails/renderEmailLayout.ts index bacfec4c..4d911a12 100644 --- a/lib/emails/renderEmailLayout.ts +++ b/lib/emails/renderEmailLayout.ts @@ -26,8 +26,13 @@ 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'; + "'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. @@ -77,7 +82,7 @@ export function renderEmailLayout({ bodyHtml, footerHtml, cta }: RenderEmailLayo ${footerBlock}
- Recoup — the AI agent platform for the music industry + 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}

-
Recoup
+ const bodyHtml = `

Catalog valuation

+

${name}

${renderArtistHeader(params.artist)} ${renderValuationBlock(params.valuation)} ${renderStatRow(params)} ${renderReleasesTable(params.releases)} -${disclaimer} -
Get the full report with Recoup →
-

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 }; } From e3fbd069651fea9de16fc8d6c4f812da2ae5c7e3 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 23 Jul 2026 20:18:43 -0500 Subject: [PATCH 3/5] style: prettier fix in renderEmailLayout regression test --- lib/emails/__tests__/renderEmailLayout.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/emails/__tests__/renderEmailLayout.test.ts b/lib/emails/__tests__/renderEmailLayout.test.ts index e6369c0c..9ae510b7 100644 --- a/lib/emails/__tests__/renderEmailLayout.test.ts +++ b/lib/emails/__tests__/renderEmailLayout.test.ts @@ -59,7 +59,7 @@ describe("renderEmailLayout", () => { 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:\""); + expect(style).not.toContain('font-family:"'); } }); From ab04ea5525063b22ae09429541772cd69bd82636 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Sun, 26 Jul 2026 23:43:37 -0500 Subject: [PATCH 4/5] feat(emails): welcome steps mirror the app's four checkpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat#1889 item 1, part 4. The welcome email numbered five steps while chat derives four checkpoints (artists, socials, catalog, task) and RosterSocialsFlow counted two — the same account was told three different things about how much setup was left. The valuation is not a derivable checkpoint: `catalogs` persists no valuation and the number is computed live from catalog measurements, so a fifth predicate would either duplicate `catalog` or push a network call into the pure deriveOnboardingState. Numbering the payoff alongside the chores was also the wrong model. - welcomeOnboardingSteps drops to the app's four checkpoints, in order. - New renderValuationPayoff renders the baseline valuation after the list as the reward the steps unlock, still linking /setup/valuation. - New welcomeOnboardingSteps test asserts titles + order + paths match the app's checkpoints; chat and api share no package, so this test is what keeps the mirror honest. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/buildWelcomeEmail.test.ts | 7 +-- lib/emails/buildWelcomeEmail.ts | 16 ++++--- .../__tests__/renderValuationPayoff.test.ts | 27 +++++++++++ .../__tests__/renderWelcomeSteps.test.ts | 12 ++++- .../__tests__/welcomeOnboardingSteps.test.ts | 45 +++++++++++++++++++ lib/emails/welcome/renderValuationPayoff.ts | 30 +++++++++++++ lib/emails/welcome/welcomeOnboardingSteps.ts | 30 ++++++------- 7 files changed, 141 insertions(+), 26 deletions(-) create mode 100644 lib/emails/welcome/__tests__/renderValuationPayoff.test.ts create mode 100644 lib/emails/welcome/__tests__/welcomeOnboardingSteps.test.ts create mode 100644 lib/emails/welcome/renderValuationPayoff.ts diff --git a/lib/emails/__tests__/buildWelcomeEmail.test.ts b/lib/emails/__tests__/buildWelcomeEmail.test.ts index f077a6bf..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) { diff --git a/lib/emails/buildWelcomeEmail.ts b/lib/emails/buildWelcomeEmail.ts index 476c3a33..4ef52c1d 100644 --- a/lib/emails/buildWelcomeEmail.ts +++ b/lib/emails/buildWelcomeEmail.ts @@ -1,14 +1,17 @@ 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"; /** * 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. * * Chrome comes from the shared `renderEmailLayout` wrapper (consistency pass, * chat#1885) — the same header / card / CTA / footer the valuation and @@ -26,8 +29,9 @@ export function buildWelcomeEmail(): { subject: string; html: string } { 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 five step path from a new account to a catalog you measure, grow, and automate.

-${renderWelcomeSteps(CHAT_APP_URL)}`; +

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)} +${renderValuationPayoff(CHAT_APP_URL)}`; const html = renderEmailLayout({ bodyHtml, 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..0755063c 100644 --- a/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts +++ b/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts @@ -31,10 +31,20 @@ describe("renderWelcomeSteps", () => { "/setup/artists", "/setup/socials", "/setup/catalog", - "/setup/valuation", "/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..8394cfe3 --- /dev/null +++ b/lib/emails/welcome/__tests__/welcomeOnboardingSteps.test.ts @@ -0,0 +1,45 @@ +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..1e2f4348 --- /dev/null +++ b/lib/emails/welcome/renderValuationPayoff.ts @@ -0,0 +1,30 @@ +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 ` + + + + +
Album cover +

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.", From 59191a4cf133ea4f867712db1161bb2acacc7204 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 27 Jul 2026 09:17:11 -0500 Subject: [PATCH 5/5] style: prettier fix on welcome step + valuation payoff files Co-Authored-By: Claude Opus 5 (1M context) --- lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts | 7 +------ .../welcome/__tests__/welcomeOnboardingSteps.test.ts | 8 +++----- lib/emails/welcome/renderValuationPayoff.ts | 3 +-- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts b/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts index 0755063c..9891dd66 100644 --- a/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts +++ b/lib/emails/welcome/__tests__/renderWelcomeSteps.test.ts @@ -27,12 +27,7 @@ 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/tasks", - ]) { + for (const path of ["/setup/artists", "/setup/socials", "/setup/catalog", "/setup/tasks"]) { expect(html).toContain(`href="${BASE}${path}"`); } }); diff --git a/lib/emails/welcome/__tests__/welcomeOnboardingSteps.test.ts b/lib/emails/welcome/__tests__/welcomeOnboardingSteps.test.ts index 8394cfe3..ec1f385a 100644 --- a/lib/emails/welcome/__tests__/welcomeOnboardingSteps.test.ts +++ b/lib/emails/welcome/__tests__/welcomeOnboardingSteps.test.ts @@ -21,13 +21,11 @@ const APP_CHECKPOINT_TITLES = [ 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, - ); + 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); + 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"); @@ -35,7 +33,7 @@ describe("WELCOME_ONBOARDING_STEPS", () => { }); it("keeps every step pointed at its canonical /setup route", () => { - expect(WELCOME_ONBOARDING_STEPS.map((s) => s.linkPath)).toEqual([ + expect(WELCOME_ONBOARDING_STEPS.map(s => s.linkPath)).toEqual([ "/setup/artists", "/setup/socials", "/setup/catalog", diff --git a/lib/emails/welcome/renderValuationPayoff.ts b/lib/emails/welcome/renderValuationPayoff.ts index 1e2f4348..d24db8e2 100644 --- a/lib/emails/welcome/renderValuationPayoff.ts +++ b/lib/emails/welcome/renderValuationPayoff.ts @@ -1,7 +1,6 @@ import { escapeHtml } from "@/lib/emails/escapeHtml"; -const COVER = - "https://i.scdn.co/image/ab67616d00001e024aafdbad18bc27d7c429cdf1"; +const COVER = "https://i.scdn.co/image/ab67616d00001e024aafdbad18bc27d7c429cdf1"; /** * The baseline valuation, framed as the payoff the four steps unlock rather than