From 3674a57839c89962d19fdddac486cd4833b4f720 Mon Sep 17 00:00:00 2001 From: sonpiaz Date: Fri, 2 Oct 2026 13:32:37 -0700 Subject: [PATCH] feat: say what a program page apply button pays The program pages asked people to apply or join without the commission. The button now says Apply and earn, plus the rate when that stays within four words, and records the click as cta_oa_program. --- .github/workflows/ci.yml | 1 + scripts/apply-cta.test.ts | 37 ++++++++++++++++++++++++++ src/app/programs/[slug]/page.tsx | 13 ++++----- src/app/programs/[slug]/track-view.tsx | 5 +++- src/lib/apply-cta.ts | 22 +++++++++++++++ 5 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 scripts/apply-cta.test.ts create mode 100644 src/lib/apply-cta.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bffa87c2..383f072d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: - run: node --test .github/scripts/fork-yaml-ci.test.cjs - run: node --test .github/scripts/program-logo-server.test.cjs - run: npx tsx --test scripts/posthog-noise.test.ts + - run: npx tsx --test scripts/apply-cta.test.ts - run: npx tsc --noEmit - run: npm run build - run: npm run verify:client-registry diff --git a/scripts/apply-cta.test.ts b/scripts/apply-cta.test.ts new file mode 100644 index 00000000..9743f8b1 --- /dev/null +++ b/scripts/apply-cta.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { applyEarnLabel, ctaWordCount } from "../src/lib/apply-cta" +import type { Program } from "../src/lib/programs" + +function commission(partial: Partial): Program["commission"] { + return { + type: "recurring", + rate: "20%", + mode: "percentage", + value: 20, + currency: "USD", + ...partial, + } +} + +test("percentage uses the signed wording and stays within 4 words", () => { + const label = applyEarnLabel(commission({ mode: "percentage", value: 20, rate: "20%" })) + assert.equal(label, "Apply & earn 20%") + assert.ok(ctaWordCount(label) <= 4) + assert.equal(label.includes("—") || label.includes("–"), false) +}) + +test("flat fee does not grow a percent sign", () => { + const label = applyEarnLabel(commission({ mode: "flat", type: "one-time", value: 36, rate: "$36" })) + assert.equal(label, "Apply & earn $36") + assert.ok(ctaWordCount(label) <= 4) +}) + +test("unknown and long rates stay short", () => { + assert.equal(applyEarnLabel(commission({ mode: "unknown", value: null, rate: "varies" })), "Apply & earn") + const long = applyEarnLabel( + commission({ mode: "hybrid", value: null, rate: "$5 per lead + 30%" }), + ) + assert.equal(long, "Apply & earn") + assert.ok(ctaWordCount(long) <= 4) +}) diff --git a/src/app/programs/[slug]/page.tsx b/src/app/programs/[slug]/page.tsx index 27aeff69..e73ab3ba 100644 --- a/src/app/programs/[slug]/page.tsx +++ b/src/app/programs/[slug]/page.tsx @@ -34,6 +34,7 @@ import { RelatedPrograms } from "@/components/related-programs"; import { SocialListenLoader } from "@/components/social-listen-loader"; import { serializeJsonLd } from "@/lib/json-ld"; import { programs, getProgram, parseCommissionRate, commissionLabel, affiliateScore, IN_HOUSE, commissionDisplay, commissionUnknown} from "@/lib/programs"; +import { applyEarnLabel } from "@/lib/apply-cta"; import { TrackView, TrackLink } from "./track-view"; export const revalidate = 86400; @@ -132,6 +133,7 @@ export default async function ProgramPage({ if (!program) notFound(); const joinUrl = program.signupUrl ?? program.url; + const applyCta = applyEarnLabel(program.commission); // Affiliate Score const score = affiliateScore(program); @@ -323,15 +325,14 @@ export default async function ProgramPage({

How to Join

{program.signupUrl && ( - - Apply to program + {applyCta} - + )}
{approvalInfo && ( @@ -617,7 +618,7 @@ export default async function ProgramPage({ slug={slug} className="flex items-center justify-center gap-2 rounded-xl bg-foreground hover:bg-foreground/90 text-background px-5 py-3 text-sm font-medium transition-colors w-full" > - Join Program + {applyCta}
diff --git a/src/app/programs/[slug]/track-view.tsx b/src/app/programs/[slug]/track-view.tsx index 4902cee8..11bbdd3c 100644 --- a/src/app/programs/[slug]/track-view.tsx +++ b/src/app/programs/[slug]/track-view.tsx @@ -27,7 +27,10 @@ export function TrackLink({ target="_blank" rel="noopener noreferrer" className={className} - onClick={() => track("outbound_click", { slug })} + onClick={() => { + track("outbound_click", { slug }) + track("cta_oa_program", { slug }) + }} > {children} diff --git a/src/lib/apply-cta.ts b/src/lib/apply-cta.ts new file mode 100644 index 00000000..dcab466a --- /dev/null +++ b/src/lib/apply-cta.ts @@ -0,0 +1,22 @@ +import { commissionDisplay, commissionUnknown, type Program } from "@/lib/programs" + +/** Whitespace-separated tokens. "&" counts, so the signed cap stays at 4. */ +export function ctaWordCount(label: string): number { + return label.trim().split(/\s+/).filter(Boolean).length +} + +/** + * Program-page apply button. Signed form: "Apply & earn {commission}%", + * no em dash, at most 4 words. A flat fee keeps its dollar sign and does + * not gain a percent. A rate that would run past 4 words falls back to + * "Apply & earn" rather than a truncated number. + */ +export function applyEarnLabel(commission: Program["commission"]): string { + const prefix = "Apply & earn" + if (commissionUnknown(commission)) return prefix + const shown = commissionDisplay(commission) + if (!shown || shown === "Not published") return prefix + const label = `${prefix} ${shown}` + if (/[—–]/.test(label) || ctaWordCount(label) > 4) return prefix + return label +}