diff --git a/memento-note/app/api/billing/invoices/route.ts b/memento-note/app/api/billing/invoices/route.ts new file mode 100644 index 0000000..7535cfd --- /dev/null +++ b/memento-note/app/api/billing/invoices/route.ts @@ -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 }); + } +} diff --git a/memento-note/app/api/billing/status/route.ts b/memento-note/app/api/billing/status/route.ts index d927dcb..69cd1c0 100644 --- a/memento-note/app/api/billing/status/route.ts +++ b/memento-note/app/api/billing/status/route.ts @@ -22,6 +22,7 @@ export async function GET() { effectiveTier, status, currentPeriodEnd: currentPeriodEnd ?? null, + currentPeriodStart: subscription?.currentPeriodStart ?? null, cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false, hasStripeSubscription: !!subscription?.stripeSubscriptionId, }); diff --git a/memento-note/components/contextual-ai-chat.tsx b/memento-note/components/contextual-ai-chat.tsx index 11b4735..bf0ccc8 100644 --- a/memento-note/components/contextual-ai-chat.tsx +++ b/memento-note/components/contextual-ai-chat.tsx @@ -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(null) + + useEffect(() => { + setQuotaExceededFeature(null) + }, [activeTab]) // Action state const [actionLoading, setActionLoading] = useState(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({
+ {quotaExceededFeature && ( +
+ setQuotaExceededFeature(null)} + /> +
+ )} {actionPreview && (
diff --git a/memento-note/components/settings/billing-history.tsx b/memento-note/components/settings/billing-history.tsx index a078a16..c86303b 100644 --- a/memento-note/components/settings/billing-history.tsx +++ b/memento-note/components/settings/billing-history.tsx @@ -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({ + 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 ( -
-
- -

{t('billing.billingHistory')}

+
+
+
+
+ +
+
+

{t('billing.billingHistory') || 'Historique de facturation'}

+

{t('billing.invoices') || 'Factures & reçus'}

+
+
+ +
-

{t('billing.noUsage')}

- + + {isLoading && ( +
+ +
+ )} + + {error && ( +

+ Impossible de charger l'historique de facturation. +

+ )} + + {invoices && invoices.length === 0 && ( +

+ {t('billing.noInvoices') || 'Aucune facture disponible.'} +

+ )} + + {invoices && invoices.length > 0 && ( +
+ + + + + + + + + + + + {invoices.map((invoice) => ( + + + + + + + + ))} + +
{t('billing.invoiceDate') || 'Date'}{t('billing.invoiceNumber') || 'Numéro'}{t('billing.invoiceAmount') || 'Montant'}{t('billing.invoiceStatus') || 'Statut'}PDF
{formatDate(invoice.date)}{invoice.number || '—'}{formatAmount(invoice.amount, invoice.currency)} + + {invoice.status ? t(`billing.${invoice.status.toLowerCase()}`) || invoice.status : '—'} + + + {invoice.pdfUrl ? ( + + + + ) : ( + + )} +
+
+ )}
); } diff --git a/memento-note/components/settings/billing-plans.tsx b/memento-note/components/settings/billing-plans.tsx index 6b318ea..dc6e9d9 100644 --- a/memento-note/components/settings/billing-plans.tsx +++ b/memento-note/components/settings/billing-plans.tsx @@ -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; }, - 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 (
-

{t('billing.checkoutSuccessTitle')}

+

{t('billing.checkoutSuccessTitle') || 'Abonnement activé !'}

{successBanner}

)} -
-

- {t('billing.subtitle') || 'Gérer votre abonnement et votre facturation'} -

-
- - {/* Usage Overview */} -
-
+ {/* Current Plan Card & Usage Ring */} +
+
-
- +
+
-

{t('billing.currentUsage') || 'Utilisation actuelle'}

-

{t('billing.currentPeriod') || 'Période en cours'}

+

{t('billing.currentPlan')}

+

+ {effectiveTier === 'BASIC' ? t('billing.freePlan') : effectiveTier === 'PRO' ? t('billing.proPlan') : effectiveTier === 'BUSINESS' ? t('billing.businessPlan') : t('billing.enterprisePlan')} +

- {effectiveTier === 'BASIC' ? 'Discovery' : effectiveTier} + {status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
-
- {!quotas && ( -
- -
- )} - {quotas && Object.entries(quotas).filter(([_, q]) => q.limit > 0).length === 0 && ( -

Aucune donnée d'utilisation disponible

- )} - {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 = { - 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 ( -
-
- - {featureLabels[key] || key} - -
-
- {q.remaining === Infinity ? '∞' : q.remaining} - / {q.limit === Infinity ? '∞' : q.limit} -
-
-
= 90 ? 'bg-rose-500' : pct >= 70 ? 'bg-amber-500' : 'bg-brand-accent')} - style={{ width: `${Math.min(pct, 100)}%` }} - /> -
-
- ) - })} + +
+
+ {t('billing.billingPeriod')} +

