Fix card printing: html2canvas renders card to PNG before printing

The root cause of white/blank print: browsers strip CSS background gradients
and colors by default even with print-color-adjust: exact, especially inside
nested components wrapped by Layout.

Solution: use html2canvas to render the live card DOM to a canvas at 4x scale,
convert to PNG, then open a dedicated print window containing ONLY that image.
Images always print in full color — no CSS override needed.

Changes:
- Add html2canvas dependency
- renderCardToImage(): captures card DOM element at 4x scale with useCORS for
  cross-origin avatars, temporarily strips holo animation for clean capture
- handlePrint(): calls renderCardToImage, opens popup window with CSS @page
  set to 85.6×54mm, injects the PNG, auto-triggers print dialog
- handleDownload(): same render pipeline, saves as PNG file (for sharing
  digitally, WhatsApp, iMessage, or taking to a print shop)
- Added Download / Save PNG button alongside Print Card button
- Added Loader2 spinner on buttons while rendering
- Removed print-only-card div and broken @media print CSS (no longer needed)
- Clean up CSS print block to minimal fallback only
- Fix "on-chain proof" → "Signed proof" in badge dialog

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-16 15:22:13 +07:00
parent 3b99392f3b
commit 046a109eca
4 changed files with 137 additions and 81 deletions

10
package-lock.json generated
View file

@ -67,6 +67,7 @@
"ecpair": "^3.0.0", "ecpair": "^3.0.0",
"embla-carousel-react": "^8.3.0", "embla-carousel-react": "^8.3.0",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"html2canvas": "^1.4.1",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"idb": "^8.0.3", "idb": "^8.0.3",
"input-otp": "^1.2.4", "input-otp": "^1.2.4",
@ -5875,6 +5876,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/html5-qrcode": { "node_modules/html5-qrcode": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",

View file

@ -68,6 +68,7 @@
"ecpair": "^3.0.0", "ecpair": "^3.0.0",
"embla-carousel-react": "^8.3.0", "embla-carousel-react": "^8.3.0",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"html2canvas": "^1.4.1",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"idb": "^8.0.3", "idb": "^8.0.3",
"input-otp": "^1.2.4", "input-otp": "^1.2.4",

View file

@ -517,64 +517,17 @@ html, body {
.card-theme-sunset .card-muted { color: rgba(255,208,160,0.65) !important; } .card-theme-sunset .card-muted { color: rgba(255,208,160,0.65) !important; }
.card-theme-sunset .card-bg-chip { background: rgba(255,80,0,0.2) !important; border: 1px solid rgba(255,80,0,0.5) !important; } .card-theme-sunset .card-bg-chip { background: rgba(255,80,0,0.2) !important; border: 1px solid rgba(255,80,0,0.5) !important; }
/* ── Print-only card: hidden on screen, shown only when printing ── */ /* ── Print: the card is rendered to PNG via html2canvas before printing.
.print-only-card { The @media print block below is only a safety fallback for direct Ctrl+P.
display: none !important; It will never look as good as the canvas approach. ── */
position: fixed;
top: 0;
left: 0;
z-index: -1;
}
/* ── Print styles ── */
@media print { @media print {
/* Step 1: hide the entire page */ @page { size: 85.6mm 54mm; margin: 0; }
html, body { body > * { display: none !important; }
background: white !important; .rider-card-print-fallback {
}
body > * {
display: none !important;
}
/* Step 2: show only the print-only-card container */
.print-only-card {
display: block !important; display: block !important;
position: fixed !important; position: fixed !important;
top: 0 !important; top: 0; left: 0;
left: 0 !important;
width: 85.6mm !important; width: 85.6mm !important;
height: 54mm !important; height: 54mm !important;
margin: 0 !important;
padding: 0 !important;
z-index: 99999 !important;
overflow: hidden !important;
}
/* Step 3: size the card to exact credit-card dimensions */
.print-only-card .rider-card {
width: 85.6mm !important;
height: 54mm !important;
border-radius: 4mm !important;
page-break-inside: avoid;
box-shadow: none !important;
overflow: hidden !important;
}
/* Reset page margins to zero */
@page {
size: 85.6mm 54mm;
margin: 0;
}
/* Kill holographic overlay on print */
.rider-card-holo::after {
display: none !important;
}
/* Kill animations */
*, *::before, *::after {
animation: none !important;
transition: none !important;
} }
} }

