- 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>
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
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 }
|
||
}
|