-
-
{planName} plan
- {statusBadge && (
-
- {statusBadge.label}
-
- )}
-
- {monthlyPerSeat !== null ? (
-
- {formatCurrency(monthlyPerSeat, currency, { minimumFractionDigits: 0 })} per user/mo, billed {formatCadence(interval, intervalCount)}
-
- ) : (
-
- {formatCurrency(unitAmount, currency, { minimumFractionDigits: 0 })} per user, billed {formatCadence(interval, intervalCount)}
-
- )}
-
-
sb_act_••••
- {license.lastSyncAt && (
- <>
-
·
-
- {isLastSyncStale(license.lastSyncAt) && (
-
- )}
- Verified {formatDistanceToNow(license.lastSyncAt, { addSuffix: true })}
-
- >
+
+
+
+
+
+
{planName} plan
+ {statusBadge && (
+
+ {statusBadge.label}
+
+ )}
+
+ {monthlyPerSeat !== null ? (
+
+ {formatCurrency(monthlyPerSeat, currency, { minimumFractionDigits: 0 })} per user/mo, billed {formatCadence(interval, intervalCount)}
+
+ ) : (
+
+ {formatCurrency(unitAmount, currency, { minimumFractionDigits: 0 })} per user, billed {formatCadence(interval, intervalCount)}
+
)}
+
+
sb_act_••••
+ {license.lastSyncAt && (
+ <>
+
·
+
+ {isLastSyncStale(license.lastSyncAt) && (
+
+ )}
+ Verified {formatDistanceToNow(license.lastSyncAt, { addSuffix: true })}
+
+ >
+ )}
+
-
-
- {isActivelyBilling && (nextRenewalAt || cancelAt || trialEnd) && (
-
-
-
Billed seats
-
{seats ?? 0}
-
- {nextRenewalAt ? (
+
+ {isActivelyBilling && (nextRenewalAt || cancelAt || trialEnd) && (
+
-
Next renewal
-
- {formatCurrency(nextRenewalAmount ?? 0, currency, { minimumFractionDigits: 0 })} on {formatDate(nextRenewalAt)}
-
+
Billed seats
+
{seats ?? 0}
- ) : cancelAt ? (
-
-
Cancels on
-
{formatDate(cancelAt)}
-
- ) : trialEnd && (
-
-
Trial ends on
-
{formatDate(trialEnd)}
-
- )}
-
- )}
-
+ {nextRenewalAt ? (
+
+
Next renewal
+
+ {formatCurrency(nextRenewalAmount ?? 0, currency, { minimumFractionDigits: 0 })} on {formatDate(nextRenewalAt)}
+
+
+ ) : cancelAt ? (
+
+
Cancels on
+
{formatDate(cancelAt)}
+
+ ) : trialEnd && (
+
+
Trial ends on
+
{formatDate(trialEnd)}
+
+ )}
+
+ )}
+
+
-
+ {!isLicenseActive &&
}
+
);
}
diff --git a/packages/web/src/app/(app)/settings/license/page.tsx b/packages/web/src/app/(app)/settings/license/page.tsx
index 2d6e66020..932495348 100644
--- a/packages/web/src/app/(app)/settings/license/page.tsx
+++ b/packages/web/src/app/(app)/settings/license/page.tsx
@@ -19,32 +19,18 @@ type LicensePageProps = {
export default authenticatedPage
(async ({ prisma, org, user }, props) => {
const searchParams = await props.searchParams;
- if (searchParams?.refresh === 'true' || searchParams?.trial_used === 'true') {
+ if (searchParams?.refresh === 'true') {
// Side-trips to the Stripe portal (add PM, manage sub) include
// ?refresh=true so we resync immediately instead of waiting for
- // the daily ping. Trial checkout returns add ?trial_used=true so
- // we can flag the org as having used its trial even before the
- // license row exists (the user still needs to enter the
- // activation code from email before syncWithLighthouse has
- // anything to pull).
+ // the daily ping.
if (searchParams.refresh === 'true') {
await syncWithLighthouse(org.id).catch(() => {
// ignore failure
});
}
- if (searchParams.trial_used === 'true' && org.trialUsedAt === null) {
- await prisma.org.update({
- where: { id: org.id, trialUsedAt: null },
- data: { trialUsedAt: new Date() },
- }).catch(() => {
- // No-op: the flag was already set by another path.
- });
- }
-
// Strip our params but preserve anything else (e.g. `checkout=success`).
const preserved = new URLSearchParams(searchParams as Record);
preserved.delete('refresh');
- preserved.delete('trial_used');
const suffix = preserved.toString();
redirect(suffix ? `/settings/license?${suffix}` : '/settings/license');
}
@@ -61,7 +47,6 @@ export default authenticatedPage(async ({ prisma, org, user },
const invoicesResult = license ? await getAllInvoices() : null;
const invoices = invoicesResult && !isServiceError(invoicesResult) ? invoicesResult : [];
- const isTrialEligible = !offlineLicense && org.trialUsedAt === null;
const showCheckoutSuccess = searchParams?.checkout === 'success' && !license;
return (
@@ -88,7 +73,7 @@ export default authenticatedPage(async ({ prisma, org, user },
)}
{license && }
{license && }
- {!offlineLicense && !license && }
+ {!offlineLicense && !license && }
{showCheckoutSuccess && }
);
diff --git a/packages/web/src/app/(app)/settings/license/planActionsMenu.tsx b/packages/web/src/app/(app)/settings/license/planActionsMenu.tsx
index 3e1410fa9..7c643d320 100644
--- a/packages/web/src/app/(app)/settings/license/planActionsMenu.tsx
+++ b/packages/web/src/app/(app)/settings/license/planActionsMenu.tsx
@@ -11,19 +11,10 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
import { useToast } from "@/components/hooks/use-toast";
-import { refreshLicense, createPortalSession, deactivateLicense } from "@/ee/features/lighthouse/actions";
+import { refreshLicense, createPortalSession } from "@/ee/features/lighthouse/actions";
import { isServiceError, cn } from "@/lib/utils";
+import { RemoveActivationCodeDialog } from "./removeActivationCodeDialog";
export function PlanActionsMenu() {
const [isRefreshing, setIsRefreshing] = useState(false);
@@ -32,23 +23,6 @@ export function PlanActionsMenu() {
const { toast } = useToast();
const router = useRouter();
- const handleRemove = useCallback(() => {
- deactivateLicense()
- .then((response) => {
- if (isServiceError(response)) {
- toast({
- description: `Failed to remove activation code: ${response.message}`,
- variant: "destructive",
- });
- } else {
- toast({
- description: "Activation code removed successfully.",
- });
- router.refresh();
- }
- });
- }, [router, toast]);
-
const handleRefresh = useCallback(() => {
setIsRefreshing(true);
refreshLicense()
@@ -132,25 +106,7 @@ export function PlanActionsMenu() {
-
-
-
- Remove activation code
-
- Are you sure you want to remove this activation code? Your deployment will no longer have a registered license.
-
-
-
- Cancel
-
- Remove
-
-
-
-
+
>
);
}
diff --git a/packages/web/src/app/(app)/settings/license/removeActivationCodeDialog.tsx b/packages/web/src/app/(app)/settings/license/removeActivationCodeDialog.tsx
new file mode 100644
index 000000000..d2071c82e
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/license/removeActivationCodeDialog.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { useCallback } from "react";
+import { useRouter } from "next/navigation";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { useToast } from "@/components/hooks/use-toast";
+import { deactivateLicense } from "@/ee/features/lighthouse/actions";
+import { isServiceError } from "@/lib/utils";
+
+interface RemoveActivationCodeDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function RemoveActivationCodeDialog({ open, onOpenChange }: RemoveActivationCodeDialogProps) {
+ const { toast } = useToast();
+ const router = useRouter();
+
+ const handleRemove = useCallback(() => {
+ deactivateLicense()
+ .then((response) => {
+ if (isServiceError(response)) {
+ toast({
+ description: `Failed to remove activation code: ${response.message}`,
+ variant: "destructive",
+ });
+ } else {
+ toast({
+ description: "Activation code removed successfully.",
+ });
+ router.refresh();
+ }
+ });
+ }, [router, toast]);
+
+ return (
+
+
+
+ Remove activation code
+
+ Are you sure you want to remove this activation code? Your deployment will no longer have a registered license.
+
+
+
+ Cancel
+
+ Remove
+
+
+
+
+ );
+}
diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts
index 22c689278..cd7596468 100644
--- a/packages/web/src/app/api/(client)/client.ts
+++ b/packages/web/src/app/api/(client)/client.ts
@@ -29,6 +29,7 @@ import type {
SearchChatShareableMembersQueryParams,
SearchChatShareableMembersResponse,
} from "../(server)/ee/chat/[chatId]/searchMembers/route";
+import { OffersResponse } from "@/ee/features/lighthouse/types";
export const search = async (body: SearchRequest): Promise
=> {
const result = await fetch("/api/search", {
@@ -214,4 +215,17 @@ export const listChats = async (queryParams: ListChatsQueryParams): Promise response.json());
return result as ListChatsResponse | ServiceError;
+}
+
+export const getOffers = async (): Promise => {
+ const url = new URL("/api/offers", window.location.origin);
+
+ const result = await fetch(url, {
+ method: "GET",
+ headers: {
+ "X-Sourcebot-Client-Source": "sourcebot-web-client",
+ },
+ }).then(response => response.json());
+
+ return result as OffersResponse | ServiceError;
}
\ No newline at end of file
diff --git a/packages/web/src/app/api/(server)/offers/route.ts b/packages/web/src/app/api/(server)/offers/route.ts
new file mode 100644
index 000000000..b54dde331
--- /dev/null
+++ b/packages/web/src/app/api/(server)/offers/route.ts
@@ -0,0 +1,16 @@
+import { client as lighthouseClient } from "@/ee/features/lighthouse/client";
+import { apiHandler } from "@/lib/apiHandler";
+import { env } from "@sourcebot/shared";
+
+// eslint-disable-next-line authz/require-auth-wrapper -- this endpoint is intended to not require auth.
+export const GET = apiHandler(async () => {
+ const offers = await lighthouseClient.offers({
+ installId: env.SOURCEBOT_INSTALL_ID,
+ });
+
+ return new Response(JSON.stringify(offers), {
+ headers: {
+ 'Cache-Control': 'public, max-age=300'
+ }
+ });
+})
\ No newline at end of file
diff --git a/packages/web/src/components/ui/switch.tsx b/packages/web/src/components/ui/switch.tsx
index 6723fb2ea..b11bf4d20 100644
--- a/packages/web/src/components/ui/switch.tsx
+++ b/packages/web/src/components/ui/switch.tsx
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
=> sew(() =>
+export const createCheckoutSession = async ({
+ requestTrial = false,
+ interval = 'month',
+}: {
+ requestTrial?: boolean;
+ interval?: 'month' | 'year';
+}): Promise<{ url: string } | ServiceError> => sew(() =>
withAuth(async ({ user, org, role, prisma }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
if (!user.email) {
@@ -114,8 +120,9 @@ export const createCheckoutSession = async (requestTrial = false): Promise<{ url
installId: env.SOURCEBOT_INSTALL_ID,
quantity: Math.max(memberCount, 1),
requestTrial,
+ interval,
successUrl: requestTrial
- ? `${env.AUTH_URL}/settings/license?checkout=success&refresh=true&trial_used=true`
+ ? `${env.AUTH_URL}/settings/license?checkout=success&refresh=true`
: `${env.AUTH_URL}/settings/license?checkout=success&refresh=true`,
cancelUrl: `${env.AUTH_URL}/settings/license?refresh=true`,
});
diff --git a/packages/web/src/ee/features/lighthouse/client.ts b/packages/web/src/ee/features/lighthouse/client.ts
index 82bfaee46..a317ffefd 100644
--- a/packages/web/src/ee/features/lighthouse/client.ts
+++ b/packages/web/src/ee/features/lighthouse/client.ts
@@ -10,6 +10,9 @@ import {
InvoicesRequest,
InvoicesResponse,
invoicesResponseSchema,
+ OffersQuery,
+ OffersResponse,
+ offersResponseSchema,
PortalRequest,
PortalResponse,
portalResponseSchema,
@@ -72,6 +75,15 @@ export const client = {
return parseResponseBody(response, invoicesResponseSchema);
},
+
+ offers: async (query: OffersQuery): Promise => {
+ const params = new URLSearchParams(query);
+ const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/offers?${params}`, {
+ method: 'GET',
+ });
+
+ return parseResponseBody(response, offersResponseSchema);
+ },
}
const parseResponseBody = async (
diff --git a/packages/web/src/ee/features/lighthouse/servicePing.ts b/packages/web/src/ee/features/lighthouse/servicePing.ts
index df48f824a..8ee0839dc 100644
--- a/packages/web/src/ee/features/lighthouse/servicePing.ts
+++ b/packages/web/src/ee/features/lighthouse/servicePing.ts
@@ -102,16 +102,6 @@ export const syncWithLighthouse = async (orgId: number) => {
},
});
- if (trialEnd) {
- await __unsafePrisma.org.update({
- where: { id: orgId, trialUsedAt: null },
- data: { trialUsedAt: new Date() },
- }).catch(() => {
- // No-op: the `where` matched zero rows because trialUsedAt
- // was already set. Safe to ignore.
- });
- }
-
logger.info(`License synced: entitlements=${entitlements.join(',')}, seats=${seats}, status=${status}`);
}
};
diff --git a/packages/web/src/ee/features/lighthouse/types.ts b/packages/web/src/ee/features/lighthouse/types.ts
index e4db85739..02c77c532 100644
--- a/packages/web/src/ee/features/lighthouse/types.ts
+++ b/packages/web/src/ee/features/lighthouse/types.ts
@@ -47,6 +47,7 @@ export const checkoutRequestSchema = z.object({
installId: z.string(),
quantity: z.number().int().positive(),
requestTrial: z.boolean().default(false),
+ interval: z.enum(['month', 'year']).default('month'),
successUrl: z.string().url(),
cancelUrl: z.string().url(),
});
@@ -90,3 +91,26 @@ export const invoicesResponseSchema = z.object({
hasMore: z.boolean(),
});
export type InvoicesResponse = z.infer;
+
+const pricingTierSchema = z.object({
+ unitAmount: z.number().int(),
+ currency: z.string(),
+});
+
+export const offersQuerySchema = z.object({
+ installId: z.string(),
+});
+export type OffersQuery = z.infer;
+
+export const offersResponseSchema = z.object({
+ pricing: z.object({
+ monthly: pricingTierSchema,
+ annual: pricingTierSchema,
+ }),
+ trial: z.object({
+ durationDays: z.number().int(),
+ eligible: z.boolean(),
+ creditCardRequired: z.boolean(),
+ }),
+});
+export type OffersResponse = z.infer;
diff --git a/packages/web/src/ee/features/lighthouse/useOffers.ts b/packages/web/src/ee/features/lighthouse/useOffers.ts
new file mode 100644
index 000000000..6e56ded3d
--- /dev/null
+++ b/packages/web/src/ee/features/lighthouse/useOffers.ts
@@ -0,0 +1,12 @@
+'use client';
+
+import { getOffers } from "@/app/api/(client)/client";
+import { unwrapServiceError } from "@/lib/utils";
+import { useQuery } from "@tanstack/react-query";
+
+export const useOffers = () => {
+ return useQuery({
+ queryKey: ["offers"],
+ queryFn: async () => unwrapServiceError(getOffers()),
+ });
+}
\ No newline at end of file
diff --git a/packages/web/src/lib/entitlements.ts b/packages/web/src/lib/entitlements.ts
index 9bd5acd77..1dd136272 100644
--- a/packages/web/src/lib/entitlements.ts
+++ b/packages/web/src/lib/entitlements.ts
@@ -2,6 +2,7 @@ import {
_getEntitlements,
_hasEntitlement,
_isAnonymousAccessAvailable,
+ _isValidLicenseActive,
createLogger,
Entitlement,
env,
@@ -61,4 +62,9 @@ export const isAnonymousAccessEnabled = async () => {
const metadata = getOrgMetadata(org);
return !!metadata?.anonymousAccessEnabled;
+}
+
+export const isValidLicenseActive = async () => {
+ const license = await getSingleTenantLicense();
+ return _isValidLicenseActive(license);
}
\ No newline at end of file