Files
Momento/memento-note/lib/brainstorm/export-pptx.ts
Antigravity 45fd501953
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 6m23s
CI / Deploy production (on server) (push) Failing after 18s
fix: PPTX watermark + black slide + pricing page /pricing
- export-pptx.ts: fix watermark position for LAYOUT_WIDE (13.33"×7.5")
  → moved from y:5.35 (71% height) to y:7.1 near bottom-right (x:10.0)
- export-pptx.ts: fix buildSummarySlide dark background (T.primary overlay
  covered 100% of slide appearing black) → cream bg with colored stat cards
  matching design of other brainstorm slides
- pptx.tool.ts: fix addImageFullSlide using t.primary as bg when no imageUrl
  → falls back to t.bg (light); text colors adapt accordingly
- pricing/page.tsx: create /pricing standalone page reusing exact landing
  page pricing section (PLANS array, billing toggle, i18n keys)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 11:45:47 +00:00

350 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type PptxGenJSModule from 'pptxgenjs'
let _PptxGenJS: (new () => PptxGenJSModule) | null = null
async function getPptxGenClass(): Promise<new () => PptxGenJSModule> {
if (!_PptxGenJS) {
const mod = await import('pptxgenjs')
_PptxGenJS = (mod.default ?? mod) as unknown as new () => PptxGenJSModule
}
return _PptxGenJS
}
// ── Theme — cohérent avec l'identité visuelle Momento ───────────────────────
const T = {
bg: 'F2F0E9',
primary: '1C1C1C',
accent: 'A47148',
secondary: 'D4A373',
muted: '9A8C87',
wave1: 'fb923c',
wave2: '60a5fa',
wave3: 'a78bfa',
green: '10b981',
}
const WAVE_LABELS: Record<number, string> = {
1: '🔄 Variations',
2: '🔗 Analogies',
3: '💥 Disruptions',
}
const WAVE_COLORS: Record<number, string> = {
1: T.wave1,
2: T.wave2,
3: T.wave3,
}
// ── Types (minimal subset) ──────────────────────────────────────────────────
interface IdeaLike {
id: string
title: string
description: string
waveNumber: number
status: string
isStarred: boolean
convertedToNoteId: string | null
}
interface SessionLike {
seedIdea: string
createdAt: Date
ideas: IdeaLike[]
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.slice(0, 40)
}
function truncate(text: string, max: number): string {
return text.length > max ? text.slice(0, max - 1) + '…' : text
}
/**
* PLG viral watermark — added to every slide automatically via addSlide monkey-patch.
* LAYOUT_WIDE = 13.33" × 7.5" → watermark anchored near bottom-right at y ≈ 7.1
*/
function addWatermark(slide: any) {
slide.addText('memento-note.com', {
x: 10.0, y: 7.1, w: 3.0, h: 0.25,
fontSize: 7, fontFace: 'Arial', color: 'B8B0A8',
align: 'right', italic: true,
})
}
/** Wrap pres.addSlide so every new slide gets the Momento watermark automatically */
function withWatermark(pres: PptxGenJSModule): PptxGenJSModule {
const original = pres.addSlide.bind(pres)
;(pres as any).addSlide = (...args: any[]) => {
const slide = original(...args)
addWatermark(slide)
return slide
}
return pres
}
// Add a consistent slide background
function addBg(slide: any) {
slide.background = { color: T.bg }
}
// Accent bar at the top of a slide
function addTopBar(slide: any, color: string = T.accent) {
slide.addShape('rect', { x: 0, y: 0, w: '100%', h: 0.12, fill: { color } })
}
// ── Slide builders ───────────────────────────────────────────────────────────
function buildCoverSlide(pres: PptxGenJSModule, session: SessionLike, stats: { total: number; converted: number; starred: number }) {
const slide = pres.addSlide()
addBg(slide)
// Left accent panel
slide.addShape('rect', { x: 0, y: 0, w: 3.6, h: '100%', fill: { color: T.primary } })
// Brand label on accent panel
slide.addText('MOMENTO', {
x: 0.3, y: 0.4, w: 3.0, h: 0.4,
fontSize: 9, fontFace: 'Arial', color: T.accent, bold: true,
charSpacing: 4, align: 'left',
})
// Seed idea (big) on right
slide.addText(truncate(session.seedIdea, 80), {
x: 4.0, y: 1.2, w: 5.6, h: 2.4,
fontSize: 26, fontFace: 'Georgia', color: T.primary,
bold: false, align: 'left', valign: 'middle', wrap: true,
})
// Date
slide.addText(session.createdAt.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }), {
x: 4.0, y: 4.0, w: 5.6, h: 0.4,
fontSize: 10, fontFace: 'Arial', color: T.muted, align: 'left',
})
// Stats row
const statItems = [
{ label: 'idées', value: String(stats.total) },
{ label: 'converties', value: String(stats.converted) },
{ label: 'favorites', value: String(stats.starred) },
]
statItems.forEach((s, i) => {
const x = 4.0 + i * 2.0
slide.addText(s.value, { x, y: 4.8, w: 1.8, h: 0.55, fontSize: 22, fontFace: 'Georgia', color: T.accent, bold: true, align: 'left' })
slide.addText(s.label, { x, y: 5.35, w: 1.8, h: 0.3, fontSize: 9, fontFace: 'Arial', color: T.muted, align: 'left', charSpacing: 1 })
})
// Vertical label "BRAINSTORM" on left panel
slide.addText('BRAINSTORM', {
x: 0.1, y: 2.0, w: 3.2, h: 0.5,
fontSize: 11, fontFace: 'Arial', color: 'FFFFFF', bold: true,
charSpacing: 6, align: 'center',
rotate: 270,
})
}
function buildWaveSlide(pres: PptxGenJSModule, wave: number, ideas: IdeaLike[]) {
const slide = pres.addSlide()
addBg(slide)
addTopBar(slide, WAVE_COLORS[wave] || T.accent)
// Wave label
slide.addText(WAVE_LABELS[wave] || `Wave ${wave}`, {
x: 0.5, y: 0.25, w: 9.0, h: 0.5,
fontSize: 14, fontFace: 'Arial', color: WAVE_COLORS[wave] || T.accent, bold: true, charSpacing: 2,
})
// Count badge
slide.addShape('roundRect', { x: 9.0, y: 0.28, w: 0.6, h: 0.4, fill: { color: WAVE_COLORS[wave] || T.accent }, rectRadius: 0.1 })
slide.addText(String(ideas.length), { x: 9.0, y: 0.28, w: 0.6, h: 0.4, fontSize: 11, fontFace: 'Arial', color: 'FFFFFF', bold: true, align: 'center', valign: 'middle' })
const maxPerSlide = 6
const shown = ideas.slice(0, maxPerSlide)
const colW = 4.5
const rowH = 1.3
const startY = 1.0
shown.forEach((idea, i) => {
const col = i % 2
const row = Math.floor(i / 2)
const x = 0.4 + col * (colW + 0.3)
const y = startY + row * (rowH + 0.15)
// Card background
slide.addShape('roundRect', { x, y, w: colW, h: rowH, fill: { color: 'FFFFFF' }, line: { color: 'E8E6E0', width: 0.75 }, rectRadius: 0.08 })
// Star / converted badge
if (idea.isStarred || idea.convertedToNoteId) {
const badge = idea.convertedToNoteId ? '✓' : '⭐'
const badgeColor = idea.convertedToNoteId ? T.green : T.wave1
slide.addText(badge, { x: x + colW - 0.45, y: y + 0.08, w: 0.35, h: 0.35, fontSize: 10, fontFace: 'Arial', color: badgeColor, align: 'center' })
}
// Title
slide.addText(truncate(idea.title, 55), {
x: x + 0.18, y: y + 0.12, w: colW - 0.55, h: 0.4,
fontSize: 11, fontFace: 'Arial', color: T.primary, bold: true, wrap: true,
})
// Description
slide.addText(truncate(idea.description, 120), {
x: x + 0.18, y: y + 0.52, w: colW - 0.36, h: 0.65,
fontSize: 9, fontFace: 'Arial', color: T.muted, wrap: true, valign: 'top',
})
})
if (ideas.length > maxPerSlide) {
slide.addText(`+ ${ideas.length - maxPerSlide} autres idées`, {
x: 0.4, y: 5.5, w: 9.2, h: 0.3,
fontSize: 9, fontFace: 'Arial', color: T.muted, align: 'center', italic: true,
})
}
}
function buildTopIdeasSlide(pres: PptxGenJSModule, starred: IdeaLike[], converted: IdeaLike[]) {
const slide = pres.addSlide()
addBg(slide)
addTopBar(slide, T.accent)
slide.addText('Top Idées', {
x: 0.5, y: 0.25, w: 9.0, h: 0.5,
fontSize: 14, fontFace: 'Arial', color: T.accent, bold: true, charSpacing: 2,
})
const all = [
...starred.map(i => ({ ...i, badge: '⭐', badgeColor: T.wave1 })),
...converted.filter(i => !i.isStarred).map(i => ({ ...i, badge: '✓', badgeColor: T.green })),
].slice(0, 6)
if (all.length === 0) {
slide.addText('Aucune idée favorite ou convertie.', {
x: 0.5, y: 3.0, w: 9.0, h: 0.5,
fontSize: 12, fontFace: 'Georgia', color: T.muted, align: 'center', italic: true,
})
return
}
const colW = 4.5
const rowH = 1.25
all.forEach((idea, i) => {
const col = i % 2
const row = Math.floor(i / 2)
const x = 0.4 + col * (colW + 0.3)
const y = 1.1 + row * (rowH + 0.15)
const waveColor = WAVE_COLORS[idea.waveNumber] || T.muted
slide.addShape('roundRect', { x, y, w: colW, h: rowH, fill: { color: 'FFFFFF' }, line: { color: waveColor, width: 1.5 }, rectRadius: 0.08 })
slide.addText(idea.badge, { x: x + 0.1, y: y + 0.1, w: 0.4, h: 0.4, fontSize: 14, fontFace: 'Arial', color: idea.badgeColor, align: 'center' })
slide.addText(truncate(idea.title, 55), {
x: x + 0.55, y: y + 0.1, w: colW - 0.7, h: 0.4,
fontSize: 11, fontFace: 'Arial', color: T.primary, bold: true, wrap: true,
})
slide.addText(truncate(idea.description, 110), {
x: x + 0.18, y: y + 0.55, w: colW - 0.36, h: 0.6,
fontSize: 9, fontFace: 'Arial', color: T.muted, wrap: true, valign: 'top',
})
})
}
function buildSummarySlide(pres: PptxGenJSModule, session: SessionLike, stats: { total: number; converted: number; starred: number; dismissed: number }) {
const slide = pres.addSlide()
addBg(slide)
addTopBar(slide, T.accent)
slide.addText('BILAN DE SESSION', {
x: 0.8, y: 0.25, w: 11.5, h: 0.5,
fontSize: 10, fontFace: 'Arial', color: T.accent, bold: true, charSpacing: 5, align: 'left',
})
slide.addText(truncate(session.seedIdea, 80), {
x: 0.8, y: 0.9, w: 11.5, h: 1.0,
fontSize: 20, fontFace: 'Georgia', color: T.primary, align: 'left', wrap: true,
})
// Divider
slide.addShape('rect', { x: 0.8, y: 2.1, w: 11.5, h: 0.02, fill: { color: T.accent } })
// Stats cards
const statCols = [
{ label: 'IDÉES GÉNÉRÉES', value: String(stats.total), color: T.wave2, bg: 'EEF6FF' },
{ label: 'CONVERTIES EN NOTES', value: String(stats.converted), color: T.green, bg: 'EDFAF4' },
{ label: 'FAVORITES', value: String(stats.starred), color: T.wave1, bg: 'FFF4EE' },
{ label: 'REJETÉES', value: String(stats.dismissed), color: T.muted, bg: 'F5F4F2' },
]
const cardW = 2.7
const cardH = 2.8
const startX = 0.8
const startY = 2.3
statCols.forEach((s, i) => {
const x = startX + i * (cardW + 0.2)
// Card bg
slide.addShape('roundRect', { x, y: startY, w: cardW, h: cardH, fill: { color: s.bg }, rectRadius: 0.1 })
// Top accent strip
slide.addShape('roundRect', { x, y: startY, w: cardW, h: 0.1, fill: { color: s.color }, rectRadius: 0.05 })
// Big number
slide.addText(s.value, { x: x + 0.2, y: startY + 0.35, w: cardW - 0.4, h: 1.2, fontSize: 48, fontFace: 'Georgia', color: s.color, bold: true, align: 'left', valign: 'middle' })
// Label
slide.addText(s.label, { x: x + 0.2, y: startY + 1.7, w: cardW - 0.4, h: 0.9, fontSize: 8, fontFace: 'Arial', color: T.muted, align: 'left', charSpacing: 1.5, wrap: true })
})
}
// ── Main export function ─────────────────────────────────────────────────────
export async function generateBrainstormPptx(session: SessionLike): Promise<{ buffer: Buffer; filename: string }> {
const PptxGenJS = await getPptxGenClass()
const pres = withWatermark(new PptxGenJS())
pres.layout = 'LAYOUT_WIDE'
pres.author = 'Momento'
pres.subject = `Brainstorm: ${session.seedIdea}`
const activeIdeas = session.ideas.filter(i => i.status !== 'dismissed')
const dismissedCount = session.ideas.filter(i => i.status === 'dismissed').length
const converted = activeIdeas.filter(i => i.convertedToNoteId !== null)
const starred = activeIdeas.filter(i => i.isStarred)
const stats = {
total: activeIdeas.length,
converted: converted.length,
starred: starred.length,
dismissed: dismissedCount,
}
// Slide 1 — Cover
buildCoverSlide(pres, session, stats)
// Slides 2-4 — One per active wave
for (const wave of [1, 2, 3]) {
const waveIdeas = activeIdeas.filter(i => i.waveNumber === wave)
if (waveIdeas.length === 0) continue
buildWaveSlide(pres, wave, waveIdeas)
}
// Slide N — Top ideas (starred + converted)
if (starred.length > 0 || converted.length > 0) {
buildTopIdeasSlide(pres, starred, converted)
}
// Last slide — Summary
buildSummarySlide(pres, session, stats)
const buffer = (await pres.write({ outputType: 'nodebuffer' })) as unknown as Buffer
const filename = `brainstorm-${slugify(session.seedIdea)}.pptx`
return { buffer, filename }
}