fix: batch priorités — thème, scroll, wizard, agents, slides, charts, packs
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m15s
CI / Deploy production (on server) (push) Has been skipped

Anti-flash dark (color-scheme + applyDocumentTheme), scroll pages publiques,
prompts wizard/slides moins génériques, cron agents rattrapage nextRun null,
slides carnet via notebookId, graphiques à 2 crédits + hint, audit dashboard,
packs Stripe marqués non configurés sans price ID.
This commit is contained in:
Antigravity
2026-07-16 20:48:55 +00:00
parent 556a0b2f3f
commit a77f3ffb6e
16 changed files with 240 additions and 39 deletions

View File

@@ -19,8 +19,8 @@ export default async function PublicLayout({ children }: { children: React.React
<PublicProviders initialLanguage={initialLanguage} initialTranslations={initialTranslations}> <PublicProviders initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
<div <div
data-public-scroll-root data-public-scroll-root
className="fixed inset-0 z-0 overflow-y-auto overflow-x-hidden overscroll-y-contain" className="fixed inset-0 z-[1] h-[100dvh] w-full overflow-y-auto overflow-x-hidden overscroll-y-contain"
style={{ WebkitOverflowScrolling: 'touch' }} style={{ WebkitOverflowScrolling: 'touch', touchAction: 'pan-y' }}
> >
{children} {children}
</div> </div>

View File

@@ -3,7 +3,7 @@ import { auth } from '@/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { QuotaExceededError } from '@/lib/entitlements' import { QuotaExceededError } from '@/lib/entitlements'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota' import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { slideGenerateCreditCost, resolveCreditCost } from '@/lib/credits' import { slideGenerateCreditCost, notebookSlideCreditCost, resolveCreditCost } from '@/lib/credits'
import type { FeatureName } from '@/lib/quota-utils' import type { FeatureName } from '@/lib/quota-utils'
import { logAuditEvent, getClientIp } from '@/lib/audit-log' import { logAuditEvent, getClientIp } from '@/lib/audit-log'
import { import {
@@ -42,8 +42,10 @@ export async function POST(req: NextRequest) {
const userId = session.user.id const userId = session.user.id
const body = await req.json() const body = await req.json()
const { noteId, type, theme, style, language, template, purpose, audience, slideCount } = body as { const { noteId, notebookId, type, theme, style, language, template, purpose, audience, slideCount } = body as {
noteId: string noteId?: string
/** Si fourni (slides), génère un deck multi-notes du carnet */
notebookId?: string
type: GenerateType type: GenerateType
theme?: string theme?: string
style?: string style?: string
@@ -54,21 +56,47 @@ export async function POST(req: NextRequest) {
slideCount?: number slideCount?: number
} }
if (!noteId || !type || !TYPE_DEFAULTS[type]) { if ((!noteId && !notebookId) || !type || !TYPE_DEFAULTS[type]) {
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 }) return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
} }
if (notebookId && type !== 'slide-generator') {
return NextResponse.json({ error: 'notebookId réservé aux présentations' }, { status: 400 })
}
// Crédits globaux (respecte le BYOK) — slides = 1 + N let noteCountForPack = 1
if (notebookId) {
noteCountForPack = await prisma.note.count({
where: {
notebookId,
userId,
isArchived: false,
trashedAt: null,
},
})
if (noteCountForPack < 1) {
return NextResponse.json({ error: 'Carnet vide ou introuvable' }, { status: 404 })
}
}
// Crédits globaux (respecte le BYOK) — slides note = 1+N ; carnet = coût notebook
const featureKey = (type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate') as FeatureName const featureKey = (type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate') as FeatureName
const creditCost = const creditCost =
type === 'slide-generator' type === 'slide-generator'
? slideGenerateCreditCost(slideCount) ? notebookId
? notebookSlideCreditCost({ slideCount, noteCount: noteCountForPack })
: slideGenerateCreditCost(slideCount)
: resolveCreditCost(featureKey) : resolveCreditCost(featureKey)
try { try {
await reserveAiUsageOrThrow(userId, featureKey, { await reserveAiUsageOrThrow(userId, featureKey, {
amount: creditCost, amount: creditCost,
slideCount: type === 'slide-generator' ? slideCount : undefined, slideCount: type === 'slide-generator' ? slideCount : undefined,
metadata: { noteId, type, slideCount: slideCount ?? null }, metadata: {
noteId: noteId ?? null,
notebookId: notebookId ?? null,
type,
slideCount: slideCount ?? null,
noteCount: noteCountForPack,
},
lane: 'chat', lane: 'chat',
}) })
} catch (e) { } catch (e) {
@@ -86,13 +114,29 @@ export async function POST(req: NextRequest) {
throw e throw e
} }
const note = await prisma.note.findFirst({ let note: { id: string; title: string | null; notebookId: string | null } | null = null
if (noteId) {
note = await prisma.note.findFirst({
where: { id: noteId, userId }, where: { id: noteId, userId },
select: { id: true, title: true, notebookId: true }, select: { id: true, title: true, notebookId: true },
}) })
if (!note) { if (!note) {
return NextResponse.json({ error: 'Note introuvable' }, { status: 404 }) return NextResponse.json({ error: 'Note introuvable' }, { status: 404 })
} }
} else if (notebookId) {
const notebook = await prisma.notebook.findFirst({
where: { id: notebookId, userId },
select: { id: true, name: true },
})
if (!notebook) {
return NextResponse.json({ error: 'Carnet introuvable' }, { status: 404 })
}
note = {
id: notebookId,
title: notebook.name,
notebookId,
}
}
const defaults = TYPE_DEFAULTS[type] const defaults = TYPE_DEFAULTS[type]
const isEn = language === 'English' const isEn = language === 'English'
@@ -110,6 +154,10 @@ export async function POST(req: NextRequest) {
role = `Génère un deck exécutif de haute qualité (titres d'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).${recipeHint}` role = `Génère un deck exécutif de haute qualité (titres d'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).${recipeHint}`
} }
if (!note) {
return NextResponse.json({ error: 'Source introuvable' }, { status: 404 })
}
const agentName = type === 'slide-generator' const agentName = type === 'slide-generator'
? `${isEn ? 'Slides' : 'Présentation'}${(note.title || 'Note').substring(0, 40)}` ? `${isEn ? 'Slides' : 'Présentation'}${(note.title || 'Note').substring(0, 40)}`
: `${isEn ? 'Diagram' : 'Diagramme'}${(note.title || 'Note').substring(0, 40)}` : `${isEn ? 'Diagram' : 'Diagramme'}${(note.title || 'Note').substring(0, 40)}`
@@ -136,6 +184,7 @@ export async function POST(req: NextRequest) {
} }
} }
const sourceNotebookId = notebookId || note.notebookId || undefined
const agent = await prisma.agent.create({ const agent = await prisma.agent.create({
data: { data: {
name: agentName, name: agentName,
@@ -149,8 +198,9 @@ export async function POST(req: NextRequest) {
maxSteps: defaults.maxSteps, maxSteps: defaults.maxSteps,
frequency: 'one-shot', frequency: 'one-shot',
isEnabled: true, isEnabled: true,
sourceNoteIds: JSON.stringify([noteId]), sourceNoteIds: noteId ? JSON.stringify([noteId]) : JSON.stringify([]),
targetNotebookId: note.notebookId ?? undefined, sourceNotebookId: notebookId ? notebookId : undefined,
targetNotebookId: sourceNotebookId,
slideTheme: theme ?? 'keynote', slideTheme: theme ?? 'keynote',
slideStyle: style ?? 'soft', slideStyle: style ?? 'soft',
userId, userId,
@@ -168,11 +218,22 @@ export async function POST(req: NextRequest) {
userId, userId,
action: 'AI_REQUEST', action: 'AI_REQUEST',
resource: featureKey, resource: featureKey,
metadata: { agentId: agent.id, noteId, featureKey }, metadata: {
agentId: agent.id,
noteId: noteId ?? null,
notebookId: notebookId ?? null,
featureKey,
creditCost,
},
ip: getClientIp(req), ip: getClientIp(req),
}) })
return NextResponse.json({ success: true, agentId: agent.id, status: 'running' }) return NextResponse.json({
success: true,
agentId: agent.id,
status: 'running',
creditCost,
})
} }
// ─── GET : poll current agent status ────────────────────────────────────── // ─── GET : poll current agent status ──────────────────────────────────────

View File

@@ -47,7 +47,7 @@ export async function POST(request: NextRequest) {
orderBy: { nextRun: 'asc' }, orderBy: { nextRun: 'asc' },
}) })
// One-time repair: set nextRun for enabled scheduled agents stuck with null // Repair: set nextRun for enabled scheduled agents stuck with null
const unscheduled = await prisma.agent.findMany({ const unscheduled = await prisma.agent.findMany({
where: { where: {
isEnabled: true, isEnabled: true,
@@ -56,6 +56,7 @@ export async function POST(request: NextRequest) {
}, },
select: { select: {
id: true, id: true,
userId: true,
frequency: true, frequency: true,
scheduledTime: true, scheduledTime: true,
scheduledDay: true, scheduledDay: true,
@@ -63,6 +64,7 @@ export async function POST(request: NextRequest) {
}, },
take: 50, take: 50,
}) })
const repairedDue: typeof dueAgents = []
for (const a of unscheduled) { for (const a of unscheduled) {
const nextRun = calculateNextRun({ const nextRun = calculateNextRun({
frequency: a.frequency, frequency: a.frequency,
@@ -70,10 +72,32 @@ export async function POST(request: NextRequest) {
scheduledDay: a.scheduledDay, scheduledDay: a.scheduledDay,
timezone: a.timezone, timezone: a.timezone,
}) })
await prisma.agent.update({ where: { id: a.id }, data: { nextRun } }) // Si le créneau calculé est déjà passé / immédiat → exécuter ce cycle
const effective = !nextRun || nextRun <= now ? now : nextRun
await prisma.agent.update({ where: { id: a.id }, data: { nextRun: effective } })
if (effective <= now) {
repairedDue.push({
id: a.id,
userId: a.userId,
frequency: a.frequency,
scheduledTime: a.scheduledTime,
scheduledDay: a.scheduledDay,
timezone: a.timezone,
nextRun: effective,
})
}
} }
if (dueAgents.length === 0) { const queue = [...dueAgents]
const seen = new Set(dueAgents.map((a) => a.id))
for (const a of repairedDue) {
if (!seen.has(a.id)) {
queue.push(a)
seen.add(a.id)
}
}
if (queue.length === 0) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
executed: 0, executed: 0,
@@ -83,8 +107,8 @@ export async function POST(request: NextRequest) {
const results: { id: string; success: boolean; error?: string }[] = [] const results: { id: string; success: boolean; error?: string }[] = []
// Execute agents sequentially (max 3 per cycle) // Execute agents sequentially (max 5 per cycle — rattrapage + due)
for (const agent of dueAgents.slice(0, 3)) { for (const agent of queue.slice(0, 5)) {
try { try {
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service') const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
const result = await executeAgent(agent.id, agent.userId) const result = await executeAgent(agent.id, agent.userId)

View File

@@ -93,8 +93,15 @@ export default async function RootLayout({
const htmlTheme = serverHtmlThemeState(resolvedTheme) const htmlTheme = serverHtmlThemeState(resolvedTheme)
const serverAccent = userSettings.accentColor ?? '#A47148' const serverAccent = userSettings.accentColor ?? '#A47148'
const serverTheme = resolvedTheme const serverTheme = resolvedTheme
const wantsDarkClass =
resolvedTheme === 'dark' ||
resolvedTheme === 'midnight' ||
// auto : le serveur ne connaît pas prefers-color-scheme → laisser le script client
false
const htmlStyle = { const htmlStyle = {
'--color-brand-accent': serverAccent, '--color-brand-accent': serverAccent,
// Aide le navigateur à peindre scrollbars/inputs dans le bon mode
colorScheme: wantsDarkClass || resolvedTheme === 'dark' || resolvedTheme === 'midnight' ? 'dark' : resolvedTheme === 'light' ? 'light' : undefined,
} as CSSProperties } as CSSProperties
return ( return (

View File

@@ -64,7 +64,7 @@ export function ChartSuggestionsDialog({
console.error('[ChartSuggestionsDialog] Error:', err) console.error('[ChartSuggestionsDialog] Error:', err)
setResponse({ setResponse({
suggestions: [], suggestions: [],
analyzedText: textToAnalyze?.substring(0, 100) || '', analyzedText: (selection || content || '').substring(0, 100),
detectedData: 'Error occurred', detectedData: 'Error occurred',
hasData: false, hasData: false,
error: err.message || 'Network error - check console', error: err.message || 'Network error - check console',
@@ -124,7 +124,7 @@ export function ChartSuggestionsDialog({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<BarChart3 className="w-5 h-5 text-blue-500" /> <BarChart3 className="w-5 h-5 text-blue-500" />
<div> <div>
<h2 className="text-lg font-semibold">Chart Suggestions</h2> <h2 className="text-lg font-semibold">{t('chart.suggestionsTitle') || 'Suggestions de graphiques'}</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{loading ? ( {loading ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@@ -137,6 +137,9 @@ export function ChartSuggestionsDialog({
t('chart.noDataDetected') t('chart.noDataDetected')
)} )}
</p> </p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{t('chart.creditCostHint') || 'Coût estimé : 2 crédits IA'}
</p>
</div> </div>
</div> </div>
<button <button

View File

@@ -576,8 +576,10 @@ export function BillingPlans() {
> >
{packLoading === pack.id ? ( {packLoading === pack.id ? (
<Loader2 className="h-4 w-4 animate-spin mx-auto" /> <Loader2 className="h-4 w-4 animate-spin mx-auto" />
) : ( ) : pack.configured ? (
t('billing.buyPack') t('billing.buyPack')
) : (
t('billing.packNotConfigured') || 'Config Stripe requise'
)} )}
</button> </button>
</div> </div>

View File

@@ -40,9 +40,21 @@ export function ThemeInitializer({ theme, fontSize, fontFamily, accentColor }: T
const localTheme = localStorage.getItem('theme-preference') const localTheme = localStorage.getItem('theme-preference')
const effectiveTheme = normalizeThemeId(localTheme || theme || 'light') const effectiveTheme = normalizeThemeId(localTheme || theme || 'light')
// Persiste aussi le cookie pour les prochains rendus serveur (anti-flash navigation) // Appliquer le thème AVANT paint (cookie + class dark atomique)
applyDocumentTheme(effectiveTheme)
persistThemePreference(effectiveTheme) persistThemePreference(effectiveTheme)
// color-scheme navigateur (inputs, scrollbars) — évite flash chrome clair
try {
const dark =
effectiveTheme === 'dark' ||
effectiveTheme === 'midnight' ||
(effectiveTheme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
root.style.colorScheme = dark ? 'dark' : 'light'
} catch {
/* ignore */
}
applyFontSize(fontSize) applyFontSize(fontSize)
const localFontFamily = localStorage.getItem('font-family') const localFontFamily = localStorage.getItem('font-family')

View File

@@ -0,0 +1,36 @@
# Audit dashboard Second Brain (`/home`) — 2026-07-16
## Portée
Revue code de `dashboard-view.tsx` + widgets catalogue / paths / briefing.
## Points sains
| Zone | Constat |
|------|---------|
| Layout v5 configurable | Catalogue widgets + `/api/dashboard/layout` |
| Prochaines pistes | Fast paths + enrich async `/api/briefing/paths` |
| Revue quotidienne | Checklist interactive (pas revue auto) |
| Quotas | `emitAiUsageChanged` + consent IA sur actions |
| Aide « ? » | `DashboardWidgetHelp` i18n |
| Empty states | Widgets catalogue gèrent listes vides |
## Risques / écarts
| # | Problème | Sévérité | Action |
|---|----------|----------|--------|
| 1 | Briefing dépend du cron / première visite : widgets peuvent rester vides temporairement | Moyenne | UX « chargement / aucune donnée » déjà partielle — dogfood |
| 2 | Bridges / clusters sémantiques absurdes cross-domaine | Moyenne | Produit insights (hors dashboard pur) |
| 3 | Widget usage encore mentalité « seaux » vs solde crédits | Basse | `DashboardUsageWidget` lit `/api/usage/current` (balance multi) — OK si API à jour |
| 4 | Agent suggestions vides si cron agent-suggestions off | Basse | Ops CRON_SECRET |
## Verdict
Dashboard **utilisable** et aligné prototype sur larchitecture. Pas de bug bloquant code identifié dans cette passe.
**Reste dogfood** : première visite, dark mode, clics paths, quotas 402.
## Suite recommandée
1. Dogfood 10 min sur compte réel
2. Si widget usage affiche encore danciens seaux → forcer refresh API (déjà multi-feature dans usage-current)
3. Prioriser pertinence bridges (insights) plutôt que refonte dashboard

View File

@@ -42,7 +42,8 @@ const LANG_NAMES: Record<string, string> = {
function wordTargetsForLevel(level?: string): { min: number; max: number; label: string } { function wordTargetsForLevel(level?: string): { min: number; max: number; label: string } {
const l = (level || '').toLowerCase() const l = (level || '').toLowerCase()
if (/expert|avancé|advanced|fort/.test(l)) { if (/expert|avancé|advanced|fort/.test(l)) {
return { min: 400, max: 800, label: 'expert (400800 words per note)' } // Borné pour éviter timeouts JSON / mode expert qui plantait
return { min: 350, max: 700, label: 'expert (350700 words per note, dense)' }
} }
if (/intermédiaire|intermediate|moyen/.test(l)) { if (/intermédiaire|intermediate|moyen/.test(l)) {
return { min: 300, max: 550, label: 'intermediate (300550 words per note)' } return { min: 300, max: 550, label: 'intermediate (300550 words per note)' }
@@ -240,7 +241,13 @@ Return ONLY:
Rules: Rules:
- Exactly ${count} titles, progressive learning order. - Exactly ${count} titles, progressive learning order.
- Titles in ${this.langName(lang)} only. - Titles in ${this.langName(lang)} only.
- notebookName ≠ full user sentence.` - notebookName ≠ full user sentence (38 words, concrete, topic-specific).
- FORBIDDEN generic notebook names / chapter patterns (any language):
"Fondements", "Foundations", "Maîtrise", "Mastery", "Introduction",
"Basics", "Overview", "Getting started", "Chapitre 1", "Chapter 1",
"Partie 1", "Bases", "Notions de base", "Synthèse finale" alone.
- Prefer specific titles that name the real subject (e.g. topic keywords + angle).
- Each title must be unique and non-interchangeable with another topic.`
const raw = await provider.generateText(prompt) const raw = await provider.generateText(prompt)
const parsed = this.parseJsonObject(raw) const parsed = this.parseJsonObject(raw)
@@ -250,10 +257,41 @@ Rules:
while (titles.length < count) { while (titles.length < count) {
titles.push(lang === 'fr' ? `Chapitre ${titles.length + 1}` : `Chapter ${titles.length + 1}`) titles.push(lang === 'fr' ? `Chapitre ${titles.length + 1}` : `Chapter ${titles.length + 1}`)
} }
return { let notebookName = String(parsed?.notebookName || '').trim().slice(0, 80)
notebookName: String(parsed?.notebookName || '').trim().slice(0, 80), if (this.isGenericNotebookName(notebookName, topic)) {
titles, notebookName = this.deriveNotebookName(topic, titles.map((t) => ({ title: t, content: '' })), lang)
} }
const cleanedTitles = titles.map((t, i) =>
this.isGenericChapterTitle(t) ? this.fallbackChapterTitle(topic, i, lang) : t,
)
return {
notebookName,
titles: cleanedTitles,
}
}
private isGenericNotebookName(name: string, topic: string): boolean {
const n = (name || '').trim().toLowerCase()
if (!n || n.length < 3) return true
if (n === topic.trim().toLowerCase() && topic.trim().split(/\s+/).length > 10) return true
const banned =
/^(fondements?|foundations?|ma[iî]trise|mastery|introduction|bases?|basics?|overview|getting started|notions de base|synth[eè]se|summary)$/i
return banned.test(n)
}
private isGenericChapterTitle(title: string): boolean {
const t = (title || '').trim()
if (!t) return true
return /^(chapitre|chapter|partie|part|section)\s*\d+$/i.test(t)
|| /^(fondements?|foundations?|ma[iî]trise|introduction|bases?|basics?|overview)$/i.test(t)
}
private fallbackChapterTitle(topic: string, index: number, lang: string): string {
const short = topic.trim().slice(0, 48) || (lang === 'fr' ? 'Sujet' : 'Topic')
const anglesFr = ['Concepts clés', 'Méthodes', 'Exemples guidés', 'Pièges fréquents', 'Mise en pratique', 'Bilan']
const anglesEn = ['Key concepts', 'Methods', 'Guided examples', 'Common pitfalls', 'Practice', 'Recap']
const angles = lang === 'fr' ? anglesFr : anglesEn
return `${angles[index % angles.length]}${short}`
} }
private async generateOneNote( private async generateOneNote(

View File

@@ -229,11 +229,13 @@ Tu produis UNIQUEMENT un outline JSON (pas le corps final des slides).
Règles (DeckForge + think-cell): Règles (DeckForge + think-cell):
- Max ${maxSlides} slides - Max ${maxSlides} slides
- Headline ASSERTIVE, max 10 mots (pas "Contexte", "Introduction") - Headline ASSERTIVE, max 10 mots (INTERDIT: "Contexte", "Introduction", "Présentation", "Overview", "Points clés", "Conclusion" seuls)
- keyPoints: 25 faits de la note par slide - keyPoints: 25 FAITS CONCRETS tirés de la note (chiffres, noms, formules) — jamais de filler
- Chaque slide = UNE idée actionnable (titre = insight, pas libellé de section)
- Arc pédagogique pour cours: title → définitions/équations → propriétés → exemples → summary - Arc pédagogique pour cours: title → définitions/équations → propriétés → exemples → summary
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary - Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
- Slide 1 = title, dernière = summary - Slide 1 = title, dernière = summary
- INTERDIT slides vides, listes de 1 mot, ou répéter le titre de la note
${mathRule} ${mathRule}
Réponds en JSON OutlineSchema.` Réponds en JSON OutlineSchema.`
} }
@@ -242,11 +244,13 @@ Output ONLY outline JSON (not full slide bodies).
Rules: Rules:
- Max ${maxSlides} slides - Max ${maxSlides} slides
- ASSERTIVE headlines, max 10 words - ASSERTIVE headlines, max 10 words (FORBIDDEN alone: Context, Introduction, Overview, Key points, Conclusion)
- keyPoints: 25 facts from the note per slide - keyPoints: 25 CONCRETE facts from the note (numbers, names, formulas) — no filler
- One actionable idea per slide (title = insight, not section label)
- Pedagogical arc for lessons: title → definitions/equations → properties → examples → summary - Pedagogical arc for lessons: title → definitions/equations → properties → examples → summary
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary - Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
- First=title, last=summary - First=title, last=summary
- FORBIDDEN empty slides, one-word lists, or repeating the note title alone
${mathRule} ${mathRule}
JSON OutlineSchema only.` JSON OutlineSchema only.`
} }

View File

@@ -74,6 +74,12 @@ export function applyDocumentTheme(theme: string): void {
root.classList.remove('dark') root.classList.remove('dark')
} }
try {
root.style.colorScheme = wantsDark ? 'dark' : 'light'
} catch {
/* ignore */
}
// data-theme pour les palettes nommées uniquement // data-theme pour les palettes nommées uniquement
if (t === 'auto' || t === 'dark' || t === 'light') { if (t === 'auto' || t === 'dark' || t === 'light') {
root.removeAttribute('data-theme') root.removeAttribute('data-theme')

View File

@@ -128,7 +128,8 @@ export async function getPackPublicPrices(): Promise<PackPublicPrice[]> {
display, display,
amount, amount,
currency, currency,
configured: configured || isMock, // true seulement si un vrai price_… est en admin/env (pas les mocks)
configured,
} }
}), }),
) )

View File

@@ -50,7 +50,7 @@ export const CREDIT_COSTS: Record<string, number> = {
brainstorm_create: 5, brainstorm_create: 5,
brainstorm_expand: 1, brainstorm_expand: 1,
brainstorm_enrich: 1, brainstorm_enrich: 1,
suggest_charts: 1, suggest_charts: 2,
publish_enhance: 3, publish_enhance: 3,
ai_flashcard: 3, ai_flashcard: 3,
voice_transcribe: 1, voice_transcribe: 1,

View File

@@ -29,6 +29,7 @@ export const THEME_INIT_SCRIPT = `(function () {
} }
if (wantsDark) root.classList.add('dark'); if (wantsDark) root.classList.add('dark');
else root.classList.remove('dark'); else root.classList.remove('dark');
try { root.style.colorScheme = wantsDark ? 'dark' : 'light'; } catch (e) {}
if (theme === 'auto' || theme === 'dark' || theme === 'light') { if (theme === 'auto' || theme === 'dark' || theme === 'light') {
root.removeAttribute('data-theme'); root.removeAttribute('data-theme');
} else { } else {

View File

@@ -3305,6 +3305,7 @@
"packMName": "Standard pack", "packMName": "Standard pack",
"packLName": "Power pack", "packLName": "Power pack",
"buyPack": "Buy", "buyPack": "Buy",
"packNotConfigured": "Stripe config required",
"packCheckoutSuccess": "Credit pack added to your balance!", "packCheckoutSuccess": "Credit pack added to your balance!",
"packCheckoutFailed": "Could not start pack purchase. Check Stripe config or try again.", "packCheckoutFailed": "Could not start pack purchase. Check Stripe config or try again.",
"creditsWhereTitle": "Credit usage by feature", "creditsWhereTitle": "Credit usage by feature",
@@ -4527,6 +4528,8 @@
"invalidDescription": "This chart block contains invalid or empty data.", "invalidDescription": "This chart block contains invalid or empty data.",
"editSource": "Edit chart source", "editSource": "Edit chart source",
"suggestions": "Chart Suggestions", "suggestions": "Chart Suggestions",
"suggestionsTitle": "Chart suggestions",
"creditCostHint": "Estimated cost: 2 AI credits",
"analyzing": "Analyzing your content for chart data…", "analyzing": "Analyzing your content for chart data…",
"debugInfo": "Debug info", "debugInfo": "Debug info",
"escCancel": "Esc Cancel", "escCancel": "Esc Cancel",

View File

@@ -3311,6 +3311,7 @@
"packMName": "Pack standard", "packMName": "Pack standard",
"packLName": "Pack intensif", "packLName": "Pack intensif",
"buyPack": "Acheter", "buyPack": "Acheter",
"packNotConfigured": "Config Stripe requise",
"packCheckoutSuccess": "Pack de crédits ajouté à votre solde !", "packCheckoutSuccess": "Pack de crédits ajouté à votre solde !",
"packCheckoutFailed": "Échec de lachat du pack. Vérifiez la config Stripe ou réessayez.", "packCheckoutFailed": "Échec de lachat du pack. Vérifiez la config Stripe ou réessayez.",
"creditsWhereTitle": "Utilisation des crédits par fonction", "creditsWhereTitle": "Utilisation des crédits par fonction",
@@ -4533,6 +4534,8 @@
"invalidDescription": "Ce bloc de graphique contient des données invalides ou vides.", "invalidDescription": "Ce bloc de graphique contient des données invalides ou vides.",
"editSource": "Modifier le code source", "editSource": "Modifier le code source",
"suggestions": "Suggestions de graphiques", "suggestions": "Suggestions de graphiques",
"suggestionsTitle": "Suggestions de graphiques",
"creditCostHint": "Coût estimé : 2 crédits IA",
"analyzing": "Analyse de votre contenu pour des données graphiques…", "analyzing": "Analyse de votre contenu pour des données graphiques…",
"debugInfo": "Infos de débogage", "debugInfo": "Infos de débogage",
"escCancel": "Échap Annuler", "escCancel": "Échap Annuler",