All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- 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
204 lines
7.2 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|