From 0d810f23abba0535548f2626a0357cf79f5fd016 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 21 May 2026 20:53:45 -0700 Subject: [PATCH 1/5] wip on upsell dialog --- packages/shared/src/entitlements.ts | 19 +- packages/shared/src/index.server.ts | 1 + .../components/defaultSidebar/index.tsx | 23 +- .../@sidebar/components/freePlanDialog.tsx | 211 ++++++++++++++++++ .../components/settingsSidebar/index.tsx | 12 + .../(app)/@sidebar/components/sidebarBase.tsx | 29 ++- .../settings/license/activationCodeCard.tsx | 4 +- .../license/licenseInactiveBanner.tsx | 33 +++ .../settings/license/onlineLicenseCard.tsx | 133 ++++++----- .../settings/license/planActionsMenu.tsx | 50 +---- .../license/removeActivationCodeDialog.tsx | 66 ++++++ packages/web/src/components/ui/switch.tsx | 2 +- .../web/src/ee/features/lighthouse/actions.ts | 9 +- .../web/src/ee/features/lighthouse/types.ts | 1 + packages/web/src/lib/entitlements.ts | 6 + 15 files changed, 468 insertions(+), 131 deletions(-) create mode 100644 packages/web/src/app/(app)/@sidebar/components/freePlanDialog.tsx create mode 100644 packages/web/src/app/(app)/settings/license/licenseInactiveBanner.tsx create mode 100644 packages/web/src/app/(app)/settings/license/removeActivationCodeDialog.tsx diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index 8ba587b43..97d743990 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -88,12 +88,12 @@ const getValidOfflineLicense = (): getValidOfflineLicense | null => { return payload; } -// If the license hasn't successfully synced with Lighthouse for this long, -// the locally-cached state is no longer trusted. This guards against an -// operator blocking egress to prevent the license row from hearing about -// a canceled or past-due subscription. 7 days absorbs week-long transient -// outages (weekends, firewall rollouts) without punishing legitimate -// customers. +// If the license hasn't successfully synced with Lighthouse for this long, +// the locally-cached state is no longer trusted. This guards against an +// operator blocking egress to prevent the license row from hearing about +// a canceled or past-due subscription. 7 days absorbs week-long transient +// outages (weekends, firewall rollouts) without punishing legitimate +// customers. export const STALE_ONLINE_LICENSE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; // Surface a UI warning (banner + "refreshed" timestamp color) when the @@ -116,6 +116,13 @@ const getValidOnlineLicense = (_license: License | null): License | null => { return null; } +export const isValidLicenseActive = (_license: License | null): boolean => { + return ( + getValidOfflineLicense() !== null || + getValidOnlineLicense(_license) !== null + ); +} + export const isAnonymousAccessAvailable = (_license: License | null): boolean => { const offlineKey = getValidOfflineLicense(); if (offlineKey) { diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index 8be1b35c2..1c45eb3cf 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -5,6 +5,7 @@ export { hasEntitlement as _hasEntitlement, getEntitlements as _getEntitlements, isAnonymousAccessAvailable as _isAnonymousAccessAvailable, + isValidLicenseActive as _isValidLicenseActive, getSeatCap, getOfflineLicenseMetadata, STALE_ONLINE_LICENSE_THRESHOLD_MS, diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx index c215af57c..33e6216f5 100644 --- a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx @@ -6,14 +6,13 @@ import { getConnectionStats } from "@/actions"; import { getOrgAccountRequests } from "@/features/userManagement/actions"; import { isServiceError } from "@/lib/utils"; import { ServiceErrorException } from "@/lib/serviceError"; -import { __unsafePrisma } from "@/prisma"; -import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; import { OrgRole } from "@prisma/client"; import { SidebarBase } from "@/app/(app)/@sidebar/components/sidebarBase"; import { Nav } from "./nav"; import { ChatHistory } from "./chatHistory"; -import { withAuth } from "@/middleware/withAuth"; +import { getAuthContext, withAuth } from "@/middleware/withAuth"; import { sew } from "@/middleware/sew"; +import { isValidLicenseActive } from "@/lib/entitlements"; const SIDEBAR_CHAT_LIMIT = 30; @@ -27,15 +26,14 @@ export async function DefaultSidebar() { throw new ServiceErrorException(chatHistory); } + const licenseActive = await isValidLicenseActive(); + + const authContext = await getAuthContext(); + const isOwner = !isServiceError(authContext) && authContext.role === OrgRole.OWNER; + const trialAvailable = !isServiceError(authContext) && authContext.org.trialUsedAt === null; + const isSettingsNotificationVisible = await (async () => { - if (!session) { - return false; - } - const membership = await __unsafePrisma.userToOrg.findUnique({ - where: { orgId_userId: { orgId: SINGLE_TENANT_ORG_ID, userId: session.user.id } }, - select: { role: true }, - }); - if (membership?.role !== OrgRole.OWNER) { + if (!isOwner) { return false; } const connectionStats = await getConnectionStats(); @@ -49,6 +47,9 @@ export async function DefaultSidebar() { + + {text} + + + + ); +} + +interface SupportIconProps { + supported: boolean; +} + +function SupportIcon({ supported }: SupportIconProps) { + const Icon = supported ? CircleCheck : CircleX; + return ( + + + + ); +} + +interface FreePlanDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trialAvailable: boolean; +} + +type BillingInterval = "year" | "month"; + +export function FreePlanDialog({ open, onOpenChange, trialAvailable }: FreePlanDialogProps) { + const [billingInterval, setBillingInterval] = useState("year"); + const [isCheckoutSessionCreating, setIsCheckoutSessionCreating] = useState(false); + const { toast } = useToast(); + + const enterprisePrice = billingInterval === "year" + ? "$16 per user/month, annually" + : "$20 per user/month"; + + const handlePrimaryAction = useCallback(() => { + setIsCheckoutSessionCreating(true); + createCheckoutSession({ + requestTrial: trialAvailable, + interval: billingInterval, + }) + .then((response) => { + if (isServiceError(response)) { + toast({ + description: `Failed to start checkout: ${response.message}`, + variant: "destructive", + }); + setIsCheckoutSessionCreating(false); + } else { + window.location.assign(response.url); + } + }) + .catch(() => { + toast({ + description: "Failed to start checkout. Please try again.", + variant: "destructive", + }); + setIsCheckoutSessionCreating(false); + }) + }, [trialAvailable, billingInterval, toast]); + + return ( + + + + + + {trialAvailable + ? "Try Sourcebot Enterprise free" + : "Your workspace is on the free plan"} + + + {trialAvailable + ? "Get full access. No credit card required." + : "Upgrade to unlock more features."} + + + + + + + + +
Community
+
Free
+
+ + Enterprise +
{enterprisePrice}
+
+ setBillingInterval(checked ? "year" : "month")} + className="scale-75 origin-left" + /> + + Annual billing + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + {trialAvailable ? "Start free trial" : "Upgrade"} + + +
+
+ ); +} diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx index fc30ad7f4..ec76ceaa7 100644 --- a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx @@ -5,6 +5,9 @@ import { getSidebarNavGroups } from "@/app/(app)/settings/layout"; import { SidebarBase } from "../sidebarBase"; import { Nav } from "./nav"; import { SettingsSidebarHeader } from "./header"; +import { isValidLicenseActive } from "@/lib/entitlements"; +import { getAuthContext } from "@/middleware/withAuth"; +import { OrgRole } from "@prisma/client"; export async function SettingsSidebar() { const session = await auth(); @@ -14,10 +17,19 @@ export async function SettingsSidebar() { throw new ServiceErrorException(sidebarNavGroups); } + const licenseActive = await isValidLicenseActive(); + + const authContext = await getAuthContext(); + const isOwner = !isServiceError(authContext) && authContext.role === OrgRole.OWNER; + const trialAvailable = !isServiceError(authContext) && authContext.org.trialUsedAt === null; + return ( } >