← vibesdk
import { useState, useMemo, type ReactNode } from 'react';
import { X } from 'lucide-react';
import { type UsageSummary, type LimitConfig } from '@/hooks/use-limits';
import { CloudflareLogo } from './icons/logos';
import { CREDITS_BANNER_THRESHOLD } from '../../shared/constants/limits';
interface CreditsBannerProps {
limitsData?: UsageSummary | null;
onConnectCloudflare?: () => void;
className?: string;
children?: ReactNode;
/** Border-radius (in px) of the encapsulated element — banner adds matching bottom padding so it tucks flush beneath it. */
radius?: number;
}
function getResetDate(window: LimitWindow, periodSeconds?: number): Date | null {
const now = new Date();
switch (window) {
case 'daily': {
const d = new Date(now);
d.setDate(d.getDate() + 1);
d.setHours(0, 0, 0, 0);
return d;
}
case 'weekly': {
const d = new Date(now);
const daysUntilMonday = (8 - d.getDay()) % 7 || 7;
d.setDate(d.getDate() + daysUntilMonday);
d.setHours(0, 0, 0, 0);
return d;
}
case 'monthly':
return new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0);
case 'rolling':
return periodSeconds ? new Date(now.getTime() + periodSeconds * 1000) : null;
case 'lifetime':
default:
return null;
}
}
/**
* Plain money string ('$61.57' for USD, '€12.00' for EUR, otherwise '12.00 XYZ').
* Avoids Intl's locale prefixes like 'US$' that appear in non-US locales.
*/
function formatMoney(amount: number, currency: string): string {
const symbols: Record<string, string> = { USD: '$', EUR: '€', GBP: '£' };
const symbol = symbols[currency];
const value = amount.toFixed(2);
return symbol ? `${symbol}${value}` : `${value} ${currency}`;
}
function formatResetIn(target: Date): string {
const diffMs = target.getTime() - Date.now();
if (diffMs <= 0) return 'soon';
const minutes = Math.floor(diffMs / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days >= 1) return `${days}d`;
if (hours >= 1) return `${hours}h`;
return `${Math.max(1, minutes)}m`;
}
type LimitWindow = 'daily' | 'weekly' | 'monthly' | 'lifetime' | 'rolling';
interface BannerInfo {
content: ReactNode;
isConnected: boolean;
}
/** Resolve the display reset date, preferring server-provided `resetAt`. */
function resolveResetDate(limit: LimitConfig): Date | null {
return limit.resetAt
? new Date(limit.resetAt)
: getResetDate(limit.window as LimitWindow, limit.periodSeconds);
}
/**
* Build the banner content for a Cloudflare-connected user whose free tier does not apply
* (either unlimited plan or free tier exhausted). Renders the Cloudflare logo + gateway balance
* when credits are known, otherwise a plain fallback string.
*/
function buildConnectedBalanceContent(
credits: UsageSummary['cloudflareCredits'],
resetDate: Date | null,
fallback: string,
): ReactNode {
const resetSuffix = resetDate ? ` · free credits reset in ${formatResetIn(resetDate)}` : '';
if (!credits) {
return `${fallback}${resetSuffix}`;
}
return (
<span className="inline-flex items-center gap-1">
<CloudflareLogo className="w-3.5 h-3.5" />
<span>{formatMoney(credits.credits, credits.currency)} {resetSuffix}</span>
</span>
);
}
function useBannerInfo(limitsData?: UsageSummary | null): { bannerInfo: BannerInfo | null; dismiss: () => void } {
const [isDismissed, setIsDismissed] = useState(false);
const bannerInfo = useMemo<BannerInfo | null>(() => {
if (isDismissed || !limitsData) return null;
const { config, usage, hasUserToken, hasCloudflareConfigured, cloudflareCredits } = limitsData;
const isConnected = hasUserToken && hasCloudflareConfigured;
// Unlimited access: for Cloudflare-connected users show their gateway balance + when free credits reset.
if (config?.unlimited) {
if (!isConnected) return null;
if (cloudflareCredits && cloudflareCredits.credits >= CREDITS_BANNER_THRESHOLD) return null;
const resetDate = config.limit ? resolveResetDate(config.limit) : null;
return {
content: buildConnectedBalanceContent(cloudflareCredits, resetDate, 'Connected to AI Gateway'),
isConnected: true,
};
}
const limit = config?.limit;
if (!limit) return null;
const current = usage[limit.type]?.[limit.window] || 0;
const remaining = Math.max(0, limit.maxValue - current);
const resetDate = resolveResetDate(limit);
// Hide banner when remaining free credits are above the threshold
if (remaining >= CREDITS_BANNER_THRESHOLD) return null;
// Free tier exhausted: if connected, surface the gateway balance instead of '0 free credits remaining'.
if (remaining <= 0 && isConnected) {
if (cloudflareCredits && cloudflareCredits.credits >= CREDITS_BANNER_THRESHOLD) return null;
return {
content: buildConnectedBalanceContent(cloudflareCredits, resetDate, 'Free credits exhausted · using your AI Gateway'),
isConnected: true,
};
}
const formatNumber = (value: number) => {
const rounded = Math.round(value * 10) / 10;
return Number.isInteger(rounded) ? rounded.toString() : rounded.toFixed(1);
};
const formatValue = (value: number) => {
switch (limit.type) {
case 'prompts': return formatNumber(value);
case 'credits': return formatNumber(value);
case 'tokens': return `${(value / 1000).toFixed(0)}K`;
case 'cost': return `$${value.toFixed(2)}`;
default: return formatNumber(value);
}
};
const unit = (() => {
switch (limit.type) {
case 'prompts': return remaining === 1 ? 'free prompt' : 'free prompts';
case 'credits': return remaining === 1 ? 'free credit' : 'free credits';
case 'tokens': return 'free tokens';
case 'cost': return 'of free credit';
default: return 'free';
}
})();
const resetVerb = limit.window === 'rolling' && !limit.resetAt ? 'resets within' : 'resets in';
const resetText = resetDate ? ` · ${resetVerb} ${formatResetIn(resetDate)}` : '';
return {
content: `${formatValue(remaining)} ${unit} remaining${resetText}`.trim(),
isConnected,
};
}, [limitsData, isDismissed]);
return { bannerInfo, dismiss: () => setIsDismissed(true) };
}
export function CreditsBanner({ limitsData, onConnectCloudflare, className, children, radius = 12 }: CreditsBannerProps) {
const { bannerInfo, dismiss } = useBannerInfo(limitsData);
if (children) {
return (
<div className={`flex flex-col ${className ?? ''}`}>
{bannerInfo && (
<BannerContent
bannerInfo={bannerInfo}
onConnectCloudflare={onConnectCloudflare}
onDismiss={dismiss}
style={{ paddingBottom: radius + 6, marginBottom: -radius, borderTopLeftRadius: radius, borderTopRightRadius: radius, borderBottomLeftRadius: 0, borderBottomRightRadius: 0 }}
/>
)}
{children}
</div>
);
}
if (!bannerInfo) return null;
return (
<BannerContent
bannerInfo={bannerInfo}
onConnectCloudflare={onConnectCloudflare}
onDismiss={dismiss}
className={className}
/>
);
}
function BannerContent({
bannerInfo,
onConnectCloudflare,
onDismiss,
className,
style,
}: {
bannerInfo: BannerInfo;
onConnectCloudflare?: () => void;
onDismiss: () => void;
className?: string;
style?: React.CSSProperties;
}) {
return (
<div
style={style}
className={`flex items-center justify-between px-3 py-1.5 bg-kumo-elevated border border-brand-primary/30 shadow-md ${style?.borderTopLeftRadius !== undefined ? '' : 'rounded-lg'} ${className ?? ''}`}
>
<span className="text-xs font-medium text-kumo-brand-primary">
{bannerInfo.content}
</span>
<div className="flex items-center gap-1.5">
{!bannerInfo.isConnected && (
<button
type="button"
onClick={onConnectCloudflare}
className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-sm text-white bg-brand/25 hover:bg-brand transition-colors duration-200"
>
<CloudflareLogo className="w-3.5 h-3.5" color1="#fff" color2="#fff" />
Connect
</button>
)}
<button
type="button"
onClick={onDismiss}
className="p-0.5 rounded hover:bg-kumo-base text-kumo-subtle hover:text-text-primary transition-colors"
>
<X className="size-3.5" />
</button>
</div>
</div>
);
}