Skip to content

Commit f884769

Browse files
committed
feat(landing): added live user count with scroll-triggered animation
- reads userCount from Firestore stats/public doc on landing page load - count-up animation triggers via IntersectionObserver when stats strip is visible - easeOutExpo easing for smooth deceleration (0 to N over 2 seconds) - waits for both data load and scroll visibility before animating - pulsing green dot indicator to signal the count is live - increments counter on new sign-ups (Google + email) via getAdditionalUserInfo - handles redirect-based sign-in new user detection in constructor
1 parent 15e2c1f commit f884769

File tree

2 files changed

+103
-9
lines changed

2 files changed

+103
-9
lines changed

src/lib/components/landing/Hero.svelte

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
<script lang="ts">
22
import { onMount } from 'svelte';
3+
import { browser } from '$app/environment';
4+
import { doc, getDoc } from 'firebase/firestore';
5+
import { db } from '$lib/firebase';
36
import FlipWords from '$components/ui/FlipWords.svelte';
47
import SparklesText from '$components/ui/SparklesText.svelte';
58
import NumberFlow from '@number-flow/svelte';
@@ -13,8 +16,59 @@
1316
let mouseX = $state(50);
1417
let mouseY = $state(50);
1518
19+
// live user count from Firestore
20+
let userCount = $state(0);
21+
let displayCount = $state(0);
22+
let isVisible = $state(false);
23+
let hasAnimated = false;
24+
let statsEl = $state<HTMLElement | null>(null);
25+
26+
// trigger count-up animation when both visible and count is loaded
27+
$effect(() => {
28+
if (isVisible && userCount > 0 && !hasAnimated) {
29+
hasAnimated = true;
30+
animateCount(userCount);
31+
}
32+
});
33+
34+
function animateCount(target: number) {
35+
const duration = 2000;
36+
const start = performance.now();
37+
38+
function tick(now: number) {
39+
const elapsed = now - start;
40+
const progress = Math.min(elapsed / duration, 1);
41+
// easeOutExpo for smooth deceleration
42+
const eased = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress);
43+
displayCount = Math.floor(eased * target);
44+
if (progress < 1) requestAnimationFrame(tick);
45+
}
46+
requestAnimationFrame(tick);
47+
}
48+
1649
onMount(() => {
1750
mounted = true;
51+
52+
// fetch live user count
53+
getDoc(doc(db, 'stats', 'public'))
54+
.then((snap) => {
55+
if (snap.exists()) {
56+
userCount = snap.data().userCount ?? 0;
57+
}
58+
})
59+
.catch(() => {});
60+
61+
// observe stats strip for scroll-triggered animation
62+
if (statsEl) {
63+
const observer = new IntersectionObserver(
64+
(entries) => {
65+
if (entries[0].isIntersecting) isVisible = true;
66+
},
67+
{ threshold: 0.5 }
68+
);
69+
observer.observe(statsEl);
70+
return () => observer.disconnect();
71+
}
1872
});
1973
2074
// converts pixel coords to percentage of hero bounds for the glow
@@ -159,7 +213,15 @@
159213
</div>
160214

161215
<!-- stats strip -->
162-
<div class="hero-stats">
216+
<div class="hero-stats" bind:this={statsEl}>
217+
<div class="stat">
218+
<span class="stat-number">{displayCount.toLocaleString()}</span>
219+
<span class="stat-label stat-live">
220+
<span class="live-dot"></span>
221+
Users Served
222+
</span>
223+
</div>
224+
<div class="stat-divider"></div>
163225
<div class="stat">
164226
<span class="stat-number">6</span>
165227
<span class="stat-label">ATS Platforms</span>
@@ -170,11 +232,6 @@
170232
<span class="stat-label">Free & Open Source</span>
171233
</div>
172234
<div class="stat-divider"></div>
173-
<div class="stat">
174-
<span class="stat-number">Any</span>
175-
<span class="stat-label">Industry or Role</span>
176-
</div>
177-
<div class="stat-divider"></div>
178235
<div class="stat">
179236
<span class="stat-number">0</span>
180237
<span class="stat-label">Data Stored</span>
@@ -565,6 +622,22 @@
565622
font-weight: 500;
566623
}
567624
625+
.stat-live {
626+
display: inline-flex;
627+
align-items: center;
628+
gap: 0.35rem;
629+
}
630+
631+
.live-dot {
632+
width: 6px;
633+
height: 6px;
634+
border-radius: 50%;
635+
background: var(--accent-green);
636+
box-shadow: 0 0 8px rgba(16, 185, 129, 0.6);
637+
animation: pulse 2s ease-in-out infinite;
638+
flex-shrink: 0;
639+
}
640+
568641
.stat-divider {
569642
width: 1px;
570643
height: 2.5rem;

src/lib/stores/auth.svelte.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
signInWithPopup,
55
signInWithRedirect,
66
getRedirectResult,
7+
getAdditionalUserInfo,
78
signInWithEmailAndPassword,
89
createUserWithEmailAndPassword,
910
sendEmailVerification,
@@ -13,7 +14,8 @@ import {
1314
updateProfile,
1415
type User
1516
} from 'firebase/auth';
16-
import { auth } from '$lib/firebase';
17+
import { doc, updateDoc, increment } from 'firebase/firestore';
18+
import { auth, db } from '$lib/firebase';
1719

1820
class AuthStore {
1921
user = $state<User | null>(null);
@@ -51,7 +53,13 @@ class AuthStore {
5153
this.loading = false;
5254
});
5355
// handle redirect result from signInWithRedirect fallback
54-
getRedirectResult(auth).catch(() => {});
56+
getRedirectResult(auth)
57+
.then((result) => {
58+
if (result && getAdditionalUserInfo(result)?.isNewUser) {
59+
this.incrementUserCount();
60+
}
61+
})
62+
.catch(() => {});
5563
} else {
5664
this.loading = false;
5765
}
@@ -61,7 +69,10 @@ class AuthStore {
6169
this.error = null;
6270
const provider = new GoogleAuthProvider();
6371
try {
64-
await signInWithPopup(auth, provider);
72+
const result = await signInWithPopup(auth, provider);
73+
if (getAdditionalUserInfo(result)?.isNewUser) {
74+
this.incrementUserCount();
75+
}
6576
} catch (err) {
6677
// if popup fails with an internal SDK error (not a Firebase auth error),
6778
// fall back to redirect-based sign-in
@@ -102,6 +113,8 @@ class AuthStore {
102113
sendEmailVerification(credential.user).catch((err) => {
103114
console.warn('[auth] failed to send verification email:', err);
104115
});
116+
// new email sign-up is always a new user
117+
this.incrementUserCount();
105118
} catch (err) {
106119
this.error = this.getErrorMessage(err);
107120
throw err;
@@ -131,6 +144,14 @@ class AuthStore {
131144
this.error = null;
132145
}
133146

147+
private incrementUserCount() {
148+
updateDoc(doc(db, 'stats', 'public'), {
149+
userCount: increment(1)
150+
}).catch(() => {
151+
// non-critical, don't break auth flow
152+
});
153+
}
154+
134155
private getErrorMessage(err: unknown): string {
135156
const code = (err as { code?: string })?.code ?? '';
136157
switch (code) {

0 commit comments

Comments
 (0)