feat: pricing Pro CTA starts Stripe checkout directly from marketing - #56
Conversation
The pricing page CTAs linked to bare https://chat.recoupable.dev/, dropping purchase intent and attribution at the marketing-to-app handoff. Add a buildChatUrl helper (TDD) and use it for the three chat-bound CTAs: - Pro "Start 30-day trial" -> ?intent=pro-trial + UTM (pro-trial campaign) - Plus "Start free" -> UTMs only (free campaign) - Bottom "Get started free" -> UTMs only (free campaign) Hrefs only; card copy untouched. Part of recoupable/chat#1902 (M1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
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. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughPricing CTAs now generate attributed chat URLs for free access. The Pro plan uses Privy-gated checkout, creates a Stripe session through the subscriptions API, handles pending and error states, and redirects to checkout. ChangesPricing checkout flow
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Rework the Pro card CTA from a deep link into chat to a direct Stripe
checkout started on the marketing site (scope change on #1902 M1):
- hooks/useProCheckout.ts: Privy-gated trigger mirroring
useCatalogValuation (login when signed out, auto-resume checkout on
auth), then POST /api/subscriptions/sessions with the Privy bearer and
redirect to the returned Stripe URL.
- lib/checkout/createCheckoutSession.ts: bearer-authed session call
(TDD), mirrors lib/valuation/runValuation.ts.
- components/pricing/ProCheckoutButton.tsx: visually identical button
with busy ("Starting checkout...") and error-with-retry states.
- buildChatUrl: intent param dropped entirely; optional checkout flag
builds the Stripe success URL landing buyers in chat with attribution.
- Free CTAs keep the UTM-only links from the previous commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
lib/copy/pricing.ts (1)
51-51: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDefine the free CTA destination once.
Both locations independently build the same attributed URL, so a campaign change can split the card and bottom CTA behavior.
lib/copy/pricing.ts#L51-L51: export a shared free-pricing chat URL constant and use it for the Plus CTA.app/pricing/page.tsx#L158-L158: import and use that shared constant for the bottom CTA.As per coding guidelines, “Follow DRY principle: no code duplication.”
🤖 Prompt for 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. In `@lib/copy/pricing.ts` at line 51, Define and export a shared free-pricing chat URL constant in lib/copy/pricing.ts near the Plus CTA, then use it for that CTA instead of rebuilding the URL. In app/pricing/page.tsx at line 158, import and use the same constant for the bottom CTA so both destinations remain synchronized.Source: Coding guidelines
🤖 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 `@hooks/useProCheckout.ts`:
- Around line 47-60: The deferred checkout flag in the checkout handler and the
auto-resume useEffect must only survive the login attempt that created it. Clear
pendingCheckout.current when login is cancelled or otherwise fails, and when
authentication becomes idle without a valid deferred request; after a successful
resume, clear it before calling doCheckout. Ensure later authentication changes
cannot trigger checkout without a new user action.
In `@lib/__tests__/buildChatUrl.test.ts`:
- Around line 6-14: Update the expected URLs in the buildChatUrl tests to derive
the host from the siteConfig.appUrl value imported from lib/config.ts, while
preserving the existing query-string assertions for checkout and free URLs.
Remove the hardcoded chat.recoupable.dev brand endpoint from both assertions.
In `@lib/checkout/createCheckoutSession.ts`:
- Around line 35-39: Update createCheckoutSession to validate the parsed
response with a Zod schema before returning data.url, requiring an absolute
Stripe checkout URL rather than accepting any truthy value. Treat schema
validation failures like other unsuccessful responses and preserve the existing
error behavior; add coverage for a malformed 2xx response.
---
Nitpick comments:
In `@lib/copy/pricing.ts`:
- Line 51: Define and export a shared free-pricing chat URL constant in
lib/copy/pricing.ts near the Plus CTA, then use it for that CTA instead of
rebuilding the URL. In app/pricing/page.tsx at line 158, import and use the same
constant for the bottom CTA so both destinations remain synchronized.
🪄 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: 8e671d88-35c8-45d3-996e-173cf22ccce8
📒 Files selected for processing (8)
app/pricing/page.tsxcomponents/pricing/ProCheckoutButton.tsxhooks/useProCheckout.tslib/__tests__/buildChatUrl.test.tslib/buildChatUrl.tslib/checkout/__tests__/createCheckoutSession.test.tslib/checkout/createCheckoutSession.tslib/copy/pricing.ts
| if (!authenticated) { | ||
| pendingCheckout.current = true; | ||
| login(); | ||
| return; | ||
| } | ||
| await doCheckout(); | ||
| } | ||
|
|
||
| // Auto-resume the deferred checkout once the user signs in. | ||
| useEffect(() => { | ||
| if (authenticated && pendingCheckout.current) { | ||
| pendingCheckout.current = false; | ||
| void doCheckout(); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
For @privy-io/react-auth version 3.31.0, how does usePrivy().login() signal a dismissed or failed login, and what is the supported way to clear deferred login intent?
💡 Result:
In @privy-io/react-auth version 3.31.0, the login process is managed via the login method provided by the useLogin hook [1][2]. When a user dismisses the login UI or an error occurs during the authentication flow, the SDK signals this event through the onError callback defined within the useLogin configuration [1][2]. To handle this, you should pass an onError callback when initializing the useLogin hook [1][2]: import { useLogin } from '@privy-io/react-auth'; const { login } = useLogin({ onComplete: (user) => { // Handle successful login }, onError: (error) => { // Handle dismissed login or other errors console.log('Login flow exited or failed:', error); } }); Regarding "deferred login intent," the Privy SDK does not explicitly expose a public API to "clear" an internal deferred intent state. The login flow is generally considered stateless regarding the SDK's internal UI state once the component unmounts or the flow is closed by the user [1][2]. If you are experiencing issues where a previous login attempt is interfering with a new one, the standard practice is to ensure the login UI is fully closed and, if necessary, ensure that any underlying auth state or wallet connections are cleared using the logout method [3] or by manual cleanup of the specific wallet provider (e.g., disconnecting the wallet directly if using a third-party provider) [4].
Citations:
- 1: https://docs.privy.io/authentication/user-authentication/ui-component
- 2: https://privy-c2af3412.mintlify.app/authentication/user-authentication/ui-component
- 3: https://docs.privy.io/authentication/user-authentication/logout
- 4: https://gist.github.com/junhoyeo/7cf818319ef995f98ac8d0a7f246d03d
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locate file =="
git ls-files | rg '(^|/)hooks/useProCheckout\.ts$|package\.json|lockfile|pnpm-lock|yarn.lock|package-lock'
echo "== relevant file =="
if [ -f hooks/useProCheckout.ts ]; then
nl -ba hooks/useProCheckout.ts | sed -n '1,140p'
fi
echo "== package privy versions =="
python3 - <<'PY'
import json, os, re
for name in ["package.json"]:
if os.path.exists(name):
data=json.load(open(name))
deps={}
for k in ["dependencies","devDependencies","peerDependencies"]:
deps.update(data.get(k,{}) or {})
for k,v in sorted(deps.items()):
if "privy" in k.lower() or "`@privy`" in k.lower():
print(k, v)
PY
if [ -f package-lock.json ]; then
node - <<'JS'
const fs=require('fs');
const p=JSON.parse(fs.readFileSync('package-lock.json','utf8'));
const q='node_modules/@privy-io/react-auth';
if (p.packages && p.packages[q]) console.log(JSON.stringify({version:p.packages[q].version, dependencies:p.packages[q].dependencies}, null, 2));
JS
fi
echo "== usages of useProCheckout and privy related login =="
rg -n "useProCheckout|Privy|privy|onError|useLogin|login\\(" -S --glob '!node_modules' --glob '!dist' --glob '!build' .Repository: recoupable/marketing
Length of output: 288
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== relevant file =="
if [ -f hooks/useProCheckout.ts ]; then
awk '{printf "%6d\t%s\n", NR, $0}' hooks/useProCheckout.ts | sed -n '1,160p'
fi
echo "== package.json deps =="
python3 - <<'PY'
import json
from pathlib import Path
p=json.loads(Path('package.json').read_text())
for section in ('dependencies','devDependencies','peerDependencies'):
deps=json.loads(json.dumps(p.get(section,{}) or {}, sort_keys=True))
for k,v in sorted(deps.items()):
if 'privy' in k.lower():
print(section, k, v)
PY
echo "== lockfile privy packages =="
rg -n "`@privy-io/react-auth`|node_modules/@privy-io/react-auth|version:" pnpm-lock.yaml | sed -n '1,120p' || true
echo "== usages of useProCheckout and privy related login =="
rg -n "useProCheckout|Privy|privy|onError|useLogin|useAuthorized|login\\(" -S --glob '!node_modules' --glob '!dist' --glob '!build' .Repository: recoupable/marketing
Length of output: 13800
🌐 Web query:
@privy-io/react-auth 3.31.0 login callback authentication error onPrivyLoginComplete usePrivy login
💡 Result:
The callback function mentioned as onPrivyLoginComplete is not a standard part of the @privy-io/react-auth API. Based on official Privy documentation, the correct way to handle login completion and errors is by passing onComplete and onError callbacks to the useLogin hook or related authentication hooks [1][2]. If you are encountering authentication errors during the login flow, ensure you are using the correct callback pattern: 1. Use the useLogin hook: Do not attempt to rely on a non-existent global or context-based callback named onPrivyLoginComplete [1]. 2. Implement callbacks correctly: tsx import { useLogin } from '`@privy-io/react-auth`'; function LoginButton() { const { login } = useLogin({ onComplete: ({ user, isNewUser, wasAlreadyAuthenticated, loginMethod, loginAccount }) => { console.log('User logged in successfully', user); }, onError: (error) => { console.error('Login failed', error); } }); return <button onClick={login}>Log in</button>; } 3. Check OAuth configuration: If your error occurs specifically during social logins (e.g., Google, Discord), ensure your app's URL scheme is correctly configured in your Privy App Client settings; otherwise, the login may fail silently [1]. 4. Troubleshooting: For specific error codes (e.g., linked_to_another_user), refer to the Privy client-side error documentation [3]. If you are using frameworks like Gatsby or Next.js, ensure the PrivyProvider is initialized correctly, as improper rendering (e.g., using wrapRootElement instead of wrapPageElement in Gatsby) can cause iframe initialization errors [4]. For version 3.31.0 specifically, ensure you are not using deprecated props from earlier versions, as recent updates have moved toward named arguments in these callbacks [5].
Citations:
- 1: https://docs.privy.io/authentication/user-authentication/ui-component
- 2: https://docs.privy.io/authentication/user-authentication/login-methods/sms-whatsapp
- 3: https://docs.privy.io/basics/troubleshooting/error-handling/client-errors
- 4: https://docs.privy.io/basics/troubleshooting/react-frameworks
- 5: https://docs.privy.io/basics/react/advanced/migrating-to-2.0
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== nearby useCatalogValuation for comparison =="
awk '{printf "%6d\t%s\n", NR, $0}' hooks/useCatalogValuation.ts | sed -n '80,120p' || true
echo "== current diff around hooks if available =="
git diff -- hooks/useProCheckout.ts hooks/useCatalogValuation.ts | sed -n '1,220p' || trueRepository: recoupable/marketing
Length of output: 1646
Clear deferred checkout if the login flow does not actually complete.
pendingCheckout.current stays true once login() is called, so any subsequent authentication while this hook is mounted will resume Stripe checkout without another button click. Clear the ref on successful resume, cancellation, or authenticated-idle state, or use an explicit request-id instead of a shared pending flag.
🧰 Tools
🪛 React Doctor (0.9.1)
[error] 56-56: This setter runs after await, so overlapping re-runs of the effect can resolve out of order and write stale state; gate it behind a cancellation/ignore flag or return a cleanup that cancels the work.
In a useEffect whose dependencies can change, guard any setter call that runs after an await behind a cancellation/ignore flag, or return a cleanup that cancels the async work.
(no-set-state-after-await-in-effect)
🤖 Prompt for 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.
In `@hooks/useProCheckout.ts` around lines 47 - 60, The deferred checkout flag in
the checkout handler and the auto-resume useEffect must only survive the login
attempt that created it. Clear pendingCheckout.current when login is cancelled
or otherwise fails, and when authentication becomes idle without a valid
deferred request; after a successful resume, clear it before calling doCheckout.
Ensure later authentication changes cannot trigger checkout without a new user
action.
| expect(buildChatUrl({ checkout: "success", campaign: "pro-trial" })).toBe( | ||
| "https://chat.recoupable.dev/?checkout=success&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trial", | ||
| ); | ||
| }); | ||
|
|
||
| it("builds the free URL with UTMs only when no checkout flag is given", () => { | ||
| expect(buildChatUrl({ campaign: "free" })).toBe( | ||
| "https://chat.recoupable.dev/?utm_source=marketing&utm_medium=pricing&utm_campaign=free", | ||
| ); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Derive the expected chat host from config.
Both assertions hardcode the brand endpoint. Import siteConfig and build the expected URL from siteConfig.appUrl so the test remains valid across configured environments. As per coding guidelines, “Never hardcode brand values; import from lib/config.ts.”
🤖 Prompt for 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.
In `@lib/__tests__/buildChatUrl.test.ts` around lines 6 - 14, Update the expected
URLs in the buildChatUrl tests to derive the host from the siteConfig.appUrl
value imported from lib/config.ts, while preserving the existing query-string
assertions for checkout and free URLs. Remove the hardcoded chat.recoupable.dev
brand endpoint from both assertions.
Source: Coding guidelines
| const data: CheckoutSessionResponse | null = await res.json().catch(() => null); | ||
| if (!res.ok || !data?.url) { | ||
| throw new Error(data?.error ?? `couldn't start checkout (${res.status})`); | ||
| } | ||
| return data.url; |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
fd -HI '^package\.json$' -x rg -n '"zod"' {}Repository: recoupable/marketing
Length of output: 1034
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "## locate files"
fd -HI '^createCheckoutSession\.ts$|^checkoutSession|^CheckoutSession' . || true
echo "## git ls target"
git ls-files | rg '(^|/)createCheckoutSession\.ts$|checkout|package\.json$' || true
echo "## package zips"
fd -HI '^package\.json$' -x sh -c 'echo "=== $1 ==="; rg -n "\"zod\"|name|dependencies|devDependencies" "$1" || true' sh {}
echo "## inspect target"
ast-grep outline lib/checkout/createCheckoutSession.ts --view compact || true
sed -n '1,220p' lib/checkout/createCheckoutSession.tsRepository: recoupable/marketing
Length of output: 50377
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "## top-level package.json zod"
sed -n '1,120p' package.json | rg -n "zod|dependencies|devDependencies|name|version" -C 2 || true
echo "## locate createCheckoutSession"
fd -HI '^createCheckoutSession\.ts$' .
FILE="$(fd -HI '^createCheckoutSession\.ts$' . | head -n 1 || true)"
if [ -n "${FILE:-}" ]; then
echo "## file: $FILE"
wc -l "$FILE"
ast-grep outline "$FILE" --view compact || true
sed -n '1,160p' "$FILE"
fi
echo "## tests referencing createCheckoutSession / checkoutSession / url"
rg -n "createCheckoutSession|checkoutSession|window\.location|location\.href|malformed|2xx|200|url" --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' | head -n 200 || true
echo "## Zod URL transform/brand helpers"
sed -n '1,220p' "$FILE" | rg -n "URL|z\.refine|z\.url|brand|Url" -C 3 || trueRepository: recoupable/marketing
Length of output: 2477
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "## all local test/spec files"
git ls-files '*.{test,spec}.{ts,tsx,js,jsx}' | sed -n '1,120p'
echo "## all local test content with createCheckoutSession/checkout"
rg -n "createCheckoutSession|checkoutSession|couldn't start checkout|session" --glob '*.{test,spec}.{ts,tsx,js,jsx}' --glob '!node_modules/**' || true
echo "## Node runtime probe for current conditional behavior"
node - <<'JS'
const samples = [
{ ok: true, data: { url: relative, status: "not-an-url" } },
{ ok: true, data: { url: relative, status: "not-an-url" } },
{ ok: false, data: { url: "https://host/" }, status: 500 },
{ ok: true, data: { error: "bad" }, status: 200 },
{ ok: true, data: null },
]
for (const {ok, data, status} of samples) {
const threw = (() => {
try {
if (!ok || !(data?.url)) throw new Error(data?.error ?? `couldn't start checkout (${status})`)
return data.url
} catch (e) {
return `THREW ${e.message}`
}
})()
console.log(JSON.stringify({ ok, data, status, result: throw }))
}
// URL constructor is stricter than truthiness; `new URL(...)` can be tested in Node.
for (const value of ["", " ", "not an url", "mailto:malicious", "https://host/", "/local?path="]) {
try {
console.log(value, "=>", new URL(value).href)
} catch (e) {
console.log(value, "=> throws", e.name)
}
}
JSRepository: recoupable/marketing
Length of output: 353
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "## Node runtime probe for current falsy-check behavior"
node - <<'JS'
const samples = [
{ ok: true, data: { url: "" }, status: 200 },
{ ok: true, data: { url: "not-an-url" }, status: 200 },
{ ok: false, data: null, status: 500 },
{ ok: true, data: null, status: 200 },
{ ok: true, data: { error: "bad" }, status: 200 },
]
for (const {ok, data, status} of samples) {
try {
const returned = (!ok || !(data?.url))
? (() => { throw new Error(data?.error ?? `couldn't start checkout (${status})`) })()
: data.url
console.log(JSON.stringify({ ok, data, status, returned }))
} catch (e) {
console.log(JSON.stringify({ ok, data, status, threw: e.message }))
}
}
for (const value of ["", " ", "not an url", "mailto:malicious", "https://host/", "/local?path="]) {
try {
console.log(value, "=>", new URL(value).href)
} catch (e) {
console.log(value, "=> throws", e.name)
}
}
JSRepository: recoupable/marketing
Length of output: 767
Validate the checkout response before returning the URL.
A 2xx response with any truthy url, including malformed values, is returned by createCheckoutSession; the caller can then assign it to window.location.href. Parse the response Zod-side with a schema requiring an absolute Stripe URL, and add coverage for a malformed 2xx response.
🤖 Prompt for 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.
In `@lib/checkout/createCheckoutSession.ts` around lines 35 - 39, Update
createCheckoutSession to validate the parsed response with a Zod schema before
returning data.url, requiring an absolute Stripe checkout URL rather than
accepting any truthy value. Treat schema validation failures like other
unsuccessful responses and preserve the existing error behavior; add coverage
for a malformed 2xx response.
Source: Coding guidelines
There was a problem hiding this comment.
6 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/copy/pricing.ts">
<violation number="1" location="lib/copy/pricing.ts:71">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**
The PR description's before/after table claims the Pro plan's "Start 30-day trial" CTA should link to a UTM-attributed href (`?intent=pro-trial&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trial`), but the diff removes `ctaHref` entirely from that plan instead. This leaves a contradiction between the documented behavior and the actual implementation. Please align the PR description with the checkout-based approach the code now uses, or update the code to match the documented href if that was the intended behavior.</violation>
</file>
<file name="lib/__tests__/buildChatUrl.test.ts">
<violation number="1" location="lib/__tests__/buildChatUrl.test.ts:5">
P3: Custom agent: **Flag AI Slop and Fabricated Changes**
The PR description documents the helper as `buildChatUrl({ intent?, campaign })` with `intent=pro-trial` in the before/after table, but both the implementation (`lib/buildChatUrl.ts`) and the tests consistently use `checkout` as the parameter name instead. Updating the PR description to reference `checkout` rather than `intent` prevents confusion for reviewers and future readers who rely on the description to understand the change.</violation>
</file>
<file name="lib/checkout/createCheckoutSession.ts">
<violation number="1" location="lib/checkout/createCheckoutSession.ts:4">
P2: Malformed successful responses can be returned as checkout URLs because `CheckoutSessionResponse` is erased at runtime. Parse the request inputs and response envelope with Zod before sending or returning values, as required for this checkout boundary.</violation>
</file>
<file name="hooks/useProCheckout.ts">
<violation number="1" location="hooks/useProCheckout.ts:45">
P3: A click while Privy is initializing is silently discarded, because `startCheckout` returns when `ready` is false while the CTA remains enabled. Disable the CTA until readiness (or retain the click and resume it) so visitors do not need to click again.</violation>
<violation number="2" location="hooks/useProCheckout.ts:52">
P1: An already signed-in customer can start a second Pro subscription: the authenticated path always calls `doCheckout()`, while the subscriptions-session API creates a new Stripe subscription without checking whether the account already has an active subscription or trial. A backend entitlement/idempotency check (and a corresponding UI state for existing subscribers) would prevent duplicate subscriptions and charges.</violation>
<violation number="3" location="hooks/useProCheckout.ts:57">
P2: pendingCheckout.current is only cleared when the auto-resume effect fires on a successful login. If the user cancels the Privy login modal or authentication happens through another path while this hook stays mounted, the ref remains true and the next authenticated state change will silently kick off Stripe checkout without another click. Consider clearing the ref on cancellation or using a request-id keyed to the specific startCheckout invocation instead of a shared boolean flag.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| login(); | ||
| return; | ||
| } | ||
| await doCheckout(); |
There was a problem hiding this comment.
P1: An already signed-in customer can start a second Pro subscription: the authenticated path always calls doCheckout(), while the subscriptions-session API creates a new Stripe subscription without checking whether the account already has an active subscription or trial. A backend entitlement/idempotency check (and a corresponding UI state for existing subscribers) would prevent duplicate subscriptions and charges.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At hooks/useProCheckout.ts, line 52:
<comment>An already signed-in customer can start a second Pro subscription: the authenticated path always calls `doCheckout()`, while the subscriptions-session API creates a new Stripe subscription without checking whether the account already has an active subscription or trial. A backend entitlement/idempotency check (and a corresponding UI state for existing subscribers) would prevent duplicate subscriptions and charges.</comment>
<file context>
@@ -0,0 +1,65 @@
+ login();
+ return;
+ }
+ await doCheckout();
+ }
+
</file context>
| ], | ||
| cta: "Start 30-day trial", | ||
| ctaHref: siteConfig.appUrl, | ||
| highlighted: true, |
There was a problem hiding this comment.
P2: Custom agent: Flag AI Slop and Fabricated Changes
The PR description's before/after table claims the Pro plan's "Start 30-day trial" CTA should link to a UTM-attributed href (?intent=pro-trial&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trial), but the diff removes ctaHref entirely from that plan instead. This leaves a contradiction between the documented behavior and the actual implementation. Please align the PR description with the checkout-based approach the code now uses, or update the code to match the documented href if that was the intended behavior.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/copy/pricing.ts, line 71:
<comment>The PR description's before/after table claims the Pro plan's "Start 30-day trial" CTA should link to a UTM-attributed href (`?intent=pro-trial&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trial`), but the diff removes `ctaHref` entirely from that plan instead. This leaves a contradiction between the documented behavior and the actual implementation. Please align the PR description with the checkout-based approach the code now uses, or update the code to match the documented href if that was the intended behavior.</comment>
<file context>
@@ -68,7 +69,6 @@ export const pricingCopy = {
"API access",
],
cta: "Start 30-day trial",
- ctaHref: buildChatUrl({ intent: "pro-trial", campaign: "pro-trial" }),
highlighted: true,
</file context>
| import { siteConfig } from "@/lib/config"; | ||
|
|
||
| /** Raw `POST /api/subscriptions/sessions` envelope. */ | ||
| type CheckoutSessionResponse = { |
There was a problem hiding this comment.
P2: Malformed successful responses can be returned as checkout URLs because CheckoutSessionResponse is erased at runtime. Parse the request inputs and response envelope with Zod before sending or returning values, as required for this checkout boundary.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/checkout/createCheckoutSession.ts, line 4:
<comment>Malformed successful responses can be returned as checkout URLs because `CheckoutSessionResponse` is erased at runtime. Parse the request inputs and response envelope with Zod before sending or returning values, as required for this checkout boundary.</comment>
<file context>
@@ -0,0 +1,40 @@
+import { siteConfig } from "@/lib/config";
+
+/** Raw `POST /api/subscriptions/sessions` envelope. */
+type CheckoutSessionResponse = {
+ id?: string;
+ url?: string;
</file context>
|
|
||
| // Auto-resume the deferred checkout once the user signs in. | ||
| useEffect(() => { | ||
| if (authenticated && pendingCheckout.current) { |
There was a problem hiding this comment.
P2: pendingCheckout.current is only cleared when the auto-resume effect fires on a successful login. If the user cancels the Privy login modal or authentication happens through another path while this hook stays mounted, the ref remains true and the next authenticated state change will silently kick off Stripe checkout without another click. Consider clearing the ref on cancellation or using a request-id keyed to the specific startCheckout invocation instead of a shared boolean flag.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At hooks/useProCheckout.ts, line 57:
<comment>pendingCheckout.current is only cleared when the auto-resume effect fires on a successful login. If the user cancels the Privy login modal or authentication happens through another path while this hook stays mounted, the ref remains true and the next authenticated state change will silently kick off Stripe checkout without another click. Consider clearing the ref on cancellation or using a request-id keyed to the specific startCheckout invocation instead of a shared boolean flag.</comment>
<file context>
@@ -0,0 +1,65 @@
+
+ // Auto-resume the deferred checkout once the user signs in.
+ useEffect(() => {
+ if (authenticated && pendingCheckout.current) {
+ pendingCheckout.current = false;
+ void doCheckout();
</file context>
| import { buildChatUrl } from "@/lib/buildChatUrl"; | ||
|
|
||
| describe("buildChatUrl", () => { | ||
| it("builds the checkout success URL with UTM attribution", () => { |
There was a problem hiding this comment.
P3: Custom agent: Flag AI Slop and Fabricated Changes
The PR description documents the helper as buildChatUrl({ intent?, campaign }) with intent=pro-trial in the before/after table, but both the implementation (lib/buildChatUrl.ts) and the tests consistently use checkout as the parameter name instead. Updating the PR description to reference checkout rather than intent prevents confusion for reviewers and future readers who rely on the description to understand the change.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/__tests__/buildChatUrl.test.ts, line 5:
<comment>The PR description documents the helper as `buildChatUrl({ intent?, campaign })` with `intent=pro-trial` in the before/after table, but both the implementation (`lib/buildChatUrl.ts`) and the tests consistently use `checkout` as the parameter name instead. Updating the PR description to reference `checkout` rather than `intent` prevents confusion for reviewers and future readers who rely on the description to understand the change.</comment>
<file context>
@@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest";
- it("builds the pro-trial URL with intent plus UTM attribution", () => {
- expect(buildChatUrl({ intent: "pro-trial", campaign: "pro-trial" })).toBe(
- "https://chat.recoupable.dev/?intent=pro-trial&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trial",
+ it("builds the checkout success URL with UTM attribution", () => {
+ expect(buildChatUrl({ checkout: "success", campaign: "pro-trial" })).toBe(
+ "https://chat.recoupable.dev/?checkout=success&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trial",
</file context>
| } | ||
|
|
||
| async function startCheckout() { | ||
| if (!ready || isPending) return; |
There was a problem hiding this comment.
P3: A click while Privy is initializing is silently discarded, because startCheckout returns when ready is false while the CTA remains enabled. Disable the CTA until readiness (or retain the click and resume it) so visitors do not need to click again.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At hooks/useProCheckout.ts, line 45:
<comment>A click while Privy is initializing is silently discarded, because `startCheckout` returns when `ready` is false while the CTA remains enabled. Disable the CTA until readiness (or retain the click and resume it) so visitors do not need to click again.</comment>
<file context>
@@ -0,0 +1,65 @@
+ }
+
+ async function startCheckout() {
+ if (!ready || isPending) return;
+ // Gate: signed out opens Privy and defers checkout to a successful login.
+ if (!authenticated) {
</file context>
Preview verificationPreview: https://marketing-5bpitfi0z-recoup.vercel.app — confirmed built from this PR's head commit Results
13/13 pass. Rows 10 to 12 were run with The end-to-end path measured here is pricing page click, Privy modal in place, code entry, Stripe with no intermediate stop on Screenshots1. Pro card with the new button CTA (visually identical to the previous anchor) 2. Signed-out click opens Privy in place on the marketing site (pricing cards blurred behind the modal, no navigation to chat.recoupable.dev) 3. Stripe checkout reached from the pricing page (30 days free, then $99.00/month from August 28, 2026) Notes for reviewers
Part of recoupable/chat#1902. |



What
The Pro card's "Start 30-day trial" CTA now starts Stripe checkout directly from the marketing site instead of deep-linking into chat. Clicking it signs the visitor in with Privy (if needed), creates a checkout session against recoup-api, and redirects straight to Stripe. The free CTAs (Plus card "Start free" and the bottom "Get started free") keep the UTM-attributed links from the first commit. The
intent=pro-trialparam approach is dropped entirely.Why (decision)
Scope change on 1902 M1, decided after the first cut of this PR: sending a buyer with checkout intent through the chat homepage adds redirects and a cross-domain session hop where intent gets lost. Checkout can start where the intent is expressed because the enablers are already in place (verified in code):
contexts/PrivyAuthProvider.tsx), so a sign-in here is a real session.POST /api/subscriptions/sessionsaccepts a Privy bearer withAccess-Control-Allow-Origin: *and an OPTIONS handler (api/app/api/subscriptions/sessions/route.ts); request{successUrl}, response{id, url}.api/lib/privy/getOrCreateAccountIdByAuthToken.ts), so first-time signups work.How it works
hooks/useProCheckout.ts: Privy-gated trigger mirroringuseCatalogValuation(signed out: open Privy, auto-resume checkout once authenticated), thengetAccessToken->POST ${siteConfig.apiUrl}/subscriptions/sessions->window.location.href = url.lib/checkout/createCheckoutSession.ts: bearer-authed session call, mirrorslib/valuation/runValuation.ts. TDD (4 tests).components/pricing/ProCheckoutButton.tsx: visually identical to the anchor CTAs (same classes, same copy), busy state "Starting checkout...", visible error message with retry on failure.lib/buildChatUrl.ts:intentreplaced by an optionalcheckoutflag; builds the Stripe success URL so the buyer lands in the app after paying with attribution preserved:https://chat.recoupable.dev/?checkout=success&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trialCTA summary
https://chat.recoupable.dev/?checkout=success&utm_source=marketing&utm_medium=pricing&utm_campaign=pro-trialhttps://chat.recoupable.dev/?utm_source=marketing&utm_medium=pricing&utm_campaign=freehttps://chat.recoupable.dev/?utm_source=marketing&utm_medium=pricing&utm_campaign=freeHow to test
/pricingsigned out.Notes
PricingCtaLinkwrapper on the Pro card no longer applies since the Pro CTA is a checkout button now; whichever merges second resolves. The free CTAs keep feat: custom analytics events for pricing CTAs and valuation funnel #57's wrapper.pnpm exec tsc --noEmitclean,pnpm buildpasses; prerendered/pricingHTML verified (nointentparam anywhere, 2x free UTM links, Pro button markup present).🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Tests