fix(ui): thème, textes et lisibilité restants de la revue
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m57s
CI / Deploy production (on server) (push) Successful in 24s

Les boutons suivent la couleur d’apparence, les libellés trop petits ou trop techniques sont clarifiés, et le catalogue des fournisseurs se met à jour tout seul.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-08-30 21:13:07 +00:00
parent ebf6f16fde
commit afbb0dfc2d
77 changed files with 2842 additions and 1546 deletions

View File

@@ -35,7 +35,7 @@ export function SettingsNav({ className }: SettingsNavProps) {
<Link
key={tab.id}
href={tab.href}
className="flex items-center gap-1.5 sm:gap-2.5 px-2 sm:px-4 py-3 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap text-concrete hover:text-ink/60"
className="flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-3 py-2.5 text-[13px] font-medium transition-all relative whitespace-nowrap text-concrete hover:text-ink"
style={{ color: isActive(tab.href) ? 'var(--ink)' : undefined }}
>
<span style={{ color: isActive(tab.href) ? 'var(--ink)' : 'var(--concrete)' }}>{tab.icon}</span>

View File

@@ -1,5 +1,5 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { loadStripe } from '@stripe/stripe-js';
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
@@ -59,7 +59,7 @@ function getStripePromise(enabled: boolean) {
}
export function BillingPlans() {
const { t } = useLanguage();
const { t, language } = useLanguage();
const queryClient = useQueryClient();
const [interval, setInterval] = useState<Interval>('month');
const [checkoutClientSecret, setCheckoutClientSecret] = useState<string | null>(null);
@@ -69,6 +69,7 @@ export function BillingPlans() {
const [portalLoading, setPortalLoading] = useState(false);
const [cancelLoading, setCancelLoading] = useState(false);
const [successBanner, setSuccessBanner] = useState<string | null>(null);
const plansSectionRef = useRef<HTMLDivElement>(null);
const { data: status, isLoading } = useQuery<BillingStatus>({
queryKey: ['billing', 'status'],
@@ -80,6 +81,17 @@ export function BillingPlans() {
},
});
const { data: byokCatalog } = useQuery({
queryKey: ['public', 'byok-catalog'],
queryFn: async () => {
const res = await fetch('/api/public/byok-catalog')
if (!res.ok) throw new Error('catalog')
return res.json() as Promise<{ providers: { id: string }[] }>
},
staleTime: 60_000,
})
const providerCount = byokCatalog?.providers.length || '…'
const { data: usageData } = useQuery({
queryKey: ['usage', 'current'],
queryFn: async () => {
@@ -235,6 +247,18 @@ export function BillingPlans() {
}
};
const scrollToPlans = () => {
plansSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleChangePlan = (tier: Tier) => {
if (status?.hasStripeSubscription) {
void handlePortal('portal');
return;
}
void handleCheckout(tier);
};
const handleCheckoutComplete = useCallback(() => {
setIsCheckoutOpen(false);
setCheckoutClientSecret(null);
@@ -276,11 +300,15 @@ export function BillingPlans() {
t('billing.freeF5'),
],
current: effectiveTier === 'BASIC',
buttonText: effectiveTier === 'BASIC' ? (t('billing.currentPlan') || 'Plan Actuel') : t('billing.startCheckout'),
buttonText: effectiveTier === 'BASIC'
? (t('billing.currentPlan') || 'Plan Actuel')
: t('billing.downgradeToFree'),
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: () => {},
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
onClick: () => {
if (effectiveTier !== 'BASIC') void handleCancelSubscription();
},
},
{
id: 'pro',
@@ -293,7 +321,7 @@ export function BillingPlans() {
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
t('billing.proFeature1'),
t('billing.proFeature2'),
t('billing.proFeature3'),
t('billing.proFeature3', { count: providerCount }),
t('billing.proFeature4'),
t('billing.proFeature5'),
t('billing.proFeature6'),
@@ -304,7 +332,7 @@ export function BillingPlans() {
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'),
onClick: () => handleChangePlan('PRO'),
},
{
id: 'business',
@@ -316,7 +344,7 @@ export function BillingPlans() {
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
t('billing.businessFeature1'),
t('billing.businessFeature2'),
t('billing.businessFeature3'),
t('billing.businessFeature3', { count: providerCount }),
t('billing.businessFeature4'),
t('billing.businessFeature5'),
t('billing.businessFeature6'),
@@ -325,8 +353,8 @@ export function BillingPlans() {
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : trialCta(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'),
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
onClick: () => handleChangePlan('BUSINESS'),
},
{
id: 'enterprise',
@@ -345,22 +373,24 @@ export function BillingPlans() {
buttonText: effectiveTier === 'ENTERPRISE' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.contactSales') || 'Contact Sales'),
buttonClass: effectiveTier === 'ENTERPRISE'
? 'bg-paper text-concrete cursor-default'
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
onClick: () => { window.location.href = 'mailto:sales@memento-note.com'; },
},
];
const plansToShow = isPaid ? plans.filter((p) => p.id !== 'free') : plans;
const plansToShow = plans;
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';
const locale = language === 'fa' ? 'fa-IR' : language === 'zh' ? 'zh-CN' : language;
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
...(language === 'fa' ? { calendar: 'persian' as const } : {}),
}).format(date);
} catch (e) {
return dateStr;
@@ -476,30 +506,44 @@ export function BillingPlans() {
</div>
)}
{isPaid && (
<div className="flex flex-wrap gap-3">
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={scrollToPlans}
className="flex items-center gap-2 px-5 py-2.5 bg-brand-accent text-white rounded-xl text-xs font-semibold hover:opacity-90 transition-all shadow-md shadow-brand-accent/20"
>
{t('billing.changeOffer')}
</button>
{isPaid && !status?.cancelAtPeriodEnd && (
<button
type="button"
onClick={handleCancelSubscription}
disabled={cancelLoading}
className="flex items-center gap-2 px-5 py-2.5 border border-rose-200 text-rose-600 dark:border-rose-800/40 dark:text-rose-400 hover:bg-rose-50/50 dark:hover:bg-rose-950/15 rounded-xl text-xs font-semibold transition-all"
>
{cancelLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{t('billing.cancelSubscription')}
</button>
)}
{isPaid && status?.hasStripeSubscription && (
<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"
className="flex items-center gap-2 px-5 py-2.5 border border-border text-ink rounded-xl text-xs font-semibold hover:bg-paper/60 dark:hover:bg-white/5 disabled:opacity-60 transition-all"
>
{portalLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ExternalLink className="h-4 w-4" />}
{t('billing.manageBilling') || 'Gérer la facturation'}
{t('billing.manageBilling')}
</button>
)}
</div>
{status?.hasStripeSubscription && !status?.cancelAtPeriodEnd && (
<button
type="button"
onClick={handleCancelSubscription}
disabled={cancelLoading}
className="flex items-center gap-2 px-5 py-2.5 border border-rose-200 text-rose-600 dark:border-rose-800/40 dark:text-rose-400 hover:bg-rose-50/50 dark:hover:bg-rose-950/15 rounded-xl text-xs font-semibold transition-all"
>
{cancelLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{t('billing.cancelSubscription') || "Résilier l'abonnement"}
</button>
)}
</div>
{isPaid && status?.cancelAtPeriodEnd && (
<p className="text-xs text-amber-700 dark:text-amber-300 bg-amber-500/10 border border-amber-500/20 rounded-xl px-3 py-2">
{t('billing.cancellingNotice', { date: formatDate(status.currentPeriodEnd) })}
</p>
)}
</div>
@@ -600,7 +644,7 @@ export function BillingPlans() {
type="button"
onClick={() => handleBuyPack(pack.id)}
disabled={!pack.configured || packLoading !== null}
className="mt-auto w-full py-3 rounded-2xl bg-ink text-white dark:bg-white dark:text-black text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 disabled:opacity-40 transition-all"
className="mt-auto w-full py-3 rounded-2xl bg-brand-accent text-white text-[13px] font-semibold uppercase tracking-wider hover:opacity-90 disabled:opacity-40 transition-all"
>
{packLoading === pack.id ? (
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
@@ -661,8 +705,8 @@ export function BillingPlans() {
const pct = totalUsed > 0 && used > 0 ? (used / totalUsed) * 100 : 0
const barFillColor =
pct >= 40
? 'bg-gradient-to-r from-violet-400 to-purple-400'
: 'bg-gradient-to-r from-violet-300/80 to-purple-300/80'
? 'bg-brand-accent'
: 'bg-brand-accent/60'
return (
<div
key={key}
@@ -693,8 +737,7 @@ export function BillingPlans() {
{isPaid && <BillingHistory />}
{/* Interval Toggle & Plan Cards */}
{!isPaid && (
<div className="space-y-8 pt-6 border-t border-border/40">
<div ref={plansSectionRef} 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'}
@@ -708,7 +751,7 @@ export function BillingPlans() {
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'
interval === 'month' ? 'bg-brand-accent text-white' : 'text-concrete hover:text-ink'
)}
>
{t('billing.monthly')}
@@ -718,7 +761,7 @@ export function BillingPlans() {
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'
interval === 'year' ? 'bg-brand-accent text-white' : 'text-concrete hover:text-ink'
)}
>
{t('billing.annual')}
@@ -773,7 +816,12 @@ export function BillingPlans() {
<button
onClick={plan.onClick}
disabled={plan.current || checkoutLoading !== null}
disabled={
plan.current
|| checkoutLoading !== null
|| cancelLoading
|| (plan.id === 'free' && !!status?.cancelAtPeriodEnd)
}
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">
@@ -784,8 +832,7 @@ export function BillingPlans() {
</div>
))}
</div>
</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">

View File

@@ -0,0 +1,31 @@
'use client'
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
const TITLE_KEYS: Record<string, string> = {
'/settings/general': 'generalSettings.title',
'/settings/ai': 'aiSettings.title',
'/settings/billing': 'billing.title',
'/settings/appearance': 'appearance.title',
'/settings/profile': 'profile.title',
'/settings/data': 'dataManagement.title',
'/settings/published': 'settings.publishedTitle',
'/settings/integrations': 'integrations.title',
'/settings/mcp': 'mcpSettings.title',
'/settings/about': 'about.title',
}
export function SettingsDocumentTitle() {
const pathname = usePathname()
const { t } = useLanguage()
useEffect(() => {
const key = Object.keys(TITLE_KEYS).find((href) => pathname === href || pathname.startsWith(`${href}/`))
const section = key ? t(TITLE_KEYS[key]) : t('settings.title')
document.title = `${section} — Memento`
}, [pathname, t])
return null
}

View File

@@ -28,7 +28,7 @@ export function SettingsHelpBox({ title, steps, defaultOpen = false, className }
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-border/10 transition-colors"
>
<HelpCircle size={14} className="text-brand-accent shrink-0" />
<span className="text-[12px] font-semibold text-ink flex-1">{title}</span>
<span className="text-sm font-semibold text-ink flex-1">{title}</span>
{open ? (
<ChevronUp size={13} className="text-concrete shrink-0" />
) : (
@@ -40,10 +40,10 @@ export function SettingsHelpBox({ title, steps, defaultOpen = false, className }
<ol className="px-4 pb-4 space-y-2.5 border-t border-border/30 pt-3">
{steps.map((step, i) => (
<li key={i} className="flex items-start gap-2.5">
<span className="w-5 h-5 rounded-full bg-brand-accent/10 text-brand-accent text-[10px] font-bold flex items-center justify-center shrink-0 mt-0.5">
<span className="w-5 h-5 rounded-full bg-brand-accent/10 text-brand-accent text-xs font-semibold flex items-center justify-center shrink-0 mt-0.5">
{step.icon ?? i + 1}
</span>
<span className="text-[12px] text-concrete leading-relaxed">
<span className="text-sm text-concrete leading-relaxed">
{step.text}
{step.link && (
<>