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:
parent
3b99392f3b
commit
046a109eca
4 changed files with 137 additions and 81 deletions
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -67,6 +67,7 @@
|
|||
"ecpair": "^3.0.0",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"html2canvas": "^1.4.1",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "^1.2.4",
|
||||
|
|
@ -5875,6 +5876,15 @@
|
|||
"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": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@
|
|||
"ecpair": "^3.0.0",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"html2canvas": "^1.4.1",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "^1.2.4",
|
||||
|
|
|
|||
|
|
@ -517,64 +517,17 @@ html, body {
|
|||
.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; }
|
||||
|
||||
/* ── Print-only card: hidden on screen, shown only when printing ── */
|
||||
.print-only-card {
|
||||
display: none !important;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* ── Print styles ── */
|
||||
/* ── Print: the card is rendered to PNG via html2canvas before printing.
|
||||
The @media print block below is only a safety fallback for direct Ctrl+P.
|
||||
It will never look as good as the canvas approach. ── */
|
||||
@media print {
|
||||
/* Step 1: hide the entire page */
|
||||
html, body {
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
body > * {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Step 2: show only the print-only-card container */
|
||||
.print-only-card {
|
||||
@page { size: 85.6mm 54mm; margin: 0; }
|
||||
body > * { display: none !important; }
|
||||
.rider-card-print-fallback {
|
||||
display: block !important;
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
top: 0; left: 0;
|
||||
width: 85.6mm !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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@
|
|||
* 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 { nip19 } from 'nostr-tools';
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
|
|
@ -42,9 +43,9 @@ import { cn } from '@/lib/utils';
|
|||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import {
|
||||
Printer, Copy, Palette, Check, Building2,
|
||||
User, Shield, Award, Plus, Search, Zap,
|
||||
ChevronDown, ChevronUp, Info,
|
||||
Printer, Download, Copy, Palette, Check, Building2,
|
||||
User, Shield, Award, Zap,
|
||||
ChevronDown, ChevronUp, Info, Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ─── Nostr kinds ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -494,9 +495,9 @@ function AwardBadgeDialog({ open, onClose, issuerPubkey }: AwardBadgeDialogProps
|
|||
</div>
|
||||
|
||||
<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>{' '}
|
||||
This badge is cryptographically signed by your Nostr key. The rider can
|
||||
display it with your name as witness — and anyone can verify it.
|
||||
<span className="font-bold text-yellow-400">🔐 Signed proof.</span>{' '}
|
||||
This badge is a signed message on the Nostr network — cryptographically tied to your key.
|
||||
The rider can show it with your name as witness, and anyone can verify it's real.
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
|
@ -643,6 +644,7 @@ export default function RiderCardPage() {
|
|||
const [validThru, setValidThru] = useState('');
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [rendering, setRendering] = useState(false);
|
||||
|
||||
// ── Parse recipientNpub → pubkey ─────────────────────────────────────────
|
||||
const recipientPubkey = (() => {
|
||||
|
|
@ -683,10 +685,96 @@ export default function RiderCardPage() {
|
|||
validThru,
|
||||
};
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────────────
|
||||
const handlePrint = useCallback(() => {
|
||||
// ── Render card to canvas → returns a PNG data URL ───────────────────────
|
||||
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();
|
||||
}, []);
|
||||
};
|
||||
</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(() => {
|
||||
navigator.clipboard.writeText(npub);
|
||||
|
|
@ -740,18 +828,7 @@ export default function RiderCardPage() {
|
|||
|
||||
return (
|
||||
<Layout>
|
||||
{/*
|
||||
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">
|
||||
<div className="container max-w-5xl px-4 py-10">
|
||||
{/* Page header */}
|
||||
<div className="mb-8">
|
||||
<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">
|
||||
<Button
|
||||
onClick={handlePrint}
|
||||
disabled={rendering}
|
||||
className="bg-[#ff0040] hover:bg-[#cc0030] gap-2 font-black uppercase"
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
Print Card
|
||||
{rendering ? <Loader2 className="h-4 w-4 animate-spin" /> : <Printer className="h-4 w-4" />}
|
||||
{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
|
||||
variant="outline"
|
||||
|
|
@ -838,9 +925,14 @@ export default function RiderCardPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-xs text-center max-w-xs">
|
||||
📄 Print → Custom 85.6×54 mm (or Business Card), no margins. Laminate for extra street cred. 🛹
|
||||
</p>
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-gray-500 text-xs max-w-xs">
|
||||
🖨️ <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>
|
||||
|
||||
{/* Right: Controls */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue