feat(billing): implement US-3.7 billing and subscription UX with detailed dashboard, real-time invoice history, inline paywall and upgrade confirmation
This commit is contained in:
40
memento-note/app/api/billing/invoices/route.ts
Normal file
40
memento-note/app/api/billing/invoices/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
try {
|
||||
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
if (!subscription?.stripeCustomerId) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const invoices = await stripe.invoices.list({
|
||||
customer: subscription.stripeCustomerId,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const formattedInvoices = invoices.data.map((invoice) => ({
|
||||
id: invoice.id,
|
||||
number: invoice.number,
|
||||
amount: invoice.amount_paid || invoice.total,
|
||||
currency: invoice.currency,
|
||||
status: invoice.status, // 'paid', 'open', 'draft', 'uncollectible', 'void'
|
||||
date: invoice.created, // timestamp en secondes
|
||||
pdfUrl: invoice.invoice_pdf,
|
||||
}));
|
||||
|
||||
return NextResponse.json(formattedInvoices);
|
||||
} catch (error) {
|
||||
console.error('[billing/invoices]', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch invoices' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export async function GET() {
|
||||
effectiveTier,
|
||||
status,
|
||||
currentPeriodEnd: currentPeriodEnd ?? null,
|
||||
currentPeriodStart: subscription?.currentPeriodStart ?? null,
|
||||
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
|
||||
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useLanguage } from '@/lib/i18n'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
import { toast } from 'sonner'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { InlinePaywall } from './settings/inline-paywall'
|
||||
|
||||
// ── Custom Toast Helper ──────────────────────────────────────────────────────
|
||||
const mToast = {
|
||||
@@ -204,6 +205,11 @@ export function ContextualAIChat({
|
||||
const [chatScope, setChatScope] = useState<'note' | 'all' | string>('note')
|
||||
const [webSearch, setWebSearch] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [quotaExceededFeature, setQuotaExceededFeature] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setQuotaExceededFeature(null)
|
||||
}, [activeTab])
|
||||
|
||||
// Action state
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
@@ -289,12 +295,16 @@ export function ContextualAIChat({
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setInput('')
|
||||
try {
|
||||
await sendMessage({ text }, { body: buildChatBody() })
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Chat send error:', error)
|
||||
toast.error(t('chat.assistantError') || 'Failed to send message')
|
||||
const isQuota = error?.status === 402 || (error?.message && error.message.includes('402')) || (error?.message && error.message.includes('quota'));
|
||||
if (isQuota) {
|
||||
setQuotaExceededFeature('chat')
|
||||
} else {
|
||||
toast.error(t('chat.assistantError') || 'Failed to send message')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +327,10 @@ export function ContextualAIChat({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(action.body('', noteImages, language)),
|
||||
})
|
||||
if (res.status === 402) {
|
||||
setQuotaExceededFeature('reformulate')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
||||
const descs = data.descriptions || []
|
||||
@@ -349,6 +363,10 @@ export function ContextualAIChat({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(action.body(noteContent, undefined, targetLang || language, format)),
|
||||
})
|
||||
if (res.status === 402) {
|
||||
setQuotaExceededFeature('reformulate')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
||||
const result = data[action.resultKey] || ''
|
||||
@@ -671,6 +689,14 @@ export function ContextualAIChat({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||
{quotaExceededFeature && (
|
||||
<div className="absolute inset-x-4 top-4 z-30 animate-in fade-in slide-in-from-top-4 duration-300">
|
||||
<InlinePaywall
|
||||
feature={quotaExceededFeature}
|
||||
onDismiss={() => setQuotaExceededFeature(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{actionPreview && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-[#FDFCFB]/95 dark:bg-[#0D0D0D]/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between shrink-0">
|
||||
|
||||
@@ -1,48 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { ExternalLink, Receipt } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ExternalLink, Receipt, Loader2, Download } from 'lucide-react';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
number: string | null;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: string | null;
|
||||
date: number;
|
||||
pdfUrl: string | null;
|
||||
}
|
||||
|
||||
export function BillingHistory() {
|
||||
const { t } = useLanguage();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [portalLoading, setPortalLoading] = useState(false);
|
||||
|
||||
const { data: invoices, isLoading, error } = useQuery<Invoice[]>({
|
||||
queryKey: ['billing', 'invoices'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/billing/invoices');
|
||||
if (!res.ok) throw new Error('Failed to fetch invoices');
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenPortal = async () => {
|
||||
setLoading(true);
|
||||
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');
|
||||
window.location.href = data.url;
|
||||
} catch {
|
||||
// ignore — portal not configured
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setPortalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number, currency: string) => {
|
||||
try {
|
||||
const locale = typeof window !== 'undefined' ? window.navigator.language : 'fr-FR';
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase(),
|
||||
}).format(amount / 100);
|
||||
} catch {
|
||||
return `${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
try {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const locale = typeof window !== 'undefined' ? window.navigator.language : 'fr-FR';
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string | null) => {
|
||||
const s = status?.toLowerCase();
|
||||
if (s === 'paid') {
|
||||
return 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20';
|
||||
}
|
||||
if (s === 'open' || s === 'pending') {
|
||||
return 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20';
|
||||
}
|
||||
return 'bg-rose-500/10 text-rose-600 dark:text-rose-400 border border-rose-500/20';
|
||||
};
|
||||
|
||||
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 className="bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-paper dark:bg-white/10 text-concrete rounded-2xl">
|
||||
<Receipt size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-ink">{t('billing.billingHistory') || 'Historique de facturation'}</h4>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-widest">{t('billing.invoices') || 'Factures & reçus'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenPortal}
|
||||
disabled={portalLoading}
|
||||
className="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.viewInvoices') || 'Gérer les factures'}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-brand-accent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-rose-500 text-center py-4">
|
||||
Impossible de charger l'historique de facturation.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{invoices && invoices.length === 0 && (
|
||||
<p className="text-xs text-concrete text-center py-4 italic">
|
||||
{t('billing.noInvoices') || 'Aucune facture disponible.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{invoices && invoices.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border/40">
|
||||
<th className="pb-3 text-[10px] font-bold uppercase tracking-wider text-concrete">{t('billing.invoiceDate') || 'Date'}</th>
|
||||
<th className="pb-3 text-[10px] font-bold uppercase tracking-wider text-concrete">{t('billing.invoiceNumber') || 'Numéro'}</th>
|
||||
<th className="pb-3 text-[10px] font-bold uppercase tracking-wider text-concrete">{t('billing.invoiceAmount') || 'Montant'}</th>
|
||||
<th className="pb-3 text-[10px] font-bold uppercase tracking-wider text-concrete">{t('billing.invoiceStatus') || 'Statut'}</th>
|
||||
<th className="pb-3 text-right text-[10px] font-bold uppercase tracking-wider text-concrete">PDF</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/20">
|
||||
{invoices.map((invoice) => (
|
||||
<tr key={invoice.id} className="group hover:bg-slate-50/50 dark:hover:bg-white/5 transition-colors">
|
||||
<td className="py-4 text-xs font-medium text-ink">{formatDate(invoice.date)}</td>
|
||||
<td className="py-4 text-xs font-mono text-concrete">{invoice.number || '—'}</td>
|
||||
<td className="py-4 text-xs font-semibold text-ink">{formatAmount(invoice.amount, invoice.currency)}</td>
|
||||
<td className="py-4 text-xs">
|
||||
<span className={cn('px-2.5 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider', getStatusBadge(invoice.status))}>
|
||||
{invoice.status ? t(`billing.${invoice.status.toLowerCase()}`) || invoice.status : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 text-right">
|
||||
{invoice.pdfUrl ? (
|
||||
<a
|
||||
href={invoice.pdfUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg text-concrete hover:text-brand-accent hover:bg-brand-accent/10 transition-all"
|
||||
title="Download PDF"
|
||||
>
|
||||
<Download size={14} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-xs text-concrete">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
@@ -10,6 +9,7 @@ import { useLanguage } from '@/lib/i18n';
|
||||
import { toast } from 'sonner';
|
||||
import { format } from 'date-fns';
|
||||
import { motion } from 'motion/react';
|
||||
import { BillingHistory } from './billing-history';
|
||||
|
||||
type Tier = 'PRO' | 'BUSINESS';
|
||||
type Interval = 'month' | 'year';
|
||||
@@ -18,6 +18,7 @@ interface BillingStatus {
|
||||
tier: string;
|
||||
effectiveTier: string;
|
||||
status: string;
|
||||
currentPeriodStart: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
hasStripeSubscription: boolean;
|
||||
@@ -61,7 +62,7 @@ export function BillingPlans() {
|
||||
const data = await res.json();
|
||||
return data.quotas as Record<string, { remaining: number; limit: number; used: number }>;
|
||||
},
|
||||
refetchInterval: 60000,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -76,6 +77,15 @@ export function BillingPlans() {
|
||||
}
|
||||
}, [status, t, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (successBanner) {
|
||||
const timer = setTimeout(() => {
|
||||
setSuccessBanner(null);
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [successBanner]);
|
||||
|
||||
const handleCheckout = async (tier: Tier) => {
|
||||
if (!billingEnabled) return;
|
||||
setCheckoutLoading(tier);
|
||||
@@ -226,6 +236,41 @@ export function BillingPlans() {
|
||||
},
|
||||
];
|
||||
|
||||
const formatDate = (dateStr: string | null | undefined) => {
|
||||
if (!dateStr) return '—';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const locale = typeof window !== 'undefined' ? window.navigator.language : 'fr-FR';
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
} catch (e) {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// Sommes pour l'usage global agrégé (donut)
|
||||
let totalUsed = 0;
|
||||
let totalLimit = 0;
|
||||
if (quotas) {
|
||||
Object.entries(quotas).forEach(([_, q]) => {
|
||||
if (q.limit > 0 && q.limit !== Infinity) {
|
||||
totalUsed += q.used;
|
||||
totalLimit += q.limit;
|
||||
}
|
||||
});
|
||||
}
|
||||
const globalPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
|
||||
|
||||
// SVG Donut Config
|
||||
const radius = 28;
|
||||
const strokeWidth = 5;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDashoffset = circumference - (Math.min(globalPct, 100) / 100) * circumference;
|
||||
const donutColor = globalPct >= 100 ? '#f43f5e' : globalPct >= 75 ? '#f59e0b' : '#a78bfa'; // Rose, Ambre, Violet
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
@@ -237,7 +282,7 @@ export function BillingPlans() {
|
||||
<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-sm font-semibold text-foreground">{t('billing.checkoutSuccessTitle') || 'Abonnement activé !'}</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">
|
||||
@@ -246,191 +291,255 @@ export function BillingPlans() {
|
||||
</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="md:col-span-2 bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 space-y-6">
|
||||
{/* Current Plan Card & Usage Ring */}
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 grid grid-cols-1 md:grid-cols-3 gap-8 items-center">
|
||||
<div className="md:col-span-2 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 className="p-2.5 bg-brand-accent/10 text-brand-accent rounded-2xl">
|
||||
<Crown size={24} className="text-brand-accent" />
|
||||
</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>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-widest">{t('billing.currentPlan')}</p>
|
||||
<h4 className="text-xl font-bold font-serif text-ink mt-0.5">
|
||||
{effectiveTier === 'BASIC' ? t('billing.freePlan') : effectiveTier === 'PRO' ? t('billing.proPlan') : effectiveTier === 'BUSINESS' ? t('billing.businessPlan') : t('billing.enterprisePlan')}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<span className={cn(
|
||||
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
|
||||
isPaid ? 'bg-brand-accent/10 text-brand-accent' : 'bg-concrete/10 text-concrete'
|
||||
status?.status === 'active' || status?.status === 'ACTIVE'
|
||||
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20'
|
||||
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
|
||||
)}>
|
||||
{effectiveTier === 'BASIC' ? 'Discovery' : effectiveTier}
|
||||
{status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{!quotas && (
|
||||
<div className="col-span-full flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-concrete" />
|
||||
</div>
|
||||
)}
|
||||
{quotas && Object.entries(quotas).filter(([_, q]) => q.limit > 0).length === 0 && (
|
||||
<p className="col-span-full text-xs text-concrete text-center py-4">Aucune donnée d'utilisation disponible</p>
|
||||
)}
|
||||
{quotas && Object.entries(quotas).filter(([_, q]) => q.limit > 0).map(([key, q]) => {
|
||||
const pct = q.limit > 0 ? (q.used / q.limit) * 100 : 0
|
||||
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'),
|
||||
}
|
||||
return (
|
||||
<div key={key} className="space-y-2 p-3 rounded-xl bg-paper/50 dark:bg-white/5">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-bold text-concrete uppercase tracking-wider truncate">
|
||||
{featureLabels[key] || key}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-lg font-bold text-ink">{q.remaining === Infinity ? '∞' : q.remaining}</span>
|
||||
<span className="text-[10px] text-concrete">/ {q.limit === Infinity ? '∞' : q.limit}</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-slate-100 dark:bg-white/5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', pct >= 90 ? 'bg-rose-500' : pct >= 70 ? 'bg-amber-500' : 'bg-brand-accent')}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border/40">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-concrete uppercase tracking-wider">{t('billing.billingPeriod')}</span>
|
||||
<p className="text-xs font-semibold text-ink">
|
||||
{status?.currentPeriodStart && status?.currentPeriodEnd ? (
|
||||
`${formatDate(status.currentPeriodStart)} – ${formatDate(status.currentPeriodEnd)}`
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-concrete uppercase tracking-wider">
|
||||
{status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')}
|
||||
</span>
|
||||
<p className="text-xs font-semibold text-ink">
|
||||
{status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPaid && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePortal}
|
||||
disabled={portalLoading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-ink text-white dark:bg-white dark:text-black rounded-xl text-xs font-semibold hover:opacity-90 disabled:opacity-60 transition-all shadow-md shadow-black/5"
|
||||
>
|
||||
{portalLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ExternalLink className="h-4 w-4" />}
|
||||
{t('billing.manageBilling') || 'Gérer la facturation'}
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
{/* Global Usage Donut */}
|
||||
<div className="flex flex-col items-center justify-center p-6 bg-paper/30 dark:bg-white/5 rounded-3xl border border-border/40 relative">
|
||||
<div className="relative w-28 h-28 flex items-center justify-center">
|
||||
<svg className="w-full h-full transform -rotate-90">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx="56"
|
||||
cy="56"
|
||||
r={radius}
|
||||
className="stroke-slate-100 dark:stroke-white/5 fill-transparent"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Foreground circle */}
|
||||
<circle
|
||||
cx="56"
|
||||
cy="56"
|
||||
r={radius}
|
||||
className="fill-transparent transition-all duration-500 ease-out"
|
||||
stroke={donutColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute flex flex-col items-center justify-center">
|
||||
<span className="text-xl font-bold text-ink">{Math.round(globalPct)}%</span>
|
||||
<span className="text-[8px] text-concrete uppercase tracking-widest">{t('billing.used')}</span>
|
||||
</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.'}
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-xs font-bold text-ink">
|
||||
{totalUsed} <span className="text-concrete font-light">/ {totalLimit} {t('billing.aiCredits') || 'crédits IA'}</span>
|
||||
</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>
|
||||
{/* Usage breakdown per feature */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
|
||||
{t('billing.usageThisPeriod') || 'Utilisation sur cette période'}
|
||||
</h3>
|
||||
<p className="text-[10px] text-concrete mt-1">
|
||||
{status?.currentPeriodStart && status?.currentPeriodEnd ? (
|
||||
`${formatDate(status.currentPeriodStart)} – ${formatDate(status.currentPeriodEnd)}`
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan Cards */}
|
||||
{(billingEnabled || true) && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{!quotas && (
|
||||
<div className="col-span-full flex items-center justify-center py-12 bg-white/40 dark:bg-white/5 border border-border rounded-3xl">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
|
||||
</div>
|
||||
)}
|
||||
{quotas && Object.entries(quotas).map(([key, q]) => {
|
||||
const pct = q.limit > 0 && q.limit !== Infinity ? (q.used / q.limit) * 100 : 0;
|
||||
const isUnlimited = q.limit === Infinity || q.limit <= 0;
|
||||
|
||||
const featureLabels: Record<string, string> = {
|
||||
semantic_search: t('usageMeter.featureSearch') || 'Recherche Sémantique',
|
||||
auto_tag: t('usageMeter.featureTags') || 'Tags Automatiques',
|
||||
auto_title: t('usageMeter.featureTitles') || 'Titres Automatiques',
|
||||
reformulate: t('usageMeter.featureReformulate') || 'Reformulation IA',
|
||||
chat: t('usageMeter.featureChat') || 'Chat IA',
|
||||
brainstorm_create: t('usageMeter.featureBrainstormCreate') || 'Création de Brainstorm',
|
||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand') || 'Expansion de Brainstorm',
|
||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich') || 'Enrichissement de Brainstorm',
|
||||
};
|
||||
|
||||
// Couleur de barre : normal (violet), warning >= 75% (ambre), exhausted >= 100% (rose)
|
||||
const barFillColor = pct >= 100 ? 'bg-rose-400' : pct >= 75 ? 'bg-amber-400' : 'bg-gradient-to-r from-violet-400 to-purple-400';
|
||||
|
||||
return (
|
||||
<div key={key} className="p-5 rounded-2xl bg-white/40 dark:bg-white/5 border border-border space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[11px] font-bold text-ink uppercase tracking-wider truncate">
|
||||
{featureLabels[key] || key}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-concrete tabular-nums">
|
||||
{isUnlimited ? t('billing.unlimited') || 'Illimité' : `${q.used} / ${q.limit}`}
|
||||
</span>
|
||||
</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 className="h-2 w-full bg-secondary/40 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500', barFillColor)}
|
||||
style={{ width: `${isUnlimited ? 100 : Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-concrete font-light leading-relaxed pe-4">{plan.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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>
|
||||
{/* Billing History (Only for paid users) */}
|
||||
{isPaid && <BillingHistory />}
|
||||
|
||||
{/* Interval Toggle & Plan Cards (Only for BASIC/Free users) */}
|
||||
{!isPaid && (
|
||||
<div className="space-y-8 pt-6 border-t border-border/40">
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
|
||||
{t('billing.upgradePlan') || 'Changer de plan'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{billingEnabled && (
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<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)}
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{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>
|
||||
))}
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
111
memento-note/components/settings/inline-paywall.tsx
Normal file
111
memento-note/components/settings/inline-paywall.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { AlertTriangle, Sparkles, Key, X, ArrowRight } from 'lucide-react';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
interface InlinePaywallProps {
|
||||
feature: string;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
|
||||
const { t } = useLanguage();
|
||||
const router = useRouter();
|
||||
const [timeLeft, setTimeLeft] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeLeft <= 0) {
|
||||
onDismiss();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setTimeLeft((prev) => prev - 1);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [timeLeft, onDismiss]);
|
||||
|
||||
const handleUpgrade = () => {
|
||||
router.push('/settings/billing');
|
||||
onDismiss();
|
||||
};
|
||||
|
||||
const handleByok = () => {
|
||||
router.push('/settings/ai#byok');
|
||||
onDismiss();
|
||||
};
|
||||
|
||||
const featureLabels: Record<string, string> = {
|
||||
semantic_search: t('usageMeter.featureSearch') || 'Recherche Sémantique',
|
||||
auto_tag: t('usageMeter.featureTags') || 'Tags Automatiques',
|
||||
auto_title: t('usageMeter.featureTitles') || 'Titres Automatiques',
|
||||
reformulate: t('usageMeter.featureReformulate') || 'Reformulation',
|
||||
chat: t('usageMeter.featureChat') || 'Chat IA',
|
||||
brainstorm_create: t('usageMeter.featureBrainstormCreate') || 'Création de Brainstorm',
|
||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand') || 'Expansion de Brainstorm',
|
||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich') || 'Enrichissement de Brainstorm',
|
||||
};
|
||||
|
||||
const currentFeatureLabel = featureLabels[feature] || feature;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
className="rounded-2xl border border-rose-200 dark:border-rose-900/40 bg-rose-50/60 dark:bg-rose-950/15 backdrop-blur-md p-6 space-y-4 shadow-lg"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2.5 bg-rose-500/10 text-rose-500 rounded-xl shrink-0">
|
||||
<AlertTriangle size={20} />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<h4 className="text-sm font-bold text-rose-800 dark:text-rose-400">
|
||||
{t('quotaPaywall.title') || 'Limite mensuelle atteinte'}
|
||||
</h4>
|
||||
<p className="text-xs text-rose-700/80 dark:text-rose-400/70 font-light leading-relaxed">
|
||||
{(t('quotaPaywall.description') || "Vous avez épuisé vos crédits pour la fonctionnalité {feature} ce mois-ci.").replace('{feature}', currentFeatureLabel)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="text-rose-500/60 hover:text-rose-500 dark:text-rose-400/50 dark:hover:text-rose-400 p-1 hover:bg-rose-500/5 dark:hover:bg-rose-400/5 rounded-lg transition-all"
|
||||
title={t('quotaPaywall.later') || 'Peut-être plus tard'}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 pt-2 sm:pt-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpgrade}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-[#D4A373] text-white hover:bg-[#C49363] text-xs font-semibold rounded-xl transition-all shadow-sm shadow-[#D4A373]/15"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
{t('quotaPaywall.upgrade') || 'Passer au plan Pro'}
|
||||
<ArrowRight size={12} className="ml-1" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleByok}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 border border-rose-300 dark:border-rose-800/40 text-rose-800 dark:text-rose-400 hover:bg-rose-100/30 dark:hover:bg-rose-400/5 text-xs font-semibold rounded-xl transition-all"
|
||||
>
|
||||
<Key size={14} />
|
||||
{t('quotaPaywall.useOwnKey') || 'Utiliser ma propre clé'}
|
||||
</button>
|
||||
|
||||
<div className="sm:ml-auto flex items-center justify-center gap-1.5 text-[10px] text-rose-500/60 dark:text-rose-400/40 font-mono">
|
||||
<span>Fermeture dans {timeLeft}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -2922,6 +2922,13 @@
|
||||
"secureTransactions": "Secure transactions",
|
||||
"satisfactionGuarantee": "30-day satisfaction guarantee"
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "Monthly limit reached",
|
||||
"description": "You've used all your {feature} credits for this month.",
|
||||
"upgrade": "Upgrade plan",
|
||||
"useOwnKey": "Use your own API key",
|
||||
"later": "Maybe later"
|
||||
},
|
||||
"landing": {
|
||||
"nav": {
|
||||
"features": "Features",
|
||||
|
||||
@@ -2926,6 +2926,13 @@
|
||||
"secureTransactions": "Transactions sécurisées",
|
||||
"satisfactionGuarantee": "Satisfait ou remboursé 30 jours"
|
||||
},
|
||||
"quotaPaywall": {
|
||||
"title": "Limite mensuelle atteinte",
|
||||
"description": "Vous avez épuisé vos crédits pour la fonctionnalité {feature} ce mois-ci.",
|
||||
"upgrade": "Passer au plan supérieur",
|
||||
"useOwnKey": "Clé API perso",
|
||||
"later": "Peut-être plus tard"
|
||||
},
|
||||
"landing": {
|
||||
"nav": {
|
||||
"features": "Fonctionnalités",
|
||||
|
||||
Reference in New Issue
Block a user