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
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:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState, useId } from 'react';
|
||||
import { ComponentShareButton } from './ComponentShareButton';
|
||||
|
||||
interface BoldNumberProps {
|
||||
/** The number to display, e.g. "53%" or "2.5M€" or "-20%" */
|
||||
@@ -28,6 +29,7 @@ export const BoldNumber: React.FC<BoldNumberProps> = ({
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [displayValue, setDisplayValue] = useState('');
|
||||
const shareId = `boldnum-${useId().replace(/:/g, '')}`;
|
||||
|
||||
// Extract numeric part for animation
|
||||
const numericMatch = value.match(/^([+-]?)(\d+(?:[.,]\d+)?)(.*)/);
|
||||
@@ -84,22 +86,16 @@ export const BoldNumber: React.FC<BoldNumberProps> = ({
|
||||
return () => clearInterval(timer);
|
||||
}, [isVisible, value, prefix, suffix, targetNum, hasDecimals, numStr]);
|
||||
|
||||
const handleShare = async () => {
|
||||
const shareText = `${value} — ${label}${source ? ` (${source})` : ''}`;
|
||||
try {
|
||||
if (navigator.share) {
|
||||
await navigator.share({ text: shareText });
|
||||
} else {
|
||||
await navigator.clipboard.writeText(shareText);
|
||||
}
|
||||
} catch { /* user cancelled */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`not-prose relative overflow-hidden rounded-2xl my-16 border border-slate-100 bg-slate-50/50 p-10 md:p-14 text-center ${className}`}
|
||||
id={shareId}
|
||||
className={`not-prose relative overflow-hidden rounded-2xl my-16 border border-slate-100 bg-slate-50/50 p-10 md:p-14 text-center group ${className}`}
|
||||
>
|
||||
<div className="absolute top-4 right-4 z-50 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500">
|
||||
<ComponentShareButton targetId={shareId} title={`Statistik: ${label}`} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<span className="block text-6xl md:text-8xl font-black tracking-tighter tabular-nums leading-none text-slate-900 pb-2">
|
||||
{displayValue || value}
|
||||
@@ -119,19 +115,6 @@ export const BoldNumber: React.FC<BoldNumberProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Share button - subtle now */}
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="absolute top-4 right-4 z-20 p-2 rounded-lg text-slate-300 hover:text-blue-600 hover:bg-blue-50 transition-all cursor-pointer"
|
||||
title="Teilen"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<line x1="12" y1="2" x2="12" y2="15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -98,10 +98,10 @@ export const Button: React.FC<ButtonProps> = ({
|
||||
// Binary stream overlay colors by variant
|
||||
const binaryColor =
|
||||
variant === "primary"
|
||||
? "rgba(255,255,255,0.06)"
|
||||
? "rgba(255,255,255,0.12)"
|
||||
: variant === "outline"
|
||||
? "rgba(59,130,246,0.08)"
|
||||
: "rgba(148,163,184,0.06)";
|
||||
? "rgba(59,130,246,0.15)"
|
||||
: "rgba(148,163,184,0.15)";
|
||||
|
||||
const inner = (
|
||||
<motion.span
|
||||
@@ -115,7 +115,7 @@ export const Button: React.FC<ButtonProps> = ({
|
||||
style={{ opacity: hovered ? 1 : 0, transition: "opacity 0.3s ease" }}
|
||||
>
|
||||
<motion.span
|
||||
className="whitespace-nowrap font-mono text-[8px] tracking-[0.3em] select-none"
|
||||
className="whitespace-nowrap font-mono text-xs tracking-[0.4em] select-none"
|
||||
style={{ color: binaryColor }}
|
||||
animate={hovered ? { x: [0, -200] } : { x: 0 }}
|
||||
transition={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,11 +25,11 @@ export const MemeCard: React.FC<MemeCardProps> = ({ template, captions, image, c
|
||||
if (image) {
|
||||
return (
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<div id={shareId} className={`not-prose max-w-xl mx-auto my-12 group relative transition-all duration-500 ease-out z-10 ${className}`}>
|
||||
<div className={`not-prose max-w-xl mx-auto my-12 group relative transition-all duration-500 ease-out z-10 ${className}`}>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100 rounded-[2rem] blur opacity-10 group-hover:opacity-30 transition duration-1000 -z-10" />
|
||||
|
||||
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl overflow-hidden shadow-sm shadow-slate-200 group-hover:shadow-md transition-all duration-500 relative">
|
||||
<div className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
|
||||
<div id={shareId} className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl overflow-hidden shadow-sm shadow-slate-200 group-hover:shadow-md transition-all duration-500 relative">
|
||||
<div data-share-wrapper="true" className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
|
||||
<ComponentShareButton targetId={shareId} title={`Meme: ${template}`} />
|
||||
</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
@@ -37,7 +37,9 @@ export const MemeCard: React.FC<MemeCardProps> = ({ template, captions, image, c
|
||||
src={image}
|
||||
alt={`Meme: ${template} - ${captionList.join(' ')}`}
|
||||
className="w-full h-auto object-cover block"
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,11 +49,11 @@ export const MemeCard: React.FC<MemeCardProps> = ({ template, captions, image, c
|
||||
|
||||
return (
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<div id={shareId} className={`not-prose max-w-xl mx-auto my-12 group relative transition-all duration-500 ease-out z-10 ${className}`}>
|
||||
<div className={`not-prose max-w-xl mx-auto my-12 group relative transition-all duration-500 ease-out z-10 ${className}`}>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100 rounded-[2rem] blur opacity-10 group-hover:opacity-30 transition duration-1000 -z-10" />
|
||||
|
||||
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl overflow-hidden shadow-sm shadow-slate-200 group-hover:shadow-md transition-all duration-500 relative">
|
||||
<div className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
|
||||
<div id={shareId} className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl overflow-hidden shadow-sm shadow-slate-200 group-hover:shadow-md transition-all duration-500 relative">
|
||||
<div data-share-wrapper="true" className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
|
||||
<ComponentShareButton targetId={shareId} title={`Meme: ${template}`} />
|
||||
</div>
|
||||
|
||||
@@ -62,9 +64,7 @@ export const MemeCard: React.FC<MemeCardProps> = ({ template, captions, image, c
|
||||
{template === 'clown' && <ClownMeme captions={captionList} />}
|
||||
{template === 'expanding' && <ExpandingBrainMeme captions={captionList} />}
|
||||
{template === 'distracted' && <DistractedMeme captions={captionList} />}
|
||||
{!['drake', 'ds', 'gru', 'fine', 'clown', 'expanding', 'distracted'].includes(template) && (
|
||||
<GenericMeme captions={captionList} template={template} />
|
||||
)}
|
||||
<GenericMeme captions={captionList} template={template} />
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ShareModalProps {
|
||||
url: string;
|
||||
qrCodeData?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
diagramImage?: string;
|
||||
}
|
||||
|
||||
@@ -21,6 +22,7 @@ export function ShareModal({
|
||||
url,
|
||||
qrCodeData,
|
||||
title,
|
||||
description,
|
||||
diagramImage,
|
||||
}: ShareModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -47,6 +49,9 @@ export function ShareModal({
|
||||
if (ogImage || ogTitle || ogDesc) {
|
||||
setOgData({ image: ogImage, title: ogTitle, description: ogDesc });
|
||||
}
|
||||
} else {
|
||||
// Force clear OG data if we HAVE a diagramImage, to prevent fallback flashes
|
||||
setOgData(null);
|
||||
}
|
||||
}, [isOpen, diagramImage, qrCodeData, title]);
|
||||
|
||||
@@ -63,13 +68,6 @@ export function ShareModal({
|
||||
const svgUrl = URL.createObjectURL(svgBlob);
|
||||
setImagePreview(svgUrl);
|
||||
}
|
||||
|
||||
// Optional: If we want to strictly apply the watermark via Canvas, we would do it here.
|
||||
// But for the sake of getting the preview to work reliably first, just setting the imagePreview
|
||||
// directly to the data URL or SVG blob URL is the safest approach. The watermark logic was
|
||||
// likely failing because `IconBlack` wasn't resolving correctly or `img.onload` wasn't firing
|
||||
// properly in all environments.
|
||||
|
||||
}
|
||||
}, [diagramImage, isOpen]);
|
||||
|
||||
@@ -83,7 +81,8 @@ export function ShareModal({
|
||||
if (navigator.share) {
|
||||
try {
|
||||
const shareData: ShareData = {
|
||||
title: title || "Mintel Diagramm",
|
||||
title: title || "Mintel Architektur Insights",
|
||||
text: description || "Schauen Sie sich diesen Beitrag an.",
|
||||
url: url,
|
||||
};
|
||||
|
||||
@@ -125,12 +124,13 @@ export function ShareModal({
|
||||
};
|
||||
|
||||
const handleShareX = () => {
|
||||
const text = encodeURIComponent(title || "Mintel Diagramm");
|
||||
const text = encodeURIComponent(`${description ? description + " " : ""}${title || "Mintel Architektur Insights"}`);
|
||||
const shareUrl = `https://twitter.com/intent/tweet?text=${text}&url=${encodeURIComponent(url)}`;
|
||||
window.open(shareUrl, "_blank", "width=550,height=420");
|
||||
};
|
||||
|
||||
const handleShareLinkedIn = () => {
|
||||
// LinkedIn share doesn't accept text directly via URL for personal profiles easily, but we pass url
|
||||
const shareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`;
|
||||
window.open(shareUrl, "_blank", "width=550,height=420");
|
||||
};
|
||||
@@ -181,7 +181,7 @@ export function ShareModal({
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt={title || "Diagram"}
|
||||
className="w-full max-h-[50vh] object-contain transition-transform duration-700 rounded-xl"
|
||||
className="w-full max-h-[30vh] object-contain transition-transform duration-700 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -288,11 +288,11 @@ export function ShareModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 pt-2">
|
||||
<div className="flex items-center justify-between gap-2 pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleShareX}
|
||||
className="w-12 h-12 bg-black text-white rounded-xl flex items-center justify-center hover:bg-slate-900 transition-all border border-slate-800 active:scale-95"
|
||||
className="w-12 h-12 bg-black text-white rounded-xl flex items-center justify-center hover:bg-slate-900 transition-all border border-slate-800 active:scale-95 shadow-sm"
|
||||
title="Auf X teilen"
|
||||
>
|
||||
<svg
|
||||
@@ -306,7 +306,7 @@ export function ShareModal({
|
||||
|
||||
<button
|
||||
onClick={handleShareLinkedIn}
|
||||
className="w-12 h-12 bg-[#0A66C2] text-white rounded-xl flex items-center justify-center hover:bg-[#004182] transition-all active:scale-95"
|
||||
className="w-12 h-12 bg-[#0A66C2] text-white rounded-xl flex items-center justify-center hover:bg-[#004182] transition-all active:scale-95 shadow-sm"
|
||||
title="Auf LinkedIn teilen"
|
||||
>
|
||||
<svg
|
||||
@@ -321,10 +321,10 @@ export function ShareModal({
|
||||
{typeof navigator !== "undefined" && !!navigator.share && (
|
||||
<button
|
||||
onClick={handleNativeShare}
|
||||
className="w-12 h-12 bg-white border border-slate-200 text-slate-900 rounded-xl flex items-center justify-center hover:bg-slate-50 transition-all active:scale-95"
|
||||
className="w-12 h-12 bg-white border border-slate-200 text-slate-900 rounded-xl flex items-center justify-center hover:bg-slate-50 transition-all active:scale-95 shadow-sm"
|
||||
title="System-Dialog öffnen"
|
||||
>
|
||||
<Share2 size={20} className="text-slate-400" />
|
||||
<Share2 size={20} className="text-slate-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -332,10 +332,10 @@ export function ShareModal({
|
||||
{imagePreview && (
|
||||
<button
|
||||
onClick={handleDownloadImage}
|
||||
className="flex-1 w-full p-3 bg-slate-100 text-slate-900 rounded-xl font-bold flex items-center justify-center gap-3 hover:bg-slate-200 transition-all border border-slate-200 active:scale-[0.98]"
|
||||
className="w-12 h-12 bg-slate-100 text-slate-600 rounded-xl flex items-center justify-center hover:bg-slate-200 transition-all active:scale-95 border border-slate-200 shrink-0"
|
||||
title="Als PNG herunterladen"
|
||||
>
|
||||
<Download size={18} />
|
||||
<span className="text-xs">Als Bild speichern (PNG)</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,6 @@ export function YouTubeEmbed({ videoId, title, className = "" }: YouTubeEmbedPro
|
||||
className="absolute inset-0 w-full h-full"
|
||||
src={`https://www.youtube-nocookie.com/embed/${videoId}?rel=0&modestbranding=1`}
|
||||
title={title || "YouTube video player"}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
136
apps/web/src/components/simulations/ArchitectureBuilder.tsx
Normal file
136
apps/web/src/components/simulations/ArchitectureBuilder.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ComponentShareButton } from '../ComponentShareButton';
|
||||
import { Reveal } from '../Reveal';
|
||||
import { Check, X, Shield, Zap, Expand, Database } from 'lucide-react';
|
||||
|
||||
export function ArchitectureBuilder({ className = '' }: { className?: string }) {
|
||||
const [stack, setStack] = useState<'saas' | 'custom'>('saas');
|
||||
|
||||
return (
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<div className={`not-prose max-w-4xl mx-auto my-12 relative group ${className}`}>
|
||||
<div className={`absolute -inset-1 blur opacity-30 rounded-3xl transition-all duration-700 ${stack === 'saas' ? 'bg-gradient-to-r from-orange-100 to-red-100' : 'bg-gradient-to-r from-emerald-100 to-blue-100'}`} />
|
||||
|
||||
<div id="sim-arch-builder" className="relative bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden flex flex-col">
|
||||
<div data-share-wrapper="true" className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity z-50">
|
||||
<ComponentShareButton
|
||||
targetId="sim-arch-builder"
|
||||
title="Architektur Vergleich: Miete vs Eigentum"
|
||||
shareText="Sehen Sie sich diesen Vergleich zwischen Software-Miete und eigenem System an:"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-2xl">🏗️</span>
|
||||
<h3 className="font-bold text-slate-900 m-0">Architektur-Vergleich: Mieten vs. Bauen</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 m-0 leading-relaxed max-w-2xl">
|
||||
Entdecken Sie die strukturellen Unterschiede zwischen einem "Out-of-the-Box" Baukasten und einer maßgeschneiderten Architektur für Ihr Unternehmen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row">
|
||||
{/* Control Panel */}
|
||||
<div className="w-full md:w-1/3 p-6 bg-slate-50 md:border-r border-b md:border-b-0 border-slate-100 space-y-4">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-widest block mb-4">Ihr System-Ansatz</label>
|
||||
|
||||
<button
|
||||
onClick={() => setStack('saas')}
|
||||
className={`w-full text-left p-4 rounded-xl border-2 transition-all ${stack === 'saas' ? 'border-orange-500 bg-white shadow-sm' : 'border-transparent hover:bg-slate-100'}`}
|
||||
>
|
||||
<div className="font-bold text-slate-900 text-sm">🏡 Software Mieten (SaaS)</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Baukästen, Plugins, fixe Lizenzen</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setStack('custom')}
|
||||
className={`w-full text-left p-4 rounded-xl border-2 transition-all ${stack === 'custom' ? 'border-emerald-500 bg-white shadow-sm' : 'border-transparent hover:bg-slate-100'}`}
|
||||
>
|
||||
<div className="font-bold text-slate-900 text-sm">🏰 System Bauen (Custom)</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Eigenes IP, Headless, Skalierbar</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Visualization Panel */}
|
||||
<div className="w-full md:w-2/3 p-6 md:p-8 bg-white min-h-[400px] flex flex-col justify-center">
|
||||
|
||||
{stack === 'saas' ? (
|
||||
<div className="animate-in fade-in slide-in-from-right-4 duration-500">
|
||||
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||
<div className="p-4 rounded-xl border border-orange-200 bg-orange-50/50 relative overflow-hidden">
|
||||
<div className="text-3xl mb-2">📦</div>
|
||||
<div className="font-bold text-sm text-orange-900">Standard-Features</div>
|
||||
<div className="text-xs text-orange-700/70 mt-1 hidden sm:block">Sie zahlen auch für 80%, die Sie nicht nutzen.</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl border border-red-200 bg-red-50/50 relative overflow-hidden">
|
||||
<div className="absolute top-2 right-2 flex gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-red-400 animate-pulse" />
|
||||
</div>
|
||||
<div className="text-3xl mb-2">🔌</div>
|
||||
<div className="font-bold text-sm text-red-900">Plugin-Chaos</div>
|
||||
<div className="text-xs text-red-700/70 mt-1 hidden sm:block">Jedes Update ist ein Sicherheitsrisiko.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm text-slate-600">
|
||||
<div className="w-6 h-6 rounded-full bg-red-100 text-red-600 flex items-center justify-center shrink-0 border border-red-200"><X size={12} strokeWidth={3} /></div>
|
||||
<span>Abhängigkeit von Preiserhöhungen</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-slate-600">
|
||||
<div className="w-6 h-6 rounded-full bg-red-100 text-red-600 flex items-center justify-center shrink-0 border border-red-200"><X size={12} strokeWidth={3} /></div>
|
||||
<span>Kein Aufbau von eigenem Firmenwert (IP)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-slate-600">
|
||||
<div className="w-6 h-6 rounded-full bg-orange-100 text-orange-600 flex items-center justify-center shrink-0 border border-orange-200"><Zap size={12} /></div>
|
||||
<span>Träge Ladezeiten durch geteilte Server</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-in fade-in slide-in-from-right-4 duration-500">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
|
||||
<div className="p-4 rounded-xl border border-emerald-200 bg-emerald-50/50 relative">
|
||||
<div className="text-emerald-500 mb-2"><Zap size={24} /></div>
|
||||
<div className="font-bold text-sm text-emerald-900">Edge Content</div>
|
||||
<div className="text-[10px] text-emerald-700/70 mt-1">Instante globale Ladezeiten.</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl border border-blue-200 bg-blue-50/50 relative">
|
||||
<div className="text-blue-500 mb-2"><Database size={24} /></div>
|
||||
<div className="font-bold text-sm text-blue-900">Headless API</div>
|
||||
<div className="text-[10px] text-blue-700/70 mt-1">Daten systemunabhängig.</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl border border-slate-200 bg-slate-50 relative">
|
||||
<div className="text-slate-500 mb-2"><Expand size={24} /></div>
|
||||
<div className="font-bold text-sm text-slate-900">Custom UI</div>
|
||||
<div className="text-[10px] text-slate-500 mt-1">100% Passform für Sie.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm text-slate-600">
|
||||
<div className="w-6 h-6 rounded-full bg-emerald-100 text-emerald-600 flex items-center justify-center shrink-0 border border-emerald-200"><Check size={12} strokeWidth={3} /></div>
|
||||
<span>0€ monatliche Fixkosten für Lizenzen</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-slate-600">
|
||||
<div className="w-6 h-6 rounded-full bg-emerald-100 text-emerald-600 flex items-center justify-center shrink-0 border border-emerald-200"><Check size={12} strokeWidth={3} /></div>
|
||||
<span>Sie besitzen den Code = Echter Firmenwertaufbau</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-slate-600">
|
||||
<div className="w-6 h-6 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center shrink-0 border border-blue-200"><Shield size={12} /></div>
|
||||
<span>Maximale Security (keine Standard-Plugins als Einfallstor)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
156
apps/web/src/components/simulations/DigitalAssetVisualizer.tsx
Normal file
156
apps/web/src/components/simulations/DigitalAssetVisualizer.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ComponentShareButton } from '../ComponentShareButton';
|
||||
import { Reveal } from '../Reveal';
|
||||
import { TrendingUp, Coins, PiggyBank, Briefcase } from 'lucide-react';
|
||||
|
||||
export function DigitalAssetVisualizer({ className = '' }: { className?: string }) {
|
||||
const [view, setView] = useState<'liability' | 'asset'>('asset');
|
||||
|
||||
return (
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<div className={`not-prose max-w-4xl mx-auto my-12 relative group ${className}`}>
|
||||
<div className={`absolute -inset-1 blur opacity-30 rounded-3xl transition-all duration-700 ${view === 'liability' ? 'bg-gradient-to-r from-red-100 to-orange-100' : 'bg-gradient-to-r from-indigo-100 to-emerald-100'}`} />
|
||||
|
||||
<div id="sim-digital-asset" className="relative bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden flex flex-col">
|
||||
<div data-share-wrapper="true" className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity z-50">
|
||||
<ComponentShareButton
|
||||
targetId="sim-digital-asset"
|
||||
title="Digitales Asset vs. Verbindlichkeit"
|
||||
shareText="Verstehen Sie den Unterschied zwischen Software-Miete und echtem digitalen Eigentum:"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-2xl">💎</span>
|
||||
<h3 className="font-bold text-slate-900 m-0">Software: Ausgabe oder Investment?</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 m-0 leading-relaxed max-w-2xl">
|
||||
Visualisieren Sie den Unterschied zwischen Software, die monatlich Geld verbrennt (SaaS-Miete), und Software, die als Firmenwert bilanziert werden kann (Eigenbau).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row">
|
||||
{/* Control Panel */}
|
||||
<div className="w-full md:w-1/3 p-6 bg-slate-50 md:border-r border-b md:border-b-0 border-slate-100 space-y-4">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-widest block mb-4">Wirtschaftliche Sicht</label>
|
||||
|
||||
<button
|
||||
onClick={() => setView('liability')}
|
||||
className={`w-full flex items-center gap-3 text-left p-4 rounded-xl border-2 transition-all ${view === 'liability' ? 'border-red-400 bg-white shadow-sm' : 'border-transparent hover:bg-slate-100'}`}
|
||||
>
|
||||
<div className={`p-2 rounded-lg ${view === 'liability' ? 'bg-red-100 text-red-600' : 'bg-slate-200 text-slate-500'}`}>
|
||||
<Coins size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-slate-900 text-sm">Verbindlichkeit (SaaS)</div>
|
||||
<div className="text-[10px] text-slate-500 mt-0.5">Laufende Kosten, 0% Eigentum</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setView('asset')}
|
||||
className={`w-full flex items-center gap-3 text-left p-4 rounded-xl border-2 transition-all ${view === 'asset' ? 'border-indigo-400 bg-white shadow-sm' : 'border-transparent hover:bg-slate-100'}`}
|
||||
>
|
||||
<div className={`p-2 rounded-lg ${view === 'asset' ? 'bg-indigo-100 text-indigo-600' : 'bg-slate-200 text-slate-500'}`}>
|
||||
<Briefcase size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-slate-900 text-sm">Digitales Asset (Custom)</div>
|
||||
<div className="text-[10px] text-slate-500 mt-0.5">Firmenwertaufbau, 100% IP</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Visualization Panel */}
|
||||
<div className="w-full md:w-2/3 p-6 md:p-8 bg-white min-h-[350px] flex flex-col justify-center">
|
||||
|
||||
{view === 'liability' ? (
|
||||
<div className="animate-in fade-in slide-in-from-right-4 duration-500">
|
||||
<div className="flex flex-col items-center justify-center space-y-6 mb-8 mt-4">
|
||||
|
||||
{/* Money Burn Animation */}
|
||||
<div className="relative w-32 h-32 flex items-center justify-center">
|
||||
{/* Fire background blob */}
|
||||
<div className="absolute inset-4 bg-orange-400 rounded-full blur-xl opacity-20 animate-pulse" />
|
||||
|
||||
{/* Money flying away */}
|
||||
<div className="absolute top-0 right-0 animate-bounce delay-75 text-red-500 opacity-50">-€</div>
|
||||
<div className="absolute top-4 left-4 animate-bounce delay-150 text-red-500 opacity-40">-€</div>
|
||||
<div className="absolute bottom-8 right-8 animate-bounce delay-300 text-red-500 opacity-60">-€</div>
|
||||
|
||||
<div className="w-20 h-20 bg-red-50 border border-red-200 text-red-500 rounded-full flex items-center justify-center text-3xl shadow-sm z-10">
|
||||
💸
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-bold text-red-900">Der ewige Geldabfluss</div>
|
||||
<p className="text-xs text-red-700 mt-2 max-w-[250px] mx-auto opacity-80">
|
||||
Jeden Monat fließt Kapital an externe Software-Anbieter ab. Kündigen Sie, ist Ihr System sofort offline.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 rounded-xl p-4 border border-slate-200 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">Unternehmenswert</div>
|
||||
<div className="font-bold text-slate-900">0,00 €</div>
|
||||
<div className="text-[10px] text-slate-500 mt-0.5">Software gehört Anbieter</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">Skalierungs-Kosten</div>
|
||||
<div className="font-bold text-red-600 flex items-center gap-1">Linear <TrendingUp size={12} /></div>
|
||||
<div className="text-[10px] text-slate-500 mt-0.5">Mehr User = Mehr Kosten</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-in fade-in slide-in-from-right-4 duration-500">
|
||||
<div className="flex flex-col items-center justify-center space-y-6 mb-8 mt-4">
|
||||
|
||||
{/* Asset Building Animation */}
|
||||
<div className="relative w-32 h-32 flex items-center justify-center">
|
||||
{/* Glow background blob */}
|
||||
<div className="absolute inset-4 bg-indigo-400 rounded-full blur-xl opacity-20" />
|
||||
|
||||
{/* Value accumulating */}
|
||||
<div className="absolute -top-2 left-1/2 -ml-3 animate-pulse text-indigo-500 text-xl font-bold">+</div>
|
||||
|
||||
<div className="w-20 h-20 bg-indigo-50 border border-indigo-200 text-indigo-600 rounded-2xl flex items-center justify-center shadow-sm z-10 rotate-3 transition-transform hover:rotate-6 hover:scale-105">
|
||||
<PiggyBank size={32} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-bold text-indigo-900">Das aktivierbare Asset</div>
|
||||
<p className="text-xs text-indigo-700 mt-2 max-w-[280px] mx-auto opacity-80">
|
||||
Maßgeschneiderte Software gehört zu 100% Ihnen. Das Intellectual Property (IP) mehrt den Wert Ihrer GmbH.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 rounded-xl p-4 border border-slate-200 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">Unternehmenswert</div>
|
||||
<div className="font-bold text-emerald-600 flex items-center gap-1">Steigt aktiv <TrendingUp size={12} /></div>
|
||||
<div className="text-[10px] text-slate-500 mt-0.5">Kann bilanziert werden</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">Skalierungs-Kosten</div>
|
||||
<div className="font-bold text-slate-900">Flach (0€ Lizenzen)</div>
|
||||
<div className="text-[10px] text-slate-500 mt-0.5">Nur reine Serverkosten</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
201
apps/web/src/components/simulations/LoadTimeSimulator.tsx
Normal file
201
apps/web/src/components/simulations/LoadTimeSimulator.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ComponentShareButton } from '../ComponentShareButton';
|
||||
import { Reveal } from '../Reveal';
|
||||
import { Play, RotateCcw } from 'lucide-react';
|
||||
|
||||
export function LoadTimeSimulator({ className = '' }: { className?: string }) {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [timeElapsed, setTimeElapsed] = useState(0);
|
||||
const [legacyState, setLegacyState] = useState(0);
|
||||
const [mintelState, setMintelState] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (isRunning && timeElapsed < 7500) {
|
||||
interval = setInterval(() => {
|
||||
setTimeElapsed(prev => {
|
||||
const next = prev + 50;
|
||||
|
||||
// Legacy Loading Logic (stuttering, slow, layout shifts)
|
||||
if (next >= 1200 && next < 3000) setLegacyState(1); // Header only
|
||||
else if (next >= 3000 && next < 5500) setLegacyState(2); // Text appears
|
||||
else if (next >= 5500 && next < 7000) setLegacyState(3); // Image loads (CLS)
|
||||
else if (next >= 7000) setLegacyState(4); // Finally interactive
|
||||
|
||||
// Mintel Loading Logic (Instant Edge Delivery)
|
||||
if (next >= 350) setMintelState(1); // Fully loaded instantly
|
||||
|
||||
return next;
|
||||
});
|
||||
}, 50);
|
||||
} else if (timeElapsed >= 7500) {
|
||||
setIsRunning(false);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [isRunning, timeElapsed]);
|
||||
|
||||
const startRace = () => {
|
||||
setTimeElapsed(0);
|
||||
setLegacyState(0);
|
||||
setMintelState(0);
|
||||
setIsRunning(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<div className={`not-prose max-w-4xl mx-auto my-12 relative group ${className}`}>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-red-100 to-emerald-100 rounded-3xl blur opacity-30" />
|
||||
|
||||
<div id="sim-load-time" className="relative bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden flex flex-col">
|
||||
<div data-share-wrapper="true" className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity z-50">
|
||||
<ComponentShareButton targetId="sim-load-time" title="Ladezeit Simulator" />
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-b border-slate-200 bg-slate-50 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xl">🏎️</span>
|
||||
<h3 className="font-bold text-slate-900 m-0">Architektur-Rennen: Legacy CMS vs. Mintel Stack</h3>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 m-0 leading-relaxed">
|
||||
Simulieren Sie den Unterschied zwischen dynamischem Server-Rendering (PHP/MySQL) und statischer Edge-Auslieferung (<span className="font-mono bg-slate-200 px-1 rounded text-[10px]">TTV < 500ms</span>).
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={startRace}
|
||||
className="shrink-0 flex items-center gap-2 px-6 py-2.5 bg-slate-900 !text-white rounded-full font-bold text-sm hover:hover:bg-black hover:scale-105 active:scale-95 transition-all shadow-md"
|
||||
>
|
||||
{timeElapsed > 0 ? <RotateCcw size={16} /> : <Play size={16} />}
|
||||
{timeElapsed > 0 ? "Neustart" : "Rennen Starten"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-slate-100 bg-slate-50/50">
|
||||
|
||||
{/* LEGACY LANE */}
|
||||
<div className="p-6 flex flex-col items-center">
|
||||
<div className="w-full flex justify-between items-center mb-6">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Legacy Monolith</span>
|
||||
<span className={`font-mono font-bold ${legacyState === 4 ? 'text-red-500' : 'text-slate-400'}`}>
|
||||
{legacyState === 4 ? "7.00s" : (timeElapsed / 1000).toFixed(2) + "s"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Browser Mockup */}
|
||||
<div className="w-full max-w-[280px] aspect-[3/4] bg-white border border-slate-200 rounded-lg shadow-sm overflow-hidden flex flex-col">
|
||||
<div className="h-6 bg-slate-100 border-b border-slate-200 flex items-center px-2 gap-1.5 shrink-0">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-300" />
|
||||
<div className="w-2 h-2 rounded-full bg-slate-300" />
|
||||
<div className="w-2 h-2 rounded-full bg-slate-300" />
|
||||
</div>
|
||||
<div className="p-4 flex-1 flex flex-col relative">
|
||||
{/* Spinner */}
|
||||
{legacyState === 0 && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-slate-300 gap-2">
|
||||
<div className="w-6 h-6 border-2 border-slate-200 border-t-slate-400 rounded-full animate-spin" />
|
||||
<span className="text-[10px] font-mono tracking-widest uppercase">TTFB Waiting...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content that shuffles around to simulate CLS */}
|
||||
<div className={`transition-opacity duration-300 ${legacyState >= 1 ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<div className="h-4 w-3/4 bg-slate-200 rounded mb-4" /> {/* Header */}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Simulate Layout Shift:
|
||||
Text loads first (state 2), then at state 3 an image drops in above it, pushing the text down.
|
||||
*/}
|
||||
<div className="flex flex-col gap-3 transition-opacity duration-300 mt-2">
|
||||
{legacyState >= 3 && (
|
||||
<div className="h-24 w-full bg-slate-200 relative overflow-hidden rounded animate-pulse">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-2xl opacity-50">🖼️</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`space-y-2 transition-transform duration-300 ${legacyState >= 2 ? 'opacity-100' : 'opacity-0'} ${legacyState === 3 ? 'translate-y-2' : ''}`}>
|
||||
<div className="h-2 w-full bg-slate-100 rounded" />
|
||||
<div className="h-2 w-5/6 bg-slate-100 rounded" />
|
||||
<div className="h-2 w-4/6 bg-slate-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{legacyState === 4 && (
|
||||
<div className="absolute bottom-4 left-4 right-4 animate-in slide-in-from-bottom-2 fade-in">
|
||||
<div className="h-8 bg-slate-900 rounded flex items-center justify-center">
|
||||
<span className="text-[10px] font-bold text-white uppercase">Interaktiv (Hydrated)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6 mt-4 flex items-center">
|
||||
{legacyState === 0 && timeElapsed > 0 && <span className="text-[10px] text-slate-400 uppercase tracking-widest animate-pulse">Waiting for Server...</span>}
|
||||
{legacyState === 1 && <span className="text-[10px] text-amber-500 uppercase tracking-widest">Parsing HTML...</span>}
|
||||
{legacyState === 2 && <span className="text-[10px] text-amber-500 uppercase tracking-widest">Downloading Assets...</span>}
|
||||
{legacyState === 3 && <span className="text-[10px] text-red-500 font-bold uppercase tracking-widest">Layout Shift Detected!</span>}
|
||||
{legacyState === 4 && <span className="text-[10px] text-red-500 font-bold uppercase tracking-widest">Finished in 7.0s</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MINTEL LANE */}
|
||||
<div className="p-6 flex flex-col items-center border-t md:border-t-0 border-slate-100">
|
||||
<div className="w-full flex justify-between items-center mb-6">
|
||||
<span className="text-[10px] font-bold text-emerald-500 uppercase tracking-widest">Mintel Stack (Edge)</span>
|
||||
<span className={`font-mono font-bold ${mintelState === 1 ? 'text-emerald-500' : 'text-slate-400'}`}>
|
||||
{mintelState === 1 ? "0.35s" : (timeElapsed / 1000).toFixed(2) + "s"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Browser Mockup */}
|
||||
<div className="w-full max-w-[280px] aspect-[3/4] bg-white border-2 border-emerald-100 rounded-lg shadow-sm overflow-hidden flex flex-col relative ring-4 ring-emerald-50 ring-opacity-50 transition-all duration-500">
|
||||
<div className="h-6 bg-emerald-50 border-b border-emerald-100 flex items-center px-2 gap-1.5 shrink-0">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-200" />
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-200" />
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-200" />
|
||||
</div>
|
||||
<div className="p-4 flex-1 flex flex-col relative">
|
||||
|
||||
{mintelState === 0 && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-emerald-300 gap-2">
|
||||
<span className="text-[10px] font-mono tracking-widest uppercase">CDN Hit...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instant Load: Everything drops in perfectly immediately */}
|
||||
<div className={`transition-opacity duration-300 h-full flex flex-col ${mintelState === 1 ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<div className="h-4 w-3/4 bg-emerald-100 rounded mb-4 shrink-0" />
|
||||
|
||||
<div className="h-24 w-full bg-emerald-50 relative overflow-hidden rounded mb-3 shrink-0 flex items-center justify-center border border-emerald-100">
|
||||
<span className="text-2xl opacity-80">🚀</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 shrink-0">
|
||||
<div className="h-2 w-full bg-slate-100 rounded" />
|
||||
<div className="h-2 w-5/6 bg-slate-100 rounded" />
|
||||
<div className="h-2 w-4/6 bg-slate-100 rounded" />
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-4 shrink-0">
|
||||
<div className="h-8 bg-emerald-500 rounded flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||
<span className="text-[10px] font-bold text-white uppercase">Instant Interactive</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6 mt-4 flex items-center">
|
||||
{mintelState === 0 && timeElapsed > 0 && <span className="text-[10px] text-emerald-400 uppercase tracking-widest">Routing to Edge...</span>}
|
||||
{mintelState === 1 && <span className="text-[10px] text-emerald-500 font-bold uppercase tracking-widest">Loaded & Ready in 350ms</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
124
apps/web/src/components/simulations/PerformanceROICalculator.tsx
Normal file
124
apps/web/src/components/simulations/PerformanceROICalculator.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ComponentShareButton } from '../ComponentShareButton';
|
||||
import { Reveal } from '../Reveal';
|
||||
|
||||
export function PerformanceROICalculator({ className = '' }: { className?: string }) {
|
||||
const [traffic, setTraffic] = useState(100); // 1000 visitors/month (approx 30/day)
|
||||
const [aov, setAov] = useState(100); // e.g. average value of a craftsman quote or small B2B service
|
||||
const [loadTime, setLoadTime] = useState(2.0);
|
||||
const [baseConv, setBaseConv] = useState(2.0);
|
||||
|
||||
// B2B Deloitte metric: 0.1s improvement = 8.4% conversion relative lift
|
||||
// We cap the improvement to realistic bounds (e.g. optimizing to 1.5s)
|
||||
const targetLoadTime = 1.5;
|
||||
const secondsSaved = Math.max(0, loadTime - targetLoadTime);
|
||||
|
||||
// Relative lift calculation
|
||||
const relativeLiftPercentage = secondsSaved * 10 * 8.4;
|
||||
const newConv = baseConv * (1 + (relativeLiftPercentage / 100));
|
||||
|
||||
const currentRevenue = traffic * (baseConv / 100) * aov;
|
||||
const newRevenue = traffic * (newConv / 100) * aov;
|
||||
const additionalAnnualRevenue = (newRevenue - currentRevenue) * 12;
|
||||
|
||||
const formatCurrency = (val: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(val);
|
||||
|
||||
return (
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<div className={`not-prose max-w-4xl mx-auto my-12 relative group ${className}`}>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100 rounded-3xl blur opacity-25" />
|
||||
|
||||
<div id="sim-performance-roi" className="relative bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div data-share-wrapper="true" className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity z-50">
|
||||
<ComponentShareButton targetId="sim-performance-roi" title="Performance ROI Simulator" />
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8 bg-slate-50 border-b border-slate-200">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-2xl">⚡</span>
|
||||
<h3 className="font-bold text-slate-900 m-0">Performance ROI für den Mittelstand</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 m-0 leading-relaxed">
|
||||
Simulieren Sie, wie sich die Ladezeit auf die Jahresumsätze Ihres Betriebs (Handwerk, Kanzlei, B2B-Service) auswirkt. Basierend auf der Branchen-Erkenntnis: Schon 0.1s schnellere Ladezeit hebt die Kontakt-Wandlungsrate um bis zu 8,4%.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-5 divide-y md:divide-y-0 md:divide-x divide-slate-100">
|
||||
{/* Inputs */}
|
||||
<div className="md:col-span-2 p-6 md:p-8 space-y-8 bg-white">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-end">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">Ø Ladezeit (Sekunden)</label>
|
||||
<span className="font-mono font-bold text-red-500">{loadTime.toFixed(1)}s</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min="1.5" max="10" step="0.1"
|
||||
value={loadTime} onChange={(e) => setLoadTime(Number(e.target.value))}
|
||||
className="w-full accent-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-end">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">Monatliche Besucher</label>
|
||||
<span className="font-mono font-bold text-slate-700">{traffic.toLocaleString('de-DE')}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min="100" max="25000" step="100"
|
||||
value={traffic} onChange={(e) => setTraffic(Number(e.target.value))}
|
||||
className="w-full accent-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-end">
|
||||
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest">Ø Projektwert / Marge</label>
|
||||
<span className="font-mono font-bold text-slate-700">{formatCurrency(aov)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min="100" max="15000" step="100"
|
||||
value={aov} onChange={(e) => setAov(Number(e.target.value))}
|
||||
className="w-full accent-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outputs */}
|
||||
<div className="md:col-span-3 p-6 md:p-8 bg-slate-50 flex flex-col justify-center">
|
||||
<div className="grid grid-cols-2 gap-6 mb-8">
|
||||
<div className="space-y-1">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Status Quo Conv.</div>
|
||||
<div className="text-2xl font-mono text-slate-400">{baseConv.toFixed(2)}%</div>
|
||||
<div className="text-xs text-slate-400 font-mono mt-2">{formatCurrency(currentRevenue * 12)} / Jahr</div>
|
||||
</div>
|
||||
<div className="space-y-1 border-l pl-6 border-slate-200">
|
||||
<div className="text-[10px] font-bold text-green-500 uppercase tracking-widest">Ziel Konversion (1.5s)</div>
|
||||
<div className="text-2xl font-mono font-bold text-slate-900 flex items-center gap-2">
|
||||
{newConv.toFixed(2)}%
|
||||
{relativeLiftPercentage > 0 && <span className="text-xs font-bold text-green-500 bg-green-50 px-2 py-0.5 rounded-full">+{relativeLiftPercentage.toFixed(1)}%</span>}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600 font-mono mt-2">{formatCurrency(newRevenue * 12)} / Jahr</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-slate-900 rounded-xl relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-[0.03] pointer-events-none" style={{ backgroundImage: "radial-gradient(#ffffff 1px, transparent 0)", backgroundSize: "15px 15px" }} />
|
||||
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-emerald-400 uppercase tracking-widest mb-1">Potenzieller Mehrumsatz / Jahr</div>
|
||||
<div className="text-xs text-slate-400">Durch {loadTime.toFixed(1)}s Ladezeit vs. Industrie-Standard (1.5s) entgangen.</div>
|
||||
</div>
|
||||
<div className="text-3xl md:text-4xl font-mono font-black text-white">
|
||||
{additionalAnnualRevenue > 0 ? '+' : ''}{formatCurrency(additionalAnnualRevenue)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user