diff --git a/index.html b/index.html
index a81ba8b..a79985d 100644
--- a/index.html
+++ b/index.html
@@ -15,7 +15,7 @@
diff --git a/src/hooks/useAnimatedNumber.ts b/src/hooks/useAnimatedNumber.ts
new file mode 100644
index 0000000..fba5aba
--- /dev/null
+++ b/src/hooks/useAnimatedNumber.ts
@@ -0,0 +1,38 @@
+import { useEffect, useRef, useState } from 'react';
+
+export function useAnimatedNumber(target: number, duration = 600): number {
+ const [current, setCurrent] = useState(target);
+ const rafRef = useRef(0);
+ const startRef = useRef(0);
+ const fromRef = useRef(target);
+ const currentRef = useRef(target);
+ const prevTargetRef = useRef(target);
+
+ // Keep currentRef in sync with state
+ currentRef.current = current;
+
+ useEffect(() => {
+ if (prevTargetRef.current === target) return;
+ prevTargetRef.current = target;
+ fromRef.current = currentRef.current;
+ startRef.current = performance.now();
+
+ const animate = (now: number) => {
+ const elapsed = now - startRef.current;
+ const progress = Math.min(elapsed / duration, 1);
+ const eased = 1 - Math.pow(1 - progress, 3);
+ const value = fromRef.current + (target - fromRef.current) * eased;
+ setCurrent(value);
+ currentRef.current = value;
+
+ if (progress < 1) {
+ rafRef.current = requestAnimationFrame(animate);
+ }
+ };
+
+ rafRef.current = requestAnimationFrame(animate);
+ return () => cancelAnimationFrame(rafRef.current);
+ }, [target, duration]);
+
+ return current;
+}
diff --git a/src/utils/trafficZone.ts b/src/utils/trafficZone.ts
new file mode 100644
index 0000000..fc8c27b
--- /dev/null
+++ b/src/utils/trafficZone.ts
@@ -0,0 +1,56 @@
+export type TrafficZone = 'normal' | 'warning' | 'danger' | 'critical';
+
+interface TrafficZoneResult {
+ zone: TrafficZone;
+ textClass: string;
+ dotClass: string;
+ glowColor: string;
+ labelKey: string;
+ gradientFrom: string;
+ gradientTo: string;
+}
+
+const ZONES: Record> = {
+ normal: {
+ textClass: 'text-success-400',
+ dotClass: 'bg-success-400',
+ glowColor: 'rgba(var(--color-success-500), 0.5)',
+ labelKey: 'dashboard.zone.normal',
+ gradientFrom: 'rgb(var(--color-success-500))',
+ gradientTo: 'rgb(var(--color-success-400))',
+ },
+ warning: {
+ textClass: 'text-warning-400',
+ dotClass: 'bg-warning-400',
+ glowColor: 'rgba(var(--color-warning-500), 0.5)',
+ labelKey: 'dashboard.zone.warning',
+ gradientFrom: 'rgb(var(--color-warning-500))',
+ gradientTo: 'rgb(var(--color-warning-400))',
+ },
+ danger: {
+ textClass: 'text-warning-300',
+ dotClass: 'bg-warning-300',
+ glowColor: 'rgba(var(--color-warning-400), 0.5)',
+ labelKey: 'dashboard.zone.danger',
+ gradientFrom: 'rgb(var(--color-warning-600))',
+ gradientTo: 'rgb(var(--color-warning-400))',
+ },
+ critical: {
+ textClass: 'text-error-400',
+ dotClass: 'bg-error-400',
+ glowColor: 'rgba(var(--color-error-500), 0.5)',
+ labelKey: 'dashboard.zone.critical',
+ gradientFrom: 'rgb(var(--color-error-500))',
+ gradientTo: 'rgb(var(--color-error-400))',
+ },
+};
+
+export function getTrafficZone(percent: number): TrafficZoneResult {
+ let zone: TrafficZone;
+ if (percent >= 90) zone = 'critical';
+ else if (percent >= 75) zone = 'danger';
+ else if (percent >= 50) zone = 'warning';
+ else zone = 'normal';
+
+ return { zone, ...ZONES[zone] };
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index d107268..027d948 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -112,6 +112,8 @@ export default {
'Roboto',
'sans-serif',
],
+ display: ['Outfit', 'Manrope', 'system-ui', 'sans-serif'],
+ mono: ['IBM Plex Mono', 'ui-monospace', 'monospace'],
},
borderRadius: {
bento: '24px',
@@ -173,6 +175,11 @@ export default {
'move-horizontal': 'moveHorizontal 40s ease infinite',
'move-in-circle-fast': 'moveInCircle 20s ease infinite',
'spotlight-ace': 'spotlightAce 2s ease 0.75s 1 forwards',
+ // Dashboard traffic animations
+ 'traffic-shimmer': 'trafficShimmer 2s ease-in-out infinite',
+ 'unlimited-flow': 'unlimitedFlow 3s linear infinite',
+ 'unlimited-pulse': 'unlimitedPulse 2s ease-in-out infinite',
+ 'trial-glow': 'trialGlow 2s ease-in-out infinite',
},
keyframes: {
fadeIn: {
@@ -251,6 +258,23 @@ export default {
'0%': { opacity: '0', transform: 'translate(-72%, -62%) scale(0.5)' },
'100%': { opacity: '1', transform: 'translate(-50%, -40%) scale(1)' },
},
+ // Dashboard traffic keyframes
+ trafficShimmer: {
+ '0%': { transform: 'translateX(-100%)' },
+ '100%': { transform: 'translateX(200%)' },
+ },
+ unlimitedFlow: {
+ '0%': { backgroundPosition: '0% 50%' },
+ '100%': { backgroundPosition: '200% 50%' },
+ },
+ unlimitedPulse: {
+ '0%, 100%': { opacity: '0.6', transform: 'scale(1)' },
+ '50%': { opacity: '1', transform: 'scale(1.3)' },
+ },
+ trialGlow: {
+ '0%, 100%': { boxShadow: '0 0 15px rgba(var(--color-warning-500), 0.2)' },
+ '50%': { boxShadow: '0 0 30px rgba(var(--color-warning-500), 0.4)' },
+ },
},
transitionTimingFunction: {
smooth: 'cubic-bezier(0.4, 0, 0.2, 1)',