436 lines
14 KiB
TypeScript
436 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState, type ReactElement, type CSSProperties } from "react";
|
||
|
||
type DurationPoint = { dateISO: string; ms: number };
|
||
|
||
type SuccessStatsProps = {
|
||
displayName: string;
|
||
days: number;
|
||
percent: number;
|
||
totalDays: number;
|
||
currentDurationMs: number | null;
|
||
averageDurationMs: number | null;
|
||
bestDurationMs: number | null;
|
||
durationSeries: DurationPoint[];
|
||
measurementCount: number;
|
||
showShortPasswordAlert: boolean;
|
||
shortPasswordMessage: string;
|
||
metricSuffix: string;
|
||
currentSubtitle: string;
|
||
chartTitle: string;
|
||
isPasswordShort: boolean;
|
||
passwordPolicyViolation: boolean;
|
||
passwordLength: number;
|
||
passwordHasUpper: boolean;
|
||
passwordHasLower: boolean;
|
||
passwordHasDigit: boolean;
|
||
passwordHasSpecial: boolean;
|
||
policyMinLength: number;
|
||
};
|
||
|
||
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
timeZone: "UTC",
|
||
});
|
||
|
||
const disqualStampWrapperStyle: CSSProperties = {
|
||
position: "absolute",
|
||
top: "24px",
|
||
left: "50%",
|
||
transform: "translateX(-50%) rotate(-12deg)",
|
||
pointerEvents: "none",
|
||
zIndex: 50,
|
||
};
|
||
|
||
const disqualStampStyle: CSSProperties = {
|
||
position: "relative",
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
minWidth: "360px",
|
||
padding: "18px 54px",
|
||
border: "5px solid rgba(239,68,68,0.95)",
|
||
background: "rgba(220,38,38,0.18)",
|
||
color: "#fee2e2",
|
||
fontSize: "1.25rem",
|
||
fontWeight: 900,
|
||
letterSpacing: "0.58em",
|
||
textTransform: "uppercase",
|
||
textShadow: "0 16px 38px rgba(220,38,38,0.6)",
|
||
boxShadow: "0 20px 46px rgba(220,38,38,0.4)",
|
||
whiteSpace: "nowrap",
|
||
};
|
||
|
||
const disqualStampInnerStyle: CSSProperties = {
|
||
position: "absolute",
|
||
inset: "6px",
|
||
border: "2px dashed rgba(248,113,113,0.7)",
|
||
};
|
||
|
||
export default function SuccessStats({
|
||
displayName,
|
||
days,
|
||
percent,
|
||
totalDays,
|
||
currentDurationMs,
|
||
averageDurationMs,
|
||
bestDurationMs,
|
||
durationSeries,
|
||
measurementCount,
|
||
showShortPasswordAlert,
|
||
shortPasswordMessage,
|
||
metricSuffix,
|
||
currentSubtitle,
|
||
chartTitle,
|
||
isPasswordShort,
|
||
passwordPolicyViolation,
|
||
passwordLength,
|
||
passwordHasUpper,
|
||
passwordHasLower,
|
||
passwordHasDigit,
|
||
passwordHasSpecial,
|
||
policyMinLength,
|
||
}: SuccessStatsProps): ReactElement {
|
||
const [dAnim, setDAnim] = useState(0);
|
||
const [pAnim, setPAnim] = useState(0);
|
||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
animateTo(days, setDAnim, 800);
|
||
animateTo(percent, setPAnim, 900);
|
||
cardRef.current?.animate(
|
||
[
|
||
{ boxShadow: "0 10px 32px rgba(0,0,0,.45)" },
|
||
{ boxShadow: "0 16px 40px rgba(89,240,168,.18)" },
|
||
{ boxShadow: "0 10px 32px rgba(0,0,0,.45)" },
|
||
],
|
||
{ duration: 800, easing: "ease-in-out" }
|
||
);
|
||
}, [days, percent]);
|
||
|
||
const hasMeasurements = measurementCount > 0;
|
||
const hasAverage = averageDurationMs !== null;
|
||
const currentTone = useMemo(() => {
|
||
if (!hasAverage || currentDurationMs === null) return "neutral" as const;
|
||
if (currentDurationMs > averageDurationMs!) return "slower" as const;
|
||
if (currentDurationMs < averageDurationMs!) return "faster" as const;
|
||
return "neutral" as const;
|
||
}, [hasAverage, currentDurationMs, averageDurationMs]);
|
||
const chartStats = useMemo(() => {
|
||
if (!hasMeasurements) return null;
|
||
const values = durationSeries.map((point) => point.ms);
|
||
const max = Math.max(...values);
|
||
const min = Math.min(...values);
|
||
return { max, min };
|
||
}, [durationSeries, hasMeasurements]);
|
||
|
||
const passwordChecks = useMemo(() => {
|
||
const lengthOk = passwordLength >= policyMinLength;
|
||
const upperOk = passwordHasUpper;
|
||
const lowerOk = passwordHasLower;
|
||
const digitOk = passwordHasDigit;
|
||
const specialOk = passwordHasSpecial;
|
||
return {
|
||
lengthOk,
|
||
upperOk,
|
||
lowerOk,
|
||
digitOk,
|
||
specialOk,
|
||
allOk: lengthOk && upperOk && lowerOk && digitOk && specialOk,
|
||
};
|
||
}, [passwordLength, passwordHasUpper, passwordHasLower, passwordHasDigit, passwordHasSpecial, policyMinLength]);
|
||
|
||
const stampNode = passwordPolicyViolation ? (
|
||
<div aria-hidden="true" style={disqualStampWrapperStyle}>
|
||
<div style={disqualStampStyle}>
|
||
<span style={disqualStampInnerStyle} />
|
||
<span style={{ position: "relative", display: "inline-block" }}>Disqualifiziert</span>
|
||
</div>
|
||
</div>
|
||
) : null;
|
||
|
||
const headerNode = (
|
||
<div className="dw-stack">
|
||
<h1
|
||
className={`text-3xl md:text-4xl font-extrabold tracking-wide ${passwordPolicyViolation ? "text-red-200" : ""}`}
|
||
style={{ color: passwordPolicyViolation ? undefined : "var(--dw-accent)" }}
|
||
>
|
||
{passwordPolicyViolation
|
||
? "Login erfolgreich, aber das Passwort ist nicht gut!"
|
||
: "Login erfolgreich!"}
|
||
</h1>
|
||
<p className={`dw-hint ${passwordPolicyViolation ? "text-red-200/80" : ""}`}>
|
||
{isPasswordShort ? "Dein Passwort ist zu kurz" : "Gut gemacht"},{" "}
|
||
<span className="font-mono glow-red">{displayName}</span>.
|
||
</p>
|
||
</div>
|
||
);
|
||
|
||
const alertNode = showShortPasswordAlert ? (
|
||
<div className="dw-alert dw-alert--warn" role="alert">
|
||
{shortPasswordMessage}
|
||
</div>
|
||
) : null;
|
||
|
||
const passwordTile = (
|
||
<div
|
||
className="dw-tile space-y-2"
|
||
style={{
|
||
textAlign: "left",
|
||
borderColor: passwordChecks.allOk ? undefined : "rgba(248,113,113,0.4)",
|
||
background: passwordChecks.allOk ? "linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.01))" : "rgba(248,113,113,0.08)",
|
||
}}
|
||
>
|
||
<div className="dw-tile-title">Passwort-Check</div>
|
||
<div className="mt-2 flex flex-col gap-2">
|
||
<SecurityRow
|
||
ok={passwordChecks.lengthOk}
|
||
label={`Passwort-Länge ≥ ${policyMinLength} Zeichen`}
|
||
/>
|
||
<SecurityRow ok={passwordChecks.upperOk} label="Großbuchstaben" />
|
||
<SecurityRow ok={passwordChecks.lowerOk} label="Kleinbuchstaben" />
|
||
<SecurityRow ok={passwordChecks.digitOk} label="Ziffern" />
|
||
<SecurityRow ok={passwordChecks.specialOk} label="Sonderzeichen" />
|
||
</div>
|
||
<p className="mt-4 text-xs uppercase tracking-widest text-emerald-100/60">
|
||
{passwordChecks.allOk
|
||
? "Starke Passwort-Anforderungen erfüllt."
|
||
: "Bitte verbessere dein Passwort, um alle Kriterien zu erfüllen."}
|
||
</p>
|
||
</div>
|
||
);
|
||
|
||
if (passwordPolicyViolation) {
|
||
return (
|
||
<div ref={cardRef} className="relative dw-stack dw-stack-lg" style={{ paddingTop: "120px" }}>
|
||
{stampNode}
|
||
{headerNode}
|
||
{alertNode}
|
||
<div className="dw-stats-layout">
|
||
{passwordTile}
|
||
</div>
|
||
<div className="flex items-center gap-2 pt-1">
|
||
<a href="/login" className="dw-btn dw-btn-ghost">Zurück zum Login</a>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div ref={cardRef} className="relative dw-stack dw-stack-lg">
|
||
{stampNode}
|
||
{headerNode}
|
||
{alertNode}
|
||
<div className="dw-stats-layout">
|
||
<div className="dw-tiles dw-stats-tiles">
|
||
<div className="dw-tile">
|
||
<div className="dw-tile-title">Letzte {totalDays} Tage</div>
|
||
<div className="dw-tile-number" aria-live="polite">{dAnim}</div>
|
||
<div className="dw-tile-sub">{dAnim === 1 ? "Tag mit Erfolg" : "Tage mit Erfolg"}</div>
|
||
</div>
|
||
<div className="dw-tile">
|
||
<div className="dw-tile-title">Rating</div>
|
||
<div className="dw-tile-number" aria-live="polite">{pAnim}%</div>
|
||
<div className="dw-tile-sub">Durchhaltequote</div>
|
||
</div>
|
||
<div className="dw-tile">
|
||
<div className="dw-tile-title">Aktueller Login</div>
|
||
<div
|
||
className={`dw-tile-number ${currentTone === "faster" ? "dw-tile-number--better" : ""}${currentTone === "slower" ? " dw-tile-number--slower" : ""}`}
|
||
aria-live="polite"
|
||
>
|
||
{renderDuration(currentDurationMs, metricSuffix)}
|
||
</div>
|
||
<div className="dw-tile-sub">{currentSubtitle}</div>
|
||
{bestDurationMs !== null && (
|
||
<div className="dw-tile-peer">Deine Bestzeit: {renderDuration(bestDurationMs, metricSuffix)}</div>
|
||
)}
|
||
</div>
|
||
{passwordTile}
|
||
</div>
|
||
<section className="dw-stack dw-stats-chart">
|
||
<header className="flex flex-wrap items-end gap-2">
|
||
<h2 className="text-base font-semibold uppercase tracking-wide text-emerald-200">
|
||
{chartTitle}
|
||
</h2>
|
||
<span className="text-xs text-emerald-100/70">
|
||
Werte in Sekunden{metricSuffix ? ` ${metricSuffix}` : ""},{" "}
|
||
{measurementCount === 1 ? "1 Messung" : `${measurementCount} Messungen`}
|
||
</span>
|
||
</header>
|
||
{hasMeasurements && chartStats ? (
|
||
<DurationChart
|
||
series={durationSeries}
|
||
minMs={chartStats.min}
|
||
maxMs={chartStats.max}
|
||
valueSuffix={metricSuffix}
|
||
/>
|
||
) : (
|
||
<p className="text-sm text-emerald-100/70">
|
||
Noch keine Messwerte vorhanden. Melde dich erneut an, um Daten zu sammeln.
|
||
</p>
|
||
)}
|
||
</section>
|
||
</div>
|
||
<div className="flex items-center gap-2 pt-1">
|
||
<a href="/login" className="dw-btn dw-btn-ghost">Zurück zum Login</a>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function animateTo(target: number, set: (v: number) => void, duration = 800): void {
|
||
const start = performance.now();
|
||
const from = 0;
|
||
const to = Math.max(0, Math.round(target));
|
||
const ease = (t: number): number => 1 - Math.pow(1 - t, 3);
|
||
function frame(now: number): void {
|
||
const p = Math.min(1, (now - start) / duration);
|
||
const val = Math.round(from + (to - from) * ease(p));
|
||
set(val);
|
||
if (p < 1) requestAnimationFrame(frame);
|
||
}
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
function formatDuration(ms: number | null): string {
|
||
if (ms === null || ms === undefined) return "–";
|
||
const seconds = ms / 1000;
|
||
if (seconds < 60) {
|
||
return `${seconds.toFixed(seconds < 10 ? 2 : 1)} s`;
|
||
}
|
||
const minutes = Math.floor(seconds / 60);
|
||
const rest = seconds - minutes * 60;
|
||
const restStr = rest >= 10 ? rest.toFixed(0) : rest.toFixed(1);
|
||
return `${minutes} min ${restStr} s`;
|
||
}
|
||
|
||
function renderDuration(ms: number | null, suffix = ""): ReactElement {
|
||
if (ms === null || ms === undefined) return <>–</>;
|
||
return (
|
||
<>
|
||
<span>{formatDuration(ms)}</span>
|
||
{suffix && <span className="ml-1 align-baseline text-xs uppercase tracking-widest">{suffix}</span>}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SecurityRow({ ok, label }: { ok: boolean; label: string }): ReactElement {
|
||
return (
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<span className={ok ? "text-emerald-300" : "text-red-300"}>{ok ? "✅" : "🚨"}</span>
|
||
<span className={ok ? "text-emerald-100" : "text-red-100"}>{label}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DurationChart({
|
||
series,
|
||
minMs,
|
||
maxMs,
|
||
valueSuffix,
|
||
}: {
|
||
series: DurationPoint[];
|
||
minMs: number;
|
||
maxMs: number;
|
||
valueSuffix: string;
|
||
}): ReactElement {
|
||
const padding = 8;
|
||
const width = 100;
|
||
const height = 80;
|
||
const usableWidth = width - padding * 2;
|
||
const usableHeight = height - padding * 2;
|
||
const range = Math.max(1, maxMs - minMs);
|
||
|
||
const points = series.map((entry, index) => {
|
||
const x = series.length === 1
|
||
? width / 2
|
||
: padding + (usableWidth * index) / (series.length - 1);
|
||
const y = height - (padding + ((entry.ms - minMs) / range) * usableHeight);
|
||
return { ...entry, x, y };
|
||
});
|
||
|
||
const polyline = points.length === 1
|
||
? `${points[0].x},${points[0].y}`
|
||
: points.map((p) => `${p.x},${p.y}`).join(" ");
|
||
|
||
return (
|
||
<div className="w-full overflow-hidden rounded-xl border border-emerald-400/20 bg-[#08111f] px-3 py-4">
|
||
<svg
|
||
viewBox={`0 0 ${width} ${height}`}
|
||
role="img"
|
||
aria-label="Verlauf der Login-Zeiten"
|
||
className="h-40 w-full"
|
||
>
|
||
<defs>
|
||
<linearGradient id="login-time-gradient" x1="0" x2="0" y1="0" y2="1">
|
||
<stop offset="0%" stopColor="rgba(16, 185, 129, 0.8)" />
|
||
<stop offset="100%" stopColor="rgba(16, 185, 129, 0.1)" />
|
||
</linearGradient>
|
||
</defs>
|
||
|
||
{points.length > 1 && (
|
||
<polygon
|
||
points={`${points
|
||
.map((p) => `${p.x},${p.y}`)
|
||
.join(" ")} ${points[points.length - 1].x},${height - padding} ${points[0].x},${height - padding}`}
|
||
fill="url(#login-time-gradient)"
|
||
opacity={0.35}
|
||
/>
|
||
)}
|
||
|
||
<line
|
||
x1={padding}
|
||
x2={width - padding}
|
||
y1={height - padding}
|
||
y2={height - padding}
|
||
stroke="rgba(74, 222, 128, 0.3)"
|
||
strokeWidth={0.8}
|
||
/>
|
||
|
||
<polyline
|
||
points={polyline}
|
||
fill="none"
|
||
stroke="rgba(16, 185, 129, 0.8)"
|
||
strokeWidth={1.8}
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
|
||
{points.map((p, idx) => (
|
||
<circle
|
||
key={`point-${idx}`}
|
||
cx={p.x}
|
||
cy={p.y}
|
||
r={2.4}
|
||
fill="rgba(45, 212, 191, 0.9)"
|
||
stroke="rgba(8, 145, 178, 0.8)"
|
||
strokeWidth={0.8}
|
||
>
|
||
<title>
|
||
{`${dateFormatter.format(new Date(p.dateISO))} · ${(p.ms / 1000).toFixed(2)} s${
|
||
valueSuffix ? ` ${valueSuffix}` : ""
|
||
}`}
|
||
</title>
|
||
</circle>
|
||
))}
|
||
</svg>
|
||
|
||
<div className="mt-2 flex items-center justify-between text-[0.7rem] uppercase tracking-wide text-emerald-100/60">
|
||
<span>
|
||
{(minMs / 1000).toFixed(2)} s{valueSuffix ? ` ${valueSuffix}` : ""}
|
||
</span>
|
||
<span>
|
||
Max {(maxMs / 1000).toFixed(2)} s{valueSuffix ? ` ${valueSuffix}` : ""}
|
||
</span>
|
||
</div>
|
||
<div className="mt-1 flex flex-wrap gap-2 text-[0.65rem] font-mono text-emerald-100/50">
|
||
{points.map((p, idx) => (
|
||
<span key={`label-${idx}`}>{dateFormatter.format(new Date(p.dateISO))}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|