Skip to content

Commit c552c90

Browse files
[v5] chore(web): Use existing stripe customer id for checkout sessions when applicable (#1239)
* s * feedback
1 parent 47a0fcb commit c552c90

10 files changed

Lines changed: 196 additions & 134 deletions

File tree

packages/web/src/app/(app)/components/banners/actions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import { cookies } from 'next/headers';
44
import { DISMISS_COOKIE_PREFIX, type BannerId } from './types';
55
import { compareVersions, formatVersion, parseVersion } from "@sourcebot/shared/client";
6+
import { createLogger } from "@sourcebot/shared";
7+
8+
const logger = createLogger("banner-actions");
69

710
// eslint-disable-next-line authz/require-auth-wrapper
811
export async function dismissBanner(id: BannerId) {
@@ -32,6 +35,7 @@ export async function tryGetLatestSourcebotTag({ timeoutMs }: { timeoutMs: numbe
3235
signal: AbortSignal.timeout(timeoutMs),
3336
});
3437
if (!response.ok) {
38+
logger.warn(`Failed to fetch Sourcebot version information. Status code: ${response.status}, status text: ${response.statusText}`);
3539
return null;
3640
}
3741
const data = (await response.json()) as { name: string }[];

packages/web/src/app/(app)/layout.tsx

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { ConnectAccountsCard } from "@/ee/features/sso/components/connectAccount
3333
import { SidebarProvider } from "@/components/ui/sidebar";
3434
import { CheckoutReturnHandler } from "@/ee/features/lighthouse/checkoutReturnHandler";
3535
import { RoleProvider } from "@/features/auth/roleProvider";
36+
import { HasLicenseProvider } from "@/ee/features/lighthouse/hasLicenseProvider";
3637
import { tryGetLatestSourcebotTag } from "./components/banners/actions";
3738

3839
interface LayoutProps {
@@ -170,37 +171,41 @@ export default async function Layout(props: LayoutProps) {
170171
: await __unsafePrisma.license.findUnique({ where: { orgId: org.id } });
171172

172173
const latestVersion = await tryGetLatestSourcebotTag({
173-
timeoutMs: 3000
174-
});
174+
timeoutMs: 3000
175+
});
175176

176177
return (
177178
<RoleProvider role={role}>
178-
<SyntaxGuideProvider>
179-
<div className="fixed inset-0 flex bg-shell">
180-
<SidebarProvider defaultOpen={cookieStore.get("sidebar_state")?.value !== "false"}>
181-
{sidebar}
182-
<div className="flex-1 min-h-0 flex flex-col pt-2 pb-2 pr-2 pl-2 md:pl-0">
183-
<div className="flex-1 min-h-0 bg-background flex flex-col border border-[#e6e6e6] dark:border-[#1d1d1f] rounded-xl overflow-hidden">
184-
<BannerSlot
185-
role={role}
186-
license={license}
187-
offlineLicense={offlineLicense}
188-
hasPermissionSyncEntitlement={hasPermissionSyncEntitlement}
189-
hasPendingFirstSync={hasPendingFirstSync}
190-
currentVersion={SOURCEBOT_VERSION}
191-
latestVersion={latestVersion}
192-
/>
193-
<div className="flex-1 min-h-0 overflow-y-auto">
194-
{children}
179+
<HasLicenseProvider
180+
hasLicense={offlineLicense !== null || license !== null}
181+
>
182+
<SyntaxGuideProvider>
183+
<div className="fixed inset-0 flex bg-shell">
184+
<SidebarProvider defaultOpen={cookieStore.get("sidebar_state")?.value !== "false"}>
185+
{sidebar}
186+
<div className="flex-1 min-h-0 flex flex-col pt-2 pb-2 pr-2 pl-2 md:pl-0">
187+
<div className="flex-1 min-h-0 bg-background flex flex-col border border-[#e6e6e6] dark:border-[#1d1d1f] rounded-xl overflow-hidden">
188+
<BannerSlot
189+
role={role}
190+
license={license}
191+
offlineLicense={offlineLicense}
192+
hasPermissionSyncEntitlement={hasPermissionSyncEntitlement}
193+
hasPendingFirstSync={hasPendingFirstSync}
194+
currentVersion={SOURCEBOT_VERSION}
195+
latestVersion={latestVersion}
196+
/>
197+
<div className="flex-1 min-h-0 overflow-y-auto">
198+
{children}
199+
</div>
195200
</div>
196201
</div>
197-
</div>
198-
</SidebarProvider>
199-
</div>
200-
<SyntaxReferenceGuide />
201-
<GitHubStarToast />
202-
<CheckoutReturnHandler userEmail={session?.user.email} />
203-
</SyntaxGuideProvider>
202+
</SidebarProvider>
203+
</div>
204+
<SyntaxReferenceGuide />
205+
<GitHubStarToast />
206+
<CheckoutReturnHandler />
207+
</SyntaxGuideProvider>
208+
</HasLicenseProvider>
204209
</RoleProvider>
205210
)
206211
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ export function TrialStep({ stepIndex }: TrialStepProps) {
226226
<CheckoutDisclosures
227227
sessionEmail={sessionEmail}
228228
onEmailChanged={setCurrentEmail}
229-
showNoCreditCardRequired={isTrialEligible && !offers.trial.creditCardRequired}
229+
isNoCreditCardRequiredMessageVisible={isTrialEligible && !offers.trial.creditCardRequired}
230230
/>
231231
<LoadingButton
232232
variant="link"

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,13 @@ export const createCheckoutSession = async ({
157157
});
158158
const quantity = Math.max(memberCount, 1);
159159

160+
const existingLicense = await prisma.license.findUnique({
161+
where: { orgId: org.id },
162+
});
163+
const existingActivationCode = existingLicense
164+
? decryptActivationCode(existingLicense.activationCode)
165+
: undefined;
166+
160167
// Resolve the candidate against AUTH_URL so absolute paths, protocol-
161168
// relative paths (`//evil.com`), and bare relative paths all get
162169
// normalized through the URL parser. Reject anything that lands off-
@@ -210,6 +217,7 @@ export const createCheckoutSession = async ({
210217
interval,
211218
successUrl: `${env.AUTH_URL}${returnPathname}${returnSearch}${successQuerySeparator}${stripeSuccessQuery}`,
212219
cancelUrl: `${env.AUTH_URL}${returnPathname}${returnSearch}`,
220+
existingActivationCode,
213221
});
214222

215223
if (isServiceError(result)) {

packages/web/src/ee/features/lighthouse/checkoutDisclosures.tsx

Lines changed: 84 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,16 @@ const emailFormSchema = z.object({
1515
interface CheckoutDisclosuresProps {
1616
sessionEmail: string;
1717
onEmailChanged: (email: string) => void;
18-
showNoCreditCardRequired?: boolean;
18+
isEmailValidationMessageVisible?: boolean;
19+
isNoCreditCardRequiredMessageVisible?: boolean;
1920
}
2021

21-
export const CheckoutDisclosures = ({ sessionEmail, onEmailChanged, showNoCreditCardRequired }: CheckoutDisclosuresProps) => {
22+
export const CheckoutDisclosures = ({
23+
sessionEmail,
24+
onEmailChanged,
25+
isNoCreditCardRequiredMessageVisible = false,
26+
isEmailValidationMessageVisible = true,
27+
}: CheckoutDisclosuresProps) => {
2228
const [isEditing, setIsEditing] = useState(false);
2329

2430
const form = useForm<z.infer<typeof emailFormSchema>>({
@@ -62,78 +68,87 @@ export const CheckoutDisclosures = ({ sessionEmail, onEmailChanged, showNoCredit
6268
setIsEditing(false);
6369
};
6470

71+
if (
72+
!isNoCreditCardRequiredMessageVisible &&
73+
!isEmailValidationMessageVisible
74+
) {
75+
return null;
76+
}
77+
6578
return (
6679
<div className="text-xs text-muted-foreground text-center space-y-1">
6780
{sessionEmail && (
6881
<div className="inline-flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
69-
{showNoCreditCardRequired && (
70-
<>
71-
<span>No credit card required</span>
72-
<span aria-hidden="true" className="text-muted-foreground/50">·</span>
73-
</>
82+
{isNoCreditCardRequiredMessageVisible && (
83+
<span>No credit card required</span>
84+
)}
85+
{(isNoCreditCardRequiredMessageVisible && isEmailValidationMessageVisible) && (
86+
<span aria-hidden="true" className="text-muted-foreground/50">·</span>
87+
)}
88+
{isEmailValidationMessageVisible && (
89+
<span className="inline-flex items-center gap-1.5">
90+
<span>Your activation code will be sent to</span>
91+
{isEditing ? (
92+
<Form {...form}>
93+
<FormField
94+
control={form.control}
95+
name="email"
96+
render={({ field }) => (
97+
<FormItem className="space-y-0">
98+
<FormControl>
99+
<input
100+
{...field}
101+
type="email"
102+
autoComplete="off"
103+
data-1p-ignore="true"
104+
data-lpignore="true"
105+
data-form-type="other"
106+
data-bwignore="true"
107+
onKeyDown={(e) => {
108+
if (e.key === "Enter") {
109+
e.preventDefault();
110+
commit();
111+
} else if (e.key === "Escape") {
112+
revertAndExit();
113+
}
114+
}}
115+
aria-invalid={!isValid}
116+
className={cn(
117+
"bg-transparent border-none outline-none p-0 m-0 font-medium text-foreground [font:inherit] [letter-spacing:inherit] [field-sizing:content] min-w-[8ch]",
118+
!isValid && "text-destructive",
119+
)}
120+
style={{ fontWeight: 500 }}
121+
/>
122+
</FormControl>
123+
</FormItem>
124+
)}
125+
/>
126+
<button
127+
type="button"
128+
onClick={commit}
129+
disabled={!isValid}
130+
className="text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
131+
aria-label="Save email (press Escape to cancel)"
132+
title="Press Escape to cancel"
133+
>
134+
<Save className="h-3 w-3" />
135+
</button>
136+
</Form>
137+
) : (
138+
<>
139+
<span className="font-medium text-foreground">{email}</span>
140+
<button
141+
type="button"
142+
onClick={() => setIsEditing(true)}
143+
className="text-muted-foreground hover:text-foreground"
144+
aria-label="Edit email"
145+
>
146+
<Pencil className="h-3 w-3" />
147+
</button>
148+
</>
149+
)}
150+
</span>
74151
)}
75-
<span className="inline-flex items-center gap-1.5">
76-
<span>Your activation code will be sent to</span>
77-
{isEditing ? (
78-
<Form {...form}>
79-
<FormField
80-
control={form.control}
81-
name="email"
82-
render={({ field }) => (
83-
<FormItem className="space-y-0">
84-
<FormControl>
85-
<input
86-
{...field}
87-
type="email"
88-
autoComplete="off"
89-
data-1p-ignore="true"
90-
data-lpignore="true"
91-
data-form-type="other"
92-
data-bwignore="true"
93-
onKeyDown={(e) => {
94-
if (e.key === "Enter") {
95-
e.preventDefault();
96-
commit();
97-
} else if (e.key === "Escape") {
98-
revertAndExit();
99-
}
100-
}}
101-
aria-invalid={!isValid}
102-
className={cn(
103-
"bg-transparent border-none outline-none p-0 m-0 font-medium text-foreground [font:inherit] [letter-spacing:inherit] [field-sizing:content] min-w-[8ch]",
104-
!isValid && "text-destructive",
105-
)}
106-
style={{ fontWeight: 500 }}
107-
/>
108-
</FormControl>
109-
</FormItem>
110-
)}
111-
/>
112-
<button
113-
type="button"
114-
onClick={commit}
115-
disabled={!isValid}
116-
className="text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
117-
aria-label="Save email (press Escape to cancel)"
118-
title="Press Escape to cancel"
119-
>
120-
<Save className="h-3 w-3" />
121-
</button>
122-
</Form>
123-
) : (
124-
<>
125-
<span className="font-medium text-foreground">{email}</span>
126-
<button
127-
type="button"
128-
onClick={() => setIsEditing(true)}
129-
className="text-muted-foreground hover:text-foreground"
130-
aria-label="Edit email"
131-
>
132-
<Pencil className="h-3 w-3" />
133-
</button>
134-
</>
135-
)}
136-
</span>
137152
</div>
138153
)}
139154
</div>

packages/web/src/ee/features/lighthouse/checkoutReturnHandler.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,17 @@
33
import { useSearchParams } from "next/navigation";
44
import { LicenseActivactionDialog } from "./licenseActivactionDialog";
55

6-
interface PostCheckoutHandlerProps {
7-
userEmail?: string | null;
8-
}
9-
106
// Layout-mounted handler that drives the post-Stripe activation flow regardless
117
// of which page the user lands on after checkout. Detects `session_id` in the
128
// URL (set by Stripe's substitution of `{CHECKOUT_SESSION_ID}` in successUrl),
139
// and renders the claim + activate modal when present.
14-
export function CheckoutReturnHandler({ userEmail }: PostCheckoutHandlerProps) {
10+
export function CheckoutReturnHandler() {
1511
const searchParams = useSearchParams();
1612
const sessionId = searchParams.get("session_id");
1713

1814
if (!sessionId) {
1915
return null;
2016
}
2117

22-
return <LicenseActivactionDialog userEmail={userEmail} />;
18+
return <LicenseActivactionDialog />;
2319
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
'use client';
2+
3+
import { createContext, useContext } from "react";
4+
5+
const HasLicenseContext = createContext<boolean>(false);
6+
7+
interface HasLicenseProviderProps {
8+
children: React.ReactNode;
9+
hasLicense: boolean;
10+
}
11+
12+
export const HasLicenseProvider = ({ children, hasLicense }: HasLicenseProviderProps) => (
13+
<HasLicenseContext.Provider value={hasLicense}>{children}</HasLicenseContext.Provider>
14+
);
15+
16+
export const useHasLicense = () => useContext(HasLicenseContext);

0 commit comments

Comments
 (0)