@@ -21,10 +21,11 @@ import useCaptureEvent from "@/hooks/useCaptureEvent";
2121import { UpsellSource } from "@/lib/posthogEvents" ;
2222import { cn , isServiceError } from "@/lib/utils" ;
2323import { OrgRole } from "@sourcebot/db" ;
24- import { ArrowUpCircle , ExternalLink , Loader2 } from "lucide-react" ;
24+ import { AlertCircle , ArrowUpCircle , ExternalLink , Loader2 } from "lucide-react" ;
2525import { useSession } from "next-auth/react" ;
2626import { ReactNode , useCallback , useEffect , useMemo , useState } from "react" ;
2727import { CheckoutDisclosures } from "./checkoutDisclosures" ;
28+ import { CodeSnippet } from "@/app/components/codeSnippet" ;
2829
2930interface UpsellDialogProps {
3031 open : boolean ;
@@ -34,8 +35,7 @@ interface UpsellDialogProps {
3435}
3536
3637export 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't reach Sourcebot'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'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" >
0 commit comments