Skip to content

feat: free CTA opens a $0 card-setup screen, enterprise CTA books a call - #60

Merged
sweetmantech merged 3 commits into
mainfrom
feat/1902-partner-calendly
Jul 29, 2026
Merged

feat: free CTA opens a $0 card-setup screen, enterprise CTA books a call#60
sweetmantech merged 3 commits into
mainfrom
feat/1902-partner-calendly

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

The Partner (enterprise) plan CTA was a bare mailto: link, which loses the lead the moment a visitor has no mail client configured, gives us no scheduling, and produces no trackable click. It now opens the booking link in a new tab.

Changes

Before After
mailto:hi@recoupable.dev?subject=Recoupable%20Partner%20Plan https://calendly.com/sweetman-recoupable/30min
CTA label "Talk to us" CTA label "Book a call"
same tab new tab (target="_blank" + rel="noopener noreferrer"), so the pricing page survives the click
  • lib/config.ts — new siteConfig.bookingUrl, so the booking link has one home instead of being pasted into copy files.
  • lib/copy/pricing.ts — Partner plan points at siteConfig.bookingUrl, label changed, and a new optional ctaExternal flag on PricingPlan.
  • app/pricing/page.tsx — honors ctaExternal for target / rel. Free and Pro CTAs are untouched and still open in the same tab.

Verification

  • pnpm exec tsc --noEmit: clean.
  • pnpm build: passes, all pages prerendered.
  • Prerendered /pricing.html contains calendly.com/sweetman-recoupable/30min with target="_blank" rel="noopener noreferrer", and zero occurrences of Partner%20Plan (the old mailto is fully gone).
  • https://calendly.com/sweetman-recoupable/30min returns 200.
  • Preview screenshot to follow in a comment.

Notes for reviewers

  • Conflicts with #58 in lib/copy/pricing.ts (that PR rewrites the plan cards). The conflict is trivial: whichever merges second keeps this Partner block. Also touches app/pricing/page.tsx, which #56 and #57 modify in different places.
  • Two other enterprise-intent mailtos were left alone, both on the /audit page in lib/copy/audit.ts: "AI Transformation Inquiry" and "Partner Inquiry". They are candidates for the same treatment; say the word and I will point them at siteConfig.bookingUrl too.
  • Closes the "Partner plan Talk to us is a bare mailto" item in Web UX conversion audit: wire the pricing→trial funnel (marketing + chat) chat#1902.

Part of recoupable/chat#1902.

🤖 Generated with Claude Code


Summary by cubic

