All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 4s
Covers architecture, configuration steps, user flows, API routes, webhooks, pricing, testing with Stripe CLI, production checklist, and troubleshooting.
263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useRouter } from 'next/navigation';
|
|
import { cn } from '@/lib/utils';
|
|
import { Sparkles, ChevronDown, X, Crown } from 'lucide-react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import { useLanguage } from '@/lib/i18n';
|
|
|
|
interface QuotaData {
|
|
remaining: number;
|
|
limit: number;
|
|
used: number;
|
|
}
|
|
|
|
interface UsageMeterProps {
|
|
className?: string;
|
|
}
|
|
|
|
export function UsageMeter({ className }: UsageMeterProps) {
|
|
const { t } = useLanguage();
|
|
const router = useRouter();
|
|
const queryClient = useQueryClient();
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [showModal, setShowModal] = useState(false);
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['usage', 'current'],
|
|
queryFn: async () => {
|
|
const res = await fetch('/api/usage/current');
|
|
if (!res.ok) throw new Error('Failed to fetch quotas');
|
|
const json = await res.json();
|
|
return { quotas: json.quotas as Record<string, QuotaData>, tier: json.tier as string };
|
|
},
|
|
staleTime: 5000,
|
|
refetchInterval: 10000,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const handler = () => queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
|
window.addEventListener('ai-usage-changed', handler);
|
|
return () => window.removeEventListener('ai-usage-changed', handler);
|
|
}, [queryClient]);
|
|
|
|
if (isLoading || !data || !data.quotas) {
|
|
return (
|
|
<div className={cn('px-2 py-2', className)}>
|
|
<div className="h-8 rounded-lg bg-paper/50 animate-pulse" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isProPlus = data.tier && data.tier !== 'BASIC';
|
|
|
|
const featureLabels: Record<string, string> = {
|
|
semantic_search: t('usageMeter.featureSearch'),
|
|
auto_tag: t('usageMeter.featureTags'),
|
|
auto_title: t('usageMeter.featureTitles'),
|
|
reformulate: t('usageMeter.featureReformulate'),
|
|
chat: t('usageMeter.featureChat'),
|
|
brainstorm_create: t('usageMeter.featureBrainstormCreate'),
|
|
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
|
|
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
|
|
};
|
|
|
|
const featureQuotas = Object.entries(data.quotas)
|
|
.filter(([_, q]) => q.limit > 0)
|
|
.map(([key, quota]) => ({
|
|
key,
|
|
label: featureLabels[key] || key,
|
|
used: quota.used,
|
|
limit: quota.limit,
|
|
remaining: quota.remaining,
|
|
}));
|
|
|
|
const isUnlimited = featureQuotas.every((f) => !Number.isFinite(f.limit));
|
|
|
|
const totalUsed = featureQuotas.reduce(
|
|
(sum, f) => sum + (Number.isFinite(f.used) ? f.used : 0), 0,
|
|
);
|
|
const totalLimit = featureQuotas.reduce(
|
|
(sum, f) => sum + (Number.isFinite(f.limit) ? f.limit : 0), 0,
|
|
);
|
|
const totalRemaining = totalLimit - totalUsed;
|
|
const totalPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
|
|
const isExhausted = !isUnlimited && totalRemaining <= 0;
|
|
|
|
return (
|
|
<>
|
|
<div className="px-2 py-2 w-full">
|
|
<div className="bg-slate-50 dark:bg-white/5 border border-border rounded-2xl overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="w-full p-3 flex items-center gap-2 hover:bg-slate-100 dark:hover:bg-white/5 transition-colors"
|
|
>
|
|
<Sparkles
|
|
size={12}
|
|
className={cn(
|
|
'shrink-0',
|
|
isExhausted
|
|
? 'text-rose-400 fill-rose-400/20'
|
|
: isUnlimited
|
|
? 'text-emerald-400 fill-emerald-400/20'
|
|
: 'text-brand-accent fill-brand-accent/20'
|
|
)}
|
|
/>
|
|
<span className="text-[11px] font-bold text-ink/70">{t('usageMeter.packName')}</span>
|
|
|
|
{!isUnlimited && (
|
|
<>
|
|
<div className="flex-1 h-1 bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden mx-2">
|
|
<div
|
|
className={cn(
|
|
'h-full rounded-full',
|
|
totalPct >= 90 ? 'bg-rose-400' : totalPct >= 70 ? 'bg-amber-400' : 'bg-brand-accent',
|
|
)}
|
|
style={{ width: `${Math.min(totalPct, 100)}%` }}
|
|
/>
|
|
</div>
|
|
<span className={cn(
|
|
'text-[10px] font-medium tabular-nums shrink-0',
|
|
totalPct >= 90 ? 'text-rose-500' : totalPct >= 70 ? 'text-amber-500' : 'text-concrete'
|
|
)}>
|
|
{totalRemaining}
|
|
</span>
|
|
</>
|
|
)}
|
|
|
|
{isUnlimited && (
|
|
<span className="text-[10px] font-medium text-emerald-500 ml-auto">{t('usageMeter.unlimited')}</span>
|
|
)}
|
|
|
|
{isProPlus && !isUnlimited && (
|
|
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest ml-auto">{data.tier}</span>
|
|
)}
|
|
|
|
<ChevronDown
|
|
size={12}
|
|
className={cn(
|
|
'text-concrete shrink-0 transition-transform duration-200',
|
|
expanded && 'rotate-180',
|
|
)}
|
|
/>
|
|
</button>
|
|
|
|
<AnimatePresence initial={false}>
|
|
{expanded && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="px-3 pb-3 space-y-2">
|
|
<div className="border-t border-border/40 pt-2" />
|
|
{featureQuotas.map((f) => {
|
|
const fPct = Number.isFinite(f.limit) && f.limit > 0 ? (f.used / f.limit) * 100 : 0
|
|
return (
|
|
<div key={f.key} className="space-y-1">
|
|
<div className="flex justify-between text-[10px]">
|
|
<span className="text-concrete truncate">{f.label}</span>
|
|
<span className={cn(
|
|
'font-medium tabular-nums',
|
|
fPct >= 90 ? 'text-rose-500' : fPct >= 70 ? 'text-amber-500' : 'text-ink/60'
|
|
)}>
|
|
{!Number.isFinite(f.remaining) ? '∞' : f.remaining}/{!Number.isFinite(f.limit) ? '∞' : f.limit}
|
|
</span>
|
|
</div>
|
|
{Number.isFinite(f.limit) && (
|
|
<div className="h-1 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
|
|
<div
|
|
className={cn(
|
|
'h-full rounded-full transition-all duration-300',
|
|
fPct >= 90 ? 'bg-rose-400' : fPct >= 70 ? 'bg-amber-400' : 'bg-brand-accent',
|
|
)}
|
|
style={{ width: `${Math.min(fPct, 100)}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{!isProPlus && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); router.push('/settings/billing'); }}
|
|
className="w-full mt-2 py-2 bg-brand-accent/10 hover:bg-brand-accent text-brand-accent hover:text-white text-[9px] font-bold uppercase tracking-widest rounded-xl transition-all duration-300 border border-brand-accent/20"
|
|
>
|
|
{t('usageMeter.upgradePricing')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
|
|
{showModal && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
<div className="bg-paper border border-border rounded-2xl p-6 mx-4 max-w-sm w-full shadow-xl">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Crown size={18} className="text-amber-500" />
|
|
<h3 className="text-sm font-semibold">{t('usageMeter.upgradeTitle')}</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowModal(false)}
|
|
className="text-muted-ink hover:text-ink transition-colors"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-[12px] text-muted-ink mb-4">
|
|
{t('usageMeter.upgradeDescription')}
|
|
</p>
|
|
|
|
<div className="space-y-2 mb-4">
|
|
<div className="text-[11px] font-medium text-ink">{t('usageMeter.proIncludes')}</div>
|
|
<ul className="text-[11px] text-muted-ink space-y-1">
|
|
<li>• {t('usageMeter.proSearch')}</li>
|
|
<li>• {t('usageMeter.proTags')}</li>
|
|
<li>• {t('usageMeter.proTitles')}</li>
|
|
<li>• {t('usageMeter.proReformulate')}</li>
|
|
<li>• {t('usageMeter.proChat')}</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowModal(false)}
|
|
className="flex-1 px-3 py-2 text-[12px] rounded-xl border border-border hover:bg-foreground/5 transition-colors"
|
|
>
|
|
{t('usageMeter.later')}
|
|
</button>
|
|
<a
|
|
href="/settings/billing"
|
|
className="flex-1 px-3 py-2 text-[12px] font-medium rounded-xl bg-brand-accent text-white text-center hover:opacity-90 transition-colors"
|
|
>
|
|
{t('usageMeter.upgradePricing')}
|
|
</a>
|
|
</div>
|
|
<a
|
|
href="/settings/ai#byok"
|
|
onClick={() => setShowModal(false)}
|
|
className="w-full px-3 py-2 text-[12px] font-medium rounded-xl border border-brand-accent/40 text-brand-accent dark:text-brand-accent text-center hover:bg-brand-accent/10 transition-colors"
|
|
>
|
|
{t('usageMeter.addApiKey')}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|