View file

@ -13,9 +13,10 @@
* Print-ready: credit-card size (85.6×54mm), @media print isolates card * Print-ready: credit-card size (85.6×54mm), @media print isolates card
*/ */
import { useState, useRef, useCallback, useEffect } from 'react'; import { useState, useRef, useCallback } from 'react';
import { useSeoMeta } from '@unhead/react'; import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import html2canvas from 'html2canvas';
import { Layout } from '@/components/Layout'; import { Layout } from '@/components/Layout';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
@ -42,9 +43,9 @@ import { cn } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
import { import {
Printer, Copy, Palette, Check, Building2, Printer, Download, Copy, Palette, Check, Building2,
User, Shield, Award, Plus, Search, Zap, User, Shield, Award, Zap,
ChevronDown, ChevronUp, Info, ChevronDown, ChevronUp, Info, Loader2,
} from 'lucide-react'; } from 'lucide-react';
// ─── Nostr kinds ────────────────────────────────────────────────────────────── // ─── Nostr kinds ──────────────────────────────────────────────────────────────
@ -494,9 +495,9 @@ function AwardBadgeDialog({ open, onClose, issuerPubkey }: AwardBadgeDialogProps
</div> </div>
<div className="p-3 rounded-xl bg-yellow-500/5 border border-yellow-500/20 text-xs text-gray-400"> <div className="p-3 rounded-xl bg-yellow-500/5 border border-yellow-500/20 text-xs text-gray-400">
<span className="font-bold text-yellow-400">🔐 On-chain proof.</span>{' '} <span className="font-bold text-yellow-400">🔐 Signed proof.</span>{' '}
This badge is cryptographically signed by your Nostr key. The rider can This badge is a signed message on the Nostr network — cryptographically tied to your key.
display it with your name as witness — and anyone can verify it. The rider can show it with your name as witness, and anyone can verify it's real.
</div> </div>
<Button <Button
@ -643,6 +644,7 @@ export default function RiderCardPage() {
const [validThru, setValidThru] = useState(''); const [validThru, setValidThru] = useState('');
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [rendering, setRendering] = useState(false);
// ── Parse recipientNpub → pubkey ───────────────────────────────────────── // ── Parse recipientNpub → pubkey ─────────────────────────────────────────
const recipientPubkey = (() => { const recipientPubkey = (() => {
@ -683,10 +685,96 @@ export default function RiderCardPage() {
validThru, validThru,
}; };
// ── Actions ───────────────────────────────────────────────────────────────── // ── Render card to canvas → returns a PNG data URL ───────────────────────
const handlePrint = useCallback(() => { const renderCardToImage = useCallback(async (): Promise<string | null> => {
if (!printRef.current) return null;
setRendering(true);
try {
// Temporarily disable holographic animation for clean capture
const cardEl = printRef.current;
cardEl.classList.remove('rider-card-holo');
const canvas = await html2canvas(cardEl, {
scale: 4, // 4× for crisp print quality
useCORS: true, // allow cross-origin avatars
allowTaint: true,
backgroundColor: null,
logging: false,
onclone: (doc) => {
// In the cloned doc, force all backgrounds to render
const style = doc.createElement('style');
style.textContent = `
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
`;
doc.head.appendChild(style);
},
});
// Restore holo class if it was on
if (cardData.holographic) cardEl.classList.add('rider-card-holo');
return canvas.toDataURL('image/png');
} catch (err) {
console.error('Card render failed:', err);
return null;
} finally {
setRendering(false);
}
}, [printRef, cardData.holographic]);
// ── Print: render to image, open print window with the image ─────────────
const handlePrint = useCallback(async () => {
const dataUrl = await renderCardToImage();
if (!dataUrl) {
toast({ title: 'Could not render card', description: 'Try again in a moment.', variant: 'destructive' });
return;
}
// 85.6mm × 54mm in points (1mm ≈ 2.835pt)
const w = Math.round(85.6 * 2.835);
const h = Math.round(54 * 2.835);
const win = window.open('', '_blank', `width=${w * 2},height=${h * 2}`);
if (!win) {
toast({ title: 'Popup blocked', description: 'Allow popups for this site then try again.', variant: 'destructive' });
return;
}
win.document.write(`<!DOCTYPE html>
<html>
<head>
<title>SkateSpots Rider Card</title>
<style>
@page { size: 85.6mm 54mm; margin: 0; }
html, body { margin: 0; padding: 0; background: #000; }
img { display: block; width: 85.6mm; height: 54mm; }
</style>
</head>
<body>
<img src="${dataUrl}" />
<script>
window.onload = function() {
window.focus();
window.print(); window.print();
}, []); };
</script>
</body>
</html>`);
win.document.close();
}, [renderCardToImage, toast]);
// ── Download card as PNG ──────────────────────────────────────────────────
const handleDownload = useCallback(async () => {
const dataUrl = await renderCardToImage();
if (!dataUrl) {
toast({ title: 'Could not render card', variant: 'destructive' });
return;
}
const a = document.createElement('a');
a.href = dataUrl;
a.download = `skatespot-card-${cardData.licenseId}.png`;
a.click();
}, [renderCardToImage, cardData.licenseId, toast]);
const handleCopyNpub = useCallback(() => { const handleCopyNpub = useCallback(() => {
navigator.clipboard.writeText(npub); navigator.clipboard.writeText(npub);
@ -740,18 +828,7 @@ export default function RiderCardPage() {
return ( return (
<Layout> <Layout>
{/* <div className="container max-w-5xl px-4 py-10">
PRINT ISOLATION: when window.print() fires, @media print CSS in index.css
hides everything except elements with `rider-card` class via direct targeting.
The printable card is rendered in a fixed-position portal outside the layout flow.
*/}
{/* ── Print-only card (fixed, invisible on screen, visible on print) ── */}
<div className="print-only-card" aria-hidden="true">
<RiderCardFront d={cardData} />
</div>
<div className="no-print container max-w-5xl px-4 py-10">
{/* Page header */} {/* Page header */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-4xl font-black uppercase mb-2"> <h1 className="text-4xl font-black uppercase mb-2">
@ -813,10 +890,20 @@ export default function RiderCardPage() {
<div className="flex gap-3 flex-wrap justify-center"> <div className="flex gap-3 flex-wrap justify-center">
<Button <Button
onClick={handlePrint} onClick={handlePrint}
disabled={rendering}
className="bg-[#ff0040] hover:bg-[#cc0030] gap-2 font-black uppercase" className="bg-[#ff0040] hover:bg-[#cc0030] gap-2 font-black uppercase"
> >
<Printer className="h-4 w-4" /> {rendering ? <Loader2 className="h-4 w-4 animate-spin" /> : <Printer className="h-4 w-4" />}
Print Card {rendering ? 'Rendering…' : 'Print Card'}
</Button>
<Button
variant="outline"
onClick={handleDownload}
disabled={rendering}
className="gap-2 border-gray-700 hover:border-gray-500"
>
{rendering ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
Save PNG
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@ -838,9 +925,14 @@ export default function RiderCardPage() {
)} )}
</div> </div>
<p className="text-gray-600 text-xs text-center max-w-xs"> <div className="text-center space-y-1">
📄 Print → Custom 85.6×54 mm (or Business Card), no margins. Laminate for extra street cred. 🛹 <p className="text-gray-500 text-xs max-w-xs">
</p> 🖨️ <strong className="text-gray-400">Print Card</strong> opens a print window with the card as an image — backgrounds guaranteed to show.
</p>
<p className="text-gray-600 text-xs max-w-xs">
💾 <strong className="text-gray-400">Save PNG</strong> to share digitally, use in iMessage, WhatsApp, or take to a print shop.
</p>
</div>
</div> </div>
{/* Right: Controls */} {/* Right: Controls */}