diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index 4eefddc..c5f1c2e 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -80,6 +80,12 @@ function useCalmMotion() { * depends on an optional API to become visible is content that disappears. */ function useReveal() { + const [ref, shown] = useInView() + return [ref, shown ? 'lp-reveal is-in' : 'lp-reveal'] +} + +/** True once the element has been scrolled into view, and true thereafter. */ +function useInView() { const ref = useRef(null) const [shown, setShown] = useState(typeof IntersectionObserver === 'undefined') @@ -92,7 +98,7 @@ function useReveal() { return () => observer.disconnect() }, [shown]) - return [ref, shown ? 'lp-reveal is-in' : 'lp-reveal'] + return [ref, shown] } function Reveal({ children, className = '' }) { @@ -109,9 +115,16 @@ function Reveal({ children, className = '' }) { */ function useCountUp(target, animate) { const [shown, setShown] = useState(animate ? 0 : target) + //: Whether this counter has already run. It starts when the figures come + //: into view, which is a state change after mount — without this, anything + //: that re-renders the section afterwards would send the number back to zero + //: and count it up again. + const done = useRef(false) useEffect(() => { + if (done.current) { setShown(target); return undefined } if (!animate || typeof requestAnimationFrame !== 'function') { setShown(target); return undefined } + done.current = true const started = Date.now() let frame = requestAnimationFrame(function step() { const progress = Math.min(1, (Date.now() - started) / 1100) @@ -148,6 +161,11 @@ function PublicFigures() { const calm = useCalmMotion() const [stats, setStats] = useState(null) const [failed, setFailed] = useState(false) + // Counted when the figures are on screen. They used to count on mount, which + // is while the visitor is still reading the hero two screens above — so the + // animation had finished long before anybody could see it, and the numbers + // simply appeared. + const [ref, inView] = useInView() useEffect(() => { let live = true @@ -162,7 +180,7 @@ function PublicFigures() { .filter(([, value]) => Number.isFinite(value) && value > 0) return ( -
+

What is in the bank today

@@ -172,7 +190,7 @@ function PublicFigures() { {figures.length > 0 && (
    {figures.map(([label, value]) => ( -
    +
    ))}
)} @@ -214,87 +232,6 @@ function Rotator({ calm }) { } // ── Contact form ────────────────────────────────────────────────────────────── -function ContactForm() { - const [form, setForm] = useState({ name: '', email: '', type: 'question', message: '' }) - const [captchaToken, setCaptchaToken] = useState('') - const [loading, setLoading] = useState(false) - const [sent, setSent] = useState(false) - const [error, setError] = useState('') - - const canSubmit = form.name && form.email && form.message && (!captchaSiteKey() || captchaToken) - - const submit = async (e) => { - e.preventDefault() - setError('') - setLoading(true) - try { - await api.post('/contact', { ...form, captcha_token: captchaToken || null }) - setSent(true) - } catch (err) { - setError(err.response?.data?.detail || 'Something went wrong. Please try again.') - } finally { - setLoading(false) - } - } - - if (sent) return ( -
- -

Message received

-

- {form.type === 'moderator' - ? "We'll review your application and get back to you." - : "We'll get back to you shortly."} -

-
- ) - - return ( -
-
-
- - setForm(f => ({ ...f, name: e.target.value }))} /> -
-
- - setForm(f => ({ ...f, email: e.target.value }))} /> -
-
-
- -
- {[['question', '💬 Question'], ['moderator', '🛡 Apply as Moderator']].map(([val, label]) => ( - - ))} -
- {form.type === 'moderator' && ( -

- Moderators can upload material and manage the question bank. Tell us about your - background and how you would like to contribute. -

- )} -