mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat: add out-of-credits warning to sidebar plan badge
Show a warning state + popover on the sidebar plan badge when the user is out of credits, prompting them to upgrade. Detection comes from two existing signals: the /v1/me billing payload (daily or monthly bucket exhausted, the same data the Settings usage bars use) and the existing 'not enough credits' API-error path. The warning clears on a successful cost-incurring call (LLM finish-step or a successful voice synth) and re-fetches /me once, only when the warning was actually showing -- no polling. Popover auto-opens on first detection and can be dismissed while the indicator persists.
This commit is contained in:
parent
6ba6f494c1
commit
e1fae252bf
6 changed files with 186 additions and 27 deletions
|
|
@ -76,6 +76,7 @@ import { Button } from "@/components/ui/button"
|
||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
import { BillingErrorDialog } from "@/components/billing-error-dialog"
|
import { BillingErrorDialog } from "@/components/billing-error-dialog"
|
||||||
import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error"
|
import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error"
|
||||||
|
import { dispatchCreditExhausted, dispatchCreditReplenished } from "@/lib/credit-status"
|
||||||
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||||
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
||||||
import { extractConferenceLink } from '@/lib/calendar-event'
|
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||||
|
|
@ -923,6 +924,7 @@ function App() {
|
||||||
lastHandledBillingErrorIdRef.current = item.id
|
lastHandledBillingErrorIdRef.current = item.id
|
||||||
setBillingErrorMatch(match)
|
setBillingErrorMatch(match)
|
||||||
setBillingErrorOpen(true)
|
setBillingErrorOpen(true)
|
||||||
|
if (match.kind === 'out_of_credits') dispatchCreditExhausted()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2370,6 +2372,7 @@ function App() {
|
||||||
const nextUsage = normalizeUsage(llmEvent.usage)
|
const nextUsage = normalizeUsage(llmEvent.usage)
|
||||||
if (nextUsage) {
|
if (nextUsage) {
|
||||||
setModelUsage(nextUsage)
|
setModelUsage(nextUsage)
|
||||||
|
dispatchCreditReplenished()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import {
|
||||||
Settings,
|
Settings,
|
||||||
Square,
|
Square,
|
||||||
Video,
|
Video,
|
||||||
|
CircleAlert,
|
||||||
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -51,6 +53,7 @@ import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
|
PopoverArrow,
|
||||||
} from "@/components/ui/popover"
|
} from "@/components/ui/popover"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
|
@ -58,6 +61,7 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status"
|
||||||
import { SettingsDialog } from "@/components/settings-dialog"
|
import { SettingsDialog } from "@/components/settings-dialog"
|
||||||
import { extractConferenceLink } from "@/lib/calendar-event"
|
import { extractConferenceLink } from "@/lib/calendar-event"
|
||||||
import { useBilling } from "@/hooks/useBilling"
|
import { useBilling } from "@/hooks/useBilling"
|
||||||
|
|
@ -430,9 +434,13 @@ export function SidebarContentPanel({
|
||||||
const [openConnectionsAfterClose, setOpenConnectionsAfterClose] = useState(false)
|
const [openConnectionsAfterClose, setOpenConnectionsAfterClose] = useState(false)
|
||||||
const connectorsButtonRef = useRef<HTMLButtonElement | null>(null)
|
const connectorsButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||||
|
const [creditPopoverOpen, setCreditPopoverOpen] = useState(false)
|
||||||
|
const [outOfCredits, setOutOfCredits] = useState(false)
|
||||||
|
const outOfCreditsRef = useRef(false)
|
||||||
|
const creditPopoverAutoShownRef = useRef(false)
|
||||||
const [loggingIn, setLoggingIn] = useState(false)
|
const [loggingIn, setLoggingIn] = useState(false)
|
||||||
const [appUrl, setAppUrl] = useState<string | null>(null)
|
const [appUrl, setAppUrl] = useState<string | null>(null)
|
||||||
const { billing } = useBilling(isRowboatConnected)
|
const { billing, refresh: refreshBilling } = useBilling(isRowboatConnected)
|
||||||
const currentBillingPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
|
const currentBillingPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
|
||||||
|
|
||||||
// Nav previews: unread important emails + next upcoming meetings (top 2 each).
|
// Nav previews: unread important emails + next upcoming meetings (top 2 each).
|
||||||
|
|
@ -654,6 +662,50 @@ export function SidebarContentPanel({
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Re-anchor the warning whenever billing (re)loads — billing is authoritative.
|
||||||
|
useEffect(() => {
|
||||||
|
if (billing) {
|
||||||
|
const next = isOutOfCredits(billing)
|
||||||
|
outOfCreditsRef.current = next
|
||||||
|
setOutOfCredits(next)
|
||||||
|
}
|
||||||
|
}, [billing])
|
||||||
|
|
||||||
|
// Live signals: a usage API error flips it on; a successful cost-incurring
|
||||||
|
// call flips it off and triggers a single billing refresh to reconcile.
|
||||||
|
useEffect(() => {
|
||||||
|
const onExhausted = () => {
|
||||||
|
outOfCreditsRef.current = true
|
||||||
|
setOutOfCredits(true)
|
||||||
|
}
|
||||||
|
const onReplenished = () => {
|
||||||
|
const wasOut = outOfCreditsRef.current
|
||||||
|
outOfCreditsRef.current = false
|
||||||
|
setOutOfCredits(false)
|
||||||
|
if (wasOut) void refreshBilling()
|
||||||
|
}
|
||||||
|
window.addEventListener(CREDIT_EXHAUSTED_EVENT, onExhausted)
|
||||||
|
window.addEventListener(CREDIT_REPLENISHED_EVENT, onReplenished)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(CREDIT_EXHAUSTED_EVENT, onExhausted)
|
||||||
|
window.removeEventListener(CREDIT_REPLENISHED_EVENT, onReplenished)
|
||||||
|
}
|
||||||
|
}, [refreshBilling])
|
||||||
|
|
||||||
|
// Auto-open the popover the first time we go out of credits; reset when
|
||||||
|
// credits return so it can auto-open again on a future episode.
|
||||||
|
useEffect(() => {
|
||||||
|
if (outOfCredits) {
|
||||||
|
if (!creditPopoverAutoShownRef.current) {
|
||||||
|
creditPopoverAutoShownRef.current = true
|
||||||
|
setCreditPopoverOpen(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
creditPopoverAutoShownRef.current = false
|
||||||
|
setCreditPopoverOpen(false)
|
||||||
|
}
|
||||||
|
}, [outOfCredits])
|
||||||
|
|
||||||
// Single preview shown as a sublabel on the Email / Meetings nav buttons.
|
// Single preview shown as a sublabel on the Email / Meetings nav buttons.
|
||||||
const previewEmail = emailThreads[0]
|
const previewEmail = emailThreads[0]
|
||||||
const previewMeeting = meetings[0]
|
const previewMeeting = meetings[0]
|
||||||
|
|
@ -913,31 +965,89 @@ export function SidebarContentPanel({
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
{/* Billing / upgrade CTA or Log in CTA */}
|
{/* Billing / upgrade CTA or Log in CTA */}
|
||||||
{isRowboatConnected && billing ? (
|
{isRowboatConnected && billing ? (() => {
|
||||||
<div className="px-3 py-2">
|
const upgradeLabel = !billing.subscriptionPlanId || currentBillingPlan?.category === 'free' || currentBillingPlan?.category === 'starter' ? 'Upgrade' : 'Manage'
|
||||||
<div className="flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2">
|
if (outOfCredits) {
|
||||||
<div className="min-w-0">
|
return (
|
||||||
<span className="text-xs font-medium capitalize text-sidebar-foreground">
|
<div className="px-3 py-2">
|
||||||
{currentBillingPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}
|
<Popover open={creditPopoverOpen} onOpenChange={setCreditPopoverOpen}>
|
||||||
</span>
|
<div className="flex items-center justify-between rounded-lg border border-red-500/50 bg-red-500/10 px-3 py-2">
|
||||||
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => {
|
<PopoverTrigger asChild>
|
||||||
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
<button type="button" className="flex min-w-0 flex-1 items-center gap-2 text-left">
|
||||||
return (
|
<AlertTriangle className="size-4 shrink-0 text-red-500" />
|
||||||
<p className="text-[10px] text-sidebar-foreground/60">
|
<div className="min-w-0">
|
||||||
{days === 0 ? 'Trial expires today' : days === 1 ? '1 day left' : `${days} days left`}
|
<span className="text-xs font-medium capitalize text-sidebar-foreground">
|
||||||
|
{currentBillingPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}
|
||||||
|
</span>
|
||||||
|
<p className="text-[10px] text-red-500">Out of credits</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{upgradeLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<PopoverContent side="top" align="start" sideOffset={10} className="w-72">
|
||||||
|
<PopoverArrow className="fill-popover" />
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-red-500/15 text-red-500">
|
||||||
|
<CircleAlert className="size-4" />
|
||||||
|
</span>
|
||||||
|
<h4 className="text-sm font-bold text-foreground">You've run out of credits</h4>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close"
|
||||||
|
onClick={() => setCreditPopoverOpen(false)}
|
||||||
|
className="rounded-md p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
Upgrade your plan to continue using all features.
|
||||||
</p>
|
</p>
|
||||||
)
|
<button
|
||||||
})()}
|
onClick={() => { appUrl && window.open(`${appUrl}?intent=upgrade`); setCreditPopoverOpen(false) }}
|
||||||
|
className="mt-3 w-full rounded-md bg-red-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-600"
|
||||||
|
>
|
||||||
|
Upgrade now
|
||||||
|
</button>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-xs font-medium capitalize text-sidebar-foreground">
|
||||||
|
{currentBillingPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : '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}?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"
|
||||||
|
>
|
||||||
|
{upgradeLabel}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{!billing.subscriptionPlanId || currentBillingPlan?.category === 'free' || currentBillingPlan?.category === 'starter' ? 'Upgrade' : 'Manage'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
) : null}
|
})() : null}
|
||||||
{/* Sign in CTA */}
|
{/* Sign in CTA */}
|
||||||
{!isRowboatConnected && (
|
{!isRowboatConnected && (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
|
|
|
||||||
|
|
@ -43,4 +43,17 @@ function PopoverAnchor({
|
||||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
function PopoverArrow({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Arrow>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Arrow
|
||||||
|
data-slot="popover-arrow"
|
||||||
|
className={cn("fill-popover", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow }
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { dispatchCreditReplenished } from '@/lib/credit-status';
|
||||||
|
|
||||||
export type TTSState = 'idle' | 'synthesizing' | 'speaking';
|
export type TTSState = 'idle' | 'synthesizing' | 'speaking';
|
||||||
|
|
||||||
|
|
@ -8,9 +9,12 @@ interface SynthesizedAudio {
|
||||||
|
|
||||||
function synthesize(text: string): Promise<SynthesizedAudio> {
|
function synthesize(text: string): Promise<SynthesizedAudio> {
|
||||||
return window.ipc.invoke('voice:synthesize', { text }).then(
|
return window.ipc.invoke('voice:synthesize', { text }).then(
|
||||||
(result: { audioBase64: string; mimeType: string }) => ({
|
(result: { audioBase64: string; mimeType: string }) => {
|
||||||
dataUrl: `data:${result.mimeType};base64,${result.audioBase64}`,
|
// A successful Rowboat voice synth is a cost-incurring call that
|
||||||
})
|
// returned OK, so it proves credits are available again.
|
||||||
|
dispatchCreditReplenished();
|
||||||
|
return { dataUrl: `data:${result.mimeType};base64,${result.audioBase64}` };
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
export const BILLING_ERROR_PATTERNS = [
|
export const BILLING_ERROR_PATTERNS = [
|
||||||
{
|
{
|
||||||
|
kind: 'subscription_required',
|
||||||
pattern: /upgrade required/i,
|
pattern: /upgrade required/i,
|
||||||
title: 'A subscription is required',
|
title: 'A subscription is required',
|
||||||
subtitle: 'Get started with a plan to access AI features in Rowboat.',
|
subtitle: 'Get started with a plan to access AI features in Rowboat.',
|
||||||
cta: 'Subscribe',
|
cta: 'Subscribe',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
kind: 'out_of_credits',
|
||||||
pattern: /not enough credits/i,
|
pattern: /not enough credits/i,
|
||||||
title: "You've run out of credits",
|
title: "You've run out of credits",
|
||||||
subtitle: 'Upgrade your plan for more usage. Daily usage resets at 00:00 UTC.',
|
subtitle: 'Upgrade your plan for more usage. Daily usage resets at 00:00 UTC.',
|
||||||
cta: 'Upgrade plan',
|
cta: 'Upgrade plan',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
kind: 'subscription_inactive',
|
||||||
pattern: /subscription not active/i,
|
pattern: /subscription not active/i,
|
||||||
title: 'Your subscription is inactive',
|
title: 'Your subscription is inactive',
|
||||||
subtitle: 'Reactivate your subscription to continue using AI features.',
|
subtitle: 'Reactivate your subscription to continue using AI features.',
|
||||||
|
|
|
||||||
26
apps/x/apps/renderer/src/lib/credit-status.ts
Normal file
26
apps/x/apps/renderer/src/lib/credit-status.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import type { BillingInfo } from '@x/shared/dist/billing.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user is "out of credits" when EITHER the daily or the monthly bucket is
|
||||||
|
* exhausted. Either exhaustion is what triggers the backend's "not enough
|
||||||
|
* credits" API error, and this mirrors the two usage bars shown in Settings
|
||||||
|
* (Plan usage = monthly, Daily use = daily). `availableCredits` already comes
|
||||||
|
* down in the /v1/me payload, so no extra API work is needed.
|
||||||
|
*/
|
||||||
|
export function isOutOfCredits(billing: BillingInfo): boolean {
|
||||||
|
return billing.daily.availableCredits <= 0 || billing.monthly.availableCredits <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired when we learn the user is out of credits (billing data or a usage API error). */
|
||||||
|
export const CREDIT_EXHAUSTED_EVENT = 'credit-status:exhausted'
|
||||||
|
|
||||||
|
/** Fired when a successful cost-incurring call (LLM / voice) proves credits are available again. */
|
||||||
|
export const CREDIT_REPLENISHED_EVENT = 'credit-status:replenished'
|
||||||
|
|
||||||
|
export function dispatchCreditExhausted(): void {
|
||||||
|
window.dispatchEvent(new Event(CREDIT_EXHAUSTED_EVENT))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchCreditReplenished(): void {
|
||||||
|
window.dispatchEvent(new Event(CREDIT_REPLENISHED_EVENT))
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue