Skip to content

Commit 117f947

Browse files
authored
feat(claw): prompt users to purchase credits when selecting a paid model (#1324)
## Summary - When a user without credits selects a paid model during KiloClaw onboarding, an inline credit purchase nudge now appears below the model picker. It offers preset amounts ($10, $20, $50), a Stripe checkout CTA, and a "Use the free model instead" fallback. - After completing payment, the user is automatically redirected back to `/claw` with their model selection preserved via the existing `payment-return-url` cookie mechanism, a success toast is shown, and provisioning starts automatically. - Free model detection uses the shared `isFreeModel()` helper, so selecting any free model (not just Kilo Auto: Free) from the 500+ dropdown also hides the nudge. ## Verification - [x] `pnpm typecheck` — passes across all packages - [x] `pnpm format:check` (oxfmt) — passes - [x] `pnpm lint` — passes (oxlint + eslint) ## Visual Changes https://github.com/user-attachments/assets/af69ed1b-46ec-4b50-8a02-8d0549216ec5 ## Reviewer Notes - The Stripe cancel URL still redirects to `/profile` (hardcoded in `getStripeTopUpCheckoutUrl`). A follow-up could pass a custom cancel URL so users return to `/claw` on cancellation too. - The auto-provision effect uses a `useRef` guard (`hasAutoProvisioned`) to prevent double-firing in React strict mode or on re-renders.
2 parents 1e4e23c + 7d29bff commit 117f947

5 files changed

Lines changed: 191 additions & 14 deletions

File tree

src/app/(app)/claw/components/CreateInstanceCard.tsx

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useEffect, useMemo, useRef, useState } from 'react';
44
import { useFeatureFlagVariantKey, usePostHog } from 'posthog-js/react';
55
import { useQuery } from '@tanstack/react-query';
6+
import { useSearchParams } from 'next/navigation';
67
import { toast } from 'sonner';
78
import type { useKiloClawMutations } from '@/hooks/useKiloClaw';
89
import { useKiloClawLatestVersion, useKiloClawMyPin } from '@/hooks/useKiloClaw';
@@ -11,10 +12,12 @@ import { useTRPC } from '@/lib/trpc/utils';
1112
import type { ModelOption } from '@/components/shared/ModelCombobox';
1213
import { useUser } from '@/hooks/useUser';
1314
import { KILO_AUTO_FRONTIER_MODEL, KILO_AUTO_FREE_MODEL } from '@/lib/kilo-auto-model';
15+
import { isFreeModel } from '@/lib/models';
1416
import { Button } from '@/components/ui/button';
1517
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
1618
import { getCreateModelOptions } from './modelSupport';
1719
import { AutoModelPicker } from './AutoModelPicker';
20+
import { CreditsNudge } from './CreditsNudge';
1821

1922
type ClawMutations = ReturnType<typeof useKiloClawMutations>;
2023

