fix: the landing figures count when you can see them, and no contact form
The counters ran on mount, which is while the visitor is still reading the hero two screens above — so the animation finished before anybody could see it and the numbers simply appeared. They now start when the figures come into view, and a ref stops a later re-render sending them back to zero. The contact section is gone, and with it the Contact link in the footer. The endpoint behind it is untouched, so the form can come back somewhere else without being rebuilt. And the clinical tools say "the full vaccine schedule" rather than naming the two bodies that publish it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
448bfdd71c
commit
dadd2447b9
2 changed files with 30 additions and 106 deletions
|
|
@ -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 (
|
||||
<section className="lp-section lp-section-raised">
|
||||
<section className="lp-section lp-section-raised" ref={ref}>
|
||||
<div className="lp-inner">
|
||||
<div className="lp-head">
|
||||
<h2>What is in the bank today</h2>
|
||||
|
|
@ -172,7 +190,7 @@ function PublicFigures() {
|
|||
{figures.length > 0 && (
|
||||
<ul className="lp-figures" aria-label="What is in the bank today">
|
||||
{figures.map(([label, value]) => (
|
||||
<Figure key={label} value={value} label={label} animate={!calm} />
|
||||
<Figure key={label} value={value} label={label} animate={!calm && inView} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
|
@ -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 (
|
||||
<div className="lp-sent">
|
||||
<div className="lp-sent-mark" aria-hidden="true">✓</div>
|
||||
<h3>Message received</h3>
|
||||
<p>
|
||||
{form.type === 'moderator'
|
||||
? "We'll review your application and get back to you."
|
||||
: "We'll get back to you shortly."}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="lp-form">
|
||||
<div className="lp-form-row">
|
||||
<div className="lp-field">
|
||||
<label htmlFor="lp-contact-name">Name</label>
|
||||
<input id="lp-contact-name" value={form.name} required maxLength={120}
|
||||
placeholder="Your name"
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="lp-field">
|
||||
<label htmlFor="lp-contact-email">Email</label>
|
||||
<input id="lp-contact-email" type="email" value={form.email} required
|
||||
placeholder="you@example.com"
|
||||
onChange={e => setForm(f => ({ ...f, email: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="lp-field">
|
||||
<label htmlFor="lp-contact-message">Message</label>
|
||||
<div className="lp-choice">
|
||||
{[['question', '💬 Question'], ['moderator', '🛡 Apply as Moderator']].map(([val, label]) => (
|
||||
<button key={val} type="button" aria-pressed={form.type === val}
|
||||
onClick={() => setForm(f => ({ ...f, type: val }))}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
{form.type === 'moderator' && (
|
||||
<p className="lp-hint">
|
||||
Moderators can upload material and manage the question bank. Tell us about your
|
||||
background and how you would like to contribute.
|
||||
</p>
|
||||
)}
|
||||
<textarea id="lp-contact-message" className="lp-message" value={form.message} required rows={4} maxLength={2000}
|
||||
placeholder={form.type === 'moderator'
|
||||
? 'Tell us about your background, specialty, and how you would like to help…'
|
||||
: 'Your question or message…'}
|
||||
onChange={e => setForm(f => ({ ...f, message: e.target.value }))} />
|
||||
</div>
|
||||
<Captcha onVerify={setCaptchaToken} />
|
||||
{error && <div className="lp-error">{error}</div>}
|
||||
<button type="submit" disabled={loading || !canSubmit} className="btn btn-primary">
|
||||
{loading ? 'Sending…' : 'Send Message'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Auth modal (Sign In / Register as overlay) ──────────────────────────────
|
||||
function AuthModal({ mode, onClose, onSwitch }) {
|
||||
const { login, loginWithToken } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -835,7 +772,7 @@ export default function LandingPage() {
|
|||
<p>
|
||||
The same material, pointed at the ward instead of the exam: well visit
|
||||
planning from newborn to adolescence, developmental milestones, the full
|
||||
AAP/ACIP schedule with a catch-up planner, weight-based dosing, and
|
||||
vaccine schedule with a catch-up planner, weight-based dosing, and
|
||||
emergency pathways for sepsis, status epilepticus, RSI, burns and
|
||||
anaphylaxis.
|
||||
</p>
|
||||
|
|
@ -855,19 +792,6 @@ export default function LandingPage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Contact ────────────────────────────────────────────────────────── */}
|
||||
<section id="contact" className="lp-section lp-section-raised">
|
||||
<div className="lp-inner-narrow">
|
||||
<div className="lp-head">
|
||||
<h2>Get in touch</h2>
|
||||
<p>A question, or an offer to help write and review the bank. Both are welcome.</p>
|
||||
</div>
|
||||
<div className="lp-card-lg">
|
||||
<ContactForm />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="lp-footer">
|
||||
<div className="lp-footer-inner">
|
||||
<span className="lp-footer-brand">
|
||||
|
|
@ -875,7 +799,6 @@ export default function LandingPage() {
|
|||
</span>
|
||||
<div className="lp-footer-links">
|
||||
<button onClick={() => setAuthModal('login')}>Sign In</button>
|
||||
<a href="#contact">Contact</a>
|
||||
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer">Clinical Tools</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -45,12 +45,14 @@ it('logs in on the standalone page without rendering, loading or submitting a ca
|
|||
expect(document.getElementById('cap-widget-script')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves the landing login free of a challenge while the contact widget stays', async () => {
|
||||
it('leaves the landing login free of a challenge', async () => {
|
||||
mount(LandingPage)
|
||||
// One on the page already: the contact form's.
|
||||
expect(document.querySelectorAll('cap-widget')).toHaveLength(1)
|
||||
// None on the page: the contact form that used to carry one is gone, and
|
||||
// signing in has never needed a challenge — the rate limiter is what stands
|
||||
// between a password field and a word list.
|
||||
expect(document.querySelectorAll('cap-widget')).toHaveLength(0)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Open login' }))
|
||||
expect(document.querySelectorAll('cap-widget')).toHaveLength(1)
|
||||
expect(document.querySelectorAll('cap-widget')).toHaveLength(0)
|
||||
await submitLogin()
|
||||
})
|
||||
|
||||
|
|
@ -58,11 +60,10 @@ it('keeps registration protected on the landing page', async () => {
|
|||
mount(LandingPage)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Open registration' }))
|
||||
const widgets = document.querySelectorAll('cap-widget')
|
||||
expect(widgets).toHaveLength(2)
|
||||
// One now, not two: registration's. The contact form's went with the form.
|
||||
expect(widgets).toHaveLength(1)
|
||||
const signup = screen.getByRole('button', { name: 'Sign Up', exact: true })
|
||||
expect(signup).toBeDisabled()
|
||||
// Solve whichever is the registration form's rather than guessing where it
|
||||
// lands in the document; the contact one ignores a token it did not ask for.
|
||||
act(() => widgets.forEach(w => w.dispatchEvent(
|
||||
new CustomEvent('solve', { detail: { token: 'synthetic-valid-token' } }))))
|
||||
// Still not enough: the password has to be typed twice and agree.
|
||||
|
|
|
|||
Loading…
Reference in a new issue