feat: show billing error dialog on out-of-credits errors

This commit is contained in:
Gagancreates 2026-05-21 23:48:31 +05:30
parent c4888e2899
commit 98c0cf3bf2
4 changed files with 113 additions and 25 deletions

View file

@ -63,6 +63,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Toaster } from "@/components/ui/sonner"
import { BillingErrorDialog } from "@/components/billing-error-dialog"
import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error"
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
import { extractConferenceLink } from '@/lib/calendar-event'
@ -793,7 +795,25 @@ function App() {
// Chat state
const [, setMessage] = useState<string>('')
const [conversation, setConversation] = useState<ConversationItem[]>([])
const [billingErrorMatch, setBillingErrorMatch] = useState<BillingErrorMatch | null>(null)
const [billingErrorOpen, setBillingErrorOpen] = useState(false)
const lastHandledBillingErrorIdRef = useRef<string | null>(null)
const [currentAssistantMessage, setCurrentAssistantMessage] = useState<string>('')
useEffect(() => {
for (let i = conversation.length - 1; i >= 0; i--) {
const item = conversation[i]
if (!isErrorMessage(item)) continue
if (item.id === lastHandledBillingErrorIdRef.current) return
const match = matchBillingError(item.message)
if (match) {
lastHandledBillingErrorIdRef.current = item.id
setBillingErrorMatch(match)
setBillingErrorOpen(true)
}
return
}
}, [conversation])
const [, setModelUsage] = useState<LanguageModelUsage | null>(null)
const [runId, setRunId] = useState<string | null>(null)
const runIdRef = useRef<string | null>(null)
@ -5399,6 +5419,11 @@ function App() {
/>
</SidebarSectionProvider>
<Toaster />
<BillingErrorDialog
open={billingErrorOpen}
match={billingErrorMatch}
onOpenChange={setBillingErrorOpen}
/>
<OnboardingModal
open={showOnboarding}
onComplete={handleOnboardingComplete}

View file

@ -0,0 +1,61 @@
import { useEffect, useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import type { BillingErrorMatch } from "@/lib/billing-error"
interface BillingRowboatAccount {
config?: {
appUrl?: string | null
} | null
}
interface BillingErrorDialogProps {
open: boolean
match: BillingErrorMatch | null
onOpenChange: (open: boolean) => void
}
export function BillingErrorDialog({ open, match, onOpenChange }: BillingErrorDialogProps) {
const [appUrl, setAppUrl] = useState<string | null>(null)
useEffect(() => {
if (!open) return
window.ipc
.invoke('account:getRowboat', null)
.then((account: BillingRowboatAccount) => setAppUrl(account.config?.appUrl ?? null))
.catch(() => {})
}, [open])
if (!match) return null
const handleUpgrade = () => {
if (appUrl) window.open(`${appUrl}?intent=upgrade`)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{match.title}</DialogTitle>
<DialogDescription>{match.subtitle}</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Dismiss
</Button>
<Button onClick={handleUpgrade} disabled={!appUrl}>
{match.cta}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -52,6 +52,7 @@ import {
parseAttachedFiles,
toToolState,
} from '@/lib/chat-conversation'
import { matchBillingError } from '@/lib/billing-error'
const streamdownComponents = { pre: MarkdownPreOverride }
@ -87,31 +88,6 @@ function AutoScrollPre({ className, children }: { className?: string; children:
/* ─── Billing error helpers ─── */
const BILLING_ERROR_PATTERNS = [
{
pattern: /upgrade required/i,
title: 'A subscription is required',
subtitle: 'Get started with a plan to access AI features in Rowboat.',
cta: 'Subscribe',
},
{
pattern: /not enough credits/i,
title: 'You\'ve run out of credits',
subtitle: 'Upgrade your plan for more credits. Free usage resets daily at 00:00 UTC.',
cta: 'Upgrade plan',
},
{
pattern: /subscription not active/i,
title: 'Your subscription is inactive',
subtitle: 'Reactivate your subscription to continue using AI features.',
cta: 'Reactivate',
},
] as const
function matchBillingError(message: string) {
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
}
interface BillingRowboatAccount {
config?: {
appUrl?: string | null

View file

@ -0,0 +1,26 @@
export const BILLING_ERROR_PATTERNS = [
{
pattern: /upgrade required/i,
title: 'A subscription is required',
subtitle: 'Get started with a plan to access AI features in Rowboat.',
cta: 'Subscribe',
},
{
pattern: /not enough credits/i,
title: "You've run out of credits",
subtitle: 'Upgrade your plan for more credits. Free usage resets daily at 00:00 UTC.',
cta: 'Upgrade plan',
},
{
pattern: /subscription not active/i,
title: 'Your subscription is inactive',
subtitle: 'Reactivate your subscription to continue using AI features.',
cta: 'Reactivate',
},
] as const
export type BillingErrorMatch = (typeof BILLING_ERROR_PATTERNS)[number]
export function matchBillingError(message: string): BillingErrorMatch | null {
return BILLING_ERROR_PATTERNS.find(({ pattern }) => pattern.test(message)) ?? null
}