Add billing error handling and UI updates

- Introduced billing error patterns to match specific error messages and display appropriate user prompts in the ChatSidebar.
- Enhanced SidebarContentPanel and AccountSettings components to reflect subscription status, including trial expiration details.
- Updated button actions to direct users to the app URL for subscription management and upgrades.
- Added a new Payment section in AccountSettings for managing invoices and payment methods, with conditional rendering based on subscription status.
This commit is contained in:
tusharmagar 2026-04-07 21:51:17 +05:30
parent 2739de6c67
commit 75ffbc781c
6 changed files with 126 additions and 11 deletions

View file

@ -49,6 +49,54 @@ import {
const streamdownComponents = { pre: MarkdownPreOverride }
/* ─── 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, or wait for your billing cycle to reset.',
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
}
function BillingErrorCTA({ label }: { label: string }) {
const [appUrl, setAppUrl] = useState<string | null>(null)
useEffect(() => {
window.ipc.invoke('account:getRowboat', null)
.then((account: any) => setAppUrl(account.config?.appUrl ?? null))
.catch(() => {})
}, [])
if (!appUrl) return null
return (
<button
onClick={() => window.open(`${appUrl}?intent=upgrade`)}
className="mt-1 rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-medium text-amber-100 transition-colors hover:bg-amber-500/30"
>
{label}
</button>
)
}
const MIN_WIDTH = 360
const MAX_WIDTH = 1600
const MIN_MAIN_PANE_WIDTH = 420
@ -378,6 +426,20 @@ export function ChatSidebar({
}
if (isErrorMessage(item)) {
const billingError = matchBillingError(item.message)
if (billingError) {
return (
<Message key={item.id} from="assistant" data-message-id={item.id}>
<MessageContent className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3">
<div className="space-y-2">
<p className="text-sm font-medium text-amber-200">{billingError.title}</p>
<p className="text-xs text-amber-300/80">{billingError.subtitle}</p>
<BillingErrorCTA label={billingError.cta} />
</div>
</MessageContent>
</Message>
)
}
return (
<Message key={item.id} from="assistant" data-message-id={item.id}>
<MessageContent className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-destructive">

View file

@ -1,7 +1,7 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { Loader2, User, CreditCard, LogOut } from "lucide-react"
import { Loader2, User, CreditCard, LogOut, ExternalLink } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
AlertDialog,
@ -162,13 +162,25 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium capitalize">{billing.subscriptionPlan ?? 'Free'} Plan</p>
{billing.subscriptionStatus && (
<p className="text-sm font-medium capitalize">
{billing.subscriptionPlan ? `${billing.subscriptionPlan} Plan` : 'No Plan'}
</p>
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
return (
<p className="text-xs text-muted-foreground">
Trial · {days === 0 ? 'expires today' : days === 1 ? '1 day left' : `${days} days left`}
</p>
)
})() : billing.subscriptionStatus ? (
<p className="text-xs text-muted-foreground capitalize">{billing.subscriptionStatus}</p>
) : null}
{!billing.subscriptionPlan && (
<p className="text-xs text-muted-foreground">Subscribe to access AI features</p>
)}
</div>
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(appUrl)}>
Upgrade
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
{!billing.subscriptionPlan ? 'Subscribe' : 'Change plan'}
</Button>
</div>
</div>
@ -179,6 +191,32 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
<Separator />
{/* Payment Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<CreditCard className="size-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Payment</h4>
</div>
<p className="text-xs text-muted-foreground">
Manage invoices, payment methods, and billing details.
</p>
<Button
variant="outline"
size="sm"
disabled={!billing?.subscriptionPlan}
onClick={() => appUrl && window.open(appUrl)}
className="gap-1.5"
>
<ExternalLink className="size-3" />
Manage in Stripe
</Button>
{!billing?.subscriptionPlan && (
<p className="text-[11px] text-muted-foreground">Subscribe to a plan first</p>
)}
</div>
<Separator />
{/* Log Out Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">

View file

@ -515,14 +515,24 @@ export function SidebarContentPanel({
{isRowboatConnected && billing ? (
<div className="px-3 py-2">
<div className="flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2">
<span className="text-xs font-medium capitalize text-sidebar-foreground">
{billing.subscriptionPlan ?? 'Free'} plan
</span>
<div className="min-w-0">
<span className="text-xs font-medium capitalize text-sidebar-foreground">
{billing.subscriptionPlan ? `${billing.subscriptionPlan} plan` : 'No plan'}
</span>
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => {
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
return (
<p className="text-[10px] text-sidebar-foreground/60">
{days === 0 ? 'Trial expires today' : days === 1 ? '1 day left' : `${days} days left`}
</p>
)
})()}
</div>
<button
onClick={() => appUrl && window.open(appUrl)}
className="rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20"
onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}
className="shrink-0 rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20"
>
Upgrade
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'starter' ? 'Upgrade' : 'Manage'}
</button>
</div>
</div>

View file

@ -5,6 +5,7 @@ interface BillingInfo {
userId: string | null
subscriptionPlan: string | null
subscriptionStatus: string | null
trialExpiresAt: string | null
sanctionedCredits: number
availableCredits: number
}

View file

@ -6,6 +6,7 @@ export interface BillingInfo {
userId: string | null;
subscriptionPlan: string | null;
subscriptionStatus: string | null;
trialExpiresAt: string | null;
sanctionedCredits: number;
availableCredits: number;
}
@ -26,6 +27,7 @@ export async function getBillingInfo(): Promise<BillingInfo> {
billing: {
plan: string | null;
status: string | null;
trialExpiresAt: string | null;
usage: {
sanctionedCredits: number;
availableCredits: number;
@ -37,6 +39,7 @@ export async function getBillingInfo(): Promise<BillingInfo> {
userId: body.user.id ?? null,
subscriptionPlan: body.billing.plan,
subscriptionStatus: body.billing.status,
trialExpiresAt: body.billing.trialExpiresAt ?? null,
sanctionedCredits: body.billing.usage.sanctionedCredits,
availableCredits: body.billing.usage.availableCredits,
};

View file

@ -566,6 +566,7 @@ const ipcSchemas = {
userId: z.string().nullable(),
subscriptionPlan: z.string().nullable(),
subscriptionStatus: z.string().nullable(),
trialExpiresAt: z.string().nullable(),
sanctionedCredits: z.number(),
availableCredits: z.number(),
}),