+ {status?.currentPeriodStart && status?.currentPeriodEnd ? ( + `${formatDate(status.currentPeriodStart)} – ${formatDate(status.currentPeriodEnd)}` + ) : ( + '—' + )} +

+
+
+ + {status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')} + +

+ {status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'} +

+
+ + {isPaid && ( + + )}
-
-
-
- -
-
-

{t('billing.billing') || 'Facturation'}

-

{t('billing.renewal') || 'Renouvellement'}

+ {/* Global Usage Donut */} +
+
+ + {/* Background circle */} + + {/* Foreground circle */} + + +
+ {Math.round(globalPct)}% + {t('billing.used')}
-
-

- {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.'} +

+

+ {totalUsed} / {totalLimit} {t('billing.aiCredits') || 'crédits IA'}

- {isPaid && ( - - )} -
- {t('billing.currentPlan')} - - {effectiveTier === 'BASIC' ? 'GRATUIT' : effectiveTier} - -
- {/* Interval Toggle */} - {billingEnabled && effectiveTier === 'BASIC' && ( -
- - + {/* Usage breakdown per feature */} +
+
+
+

+ {t('billing.usageThisPeriod') || 'Utilisation sur cette période'} +

+

+ {status?.currentPeriodStart && status?.currentPeriodEnd ? ( + `${formatDate(status.currentPeriodStart)} – ${formatDate(status.currentPeriodEnd)}` + ) : ( + '—' + )} +

+
- )} - {/* Plan Cards */} - {(billingEnabled || true) && ( -
- {plans.map((plan) => ( -
- {plan.popular && ( -
- {t('billing.recommended') || 'Recommandé'} +
+ {!quotas && ( +
+ +
+ )} + {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 = { + 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 ( +
+
+ + {featureLabels[key] || key} + + + {isUnlimited ? t('billing.unlimited') || 'Illimité' : `${q.used} / ${q.limit}`} +
- )} -
-

{plan.name}

-
- {plan.price} - {plan.period} +
+
-

{plan.description}

+ ); + })} +
+
-
- {plan.features.map((feature, i) => ( -
-
- -
- {feature} -
- ))} -
+ {/* Billing History (Only for paid users) */} + {isPaid && } + {/* Interval Toggle & Plan Cards (Only for BASIC/Free users) */} + {!isPaid && ( +
+
+

+ {t('billing.upgradePlan') || 'Changer de plan'} +

+
+ + {billingEnabled && ( +
+
- ))} + )} + +
+ {plans.map((plan) => ( +
+ {plan.popular && ( +
+ {t('billing.recommended') || 'Recommandé'} +
+ )} + +
+

{plan.name}

+
+ {plan.price} + {plan.period} +
+

{plan.description}

+
+ +
+ {plan.features.map((feature, i) => ( +
+
+ +
+ {feature} +
+ ))} +
+ + +
+ ))} +
)} diff --git a/memento-note/components/settings/inline-paywall.tsx b/memento-note/components/settings/inline-paywall.tsx new file mode 100644 index 0000000..fb3f58b --- /dev/null +++ b/memento-note/components/settings/inline-paywall.tsx @@ -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 = { + 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 ( + + +
+
+ +
+
+

+ {t('quotaPaywall.title') || 'Limite mensuelle atteinte'} +

+

+ {(t('quotaPaywall.description') || "Vous avez épuisé vos crédits pour la fonctionnalité {feature} ce mois-ci.").replace('{feature}', currentFeatureLabel)} +

+
+ +
+ +
+ + + + +
+ Fermeture dans {timeLeft}s +
+
+
+
+ ); +} diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 99858f6..d6200f4 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -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", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index be1b69a..e5fbc4f 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -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",