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>
This commit is contained in:
94
memento-note/app/(public)/pricing/page.tsx
Normal file
94
memento-note/app/(public)/pricing/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { Shield } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function PricingPage() {
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
|
||||
|
||||
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') },
|
||||
]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-paper">
|
||||
<section className="py-32 px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.pricing.label')}</span>
|
||||
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-6">{t('landing.pricing.title')}</h2>
|
||||
<p className="text-concrete font-light max-w-xl mx-auto mb-12">{t('landing.pricing.desc')}</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-10 mb-8">
|
||||
<button onClick={() => setBillingInterval('monthly')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'monthly' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
|
||||
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.monthly')}</span>
|
||||
{billingInterval === 'monthly' && (
|
||||
<motion.div layoutId="interval-active-pricing" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
|
||||
)}
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button onClick={() => setBillingInterval('annual')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'annual' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
|
||||
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.annual')}</span>
|
||||
{billingInterval === 'annual' && (
|
||||
<motion.div layoutId="interval-active-pricing" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
|
||||
{PLANS.map((plan) => (
|
||||
<div key={plan.key} className={`relative p-8 rounded-[32px] border flex flex-col transition-all duration-300 hover:shadow-2xl hover:shadow-ink/5 ${plan.popular ? 'bg-ink text-paper border-ink ring-4 ring-ochre/20' : 'bg-white border-border text-ink'}`}>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-ochre text-ink text-[10px] font-bold uppercase tracking-widest rounded-full">
|
||||
{t('landing.pricing.popular')}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-8">
|
||||
<h4 className="text-[11px] font-bold uppercase tracking-widest mb-2 opacity-60">{t(`landing.pricing.${plan.key}.name`)}</h4>
|
||||
<div className="flex items-baseline gap-1 mb-4">
|
||||
<span className="text-4xl font-serif font-medium">{plan.price}</span>
|
||||
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
|
||||
</div>
|
||||
<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">
|
||||
{[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
|
||||
return (
|
||||
<div key={j} 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-light">{feat}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
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`)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -624,26 +624,30 @@ function addImageContentSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme,
|
||||
/** Full-bleed image slide with title + subtitle overlay */
|
||||
function addImageFullSlide(pres: PptxGenJSModule, slide: SlideSpec, t: Theme, style: StyleCfg, idx: number) {
|
||||
const s = pres.addSlide()
|
||||
s.background = { color: t.primary }
|
||||
const hasImage = !!slide.imageUrl
|
||||
// Use light bg as fallback when no image — t.primary makes the slide appear black without an image
|
||||
s.background = { color: hasImage ? t.primary : t.bg }
|
||||
|
||||
if (slide.imageUrl) {
|
||||
if (hasImage) {
|
||||
try {
|
||||
s.addImage({ x: 0, y: 0, w: 10, h: 5.63, ...resolveImageProps(slide.imageUrl) })
|
||||
} catch { /* fallback: colored bg */ }
|
||||
s.addImage({ x: 0, y: 0, w: 10, h: 5.63, ...resolveImageProps(slide.imageUrl!) })
|
||||
} catch { s.background = { color: t.bg } }
|
||||
}
|
||||
|
||||
// Dark overlay at bottom for text legibility
|
||||
s.addShape(SHAPE_RECT, { x: 0, y: 3.3, w: 10, h: 2.33, fill: { color: '000000', transparency: 40 } })
|
||||
// Overlay at bottom (dark for image, accent-tinted for no-image)
|
||||
const overlayColor = hasImage ? '000000' : t.primary
|
||||
s.addShape(SHAPE_RECT, { x: 0, y: 3.3, w: 10, h: 2.33, fill: { color: overlayColor, transparency: hasImage ? 40 : 10 } })
|
||||
s.addShape(SHAPE_RECT, { x: 0, y: 3.3, w: 0.12, h: 2.33, fill: { color: t.accent } })
|
||||
|
||||
const titleColor = hasImage ? 'FFFFFF' : textOnBg(t.primary)
|
||||
s.addText(slide.title, {
|
||||
x: 0.3, y: 3.45, w: 9.4, h: 1.0,
|
||||
fontSize: 28, fontFace: 'Arial', color: 'FFFFFF', bold: true, valign: 'middle', fit: 'shrink',
|
||||
fontSize: 28, fontFace: 'Arial', color: titleColor, bold: true, valign: 'middle', fit: 'shrink',
|
||||
})
|
||||
if (slide.subtitle) {
|
||||
s.addText(slide.subtitle, {
|
||||
x: 0.3, y: 4.5, w: 9.4, h: 0.8,
|
||||
fontSize: 15, fontFace: 'Arial', color: 'FFFFFF', valign: 'middle', fit: 'shrink', transparency: 15,
|
||||
fontSize: 15, fontFace: 'Arial', color: titleColor, valign: 'middle', fit: 'shrink', transparency: 15,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -68,11 +68,11 @@ function truncate(text: string, max: number): string {
|
||||
|
||||
/**
|
||||
* PLG viral watermark — added to every slide automatically via addSlide monkey-patch.
|
||||
* Subtle branding in bottom-right corner to drive organic acquisition.
|
||||
* 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: 7.0, y: 5.35, w: 2.7, h: 0.2,
|
||||
x: 10.0, y: 7.1, w: 3.0, h: 0.25,
|
||||
fontSize: 7, fontFace: 'Arial', color: 'B8B0A8',
|
||||
align: 'right', italic: true,
|
||||
})
|
||||
@@ -261,40 +261,44 @@ function buildTopIdeasSlide(pres: PptxGenJSModule, starred: IdeaLike[], converte
|
||||
function buildSummarySlide(pres: PptxGenJSModule, session: SessionLike, stats: { total: number; converted: number; starred: number; dismissed: number }) {
|
||||
const slide = pres.addSlide()
|
||||
addBg(slide)
|
||||
|
||||
// Dark left panel
|
||||
slide.addShape('rect', { x: 0, y: 0, w: '100%', h: '100%', fill: { color: T.primary } })
|
||||
addTopBar(slide, T.accent)
|
||||
|
||||
slide.addText('BILAN DE SESSION', {
|
||||
x: 0.8, y: 0.8, w: 8.4, h: 0.6,
|
||||
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, 70), {
|
||||
x: 0.8, y: 1.55, w: 8.4, h: 1.0,
|
||||
fontSize: 20, fontFace: 'Georgia', color: 'FFFFFF', align: 'left', wrap: true,
|
||||
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.8, w: 8.4, h: 0.02, fill: { color: T.accent } })
|
||||
slide.addShape('rect', { x: 0.8, y: 2.1, w: 11.5, h: 0.02, fill: { color: T.accent } })
|
||||
|
||||
// Stats grid
|
||||
// Stats cards
|
||||
const statCols = [
|
||||
{ label: 'IDÉES GÉNÉRÉES', value: String(stats.total), color: T.wave2 },
|
||||
{ label: 'CONVERTIES EN NOTES', value: String(stats.converted), color: T.green },
|
||||
{ label: 'FAVORITES', value: String(stats.starred), color: T.wave1 },
|
||||
{ label: 'REJETÉES', value: String(stats.dismissed), color: T.muted },
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
statCols.forEach((s, i) => {
|
||||
const x = 0.8 + i * 2.3
|
||||
slide.addText(s.value, { x, y: 3.2, w: 2.2, h: 0.9, fontSize: 36, fontFace: 'Georgia', color: s.color, bold: true, align: 'left' })
|
||||
slide.addText(s.label, { x, y: 4.1, w: 2.2, h: 0.5, fontSize: 8, fontFace: 'Arial', color: T.muted, align: 'left', charSpacing: 1.5, wrap: true })
|
||||
})
|
||||
const cardW = 2.7
|
||||
const cardH = 2.8
|
||||
const startX = 0.8
|
||||
const startY = 2.3
|
||||
|
||||
slide.addText('Généré par Momento', {
|
||||
x: 0.8, y: 5.5, w: 8.4, h: 0.3,
|
||||
fontSize: 8, fontFace: 'Arial', color: T.muted, align: 'right', italic: true,
|
||||
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 })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user