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
-

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)} -
Confirm your roster →
-${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 + ? ` +
+ + ${cta.label} + +
`.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}

-
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 }; } 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 ` + + + + +
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.",