Skip to content

Commit 7c9c670

Browse files
chore(web): Allow user to modify checkout email in upsell dialog
1 parent 8a4c05a commit 7c9c670

5 files changed

Lines changed: 206 additions & 34 deletions

File tree

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

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
import { useCallback, useEffect, useRef, useState } from "react";
44
import { useRouter, useSearchParams } from "next/navigation";
5+
import { useSession } from "next-auth/react";
56
import { LoadingButton } from "@/components/ui/loading-button";
67
import { Skeleton } from "@/components/ui/skeleton";
78
import { completeOnboarding } from "@/actions";
89
import { createCheckoutSession } from "@/ee/features/lighthouse/actions";
910
import { useOffers } from "@/ee/features/lighthouse/useOffers";
1011
import { BillingInterval, PlanComparisonTable } from "@/ee/features/lighthouse/planComparisonTable";
12+
import { CheckoutDisclosures } from "@/ee/features/lighthouse/checkoutDisclosures";
1113
import { useToast } from "@/components/hooks/use-toast";
1214
import { isServiceError } from "@/lib/utils";
1315
import useCaptureEvent from "@/hooks/useCaptureEvent";
@@ -63,11 +65,10 @@ export function TrialStepSubtitle() {
6365
}
6466

6567
interface TrialStepProps {
66-
memberCount: number;
6768
stepIndex: number;
6869
}
6970

70-
export function TrialStep({ memberCount, stepIndex }: TrialStepProps) {
71+
export function TrialStep({ stepIndex }: TrialStepProps) {
7172
const { data: offers, isPending, isError } = useOffers();
7273
const { toast } = useToast();
7374
const router = useRouter();
@@ -76,6 +77,16 @@ export function TrialStep({ memberCount, stepIndex }: TrialStepProps) {
7677
const [billingInterval, setBillingInterval] = useState<BillingInterval>("year");
7778
const [isPrimaryLoading, setIsPrimaryLoading] = useState(false);
7879
const [isSkipLoading, setIsSkipLoading] = useState(false);
80+
const { data: session } = useSession();
81+
const sessionEmail = session?.user?.email ?? "";
82+
const [currentEmail, setCurrentEmail] = useState<string>("");
83+
84+
// Only treat the email as an override when the user has actually changed it
85+
// away from the canonical session email.
86+
const overrideEmail =
87+
sessionEmail && currentEmail && currentEmail !== sessionEmail
88+
? currentEmail
89+
: undefined;
7990

8091
const [isReturningFromCheckoutSuccess, setIsReturningFromCheckoutSuccess] = useState(searchParams.get('checkout') === 'success');
8192

@@ -145,6 +156,7 @@ export function TrialStep({ memberCount, stepIndex }: TrialStepProps) {
145156
requestTrial,
146157
interval: billingInterval,
147158
returnPath: `/onboard?step=${stepIndex}`,
159+
overrideEmail,
148160
});
149161

150162
if (isServiceError(checkoutResult)) {
@@ -157,7 +169,7 @@ export function TrialStep({ memberCount, stepIndex }: TrialStepProps) {
157169
}
158170

159171
window.location.assign(checkoutResult.url);
160-
}, [billingInterval, stepIndex, toast]);
172+
}, [billingInterval, stepIndex, toast, overrideEmail]);
161173

