fix(ui): thème, textes et lisibilité restants de la revue
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:
@@ -103,7 +103,7 @@ export default function InsightsPage() {
|
||||
const [isStale, setIsStale] = useState(false)
|
||||
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard')
|
||||
const [graphMode, setGraphMode] = useState<'visual' | 'list'>('visual')
|
||||
const [graphMode, setGraphMode] = useState<'visual' | 'list'>('list')
|
||||
const [listFilter, setListFilter] = useState('')
|
||||
const [listSort, setListSort] = useState<'size' | 'alpha' | 'bridges'>('size')
|
||||
/** Un seul cluster déplié à la fois (null = tous repliés) */
|
||||
@@ -445,7 +445,7 @@ export default function InsightsPage() {
|
||||
<button
|
||||
className="p-2 -ms-1 text-foreground hover:bg-foreground/5 rounded-lg transition-colors shrink-0 cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('toggle-insights-sidebar'))}
|
||||
aria-label="Toggle sidebar"
|
||||
aria-label={t('insightsView.toggleMenu')}
|
||||
>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
@@ -717,7 +717,7 @@ export default function InsightsPage() {
|
||||
style={{ backgroundColor: cluster.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-ink dark:text-dark-ink truncate flex-1 min-w-0">
|
||||
<h3 className="min-w-0 flex-1 text-[13px] font-semibold leading-snug text-ink dark:text-dark-ink">
|
||||
{name}
|
||||
</h3>
|
||||
<span className="text-[9px] text-concrete shrink-0 tabular-nums">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BillingPlans } from '@/components/settings/billing-plans';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Billing',
|
||||
title: 'Facturation — Memento',
|
||||
};
|
||||
|
||||
function Fallback() {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Menu } from 'lucide-react'
|
||||
import { SettingsNav } from '@/components/settings'
|
||||
import { SettingsDocumentTitle } from '@/components/settings/settings-document-title'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default function SettingsLayout({
|
||||
@@ -11,9 +12,10 @@ export default function SettingsLayout({
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-zinc-950">
|
||||
<header className="px-4 sm:px-8 md:px-12 pt-8 sm:pt-14 md:pt-20 pb-6 sm:pb-10 md:pb-16 space-y-6 sm:space-y-10 md:space-y-12 shrink-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<SettingsDocumentTitle />
|
||||
<header className="px-4 sm:px-8 md:px-12 pt-6 sm:pt-8 md:pt-10 pb-4 sm:pb-6 shrink-0">
|
||||
<div className="flex items-start gap-3 mb-6">
|
||||
<button
|
||||
className="md:hidden p-2 -ms-1 text-ink dark:text-zinc-200 hover:bg-ink/5 dark:hover:bg-white/10 rounded-lg transition-colors shrink-0 mt-1"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
|
||||
@@ -22,10 +24,10 @@ export default function SettingsLayout({
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-3xl sm:text-5xl md:text-[64px] font-serif text-ink dark:text-zinc-50 tracking-tight leading-none italic font-medium">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-serif text-ink dark:text-zinc-50 tracking-tight leading-tight font-medium">
|
||||
{t('settings.title')}
|
||||
</h1>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete dark:text-zinc-500 opacity-60 mt-4">
|
||||
<p className="text-sm text-concrete dark:text-zinc-400 mt-1.5">
|
||||
{t('settings.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function McpSettingsHeader() {
|
||||
{ text: t('mcpSettings.helpBox.step2') },
|
||||
{ text: t('mcpSettings.helpBox.step3') },
|
||||
{ text: t('mcpSettings.helpBox.step4'), link: { label: t('mcpSettings.helpBox.step4Link'), href: 'https://modelcontextprotocol.io/docs' } },
|
||||
{ icon: '⚡', text: t('mcpSettings.helpBox.step5') },
|
||||
{ text: t('mcpSettings.helpBox.step5') },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { Shield } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Check } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function PricingPage() {
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
|
||||
const trialDays = SUBSCRIPTION_TRIAL_DAYS
|
||||
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 PLANS = [
|
||||
const plans = [
|
||||
{ key: 'basic', popular: false, hasTrial: false, price: t('landing.pricing.basicPrice'), period: '' },
|
||||
{
|
||||
key: 'pro',
|
||||
@@ -39,88 +48,113 @@ export default function PricingPage() {
|
||||
]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-paper">
|
||||
<section className="py-32 px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.pricing.label')}</span>
|
||||
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-6">{t('landing.pricing.title')}</h2>
|
||||
<p className="text-concrete font-light max-w-xl mx-auto mb-12">{t('landing.pricing.desc')}</p>
|
||||
<main className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white">
|
||||
<nav className="sticky top-0 z-[100] px-5 sm:px-8 py-4 flex items-center justify-between bg-[#0B0A09]/70 backdrop-blur-xl border-b border-white/[0.06]">
|
||||
<Link href="/" className="flex items-center gap-2.5 group">
|
||||
<div className="w-9 h-9 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-lg">
|
||||
<span className="font-serif text-xl font-bold leading-none">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Link href="/login" className="text-[13px] text-white/75 hover:text-white transition-colors px-2">
|
||||
{t('landing.nav.login')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[13px] font-semibold hover:bg-white transition-colors"
|
||||
>
|
||||
{t('landing.nav.cta')}
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center justify-center gap-10 mb-8">
|
||||
<button onClick={() => setBillingInterval('monthly')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'monthly' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
|
||||
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.monthly')}</span>
|
||||
{billingInterval === 'monthly' && (
|
||||
<motion.div layoutId="interval-active-pricing" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
|
||||
)}
|
||||
<section className="px-5 sm:px-8 py-28">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-[#D4A373] mb-4 block">
|
||||
{t('landing.pricing.label')}
|
||||
</span>
|
||||
<h1 className="font-serif text-3xl sm:text-5xl tracking-tight mb-4">{t('landing.pricing.title')}</h1>
|
||||
<p className="text-white/70 mb-8">{t('landing.pricing.desc')}</p>
|
||||
<div className="inline-flex p-1 rounded-full border border-white/10 bg-white/[0.03]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingInterval('monthly')}
|
||||
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all ${billingInterval === 'monthly' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/70'}`}
|
||||
>
|
||||
{t('landing.pricing.monthly')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingInterval('annual')}
|
||||
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/70'}`}
|
||||
>
|
||||
{t('landing.pricing.annual')}
|
||||
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373] whitespace-nowrap">
|
||||
{t('landing.pricing.savePercent')}
|
||||
</span>
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button onClick={() => setBillingInterval('annual')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'annual' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
|
||||
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.annual')}</span>
|
||||
{billingInterval === 'annual' && (
|
||||
<motion.div layoutId="interval-active-pricing" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
|
||||
)}
|
||||
</button>
|
||||
<div className="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
||||
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">
|
||||
{t('landing.pricing.savePercent')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
|
||||
{PLANS.map((plan) => (
|
||||
<div key={plan.key} className={`relative p-8 rounded-[32px] border flex flex-col transition-all duration-300 hover:shadow-2xl hover:shadow-ink/5 ${plan.popular ? 'bg-ink text-paper border-ink ring-4 ring-ochre/20' : 'bg-white border-border text-ink'}`}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{plans.map((plan) => (
|
||||
<div
|
||||
key={plan.key}
|
||||
className={`rounded-2xl border p-6 flex flex-col ${
|
||||
plan.popular
|
||||
? 'border-[#D4A373]/50 bg-[#D4A373]/10'
|
||||
: 'border-white/[0.08] bg-white/[0.02]'
|
||||
}`}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-ochre text-ink text-[10px] font-bold uppercase tracking-widest rounded-full">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-[#D4A373] mb-3">
|
||||
{t('landing.pricing.popular')}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
<div className="mb-8">
|
||||
<h4 className="text-[11px] font-bold uppercase tracking-widest mb-2 opacity-60">{t(`landing.pricing.${plan.key}.name`)}</h4>
|
||||
<div className="flex items-baseline gap-1 mb-4">
|
||||
<span className="text-4xl font-serif font-medium">{plan.price}</span>
|
||||
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
|
||||
</div>
|
||||
{plan.hasTrial && (
|
||||
<p className={`text-[11px] font-semibold mb-3 ${plan.popular ? 'text-ochre' : 'text-brand-accent'}`}>
|
||||
{t('landing.pricing.trialBadge', { days: trialDays })}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
||||
<h2 className="text-[13px] font-medium tracking-wide text-white/80 mb-2">
|
||||
{t(`landing.pricing.${plan.key}.name`)}
|
||||
</h2>
|
||||
<div className="flex items-baseline gap-1 mb-2">
|
||||
<span className="text-3xl font-serif">{plan.price}</span>
|
||||
{plan.period && <span className="text-sm text-white/70">{plan.period}</span>}
|
||||
</div>
|
||||
<div className="flex-1 space-y-4 mb-10">
|
||||
{plan.hasTrial && (
|
||||
<p className="text-[11px] font-semibold text-[#D4A373] mb-3">
|
||||
{t('landing.pricing.trialBadge', { days: trialDays })}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-white/70 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
||||
<ul className="space-y-2.5 mb-8 flex-1">
|
||||
{plan.hasTrial && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`mt-1 rounded-full p-0.5 ${plan.popular ? 'bg-ochre text-ink' : 'bg-brand-accent/10 text-brand-accent'}`}>
|
||||
<Shield size={10} fill="currentColor" />
|
||||
</div>
|
||||
<span className="text-xs font-medium">{t('landing.pricing.trialFeature', { days: trialDays })}</span>
|
||||
</div>
|
||||
<li className="flex gap-2 text-xs text-[#D4A373]/90">
|
||||
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
|
||||
{t('landing.pricing.trialFeature', { days: trialDays })}
|
||||
</li>
|
||||
)}
|
||||
{[0, 1, 2, 3, 4, 5].map(j => {
|
||||
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
|
||||
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null
|
||||
{[0, 1, 2, 3, 4, 5].map((j) => {
|
||||
const feat = t(`landing.pricing.${plan.key}.feature${j}`, { count: providerCount })
|
||||
if (!feat || feat.startsWith('landing.')) return null
|
||||
return (
|
||||
<div key={j} className="flex items-start gap-3">
|
||||
<div className={`mt-1 rounded-full p-0.5 ${plan.popular ? 'bg-ochre text-ink' : 'bg-brand-accent/10 text-brand-accent'}`}>
|
||||
<Shield size={10} fill="currentColor" />
|
||||
</div>
|
||||
<span className="text-xs font-light">{feat}</span>
|
||||
</div>
|
||||
<li key={j} className="flex gap-2 text-sm text-white/80">
|
||||
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
|
||||
{feat}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/register')}
|
||||
className={`w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-widest transition-all ${plan.popular ? 'bg-ochre text-ink hover:opacity-90' : 'bg-ink text-paper hover:bg-ink/90'}`}
|
||||
</ul>
|
||||
<Link
|
||||
href="/register"
|
||||
className={`py-3 rounded-xl text-center text-[13px] font-semibold transition-colors ${
|
||||
plan.popular
|
||||
? 'bg-[#F4F1EA] text-[#0B0A09] hover:bg-white'
|
||||
: 'bg-white/10 text-white hover:bg-white/15'
|
||||
}`}
|
||||
>
|
||||
{plan.hasTrial
|
||||
? t('landing.pricing.trialCta', { days: trialDays })
|
||||
: t(`landing.pricing.${plan.key}.cta`)}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -368,11 +368,11 @@ export default async function PrivacyPage({
|
||||
{locale === 'fr' ? 'Confidentialité' : 'Privacy'}
|
||||
</p>
|
||||
<h1 className="font-serif text-4xl sm:text-5xl tracking-tight mb-4">{doc.title}</h1>
|
||||
<p className="text-sm text-white/40 mb-3">{doc.lastUpdated}</p>
|
||||
<p className="text-sm text-white/70 mb-3">{doc.lastUpdated}</p>
|
||||
<p className="text-white/65 leading-relaxed text-lg mb-12">{doc.intro}</p>
|
||||
|
||||
<nav aria-label="Sommaire" className="mb-12 p-5 rounded-xl bg-white/[0.03] border border-white/[0.06]">
|
||||
<p className="text-[11px] uppercase tracking-[0.2em] text-white/40 mb-3">
|
||||
<p className="text-[13px] font-medium tracking-wide text-white/70 mb-3">
|
||||
{locale === 'fr' ? 'Sommaire' : 'Contents'}
|
||||
</p>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { auth } from '@/auth'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import { SubscriptionTier, SubscriptionStatus } from '@prisma/client'
|
||||
import { addUtcMonths } from '@/lib/billing/period'
|
||||
|
||||
// Schema pour la création d'utilisateur
|
||||
const CreateUserSchema = z.object({
|
||||
@@ -152,8 +153,7 @@ export async function updateUserSubscription(userId: string, tier: string) {
|
||||
const oldTier = existing?.tier ?? 'BASIC'
|
||||
|
||||
const now = new Date()
|
||||
const periodEnd = new Date(now)
|
||||
periodEnd.setFullYear(periodEnd.getFullYear() + 1)
|
||||
const periodEnd = addUtcMonths(now, 1)
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: { userId },
|
||||
|
||||
@@ -8,25 +8,12 @@ import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
|
||||
import { z } from 'zod'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
import { countPlainWords, MIN_WORDS_FOR_TITLE_SUGGESTION, stripHtmlToPlainText } from '@/lib/text/plain-text'
|
||||
|
||||
const requestSchema = z.object({
|
||||
content: z.string().min(1, "Le contenu ne peut pas être vide"),
|
||||
})
|
||||
|
||||
/** Supprime les balises HTML pour extraire le texte brut */
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Check authentication and user setting
|
||||
@@ -48,17 +35,10 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json()
|
||||
const { content: rawContent } = requestSchema.parse(body)
|
||||
|
||||
// Nettoyer le HTML (l'éditeur TipTap envoie du HTML)
|
||||
const content = stripHtml(rawContent)
|
||||
const content = stripHtmlToPlainText(rawContent)
|
||||
|
||||
// Vérifier qu'il y a au moins 10 mots
|
||||
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||
|
||||
if (wordCount < 10) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Le contenu doit avoir au moins 10 mots' },
|
||||
{ status: 400 }
|
||||
)
|
||||
if (countPlainWords(content) < MIN_WORDS_FOR_TITLE_SUGGESTION) {
|
||||
return NextResponse.json({ suggestions: [] })
|
||||
}
|
||||
|
||||
const config = await getSystemConfig()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { stripe } from '@/lib/stripe';
|
||||
import { getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
|
||||
import { syncSubscriptionFromStripe } from '@/lib/billing/sync-subscription-from-stripe';
|
||||
import { shouldOfferSubscriptionTrial, SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial';
|
||||
import { periodsDiffer, rollBillingPeriod } from '@/lib/billing/period';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -41,9 +42,37 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
let subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
const stripeSecret = process.env.STRIPE_SECRET_KEY;
|
||||
const canTalkToStripe = !!stripeSecret && stripeSecret !== 'sk_test_placeholder';
|
||||
|
||||
if (
|
||||
subscription?.stripeSubscriptionId
|
||||
&& !subscription.stripeSubscriptionId.startsWith('sub_mock')
|
||||
&& canTalkToStripe
|
||||
) {
|
||||
try {
|
||||
const live = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId);
|
||||
await syncSubscriptionFromStripe(live, userId);
|
||||
subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
} catch (syncErr) {
|
||||
console.error('[billing/status] live Stripe period sync failed:', syncErr);
|
||||
}
|
||||
} else if (subscription && !subscription.cancelAtPeriodEnd) {
|
||||
const rolled = rollBillingPeriod(subscription.currentPeriodStart);
|
||||
if (periodsDiffer(rolled, subscription)) {
|
||||
subscription = await prisma.subscription.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
currentPeriodStart: rolled.currentPeriodStart,
|
||||
currentPeriodEnd: rolled.currentPeriodEnd,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { tier, status, currentPeriodEnd } = await getUserInfo(userId);
|
||||
const effectiveTier = await getEffectiveTier(userId);
|
||||
const subscription = await prisma.subscription.findUnique({ where: { userId } });
|
||||
const prices = await getDynamicPrices();
|
||||
const billingEnabled = await isBillingEnabled();
|
||||
const { getPackPublicPrices } = await import('@/lib/billing/credit-packs');
|
||||
@@ -89,8 +118,8 @@ export async function GET(req: NextRequest) {
|
||||
tier,
|
||||
effectiveTier,
|
||||
status,
|
||||
currentPeriodEnd: currentPeriodEnd ?? null,
|
||||
currentPeriodStart: subscription?.currentPeriodStart ?? null,
|
||||
currentPeriodEnd: subscription?.currentPeriodEnd?.toISOString() ?? currentPeriodEnd?.toISOString() ?? null,
|
||||
currentPeriodStart: subscription?.currentPeriodStart?.toISOString() ?? null,
|
||||
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
|
||||
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
|
||||
trialEndsAt: subscription?.trialEndsAt?.toISOString() ?? null,
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function GET() {
|
||||
notebookId: true, updatedAt: true, createdAt: true,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 8,
|
||||
take: 12,
|
||||
}),
|
||||
|
||||
prisma.note.count({
|
||||
@@ -86,7 +86,7 @@ export async function GET() {
|
||||
|
||||
prisma.note.findMany({
|
||||
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
|
||||
select: { id: true, title: true, notebookId: true, updatedAt: true },
|
||||
select: { id: true, title: true, content: true, notebookId: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 3,
|
||||
}),
|
||||
@@ -132,7 +132,7 @@ export async function GET() {
|
||||
|
||||
prisma.note.findMany({
|
||||
where: { userId, isPinned: true, trashedAt: null, isArchived: false },
|
||||
select: { id: true, title: true, notebookId: true, updatedAt: true },
|
||||
select: { id: true, title: true, content: true, notebookId: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 5,
|
||||
}),
|
||||
@@ -170,7 +170,10 @@ export async function GET() {
|
||||
insightRows = [...insightRows, ...viewedRecent]
|
||||
}
|
||||
|
||||
const notebookIds = [...new Set(recentNotes.map(n => n.notebookId).filter(Boolean))] as string[]
|
||||
const notebookIds = [...new Set([
|
||||
...recentNotes.map(n => n.notebookId),
|
||||
...pinnedNotes.map(n => n.notebookId),
|
||||
].filter(Boolean))] as string[]
|
||||
const notebooks = notebookIds.length > 0
|
||||
? await prisma.notebook.findMany({
|
||||
where: { id: { in: notebookIds } },
|
||||
@@ -188,6 +191,7 @@ export async function GET() {
|
||||
inboxPreview: inboxPreview.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
excerpt: excerptNoteContent(n.content, 80),
|
||||
notebookId: n.notebookId,
|
||||
updatedAt: n.updatedAt.toISOString(),
|
||||
})),
|
||||
@@ -235,8 +239,10 @@ export async function GET() {
|
||||
pinnedNotes: pinnedNotes.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
excerpt: excerptNoteContent(n.content, 80),
|
||||
notebookId: n.notebookId,
|
||||
updatedAt: n.updatedAt.toISOString(),
|
||||
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
|
||||
})),
|
||||
writingActivity,
|
||||
agentSuggestions: agentSuggestions.map(s => ({
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function GET() {
|
||||
|
||||
const userId = session.user.id
|
||||
const locale = await detectUserLanguage()
|
||||
const cacheKey = `briefing:sentiment:${userId}:${locale}`
|
||||
const cacheKey = `briefing:sentiment:${userId}:${locale}:v2`
|
||||
|
||||
try {
|
||||
const cached = await redis.get(cacheKey)
|
||||
@@ -39,7 +39,7 @@ export async function GET() {
|
||||
isArchived: false,
|
||||
updatedAt: { gte: weekAgo },
|
||||
},
|
||||
select: { title: true, content: true },
|
||||
select: { id: true, title: true, content: true, notebookId: true },
|
||||
take: 20,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
@@ -64,10 +64,14 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const localeLabel = locale === 'fr' ? 'French' : locale === 'en' ? 'English' : locale
|
||||
const voice = locale === 'fr'
|
||||
? 'Write summary in French, second person singular (tu). Example: « Cette semaine, tu as surtout… ». Never write « the person », « the user », or third person.'
|
||||
: `Write summary in ${localeLabel}, second person ("you"). Never write "the person", "the user", or third person.`
|
||||
const prompt = `Analyze the emotional patterns in these notes from the past week. Return ONLY valid JSON (no markdown, no code fences).
|
||||
Write "summary" and "topTopic" in ${localeLabel} (locale code: ${locale}). Keep dominantEmotion keys in English as listed.
|
||||
${voice}
|
||||
Keep dominantEmotion keys in English as listed. One short sentence for summary. Do not quote secrets, passwords, or URLs.
|
||||
|
||||
{"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence describing the emotional pattern","topTopic":"most discussed topic"}
|
||||
{"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence","topTopic":"most discussed topic"}
|
||||
|
||||
Notes:
|
||||
${snippets.slice(0, 3000)}`
|
||||
@@ -84,7 +88,16 @@ ${snippets.slice(0, 3000)}`
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0])
|
||||
const payload = { available: true, ...parsed }
|
||||
const relatedNotes = recentNotes.slice(0, 2).map(n => {
|
||||
const title = n.title?.trim()
|
||||
const plain = n.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
return {
|
||||
id: n.id,
|
||||
title: title || (plain ? (plain.length > 80 ? `${plain.slice(0, 80).trim()}…` : plain) : null),
|
||||
notebookId: n.notebookId,
|
||||
}
|
||||
})
|
||||
const payload = { available: true, ...parsed, relatedNotes }
|
||||
try { await redis.setex(cacheKey, CACHE_TTL_SEC, JSON.stringify(payload)) } catch {}
|
||||
return NextResponse.json(payload)
|
||||
} catch (error) {
|
||||
|
||||
13
memento-note/app/api/public/byok-catalog/route.ts
Normal file
13
memento-note/app/api/public/byok-catalog/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPublicByokCatalog } from '@/lib/ai/byok-catalog'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/** Liste publique des fournisseurs / modèles BYOK — aucun secret. */
|
||||
export async function GET() {
|
||||
const providers = await getPublicByokCatalog()
|
||||
return NextResponse.json(
|
||||
{ providers },
|
||||
{ headers: { 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400' } },
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { getEffectiveTier } from '@/lib/entitlements';
|
||||
import { isByokProviderAllowed } from '@/lib/byok';
|
||||
import { fetchLiveModelsForProvider, PROVIDER_MODEL_SUGGESTIONS, type FetchModelsResult } from '@/lib/ai/models-list';
|
||||
import { getActiveByokKey, isByokProviderAllowed } from '@/lib/byok';
|
||||
import { decryptApiKey } from '@/lib/crypto';
|
||||
import { fetchLiveModelsForProvider, type FetchModelsResult } from '@/lib/ai/models-list';
|
||||
import { VALID_PROVIDERS, type AiGatewayProvider } from '@/lib/ai/router';
|
||||
|
||||
// Providers that return static suggestions regardless of key
|
||||
const STATIC_PROVIDERS = new Set(['anthropic', 'anthropic_custom', 'custom_anthropic', 'google', 'minimax']);
|
||||
|
||||
/**
|
||||
* GET /api/user/api-keys/live-models?provider=<provider>[&key=<api_key>][&baseUrl=<url>]
|
||||
*
|
||||
* - Static providers (minimax, anthropic, google): returns suggestions immediately, no key needed.
|
||||
* - Live providers (openai, deepseek…): requires key to fetch live from provider.
|
||||
* Liste les modèles chez le fournisseur. Sans clé (saisie ou déjà enregistrée) : liste vide.
|
||||
* Pas de liste de secours figée.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
@@ -27,8 +25,8 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const provider = searchParams.get('provider') as AiGatewayProvider;
|
||||
const apiKey = searchParams.get('key') ?? '';
|
||||
const baseUrl = searchParams.get('baseUrl') ?? undefined;
|
||||
let apiKey = searchParams.get('key') ?? '';
|
||||
let baseUrl = searchParams.get('baseUrl') ?? undefined;
|
||||
|
||||
if (!provider) {
|
||||
return NextResponse.json({ error: 'Missing provider' }, { status: 400 });
|
||||
@@ -42,16 +40,20 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Tier restricted' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Static suggestion providers: return immediately without a key
|
||||
if (STATIC_PROVIDERS.has(provider)) {
|
||||
const base = provider === 'anthropic_custom' || provider === 'custom_anthropic' ? 'anthropic' : provider;
|
||||
const models = PROVIDER_MODEL_SUGGESTIONS[base] ?? [];
|
||||
return NextResponse.json({ success: true, models, fromApi: false });
|
||||
if (!apiKey || apiKey.length < 4) {
|
||||
const saved = await getActiveByokKey(session.user.id, provider, tier);
|
||||
if (saved) {
|
||||
try {
|
||||
apiKey = await decryptApiKey(saved.encryptedKey);
|
||||
if (!baseUrl && saved.baseUrl) baseUrl = saved.baseUrl;
|
||||
} catch {
|
||||
apiKey = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Live providers need a key
|
||||
if (!apiKey || apiKey.length < 4) {
|
||||
return NextResponse.json({ success: true, models: PROVIDER_MODEL_SUGGESTIONS[provider] ?? [], fromApi: false });
|
||||
return NextResponse.json({ success: true, models: [], fromApi: false });
|
||||
}
|
||||
|
||||
const result: FetchModelsResult = await fetchLiveModelsForProvider(provider, apiKey, baseUrl);
|
||||
|
||||
Reference in New Issue
Block a user