feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
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
This commit is contained in:
@@ -2,16 +2,9 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Settings, Sparkles, Palette, User, Database, Info, Key } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Settings, Sparkles, Palette, User, Database, Info, Key, CreditCard } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface SettingsSection {
|
||||
id: string
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
href: string
|
||||
}
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
interface SettingsNavProps {
|
||||
className?: string
|
||||
@@ -21,33 +14,37 @@ export function SettingsNav({ className }: SettingsNavProps) {
|
||||
const pathname = usePathname()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const sections: SettingsSection[] = [
|
||||
{ id: 'general', label: t('generalSettings.title'), icon: <Settings className="h-4 w-4" />, href: '/settings/general' },
|
||||
{ id: 'ai', label: t('aiSettings.title'), icon: <Sparkles className="h-4 w-4" />, href: '/settings/ai' },
|
||||
{ id: 'appearance', label: t('appearance.title'), icon: <Palette className="h-4 w-4" />, href: '/settings/appearance' },
|
||||
{ id: 'profile', label: t('profile.title'), icon: <User className="h-4 w-4" />, href: '/settings/profile' },
|
||||
{ id: 'data', label: t('dataManagement.title'), icon: <Database className="h-4 w-4" />, href: '/settings/data' },
|
||||
{ id: 'mcp', label: t('mcpSettings.title'), icon: <Key className="h-4 w-4" />, href: '/settings/mcp' },
|
||||
{ id: 'about', label: t('about.title'), icon: <Info className="h-4 w-4" />, href: '/settings/about' },
|
||||
const tabs = [
|
||||
{ id: 'general', label: t('generalSettings.title'), icon: <Settings size={14} />, href: '/settings/general' },
|
||||
{ id: 'ai', label: t('aiSettings.title'), icon: <Sparkles size={14} />, href: '/settings/ai' },
|
||||
{ id: 'billing', label: t('billing.title'), icon: <CreditCard size={14} />, href: '/settings/billing' },
|
||||
{ id: 'appearance', label: t('appearance.title'), icon: <Palette size={14} />, href: '/settings/appearance' },
|
||||
{ id: 'profile', label: t('profile.title'), icon: <User size={14} />, href: '/settings/profile' },
|
||||
{ id: 'data', label: t('dataManagement.title'), icon: <Database size={14} />, href: '/settings/data' },
|
||||
{ id: 'mcp', label: t('mcpSettings.title'), icon: <Key size={14} />, href: '/settings/mcp' },
|
||||
{ id: 'about', label: t('about.title'), icon: <Info size={14} />, href: '/settings/about' },
|
||||
]
|
||||
|
||||
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
|
||||
|
||||
return (
|
||||
<nav className={cn('flex items-center gap-6 border-b border-border/40', className)}>
|
||||
{sections.map((section) => (
|
||||
<nav className={`flex items-center gap-1 border-b border-border/40 pb-px ${className || ''}`}>
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={section.id}
|
||||
href={section.href}
|
||||
className={cn(
|
||||
'flex items-center gap-2 pb-3 pt-4 text-[11px] font-bold uppercase tracking-[0.15em] transition-all whitespace-nowrap border-b-2',
|
||||
isActive(section.href)
|
||||
? 'border-[#D4A373] text-ink'
|
||||
: 'border-transparent text-muted-ink hover:text-ink'
|
||||
)}
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className="flex items-center gap-2.5 px-6 py-5 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap text-concrete hover:text-ink/60"
|
||||
style={{ color: isActive(tab.href) ? 'var(--ink)' : undefined }}
|
||||
>
|
||||
{section.icon}
|
||||
<span>{section.label}</span>
|
||||
<span style={{ color: isActive(tab.href) ? 'var(--ink)' : 'var(--concrete)' }}>{tab.icon}</span>
|
||||
{tab.label}
|
||||
{isActive(tab.href) && (
|
||||
<motion.div
|
||||
layoutId="activeSettingsTabLine"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-ink"
|
||||
transition={{ type: 'spring', bounce: 0.1, duration: 0.8 }}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
48
memento-note/components/settings/billing-history.tsx
Normal file
48
memento-note/components/settings/billing-history.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { ExternalLink, Receipt } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
export function BillingHistory() {
|
||||
const { t } = useLanguage();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleOpenPortal = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/billing/portal', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'Failed');
|
||||
window.location.href = data.url;
|
||||
} catch {
|
||||
// ignore — portal not configured
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/40 bg-paper p-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">{t('billing.billingHistory')}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('billing.noUsage')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenPortal}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-60"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('billing.viewInvoices')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
432
memento-note/components/settings/billing-plans.tsx
Normal file
432
memento-note/components/settings/billing-plans.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
|
||||
import { Check, Shield, Zap, Crown, X, ExternalLink, Loader2, CheckCircle2, Activity, Clock, ArrowRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import { toast } from 'sonner';
|
||||
import { format } from 'date-fns';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
type Tier = 'PRO' | 'BUSINESS';
|
||||
type Interval = 'month' | 'year';
|
||||
|
||||
interface BillingStatus {
|
||||
tier: string;
|
||||
effectiveTier: string;
|
||||
status: string;
|
||||
currentPeriodEnd: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
hasStripeSubscription: boolean;
|
||||
}
|
||||
|
||||
const billingEnabled = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true';
|
||||
|
||||
let stripePromise: ReturnType<typeof loadStripe> | null = null;
|
||||
function getStripePromise() {
|
||||
if (!billingEnabled) return null;
|
||||
if (!stripePromise && process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) {
|
||||
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
|
||||
}
|
||||
return stripePromise;
|
||||
}
|
||||
|
||||
export function BillingPlans() {
|
||||
const { t } = useLanguage();
|
||||
const queryClient = useQueryClient();
|
||||
const [interval, setInterval] = useState<Interval>('month');
|
||||
const [checkoutClientSecret, setCheckoutClientSecret] = useState<string | null>(null);
|
||||
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
|
||||
const [checkoutLoading, setCheckoutLoading] = useState<Tier | null>(null);
|
||||
const [portalLoading, setPortalLoading] = useState(false);
|
||||
const [successBanner, setSuccessBanner] = useState<string | null>(null);
|
||||
|
||||
const { data: status, isLoading } = useQuery<BillingStatus>({
|
||||
queryKey: ['billing', 'status'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/billing/status');
|
||||
if (!res.ok) throw new Error('Failed to fetch billing status');
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const { data: quotas } = 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, { remaining: number; limit: number; used: number }>;
|
||||
},
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const sessionId = params.get('session_id');
|
||||
if (sessionId) {
|
||||
const tier = status?.effectiveTier ?? 'Pro';
|
||||
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
|
||||
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
|
||||
window.history.replaceState({}, '', '/settings/billing');
|
||||
}
|
||||
}, [status, t, queryClient]);
|
||||
|
||||
const handleCheckout = async (tier: Tier) => {
|
||||
if (!billingEnabled) return;
|
||||
setCheckoutLoading(tier);
|
||||
try {
|
||||
const res = await fetch('/api/billing/create-checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tier, interval }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'Failed to create checkout');
|
||||
if (data.clientSecret) {
|
||||
setCheckoutClientSecret(data.clientSecret);
|
||||
setIsCheckoutOpen(true);
|
||||
} else if (data.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[BillingPlans] checkout error:', err);
|
||||
toast.error('Failed to start checkout. Please try again.');
|
||||
} finally {
|
||||
setCheckoutLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePortal = async () => {
|
||||
setPortalLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/billing/portal', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'Failed to open portal');
|
||||
window.location.href = data.url;
|
||||
} catch (err) {
|
||||
console.error('[BillingPlans] portal error:', err);
|
||||
toast.error('Failed to open billing portal.');
|
||||
} finally {
|
||||
setPortalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckoutComplete = useCallback(() => {
|
||||
setIsCheckoutOpen(false);
|
||||
setCheckoutClientSecret(null);
|
||||
const tier = status?.effectiveTier ?? 'Pro';
|
||||
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
|
||||
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
|
||||
}, [status, t, queryClient]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveTier = status?.effectiveTier ?? 'BASIC';
|
||||
const isPaid = effectiveTier !== 'BASIC';
|
||||
|
||||
const aiUsed = quotas?.semantic_search?.used ?? 0;
|
||||
const aiLimit = quotas?.semantic_search?.limit ?? 50;
|
||||
const aiPct = aiLimit > 0 ? (aiUsed / aiLimit) * 100 : 0;
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: 'free',
|
||||
name: t('billing.freePlan'),
|
||||
price: t('billing.freePrice') || 'Gratuit',
|
||||
period: '',
|
||||
description: t('billing.freeDescription') || 'Pour découvrir la magie de Momento.',
|
||||
features: [
|
||||
t('billing.freeF1') || '100 Notes max',
|
||||
t('billing.freeF2') || '3 Carnets',
|
||||
t('billing.freeF3') || '50 crédits IA (Lifetime)',
|
||||
t('billing.freeF4') || 'Recherche sémantique',
|
||||
t('billing.freeF5') || 'Historique 7 jours',
|
||||
],
|
||||
current: effectiveTier === 'BASIC',
|
||||
buttonText: effectiveTier === 'BASIC' ? (t('billing.currentPlan') || 'Plan Actuel') : t('billing.startCheckout'),
|
||||
buttonClass: effectiveTier === 'BASIC'
|
||||
? 'bg-paper text-concrete cursor-default'
|
||||
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
||||
onClick: () => {},
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: t('billing.proPlan'),
|
||||
price: interval === 'month' ? (t('billing.proPrice') || '9,90€') : (t('billing.proAnnualPrice') || '99€'),
|
||||
period: interval === 'month' ? '/mois' : '/an',
|
||||
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
|
||||
features: [
|
||||
t('billing.proFeature1') || 'Notes illimitées',
|
||||
t('billing.proFeature2') || 'BYOK (OpenAI/Anthropic)',
|
||||
t('billing.proFeature3') || '200 recherches sémantiques',
|
||||
t('billing.proFeature4') || 'Agents (12 runs/mois)',
|
||||
t('billing.proFeature5') || 'Historique 30 jours',
|
||||
t('billing.proFeature6') || 'Support Email',
|
||||
],
|
||||
current: effectiveTier === 'PRO',
|
||||
popular: true,
|
||||
buttonText: effectiveTier === 'PRO' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.proCta') || 'Passer au Plan Pro'),
|
||||
buttonClass: effectiveTier === 'PRO'
|
||||
? 'bg-paper text-concrete cursor-default'
|
||||
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
|
||||
onClick: () => handleCheckout('PRO'),
|
||||
},
|
||||
{
|
||||
id: 'business',
|
||||
name: t('billing.businessPlan'),
|
||||
price: interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€'),
|
||||
period: interval === 'month' ? '/mois' : '/an',
|
||||
description: t('billing.businessDescription') || 'Pour les équipes et chefs de produit.',
|
||||
features: [
|
||||
t('billing.businessFeature1') || '10 Collaborateurs inclus',
|
||||
t('billing.businessFeature2') || 'BYOK (13 fournisseurs)',
|
||||
t('billing.businessFeature3') || '1000 recherches sémantiques',
|
||||
t('billing.businessFeature4') || 'Agents (60 runs/mois)',
|
||||
t('billing.businessFeature5') || 'Brainstorm illimité',
|
||||
t('billing.businessFeature6') || 'Accès API',
|
||||
],
|
||||
current: effectiveTier === 'BUSINESS',
|
||||
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.businessCta') || 'Choisir Plan Business'),
|
||||
buttonClass: effectiveTier === 'BUSINESS'
|
||||
? 'bg-paper text-concrete cursor-default'
|
||||
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
||||
onClick: () => handleCheckout('BUSINESS'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-12"
|
||||
>
|
||||
{/* Success Banner */}
|
||||
{successBanner && (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">{t('billing.checkoutSuccessTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{successBanner}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setSuccessBanner(null)} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">
|
||||
{t('billing.subtitle') || 'Gérer votre abonnement et votre facturation'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Usage Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-brand-accent/10 text-brand-accent rounded-xl">
|
||||
<Activity size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-ink">{t('billing.currentUsage') || 'Utilisation actuelle'}</h4>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-widest">{t('billing.currentPeriod') || 'Période en cours'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-[11px] font-medium text-concrete uppercase tracking-wider">
|
||||
<span>{t('billing.aiCredits') || 'Crédits IA'}</span>
|
||||
<span>{aiUsed} / {aiLimit} {t('billing.used') || 'utilisés'}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-slate-100 dark:bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-brand-accent rounded-full" style={{ width: `${aiPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-paper dark:bg-white/10 text-concrete rounded-xl">
|
||||
<Clock size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-ink">{t('billing.billing') || 'Facturation'}</h4>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-widest">{t('billing.renewal') || 'Renouvellement'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-concrete font-light">
|
||||
{isPaid
|
||||
? t('billing.paidPlanDesc') || 'Votre abonnement se renouvelle automatiquement.'
|
||||
: t('billing.freePlanDesc') || 'Votre plan gratuit n\'expire jamais. Passez à la vitesse supérieure pour débloquer toute la puissance de Momento.'}
|
||||
</p>
|
||||
{isPaid && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePortal}
|
||||
disabled={portalLoading}
|
||||
className="mt-2 flex items-center gap-2 text-[10px] font-bold text-brand-accent uppercase tracking-widest hover:underline disabled:opacity-60"
|
||||
>
|
||||
{portalLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <ExternalLink className="h-3 w-3" />}
|
||||
{t('billing.openPortal')}
|
||||
</button>
|
||||
)}
|
||||
<div className="pt-4 flex items-center justify-between border-t border-border/40 mt-4">
|
||||
<span className="text-[11px] font-bold text-ink uppercase tracking-widest">{t('billing.currentPlan')}</span>
|
||||
<span className="text-[11px] font-bold text-brand-accent uppercase tracking-widest">
|
||||
{effectiveTier === 'BASIC' ? 'GRATUIT' : effectiveTier}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interval Toggle */}
|
||||
{billingEnabled && effectiveTier === 'BASIC' && (
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInterval('month')}
|
||||
className={cn(
|
||||
'px-4 py-1.5 text-xs font-medium rounded-full transition-all',
|
||||
interval === 'month' ? 'bg-ink text-paper' : 'text-concrete hover:text-ink'
|
||||
)}
|
||||
>
|
||||
{t('billing.monthly')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInterval('year')}
|
||||
className={cn(
|
||||
'px-4 py-1.5 text-xs font-medium rounded-full transition-all',
|
||||
interval === 'year' ? 'bg-ink text-paper' : 'text-concrete hover:text-ink'
|
||||
)}
|
||||
>
|
||||
{t('billing.annual')}
|
||||
<span className="ms-1 text-emerald-600 dark:text-emerald-400">{t('billing.save')} ~17%</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan Cards */}
|
||||
{(billingEnabled || true) && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{plans.map((plan) => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
'relative p-8 rounded-[40px] border transition-all duration-500 overflow-hidden group flex flex-col',
|
||||
plan.popular
|
||||
? 'bg-white dark:bg-paper border-brand-accent shadow-2xl shadow-brand-accent/10 scale-105 z-10'
|
||||
: 'bg-white/40 dark:bg-white/5 border-border hover:border-concrete/30'
|
||||
)}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute top-0 right-0 py-1.5 px-6 bg-brand-accent text-white text-[9px] font-bold uppercase tracking-widest rounded-bl-2xl">
|
||||
{t('billing.recommended') || 'Recommandé'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8 space-y-2">
|
||||
<h4 className="text-xl font-serif font-bold text-ink">{plan.name}</h4>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-serif font-bold text-ink">{plan.price}</span>
|
||||
<span className="text-concrete text-xs font-light italic">{plan.period}</span>
|
||||
</div>
|
||||
<p className="text-xs text-concrete font-light leading-relaxed pe-4">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-10 flex-1">
|
||||
{plan.features.map((feature, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<div className={cn('mt-1 p-0.5 rounded-full', plan.popular ? 'bg-brand-accent/10 text-brand-accent' : 'bg-concrete/10 text-concrete')}>
|
||||
<Check size={10} />
|
||||
</div>
|
||||
<span className="text-xs font-light text-ink/80">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={plan.onClick}
|
||||
disabled={plan.current || checkoutLoading !== null}
|
||||
className={cn('w-full py-4 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] transition-all duration-300', plan.buttonClass)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{checkoutLoading && !plan.current ? <Loader2 className="h-4 w-4 animate-spin" /> : plan.buttonText}
|
||||
{!plan.current && <ArrowRight size={14} />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="bg-slate-50 dark:bg-black/20 rounded-[32px] p-8 border border-border/40 flex flex-col md:flex-row items-center justify-between gap-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-white dark:bg-paper rounded-2xl shadow-sm border border-border">
|
||||
<Shield size={24} className="text-brand-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-sm font-bold text-ink">{t('billing.secureTransactions') || 'Transactions sécurisées'}</h5>
|
||||
<p className="text-xs text-concrete font-light">{t('billing.secureDesc') || 'Paiement via Stripe. Annulez à tout moment, sans engagement.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap size={16} className="text-ochre" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('billing.instantActivation') || 'Activation instantanée'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Crown size={16} className="text-amber-500" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('billing.satisfactionGuarantee') || 'Garantie satisfait'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Embedded Checkout Modal */}
|
||||
{isCheckoutOpen && checkoutClientSecret && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h3 className="font-semibold text-foreground">{t('billing.upgradeTitle')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsCheckoutOpen(false);
|
||||
setCheckoutClientSecret(null);
|
||||
toast.info(t('billing.checkoutCanceled'));
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={getStripePromise()}
|
||||
options={{ clientSecret: checkoutClientSecret, onComplete: handleCheckoutComplete }}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -4,3 +4,6 @@ export { SettingToggle } from './SettingToggle'
|
||||
export { SettingSelect } from './SettingSelect'
|
||||
export { SettingInput } from './SettingInput'
|
||||
export { SettingsSearch } from './SettingsSearch'
|
||||
export { BillingPlans } from './billing-plans'
|
||||
export { UsageBreakdown } from './usage-breakdown'
|
||||
export { BillingHistory } from './billing-history'
|
||||
|
||||
99
memento-note/components/settings/usage-breakdown.tsx
Normal file
99
memento-note/components/settings/usage-breakdown.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, BarChart2 } from 'lucide-react';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface QuotaEntry {
|
||||
remaining: number;
|
||||
limit: number;
|
||||
used: number;
|
||||
}
|
||||
|
||||
type Quotas = Record<string, QuotaEntry>;
|
||||
|
||||
const FEATURE_LABEL_KEYS: Record<string, string> = {
|
||||
aiSummary: 'sidebar.aiSummary',
|
||||
aiFlashcards: 'sidebar.aiFlashcards',
|
||||
aiMindmap: 'sidebar.aiMindmap',
|
||||
aiTranscribe: 'sidebar.aiTranscribe',
|
||||
aiDiagram: 'sidebar.aiDiagram',
|
||||
aiAgent: 'sidebar.aiAgent',
|
||||
};
|
||||
|
||||
function UsageBar({ used, limit, isUnlimited }: { used: number; limit: number; isUnlimited: boolean }) {
|
||||
const pct = isUnlimited ? 0 : Math.min(100, limit > 0 ? (used / limit) * 100 : 0);
|
||||
const color =
|
||||
pct >= 90 ? 'bg-rose-500' : pct >= 70 ? 'bg-amber-500' : 'bg-emerald-500';
|
||||
|
||||
return (
|
||||
<div className="h-1.5 w-full rounded-full bg-foreground/10 overflow-hidden">
|
||||
{!isUnlimited && (
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', color)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsageBreakdown() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const { data, isLoading } = useQuery<{ quotas: Quotas }>({
|
||||
queryKey: ['usage', 'current'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/usage/current');
|
||||
if (!res.ok) throw new Error('Failed to fetch usage');
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const quotas = data?.quotas ?? {};
|
||||
const entries = Object.entries(quotas);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/40 bg-paper p-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart2 className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{t('billing.usageThisPeriod')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-2">{t('billing.noUsage')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{entries.map(([feature, quota]) => {
|
||||
const isUnlimited = quota.limit === -1 || !isFinite(quota.limit);
|
||||
const labelKey = FEATURE_LABEL_KEYS[feature];
|
||||
const label = labelKey ? t(labelKey) : feature;
|
||||
|
||||
return (
|
||||
<div key={feature} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-xs font-medium text-foreground tabular-nums">
|
||||
{isUnlimited ? (
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{t('billing.unlimited')}</span>
|
||||
) : (
|
||||
`${quota.used} / ${quota.limit}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<UsageBar used={quota.used} limit={quota.limit} isUnlimited={isUnlimited} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user