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 +}