feat: add TrafficProgressBar and Sparkline components

TrafficProgressBar with zone tints, shimmer animation, glow dot,
threshold markers, scale labels, unlimited/compact modes, and full
ARIA support. Sparkline SVG component ready for daily usage data.
This commit is contained in:
Fringg
2026-02-25 10:07:49 +03:00
parent 7e345fc7d0
commit eb1f788033
2 changed files with 220 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
import { useId } from 'react';
interface SparklineProps {
data: number[];
width?: number;
height?: number;
className?: string;
}
export default function Sparkline({
data,
width = 120,
height = 32,
className = '',
}: SparklineProps) {
const gradientId = useId();
if (data.length < 2) return null;
const max = Math.max(...data);
const min = Math.min(...data);
const range = max - min || 1;
const padding = 2;
const innerW = width - padding * 2;
const innerH = height - padding * 2;
const points = data.map((v, i) => {
const x = padding + (i / (data.length - 1)) * innerW;
const y = padding + innerH - ((v - min) / range) * innerH;
return `${x},${y}`;
});
const polyline = points.join(' ');
const lastX = padding + innerW;
const bottomY = padding + innerH;
const areaPath = `M${points[0]} ${points.slice(1).join(' ')} L${lastX},${bottomY} L${padding},${bottomY} Z`;
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={className}
fill="none"
aria-hidden="true"
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgb(var(--color-accent-400))" stopOpacity="0.3" />
<stop offset="100%" stopColor="rgb(var(--color-accent-400))" stopOpacity="0" />
</linearGradient>
</defs>
<path d={areaPath} fill={`url(#${gradientId})`} />
<polyline
points={polyline}
stroke="rgb(var(--color-accent-400))"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@@ -0,0 +1,157 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { getTrafficZone } from '../../utils/trafficZone';
interface TrafficProgressBarProps {
usedGb: number;
limitGb: number;
percent: number;
isUnlimited: boolean;
showScale?: boolean;
showThresholds?: boolean;
compact?: boolean;
}
const THRESHOLDS = [50, 75, 90];
export default function TrafficProgressBar({
limitGb,
percent,
isUnlimited,
showScale = false,
showThresholds = false,
compact = false,
}: TrafficProgressBarProps) {
const { t } = useTranslation();
const zone = useMemo(() => getTrafficZone(percent), [percent]);
const clampedPercent = Math.min(percent, 100);
const barHeight = compact ? 'h-2' : 'h-3';
if (isUnlimited) {
return (
<div className="relative" role="progressbar" aria-label={t('dashboard.unlimited')}>
<div className={`${barHeight} w-full overflow-hidden rounded-full bg-dark-800`}>
<div
className="h-full w-full animate-unlimited-flow rounded-full"
style={{
background:
'linear-gradient(90deg, rgb(var(--color-accent-400)), rgb(var(--color-accent-600)), rgb(var(--color-accent-400)))',
backgroundSize: '200% 100%',
}}
/>
</div>
{!compact && (
<div className="absolute -right-1 top-1/2 -translate-y-1/2" aria-hidden="true">
<div className="h-2.5 w-2.5 animate-unlimited-pulse rounded-full bg-accent-400" />
</div>
)}
</div>
);
}
return (
<div
className="relative"
role="progressbar"
aria-valuenow={Math.round(clampedPercent)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${t('subscription.traffic')}: ${clampedPercent.toFixed(1)}%`}
>
{/* Track with zone tint backgrounds */}
<div className={`relative ${barHeight} w-full overflow-hidden rounded-full bg-dark-800`}>
{/* Zone tint backgrounds */}
{showThresholds && (
<>
<div
className="absolute inset-y-0 left-1/2 opacity-[0.06]"
style={{
right: '25%',
background: 'rgb(var(--color-warning-500))',
}}
/>
<div
className="absolute inset-y-0 opacity-[0.06]"
style={{
left: '75%',
right: '10%',
background: 'rgb(var(--color-warning-600))',
}}
/>
<div
className="absolute inset-y-0 right-0 opacity-[0.06]"
style={{
left: '90%',
background: 'rgb(var(--color-error-500))',
}}
/>
</>
)}
{/* Fill bar */}
<div
className="relative h-full rounded-full transition-all duration-700 ease-smooth"
style={{
width: `${clampedPercent}%`,
background: `linear-gradient(90deg, ${zone.gradientFrom}, ${zone.gradientTo})`,
}}
>
{/* Shimmer overlay */}
<div className="absolute inset-0 overflow-hidden rounded-full" aria-hidden="true">
<div
className="absolute inset-y-0 w-1/2 animate-traffic-shimmer"
style={{
background:
'linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)',
}}
/>
</div>
</div>
{/* Glow dot at fill edge */}
{clampedPercent > 2 && !compact && (
<div
className="absolute top-1/2 transition-all duration-700"
style={{ left: `${clampedPercent}%`, transform: 'translate(-50%, -50%)' }}
aria-hidden="true"
>
<div
className="h-2.5 w-2.5 rounded-full"
style={{
background: zone.gradientTo,
boxShadow: `0 0 8px ${zone.glowColor}`,
}}
/>
</div>
)}
{/* Threshold marker lines */}
{showThresholds &&
THRESHOLDS.map((threshold) => (
<div
key={threshold}
className="absolute inset-y-0 w-px bg-dark-500/40"
style={{ left: `${threshold}%` }}
aria-hidden="true"
/>
))}
</div>
{/* Scale labels */}
{showScale && limitGb > 0 && (
<div
className="mt-1.5 flex justify-between font-mono text-2xs text-dark-500"
aria-hidden="true"
>
<span>0</span>
<span>{(limitGb * 0.25).toFixed(0)}</span>
<span>{(limitGb * 0.5).toFixed(0)}</span>
<span>{(limitGb * 0.75).toFixed(0)}</span>
<span>
{limitGb} {t('common.units.gb')}
</span>
</div>
)}
</div>
);
}