feat: dashboard Second Brain, essai 7 jours et vérification e-mail
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m14s
CI / Deploy production (on server) (push) Successful in 1m25s

Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-08-30 07:19:36 +00:00
parent 69c99e4f4f
commit 80ccc1f6de
95 changed files with 4158 additions and 618 deletions

View File

@@ -8,7 +8,32 @@ import { Checkbox } from '@/components/ui/checkbox'
import { updateBillingConfig } from '@/app/actions/admin-billing'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { CreditCard, Gauge, Coins, Package } from 'lucide-react'
import {
CreditCard,
Gauge,
Coins,
Package,
Activity,
CheckCircle2,
XCircle,
AlertTriangle,
Users,
BookOpen,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type PriceHealth = {
key: string
priceId: string
configured: boolean
source: string
stripeAmount: number | null
stripeCurrency: string | null
stripeActive: boolean | null
error: string | null
}
type BillingAdminData = {
billingConfig: Record<string, string>
@@ -19,7 +44,6 @@ type BillingAdminData = {
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
}
tiers: string[]
/** Allocations mensuelles du solde unique (source de vérité débit) */
creditAllocations?: Array<{
tier: string
monthlyCredits: number | null
@@ -31,6 +55,35 @@ type BillingAdminData = {
credits: number
defaultDisplay: string
}>
stripeHealth?: {
secretConfigured: boolean
secretMode: 'test' | 'live' | 'missing' | 'placeholder'
publishableConfigured: boolean
webhookSecretConfigured: boolean
billingEnabled: boolean
trialDays: number
prices: PriceHealth[]
}
subscriptionStats?: {
byTier: Record<string, number>
byStatus: Record<string, number>
cancelAtPeriodEnd: number
usersWithoutSub: number
trialing: number
pastDue: number
paidActive: number
recent: Array<{
email: string
name: string | null
tier: string
status: string
trialEndsAt: string | null
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
hasStripeSub: boolean
updatedAt: string
}>
}
}
const SUBSCRIPTION_PRICE_KEYS = [
@@ -46,7 +99,6 @@ const PACK_PRICE_KEYS = [
'STRIPE_PRICE_CREDITS_L',
] as const
/** Date stable SSR/client (pas de toLocaleString — mismatch locale + fuseau). */
function formatStableUtc(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
@@ -54,10 +106,21 @@ function formatStableUtc(iso: string): string {
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`
}
function StatusDot({ ok, warn }: { ok: boolean; warn?: boolean }) {
if (ok) return <CheckCircle2 className="h-4 w-4 text-emerald-600 shrink-0" />
if (warn) return <AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
return <XCircle className="h-4 w-4 text-rose-500 shrink-0" />
}
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
const { t } = useLanguage()
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
const [isSavingBilling, setIsSavingBilling] = useState(false)
const [showTestGuide, setShowTestGuide] = useState(true)
const health = initialData.stripeHealth
const stats = initialData.subscriptionStats
const priceMap = Object.fromEntries((health?.prices ?? []).map((p) => [p.key, p]))
const handleSaveBilling = async (formData: FormData) => {
setIsSavingBilling(true)
@@ -77,6 +140,15 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
}
}
const modeLabel =
health?.secretMode === 'test'
? t('admin.billing.modeTest')
: health?.secretMode === 'live'
? t('admin.billing.modeLive')
: health?.secretMode === 'placeholder'
? t('admin.billing.modePlaceholder')
: t('admin.billing.modeMissing')
return (
<div className="space-y-8">
<div>
@@ -84,6 +156,246 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
<p className="text-sm text-muted-foreground mt-1">{t('admin.billing.description')}</p>
</div>
{/* Stripe health */}
{health && (
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Activity className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{t('admin.billing.healthTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.healthDescription')}</p>
</div>
</div>
<div className="p-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok={health.secretConfigured} warn={health.secretMode === 'placeholder'} />
<div className="min-w-0">
<p className="text-xs font-semibold">{t('admin.billing.healthSecret')}</p>
<p className="text-[11px] text-muted-foreground">{modeLabel}</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok={health.publishableConfigured} />
<div>
<p className="text-xs font-semibold">{t('admin.billing.healthPublishable')}</p>
<p className="text-[11px] text-muted-foreground">
{health.publishableConfigured ? t('admin.billing.configured') : t('admin.billing.missing')}
</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok={health.webhookSecretConfigured} />
<div>
<p className="text-xs font-semibold">{t('admin.billing.healthWebhook')}</p>
<p className="text-[11px] text-muted-foreground">
{health.webhookSecretConfigured ? t('admin.billing.configured') : t('admin.billing.missing')}
</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok={health.billingEnabled} warn={!health.billingEnabled} />
<div>
<p className="text-xs font-semibold">{t('admin.billing.healthBillingFlag')}</p>
<p className="text-[11px] text-muted-foreground">
{health.billingEnabled ? t('admin.billing.enabled') : t('admin.billing.disabled')}
</p>
</div>
</div>
<div className="flex items-start gap-2 rounded-xl border border-border/60 bg-muted/20 p-3">
<StatusDot ok />
<div>
<p className="text-xs font-semibold">{t('admin.billing.healthTrial')}</p>
<p className="text-[11px] text-muted-foreground">
{t('admin.billing.trialDaysValue', { days: health.trialDays })}
</p>
</div>
</div>
</div>
<div className="px-6 pb-6">
<h3 className="text-sm font-medium mb-3">{t('admin.billing.priceStatusTitle')}</h3>
<div className="overflow-x-auto rounded-xl border border-border/60">
<table className="w-full text-xs">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colKey')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colPriceId')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colSource')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colStripe')}</th>
</tr>
</thead>
<tbody>
{(health.prices ?? []).map((row) => (
<tr key={row.key} className="border-t border-border/50">
<td className="p-2.5 font-mono">{t(`admin.billing.${row.key}`)}</td>
<td className="p-2.5 font-mono truncate max-w-[180px]">
{row.configured ? row.priceId : '—'}
</td>
<td className="p-2.5">{row.source}</td>
<td className="p-2.5">
{!row.configured ? (
<span className="text-rose-600">{t('admin.billing.missing')}</span>
) : row.error ? (
<span className="text-rose-600" title={row.error}>{t('admin.billing.priceError')}</span>
) : row.stripeAmount != null ? (
<span className={cn(row.stripeActive === false && 'text-amber-600')}>
{row.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {row.stripeCurrency}
{row.stripeActive === false ? ` (${t('admin.billing.inactive')})` : ''}
</span>
) : (
<span className="text-muted-foreground">{t('admin.billing.notChecked')}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Subscription stats */}
{stats && (
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Users className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{t('admin.billing.subsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.subsDescription')}</p>
</div>
</div>
<div className="p-6 grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statPaid')}</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.paidActive}</p>
</div>
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statTrialing')}</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.trialing}</p>
</div>
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statPastDue')}</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.pastDue}</p>
</div>
<div className="rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('admin.billing.statCanceling')}</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{stats.cancelAtPeriodEnd}</p>
</div>
</div>
<div className="px-6 pb-4 grid gap-4 sm:grid-cols-2">
<div>
<h3 className="text-sm font-medium mb-2">{t('admin.billing.byTier')}</h3>
<ul className="space-y-1.5 text-sm">
{Object.entries(stats.byTier).map(([tier, count]) => (
<li key={tier} className="flex justify-between border-b border-border/40 pb-1">
<span>{tier}</span>
<span className="tabular-nums text-muted-foreground">{count}</span>
</li>
))}
<li className="flex justify-between text-xs text-muted-foreground pt-1">
<span>{t('admin.billing.usersWithoutSub')}</span>
<span className="tabular-nums">{stats.usersWithoutSub}</span>
</li>
</ul>
</div>
<div>
<h3 className="text-sm font-medium mb-2">{t('admin.billing.byStatus')}</h3>
<ul className="space-y-1.5 text-sm">
{Object.keys(stats.byStatus).length === 0 ? (
<li className="text-muted-foreground text-xs">{t('admin.billing.noSubs')}</li>
) : (
Object.entries(stats.byStatus).map(([status, count]) => (
<li key={status} className="flex justify-between border-b border-border/40 pb-1">
<span>{status}</span>
<span className="tabular-nums text-muted-foreground">{count}</span>
</li>
))
)}
</ul>
</div>
</div>
<div className="px-6 pb-6">
<h3 className="text-sm font-medium mb-2">{t('admin.billing.recentSubs')}</h3>
{stats.recent.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.billing.noSubs')}</p>
) : (
<div className="overflow-x-auto rounded-xl border border-border/60">
<table className="w-full text-xs">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colUser')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colTier')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colStatus')}</th>
<th className="text-start p-2.5 font-medium">{t('admin.billing.colPeriod')}</th>
</tr>
</thead>
<tbody>
{stats.recent.map((row) => (
<tr key={`${row.email}-${row.updatedAt}`} className="border-t border-border/50">
<td className="p-2.5 truncate max-w-[200px]">{row.email}</td>
<td className="p-2.5">{row.tier}</td>
<td className="p-2.5">
{row.status}
{row.cancelAtPeriodEnd ? ` · ${t('admin.billing.canceling')}` : ''}
{!row.hasStripeSub ? ` · ${t('admin.billing.manualTier')}` : ''}
</td>
<td className="p-2.5 text-muted-foreground">
{row.trialEndsAt
? t('admin.billing.trialUntil', { date: formatStableUtc(row.trialEndsAt) })
: row.currentPeriodEnd
? formatStableUtc(row.currentPeriodEnd)
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{/* How to test Stripe */}
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<button
type="button"
onClick={() => setShowTestGuide((v) => !v)}
className="w-full flex items-center gap-3 p-6 border-b border-border text-start hover:bg-muted/20 transition-colors"
>
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<BookOpen className="h-5 w-5" />
</div>
<div className="flex-1">
<h2 className="font-semibold">{t('admin.billing.testGuideTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.testGuideDescription')}</p>
</div>
{showTestGuide ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
</button>
{showTestGuide && (
<div className="p-6 space-y-3 text-sm text-muted-foreground leading-relaxed">
<ol className="list-decimal ps-5 space-y-2">
<li>{t('admin.billing.testStep1')}</li>
<li>{t('admin.billing.testStep2')}</li>
<li>
<code className="text-[11px] bg-muted px-1.5 py-0.5 rounded">stripe listen --forward-to localhost:3000/api/billing/webhook</code>
{' — '}{t('admin.billing.testStep3')}
</li>
<li>{t('admin.billing.testStep4')}</li>
<li>{t('admin.billing.testStep5')}</li>
<li>{t('admin.billing.testStep6')}</li>
</ol>
<p className="text-xs border-t border-border/50 pt-3">
{t('admin.billing.testCardHint')}
</p>
</div>
)}
</div>
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
@@ -106,17 +418,28 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
<div>
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{SUBSCRIPTION_PRICE_KEYS.map((key) => (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
id={key}
name={key}
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
</div>
))}
{SUBSCRIPTION_PRICE_KEYS.map((key) => {
const meta = priceMap[key]
return (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
id={key}
name={key}
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
{meta?.stripeAmount != null && (
<p className="text-[11px] text-muted-foreground">
Stripe: {meta.stripeAmount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} {meta.stripeCurrency}
</p>
)}
{meta?.error && (
<p className="text-[11px] text-rose-600">{meta.error}</p>
)}
</div>
)
})}
</div>
</div>
<div className="border-t border-border/50 pt-4">
@@ -144,7 +467,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
</form>
</div>
{/* Solde unique — source de vérité du débit */}
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
@@ -216,7 +538,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
{initialData.usageOverview.lastSyncedAt
? ` · ${t('admin.billing.lastSync', {
// Format fixe UTC (évite mismatch SSR locale/fuseau vs client)
date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
})}`
: ` · ${t('admin.billing.notSynced')}`}

View File

@@ -0,0 +1,92 @@
'use client'
import { Suspense, useState } from 'react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Mail, ArrowLeft, Sparkles } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { resendSignupVerification } from '@/app/actions/auth-verify'
import { toast } from 'sonner'
function CheckEmailContent() {
const { t, language } = useLanguage()
const searchParams = useSearchParams()
const initialEmail = searchParams.get('email') ?? ''
const [email, setEmail] = useState(initialEmail)
const [sending, setSending] = useState(false)
const handleResend = async () => {
const target = email.trim()
if (!target) {
toast.error(t('auth.verifyMissingEmail'))
return
}
setSending(true)
const result = await resendSignupVerification(target, language)
setSending(false)
if (result.success) {
toast.success(t('auth.verifyResent'))
} else {
toast.error(t('auth.verifyResendFailed'))
}
}
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl">
<div className="space-y-8 text-center">
<div className="w-14 h-14 mx-auto rounded-2xl bg-[var(--color-brand-accent)]/10 flex items-center justify-center">
<Mail size={28} className="text-[var(--color-brand-accent)]" />
</div>
<div className="space-y-2">
<h1 className="text-2xl md:text-3xl font-serif font-bold">
{t('auth.checkEmailTitle')}
</h1>
<p className="text-[var(--muted-foreground)] text-sm font-light leading-relaxed">
{initialEmail
? t('auth.checkEmailDescription', { email: initialEmail })
: t('auth.checkEmailDescriptionGeneric')}
</p>
</div>
<div className="space-y-1.5 text-start">
<label htmlFor="email" className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
{t('auth.email')}
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('auth.emailPlaceholder')}
className="w-full bg-slate-50 dark:bg-white/5 border border-[var(--border)] rounded-2xl py-4 px-4 text-sm outline-none focus:border-[var(--color-brand-accent)] focus:ring-4 ring-[var(--color-brand-accent)]/5 transition-all"
/>
</div>
<button
type="button"
onClick={handleResend}
disabled={sending}
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] flex items-center justify-center gap-3 transition-all hover:shadow-xl hover:shadow-black/10 active:scale-[0.98] disabled:opacity-50"
>
{sending ? <Sparkles size={16} className="animate-spin" /> : t('auth.resendVerification')}
</button>
<Link
href="/login"
className="inline-flex items-center gap-2 text-xs text-[var(--muted-foreground)] hover:text-[var(--color-brand-accent)] transition-colors"
>
<ArrowLeft size={14} />
{t('auth.backToLogin')}
</Link>
</div>
</div>
)
}
export default function CheckEmailPage() {
return (
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground"></div>}>
<CheckEmailContent />
</Suspense>
)
}

View File

@@ -0,0 +1,97 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { CheckCircle2, AlertCircle, Sparkles } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { confirmEmail } from '@/app/actions/auth-verify'
function VerifyEmailContent() {
const { t } = useLanguage()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [status, setStatus] = useState<'loading' | 'ok' | 'invalid' | 'expired'>('loading')
useEffect(() => {
if (!token) {
setStatus('invalid')
return
}
let cancelled = false
;(async () => {
const result = await confirmEmail(token)
if (cancelled) return
if (result.success) setStatus('ok')
else setStatus(result.error === 'expired' ? 'expired' : 'invalid')
})()
return () => {
cancelled = true
}
}, [token])
if (status === 'loading') {
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-4">
<Sparkles size={28} className="mx-auto animate-spin text-[var(--color-brand-accent)]" />
<p className="text-sm text-[var(--muted-foreground)]">{t('auth.verifyLoading')}</p>
</div>
)
}
if (status === 'ok') {
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
<div className="w-14 h-14 mx-auto rounded-2xl bg-emerald-500/10 flex items-center justify-center">
<CheckCircle2 size={28} className="text-emerald-600" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-serif font-bold">{t('auth.verifySuccessTitle')}</h1>
<p className="text-sm text-[var(--muted-foreground)]">{t('auth.verifySuccessDescription')}</p>
</div>
<Link href="/login?verified=1" className="block">
<button
type="button"
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all hover:shadow-xl active:scale-[0.98]"
>
{t('auth.signIn')}
</button>
</Link>
</div>
)
}
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
<div className="w-14 h-14 mx-auto rounded-2xl bg-red-500/10 flex items-center justify-center">
<AlertCircle size={28} className="text-red-500" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-serif font-bold">
{status === 'expired' ? t('auth.verifyExpiredTitle') : t('auth.verifyInvalidTitle')}
</h1>
<p className="text-sm text-[var(--muted-foreground)]">
{status === 'expired'
? t('auth.verifyExpiredDescription')
: t('auth.verifyInvalidDescription')}
</p>
</div>
<Link href="/check-email" className="block">
<button
type="button"
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all hover:shadow-xl active:scale-[0.98]"
>
{t('auth.resendVerification')}
</button>
</Link>
</div>
)
}
export default function VerifyEmailPage() {
return (
<Suspense fallback={<div className="p-10 text-center text-sm text-muted-foreground"></div>}>
<VerifyEmailContent />
</Suspense>
)
}

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { useRouter, useSearchParams } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
import { motion, AnimatePresence, useReducedMotion } from 'motion/react'
import {
@@ -84,6 +84,7 @@ const COLOR_PALETTE = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#
export default function InsightsPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { t, language: locale } = useLanguage()
const formatSyncTime = useCallback(
@@ -129,6 +130,15 @@ export default function InsightsPage() {
loadInitialData()
}, [])
useEffect(() => {
const clusterParam = searchParams.get('cluster')
if (!clusterParam || clusters.length === 0) return
const match = clusters.find(
c => c.id === clusterParam || String(c.clusterId) === clusterParam,
)
if (match) setSelectedClusterId(match.id)
}, [searchParams, clusters])
// ─── Données calculées ───────────────────────────────────────────────────────
const selectedCluster = useMemo(

View File

@@ -4,18 +4,38 @@ import { motion } from 'motion/react'
import { Shield } from 'lucide-react'
import { useRouter } from 'next/navigation'
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 PLANS = [
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
{ key: 'pro', popular: true, price: billingInterval === 'monthly' ? '9,90€' : '7,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
{ key: 'business', popular: false, price: billingInterval === 'monthly' ? '29,90€' : '23,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
{ key: 'basic', popular: false, hasTrial: false, price: t('landing.pricing.basicPrice'), period: '' },
{
key: 'pro',
popular: true,
hasTrial: true,
price: billingInterval === 'monthly' ? t('landing.pricing.proMonthly') : t('landing.pricing.proAnnualMonthly'),
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
},
{
key: 'business',
popular: false,
hasTrial: true,
price: billingInterval === 'monthly' ? t('landing.pricing.businessMonthly') : t('landing.pricing.businessAnnualMonthly'),
period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual'),
},
{
key: 'enterprise',
popular: false,
hasTrial: false,
price: t('landing.pricing.enterprisePrice'),
period: '',
},
]
return (
@@ -42,7 +62,9 @@ export default function PricingPage() {
)}
</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">(-20%)</span>
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">
{t('landing.pricing.savePercent')}
</span>
</div>
</div>
</div>
@@ -62,9 +84,22 @@ export default function PricingPage() {
<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>
</div>
<div className="flex-1 space-y-4 mb-10">
{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>
)}
{[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
@@ -82,7 +117,9 @@ export default function PricingPage() {
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'}`}
>
{t(`landing.pricing.${plan.key}.cta`)}
{plan.hasTrial
? t('landing.pricing.trialCta', { days: trialDays })
: t(`landing.pricing.${plan.key}.cta`)}
</button>
</div>
))}

View File

@@ -2,13 +2,24 @@ import { headers } from 'next/headers'
import Link from 'next/link'
import { parseAcceptLanguage } from '@/lib/i18n/detect-user-language'
export const metadata = {
title: 'Privacy Policy — Memento',
description: 'How Memento handles your data, the Chrome and Firefox web clipper extension, and AI features.',
alternates: { canonical: 'https://memento-note.com/privacy' },
robots: { index: true, follow: true },
export async function generateMetadata({
searchParams,
}: {
searchParams?: Promise<{ lang?: string | string[] }>
}) {
const sp = searchParams ? (await searchParams) : undefined
const locale = await pickLocale(sp?.lang)
return {
title: locale === 'fr' ? 'Politique de confidentialité — Memento' : 'Privacy Policy — Memento',
description: locale === 'fr'
? "Comment Memento gère vos données, l'extension de capture web Chrome et Firefox, et les fonctionnalités d'IA."
: 'How Memento handles your data, the Chrome and Firefox web clipper extension, and AI features.',
alternates: { canonical: 'https://memento-note.com/privacy' },
robots: { index: true, follow: true },
}
}
type Locale = 'en' | 'fr'
const SUPPORTED: Locale[] = ['en', 'fr']
const RTL: Locale[] = []
@@ -353,7 +364,9 @@ export default async function PrivacyPage({
</header>
<article className="max-w-3xl mx-auto px-5 sm:px-8 py-16 sm:py-24">
<p className="text-[11px] uppercase tracking-[0.3em] text-[#D4A373] mb-4">Privacy</p>
<p className="text-[11px] uppercase tracking-[0.3em] text-[#D4A373] mb-4">
{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-white/65 leading-relaxed text-lg mb-12">{doc.intro}</p>

View File

@@ -46,6 +46,152 @@ function assertValidTier(tier: string): asserts tier is TierType {
}
}
async function getStripeHealth(billingConfig: Record<string, string>) {
const secret = process.env.STRIPE_SECRET_KEY ?? ''
const publishable = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ''
const webhook = process.env.STRIPE_WEBHOOK_SECRET ?? ''
let secretMode: 'test' | 'live' | 'missing' | 'placeholder' = 'missing'
if (!secret) secretMode = 'missing'
else if (secret === 'sk_test_placeholder' || secret.includes('placeholder')) secretMode = 'placeholder'
else if (secret.startsWith('sk_live_')) secretMode = 'live'
else if (secret.startsWith('sk_test_')) secretMode = 'test'
else secretMode = 'placeholder'
const { isBillingEnabled } = await import('@/lib/billing/stripe-prices')
const { SUBSCRIPTION_TRIAL_DAYS } = await import('@/lib/billing/trial-constants')
const billingEnabled = await isBillingEnabled()
const priceKeys = [
...BILLING_CONFIG_KEYS.filter((k) => k.startsWith('STRIPE_PRICE_')),
] as string[]
const canCallStripe = secretMode === 'test' || secretMode === 'live'
let stripe: Awaited<ReturnType<typeof import('@/lib/stripe').getStripe>> | null = null
if (canCallStripe) {
try {
const { getStripe } = await import('@/lib/stripe')
stripe = getStripe()
} catch {
stripe = null
}
}
const prices = await Promise.all(
priceKeys.map(async (key) => {
const priceId = (billingConfig[key] || process.env[key] || '').trim()
const configured = Boolean(priceId) && !priceId.startsWith('price_mock_')
let stripeAmount: number | null = null
let stripeCurrency: string | null = null
let stripeActive: boolean | null = null
let error: string | null = null
if (configured && stripe) {
try {
const price = await stripe.prices.retrieve(priceId)
stripeAmount = price.unit_amount != null ? price.unit_amount / 100 : null
stripeCurrency = price.currency?.toUpperCase() ?? null
stripeActive = price.active
} catch (e) {
error = e instanceof Error ? e.message : 'Stripe price lookup failed'
}
}
return {
key,
priceId: configured ? priceId : '',
configured,
source: billingConfig[key] ? 'db' : process.env[key] ? 'env' : 'missing',
stripeAmount,
stripeCurrency,
stripeActive,
error,
}
}),
)
return {
secretConfigured: secretMode === 'test' || secretMode === 'live',
secretMode,
publishableConfigured: Boolean(publishable) && publishable.startsWith('pk_'),
webhookSecretConfigured: Boolean(webhook) && webhook.startsWith('whsec_'),
billingEnabled,
trialDays: SUBSCRIPTION_TRIAL_DAYS,
prices,
}
}
async function getSubscriptionStats() {
const [byTierRaw, byStatusRaw, cancelAtPeriodEnd, recent] = await Promise.all([
prisma.subscription.groupBy({
by: ['tier'],
_count: { _all: true },
}),
prisma.subscription.groupBy({
by: ['status'],
_count: { _all: true },
}),
prisma.subscription.count({ where: { cancelAtPeriodEnd: true } }),
prisma.subscription.findMany({
where: {
OR: [
{ tier: { in: ['PRO', 'BUSINESS', 'ENTERPRISE'] } },
{ status: { in: ['TRIALING', 'PAST_DUE', 'ACTIVE'] } },
],
},
orderBy: { updatedAt: 'desc' },
take: 15,
select: {
tier: true,
status: true,
trialEndsAt: true,
currentPeriodEnd: true,
cancelAtPeriodEnd: true,
updatedAt: true,
stripeSubscriptionId: true,
user: { select: { email: true, name: true } },
},
}),
])
const byTier: Record<string, number> = Object.fromEntries(TIERS.map((t) => [t, 0]))
for (const row of byTierRaw) {
byTier[row.tier] = row._count._all
}
const byStatus: Record<string, number> = {}
for (const row of byStatusRaw) {
byStatus[row.status] = row._count._all
}
const usersWithoutSub = await prisma.user.count({
where: { subscription: null },
})
return {
byTier,
byStatus,
cancelAtPeriodEnd,
usersWithoutSub,
trialing: byStatus.TRIALING ?? 0,
pastDue: byStatus.PAST_DUE ?? 0,
paidActive:
(byStatus.ACTIVE ?? 0) +
(byStatus.TRIALING ?? 0),
recent: recent.map((s) => ({
email: s.user.email,
name: s.user.name,
tier: s.tier,
status: s.status,
trialEndsAt: s.trialEndsAt?.toISOString() ?? null,
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? null,
cancelAtPeriodEnd: s.cancelAtPeriodEnd,
hasStripeSub: Boolean(s.stripeSubscriptionId),
updatedAt: s.updatedAt.toISOString(),
})),
}
}
export async function getBillingAdminData() {
await checkAdmin()
const { getSystemConfig } = await import('@/lib/config')
@@ -83,6 +229,11 @@ export async function getBillingAdminData() {
}
})
const [stripeHealth, subscriptionStats] = await Promise.all([
getStripeHealth(billingConfig),
getSubscriptionStats(),
])
return {
entitlements,
billingConfig,
@@ -92,6 +243,8 @@ export async function getBillingAdminData() {
creditAllocations,
creditCosts,
creditPacks,
stripeHealth,
subscriptionStats,
}
}

View File

@@ -0,0 +1,11 @@
'use server'
import { verifyEmailToken, resendVerificationEmail } from '@/lib/auth/email-verification'
export async function confirmEmail(token: string) {
return verifyEmailToken(token)
}
export async function resendSignupVerification(email: string, locale?: string) {
return resendVerificationEmail(email, locale)
}

View File

@@ -2,20 +2,49 @@
import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
import bcrypt from 'bcryptjs';
import prisma from '@/lib/prisma';
export async function authenticate(
prevState: string | undefined,
formData: FormData,
) {
const emailRaw = formData.get('email');
const passwordRaw = formData.get('password');
const email = typeof emailRaw === 'string' ? emailRaw.toLowerCase().trim() : '';
const password = typeof passwordRaw === 'string' ? passwordRaw : '';
// Surface a clear message when credentials are valid but email is unverified.
if (email && password.length >= 6) {
try {
const user = await prisma.user.findUnique({ where: { email } });
if (user?.password) {
const match = await bcrypt.compare(password, user.password);
if (match && !user.emailVerified) {
return 'EMAIL_NOT_VERIFIED';
}
}
} catch (preCheckErr) {
console.error('[authenticate] emailVerified pre-check failed:', preCheckErr);
}
}
try {
await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
email,
password,
redirectTo: '/home',
});
} catch (error) {
if (error instanceof AuthError) {
console.error('AuthError details:', error.type, error.message);
if (
error.type === 'CredentialsSignin' &&
(error.message?.includes('EMAIL_NOT_VERIFIED') ||
(error.cause as { err?: Error } | undefined)?.err?.message === 'EMAIL_NOT_VERIFIED')
) {
return 'EMAIL_NOT_VERIFIED';
}
switch (error.type) {
case 'CredentialsSignin':
return 'Invalid credentials.';

View File

@@ -5,6 +5,7 @@ import prisma from '@/lib/prisma';
import { z } from 'zod';
import { redirect } from 'next/navigation';
import { getSystemConfig } from '@/lib/config';
import { sendVerificationEmail } from '@/lib/auth/email-verification';
const RegisterSchema = z.object({
email: z.string().email(),
@@ -17,9 +18,8 @@ const RegisterSchema = z.object({
});
export async function register(prevState: string | undefined, formData: FormData) {
// Check if registration is allowed
const config = await getSystemConfig();
const allowRegister = config.ALLOW_REGISTRATION !== 'false' || process.env.ALLOW_REGISTRATION !== 'false';
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
if (!allowRegister) {
return 'Registration is currently disabled by the administrator.';
@@ -37,36 +37,48 @@ export async function register(prevState: string | undefined, formData: FormData
}
const { email, password, name } = validatedFields.data;
const normalizedEmail = email.toLowerCase();
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
const isAdmin = Boolean(adminEmail && normalizedEmail === adminEmail);
try {
const existingUser = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
const existingUser = await prisma.user.findUnique({ where: { email: normalizedEmail } });
if (existingUser) {
return 'User already exists.';
}
const hashedPassword = await bcrypt.hash(password, 10);
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
const role = adminEmail && email.toLowerCase() === adminEmail ? 'ADMIN' : 'USER';
const role = isAdmin ? 'ADMIN' : 'USER';
await prisma.user.create({
data: {
email: email.toLowerCase(),
email: normalizedEmail,
password: hashedPassword,
name,
role,
// Admin bootstrap + Google OAuth are trusted; everyone else must verify.
emailVerified: isAdmin ? new Date() : null,
},
});
// Attempt to sign in immediately after registration
// We cannot import signIn here directly if it causes circular deps or issues,
// but usually it works. If not, redirecting to login is fine.
// Let's stick to redirecting to login but with a clear success message?
// Or better: lowercase the email to fix the potential bug.
if (!isAdmin) {
const mailResult = await sendVerificationEmail({
email: normalizedEmail,
name,
});
if (!mailResult.success) {
console.error('[register] verification email failed:', mailResult.error);
}
}
} catch (error) {
console.error('Registration Error:', error);
return 'Database Error: Failed to create user.';
}
redirect('/login');
if (isAdmin) {
redirect('/login?verified=1');
}
redirect(`/check-email?email=${encodeURIComponent(normalizedEmail)}`);
}

View File

@@ -17,14 +17,14 @@ export const maxDuration = 60
const sectionPlanSchema = z.object({
title: z.string().min(1),
goal: z.string().min(1),
demoKind: z.enum(['svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']),
demoGoal: z.string().optional(),
demoKind: z.enum(['steps', 'svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none']),
demoGoal: z.string().nullish(),
})
const requestSchema = z.object({
/** undefined = legacy deterministic full-page (fallback path) */
action: z.enum(['plan', 'section']).optional(),
content: z.string().min(40),
content: z.string().min(40).max(500_000),
lang: z.string().optional(),
noteId: z.string().optional(),
notebookId: z.string().optional(),
@@ -44,7 +44,10 @@ export async function POST(req: NextRequest) {
return aiConsentForbiddenResponse()
}
const body = await req.json()
const body = await req.json().catch(() => null)
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const parsed = requestSchema.parse(body)
const wordCount = parsed.content
.replace(/<[^>]+>/g, ' ')
@@ -61,11 +64,12 @@ export async function POST(req: NextRequest) {
const provider = getSlidesProvider(config)
const lang = parsed.lang || 'fr'
// ── LLM plan (billed: page = 20 crédits, spec §8.5) ──────────────────
// ── LLM plan (billed 5/20 crédits — le reste est facturé par section) ──
if (parsed.action === 'plan') {
try {
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
lane: 'chat',
amount: 5,
})
} catch (err) {
if (err instanceof QuotaExceededError) {
@@ -97,7 +101,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ plan: result.plan, attempts: result.attempts })
}
// ── LLM single section (already billed at plan time) ────────────────
// ── LLM single section (billed 3/20 crédits per section — no free LLM) ──
if (parsed.action === 'section') {
if (!parsed.section || !parsed.sectionId || !parsed.pageTitle) {
return NextResponse.json(
@@ -105,6 +109,26 @@ export async function POST(req: NextRequest) {
{ status: 400 }
)
}
try {
await reserveAiUsageOrThrow(session.user.id, 'interactive_page', {
lane: 'chat',
amount: 3,
})
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 402 })
}
if (
err instanceof QuotaServiceUnavailableError ||
process.env.NODE_ENV === 'production'
) {
return NextResponse.json(
{ error: 'QUOTA_SERVICE_UNAVAILABLE' },
{ status: 503 }
)
}
console.error('[/api/ai/interactive-page] Quota check error (fail-open):', err)
}
const result = await generatePageSection({
content: parsed.content,
lang,
@@ -171,7 +195,16 @@ export async function POST(req: NextRequest) {
})
} catch (error: unknown) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.issues }, { status: 400 })
return NextResponse.json(
{
error: 'Requête invalide',
issues: error.issues.slice(0, 10).map((i) => ({
path: i.path.join('.'),
message: i.message,
})),
},
{ status: 400 }
)
}
const message =
error instanceof Error ? error.message : 'Erreur génération interactive page'

View File

@@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { stripe } from '@/lib/stripe';
import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices';
import {
shouldOfferSubscriptionTrial,
SUBSCRIPTION_TRIAL_DAYS,
} from '@/lib/billing/trial';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';
@@ -91,6 +95,17 @@ export async function POST(req: NextRequest) {
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
const origin = `${proto}://${host}`;
const offerTrial = await shouldOfferSubscriptionTrial(userId);
const subscriptionData: {
metadata: { userId: string; tier: string };
trial_period_days?: number;
} = {
metadata: { userId, tier },
};
if (offerTrial) {
subscriptionData.trial_period_days = SUBSCRIPTION_TRIAL_DAYS;
}
// Hosted Checkout is the most reliable path (redirect). Embedded is optional.
if (preferredMode === 'embedded') {
try {
@@ -100,8 +115,8 @@ export async function POST(req: NextRequest) {
line_items: [{ price: priceId, quantity: 1 }],
ui_mode: 'embedded' as any,
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
subscription_data: subscriptionData,
customer_update: { address: 'auto' },
allow_promotion_codes: true,
} as any);
@@ -109,6 +124,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({
clientSecret: embedded.client_secret,
sessionId: embedded.id,
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
});
}
} catch (embeddedErr) {
@@ -122,8 +138,8 @@ export async function POST(req: NextRequest) {
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/settings/billing?canceled=1`,
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },
metadata: { userId, tier, trial: offerTrial ? '1' : '0' },
subscription_data: subscriptionData,
customer_update: { address: 'auto' },
allow_promotion_codes: true,
});
@@ -132,7 +148,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 });
}
return NextResponse.json({ url: checkoutSession.url, sessionId: checkoutSession.id });
return NextResponse.json({
url: checkoutSession.url,
sessionId: checkoutSession.id,
trialDays: offerTrial ? SUBSCRIPTION_TRIAL_DAYS : 0,
});
} catch (error) {
console.error('[billing/create-checkout]', error);
const msg = error instanceof Error ? error.message : 'Failed to create checkout session';

View File

@@ -2,7 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
import { stripe } from '@/lib/stripe';
import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
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';
export const dynamic = 'force-dynamic';
@@ -23,47 +25,16 @@ export async function GET(req: NextRequest) {
if (checkoutSession.subscription && checkoutSession.status === 'complete') {
const subId = typeof checkoutSession.subscription === 'string'
? checkoutSession.subscription
: (checkoutSession.subscription as any).id;
: (checkoutSession.subscription as { id: string }).id;
const sub = await stripe.subscriptions.retrieve(subId) as any;
const priceId = sub.items.data[0].price.id;
const tier = (await priceIdToTier(priceId)) || (checkoutSession.metadata?.tier as any) || 'PRO';
const currentPeriodStartTimestamp =
sub.current_period_start ??
sub.items?.data?.[0]?.current_period_start ??
sub.start_date ??
Math.floor(Date.now() / 1000);
const currentPeriodEndTimestamp =
sub.current_period_end ??
sub.items?.data?.[0]?.current_period_end ??
(currentPeriodStartTimestamp + 30 * 24 * 3600);
await prisma.subscription.upsert({
where: { userId },
update: {
tier,
status: 'ACTIVE',
stripeCustomerId: checkoutSession.customer as string,
stripeSubscriptionId: sub.id,
stripePriceId: priceId,
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
canceledAt: sub.canceled_at ? new Date(sub.canceled_at * 1000) : null,
cancelAtPeriodEnd: sub.cancel_at_period_end,
},
create: {
userId,
tier,
status: 'ACTIVE',
stripeCustomerId: checkoutSession.customer as string,
stripeSubscriptionId: sub.id,
stripePriceId: priceId,
currentPeriodStart: new Date(currentPeriodStartTimestamp * 1000),
currentPeriodEnd: new Date(currentPeriodEndTimestamp * 1000),
},
});
const sub = await stripe.subscriptions.retrieve(subId);
const syncUserId =
(checkoutSession.metadata?.userId as string | undefined) ??
(sub.metadata?.userId as string | undefined) ??
userId;
if (syncUserId === userId) {
await syncSubscriptionFromStripe(sub, userId);
}
}
} catch (err) {
console.error('[billing/status] Failed to sync Stripe session:', err);
@@ -112,6 +83,8 @@ export async function GET(req: NextRequest) {
}
}
const trialEligible = await shouldOfferSubscriptionTrial(userId);
return NextResponse.json({
tier,
effectiveTier,
@@ -120,6 +93,9 @@ export async function GET(req: NextRequest) {
currentPeriodStart: subscription?.currentPeriodStart ?? null,
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
trialEndsAt: subscription?.trialEndsAt?.toISOString() ?? null,
trialEligible,
trialDays: trialEligible ? SUBSCRIPTION_TRIAL_DAYS : 0,
prices,
creditPacks,
billingEnabled,

View File

@@ -12,6 +12,8 @@ import {
isCreditPackId,
resolvePackFromPriceId,
} from '@/lib/billing/credit-packs';
import { sendTrialEndingReminder } from '@/lib/billing/trial-reminder-email';
import { prisma } from '@/lib/prisma';
import type Stripe from 'stripe';
export const runtime = 'nodejs';
@@ -159,6 +161,48 @@ export async function POST(req: NextRequest) {
break;
}
case 'customer.subscription.trial_will_end': {
const subscription = event.data.object as Stripe.Subscription;
const userId = await resolveUserIdFromStripeEvent(subscription);
if (!userId) {
console.warn('[billing/webhook] trial_will_end: no userId', subscription.id);
break;
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
email: true,
name: true,
aiSettings: { select: { preferredLanguage: true } },
},
});
if (!user?.email) break;
await syncSubscriptionFromStripe(subscription, userId);
const trialEndsAt = subscription.trial_end
? new Date(subscription.trial_end * 1000)
: new Date(Date.now() + 3 * 24 * 3600 * 1000);
const appUrl =
process.env.NEXTAUTH_URL?.replace(/\/$/, '') ||
process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') ||
'https://memento-note.com';
const preferred = user.aiSettings?.preferredLanguage ?? 'en';
const mailResult = await sendTrialEndingReminder({
to: user.email,
name: user.name,
trialEndsAt,
billingUrl: `${appUrl}/settings/billing`,
locale: preferred === 'auto' ? 'en' : preferred,
});
if (!mailResult.success) {
console.error('[billing/webhook] trial reminder email failed:', mailResult.error);
}
break;
}
default:
break;
}

View File

@@ -59,6 +59,7 @@ export async function GET() {
const [
recentNotes,
inboxCount,
inboxPreview,
dueFlashcards,
upcomingReminders,
unviewedInsights,
@@ -83,6 +84,13 @@ export async function GET() {
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
}),
prisma.note.findMany({
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
select: { id: true, title: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 3,
}),
prisma.flashcard.count({
where: { deck: { userId }, nextReviewAt: { lte: now } },
}),
@@ -177,6 +185,12 @@ export async function GET() {
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
})),
inboxCount,
inboxPreview: inboxPreview.map(n => ({
id: n.id,
title: n.title,
notebookId: n.notebookId,
updatedAt: n.updatedAt.toISOString(),
})),
dueFlashcards,
upcomingReminders: upcomingReminders.map(r => ({
id: r.id,

View File

@@ -7,8 +7,7 @@ import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { isPublishTemplateId, isInteractivePageTemplate } from '@/lib/publish/types'
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
import { validateInteractivePage } from '@/lib/interactive-page'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { validateInteractivePage, type PageSpecV1, type PageValidationResult } from '@/lib/interactive-page'
import { getSystemConfig } from '@/lib/config'
import { getSlidesProvider } from '@/lib/ai/factory'
import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service'
@@ -93,11 +92,61 @@ async function updateNotePublishState(noteId: string, data: PublishUpdateData) {
}
}
/** All human-facing text of a PageSpecV1 — fed to moderation. */
function collectPageText(page: PageSpecV1): string[] {
const out: string[] = [
page.hero.kicker,
page.hero.title,
page.hero.subtitle ?? '',
page.hero.meta ?? '',
page.footer ?? '',
]
if (page.overview) {
out.push(page.overview.lead)
for (const c of page.overview.cards) out.push(c.badge, c.title, c.body)
}
for (const section of page.sections) {
out.push(section.title)
for (const b of section.blocks) {
if (b.type === 'prose') out.push(b.md)
else if (b.type === 'formula') out.push(b.caption ?? '')
else if (b.type === 'callout') out.push(b.title, b.md)
else if (b.type === 'demo') {
out.push(b.caption ?? '', b.demo.disclaimer ?? '')
for (const act of b.demo.acts) {
out.push(act.title)
for (const st of act.steps) out.push(st.speak)
}
for (const panel of b.demo.scene.panels) {
if (panel.type === 'svg-scene') {
for (const n of panel.payload.nodes) out.push(n.label ?? '')
}
}
} else if (b.type === 'chart') {
out.push(b.caption ?? '')
for (const s of b.payload.series) out.push(s.label ?? '')
} else if (b.type === 'stats') {
for (const it of b.items) out.push(it.value, it.label)
} else if (b.type === 'table') {
out.push(b.caption ?? '', ...b.columns, ...b.rows.flat())
} else if (b.type === 'image') {
out.push(b.alt, b.caption ?? '')
} else if (b.type === 'sim') {
out.push(b.caption ?? '', b.sim.title ?? '', b.sim.disclaimer ?? '')
}
}
}
return out.filter((s) => s && s.trim())
}
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await request.json()
const body = await request.json().catch(() => null)
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { noteId, action, mode, template, language, rewrite, pageSpec } = body as {
noteId?: string
action?: string
@@ -123,9 +172,21 @@ export async function POST(request: NextRequest) {
return aiConsentForbiddenResponse()
}
let validatedPage = pageSpec ? validateInteractivePage(pageSpec) : null
let validatedPage: PageValidationResult | null = null
if (pageSpec) {
// Client-provided page (from the preview dialog): must validate as-is.
// Never silently substitute a different page than the one previewed.
const checked = validateInteractivePage(pageSpec)
if (!checked.ok) {
return NextResponse.json(
{ error: 'invalid_page_spec', issues: checked.issues.slice(0, 12) },
{ status: 422 }
)
}
validatedPage = checked
}
if (!validatedPage?.ok) {
if (!validatedPage) {
// Deterministic generate (no LLM quota)
const config = await getSystemConfig()
const provider = getSlidesProvider(config)
@@ -144,22 +205,12 @@ export async function POST(request: NextRequest) {
{ status: 422 }
)
}
validatedPage = { ok: true, page: generated.page }
validatedPage = { ok: true as const, page: generated.page }
}
// Guaranteed valid PageSpec after generate-or-validate above
if (!validatedPage?.ok) {
return NextResponse.json({ error: 'invalid_page_spec' }, { status: 422 })
}
const textForModeration = [
validatedPage.page.hero.title,
validatedPage.page.hero.subtitle,
validatedPage.page.overview?.lead,
...validatedPage.page.sections.map((s) => s.title),
]
.filter(Boolean)
.join('\n')
// Moderate ALL human-facing text of the page (prose, callouts, demo
// narration, tables, captions) — not just titles.
const textForModeration = collectPageText(validatedPage.page).join('\n')
const moderation = await moderateWithFallback(
note.title || '',