From a00b73745182906bf6e42ba83825c988e4b7f7d2 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 27 May 2026 21:58:01 -0700 Subject: [PATCH 1/2] s --- .../app/(app)/components/banners/actions.ts | 4 + packages/web/src/app/(app)/layout.tsx | 55 ++++--- .../src/app/onboard/components/trialStep.tsx | 2 +- .../web/src/ee/features/lighthouse/actions.ts | 8 + .../lighthouse/checkoutDisclosures.tsx | 153 ++++++++++-------- .../lighthouse/checkoutReturnHandler.tsx | 8 +- .../lighthouse/hasLicenseProvider.tsx | 16 ++ .../lighthouse/licenseActivactionDialog.tsx | 62 +++---- .../web/src/ee/features/lighthouse/types.ts | 1 + .../ee/features/lighthouse/upsellDialog.tsx | 19 ++- 10 files changed, 194 insertions(+), 134 deletions(-) create mode 100644 packages/web/src/ee/features/lighthouse/hasLicenseProvider.tsx diff --git a/packages/web/src/app/(app)/components/banners/actions.ts b/packages/web/src/app/(app)/components/banners/actions.ts index 4ceccb087..47e109b27 100644 --- a/packages/web/src/app/(app)/components/banners/actions.ts +++ b/packages/web/src/app/(app)/components/banners/actions.ts @@ -3,6 +3,9 @@ import { cookies } from 'next/headers'; import { DISMISS_COOKIE_PREFIX, type BannerId } from './types'; import { compareVersions, formatVersion, parseVersion } from "@sourcebot/shared/client"; +import { createLogger } from "@sourcebot/shared"; + +const logger = createLogger("banner-actions"); // eslint-disable-next-line authz/require-auth-wrapper export async function dismissBanner(id: BannerId) { @@ -32,6 +35,7 @@ export async function tryGetLatestSourcebotTag({ timeoutMs }: { timeoutMs: numbe signal: AbortSignal.timeout(timeoutMs), }); if (!response.ok) { + logger.warn(`Failed to fetch Sourcebot version information. Status code: ${response.status}, status text: ${response.statusText}`); return null; } const data = (await response.json()) as { name: string }[]; diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx index ab6fac557..769026c07 100644 --- a/packages/web/src/app/(app)/layout.tsx +++ b/packages/web/src/app/(app)/layout.tsx @@ -33,6 +33,7 @@ import { ConnectAccountsCard } from "@/ee/features/sso/components/connectAccount import { SidebarProvider } from "@/components/ui/sidebar"; import { CheckoutReturnHandler } from "@/ee/features/lighthouse/checkoutReturnHandler"; import { RoleProvider } from "@/features/auth/roleProvider"; +import { HasLicenseProvider } from "@/ee/features/lighthouse/hasLicenseProvider"; import { tryGetLatestSourcebotTag } from "./components/banners/actions"; interface LayoutProps { @@ -170,37 +171,39 @@ export default async function Layout(props: LayoutProps) { : await __unsafePrisma.license.findUnique({ where: { orgId: org.id } }); const latestVersion = await tryGetLatestSourcebotTag({ - timeoutMs: 3000 - }); + timeoutMs: 3000 + }); return ( - -
- - {sidebar} -
-
- -
- {children} + + +
+ + {sidebar} +
+
+ +
+ {children} +
-
- -
- - - - + +
+ + + + + ) } \ No newline at end of file diff --git a/packages/web/src/app/onboard/components/trialStep.tsx b/packages/web/src/app/onboard/components/trialStep.tsx index 222d81a07..95e05a6a4 100644 --- a/packages/web/src/app/onboard/components/trialStep.tsx +++ b/packages/web/src/app/onboard/components/trialStep.tsx @@ -226,7 +226,7 @@ export function TrialStep({ stepIndex }: TrialStepProps) { void; - showNoCreditCardRequired?: boolean; + isEmailValidationMessageVisible?: boolean; + isNoCreditCardRequiredMessageVisible?: boolean; } -export const CheckoutDisclosures = ({ sessionEmail, onEmailChanged, showNoCreditCardRequired }: CheckoutDisclosuresProps) => { +export const CheckoutDisclosures = ({ + sessionEmail, + onEmailChanged, + isNoCreditCardRequiredMessageVisible = false, + isEmailValidationMessageVisible = true, +}: CheckoutDisclosuresProps) => { const [isEditing, setIsEditing] = useState(false); const form = useForm>({ @@ -62,78 +68,87 @@ export const CheckoutDisclosures = ({ sessionEmail, onEmailChanged, showNoCredit setIsEditing(false); }; + if ( + !isNoCreditCardRequiredMessageVisible && + !isEmailValidationMessageVisible + ) { + return null; + } + return (
{sessionEmail && (
- {showNoCreditCardRequired && ( - <> - No credit card required - - + {isNoCreditCardRequiredMessageVisible && ( + No credit card required + )} + {(isNoCreditCardRequiredMessageVisible && isEmailValidationMessageVisible) && ( + + )} + {isEmailValidationMessageVisible && ( + + Your activation code will be sent to + {isEditing ? ( +
+ ( + + + { + if (e.key === "Enter") { + e.preventDefault(); + commit(); + } else if (e.key === "Escape") { + revertAndExit(); + } + }} + aria-invalid={!isValid} + className={cn( + "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]", + !isValid && "text-destructive", + )} + style={{ fontWeight: 500 }} + /> + + + )} + /> + + + ) : ( + <> + {email} + + + )} +
)} - - Your activation code will be sent to - {isEditing ? ( -
- ( - - - { - if (e.key === "Enter") { - e.preventDefault(); - commit(); - } else if (e.key === "Escape") { - revertAndExit(); - } - }} - aria-invalid={!isValid} - className={cn( - "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]", - !isValid && "text-destructive", - )} - style={{ fontWeight: 500 }} - /> - - - )} - /> - - - ) : ( - <> - {email} - - - )} -
)}
diff --git a/packages/web/src/ee/features/lighthouse/checkoutReturnHandler.tsx b/packages/web/src/ee/features/lighthouse/checkoutReturnHandler.tsx index 65143b74c..435608570 100644 --- a/packages/web/src/ee/features/lighthouse/checkoutReturnHandler.tsx +++ b/packages/web/src/ee/features/lighthouse/checkoutReturnHandler.tsx @@ -3,15 +3,11 @@ import { useSearchParams } from "next/navigation"; import { LicenseActivactionDialog } from "./licenseActivactionDialog"; -interface PostCheckoutHandlerProps { - userEmail?: string | null; -} - // Layout-mounted handler that drives the post-Stripe activation flow regardless // of which page the user lands on after checkout. Detects `session_id` in the // URL (set by Stripe's substitution of `{CHECKOUT_SESSION_ID}` in successUrl), // and renders the claim + activate modal when present. -export function CheckoutReturnHandler({ userEmail }: PostCheckoutHandlerProps) { +export function CheckoutReturnHandler() { const searchParams = useSearchParams(); const sessionId = searchParams.get("session_id"); @@ -19,5 +15,5 @@ export function CheckoutReturnHandler({ userEmail }: PostCheckoutHandlerProps) { return null; } - return ; + return ; } diff --git a/packages/web/src/ee/features/lighthouse/hasLicenseProvider.tsx b/packages/web/src/ee/features/lighthouse/hasLicenseProvider.tsx new file mode 100644 index 000000000..16ab16c6a --- /dev/null +++ b/packages/web/src/ee/features/lighthouse/hasLicenseProvider.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { createContext, useContext } from "react"; + +const HasLicenseContext = createContext(false); + +interface HasLicenseProviderProps { + children: React.ReactNode; + hasLicense: boolean; +} + +export const HasLicenseProvider = ({ children, hasLicense }: HasLicenseProviderProps) => ( + {children} +); + +export const useHasLicense = () => useContext(HasLicenseContext); diff --git a/packages/web/src/ee/features/lighthouse/licenseActivactionDialog.tsx b/packages/web/src/ee/features/lighthouse/licenseActivactionDialog.tsx index 21aebdea7..a2f2debb2 100644 --- a/packages/web/src/ee/features/lighthouse/licenseActivactionDialog.tsx +++ b/packages/web/src/ee/features/lighthouse/licenseActivactionDialog.tsx @@ -15,9 +15,10 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { LoadingButton } from "@/components/ui/loading-button"; import { useToast } from "@/components/hooks/use-toast"; -import { activateLicense } from "@/ee/features/lighthouse/actions"; +import { activateLicense, deactivateLicense } from "@/ee/features/lighthouse/actions"; import { useClaimActivationCode } from "@/ee/features/lighthouse/useClaimActivationCode"; import { isServiceError } from "@/lib/utils"; +import { useHasLicense } from "./hasLicenseProvider"; const CONFETTI_COLORS = [ "#ff3b3b", // red @@ -49,17 +50,14 @@ const rainConfetti = () => { frame(); }; -interface CheckoutSuccessModalProps { - userEmail?: string | null; -} - -export function LicenseActivactionDialog({ userEmail }: CheckoutSuccessModalProps) { +export function LicenseActivactionDialog() { const [open, setOpen] = useState(true); const [activationCode, setActivationCode] = useState(""); const [isActivating, setIsActivating] = useState(false); const router = useRouter(); const pathname = usePathname(); const { toast } = useToast(); + const hasLicense = useHasLicense(); const searchParams = useSearchParams(); const sessionId = searchParams.get("session_id"); @@ -84,33 +82,44 @@ export function LicenseActivactionDialog({ userEmail }: CheckoutSuccessModalProp } }, [dismiss]); - const activate = useCallback((code: string) => { + const activate = useCallback(async (code: string) => { setIsActivating(true); - activateLicense(code) - .then((response) => { - if (isServiceError(response)) { + + try { + // Deactivate any existing license + if (hasLicense) { + const deactivateLicenseResponse = await deactivateLicense() + if (isServiceError(deactivateLicenseResponse)) { toast({ - description: `Failed to activate license: ${response.message}`, + description: `Failed to deactive existing license: ${deactivateLicenseResponse.message}`, variant: "destructive", }); - return; } + } + const activateLicenseResponse = await activateLicense(code) + if (isServiceError(activateLicenseResponse)) { toast({ - description: "✅ License activated successfully.", + description: `Failed to activate license: ${activateLicenseResponse.message}`, + variant: "destructive", }); - rainConfetti(); - // Re-fetch the server-rendered layout so PlanContext picks up the - // newly granted entitlements. Without this, callers like ChatBox - // would keep reading the stale `isAskEnabled === false` and never - // resume the pending submission stashed pre-checkout. - router.refresh(); - dismiss(); - }) - .finally(() => { - setIsActivating(false); + return; + } + + toast({ + description: "✅ License activated successfully.", }); - }, [toast, dismiss, router]); + rainConfetti(); + // Re-fetch the server-rendered layout so PlanContext picks up the + // newly granted entitlements. Without this, callers like ChatBox + // would keep reading the stale `isAskEnabled === false` and never + // resume the pending submission stashed pre-checkout. + router.refresh(); + dismiss(); + } finally { + setIsActivating(false); + } + }, [hasLicense, toast, router, dismiss]); const handleManualActivate = useCallback(() => { const code = activationCode.trim(); @@ -191,11 +200,6 @@ export function LicenseActivactionDialog({ userEmail }: CheckoutSuccessModalProp disabled={isActivating} className="font-mono" /> - {userEmail && ( -

- Sent to {userEmail} -

- )}
; diff --git a/packages/web/src/ee/features/lighthouse/upsellDialog.tsx b/packages/web/src/ee/features/lighthouse/upsellDialog.tsx index e9be5dc9e..d7b65eff9 100644 --- a/packages/web/src/ee/features/lighthouse/upsellDialog.tsx +++ b/packages/web/src/ee/features/lighthouse/upsellDialog.tsx @@ -10,7 +10,9 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { LoadingButton } from "@/components/ui/loading-button"; +import { Skeleton } from "@/components/ui/skeleton"; import { createCheckoutSession } from "@/ee/features/lighthouse/actions"; +import { useHasLicense } from "@/ee/features/lighthouse/hasLicenseProvider"; import { BillingInterval, PlanComparisonTable } from "@/ee/features/lighthouse/planComparisonTable"; import { OffersResponse } from "@/ee/features/lighthouse/types"; import { useOffers } from "@/ee/features/lighthouse/useOffers"; @@ -90,8 +92,17 @@ export function UpsellPanel({ source, returnPath, className }: UpsellPanelProps) if (isPending || !offers) { return ( -
- +
+
+ + + +
+ +
+ + +
); } @@ -119,6 +130,7 @@ function UpsellPanelContent({ offers, source, returnPath, variant }: UpsellPanel const { toast } = useToast(); const role = useRole(); const isOwner = role === OrgRole.OWNER; + const hasExistingLicense = useHasLicense(); // Only treat the email as an override when the user has actually changed it // away from the canonical session email. @@ -246,7 +258,8 @@ function UpsellPanelContent({ offers, source, returnPath, variant }: UpsellPanel )} From 875a0e023d8723a22fc02bf5a73f765c487bc280 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 28 May 2026 12:13:55 -0700 Subject: [PATCH 2/2] feedback --- packages/web/src/app/(app)/layout.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx index 769026c07..9842d0333 100644 --- a/packages/web/src/app/(app)/layout.tsx +++ b/packages/web/src/app/(app)/layout.tsx @@ -176,7 +176,9 @@ export default async function Layout(props: LayoutProps) { return ( - +