feat(credits): solde IA unique (option M), packs Stripe et polish billing

Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace
les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux
legacy, usage multi-features, hints de coût, anti-flash thème et hydratation
admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
This commit is contained in:
Antigravity
2026-07-16 20:43:18 +00:00
parent 704fed1191
commit 556a0b2f3f
77 changed files with 35745 additions and 10430 deletions

View File

@@ -2,9 +2,7 @@
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2, Download, Pencil, Presentation, Wind, Scissors } from 'lucide-react'
import { Bell, Check, X, Clock, AlertCircle, Circle, Share2, Bot, Download, Pencil, Presentation, Wind, Scissors } from 'lucide-react'
import {
Popover,
PopoverContent,
@@ -46,15 +44,6 @@ interface ReminderNote {
isReminderDone: boolean
}
// ── Memento brand tokens ──────────────────────────────────────────────────────
const C = {
blue: '#FDFDFE',
gold: '#D4A373',
green: '#A3B18A',
dark: '#1C1C1C',
beige: '#FDFDFE',
}
export function NotificationPanel() {
const { t } = useLanguage()
const router = useRouter()
@@ -171,38 +160,32 @@ export function NotificationPanel() {
const hasContent = requests.length > 0 || brainstormShares.length > 0 || activeReminders.length > 0 || appNotifications.length > 0
// ── icon bg/color per notification type ──────────────────────────────────
const notifIconStyle = (type: string) => {
if (type === 'agent_success') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
if (type === 'agent_slides_ready') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
if (type === 'agent_canvas_ready') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
if (type === 'agent_failure') return { bg: 'rgba(239,68,68,0.12)', color: '#EF4444' }
if (type === 'brainstorm_invite') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
if (type === 'brainstorm_joined') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
if (type === 'clip') return { bg: 'rgba(99,102,241,0.12)', color: '#6366F1' }
return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
// ── icon styles per notification type (theme-aware via opacity) ─────────
const notifIconClass = (type: string) => {
if (type === 'agent_failure') return 'bg-red-500/15 text-red-500'
if (type.startsWith('agent')) return 'bg-brand-accent/15 text-brand-accent'
if (type.startsWith('brainstorm')) return 'bg-ochre/15 text-ochre'
if (type === 'clip') return 'bg-brand-accent/15 text-brand-accent'
return 'bg-muted text-muted-foreground'
}
const notifLabelColor = (type: string) => {
if (type.startsWith('agent')) {
if (type === 'agent_failure') return '#EF4444'
if (type === 'brainstorm_invite') return '#A3B18A'
if (type === 'brainstorm_joined') return '#A3B18A'
return '#A47148'
}
return '#A3B18A'
const notifLabelClass = (type: string) => {
if (type === 'agent_failure') return 'text-red-500'
if (type.startsWith('agent')) return 'text-brand-accent'
if (type.startsWith('brainstorm')) return 'text-ochre'
return 'text-muted-foreground'
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className="relative p-1.5 text-muted-ink hover:text-ink transition-all hover:bg-white/50 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
className="relative p-1.5 text-muted-foreground hover:text-foreground transition-all hover:bg-black/5 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
>
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
{pendingCount > 0 && (
<span
className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full text-white text-[9px] font-bold border border-white shadow-sm bg-brand-accent"
className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full text-white text-[9px] font-bold border-2 border-background shadow-sm bg-brand-accent"
>
{pendingCount > 9 ? '9+' : pendingCount}
</span>
@@ -210,12 +193,15 @@ export function NotificationPanel() {
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0 rounded-2xl overflow-hidden shadow-2xl border border-black/20">
<PopoverContent
align="end"
className="w-80 p-0 rounded-2xl overflow-hidden shadow-2xl border border-border bg-popover text-popover-foreground"
>
{/* Header */}
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ background: '#FDFDFE' }}>
<div className="px-4 py-3 border-b border-border bg-muted/40 dark:bg-white/[0.03] flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="h-4 w-4" style={{ color: C.dark }} />
<span className="font-bold text-sm tracking-tight" style={{ color: C.dark }}>
<Bell className="h-4 w-4 text-foreground/70" />
<span className="font-bold text-sm tracking-tight text-foreground">
{t('notification.notifications')}
</span>
</div>
@@ -223,7 +209,7 @@ export function NotificationPanel() {
{appNotifications.length > 0 && (
<button
onClick={handleMarkAllRead}
className="text-[10px] text-foreground/40 hover:text-foreground transition-colors"
className="text-muted-foreground hover:text-foreground transition-colors p-1 rounded-md hover:bg-black/5 dark:hover:bg-white/10"
title={t('notification.markAllRead') || 'Mark all read'}
>
<Check className="h-3.5 w-3.5" />
@@ -241,25 +227,24 @@ export function NotificationPanel() {
{isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
<div className="animate-spin h-6 w-6 border-2 border-t-transparent rounded-full mx-auto mb-2" style={{ borderColor: C.blue, borderTopColor: 'transparent' }} />
<div className="animate-spin h-6 w-6 border-2 border-brand-accent border-t-transparent rounded-full mx-auto mb-2" />
</div>
) : !hasContent ? (
<div className="p-8 text-center">
<Bell className="h-9 w-9 mx-auto mb-3 opacity-20" />
<p className="text-[12px] font-medium text-foreground/40">{t('notification.noNotifications') || 'Aucune notification'}</p>
<Bell className="h-9 w-9 mx-auto mb-3 text-muted-foreground/40" />
<p className="text-[12px] font-medium text-muted-foreground">{t('notification.noNotifications') || 'Aucune notification'}</p>
</div>
) : (
<div className="max-h-96 overflow-y-auto divide-y divide-black/5">
<div className="max-h-96 overflow-y-auto divide-y divide-border">
{/* ── App notifications (agents, system) ── */}
{appNotifications.map((notif) => {
const isSlides = notif.type === 'agent_slides_ready'
const isCanvas = notif.type === 'agent_canvas_ready'
const canvasId = notif.relatedId
const iconStyle = notifIconStyle(notif.type)
return (
<div key={notif.id} className="p-3 hover:bg-black/[0.02] transition-colors">
<div key={notif.id} className="p-3 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors">
<div
className="flex items-start gap-3 cursor-pointer"
onClick={() => {
@@ -270,11 +255,7 @@ export function NotificationPanel() {
}
}}
>
{/* Icon badge */}
<div
className="mt-0.5 flex-none rounded-lg p-1.5"
style={{ background: iconStyle.bg, color: iconStyle.color }}
>
<div className={cn('mt-0.5 flex-none rounded-lg p-1.5', notifIconClass(notif.type))}>
{isSlides ? <Presentation className="w-3.5 h-3.5" />
: isCanvas ? <Pencil className="w-3.5 h-3.5" />
: notif.type === 'brainstorm_invite' ? <Wind className="w-3.5 h-3.5" />
@@ -285,10 +266,7 @@ export function NotificationPanel() {
</div>
<div className="flex-1 min-w-0">
<span
className="text-[9px] font-bold uppercase tracking-[0.2em]"
style={{ color: notifLabelColor(notif.type) }}
>
<span className={cn('text-[9px] font-bold uppercase tracking-[0.2em]', notifLabelClass(notif.type))}>
{notif.type === 'agent_slides_ready' && (t('notification.slidesReady') || 'Présentation prête')}
{notif.type === 'agent_canvas_ready' && (t('notification.canvasReady') || 'Diagramme prêt')}
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent terminé')}
@@ -298,11 +276,11 @@ export function NotificationPanel() {
{notif.type === 'clip' && (t('notification.clipSaved') || 'Web clip')}
{notif.type === 'system' && t('notification.systemNotification')}
</span>
<p className="text-[13px] font-semibold truncate mt-0.5">{notif.title}</p>
<p className="text-[13px] font-semibold truncate mt-0.5 text-foreground">{notif.title}</p>
{notif.message && (
<p className="text-[11px] text-foreground/50 mt-0.5 line-clamp-2">{notif.message}</p>
<p className="text-[11px] text-muted-foreground mt-0.5 line-clamp-2">{notif.message}</p>
)}
<div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
<div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/70">
<Clock className="w-3 h-3" />
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
</div>
@@ -310,13 +288,12 @@ export function NotificationPanel() {
<button
onClick={(e) => { e.stopPropagation(); handleMarkNotifRead(notif.id) }}
className="mt-0.5 text-foreground/20 hover:text-foreground transition-colors"
className="mt-0.5 text-muted-foreground/50 hover:text-foreground transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
{/* Download PPTX button */}
{isSlides && canvasId && (
<div className="mt-2 ml-8">
<button
@@ -338,8 +315,7 @@ export function NotificationPanel() {
document.body.removeChild(a); URL.revokeObjectURL(url)
} catch { toast.error(t('notification.downloadFailed')) }
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold rounded-lg text-white uppercase tracking-wide transition-all hover:opacity-90 active:scale-95 shadow-sm"
style={{ background: C.blue }}
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold rounded-lg bg-brand-accent text-white uppercase tracking-wide transition-all hover:opacity-90 active:scale-95 shadow-sm"
>
<Download className="w-3 h-3" />
{t('notification.downloadPptx') || 'Télécharger .pptx'}
@@ -352,25 +328,24 @@ export function NotificationPanel() {
{/* ── Overdue reminders ── */}
{overdueReminders.map((note) => (
<div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
<div key={note.id} className="p-3 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors">
<div className="flex items-start gap-3">
<button
onClick={() => handleToggleReminder(note.id, true)}
className="mt-0.5 flex-none transition-colors hover:opacity-70"
style={{ color: C.green }}
className="mt-0.5 flex-none text-ochre transition-colors hover:opacity-70"
title={t('reminders.markDone')}
>
<Circle className="w-4 h-4" />
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<AlertCircle className="w-3 h-3" style={{ color: C.green }} />
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.green }}>
<AlertCircle className="w-3 h-3 text-ochre" />
<span className="text-[9px] font-bold uppercase tracking-[0.2em] text-ochre">
{t('reminders.overdue')}
</span>
</div>
<p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
<div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
<p className="text-[13px] font-semibold truncate text-foreground">{note.title || t('notification.untitled')}</p>
<div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/70">
<Clock className="w-3 h-3" />
{note.reminder && formatDistanceToNow(new Date(note.reminder), { addSuffix: true })}
</div>
@@ -381,12 +356,12 @@ export function NotificationPanel() {
{/* ── Upcoming reminders ── */}
{upcomingReminders.slice(0, 5).map((note) => (
<div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
<div key={note.id} className="p-3 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors">
<div className="flex items-start gap-3">
<Clock className="w-4 h-4 mt-0.5 flex-none" style={{ color: C.blue }} />
<Clock className="w-4 h-4 mt-0.5 flex-none text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
<div className="text-[11px] text-foreground/40 mt-0.5">
<p className="text-[13px] font-semibold truncate text-foreground">{note.title || t('notification.untitled')}</p>
<div className="text-[11px] text-muted-foreground mt-0.5">
{note.reminder && new Date(note.reminder).toLocaleDateString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
})}
@@ -398,25 +373,22 @@ export function NotificationPanel() {
{/* ── Brainstorm share invites ── */}
{brainstormShares.map((share) => (
<div key={share.id} className="p-4 hover:bg-black/[0.02] transition-colors space-y-3">
<div key={share.id} className="p-4 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors space-y-3">
<div className="flex items-start gap-3">
<div
className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm"
style={{ background: `linear-gradient(135deg, #A47148, #A47148)` }}
>
<div className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm bg-brand-accent">
{(share.sharer?.name || share.sharer?.email || '?')[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<Wind className="w-3 h-3" style={{ color: '#A47148' }} />
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: '#A47148' }}>
Brainstorm
<Wind className="w-3 h-3 text-brand-accent" />
<span className="text-[9px] font-bold uppercase tracking-[0.2em] text-brand-accent">
{t('notification.brainstormInvite')}
</span>
</div>
<p className="text-[13px] font-semibold truncate">
<p className="text-[13px] font-semibold truncate text-foreground">
{share.sharer?.name || share.sharer?.email}
</p>
<p className="text-[11px] text-foreground/50 truncate">
<p className="text-[11px] text-muted-foreground truncate">
{t('notification.brainstormShared') || 'invited you to a brainstorm'} « {share.session?.seedIdea?.length > 35 ? share.session.seedIdea.substring(0, 35) + '…' : share.session?.seedIdea} »
</p>
</div>
@@ -424,7 +396,7 @@ export function NotificationPanel() {
<div className="flex gap-2 ml-11">
<button
onClick={() => handleDeclineBrainstorm(share.id)}
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-black/15 text-foreground/60 hover:bg-black/5 transition-all active:scale-95 flex items-center justify-center gap-1"
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-border text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-all active:scale-95 flex items-center justify-center gap-1"
>
<X className="h-3 w-3" />
{t('notification.decline') || 'Decline'}
@@ -442,26 +414,22 @@ export function NotificationPanel() {
{/* ── Share requests ── */}
{requests.map((request) => (
<div key={request.id} className="p-4 hover:bg-black/[0.02] transition-colors space-y-3">
<div key={request.id} className="p-4 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors space-y-3">
<div className="flex items-start gap-3">
{/* Avatar */}
<div
className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm"
style={{ background: `linear-gradient(135deg, ${C.blue}, ${C.green})` }}
>
<div className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm bg-brand-accent">
{(request.sharer.name || request.sharer.email)[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<Share2 className="w-3 h-3" style={{ color: C.blue }} />
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.blue }}>
Partage
<Share2 className="w-3 h-3 text-brand-accent" />
<span className="text-[9px] font-bold uppercase tracking-[0.2em] text-brand-accent">
{t('notification.shareLabel')}
</span>
</div>
<p className="text-[13px] font-semibold truncate">
<p className="text-[13px] font-semibold truncate text-foreground">
{request.sharer.name || request.sharer.email}
</p>
<p className="text-[11px] text-foreground/50 truncate">
<p className="text-[11px] text-muted-foreground truncate">
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
</p>
</div>
@@ -470,15 +438,14 @@ export function NotificationPanel() {
<div className="flex gap-2 ml-11">
<button
onClick={() => handleDecline(request.id)}
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-black/15 text-foreground/60 hover:bg-black/5 transition-all active:scale-95 flex items-center justify-center gap-1"
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-border text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-all active:scale-95 flex items-center justify-center gap-1"
>
<X className="h-3 w-3" />
{t('notification.decline') || 'Refuser'}
</button>
<button
onClick={() => handleAccept(request.id)}
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90"
style={{ background: C.blue }}
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white bg-brand-accent transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90"
>
<Check className="h-3 w-3" />
{t('notification.accept') || 'Accepter'}
@@ -489,13 +456,11 @@ export function NotificationPanel() {
</div>
)}
{/* Footer */}
{activeReminders.length > 0 && (
<div className="px-4 py-2.5 border-t bg-black/[0.02]">
<div className="px-4 py-2.5 border-t border-border bg-muted/30 dark:bg-white/[0.02]">
<a
href="/reminders"
className="text-[11px] font-semibold hover:opacity-70 transition-opacity"
style={{ color: C.blue }}
className="text-[11px] font-semibold text-brand-accent hover:opacity-80 transition-opacity"
>
{t('reminders.viewAll') || 'Voir tous les rappels →'}
</a>