Skip to content

Commit e93d8d8

Browse files
feat(web): graceful error and loading states for billing/offers surfaces (#1267)
* feat(web): graceful error and loading states for billing/offers surfaces Replace silent `null` returns with recoverable error states when the offers fetch fails, across onboarding, the upsell dialog/panel, and feature gates: - Onboarding trial step: reframe the error copy and add a "View pricing" link. - UpsellDialog / UpsellPanel: shared UpsellLoadError fallback with a retry, Status page link, and role-aware copy (owners get an outbound-access hint to deployments.sourcebot.dev + Learn more; members are routed to their admin). - UpsellPanel: add a `loadingVariant` prop so full-area feature gates render a centered spinner while the in-flow license card keeps its skeleton. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * nit --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent eedfef3 commit e93d8d8

8 files changed

Lines changed: 122 additions & 35 deletions

File tree

packages/web/src/app/(app)/settings/accountAskAgent/accountAskAgentEntitlementMessage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export function AccountAskAgentEntitlementMessage() {
1717
title="Upgrade to use Ask Sourcebot connectors"
1818
description="Connect Ask Sourcebot to your team's tools (like Linear, Notion, and Sentry) so it can pull in context beyond your indexed code."
1919
className="w-full max-w-2xl"
20+
loadingVariant="spinner"
2021
/>
2122
</div>
2223
)

packages/web/src/app/(app)/settings/analytics/analyticsEntitlementMessage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export function AnalyticsEntitlementMessage() {
1717
title="Upgrade to view analytics"
1818
description="Get insights into your organization's usage patterns and activity across search and Ask Sourcebot."
1919
className="w-full max-w-2xl"
20+
loadingVariant="spinner"
2021
/>
2122
</div>
2223
)

packages/web/src/app/(app)/settings/mcp/mcpEntitlementMessage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export function McpEntitlementMessage() {
1717
title="Upgrade to use the MCP server"
1818
description="Connect your agents to Sourcebot to allow them to fetch code context, and more."
1919
className="w-full max-w-2xl"
20+
loadingVariant="spinner"
2021
/>
2122
</div>
2223
)

packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentEntitlementMessage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export function WorkspaceAskAgentEntitlementMessage() {
1818
title="Upgrade to configure Ask Sourcebot connectors"
1919
description="Approve the external tools (like Linear, Notion, and Sentry) that your users can connect to Ask Sourcebot."
2020
className="w-full max-w-2xl"
21+
loadingVariant="spinner"
2122
/>
2223
</div>
2324
)

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

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ function useTrialStepCopy(): TrialStepCopy | null {
2929
}
3030
if (isError || !offers) {
3131
return {
32-
title: "Upgrade to Sourcebot Pro",
33-
subtitle: "Unlock advanced features for your team. You can upgrade later from your license settings.",
32+
title: "You're all set",
33+
subtitle: "Your Sourcebot deployment is ready to go.",
3434
};
3535
}
3636
if (!offers.trial.eligible) {
@@ -203,13 +203,23 @@ export function TrialStep({ stepIndex }: TrialStepProps) {
203203

204204
if (isError || !offers) {
205205
return (
206-
<LoadingButton
207-
onClick={onSkipCheckout}
208-
loading={isSkipLoading}
209-
className="w-full"
210-
>
211-
Continue to Sourcebot
212-
</LoadingButton>
206+
<div className="flex flex-col">
207+
<LoadingButton
208+
onClick={onSkipCheckout}
209+
loading={isSkipLoading}
210+
className="w-full"
211+
>
212+
Continue to Sourcebot
213+
</LoadingButton>
214+
<a
215+
href="https://www.sourcebot.dev/pricing"
216+
target="_blank"
217+
rel="noopener noreferrer"
218+
className="mx-auto text-sm text-muted-foreground hover:text-foreground mt-8"
219+
>
220+
View pricing
221+
</a>
222+
</div>
213223
);
214224
}
215225

packages/web/src/features/billing/upsellDialog.tsx

Lines changed: 96 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ import useCaptureEvent from "@/hooks/useCaptureEvent";
2121
import { UpsellSource } from "@/lib/posthogEvents";
2222
import { cn, isServiceError } from "@/lib/utils";
2323
import { OrgRole } from "@sourcebot/db";
24-
import { ArrowUpCircle, ExternalLink, Loader2 } from "lucide-react";
24+
import { AlertCircle, ArrowUpCircle, ExternalLink, Loader2 } from "lucide-react";
2525
import { useSession } from "next-auth/react";
2626
import { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
2727
import { CheckoutDisclosures } from "./checkoutDisclosures";
28+
import { CodeSnippet } from "@/app/components/codeSnippet";
2829

2930
interface UpsellDialogProps {
3031
open: boolean;
@@ -34,8 +35,7 @@ interface UpsellDialogProps {
3435
}
3536

3637
export function UpsellDialog({ open, onOpenChange, source, returnPath }: UpsellDialogProps) {
37-
const { data: offers, isPending, isError } = useOffers();
38-
const { toast } = useToast();
38+
const { data: offers, isPending, isError, refetch } = useOffers();
3939
const captureEvent = useCaptureEvent();
4040

4141
useEffect(() => {
@@ -44,28 +44,15 @@ export function UpsellDialog({ open, onOpenChange, source, returnPath }: UpsellD
4444
}
4545
}, [open, source, captureEvent]);
4646

47-
// Surface pricing-fetch failures via a toast and dismiss the dialog. Without
48-
// closing it ourselves, the parent's `open` state would keep us mounted but
49-
// we'd have nothing to render — leaving the user stuck with an invisible
50-
// dialog they can't dismiss.
51-
useEffect(() => {
52-
if (open && isError) {
53-
toast({
54-
description: "Something went wrong loading pricing. Please try again.",
55-
variant: "destructive",
56-
});
57-
onOpenChange(false);
58-
}
59-
}, [open, isError, toast, onOpenChange]);
60-
61-
if (isError) {
62-
return null;
63-
}
64-
6547
return (
6648
<Dialog open={open} onOpenChange={onOpenChange}>
6749
<DialogContent className="max-w-2xl gap-6 focus:outline-none">
68-
{isPending || !offers ? (
50+
{isError ? (
51+
// Keep the dialog open with a recoverable error state rather than
52+
// closing it out from under the user — the built-in close button
53+
// is still the dismiss affordance.
54+
<UpsellLoadError variant="dialog" onRetry={() => { void refetch(); }} />
55+
) : isPending ? (
6956
<div className="flex items-center justify-center py-12">
7057
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
7158
</div>
@@ -77,6 +64,77 @@ export function UpsellDialog({ open, onOpenChange, source, returnPath }: UpsellD
7764
);
7865
}
7966

67+
interface UpsellLoadErrorProps {
68+
variant: "dialog" | "inline";
69+
onRetry: () => void;
70+
className?: string;
71+
}
72+
73+
// Shared fallback for when the offers/pricing fetch fails. Offers a retry plus a
74+
// link to the public pricing page — the latter is resilient to the very failure
75+
// that triggered this state, since it doesn't depend on the offers endpoint.
76+
function UpsellLoadError({ variant, onRetry, className }: UpsellLoadErrorProps) {
77+
const role = useRole();
78+
const isOwner = role === OrgRole.OWNER;
79+
const heading = "Something went wrong";
80+
const body = (
81+
<>
82+
We couldn&apos;t reach Sourcebot&apos;s deployments server.{" "}
83+
{/* Owners get an actionable hint (the most common cause on self-hosted
84+
deployments is outbound access to the lighthouse host being blocked);
85+
members can't act on this themselves, so route them to an admin. */}
86+
{isOwner ? (
87+
<>
88+
Check that outbound access to{" "}
89+
<CodeSnippet>deployments.sourcebot.dev</CodeSnippet>{" "}isn&apos;t blocked.{" "}
90+
<a
91+
href="https://docs.sourcebot.dev/docs/misc/service-ping"
92+
target="_blank"
93+
rel="noopener noreferrer"
94+
className="text-link hover:underline"
95+
>
96+
Learn more
97+
</a>
98+
.
99+
</>
100+
) : (
101+
<>Contact your organization admin.</>
102+
)}
103+
</>
104+
);
105+
return (
106+
<div className={cn("flex flex-col gap-6", className)}>
107+
<div className="flex flex-col gap-2 text-center sm:text-left">
108+
<AlertCircle className="h-6 w-6 text-destructive" />
109+
{variant === "dialog" ? (
110+
<DialogTitle>{heading}</DialogTitle>
111+
) : (
112+
<h3 className="text-lg font-semibold leading-none tracking-tight">{heading}</h3>
113+
)}
114+
{variant === "dialog" ? (
115+
<DialogDescription className="text-sm">{body}</DialogDescription>
116+
) : (
117+
<p className="text-sm text-muted-foreground">{body}</p>
118+
)}
119+
</div>
120+
121+
<div className="flex flex-col-reverse items-center gap-2 sm:flex-row sm:justify-end sm:gap-4">
122+
<Button variant="ghost" asChild>
123+
<a
124+
href="https://status.sourcebot.dev"
125+
target="_blank"
126+
rel="noopener noreferrer"
127+
>
128+
Status page
129+
<ExternalLink className="h-3.5 w-3.5 ml-2" />
130+
</a>
131+
</Button>
132+
<Button onClick={onRetry}>Try again</Button>
133+
</div>
134+
</div>
135+
);
136+
}
137+
80138
// Whether the upsell is being shown to a workspace with no usable license at
81139
// all ('free') or to one with an existing online license that's lapsed
82140
// ('expired'). Drives the no-trial-eligible copy so an expired-license user
@@ -92,16 +150,29 @@ interface UpsellPanelProps {
92150
// Sourcebot history"). Fall back to the billing-state-derived copy when omitted.
93151
title?: string;
94152
description?: ReactNode;
153+
// How to render the pending state. Use 'skeleton' (default) when the panel is
154+
// embedded in-flow alongside other content, so the reserved space avoids
155+
// layout shift. Use 'spinner' when the panel is the sole, centered element in
156+
// a large empty region (e.g. a full-area feature gate), where a shaped
157+
// skeleton reads as heavier than a simple spinner.
158+
loadingVariant?: 'skeleton' | 'spinner';
95159
}
96160

97-
export function UpsellPanel({ source, returnPath, className, licenseState = 'free', title, description }: UpsellPanelProps) {
98-
const { data: offers, isPending, isError } = useOffers();
161+
export function UpsellPanel({ source, returnPath, className, licenseState = 'free', title, description, loadingVariant = 'skeleton' }: UpsellPanelProps) {
162+
const { data: offers, isPending, isError, refetch } = useOffers();
99163

100164
if (isError) {
101-
return null;
165+
return <UpsellLoadError variant="inline" onRetry={() => { void refetch(); }} className={className} />;
102166
}
103167

104168
if (isPending || !offers) {
169+
if (loadingVariant === 'spinner') {
170+
return (
171+
<div className={cn("flex items-center justify-center py-12", className)} aria-busy="true">
172+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
173+
</div>
174+
);
175+
}
105176
return (
106177
<div className={cn("flex flex-col gap-6", className)} aria-busy="true">
107178
<div className="flex flex-col gap-2">

packages/web/src/features/billing/useOffers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const useOffers = (params?: {
1010
return useQuery({
1111
queryKey: ["offers"],
1212
queryFn: async () => unwrapServiceError(getOffers()),
13-
retry: params?.retry
13+
retry: params?.retry,
14+
refetchOnWindowFocus: false
1415
});
1516
}

packages/web/src/features/chat/components/chatEntitlementMessage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export function ChatEntitlementMessage({
3333
title={title}
3434
description={description}
3535
className="w-full max-w-2xl"
36+
loadingVariant="spinner"
3637
/>
3738
</div>
3839
)

0 commit comments

Comments
 (0)