diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index f0e4dfcf..a5770b74 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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() } } } diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index f2c26145..6cf0c206 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -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(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(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({ {/* Billing / upgrade CTA or Log in CTA */} - {isRowboatConnected && billing ? ( -
-
-
- - {currentBillingPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')} - - {billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => { - const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24))) - return ( -

- {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 ( +

+ +
+ + + + +
+ + +
+
+ + + +

You've run out of credits

+
+ +
+

+ Upgrade your plan to continue using all features.

- ) - })()} + +
+
+
+ ) + } + return ( +
+
+
+ + {currentBillingPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')} + + {billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => { + const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24))) + return ( +

+ {days === 0 ? 'Trial expires today' : days === 1 ? '1 day left' : `${days} days left`} +

+ ) + })()} +
+
-
-
- ) : null} + ) + })() : null} {/* Sign in CTA */} {!isRowboatConnected && (
diff --git a/apps/x/apps/renderer/src/components/ui/popover.tsx b/apps/x/apps/renderer/src/components/ui/popover.tsx index 27f1af3c..5823782d 100644 --- a/apps/x/apps/renderer/src/components/ui/popover.tsx +++ b/apps/x/apps/renderer/src/components/ui/popover.tsx @@ -43,4 +43,17 @@ function PopoverAnchor({ return } -export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } +function PopoverArrow({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow } diff --git a/apps/x/apps/renderer/src/hooks/useVoiceTTS.ts b/apps/x/apps/renderer/src/hooks/useVoiceTTS.ts index bfdcc4ae..768b8322 100644 --- a/apps/x/apps/renderer/src/hooks/useVoiceTTS.ts +++ b/apps/x/apps/renderer/src/hooks/useVoiceTTS.ts @@ -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 { 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}` }; + } ); } diff --git a/apps/x/apps/renderer/src/lib/billing-error.ts b/apps/x/apps/renderer/src/lib/billing-error.ts index 2e6736ed..fc0cc896 100644 --- a/apps/x/apps/renderer/src/lib/billing-error.ts +++ b/apps/x/apps/renderer/src/lib/billing-error.ts @@ -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.', diff --git a/apps/x/apps/renderer/src/lib/credit-status.ts b/apps/x/apps/renderer/src/lib/credit-status.ts new file mode 100644 index 00000000..2cdc2284 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/credit-status.ts @@ -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)) +}