Files
Momento/memento-note/components/usage-meter.tsx
Antigravity bd495be965
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
- Sidebar: dynamic brand-accent colors, brainstorm section restyled
- AI chat general: popup panel with expand/collapse, hides when contextual AI open
- AI chat contextual: tabs reordered (Actions first), X close button, height fix
- Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.)
- Global color cleanup: emerald/orange hardcoded → brand-accent dynamic
- Brainstorm page: orange → brand-accent throughout
- PageEntry animation component added to key pages
- Floating AI button: bg-brand-accent instead of hardcoded black
- i18n: all 15 locales updated with new AI/billing keys
- Billing: freemium quota tracking, BYOK, stripe subscription scaffolding
- Admin: integrated into new design
- AGENTS.md + CLAUDE.md project rules added
2026-05-16 12:59:30 +00:00

204 lines
7.2 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { cn } from '@/lib/utils';
import { Sparkles, X, Crown } from 'lucide-react';
import { useLanguage } from '@/lib/i18n';
interface QuotaData {
remaining: number;
limit: number;
used: number;
}
interface UsageMeterProps {
className?: string;
}
function formatRemaining(value: number): string {
if (!Number.isFinite(value)) return '∞';
return String(value);
}
export function UsageMeter({ className }: UsageMeterProps) {
const { t } = useLanguage();
const router = useRouter();
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 data = await res.json();
return data.quotas as Record<string, QuotaData>;
},
refetchInterval: 30000,
});
if (isLoading || !data) {
return (
<div className={cn('px-2 py-2', className)}>
<div className="h-8 rounded-lg bg-paper/50 animate-pulse" />
</div>
);
}
const features = [
{ key: 'semantic_search', labelKey: 'usageMeter.featureSearch' as const },
{ key: 'auto_tag', labelKey: 'usageMeter.featureTags' as const },
{ key: 'auto_title', labelKey: 'usageMeter.featureTitles' as const },
] as const;
const featureQuotas = features.map((f) => {
const quota = data[f.key];
return {
...f,
label: t(f.labelKey),
used: quota?.used ?? 0,
limit: quota?.limit ?? 0,
remaining: quota?.remaining ?? 0,
};
});
const isUnlimited = featureQuotas.every((f) => !Number.isFinite(f.limit));
const totalRemaining = featureQuotas.reduce(
(sum, f) => sum + (Number.isFinite(f.remaining) ? f.remaining : 0),
0,
);
const totalLimit = featureQuotas.reduce(
(sum, f) => sum + (Number.isFinite(f.limit) ? f.limit : 0),
0,
);
const used = totalLimit - totalRemaining;
const percentage = totalLimit > 0 ? Math.min((used / totalLimit) * 100, 100) : 0;
const isExhausted = !isUnlimited && totalRemaining <= 0;
const isLow = !isUnlimited && percentage >= 75 && !isExhausted;
return (
<>
<div className="px-2 py-2 w-full">
<div className="p-4 bg-slate-50 dark:bg-white/5 border border-border rounded-2xl space-y-3 group hover:shadow-lg transition-all duration-300">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles
size={12}
className={
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>
</div>
<span
className={cn(
'text-[10px] font-medium',
isExhausted
? 'text-rose-500'
: isUnlimited
? 'text-emerald-500'
: isLow
? 'text-amber-500'
: 'text-concrete',
)}
>
{isUnlimited
? t('usageMeter.unlimited')
: t('usageMeter.remaining', { count: totalRemaining })}
</span>
</div>
{!isUnlimited && (
<div className="h-1.5 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-300',
isExhausted
? 'bg-rose-400'
: isLow
? 'bg-amber-400'
: 'bg-brand-accent',
)}
style={{ width: `${percentage}%` }}
/>
</div>
)}
<button
onClick={() => router.push('/settings/billing')}
className="w-full 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') || 'Passer à Pro'}
</button>
</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>
)}
</>
);
}