fix(homepage): stop stats counters from getting stuck at "0"
E2E Test Suite / Smoke Tests (P0) (push) Failing after 9m24s
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 10m12s
E2E Test Suite / Form Interaction Tests (push) Failing after 11m4s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Has been cancelled
E2E Test Suite / Cross-Browser Regression (firefox) (push) Has been cancelled
E2E Test Suite / Cross-Browser Regression (webkit) (push) Has been cancelled
E2E Test Suite / Mobile Device Tests (push) Has been cancelled
E2E Test Suite / Security Header Tests (push) Has been cancelled
E2E Test Suite / Test Report Summary (push) Has been cancelled
Ping Search Engines / Notify Search Engines (push) Failing after 14m58s
Deploy to Production / Build & Verify (push) Blocked by required conditions
Deploy to Production / Pre-Deploy Tests (push) Blocked by required conditions
Deploy to Production / Deploy to Railway (push) Blocked by required conditions
Deploy to Production / Deploy to Render (push) Blocked by required conditions
Deploy to Production / Deploy to VPS (PM2) (push) Blocked by required conditions
Deploy to Production / Deploy to Fly.io (push) Blocked by required conditions
Deploy to Production / Post-Deploy Verification (push) Blocked by required conditions
Deploy to Production / Notify on Failure (push) Blocked by required conditions
E2E Test Suite / Smoke Tests (P0) (push) Failing after 9m24s
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 10m12s
E2E Test Suite / Form Interaction Tests (push) Failing after 11m4s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Has been cancelled
E2E Test Suite / Cross-Browser Regression (firefox) (push) Has been cancelled
E2E Test Suite / Cross-Browser Regression (webkit) (push) Has been cancelled
E2E Test Suite / Mobile Device Tests (push) Has been cancelled
E2E Test Suite / Security Header Tests (push) Has been cancelled
E2E Test Suite / Test Report Summary (push) Has been cancelled
Ping Search Engines / Notify Search Engines (push) Failing after 14m58s
Deploy to Production / Build & Verify (push) Blocked by required conditions
Deploy to Production / Pre-Deploy Tests (push) Blocked by required conditions
Deploy to Production / Deploy to Railway (push) Blocked by required conditions
Deploy to Production / Deploy to Render (push) Blocked by required conditions
Deploy to Production / Deploy to VPS (PM2) (push) Blocked by required conditions
Deploy to Production / Deploy to Fly.io (push) Blocked by required conditions
Deploy to Production / Post-Deploy Verification (push) Blocked by required conditions
Deploy to Production / Notify on Failure (push) Blocked by required conditions
The stats tiles ("Projects Delivered" etc.) could end up frozen at
0+/0%/0+/0 whenever the IntersectionObserver never fired (tall
sections on mobile, threshold too strict, JS error, prefers-reduced-motion).
- Server-render the final value as the initial textContent so the
numbers are correct on first paint, with JS disabled, and on any
hydration failure.
- Lower observer threshold to 0.1 and add rootMargin so the animation
triggers reliably on tall viewports.
- Detect prefers-reduced-motion and render the final value without
animating.
- Fall back to immediate animation when IntersectionObserver is
unavailable or the section is already in view at load.
- Reset to 0 only at the moment the animation starts (and snap to
the exact target at the end) so a mid-flight failure can never
leave the counter visibly stuck.
- Boot all homepage scripts after DOMContentLoaded.
This commit is contained in:
+67
-9
@@ -492,7 +492,7 @@ const faqs = [
|
||||
</div>
|
||||
|
||||
<div class="stat-counter text-5xl font-bold text-secondary-900 dark:text-secondary-100 mb-2" data-target={stat.value} data-suffix={stat.suffix}>
|
||||
0{stat.suffix}
|
||||
{stat.value}{stat.suffix}
|
||||
</div>
|
||||
<div class="text-secondary-600 dark:text-secondary-400 font-semibold">{stat.label}</div>
|
||||
</div>
|
||||
@@ -792,14 +792,33 @@ const faqs = [
|
||||
|
||||
<script>
|
||||
// Stats counter animation
|
||||
// The initial textContent already renders the final value (server-side),
|
||||
// so users with no JS / failed hydration / reduced-motion still see the
|
||||
// correct number. The animation here briefly resets to 0 and counts up.
|
||||
function animateCounters() {
|
||||
const counters = document.querySelectorAll('.stat-counter');
|
||||
const counters = document.querySelectorAll<HTMLElement>('.stat-counter');
|
||||
const duration = 2000;
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
counters.forEach((counter) => {
|
||||
const target = parseInt(counter.getAttribute('data-target') || '0');
|
||||
const target = parseInt(counter.getAttribute('data-target') || '0', 10);
|
||||
const suffix = counter.getAttribute('data-suffix') || '';
|
||||
|
||||
// Guaranteed final state — set the resolved value up front so a
|
||||
// mid-animation failure can never leave the user staring at "0".
|
||||
const finalText = target + suffix;
|
||||
|
||||
if (reduceMotion || !Number.isFinite(target) || target <= 0) {
|
||||
counter.textContent = finalText;
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid running the animation more than once per counter.
|
||||
if (counter.dataset.counted === 'true') return;
|
||||
counter.dataset.counted = 'true';
|
||||
|
||||
const startTime = performance.now();
|
||||
counter.textContent = '0' + suffix;
|
||||
|
||||
function updateCounter(currentTime: number) {
|
||||
const elapsed = currentTime - startTime;
|
||||
@@ -811,6 +830,9 @@ const faqs = [
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(updateCounter);
|
||||
} else {
|
||||
// Snap to exact final value to avoid floor-rounding drift.
|
||||
counter.textContent = finalText;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,8 +939,22 @@ const faqs = [
|
||||
}
|
||||
|
||||
// Intersection Observer for counter animation
|
||||
const statsSection = document.querySelector('.stat-counter')?.closest('section');
|
||||
if (statsSection) {
|
||||
function initStatsCounter() {
|
||||
const firstCounter = document.querySelector('.stat-counter');
|
||||
const statsSection = firstCounter?.closest('section');
|
||||
|
||||
// If the markup isn't there for any reason, bail silently — the
|
||||
// server-rendered numbers are already correct.
|
||||
if (!statsSection) return;
|
||||
|
||||
// Older browsers / failed feature detection: just render the final
|
||||
// values immediately. They're already in the DOM, but this also
|
||||
// protects against the path where data-target was patched at runtime.
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
animateCounters();
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
@@ -928,14 +964,36 @@ const faqs = [
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.3 }
|
||||
// Lower threshold so the animation fires reliably on tall mobile
|
||||
// viewports; rootMargin lets it kick in just before the section
|
||||
// scrolls into view for a smoother first impression.
|
||||
{ threshold: 0.1, rootMargin: '0px 0px -10% 0px' }
|
||||
);
|
||||
|
||||
observer.observe(statsSection);
|
||||
|
||||
// If the section is already in the viewport at load time the
|
||||
// observer fires on its next tick, but we also guard with a fallback
|
||||
// so anyone who lands mid-scroll never sees the static "0" path.
|
||||
const rect = statsSection.getBoundingClientRect();
|
||||
const inView = rect.top < window.innerHeight && rect.bottom > 0;
|
||||
if (inView) {
|
||||
animateCounters();
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize components
|
||||
initCarousel();
|
||||
initFAQ();
|
||||
function bootHomepageScripts() {
|
||||
initStatsCounter();
|
||||
initCarousel();
|
||||
initFAQ();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bootHomepageScripts);
|
||||
} else {
|
||||
bootHomepageScripts();
|
||||
}
|
||||
|
||||
// Smooth scroll for anchor links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
|
||||
Reference in New Issue
Block a user