diff --git a/.claude/memory.md b/.claude/memory.md index 5a8fd2947..650b1aa3b 100644 --- a/.claude/memory.md +++ b/.claude/memory.md @@ -79,6 +79,15 @@ Quick reference for anyone starting with Claude on this project. Updated by the - **Agent message bubbles** need `bg-stone-200/80` (not `bg-stone-100`) on `#F5F5F5` background — `bg-stone-100` is nearly invisible. - **~55 files touched** — purely CSS class changes, zero logic/handler/state changes. +## Upsell / Billing (Phase 1 — Issue #403) + +- **Upsell components** live in `app/src/components/upsell/` — `UpsellBanner`, `UsageLimitModal`, `GlobalUpsellBanner`, `upsellDismissState`. Shared hook: `app/src/hooks/useUsageState.ts`. +- **Usage data sources** — `creditsApi.getTeamUsage()` returns `TeamUsage` (rolling 10h spend/cap + weekly budget/remaining). `billingApi.getCurrentPlan()` returns `CurrentPlanData` (plan tier, caps, subscription status). Both go through `callCoreCommand` (core RPC). No Redux slice — all local hook state. +- **Module-level cache in `useUsageState`** — `_cache` variable with 60s TTL prevents duplicate API calls when multiple components mount simultaneously. New pattern; do not remove. +- **Banner dismiss state uses localStorage** (prefix `openhuman:upsell:`), not Redux — consistent with CLAUDE.md exception for ephemeral UI state. +- **Phased rollout** — Phase 1 = banners + limit modal + hook. Phase 2 = onboarding upsell + analytics. Phase 3 = remote config + A/B testing. +- **"5-hour" label stragglers in Conversations.tsx** — `LimitPill` label and its hover tooltip still say "5h" / "5-hour". Commit 8c52236's "10-hour" terminology refactor missed those two spots. + ## Environment - **Core sidecar port** — `7788` (default). Check with `lsof -i :7788`. diff --git a/app/src/App.tsx b/app/src/App.tsx index 9c62a1b30..988e431b4 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -12,6 +12,7 @@ import LocalAIDownloadSnackbar from './components/LocalAIDownloadSnackbar'; import MeshGradient from './components/MeshGradient'; import OnboardingOverlay from './components/OnboardingOverlay'; import RouteLoadingScreen from './components/RouteLoadingScreen'; +import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner'; import CoreStateProvider from './providers/CoreStateProvider'; import SocketProvider from './providers/SocketProvider'; import { tagErrorSource } from './services/errorReportQueue'; @@ -44,6 +45,7 @@ function App() { + diff --git a/app/src/chat/chatSendError.ts b/app/src/chat/chatSendError.ts index 3b61055e1..08b0efcad 100644 --- a/app/src/chat/chatSendError.ts +++ b/app/src/chat/chatSendError.ts @@ -9,7 +9,8 @@ export type ChatSendErrorCode = | 'microphone_recording' | 'microphone_access' | 'voice_playback' - | 'safety_timeout'; + | 'safety_timeout' + | 'usage_limit_reached'; export interface ChatSendError { code: ChatSendErrorCode; diff --git a/app/src/components/upsell/GlobalUpsellBanner.tsx b/app/src/components/upsell/GlobalUpsellBanner.tsx new file mode 100644 index 000000000..2c0723b1c --- /dev/null +++ b/app/src/components/upsell/GlobalUpsellBanner.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useUsageState } from '../../hooks/useUsageState'; +import UpsellBanner from './UpsellBanner'; +import { dismissBanner, shouldShowBanner } from './upsellDismissState'; + +const COOLDOWN_WARNING_MS = 24 * 60 * 60 * 1000; +const COOLDOWN_UPGRADE_MS = 7 * 24 * 60 * 60 * 1000; + +export default function GlobalUpsellBanner() { + const navigate = useNavigate(); + const { teamUsage, isLoading, isAtLimit, isNearLimit, isFreeTier, usagePct10h, usagePct7d } = + useUsageState(); + + const [dismissed, setDismissed] = useState>({}); + + if (isLoading || !teamUsage) return null; + + if (isAtLimit) { + const bannerId = 'global-upgrade'; + if (!shouldShowBanner(bannerId, COOLDOWN_UPGRADE_MS) || dismissed[bannerId]) return null; + return ( +
+ navigate('/settings/billing')} + dismissible + onDismiss={() => { + dismissBanner(bannerId); + setDismissed(prev => ({ ...prev, [bannerId]: true })); + }} + /> +
+ ); + } + + if (isNearLimit && isFreeTier) { + const bannerId = 'global-warning'; + if (!shouldShowBanner(bannerId, COOLDOWN_WARNING_MS) || dismissed[bannerId]) return null; + const pct = Math.round(Math.max(usagePct10h, usagePct7d) * 100); + return ( +
+ navigate('/settings/billing')} + dismissible + onDismiss={() => { + dismissBanner(bannerId); + setDismissed(prev => ({ ...prev, [bannerId]: true })); + }} + /> +
+ ); + } + + return null; +} diff --git a/app/src/components/upsell/UpsellBanner.tsx b/app/src/components/upsell/UpsellBanner.tsx new file mode 100644 index 000000000..e8b23e56a --- /dev/null +++ b/app/src/components/upsell/UpsellBanner.tsx @@ -0,0 +1,93 @@ +interface UpsellBannerProps { + variant: 'info' | 'warning' | 'upgrade'; + title: string; + message: string; + ctaLabel?: string; + onCtaClick?: () => void; + dismissible?: boolean; + onDismiss?: () => void; +} + +const VARIANT_STYLES = { + info: { + container: 'bg-blue-50 border-blue-200', + icon: 'text-blue-400', + title: 'text-blue-700', + text: 'text-blue-600', + cta: 'bg-blue-500 hover:bg-blue-400 text-white', + }, + warning: { + container: 'bg-amber-50 border-amber-200', + icon: 'text-amber-400', + title: 'text-amber-700', + text: 'text-amber-600', + cta: 'bg-amber-500 hover:bg-amber-400 text-white', + }, + upgrade: { + container: 'bg-coral-50 border-coral-200', + icon: 'text-coral-400', + title: 'text-coral-700', + text: 'text-coral-600', + cta: 'bg-coral-500 hover:bg-coral-400 text-white', + }, +}; + +export default function UpsellBanner({ + variant, + title, + message, + ctaLabel, + onCtaClick, + dismissible, + onDismiss, +}: UpsellBannerProps) { + const styles = VARIANT_STYLES[variant]; + + return ( +
+
+ + + +
+

{title}

+

{message}

+
+
+
+ {ctaLabel && onCtaClick && ( + + )} + {dismissible && onDismiss && ( + + )} +
+
+ ); +} diff --git a/app/src/components/upsell/UsageLimitModal.tsx b/app/src/components/upsell/UsageLimitModal.tsx new file mode 100644 index 000000000..cb9521649 --- /dev/null +++ b/app/src/components/upsell/UsageLimitModal.tsx @@ -0,0 +1,106 @@ +import { useNavigate } from 'react-router-dom'; + +import type { PlanTier } from '../../types/api'; +import { PLANS } from '../settings/panels/billingHelpers'; + +interface UsageLimitModalProps { + open: boolean; + onClose: () => void; + isBudgetExhausted: boolean; + resetTime?: string | null; + currentTier: PlanTier; +} + +function formatResetTime(isoStr: string): string { + const ms = new Date(isoStr).getTime() - Date.now(); + if (ms <= 0) return 'now'; + const mins = Math.ceil(ms / 60_000); + if (mins < 60) return `in ${mins}m`; + const hours = Math.floor(mins / 60); + const remMins = mins % 60; + if (hours < 24) return remMins > 0 ? `in ${hours}h ${remMins}m` : `in ${hours}h`; + const days = Math.floor(hours / 24); + const remHours = hours % 24; + return remHours > 0 ? `in ${days}d ${remHours}h` : `in ${days}d`; +} + +function getNextPlan(currentTier: PlanTier) { + const currentIndex = PLANS.findIndex(p => p.tier === currentTier); + if (currentIndex === -1 || currentIndex >= PLANS.length - 1) return null; + return PLANS[currentIndex + 1]; +} + +export default function UsageLimitModal({ + open, + onClose, + isBudgetExhausted, + resetTime, + currentTier, +}: UsageLimitModalProps) { + const navigate = useNavigate(); + const nextPlan = getNextPlan(currentTier); + + if (!open) return null; + + const bodyText = isBudgetExhausted + ? 'Your weekly inference budget is exhausted. Upgrade your plan or top up credits to continue.' + : `You've hit your 10-hour inference rate limit.${resetTime ? ` It resets ${formatResetTime(resetTime)}.` : ''} Upgrade for higher limits.`; + + return ( +
+
+
+
+ + + +
+

Usage Limit Reached

+
+ +

{bodyText}

+ + {nextPlan && ( +
+

+ {nextPlan.name} plan includes: +

+
    +
  • + ${nextPlan.fiveHourCapUsd.toFixed(2)} per 10-hour window +
  • +
  • + ${nextPlan.weeklyBudgetUsd}/week included inference +
  • +
+
+ )} + +
+ + +
+
+
+ ); +} diff --git a/app/src/components/upsell/upsellDismissState.ts b/app/src/components/upsell/upsellDismissState.ts new file mode 100644 index 000000000..1616c75b0 --- /dev/null +++ b/app/src/components/upsell/upsellDismissState.ts @@ -0,0 +1,16 @@ +const STORAGE_PREFIX = 'openhuman:upsell:'; + +export function dismissBanner(bannerId: string): void { + localStorage.setItem(`${STORAGE_PREFIX}${bannerId}`, JSON.stringify({ dismissedAt: Date.now() })); +} + +export function shouldShowBanner(bannerId: string, cooldownMs: number): boolean { + const raw = localStorage.getItem(`${STORAGE_PREFIX}${bannerId}`); + if (!raw) return true; + try { + const { dismissedAt } = JSON.parse(raw) as { dismissedAt: number }; + return Date.now() - dismissedAt > cooldownMs; + } catch { + return true; + } +} diff --git a/app/src/hooks/useUsageState.ts b/app/src/hooks/useUsageState.ts new file mode 100644 index 000000000..0c934af6d --- /dev/null +++ b/app/src/hooks/useUsageState.ts @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { billingApi } from '../services/api/billingApi'; +import { creditsApi, type TeamUsage } from '../services/api/creditsApi'; +import type { CurrentPlanData, PlanTier } from '../types/api'; + +export interface UsageState { + teamUsage: TeamUsage | null; + currentPlan: CurrentPlanData | null; + currentTier: PlanTier; + isFreeTier: boolean; + usagePct10h: number; + usagePct7d: number; + isNearLimit: boolean; + isAtLimit: boolean; + isRateLimited: boolean; + isBudgetExhausted: boolean; + isLoading: boolean; + refresh: () => void; +} + +const CACHE_TTL_MS = 60_000; + +let _cache: { + data: { teamUsage: TeamUsage; currentPlan: CurrentPlanData }; + fetchedAt: number; +} | null = null; + +async function fetchUsageData(): Promise<{ teamUsage: TeamUsage; currentPlan: CurrentPlanData }> { + if (_cache && Date.now() - _cache.fetchedAt < CACHE_TTL_MS) { + return _cache.data; + } + const [teamUsage, currentPlan] = await Promise.all([ + creditsApi.getTeamUsage(), + billingApi.getCurrentPlan(), + ]); + _cache = { data: { teamUsage, currentPlan }, fetchedAt: Date.now() }; + return _cache.data; +} + +export function useUsageState(): UsageState { + const [teamUsage, setTeamUsage] = useState(null); + const [currentPlan, setCurrentPlan] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [fetchCount, setFetchCount] = useState(0); + + const refresh = useCallback(() => { + _cache = null; + setFetchCount(c => c + 1); + }, []); + + useEffect(() => { + let cancelled = false; + setIsLoading(true); + fetchUsageData() + .then(data => { + if (cancelled) return; + setTeamUsage(data.teamUsage); + setCurrentPlan(data.currentPlan); + }) + .catch(() => { + // Usage unavailable — silently ignore + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [fetchCount]); + + const currentTier: PlanTier = currentPlan?.plan ?? 'FREE'; + const isFreeTier = currentTier === 'FREE'; + + const usagePct10h = + teamUsage && teamUsage.fiveHourCapUsd > 0 + ? Math.min(1, teamUsage.fiveHourSpendUsd / teamUsage.fiveHourCapUsd) + : 0; + + const usagePct7d = + teamUsage && teamUsage.cycleBudgetUsd > 0 + ? Math.min(1, (teamUsage.cycleBudgetUsd - teamUsage.remainingUsd) / teamUsage.cycleBudgetUsd) + : 0; + + const isBudgetExhausted = teamUsage ? teamUsage.remainingUsd <= 0 : false; + + const isRateLimited = + teamUsage !== null && + !teamUsage.bypassRateLimit && + teamUsage.fiveHourCapUsd > 0 && + teamUsage.fiveHourSpendUsd >= teamUsage.fiveHourCapUsd; + + const isAtLimit = isBudgetExhausted || isRateLimited; + + const isNearLimit = !isAtLimit && teamUsage !== null && (usagePct10h >= 0.8 || usagePct7d >= 0.8); + + return { + teamUsage, + currentPlan, + currentTier, + isFreeTier, + usagePct10h, + usagePct7d, + isNearLimit, + isAtLimit, + isRateLimited, + isBudgetExhausted, + isLoading, + refresh, + }; +} diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 982babc2c..0248508ef 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -4,7 +4,10 @@ import Markdown from 'react-markdown'; import { useNavigate } from 'react-router-dom'; import { type ChatSendError, chatSendError } from '../chat/chatSendError'; -import { creditsApi, type TeamUsage } from '../services/api/creditsApi'; +import UpsellBanner from '../components/upsell/UpsellBanner'; +import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDismissState'; +import UsageLimitModal from '../components/upsell/UsageLimitModal'; +import { useUsageState } from '../hooks/useUsageState'; import { chatCancel, type ChatSegmentEvent, @@ -203,8 +206,18 @@ const Conversations = () => { selectedThreadIdRef.current = selectedThreadId; }, [selectedThreadId]); - const [teamUsage, setTeamUsage] = useState(null); - const [isLoadingBudget, setIsLoadingBudget] = useState(false); + const { + teamUsage, + isLoading: isLoadingBudget, + isAtLimit, + isBudgetExhausted, + isNearLimit, + isFreeTier, + usagePct10h, + usagePct7d, + currentTier, + } = useUsageState(); + const [showLimitModal, setShowLimitModal] = useState(false); const messagesEndRef = useRef(null); const textInputRef = useRef(null); @@ -248,17 +261,6 @@ const Conversations = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [dispatch]); - useEffect(() => { - setIsLoadingBudget(true); - creditsApi - .getTeamUsage() - .then(data => setTeamUsage(data)) - .catch(() => { - // Budget unavailable — silently ignore - }) - .finally(() => setIsLoadingBudget(false)); - }, []); - useEffect(() => { if (selectedThreadId) dispatch(setLastViewed(selectedThreadId)); }, [selectedThreadId, dispatch]); @@ -628,6 +630,13 @@ const Conversations = () => { const trimmed = normalized.trim(); if (!trimmed || !selectedThreadId || isSending) return; + if (isAtLimit) { + setShowLimitModal(true); + setSendError( + chatSendError('usage_limit_reached', 'Usage limit reached. Upgrade or wait for reset.') + ); + return; + } if (socketStatus !== 'connected') { setSendError( chatSendError( @@ -1149,6 +1158,22 @@ const Conversations = () => { )}
+ {isNearLimit && + !isAtLimit && + isFreeTier && + shouldShowBanner('conversations-warning', 24 * 60 * 60 * 1000) && ( +
+ navigate('/settings/billing')} + dismissible + onDismiss={() => dismissBanner('conversations-warning')} + /> +
+ )} {teamUsage && (teamUsage.remainingUsd <= 0 || (!teamUsage.bypassRateLimit && @@ -1376,6 +1401,13 @@ const Conversations = () => { )}
+ setShowLimitModal(false)} + isBudgetExhausted={isBudgetExhausted} + resetTime={teamUsage?.fiveHourResetsAt} + currentTier={currentTier} + /> ); };