@@ -30,6 +33,7 @@ export function CreateInstanceCard({
3033
useFeatureFlagVariantKey('button-vs-card');
3134
const posthog = usePostHog();
3235
const trpc = useTRPC();
36+
const searchParams = useSearchParams();
3337
const { data: billingStatus } = useQuery(trpc.kiloclaw.getBillingStatus.queryOptions());
3438
const { data: user, isLoading: isLoadingUser } = useUser();
3539
const { data: modelsData, isLoading: isLoadingModels } = useOpenRouterModels();
@@ -74,16 +78,60 @@ export function CreateInstanceCard({
7478
);
7579

7680
const hasCredits = (user?.total_microdollars_acquired ?? 0) > 0;
81+
const isPaymentReturn = searchParams.get('payment') === 'success';
82+
const hasAutoProvisioned = useRef(false);
7783

7884
useEffect(() => {
7985
if (hasAppliedDefault.current || selectedModel !== '' || modelOptions.length === 0) return;
8086
if (isLoadingUser) return;
87+
88+
// If returning from a checkout flow, restore the previously-selected model
89+
const modelParam = searchParams.get('model');
90+
if (modelParam && modelOptions.some(m => m.id === modelParam)) {
91+
setSelectedModel(modelParam);
92+
hasAppliedDefault.current = true;
93+
return;
94+
}
95+
8196
const defaultId = hasCredits ? KILO_AUTO_FRONTIER_MODEL.id : KILO_AUTO_FREE_MODEL.id;
8297
if (modelOptions.some(m => m.id === defaultId)) {
8398
setSelectedModel(defaultId);
8499
hasAppliedDefault.current = true;
85100
}
86-
}, [modelOptions, hasCredits, selectedModel, isLoadingUser]);
101+
}, [modelOptions, hasCredits, selectedModel, isLoadingUser, searchParams]);
102+
103+
// After returning from a successful credit purchase, show a toast and
104+
// auto-start provisioning so the user doesn't have to click again.
105+
useEffect(() => {
106+
if (!isPaymentReturn || hasAutoProvisioned.current) return;
107+
if (!selectedModel || isLoadingModels || isLoadingProvisionTargetVersion) return;
108+
if (hasProvisionTargetError) return;
109+
110+
hasAutoProvisioned.current = true;
111+
toast.success('Payment processed — setting up your instance!');
112+
113+
posthog?.capture('claw_create_instance_clicked', {
114+
selected_model: selectedModel,
115+
auto_provision_after_payment: true,
116+
});
117+
118+
mutations.provision.mutate(
119+
{ kilocodeDefaultModel: `kilocode/${selectedModel}` },
120+
{
121+
onSuccess: () => onProvisionStart?.(),
122+
onError: err => toast.error(`Failed to create: ${err.message}`),
123+
}
124+
);
125+
}, [
126+
isPaymentReturn,
127+
selectedModel,
128+
isLoadingModels,
129+
isLoadingProvisionTargetVersion,
130+
hasProvisionTargetError,
131+
mutations.provision,
132+
posthog,
133+
onProvisionStart,
134+
]);
87135

88136
function handleCreate() {
89137
if (hasProvisionTargetError) {
@@ -129,6 +177,8 @@ export function CreateInstanceCard({
129177
);
130178
}
131179

180+
const needsCredits = !hasCredits && selectedModel !== '' && !isFreeModel(selectedModel);
181+
132182
return (
133183
<Card>
134184
<CardHeader>
@@ -158,15 +208,22 @@ export function CreateInstanceCard({
158208
}
159209
/>
160210

161-
<div className="flex">
162-
<Button
163-
onClick={handleCreate}
164-
disabled={mutations.provision.isPending || !selectedModel || isLoadingUser}
165-
className="grow bg-emerald-600 text-white hover:bg-emerald-700"
166-
>
167-
{mutations.provision.isPending ? 'Setting up...' : 'Get Started'}
168-
</Button>
169-
</div>
211+
{needsCredits ? (
212+
<CreditsNudge
213+
selectedModel={selectedModel}
214+
onSwitchToFree={() => setSelectedModel(KILO_AUTO_FREE_MODEL.id)}
215+
/>
216+
) : (
217+
<div className="flex">
218+
<Button
219+
onClick={handleCreate}
220+
disabled={mutations.provision.isPending || !selectedModel}
221+
className="grow bg-emerald-600 text-white hover:bg-emerald-700"
222+
>
223+
{mutations.provision.isPending ? 'Setting up...' : 'Get Started'}
224+
</Button>
225+
</div>
226+
)}
170227
</CardContent>
171228
</Card>
172229
);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
'use server';
2+
import 'server-only';
3+
4+
import { setPaymentReturnUrl } from '@/lib/payment-return-url';
5+
6+
export async function setClawReturnUrl(modelId: string): Promise<void> {
7+
await setPaymentReturnUrl(`/claw?model=${encodeURIComponent(modelId)}&payment=success`);
8+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
'use client';
2+
3+
import { useRef, useState } from 'react';
4+
import { CreditCard, Gift, TriangleAlert } from 'lucide-react';
5+
import { cn } from '@/lib/utils';
6+
import { Button } from '@/components/ui/button';
7+
import { setClawReturnUrl } from './CreditsNudge.actions';
8+
9+
const AMOUNT_OPTIONS = [10, 20, 50] as const;
10+
11+
export function CreditsNudge({
12+
selectedModel,
13+
onSwitchToFree,
14+
}: {
15+
selectedModel: string;
16+
onSwitchToFree: () => void;
17+
}) {
18+
const [selectedAmount, setSelectedAmount] = useState<number>(10);
19+
// Plain boolean that stays true once set — the form submit navigates
20+
// away, so we never need to reset it. Using useTransition would flicker
21+
// because submitting resets when the server action finishes, before the
22+
// browser has actually navigated.
23+
const [submitting, setSubmitting] = useState(false);
24+
const formRef = useRef<HTMLFormElement>(null);
25+
26+
async function handlePurchase() {
27+
setSubmitting(true);
28+
await setClawReturnUrl(selectedModel);
29+
formRef.current?.submit();
30+
}
31+
32+
return (
33+
<div className="rounded-lg border border-yellow-500/40 bg-yellow-500/5 p-4">
34+
<div className="mb-1 flex items-center gap-2">
35+
<TriangleAlert className="h-5 w-5 shrink-0 text-yellow-400" />
36+
<span className="text-sm font-bold">You need credits to use this model</span>
37+
</div>
38+
<p className="text-muted-foreground mb-4 text-sm">
39+
Add a small balance and your bot will be ready to go.
40+
</p>
41+
42+
{/* Amount selector */}
43+
<div className="mb-3 grid grid-cols-3 gap-2">
44+
{AMOUNT_OPTIONS.map(amount => (
45+
<button
46+
key={amount}
47+
type="button"
48+
disabled={submitting}
49+
onClick={() => setSelectedAmount(amount)}
50+
className={cn(
51+
'rounded-lg border py-2.5 text-sm font-medium transition-colors',
52+
amount === selectedAmount
53+
? 'border-blue-500 bg-blue-500/10 text-blue-300'
54+
: 'border-border bg-background hover:bg-accent'
55+
)}
56+
>
57+
${amount}
58+
</button>
59+
))}
60+
</div>
61+
62+
{/* Hidden form for POST to /payments/topup */}
63+
<form
64+
ref={formRef}
65+
action={`/payments/topup?amount=${selectedAmount}&cancel-path=${encodeURIComponent('/claw')}`}
66+
method="post"
67+
className="hidden"
68+
/>
69+
70+
{/* Purchase CTA */}
71+
<Button
72+
type="button"
73+
onClick={handlePurchase}
74+
disabled={submitting}
75+
className="w-full bg-emerald-600 py-5 text-white hover:bg-emerald-700"
76+
>
77+
<CreditCard className="h-4 w-4" />
78+
{submitting ? 'Redirecting...' : `Add $${selectedAmount} & get started`}
79+
</Button>
80+
81+
{/* Divider */}
82+
<div className="my-3 flex items-center gap-3">
83+
<hr className="grow opacity-30" />
84+
<span className="text-muted-foreground text-xs">or</span>
85+
<hr className="grow opacity-30" />
86+
</div>
87+
88+
{/* Free model fallback */}
89+
<Button
90+
type="button"
91+
variant="outline"
92+
onClick={onSwitchToFree}
93+
disabled={submitting}
94+
className="w-full py-5"
95+
>
96+
<Gift className="h-4 w-4" />
97+
Use the Kilo Auto: Free model instead
98+
</Button>
99+
</div>
100+
);
101+
}

src/app/payments/topup/route.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
33
import { getUserFromAuth } from '@/lib/user.server';
44
import { getStripeTopUpCheckoutUrl } from '@/lib/stripe';
55
import { MAXIMUM_TOP_UP_AMOUNT, MINIMUM_TOP_UP_AMOUNT } from '@/lib/constants';
6+
import { isValidReturnUrl } from '@/lib/payment-return-url';
67
import { captureException } from '@sentry/nextjs';
78
import { getOrCreateStripeCustomerIdForOrganization } from '@/lib/organizations/organization-billing';
89

@@ -67,12 +68,16 @@ export async function POST(request: NextRequest): Promise<NextResponse<unknown>>
6768
await getOrCreateStripeCustomerIdForOrganization(organizationId)
6869
: currentUser.stripe_customer_id;
6970

71+
const cancelPathRaw = searchParams.get('cancel-path');
72+
const cancelPath = cancelPathRaw && isValidReturnUrl(cancelPathRaw) ? cancelPathRaw : null;
73+
7074
const url = await getStripeTopUpCheckoutUrl(
7175
currentUser.id,
7276
stripeCustomerId,
7377
validationResult.amount as number,
7478
origin,
75-
organizationId
79+
organizationId,
80+
cancelPath
7681
);
7782

7883
if (!url) {

src/lib/stripe.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -993,7 +993,9 @@ export async function getStripeTopUpCheckoutUrl(
993993
stripeCustomerId: User['stripe_customer_id'],
994994
amount: number,
995995
origin: string = 'web',
996-
organizationId?: string | null
996+
organizationId?: string | null,
997+
/** Optional internal path to redirect to when the user cancels checkout. */
998+
cancelPath?: string | null
997999
): Promise<string | null> {
9981000
const line_items = amount
9991001
? [
@@ -1016,9 +1018,13 @@ export async function getStripeTopUpCheckoutUrl(
10161018
];
10171019

10181020
const isOrganizationTopUp = Boolean(organizationId);
1019-
let cancelUrl = `${APP_URL}/profile?payment_status=topup_cancelled&origin=${origin}`;
1020-
if (isOrganizationTopUp) {
1021+
let cancelUrl: string;
1022+
if (cancelPath) {
1023+
cancelUrl = `${APP_URL}${cancelPath}`;
1024+
} else if (isOrganizationTopUp) {
10211025
cancelUrl = `${APP_URL}/organizations/${organizationId}?${TOPUP_CANCELED_QUERY_STRING_KEY}=true`;
1026+
} else {
1027+
cancelUrl = `${APP_URL}/profile?payment_status=topup_cancelled&origin=${origin}`;
10221028
}
10231029

10241030
const rewardfulReferral = await getRewardfulReferral();

0 commit comments

Comments
 (0)