feat: dashboard Second Brain, essai 7 jours et vérification e-mail
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:
@@ -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')}`}
|
||||
|
||||
92
memento-note/app/(auth)/check-email/page.tsx
Normal file
92
memento-note/app/(auth)/check-email/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
memento-note/app/(auth)/verify-email/page.tsx
Normal file
97
memento-note/app/(auth)/verify-email/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
memento-note/app/actions/auth-verify.ts
Normal file
11
memento-note/app/actions/auth-verify.ts
Normal 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)
|
||||
}
|
||||
@@ -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.';
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
Reference in New Issue
Block a user