Skip to content

Commit 8a4c05a

Browse files
improve upsell behaviour during onboarding
1 parent dcaae5e commit 8a4c05a

4 files changed

Lines changed: 155 additions & 119 deletions

File tree

packages/web/src/__mocks__/prisma.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const MOCK_USER_WITH_ACCOUNTS: User & { accounts: Account[] } = {
4040
updatedAt: new Date(),
4141
hashedPassword: null,
4242
emailVerified: null,
43+
lastActiveAt: null,
4344
image: null,
4445
sessionVersion: 0,
4546
accounts: [],

packages/web/src/app/onboard/components/trialStep.tsx

Lines changed: 51 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
"use client";
22

33
import { useCallback, useEffect, useRef, useState } from "react";
4-
import { useRouter } from "next/navigation";
5-
import { Loader2 } from "lucide-react";
6-
import { Button } from "@/components/ui/button";
4+
import { useRouter, useSearchParams } from "next/navigation";
75
import { LoadingButton } from "@/components/ui/loading-button";
86
import { Skeleton } from "@/components/ui/skeleton";
97
import { completeOnboarding } from "@/actions";
@@ -66,28 +64,64 @@ export function TrialStepSubtitle() {
6664

6765
interface TrialStepProps {
6866
memberCount: number;
67+
stepIndex: number;
6968
}
7069

71-
export function TrialStep({ memberCount }: TrialStepProps) {
70+
export function TrialStep({ memberCount, stepIndex }: TrialStepProps) {
7271
const { data: offers, isPending, isError } = useOffers();
7372
const { toast } = useToast();
7473
const router = useRouter();
74+
const searchParams = useSearchParams();
7575
const captureEvent = useCaptureEvent();
7676
const [billingInterval, setBillingInterval] = useState<BillingInterval>("year");
7777
const [isPrimaryLoading, setIsPrimaryLoading] = useState(false);
7878
const [isSkipLoading, setIsSkipLoading] = useState(false);
7979

80+
const [isReturningFromCheckoutSuccess, setIsReturningFromCheckoutSuccess] = useState(searchParams.get('checkout') === 'success');
81+
8082
// Fire-once when offers resolve, so isTrialEligible is reliable and react-query
8183
// refetches don't cause duplicate views.
8284
const hasCapturedViewRef = useRef(false);
8385
useEffect(() => {
86+
if (isReturningFromCheckoutSuccess) {
87+
return;
88+
}
8489
if (offers && !hasCapturedViewRef.current) {
8590
hasCapturedViewRef.current = true;
8691
captureEvent('wa_onboard_trial_step_viewed', {
8792
isTrialEligible: offers.trial.eligible,
8893
});
8994
}
90-
}, [offers, captureEvent]);
95+
}, [offers, captureEvent, isReturningFromCheckoutSuccess]);
96+
97+
// Post-checkout: complete onboarding, then forward `session_id` to `/` so
98+
// CheckoutReturnHandler (mounted in the (app) layout) can fire the activation
99+
// dialog. We defer completeOnboarding until after Stripe actually returns
100+
// success — abandoning checkout leaves the user on /onboard to retry.
101+
const hasHandledReturnRef = useRef(false);
102+
useEffect(() => {
103+
if (!isReturningFromCheckoutSuccess || hasHandledReturnRef.current) {
104+
return;
105+
}
106+
hasHandledReturnRef.current = true;
107+
108+
const sessionId = searchParams.get('session_id');
109+
void (async () => {
110+
const result = await completeOnboarding();
111+
if (isServiceError(result)) {
112+
toast({
113+
description: `Failed to complete onboarding: ${result.message}`,
114+
variant: "destructive",
115+
});
116+
setIsReturningFromCheckoutSuccess(false);
117+
return;
118+
}
119+
const dest = sessionId
120+
? `/?session_id=${encodeURIComponent(sessionId)}`
121+
: "/";
122+
router.push(dest);
123+
})();
124+
}, [isReturningFromCheckoutSuccess, searchParams, router, toast]);
91125

92126
const onSkipCheckout = useCallback(async () => {
93127
setIsSkipLoading(true);
@@ -106,39 +140,24 @@ export function TrialStep({ memberCount }: TrialStepProps) {
106140
const onCheckout = useCallback(async (requestTrial: boolean) => {
107141
setIsPrimaryLoading(true);
108142

109-
// Mark onboarding complete first so the post-checkout return lands inside
110-
// the (app) layout where CheckoutReturnHandler can fire. Otherwise the
111-
// layout's !isOnboarded guard would bounce them back to /onboard.
112-
const completeResult = await completeOnboarding();
113-
if (isServiceError(completeResult)) {
114-
toast({
115-
description: `Failed to complete onboarding: ${completeResult.message}`,
116-
variant: "destructive",
117-
});
118-
setIsPrimaryLoading(false);
119-
return;
120-
}
121-
122143
const checkoutResult = await createCheckoutSession({
123144
source: "onboard",
124145
requestTrial,
125146
interval: billingInterval,
126-
returnPath: "/",
147+
returnPath: `/onboard?step=${stepIndex}`,
127148
});
128149

129150
if (isServiceError(checkoutResult)) {
130151
toast({
131152
description: `Failed to start checkout: ${checkoutResult.message}`,
132153
variant: "destructive",
133154
});
134-
// Onboarding is already marked complete — send them to the app where
135-
// they can retry the upgrade from /settings/license.
136-
router.push("/");
155+
setIsPrimaryLoading(false);
137156
return;
138157
}
139158

140159
window.location.assign(checkoutResult.url);
141-
}, [billingInterval, router, toast]);
160+
}, [billingInterval, stepIndex, toast]);
142161

143162
if (isPending) {
144163
return (
@@ -180,27 +199,27 @@ export function TrialStep({ memberCount }: TrialStepProps) {
180199
<div className="space-y-2">
181200
<LoadingButton
182201
onClick={() => onCheckout(isTrialEligible)}
183-
loading={isPrimaryLoading}
202+
loading={isPrimaryLoading || isReturningFromCheckoutSuccess}
184203
disabled={isSkipLoading}
185204
className="w-full"
186205
>
187206
{primaryButtonText}
188207
</LoadingButton>
189-
<Button
208+
<LoadingButton
190209
variant="ghost"
191210
onClick={() => {
192211
captureEvent('wa_onboard_trial_step_skipped', { isTrialEligible });
193212
onSkipCheckout();
194213
}}
195-
disabled={isPrimaryLoading || isSkipLoading}
214+
loading={isSkipLoading}
215+
disabled={
216+
isPrimaryLoading ||
217+
isReturningFromCheckoutSuccess
218+
}
196219
className="w-full text-muted-foreground hover:text-foreground"
197220
>
198-
{isSkipLoading ? (
199-
<Loader2 className="h-4 w-4 animate-spin" />
200-
) : (
201-
"Skip for now"
202-
)}
203-
</Button>
221+
Skip for now
222+
</LoadingButton>
204223
</div>
205224

206225
{memberCount > 1 && (

packages/web/src/app/onboard/page.tsx

Lines changed: 81 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -77,83 +77,87 @@ export default async function Onboarding(props: OnboardingProps) {
7777
where: { orgId: org.id },
7878
});
7979

80-
const steps: OnboardingStep[] = [
81-
{
82-
id: "welcome",
83-
title: "Welcome to Sourcebot",
84-
subtitle: "This onboarding flow will guide you through creating your owner account and configuring your organization.",
85-
component: (
86-
<div className="space-y-6">
87-
<Button asChild className="w-full">
88-
<Link href="/onboard?step=1">Get Started →</Link>
89-
</Button>
90-
</div>
91-
),
92-
},
93-
{
94-
id: "owner-signup",
95-
title: "Create Owner Account",
96-
subtitle: (
97-
<>
98-
Use your preferred authentication method to create your owner account. To set up additional authentication providers, check out our{" "}
99-
<a
100-
href="https://docs.sourcebot.dev/docs/configuration/auth/overview"
101-
target="_blank"
102-
rel="noopener"
103-
className="underline text-primary hover:text-primary/80 transition-colors"
104-
>
105-
documentation
106-
</a>.
107-
</>
108-
),
109-
component: (
110-
<div className="space-y-6">
111-
<AuthMethodSelector
112-
callbackUrl="/onboard"
113-
context="signup"
114-
securityNoticeClosable={false}
115-
/>
116-
</div>
117-
),
118-
},
119-
{
120-
id: "configure-org",
121-
title: "Configure Access Settings",
122-
subtitle: (
123-
<>
124-
Set up your organization&apos;s access settings.{" "}
125-
<a
126-
href="https://docs.sourcebot.dev/docs/configuration/auth/access-settings"
127-
target="_blank"
128-
rel="noopener"
129-
className="underline text-primary hover:text-primary/80 transition-colors"
130-
>
131-
Learn more
132-
</a>
133-
</>
134-
),
135-
component: (
136-
<div className="space-y-6">
137-
<OrganizationAccessSettings />
138-
<Button asChild className="w-full">
139-
<Link href="/onboard?step=3">Continue →</Link>
140-
</Button>
141-
</div>
142-
),
143-
},
144-
isLicensed ? {
145-
id: "complete",
146-
title: "You're all set",
147-
subtitle: "Your Sourcebot deployment is ready to go.",
148-
component: <AlreadyLicensedStep />,
149-
} : {
150-
id: "trial",
151-
title: "Try Sourcebot Pro",
152-
headerTitle: <TrialStepTitle />,
153-
subtitle: <TrialStepSubtitle />,
154-
component: <TrialStep memberCount={memberCount} />,
155-
},
156-
]
80+
const steps: OnboardingStep[] = [];
81+
82+
steps.push({
83+
id: "welcome",
84+
title: "Welcome to Sourcebot",
85+
subtitle: "This onboarding flow will guide you through creating your owner account and configuring your organization.",
86+
component: (
87+
<div className="space-y-6">
88+
<Button asChild className="w-full">
89+
<Link href={`/onboard?step=${steps.length + 1}`}>Get Started →</Link>
90+
</Button>
91+
</div>
92+
),
93+
});
94+
95+
steps.push({
96+
id: "owner-signup",
97+
title: "Create Owner Account",
98+
subtitle: (
99+
<>
100+
Use your preferred authentication method to create your owner account. To set up additional authentication providers, check out our{" "}
101+
<a
102+
href="https://docs.sourcebot.dev/docs/configuration/auth/overview"
103+
target="_blank"
104+
rel="noopener"
105+
className="underline text-primary hover:text-primary/80 transition-colors"
106+
>
107+
documentation
108+
</a>.
109+
</>
110+
),
111+
component: (
112+
<div className="space-y-6">
113+
<AuthMethodSelector
114+
callbackUrl={`/onboard?step=${steps.length + 1}`}
115+
context="signup"
116+
securityNoticeClosable={false}
117+
/>
118+
</div>
119+
),
120+
});
121+
122+
steps.push({
123+
id: "configure-org",
124+
title: "Configure Access Settings",
125+
subtitle: (
126+
<>
127+
Set up your organization&apos;s access settings.{" "}
128+
<a
129+
href="https://docs.sourcebot.dev/docs/configuration/auth/access-settings"
130+
target="_blank"
131+
rel="noopener"
132+
className="underline text-primary hover:text-primary/80 transition-colors"
133+
>
134+
Learn more
135+
</a>
136+
</>
137+
),
138+
component: (
139+
<div className="space-y-6">
140+
<OrganizationAccessSettings />
141+
<Button asChild className="w-full">
142+
<Link href={`/onboard?step=${steps.length + 1}`}>Continue →</Link>
143+
</Button>
144+
</div>
145+
),
146+
});
147+
148+
const finalStepIndex = steps.length;
149+
steps.push(isLicensed ? {
150+
id: "complete",
151+
title: "You're all set",
152+
subtitle: "Your Sourcebot deployment is ready to go.",
153+
component: <AlreadyLicensedStep />,
154+
} : {
155+
id: "trial",
156+
title: "Try Sourcebot Pro",
157+
headerTitle: <TrialStepTitle />,
158+
subtitle: <TrialStepSubtitle />,
159+
component: <TrialStep memberCount={memberCount} stepIndex={finalStepIndex} />,
160+
});
157161

158162
const currentStepData = steps[currentStep]
159163

packages/web/src/ee/features/lighthouse/actions.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -142,18 +142,26 @@ export const createCheckoutSession = async ({
142142
// Resolve the candidate against AUTH_URL so absolute paths, protocol-
143143
// relative paths (`//evil.com`), and bare relative paths all get
144144
// normalized through the URL parser. Reject anything that lands off-
145-
// origin or carries its own query / fragment — we own those.
146-
let returnPath: string;
145+
// origin, carries a fragment, or already uses the reserved query keys
146+
// we append below.
147+
let returnPathname: string;
148+
let returnSearch: string;
147149
try {
148150
const candidate = new URL(_returnPath, env.AUTH_URL);
149151
const authOrigin = new URL(env.AUTH_URL).origin;
150152
if (candidate.origin !== authOrigin) {
151153
throw new Error('returnPath escapes AUTH_URL origin');
152154
}
153-
if (candidate.search || candidate.hash) {
154-
throw new Error('returnPath must not include query string or fragment');
155+
if (candidate.hash) {
156+
throw new Error('returnPath must not include a fragment');
155157
}
156-
returnPath = candidate.pathname;
158+
for (const reservedKey of ['checkout', 'session_id']) {
159+
if (candidate.searchParams.has(reservedKey)) {
160+
throw new Error(`returnPath must not include reserved query parameter: ${reservedKey}`);
161+
}
162+
}
163+
returnPathname = candidate.pathname;
164+
returnSearch = candidate.search;
157165
} catch {
158166
return {
159167
statusCode: StatusCodes.BAD_REQUEST,
@@ -166,20 +174,24 @@ export const createCheckoutSession = async ({
166174
source,
167175
requestTrial,
168176
interval,
169-
returnPath,
177+
returnPath: `${returnPathname}${returnSearch}`,
170178
quantity,
171179
});
172180

181+
// Build success/cancel URLs as raw strings so Stripe's literal
182+
// `{CHECKOUT_SESSION_ID}` placeholder isn't URL-encoded by URL/
183+
// URLSearchParams (Stripe substitutes the raw token, not %7B...%7D).
184+
const stripeSuccessQuery = 'checkout=success&session_id={CHECKOUT_SESSION_ID}';
185+
const successQuerySeparator = returnSearch ? '&' : '?';
186+
173187
const result = await client.checkout({
174188
email: user.email,
175189
installId: env.SOURCEBOT_INSTALL_ID,
176190
quantity,
177191
requestTrial,
178192
interval,
179-
// `{CHECKOUT_SESSION_ID}` is substituted server-side by Stripe at
180-
// redirect time with the real session ID.
181-
successUrl: `${env.AUTH_URL}${returnPath}?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
182-
cancelUrl: `${env.AUTH_URL}${returnPath}`,
193+
successUrl: `${env.AUTH_URL}${returnPathname}${returnSearch}${successQuerySeparator}${stripeSuccessQuery}`,
194+
cancelUrl: `${env.AUTH_URL}${returnPathname}${returnSearch}`,
183195
});
184196

185197
if (isServiceError(result)) {

0 commit comments

Comments
 (0)