Commit 14972ee2 authored by Mahmoud Aglan's avatar Mahmoud Aglan

build(proposal): add PDF export with print stylesheet

The microsite is built for scrolling, so a naive print lost most of it:
reveals start invisible, the phone shows one prototype screen at a time,
the portal shows one admin screen, and <details> print collapsed.

- @media print in styles.css: force reveals visible, drop the fixed nav
  and prototype tools, start each section on a fresh page, and mark cards,
  timeline items, tables and price blocks break-inside:avoid so none is
  split across a page boundary
- generate-pdf.mjs (puppeteer): expands both prototypes before printing —
  the single phone frame becomes a labelled 3x3 grid of nine real app
  screens, and all seven portal screens are stacked. 1240x1754 pages (A4
  proportion at 150dpi), backgrounds on.
- Adds a "تحميل العرض PDF" button to the hero; the print stylesheet hides
  .btn-row so it does not appear inside the PDF itself

Output is 14 pages with a real text layer: Arabic extracts correctly and
figures stay searchable. Ghostscript compression reaches 2.3MB but its
font re-embedding drops Arabic strings from the text layer, so the
uncompressed 8.5MB file is kept instead.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent deefac32
......@@ -579,3 +579,31 @@ footer > *{ position:relative; z-index:1; }
@media (max-width:1020px){
#portal .grid[style*="1.3fr"]{ grid-template-columns:1fr !important; }
}
/* ================= PRINT / PDF =================
The page is built for scrolling: reveals start invisible, the nav is fixed,
and the prototypes show one screen at a time. Print needs the opposite —
everything visible, nothing floating, and sections that start on a fresh
page instead of splitting a card in half. */
@media print{
.nav, .progress, .proto-tools, .btn-row, .hero-logo{ display:none !important; }
.reveal{ opacity:1 !important; transform:none !important; }
html,body{ background:#fff; }
section{ break-before:page; padding:46px 30px; }
.hero{ break-before:auto; min-height:auto; padding:56px 30px 48px; }
#problem{ break-before:auto; }
/* Never split an atomic block across a page boundary. */
.card,.pain,.tmpl,.pay-step,.tl-i,.faq,.kpi,.pline,.note,.drop,
.price-hero,.phone,.mcard,.stat,.fstep,.tbl,.scope-list li{ break-inside:avoid; }
.faq{ break-inside:avoid; }
.faq p{ display:block !important; } /* <details> print collapsed otherwise */
footer{ break-before:page; }
/* Watermarks read as grey smudges on paper at screen opacity. */
.wm::before,.hero::before,.price-hero::before,footer::before{ opacity:.045 !important; }
a{ text-decoration:none; }
}
/**
* Renders the proposal microsite to PDF.
*
* The site is built for scrolling, so two things need undoing before printing:
* the phone shows one prototype screen at a time, and the portal shows one
* admin screen at a time. On paper that would lose almost the whole prototype,
* so both are expanded into labelled grids first.
*
* node generate-pdf.mjs # from the working tree
* node generate-pdf.mjs --live # from the deployed site
*/
import puppeteer from '../node_modules/puppeteer/lib/esm/puppeteer/puppeteer.js';
import { fileURLToPath } from 'url';
import path from 'path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIVE = process.argv.includes('--live');
const SRC = LIVE
? 'https://sayd-mobile.caprover.al-arcade.com/'
: 'file://' + path.join(__dirname, 'index.html');
const OUT = path.join(__dirname, 'عرض-تطبيق-الأعضاء-نادي-الصيد-المصري.pdf');
/* Screens worth a page in the printed prototype, in reading order. */
const APP_SCREENS = [
['home', 'الرئيسية وبطاقة العضوية'],
['dues', 'المستحقات والأقساط'],
['pay', 'السداد الإلكتروني'],
['qr', 'كود دخول البوابة'],
['invites', 'الدعوات وشراء المزيد'],
['activities', 'الأنشطة الرياضية'],
['news', 'أخبار النادي'],
['article/a1', 'صفحة الخبر'],
['schedule', 'جدول التدريب'],
];
const PORTAL_LABELS = {
dash: 'لوحة التحصيل',
import: 'رفع بيانات الأعضاء',
members: 'الأعضاء والمستحقات',
catalog: 'تسعير الأنشطة',
invites: 'سياسة الدعوات',
sched: 'جداول المدربين',
news: 'أخبار النادي',
};
const wait = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--allow-file-access-from-files', // the phone frames are file:// iframes
'--font-render-hinting=none',
],
});
const page = await browser.newPage();
await page.setViewport({ width: 1240, height: 1754, deviceScaleFactor: 2 });
console.log('· loading', LIVE ? 'live site' : 'working tree');
await page.goto(SRC, { waitUntil: 'networkidle0', timeout: 90000 });
await page.emulateMediaType('print');
console.log('· expanding prototypes');
await page.evaluate(({ screens, labels }) => {
document.querySelectorAll('.reveal').forEach(el => el.classList.add('in'));
const nav = document.getElementById('nav'); if (nav) nav.remove();
const tools = document.querySelector('.proto-tools'); if (tools) tools.remove();
/* --- mobile prototype: one live frame -> a grid of labelled frames --- */
const stage = document.querySelector('.phone-stage');
const protoNav = document.querySelector('.proto-nav');
const proto = document.querySelector('.proto');
if (stage && proto) {
const desc = protoNav ? protoNav.querySelector('.pn-desc') : null;
const intro = desc ? desc.outerHTML : '';
proto.style.display = 'block';
if (protoNav) protoNav.remove();
stage.outerHTML = `
<div class="print-intro">${intro}</div>
<div class="print-phones">
${screens.map(([route, label]) => `
<figure class="print-phone">
<div class="phone"><iframe class="screen" data-route="${route}"
src="app/index.html#/${route}" loading="eager"></iframe></div>
<figcaption>${label}</figcaption>
</figure>`).join('')}
</div>`;
}
/* --- portal: one visible screen -> all of them, stacked ---
Each .pscr already opens with its own .ptop heading, so no caption is
injected here; adding one printed the screen name twice. */
document.querySelectorAll('.pscr').forEach(scr => { scr.style.display = 'block'; });
const pnav = document.querySelector('[data-pscr]');
if (pnav && pnav.parentElement) pnav.parentElement.remove();
const side = document.querySelector('.side'); if (side) side.style.display = 'none';
const bbar = document.querySelector('.bbar'); if (bbar) bbar.style.display = 'none';
/* <details> print collapsed unless forced open */
document.querySelectorAll('details').forEach(d => d.open = true);
const css = document.createElement('style');
css.textContent = `
.print-intro{ max-width:760px; margin:0 0 26px; font-size:.9rem; color:var(--txt-2); line-height:1.85; }
.print-intro .pn-desc{ border:0; padding:0; margin:0; }
.print-phones{ display:grid; grid-template-columns:repeat(3,1fr); gap:24px 18px; }
/* 600px keeps two rows inside one 1754px page; at 660 the second row
overflowed by a few pixels and half a page was left blank. */
.print-phone{ margin:0; break-inside:avoid; text-align:center; }
.print-phone .phone{ width:100%; }
.print-phone .screen{ width:100%; height:600px; border:0; }
.print-phone figcaption{ margin-top:9px; font-family:var(--f-head); font-weight:700;
font-size:.82rem; color:var(--navy); }
.pscr{ break-inside:avoid; margin-bottom:8px; }
.bwin{ display:block; } .pmain{ padding:18px 4px; }
`;
document.head.appendChild(css);
}, { screens: APP_SCREENS, labels: PORTAL_LABELS });
console.log('· waiting for prototype frames');
await page.waitForFunction(() => {
const frames = [...document.querySelectorAll('.print-phone iframe')];
if (!frames.length) return false;
return frames.every(f => {
try { return !!f.contentDocument?.querySelector('#app .screen'); }
catch { return false; }
});
}, { timeout: 60000 });
await page.evaluate(() => document.fonts.ready);
await wait(2500); // let the in-frame reveal/transition settle
console.log('· printing');
await page.pdf({
path: OUT,
width: '1240px',
height: '1754px',
printBackground: true,
margin: { top: 0, right: 0, bottom: 0, left: 0 },
displayHeaderFooter: false,
});
await browser.close();
console.log('✓ ' + OUT);
})().catch(err => { console.error(err); process.exit(1); });
......@@ -88,6 +88,8 @@
<div class="btn-row">
<a href="#app" class="btn btn-gold">شاهدوا نموذج التطبيق ↓</a>
<a href="#portal" class="btn btn-ghost">شاهدوا بوابة الإدارة</a>
<a href="عرض-تطبيق-الأعضاء-نادي-الصيد-المصري.pdf" class="btn btn-ghost" download>
<svg class="ico"><use href="#i-download"/></svg> تحميل العرض PDF</a>
</div>
<div class="hero-stats">
<div class="hero-stat"><b class="num">4</b><span>محاور في المرحلة الأولى</span></div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment