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

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:
platform-mcp
2026-06-17 11:23:12 +05:30
parent 1289aa43c4
commit d9bd0cb86a
+67 -9
View File
@@ -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 => {