Improves pricing conversions: Enterprise CTA now books a Calendly call in a new tab, and Free CTA opens a $0 Stripe card-setup to save a card for future top-ups. Also refactors checkout gating, updates copy, and fixes the Calendly slug to /roster (recoupable/chat#1902).

  • New Features

    • Enterprise/Partner: label is "Book a call", href uses siteConfig.bookingUrl, opens in a new tab.
    • Free plan: new CardOnFileButton triggers a Stripe setup-mode session (no charge) via useCardOnFile.
    • Pricing copy updated (removed "No credit card required" and clarified Pro trial collects a card).
  • Refactors

    • Added useGatedStripeRedirect to share Privy-gated Stripe redirects; useProCheckout now uses it.
    • Added createCardOnFileSession with tests.

Written for commit 164e8ee. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Updated the Partner pricing plan with a “Book a call” action for enterprise and partner inquiries.
    • The booking link now opens securely in a new browser tab.
    • Pricing call-to-action links can now be configured to open externally when appropriate.

@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
marketing Ready Ready Preview Jul 29, 2026 10:23pm

Request Review

@cursor

cursor Bot commented Jul 29, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Partner pricing now uses a configured booking URL and marks its CTA as external. PlanCard conditionally opens external CTAs in a new tab with appropriate security attributes.

Changes

Pricing CTA behavior

Layer / File(s) Summary
Booking CTA configuration and plan data
lib/config.ts, lib/copy/pricing.ts
Adds siteConfig.bookingUrl, extends PricingPlan with ctaExternal, and changes the Partner CTA to “Book a call” using the booking URL.
External CTA rendering
app/pricing/page.tsx
Adds conditional target="_blank" and rel="noopener noreferrer" attributes for external CTAs.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Suggested reviewers: sidneyswift

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title captures the enterprise CTA booking-call change, which is the main update, though the free CTA detail is extra.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/1902-partner-calendly

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/config.ts`:
- Line 40: Rewrite the comments at lib/config.ts#L40-L40 and
lib/copy/pricing.ts#L16-L16 to explain their business rationale rather than
describing behavior: document that the booking URL centralizes enterprise
conversion routing, and that ctaExternal preserves the pricing page while
directing users to third-party booking flows.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9d55fbf3-c82d-490b-9500-d5393f68248f

📥 Commits

Reviewing files that changed from the base of the PR and between e1a67c7 and 25bbf73.

📒 Files selected for processing (3)
  • app/pricing/page.tsx
  • lib/config.ts
  • lib/copy/pricing.ts

Comment thread lib/config.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 3 files

Re-trigger cubic

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification

Preview: https://marketing-8k2xee2u1-recoup.vercel.app — built from head commit 719c239.
Tested 2026-07-29, Chrome 1280x900, cookie-less isolated context.

A broken link was caught and fixed during this pass

The first commit shipped https://calendly.com/sweetman-recoupable/30min, which returned 200 when the PR was opened and 404 by the time the preview was tested. Cause: Calendly derives an event's slug from its name, so renaming the event to "Recoup for your roster" silently moved it from /30min to /roster and killed the old URL. Fixed in 719c239; lib/config.ts now carries a comment warning that a rename breaks this URL.

Worth knowing for anyone touching this later: a curl check at authoring time is not sufficient evidence that a third-party link will still resolve at merge time.

Results

# Check Expected Actual Status
1 Partner CTA href Calendly booking URL, no mailto: https://calendly.com/sweetman-recoupable/roster PASS
2 Old mailto fully gone zero occurrences Partner%20Plan appears 0 times in prerendered /pricing.html PASS
3 New tab, safely target="_blank" + rel="noopener noreferrer" both present on the anchor PASS
4 CTA label "Book a call" "Book a call" (shot 1) PASS
5 Free and Pro CTAs unaffected still same-tab, no target unchanged PASS
6 Booking URL resolves 200 200 for /roster; 404 for the old /30min PASS
7 Booking page renders the real event title, duration, description "Recoup for your roster", 30 min, full description (shot 2) PASS
8 Build gate passes pnpm build compiled successfully, all pages prerendered; tsc --noEmit clean PASS

8/8 pass.

Screenshots

1. Partner card CTA on the preview

Partner card Book a call CTA

2. Where the CTA lands

Calendly booking page

Follow-up worth considering (not in this PR)

The slug fragility above is structural: any future rename of the Calendly event breaks production silently. An owned redirect (recoupable.dev/book to the Calendly URL) would contain that blast radius to a one-line config change and give us a stable link to use in outreach emails and decks. Happy to add it if wanted.

Part of recoupable/chat#1902.

sweetmantech and others added 2 commits July 29, 2026 17:10
The Partner plan CTA was a bare mailto, which drops the lead when no mail
client is configured and gives us no scheduling or click tracking. Point it
at the booking link in a new tab, with the URL held once in siteConfig.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Renaming the event to "Recoup for your roster" changed its Calendly slug
from /30min to /roster, so the previous URL 404'd.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The free plan CTA linked to the chat app. It now opens a Stripe setup-mode
checkout so the visitor registers a card for later credit top-ups without
being charged, which is what lets the credits auto-recharge path top an
account up instead of dead-ending it at zero.

Extracts the Privy sign-in gate shared with the Pro checkout into
useGatedStripeRedirect rather than duplicating it, and corrects the FAQ
claim that the Pro trial needs no card (the trial checkout does collect one).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sweetmantech sweetmantech changed the title feat: enterprise CTA books a call instead of opening a mail client feat: free CTA opens a $0 card-setup screen, enterprise CTA books a call Jul 29, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 8 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/checkout/createCardOnFileSession.ts">

<violation number="1" location="lib/checkout/createCardOnFileSession.ts:27">
P3: Future fixes to authenticated checkout requests or response handling can diverge between these two flows because this helper copies the full transport block from `createCheckoutSession`; a shared POST/hosted-URL helper would keep both implementations consistent.</violation>
</file>

<file name="components/pricing/CardOnFileButton.tsx">

<violation number="1" location="components/pricing/CardOnFileButton.tsx:25">
P2: The CTA can silently do nothing on the first click while Privy is initializing because it is disabled only for `isPending`, while `useGatedStripeRedirect.start()` drops calls when `ready` is false without feedback. Exposing the auth-ready state and disabling or queuing the action until initialization completes would prevent this lost CTA interaction.</violation>
</file>

<file name="hooks/useGatedStripeRedirect.ts">

<violation number="1" location="hooks/useGatedStripeRedirect.ts:52">
P2: A visitor can be redirected into the previously selected Stripe flow without clicking that CTA again: this ref is set before opening Privy, but it is only cleared when `authenticated` becomes true. If the visitor cancels or abandons the sign-in modal and later authenticates through another flow, the effect at lines 60–64 will automatically call the old `createSessionUrl` callback (paid checkout for Pro or card setup for Free). Clearing the deferred action on login cancellation/error or explicit modal close would prevent an unexpected checkout/setup redirect.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

<button
type="button"
onClick={() => void startCardSetup()}
disabled={isPending}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The CTA can silently do nothing on the first click while Privy is initializing because it is disabled only for isPending, while useGatedStripeRedirect.start() drops calls when ready is false without feedback. Exposing the auth-ready state and disabling or queuing the action until initialization completes would prevent this lost CTA interaction.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At components/pricing/CardOnFileButton.tsx, line 25:

<comment>The CTA can silently do nothing on the first click while Privy is initializing because it is disabled only for `isPending`, while `useGatedStripeRedirect.start()` drops calls when `ready` is false without feedback. Exposing the auth-ready state and disabling or queuing the action until initialization completes would prevent this lost CTA interaction.</comment>

<file context>
@@ -0,0 +1,40 @@
+      <button
+        type="button"
+        onClick={() => void startCardSetup()}
+        disabled={isPending}
+        className={`${className} disabled:opacity-70 disabled:cursor-wait`}
+      >
</file context>

if (!ready || isPending) return;
// Gate: signed out opens Privy and defers the redirect to a successful login.
if (!authenticated) {
pendingRedirect.current = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A visitor can be redirected into the previously selected Stripe flow without clicking that CTA again: this ref is set before opening Privy, but it is only cleared when authenticated becomes true. If the visitor cancels or abandons the sign-in modal and later authenticates through another flow, the effect at lines 60–64 will automatically call the old createSessionUrl callback (paid checkout for Pro or card setup for Free). Clearing the deferred action on login cancellation/error or explicit modal close would prevent an unexpected checkout/setup redirect.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At hooks/useGatedStripeRedirect.ts, line 52:

<comment>A visitor can be redirected into the previously selected Stripe flow without clicking that CTA again: this ref is set before opening Privy, but it is only cleared when `authenticated` becomes true. If the visitor cancels or abandons the sign-in modal and later authenticates through another flow, the effect at lines 60–64 will automatically call the old `createSessionUrl` callback (paid checkout for Pro or card setup for Free). Clearing the deferred action on login cancellation/error or explicit modal close would prevent an unexpected checkout/setup redirect.</comment>

<file context>
@@ -0,0 +1,69 @@
+    if (!ready || isPending) return;
+    // Gate: signed out opens Privy and defers the redirect to a successful login.
+    if (!authenticated) {
+      pendingRedirect.current = true;
+      login();
+      return;
</file context>

successUrl: string,
token: string,
): Promise<string> {
const res = await fetch(`${siteConfig.apiUrl}/subscriptions/card-on-file`, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Future fixes to authenticated checkout requests or response handling can diverge between these two flows because this helper copies the full transport block from createCheckoutSession; a shared POST/hosted-URL helper would keep both implementations consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/checkout/createCardOnFileSession.ts, line 27:

<comment>Future fixes to authenticated checkout requests or response handling can diverge between these two flows because this helper copies the full transport block from `createCheckoutSession`; a shared POST/hosted-URL helper would keep both implementations consistent.</comment>

<file context>
@@ -0,0 +1,41 @@
+  successUrl: string,
+  token: string,
+): Promise<string> {
+  const res = await fetch(`${siteConfig.apiUrl}/subscriptions/card-on-file`, {
+    method: "POST",
+    headers: {
</file context>

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — free CTA card-on-file, end to end

Preview: https://marketing-41n7f2tu8-recoup.vercel.app, confirmed built from head 164e8ee1.
Tested 2026-07-29, Chrome 1280x900, cookie-less isolated context, real sign-in on sweetman+july2920261525@recoupable.com.

This run was only possible after api#797 merged and the api test branch was synced with main: lib/config.ts resolves previews to test-recoup-api, so before the sync this CTA would have called a route that returned 404 there. Confirmed the route went live on that host (404 to 401) before starting.

Results

# Check Expected Actual Status
1 Free CTA element <button>, no href {"tag":"BUTTON","text":"Start free","href":null} PASS
2 Small print under the CTA states no charge today "No charge today. Your card is saved for credit top-ups." PASS
3 Contradictory copy removed no "No credit card required" on the page zero occurrences PASS
4 Signed-out click Privy modal in place, no navigation modal opened, URL stayed on /pricing (shot 1) PASS
5 Auto-resume after login no second click needed entering the emailed code advanced straight to Stripe with no further interaction PASS
6 Redirect Stripe, same tab same tab to checkout.stripe.com/c/pay/cs_live_c15M6sf… PASS
7 Session is setup mode, not a payment card saved, nothing charged "Save payment information" / Save button / consent about future payments (shot 2) PASS
8 No amount or subscription language none asserted on the rendered page: hasCurrencyAmount: false, hasSubscriptionLanguage: false PASS
9 Pro CTA unaffected still starts the paid trial <button> "Start 30-day trial", unchanged PASS
10 Partner CTA Calendly, new tab calendly.com/sweetman-recoupable/roster, target="_blank" PASS
11 Other free link keeps UTMs UTM-tagged link to chat bottom "Get started free" to chat.recoupable.dev/?utm_source=marketing&utm_medium=pricing&utm_campaign=free PASS
12 No duplicate Stripe customer existing customer reused for the account no new customer created; resolveStripeCustomerForAccount reused cus_UycHwnGosC0cAE from the earlier #56 run for the same account PASS
13 Nothing charged or saved 0 payment methods, 0 charges, 0 subscriptions 0 / 0 / 0 across every customer touched in this session's tests PASS

13/13.

The two flows are visibly different, which is the point

Free CTA (this PR) Pro CTA (#56, already merged)
Stripe heading Save payment information Try Recoupable Pro / 30 days free
Button Save Start trial
Amount none $99.00 per month
Consent "charge you for future payments" "charge you according to the terms until you cancel"

Screenshots

1. "Start free" opens Privy in place on the marketing domain

Start free opens Privy on marketing

2. Where it lands: Stripe card setup, no charge

Stripe card setup screen

Notes

  • Card details were deliberately not entered. The goal was to prove the session is created in setup mode and reaches the card screen, not to save a real card. Verified via Stripe that nothing was attached or charged.
  • Live-mode caveat. The session id is cs_live_…: previews resolve to test-recoup-api, which carries live Stripe credentials. This run created no new Stripe customer (it reused an existing one) and no charge, but anyone testing here should know it is live mode.
  • Still open, needs a decision, not a code change: #58 adds a "No credit card required" bullet to the Free card, which this PR makes false. Whichever merges second must drop it. Also unresolved by design: the page now has two free entry points with different friction, this CTA (collects a card) and the bottom "Get started free" (does not).

Part of recoupable/chat#1902 and recoupable/chat#1910.

sweetmantech added a commit that referenced this pull request Jul 29, 2026
@sweetmantech

Copy link
Copy Markdown
Contributor Author

Addendum — enterprise CTA tested by clicking it, on this preview

Correcting a gap in the previous comment: row 10 there asserted the Partner CTA's href and target from the DOM but never clicked it, and the screenshots in it were only of the free/card flow. The Calendly screenshots in the earlier comment were taken on an older preview (marketing-hp0u0knua, commit 719c239), before the card-on-file work was folded in. So the enterprise path had never been exercised on the current head.

Now done on https://marketing-41n7f2tu8-recoup.vercel.app (head 164e8ee1).

# Check Expected Actual Status
1 CTA label and attributes "Book a call", Calendly href, new tab {"text":"Book a call","href":"https://calendly.com/sweetman-recoupable/roster","target":"_blank","rel":"noopener noreferrer"} PASS
2 Clicking opens a new tab pricing page survives click produced a second tab; /pricing remained open and unchanged PASS
3 Destination loads real booking page, not a 404 calendly.com/sweetman-recoupable/roster?month=2026-07, is404: false PASS
4 Correct event the renamed event, 30 min, with the description title "Recoup for your roster", "30 min", description present, date picker rendered PASS

4/4. Asserted programmatically on the loaded page rather than by eye: eventTitle: "Recoup for your roster", duration: "30 min", hasDescription: true, hasDatePicker: true, is404: false.

1. Partner card CTA on this preview

Partner Book a call CTA

2. Where the click lands (new tab)

Calendly booking page reached from the CTA

Also fixed: I had broken my own earlier screenshots

Pushing the card-on-file screenshots to assets/pr60 I removed the directory and force-pushed, which deleted 1-partner-book-a-call.png and 2-calendly-booking-page.png and left the earlier comment with broken images. Both files are restored from the earlier commit on that branch, so all six screenshots resolve again. Lesson for next time: add to an assets branch, never git rm and force-push it, since older comments keep pointing at those paths.

Combined coverage for this PR is now 17/17: 13 on the free card-on-file flow, 4 on the enterprise CTA.

@sweetmantech
sweetmantech merged commit daec044 into main Jul 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant