feat(ui): enhance component share UX and add new interactive simulations
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🏗️ Build (push) Failing after 26s
Build & Deploy / 🧪 QA (push) Failing after 1m14s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s

This commit is contained in:
2026-02-22 16:58:42 +01:00
parent d9ddce412a
commit 38f2cc8b85
22 changed files with 918 additions and 163 deletions

View File

@@ -5,16 +5,19 @@ import { Share2 } from "lucide-react";
import { ShareModal } from "./ShareModal";
import { useAnalytics } from "./analytics/useAnalytics";
import * as htmlToImage from "html-to-image";
import QRCode from "qrcode";
interface ComponentShareButtonProps {
targetId: string;
title?: string;
shareText?: string;
className?: string;
}
export const ComponentShareButton: React.FC<ComponentShareButtonProps> = ({
targetId,
title = "Component",
title = "Mintel Architektur Insights",
shareText = "Schauen Sie sich diese spannende interaktive Simulation an:",
className = ""
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -27,31 +30,131 @@ export const ComponentShareButton: React.FC<ComponentShareButtonProps> = ({
? `${window.location.origin}${window.location.pathname}#${targetId}`
: "";
const applyWatermark = async (base64Img: string, qrCodeSrc: string): Promise<string> => {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
// html-to-image uses pixelRatio: 2, so img.width/img.height are 2x
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
if (!ctx) return resolve(base64Img);
// Draw main image
ctx.drawImage(img, 0, 0);
// Watermark styling for 2x resolution
const padding = 32;
const wHeight = 110;
const wWidth = 360;
const x = canvas.width - wWidth - padding;
const y = canvas.height - wHeight - padding;
// Draw pill shape background
ctx.fillStyle = "rgba(255, 255, 255, 0.95)";
ctx.shadowColor = "rgba(0, 0, 0, 0.05)";
ctx.shadowBlur = 10;
ctx.shadowOffsetY = 4;
if (ctx.roundRect) {
ctx.beginPath();
ctx.roundRect(x, y, wWidth, wHeight, 24);
ctx.fill();
} else {
ctx.fillRect(x, y, wWidth, wHeight);
}
// Draw text
ctx.shadowColor = "transparent";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
// "mintel."
ctx.font = "900 36px sans-serif";
const textX = x + wWidth - wHeight - 12; // Right of text, left of QR code
const mintelText = "mintel";
const dotText = ".";
const dotWidth = ctx.measureText(dotText).width;
const mintelWidth = ctx.measureText(mintelText).width;
ctx.fillStyle = "#0ea5e9";
ctx.fillText(dotText, textX, y + 40);
ctx.fillStyle = "#0f172a";
ctx.fillText(mintelText, textX - dotWidth, y + 40);
// "Artikel lesen"
ctx.font = "600 20px sans-serif";
ctx.fillStyle = "#64748b";
ctx.fillText("Zum Artikel", textX, y + 76);
// Draw solid line separator
ctx.beginPath();
ctx.moveTo(textX + 6, y + 20);
ctx.lineTo(textX + 6, y + wHeight - 20);
ctx.strokeStyle = "#e2e8f0";
ctx.lineWidth = 2;
ctx.stroke();
// Draw QR Code
const qrImg = new Image();
qrImg.onload = () => {
const qrSize = wHeight - 24; // padding 12*2 inside pill
const qrX = x + wWidth - qrSize - 12;
const qrY = y + 12;
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
resolve(canvas.toDataURL("image/png"));
};
qrImg.onerror = () => resolve(base64Img); // Error fallback
qrImg.src = qrCodeSrc;
};
img.onerror = () => resolve(base64Img); // Error fallback
img.src = base64Img;
});
};
const handleOpenModal = async () => {
setIsCapturing(true);
try {
const element = document.getElementById(targetId);
if (element) {
const dataUrl = await htmlToImage.toPng(element, {
// Force explicit dimensions to prevent html-to-image from clipping
const w = element.offsetWidth;
const h = element.offsetHeight;
const rawDataUrl = await htmlToImage.toPng(element, {
quality: 1,
type: 'image/png',
pixelRatio: 2,
backgroundColor: 'transparent',
backgroundColor: 'white',
skipFonts: true,
// Filter out any buttons that are part of the UI but shouldn't be in the image
width: w,
height: h,
style: {
transform: 'none',
margin: '0',
width: `${w}px`,
height: `${h}px`
},
filter: (node) => {
if (node.tagName === 'BUTTON' || (node as HTMLElement).dataset?.shareButton === 'true') {
const el = node as HTMLElement;
if (el.tagName === 'BUTTON' || el.dataset?.shareButton === 'true' || el.dataset?.shareWrapper === 'true') {
return false;
}
return true;
},
style: {
// Ensure we don't accidentally cut off by resetting transform/margins inside the clone
transform: 'none',
}
});
setGeneratedImage(dataUrl);
// Generate QR Code
const qrDataUrl = await QRCode.toDataURL(currentUrl, {
width: 120, // slightly larger for sharper scaling
margin: 0,
color: { dark: '#0f172a', light: '#ffffff' }
});
// Add Watermark
const watermarkedImage = await applyWatermark(rawDataUrl, qrDataUrl);
setGeneratedImage(watermarkedImage);
}
} catch (err) {
console.error("Failed to capture component:", err);
@@ -60,40 +163,41 @@ export const ComponentShareButton: React.FC<ComponentShareButtonProps> = ({
setIsModalOpen(true);
trackEvent("component_share_opened", {
component_id: targetId,
component_title: title,
component_title: title
});
}
};
return (
<>
<div className={`relative ${className}`}>
<button
onClick={handleOpenModal}
disabled={isCapturing}
data-share-button="true"
className={`inline-flex z-20 items-center justify-center gap-2 px-3 py-1.5 bg-white border border-slate-200 rounded-sm text-slate-500 hover:text-slate-900 hover:bg-slate-50 hover:border-slate-400 transition-all text-[10px] font-mono uppercase tracking-widest ${className}`}
aria-label="Component als Grafik teilen"
className={`flex items-center gap-2 px-3 py-1.5 rounded-full font-medium text-xs transition-all shadow-sm
${isCapturing
? "bg-slate-100 text-slate-400 cursor-wait border border-slate-200"
: "bg-white text-slate-600 hover:text-slate-900 border border-slate-200 hover:border-slate-300 hover:shadow-md"
}`}
title="Diesen Abschnitt als Bild teilen"
>
<Share2 strokeWidth={2.5} className={`w-3 h-3 ${isCapturing ? 'animate-spin' : ''}`} />
<span>{isCapturing ? "Erstelle Bild..." : "Teilen"}</span>
{isCapturing ? (
<div className="w-3.5 h-3.5 border-2 border-slate-300 border-t-slate-500 rounded-full animate-spin" />
) : (
<Share2 size={14} />
)}
<span>Teilen</span>
</button>
{/* ShareModal expects a direct image string in 'qrCodeData' or 'diagramImage' (except diagramImage specifically assumes SVGs).
Because ShareModal has logic that redraws diagramImage on a canvas assuming it's an SVG string,
we must bypass the SVG renderer. However, if we look at ShareModal, we need a way to pass a raw PNG.
Passing it as qrCodeData is a hack, or we can just send it via diagramImage and hope the canvas ignores it if it's already a Data URL.
Wait: ShareModal expects `diagramImage` (svg string) AND re-renders it.
Let's just pass our Data URL into a NEW prop or hijack the qrCodeData if necessary, but actually ShareModal only allows `diagramImage` as SVG logic right now.
Let's see if ShareModal needs an update to accept pure images, we'll check it. for now, let's pass it via diagramImage and see if we can adapt ShareModal. */}
{/* We will adapt ShareModal to handle both SVG strings & base64 PNG inputs via `diagramImage` */}
<ShareModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
url={currentUrl}
title={title}
description={shareText}
diagramImage={generatedImage}
qrCodeData={generatedImage} // Let's try sending it to both to be safe depending on ShareModal's internals
/>
</>
</div>
);
};