feat(upsell): add usage-aware banners, rate-limit modal, and shared usage hook (#403) (#440)

Phase 1 of the upsell flow for free users:
- Shared `useUsageState` hook with module-level cache (60s TTL)
- Reusable `UpsellBanner` component (info/warning/upgrade variants)
- `UsageLimitModal` shown when user tries to send at hard limit
- `GlobalUpsellBanner` for app-level usage warnings
- Pre-limit warning banner in Conversations at 80%+ usage
- localStorage-based dismiss persistence with cooldown

Closes #403
This commit is contained in:
Cyrus Gray
2026-04-09 00:47:59 +05:30
committed by GitHub
parent 90fac95d7a
commit 320e31f4b3
9 changed files with 449 additions and 15 deletions
+9
View File
@@ -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`.
+2
View File
@@ -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() {
<OnboardingOverlay />
<DictationHotkeyManager />
<LocalAIDownloadSnackbar />
<GlobalUpsellBanner />
</ServiceBlockingGate>
</Router>
</SocketProvider>
+2 -1
View File
@@ -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;
@@ -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<Record<string, boolean>>({});
if (isLoading || !teamUsage) return null;
if (isAtLimit) {
const bannerId = 'global-upgrade';
if (!shouldShowBanner(bannerId, COOLDOWN_UPGRADE_MS) || dismissed[bannerId]) return null;
return (
<div className="fixed top-0 left-0 right-0 z-[9997] px-4 pt-2">
<UpsellBanner
variant="upgrade"
title="Usage limit reached"
message="Upgrade to continue chatting."
ctaLabel="Upgrade"
onCtaClick={() => navigate('/settings/billing')}
dismissible
onDismiss={() => {
dismissBanner(bannerId);
setDismissed(prev => ({ ...prev, [bannerId]: true }));
}}
/>
</div>
);
}
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 (
<div className="fixed top-0 left-0 right-0 z-[9997] px-4 pt-2">
<UpsellBanner
variant="warning"
title="Approaching usage limit"
message={`You've used ${pct}% of your usage limit. Upgrade for higher limits.`}
ctaLabel="Upgrade"
onCtaClick={() => navigate('/settings/billing')}
dismissible
onDismiss={() => {
dismissBanner(bannerId);
setDismissed(prev => ({ ...prev, [bannerId]: true }));
}}
/>
</div>
);
}
return null;
}
@@ -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 (
<div
className={`p-3 rounded-xl border flex items-center justify-between gap-3 ${styles.container}`}>
<div className="flex items-center gap-2 min-w-0">
<svg
className={`w-4 h-4 flex-shrink-0 ${styles.icon}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div className="min-w-0">
<p className={`text-xs font-medium ${styles.title}`}>{title}</p>
<p className={`text-xs ${styles.text} truncate`}>{message}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{ctaLabel && onCtaClick && (
<button
onClick={onCtaClick}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${styles.cta}`}>
{ctaLabel}
</button>
)}
{dismissible && onDismiss && (
<button
onClick={onDismiss}
className="p-1 rounded text-stone-400 hover:text-stone-600 transition-colors"
aria-label="Dismiss">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
</div>
);
}
@@ -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 (
<div className="fixed inset-0 z-[9999] bg-black/30 backdrop-blur-sm flex items-center justify-center">
<div className="bg-white rounded-2xl shadow-xl max-w-sm w-full mx-4 p-6">
<div className="flex flex-col items-center text-center mb-4">
<div className="w-12 h-12 rounded-full bg-amber-100 flex items-center justify-center mb-3">
<svg
className="w-6 h-6 text-amber-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h2 className="text-lg font-semibold text-stone-900">Usage Limit Reached</h2>
</div>
<p className="text-sm text-stone-600 text-center mb-4">{bodyText}</p>
{nextPlan && (
<div className="rounded-xl bg-stone-50 border border-stone-200 p-3 mb-5">
<p className="text-xs font-medium text-stone-700 mb-1">
{nextPlan.name} plan includes:
</p>
<ul className="space-y-0.5">
<li className="text-xs text-stone-600">
${nextPlan.fiveHourCapUsd.toFixed(2)} per 10-hour window
</li>
<li className="text-xs text-stone-600">
${nextPlan.weeklyBudgetUsd}/week included inference
</li>
</ul>
</div>
)}
<div className="flex flex-col gap-2">
<button
onClick={() => {
onClose();
navigate('/settings/billing');
}}
className="w-full py-2.5 rounded-xl bg-primary-600 hover:bg-primary-500 text-white text-sm font-medium transition-colors">
Upgrade Plan
</button>
<button
onClick={onClose}
className="w-full py-2 text-sm text-stone-500 hover:text-stone-700 transition-colors">
Not Now
</button>
</div>
</div>
</div>
);
}
@@ -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;
}
}
+111
View File
@@ -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<TeamUsage | null>(null);
const [currentPlan, setCurrentPlan] = useState<CurrentPlanData | null>(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,
};
}
+46 -14
View File
@@ -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<TeamUsage | null>(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<HTMLDivElement>(null);
const textInputRef = useRef<HTMLTextAreaElement>(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 = () => {
)}
<div className="flex-shrink-0 border-t border-stone-200 px-4 py-3">
{isNearLimit &&
!isAtLimit &&
isFreeTier &&
shouldShowBanner('conversations-warning', 24 * 60 * 60 * 1000) && (
<div className="mb-3">
<UpsellBanner
variant="warning"
title="Approaching usage limit"
message={`You've used ${Math.round(Math.max(usagePct10h, usagePct7d) * 100)}% of your inference budget. Upgrade for higher limits.`}
ctaLabel="Upgrade"
onCtaClick={() => navigate('/settings/billing')}
dismissible
onDismiss={() => dismissBanner('conversations-warning')}
/>
</div>
)}
{teamUsage &&
(teamUsage.remainingUsd <= 0 ||
(!teamUsage.bypassRateLimit &&
@@ -1376,6 +1401,13 @@ const Conversations = () => {
)}
</div>
</div>
<UsageLimitModal
open={showLimitModal}
onClose={() => setShowLimitModal(false)}
isBudgetExhausted={isBudgetExhausted}
resetTime={teamUsage?.fiveHourResetsAt}
currentTier={currentTier}
/>
</div>
);
};