162174
if (isPending) {
163175
return (
@@ -196,17 +208,21 @@ export function TrialStep({ memberCount, stepIndex }: TrialStepProps) {
196208
onBillingIntervalChange={setBillingInterval}
197209
/>
198210

199-
<div className="space-y-2">
211+
<div className="flex flex-col">
200212
<LoadingButton
201213
onClick={() => onCheckout(isTrialEligible)}
202214
loading={isPrimaryLoading || isReturningFromCheckoutSuccess}
203215
disabled={isSkipLoading}
204-
className="w-full"
216+
className="w-full mb-4"
205217
>
206218
{primaryButtonText}
207219
</LoadingButton>
220+
<CheckoutDisclosures
221+
sessionEmail={sessionEmail}
222+
onEmailChanged={setCurrentEmail}
223+
/>
208224
<LoadingButton
209-
variant="ghost"
225+
variant="link"
210226
onClick={() => {
211227
captureEvent('wa_onboard_trial_step_skipped', { isTrialEligible });
212228
onSkipCheckout();
@@ -216,17 +232,11 @@ export function TrialStep({ memberCount, stepIndex }: TrialStepProps) {
216232
isPrimaryLoading ||
217233
isReturningFromCheckoutSuccess
218234
}
219-
className="w-full text-muted-foreground hover:text-foreground"
235+
className="mx-auto text-muted-foreground hover:text-foreground mt-8"
220236
>
221237
Skip for now
222238
</LoadingButton>
223239
</div>
224-
225-
{memberCount > 1 && (
226-
<p className="text-xs text-muted-foreground text-center">
227-
Trial includes your team of {memberCount} members.
228-
</p>
229-
)}
230240
</div>
231241
);
232242
}

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
1616
import { redirect } from "next/navigation";
1717
import { env } from "@sourcebot/shared";
1818
import { hasEntitlement, isValidLicenseActive } from "@/lib/entitlements";
19-
import { cn } from "@/lib/utils";
2019
import { GcpIapAuth } from "@/app/(app)/components/gcpIapAuth";
2120

2221
interface OnboardingProps {
@@ -73,9 +72,6 @@ export default async function Onboarding(props: OnboardingProps) {
7372
const currentStep = session?.user ? Math.max(2, stepParam) : Math.max(0, Math.min(stepParam, 1));
7473

7574
const isLicensed = await isValidLicenseActive();
76-
const memberCount = await __unsafePrisma.userToOrg.count({
77-
where: { orgId: org.id },
78-
});
7975

8076
const steps: OnboardingStep[] = [];
8177

@@ -156,7 +152,7 @@ export default async function Onboarding(props: OnboardingProps) {
156152
title: "Try Sourcebot Pro",
157153
headerTitle: <TrialStepTitle />,
158154
subtitle: <TrialStepSubtitle />,
159-
component: <TrialStep memberCount={memberCount} stepIndex={finalStepIndex} />,
155+
component: <TrialStep stepIndex={finalStepIndex} />,
160156
});
161157

162158
const currentStepData = steps[currentStep]

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { captureEvent } from "@/lib/posthog";
1515
import { UpsellSource } from "@/lib/posthogEvents";
1616
import { client } from "./client";
1717
import { Invoice } from "./types";
18+
import { z } from "zod";
1819

1920
export const activateLicense = async (activationCode: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
2021
withAuth(async ({ org, role, prisma }) =>
@@ -115,12 +116,14 @@ export const createCheckoutSession = async ({
115116
source,
116117
requestTrial = false,
117118
interval = 'year',
118-
returnPath: _returnPath = '/settings/license'
119+
returnPath: _returnPath = '/settings/license',
120+
overrideEmail,
119121
}: {
120122
source: UpsellSource;
121123
requestTrial?: boolean;
122124
interval?: 'month' | 'year';
123125
returnPath?: string;
126+
overrideEmail?: string;
124127
}): Promise<{ url: string } | ServiceError> => sew(() =>
125128
withAuth(async ({ user, org, role, prisma }) =>
126129
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
@@ -132,6 +135,21 @@ export const createCheckoutSession = async ({
132135
} satisfies ServiceError;
133136
}
134137

138+
// Validate the override on the server — never trust a client-supplied
139+
// email. Fall back to the authenticated user's email when omitted.
140+
let checkoutEmail = user.email;
141+
if (overrideEmail !== undefined) {
142+
const parsed = z.string().email().safeParse(overrideEmail);
143+
if (!parsed.success) {
144+
return {
145+
statusCode: StatusCodes.BAD_REQUEST,
146+
errorCode: ErrorCode.UNEXPECTED_ERROR,
147+
message: "Invalid overrideEmail.",
148+
} satisfies ServiceError;
149+
}
150+
checkoutEmail = parsed.data;
151+
}
152+
135153
const memberCount = await prisma.userToOrg.count({
136154
where: {
137155
orgId: org.id,
@@ -185,7 +203,7 @@ export const createCheckoutSession = async ({
185203
const successQuerySeparator = returnSearch ? '&' : '?';
186204

187205
const result = await client.checkout({
188-
email: user.email,
206+
email: checkoutEmail,
189207
installId: env.SOURCEBOT_INSTALL_ID,
190208
quantity,
191209
requestTrial,
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
'use client';
2+
3+
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
4+
import { Input } from "@/components/ui/input";
5+
import { cn } from "@/lib/utils";
6+
import { zodResolver } from "@hookform/resolvers/zod";
7+
import { Pencil } from "lucide-react";
8+
import { useEffect, useState } from "react";
9+
import { useForm } from "react-hook-form";
10+
import { z } from "zod";
11+
12+
const emailFormSchema = z.object({
13+
email: z.string().email(),
14+
});
15+
16+
interface CheckoutDisclosuresProps {
17+
sessionEmail: string;
18+
onEmailChanged: (email: string) => void;
19+
}
20+
21+
export const CheckoutDisclosures = ({ sessionEmail, onEmailChanged }: CheckoutDisclosuresProps) => {
22+
const [isEditing, setIsEditing] = useState(false);
23+
24+
const form = useForm<z.infer<typeof emailFormSchema>>({
25+
resolver: zodResolver(emailFormSchema),
26+
defaultValues: { email: sessionEmail },
27+
mode: "onChange",
28+
});
29+
30+
// Sync once when sessionEmail arrives — the parent's useSession may pass an
31+
// empty value on the first render and populate afterwards.
32+
useEffect(() => {
33+
if (sessionEmail && !form.getValues("email")) {
34+
form.reset({ email: sessionEmail });
35+
}
36+
}, [sessionEmail, form]);
37+
38+
useEffect(() => {
39+
if (isEditing) {
40+
form.setFocus("email");
41+
}
42+
}, [isEditing, form]);
43+
44+
const email = form.watch("email");
45+
const isValid = !form.formState.errors.email;
46+
47+
useEffect(() => {
48+
if (isValid && email && !isEditing) {
49+
onEmailChanged(email);
50+
}
51+
}, [email, isValid, isEditing, onEmailChanged]);
52+
53+
const commit = () => {
54+
if (!isValid) {
55+
return;
56+
}
57+
setIsEditing(false);
58+
};
59+
60+
const revertAndExit = () => {
61+
form.reset({ email: sessionEmail });
62+
setIsEditing(false);
63+
};
64+
65+
return (
66+
<div className="text-xs text-muted-foreground text-center space-y-1">
67+
{sessionEmail && (
68+
<div className="inline-flex items-center justify-center gap-1.5">
69+
<span>Your activation code will be sent to</span>
70+
{isEditing ? (
71+
<Form {...form}>
72+
<FormField
73+
control={form.control}
74+
name="email"
75+
render={({ field }) => (
76+
<FormItem className="space-y-0">
77+
<FormControl>
78+
<Input
79+
{...field}
80+
type="email"
81+
onBlur={() => {
82+
if (!isValid) {
83+
revertAndExit();
84+
} else {
85+
setIsEditing(false);
86+
}
87+
}}
88+
onKeyDown={(e) => {
89+
if (e.key === "Enter") {
90+
e.preventDefault();
91+
commit();
92+
} else if (e.key === "Escape") {
93+
revertAndExit();
94+
}
95+
}}
96+
aria-invalid={!isValid}
97+
className={cn(
98+
"h-6 px-1.5 py-0 text-xs w-56",
99+
!isValid && "border-destructive focus-visible:ring-destructive",
100+
)}
101+
/>
102+
</FormControl>
103+
</FormItem>
104+
)}
105+
/>
106+
</Form>
107+
) : (
108+
<>
109+
<span className="font-medium text-foreground">{email}</span>
110+
<button
111+
type="button"
112+
onClick={() => setIsEditing(true)}
113+
className="text-muted-foreground hover:text-foreground"
114+
aria-label="Edit email"
115+
>
116+
<Pencil className="h-3 w-3" />
117+
</button>
118+
</>
119+
)}
120+
</div>
121+
)}
122+
</div>
123+
);
124+
}

0 commit comments

Comments
 (0)