mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge pull request #637 from prakhar1605/feat/out-of-credits-warning
feat: add out-of-credits warning to sidebar plan badge
This commit is contained in:
commit
1a71b38f2e
6 changed files with 186 additions and 27 deletions
|
|
@ -78,6 +78,7 @@ 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 { dispatchCreditExhausted, dispatchCreditReplenished } from "@/lib/credit-status"
|
||||
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
||||
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||
|
|
@ -941,6 +942,7 @@ function App() {
|
|||
lastHandledBillingErrorIdRef.current = item.id
|
||||
setBillingErrorMatch(match)
|
||||
setBillingErrorOpen(true)
|
||||
if (match.kind === 'out_of_credits') dispatchCreditExhausted()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -2544,6 +2546,7 @@ function App() {
|
|||
const nextUsage = normalizeUsage(llmEvent.usage)
|
||||
if (nextUsage) {
|
||||
setModelUsage(nextUsage)
|
||||
dispatchCreditReplenished()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import {
|
|||
Settings,
|
||||
Square,
|
||||
Video,
|
||||
CircleAlert,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -51,6 +53,7 @@ import {
|
|||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
PopoverArrow,
|
||||
} from "@/components/ui/popover"
|
||||
import {
|
||||
Tooltip,
|
||||
|
|
@ -58,6 +61,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { isOutOfCredits, CREDIT_EXHAUSTED_EVENT, CREDIT_REPLENISHED_EVENT } from "@/lib/credit-status"
|
||||
import { SettingsDialog } from "@/components/settings-dialog"
|
||||
import { MascotFaceIcon } from "@/components/talking-head"
|
||||
import { extractConferenceLink } from "@/lib/calendar-event"
|
||||
|
|
@ -434,9 +438,13 @@ export function SidebarContentPanel({
|
|||
const [openConnectionsAfterClose, setOpenConnectionsAfterClose] = useState(false)
|
||||
const connectorsButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||
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 [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
|
||||
|
||||
// Nav previews: unread important emails + next upcoming meetings (top 2 each).
|
||||
|
|
@ -658,6 +666,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.
|
||||
const previewEmail = emailThreads[0]
|
||||
const previewMeeting = meetings[0]
|
||||
|
|
@ -923,31 +975,89 @@ export function SidebarContentPanel({
|
|||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
{/* Billing / upgrade CTA or Log in CTA */}
|
||||
{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">
|
||||
<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`}
|
||||
{isRowboatConnected && billing ? (() => {
|
||||
const upgradeLabel = !billing.subscriptionPlanId || currentBillingPlan?.category === 'free' || currentBillingPlan?.category === 'starter' ? 'Upgrade' : 'Manage'
|
||||
if (outOfCredits) {
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<Popover open={creditPopoverOpen} onOpenChange={setCreditPopoverOpen}>
|
||||
<div className="flex items-center justify-between rounded-lg border border-red-500/50 bg-red-500/10 px-3 py-2">
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className="flex min-w-0 flex-1 items-center gap-2 text-left">
|
||||
<AlertTriangle className="size-4 shrink-0 text-red-500" />
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
)
|
||||
})()}
|
||||
<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>
|
||||
<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>
|
||||
) : null}
|
||||
)
|
||||
})() : null}
|
||||
{/* Sign in CTA */}
|
||||
{!isRowboatConnected && (
|
||||
<div className="px-3 py-2">
|
||||
|
|
|
|||
|
|
@ -43,4 +43,17 @@ function PopoverAnchor({
|
|||
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, useEffect, useRef, useState } from 'react';
|
||||
import { dispatchCreditReplenished } from '@/lib/credit-status';
|
||||
|
||||
export type TTSState = 'idle' | 'synthesizing' | 'speaking';
|
||||
|
||||
|
|
@ -8,9 +9,12 @@ interface SynthesizedAudio {
|
|||
|
||||
function synthesize(text: string): Promise<SynthesizedAudio> {
|
||||
return window.ipc.invoke('voice:synthesize', { text }).then(
|
||||
(result: { audioBase64: string; mimeType: string }) => ({
|
||||
dataUrl: `data:${result.mimeType};base64,${result.audioBase64}`,
|
||||
})
|
||||
(result: { audioBase64: string; mimeType: string }) => {
|
||||
// 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 = [
|
||||
{
|
||||
kind: 'subscription_required',
|
||||
pattern: /upgrade required/i,
|
||||
title: 'A subscription is required',
|
||||
subtitle: 'Get started with a plan to access AI features in Rowboat.',
|
||||
cta: 'Subscribe',
|
||||
},
|
||||
{
|
||||
kind: 'out_of_credits',
|
||||
pattern: /not enough credits/i,
|
||||
title: "You've run out of credits",
|
||||
subtitle: 'Upgrade your plan for more usage. Daily usage resets at 00:00 UTC.',
|
||||
cta: 'Upgrade plan',
|
||||
},
|
||||
{
|
||||
kind: 'subscription_inactive',
|
||||
pattern: /subscription not active/i,
|
||||
title: 'Your subscription is inactive',
|
||||
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