The landing page's Sign in opened a modal whose entire content was a single "Sign in with PedsHub SSO" link. That is a step that exists to be clicked through. Every sign-in control on the page — the header, the hero, the closing call to action — now goes straight to /api/auth/sso/login when the site is SSO-only. The modal is still built and still opens on a site that has a password door, which is the only thing it was ever for. /login is deliberately left as it is. It renders the one button rather than redirecting, because it is also where the provider sends somebody back when sign-in fails — ?error=sso_failed — and a page that redirected on sight would bounce them into the provider again, forever. Verified live: one click from the landing page lands on sso.pedshub.com's flow with the client id, callback, scope, state and nonce, and no modal is rendered on the way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
745 lines
32 KiB
JavaScript
745 lines
32 KiB
JavaScript
import { useEffect, useRef, useState } from 'react'
|
|
import { Link, useNavigate } from 'react-router-dom'
|
|
import api from '../api/client'
|
|
import { useAuth } from '../context/AuthContext'
|
|
import useMediaQuery from '../hooks/useMediaQuery'
|
|
import Navbar from '../components/Navbar'
|
|
import Captcha, { captchaSiteKey } from '../components/Captcha'
|
|
import './LandingPage.css'
|
|
|
|
/**
|
|
* The page a stranger lands on.
|
|
*
|
|
* It describes the product as it is now: a question bank you sit as sessions,
|
|
* an analysis that says where you stand, and an assistant that is only allowed
|
|
* to answer out of your own library. The previous copy described the one
|
|
* feature the site launched with — extracting questions out of a PDF — which
|
|
* had stopped being the point some months ago.
|
|
*
|
|
* Nothing here is decorative motion for its own sake. The line under the
|
|
* heading cycles because it has four things to say and room for one; the
|
|
* figures count because they are real and worth looking at; the cards arrive
|
|
* on scroll because a page this long otherwise lands all at once. Every one of
|
|
* those stops dead under `prefers-reduced-motion`, and nothing on the page
|
|
* needs movement to be read.
|
|
*/
|
|
|
|
// The four rules of the adaptive picker, in the order it applies them. Kept
|
|
// deliberately close to the wording in docs/adaptive-sessions.md: a landing
|
|
// page that promises something subtly different from what the algorithm does
|
|
// is a landing page that will be wrong the first time anybody checks.
|
|
const ADAPTIVE_STEPS = [
|
|
'what you have never seen',
|
|
'then your weakest topic',
|
|
'then what you got wrong, most recently first',
|
|
'all of it weighted by the real paper',
|
|
]
|
|
|
|
const FIGURES = [
|
|
['questions', 'Questions'],
|
|
['topics', 'Topics'],
|
|
['systems', 'Systems'],
|
|
['articles', 'Articles'],
|
|
]
|
|
|
|
const CLAIMS = [
|
|
{
|
|
title: 'Where the model writes',
|
|
body: 'AI Mode, and the drafting of explanations and tips. In AI Mode its sources are fixed before it starts and checked after it stops — a model cannot cite something it was not given.',
|
|
},
|
|
{
|
|
title: 'Where it does not',
|
|
body: 'Choosing your next session is arithmetic on your own answers and a published exam blueprint. It is inspectable, it is the same every time, and no model is asked to guess at it.',
|
|
},
|
|
{
|
|
title: 'Why the difference matters',
|
|
body: 'A tool that says "powered by AI" and stops there is asking to be believed. The point of every constraint above is that you can check the claim instead.',
|
|
},
|
|
]
|
|
|
|
const CLINICAL_TILES = [
|
|
['Well visits', '2wk → 18yr'],
|
|
['Milestones', '2mo → 5yr'],
|
|
['Vaccines', 'Full schedule'],
|
|
['Dosing', 'Weight-based'],
|
|
['References', 'Plans + pathways'],
|
|
['Bedside', 'Emergency care'],
|
|
]
|
|
|
|
|
|
/** True when the visitor has asked their system for less movement. */
|
|
function useCalmMotion() {
|
|
return useMediaQuery('(prefers-reduced-motion: reduce)')
|
|
}
|
|
|
|
/**
|
|
* Adds `is-in` the first time the element is scrolled into view.
|
|
*
|
|
* Where there is no IntersectionObserver — an older browser, or jsdom under
|
|
* test — the element is revealed immediately rather than never. Content that
|
|
* 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')
|
|
|
|
useEffect(() => {
|
|
if (shown || !ref.current) return undefined
|
|
const observer = new IntersectionObserver(entries => {
|
|
if (entries.some(entry => entry.isIntersecting)) setShown(true)
|
|
}, { rootMargin: '0px 0px -60px 0px' })
|
|
observer.observe(ref.current)
|
|
return () => observer.disconnect()
|
|
}, [shown])
|
|
|
|
return [ref, shown]
|
|
}
|
|
|
|
function Reveal({ children, className = '' }) {
|
|
const [ref, revealClass] = useReveal()
|
|
return <div ref={ref} className={`${revealClass} ${className}`.trim()}>{children}</div>
|
|
}
|
|
|
|
/**
|
|
* Counts from nothing up to the figure it was given.
|
|
*
|
|
* The count is animation, so the number the page reports is the target from
|
|
* the first render — an assistive reader is given the finished figure, and
|
|
* anyone who has asked for calm motion simply sees it.
|
|
*/
|
|
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)
|
|
// Eased out, so it arrives at the figure rather than stopping at it.
|
|
setShown(Math.round(target * (1 - Math.pow(1 - progress, 3))))
|
|
if (progress < 1) frame = requestAnimationFrame(step)
|
|
})
|
|
return () => cancelAnimationFrame(frame)
|
|
}, [target, animate])
|
|
|
|
return shown
|
|
}
|
|
|
|
function Figure({ value, label, animate }) {
|
|
const shown = useCountUp(value, animate)
|
|
// The name is on the item and the digits inside it are hidden, so a screen
|
|
// reader hears "2,924 questions" once instead of a number ticking upwards.
|
|
return (
|
|
<li className="lp-figure" aria-label={`${value.toLocaleString()} ${label.toLowerCase()}`}>
|
|
<span className="lp-figure-value" aria-hidden="true">{shown.toLocaleString()}</span>
|
|
<span className="lp-figure-label" aria-hidden="true">{label}</span>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* How much there is, asked of the thing that knows.
|
|
*
|
|
* When the count cannot be fetched the section keeps its heading and loses its
|
|
* figures. Rendering zeros instead would be a claim, and a false one: the bank
|
|
* is not empty, we just could not reach it.
|
|
*/
|
|
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
|
|
api.get('/public/stats')
|
|
.then(res => { if (live) setStats(res.data || null) })
|
|
.catch(() => { if (live) setFailed(true) })
|
|
return () => { live = false }
|
|
}, [])
|
|
|
|
const figures = FIGURES
|
|
.map(([key, label]) => [label, Number(stats?.[key])])
|
|
.filter(([, value]) => Number.isFinite(value) && value > 0)
|
|
|
|
return (
|
|
<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>
|
|
<p>Counted at the moment you loaded this page, not typed into it last spring.</p>
|
|
</div>
|
|
<Reveal>
|
|
{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 && inView} />
|
|
))}
|
|
</ul>
|
|
)}
|
|
{failed && (
|
|
<p className="lp-figures-missing">
|
|
The counts could not be reached just now. Rather than show you a number we
|
|
have not checked, here is none.
|
|
</p>
|
|
)}
|
|
</Reveal>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* One line, four things to say.
|
|
*
|
|
* Under calm motion it settles on the first and stays there; the whole
|
|
* sentence is in the document either way, for anyone reading it rather than
|
|
* watching it.
|
|
*/
|
|
function Rotator({ calm }) {
|
|
const [index, setIndex] = useState(0)
|
|
|
|
useEffect(() => {
|
|
if (calm) return undefined
|
|
const timer = setInterval(() => setIndex(i => (i + 1) % ADAPTIVE_STEPS.length), 2600)
|
|
return () => clearInterval(timer)
|
|
}, [calm])
|
|
|
|
return (
|
|
<p className="lp-rotator">
|
|
<span className="lp-rotator-label">Your next session:</span>
|
|
<span className="lp-sr">{ADAPTIVE_STEPS.join(', ')}.</span>
|
|
<span className="lp-rotator-word" key={index} aria-hidden="true">{ADAPTIVE_STEPS[index]}</span>
|
|
</p>
|
|
)
|
|
}
|
|
|
|
// ── Contact form ──────────────────────────────────────────────────────────────
|
|
function AuthModal({ onClose }) {
|
|
const { login, loginWithToken } = useAuth()
|
|
const navigate = useNavigate()
|
|
const [email, setEmail] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [error, setError] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [unverified, setUnverified] = useState(false)
|
|
const [resendSent, setResendSent] = useState(false)
|
|
const [resending, setResending] = useState(false)
|
|
//: The public page's own sign-in box. It is a different component from
|
|
//: /login and knew nothing about the provider, so on a site where the only
|
|
//: way in is single sign-on it offered an email and a password and a
|
|
//: "Forgot password?" — three things that cannot work.
|
|
//: Null until the answer arrives. Defaulting to "no provider" drew the
|
|
//: email form for a moment on every open and then swapped it for the SSO
|
|
//: button — a flash of a way in that does not exist.
|
|
const [ssoOnly, setSsoOnly] = useState(null)
|
|
const [ssoEnabled, setSsoEnabled] = useState(null)
|
|
const [providerName, setProviderName] = useState('single sign-on')
|
|
|
|
useEffect(() => {
|
|
let live = true
|
|
// One question, one endpoint. This used to ask the sign-up policy as well,
|
|
// for a sign-up that no longer exists.
|
|
api.get('/auth/sso/config')
|
|
.then(res => {
|
|
if (!live) return
|
|
setSsoEnabled(res.data?.sso_enabled === true)
|
|
setSsoOnly(res.data?.sso_only === true)
|
|
setProviderName(res.data?.provider_name || 'single sign-on')
|
|
})
|
|
.catch(() => { if (live) { setSsoEnabled(false); setSsoOnly(false) } })
|
|
return () => { live = false }
|
|
}, [])
|
|
|
|
const reset = () => { setError(''); setUnverified(false); setResendSent(false) }
|
|
|
|
const handleLogin = async (e) => {
|
|
e.preventDefault()
|
|
setError(''); setUnverified(false)
|
|
setLoading(true)
|
|
try {
|
|
await login(email, password)
|
|
onClose()
|
|
navigate('/')
|
|
} catch (err) {
|
|
if (err.response?.status === 403) setUnverified(true)
|
|
else setError(err.response?.data?.detail || 'Login failed')
|
|
} finally { setLoading(false) }
|
|
}
|
|
|
|
const resendVerification = async () => {
|
|
setResending(true)
|
|
try { await api.post('/auth/resend-verification', { email }) } catch {}
|
|
setResendSent(true)
|
|
setResending(false)
|
|
}
|
|
|
|
return (
|
|
<div onClick={onClose} className="lp-modal-backdrop">
|
|
<div onClick={e => e.stopPropagation()} className="lp-modal">
|
|
<button onClick={onClose} className="lp-modal-close" aria-label="Close">✕</button>
|
|
|
|
{ssoEnabled === null && <div className="loading"><div className="spinner" /></div>}
|
|
|
|
|
|
|
|
{/* The provider, first, and on an SSO-only site the only thing here. */}
|
|
{ssoEnabled === true && (
|
|
<>
|
|
<a href="/api/auth/sso/login" className="btn btn-primary btn-block"
|
|
style={{ display: 'block', textAlign: 'center', textDecoration: 'none' }}>
|
|
Sign in with {providerName}
|
|
</a>
|
|
{ssoOnly === false && <p className="lp-modal-or">or sign in with email</p>}
|
|
</>
|
|
)}
|
|
|
|
{/* Login form */}
|
|
{ssoOnly === false && (
|
|
<>
|
|
{unverified && !resendSent && (
|
|
<div className="lp-unverified">
|
|
<p>Email not verified. Check your inbox.</p>
|
|
<button className="btn btn-sm btn-primary" onClick={resendVerification} disabled={resending}>
|
|
{resending ? 'Sending…' : 'Resend verification email'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{resendSent && <div className="alert alert-success">Verification email sent.</div>}
|
|
{error && <div className="alert alert-error">{error}</div>}
|
|
<form aria-label="Sign in" onSubmit={handleLogin}>
|
|
<div className="form-group">
|
|
<label htmlFor="modal-login-email">Email</label>
|
|
<input id="modal-login-email" type="email" autoComplete="username" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="modal-login-password">Password</label>
|
|
<input id="modal-login-password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required />
|
|
</div>
|
|
<button className="btn btn-primary btn-block" disabled={loading}>
|
|
{loading ? 'Signing in…' : 'Sign In'}
|
|
</button>
|
|
</form>
|
|
<div className="lp-forgot">
|
|
<Link to="/forgot-password" onClick={onClose}>Forgot password?</Link>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The two modes, played out rather than described.
|
|
*
|
|
* "Six things, each of which is doing something specific about a real problem
|
|
* with revising" was a sentence asking to be trusted. These are the same two
|
|
* claims shown happening: a study question marks itself the moment it is
|
|
* answered and opens its explanation, and an exam block keeps its answers to
|
|
* itself while the clock runs down.
|
|
*
|
|
* Everything moves on CSS keyframes off one shared loop, so there is no timer
|
|
* to drift and nothing to tear down. Under calm motion the animation is simply
|
|
* not applied: both panels render in their finished state, which is the state
|
|
* worth reading anyway.
|
|
*/
|
|
function ModeShowcase({ calm }) {
|
|
const cls = `lp-modes${calm ? ' is-calm' : ''}`
|
|
return (
|
|
<div className={cls}>
|
|
<figure className="lp-mode lp-mode-study" aria-label="A study question marking itself and opening its explanation">
|
|
{/* No caption. The panel is a picture of a study question marking
|
|
itself, which is the whole of what a caption would have said — and
|
|
a screen reader is told that by the figure's own label rather than
|
|
by a line of prose under a mimed screen. */}
|
|
<div className="lp-screen" aria-hidden="true">
|
|
<div className="lp-screen-bar">
|
|
<span className="lp-pill is-study">Study</span>
|
|
<span className="lp-line w40" />
|
|
</div>
|
|
<div className="lp-screen-body">
|
|
<span className="lp-line w95" />
|
|
<span className="lp-line w88" />
|
|
<span className="lp-line w60" />
|
|
<ul className="lp-opts">
|
|
<li><i>A</i><span className="lp-line w55" /></li>
|
|
<li className="is-picked is-wrong"><i>B</i><span className="lp-line w42" /></li>
|
|
<li className="is-right"><i>C</i><span className="lp-line w62" /></li>
|
|
<li><i>D</i><span className="lp-line w38" /></li>
|
|
</ul>
|
|
<div className="lp-explain">
|
|
<span className="lp-line w90" />
|
|
<span className="lp-line w72" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</figure>
|
|
|
|
<figure className="lp-mode lp-mode-exam" aria-label="An exam block with its clock running and nothing marked">
|
|
<div className="lp-screen is-exam" aria-hidden="true">
|
|
<div className="lp-screen-bar is-exam">
|
|
<span className="lp-block">Item 3 of 40</span>
|
|
{/* A real countdown rather than a still of one: the seconds
|
|
column scrolls, which is the only thing an exam block does
|
|
while you are reading it. */}
|
|
<span className="lp-clock">
|
|
<b>00</b>:<b>5</b>
|
|
<b className="lp-tick"><i>9</i><i>8</i><i>7</i><i>6</i><i>5</i></b>
|
|
</span>
|
|
</div>
|
|
<div className="lp-screen-split">
|
|
<ul className="lp-rail">
|
|
{Array.from({ length: 8 }, (_, i) => (
|
|
<li key={i} className={i < 3 ? 'is-done' : i === 3 ? 'is-here' : ''}>{i + 1}</li>
|
|
))}
|
|
</ul>
|
|
<div className="lp-screen-body">
|
|
<span className="lp-line w92" />
|
|
<span className="lp-line w78" />
|
|
<ul className="lp-opts is-plain">
|
|
<li><i>A</i><span className="lp-line w58" /></li>
|
|
<li className="is-picked"><i>B</i><span className="lp-line w44" /></li>
|
|
<li><i>C</i><span className="lp-line w64" /></li>
|
|
<li><i>D</i><span className="lp-line w36" /></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
<div className="lp-screen-foot">
|
|
<span className="lp-line w28" />
|
|
<span className="lp-end">End Block</span>
|
|
</div>
|
|
</div>
|
|
</figure>
|
|
|
|
<figure className="lp-mode lp-mode-analysis" aria-label="An analysis filling in as answers are recorded">
|
|
<div className="lp-screen" aria-hidden="true">
|
|
<div className="lp-screen-bar">
|
|
<span className="lp-pill is-analysis">Analysis</span>
|
|
<span className="lp-line w40" />
|
|
</div>
|
|
<div className="lp-screen-body">
|
|
{/* A ring that fills and three bars that grow. Nothing here claims a
|
|
number: the shapes move, and what they are is obvious from where
|
|
they sit. */}
|
|
<div className="lp-donut"><span /><i /></div>
|
|
<ul className="lp-bars">
|
|
<li><span className="lp-bar-label" /><span className="lp-bar"><span className="w72" /></span></li>
|
|
<li><span className="lp-bar-label" /><span className="lp-bar"><span className="w48" /></span></li>
|
|
<li><span className="lp-bar-label" /><span className="lp-bar"><span className="w86" /></span></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</figure>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The deck studio.
|
|
*
|
|
* The third thing PedsHub does is make slides — a talk built from the same
|
|
* evidence the bank is written from, laid out on a template, with the figures
|
|
* dropped in, exported as PowerPoint. That is a hard thing to say in a
|
|
* sentence and an easy thing to show, so this says nothing: a slide assembles
|
|
* itself, the deck beside it lights up as each one lands, and the eye works
|
|
* out what it is watching before any caption could have told it.
|
|
*
|
|
* Three slides share one 21s loop, each phase-shifted by a third of it with a
|
|
* negative delay, so there is no JavaScript clock and nothing to unwind on
|
|
* unmount. Every part inside a slide is on the same loop and the same delay
|
|
* plus its own small `--d`, which is what staggers the build: the panel, then
|
|
* the heading, then the figure, then the numbers.
|
|
*
|
|
* Under calm motion the loop is not applied at all and the three slides are
|
|
* laid out side by side, finished — which is the state a deck is meant to be
|
|
* looked at in anyway.
|
|
*/
|
|
function SlideStudio({ calm }) {
|
|
return (
|
|
<div className={`lp-studio${calm ? ' is-calm' : ''}`}>
|
|
{/* aria-hidden throughout: this is a picture of a deck being made, not a
|
|
deck. A screen reader is told what it is by the figure's label and
|
|
skips the mime. */}
|
|
<figure className="lp-stage" aria-label="A slide deck being built: a title, a figure with a growth curve, and a slide of results, then exported">
|
|
<div className="lp-deck" aria-hidden="true">
|
|
|
|
{/* The app around the slide. Without it three slides in a row are
|
|
three pictures; with it they are a deck being made. */}
|
|
<div className="lp-chrome">
|
|
<span className="lp-chrome-dots"><i /><i /><i /></span>
|
|
<span className="lp-chrome-tools"><i /><i /><i /></span>
|
|
<span className="lp-chrome-export" />
|
|
</div>
|
|
|
|
<div className="lp-canvas">
|
|
{/* 1 — the opener. */}
|
|
<div className="lp-slide is-title" style={{ '--i': 0 }}>
|
|
<span className="lp-sl-wash" style={{ '--d': '0.15s' }} />
|
|
<span className="lp-sl-mark" style={{ '--d': '1.0s' }} />
|
|
<div className="lp-sl-titleblock">
|
|
<span className="lp-sl-rule" style={{ '--d': '0.45s' }} />
|
|
<span className="lp-sl-h1" style={{ '--d': '0.65s' }} />
|
|
<span className="lp-sl-h2" style={{ '--d': '0.85s' }} />
|
|
</div>
|
|
<div className="lp-sl-hero" style={{ '--d': '0.55s' }}>
|
|
<svg viewBox="0 0 60 80" preserveAspectRatio="none" role="presentation">
|
|
<circle className="lp-hero-a" cx="30" cy="26" r="17" />
|
|
<path className="lp-hero-b" d="M8 78 C 10 52, 22 44, 30 44 C 38 44, 50 52, 52 78 Z" />
|
|
</svg>
|
|
</div>
|
|
<span className="lp-sl-foot" style={{ '--d': '1.25s' }} />
|
|
</div>
|
|
|
|
{/* 2 — evidence and a figure that draws itself. */}
|
|
<div className="lp-slide is-figure" style={{ '--i': 1 }}>
|
|
<span className="lp-sl-eyebrow" style={{ '--d': '0.25s' }} />
|
|
<span className="lp-sl-title" style={{ '--d': '0.4s' }} />
|
|
<div className="lp-sl-cols">
|
|
<div className="lp-sl-copy">
|
|
<span style={{ '--d': '0.65s' }} />
|
|
<span style={{ '--d': '0.78s' }} />
|
|
<span style={{ '--d': '0.91s' }} />
|
|
<span style={{ '--d': '1.04s' }} />
|
|
<span className="lp-sl-bullet" style={{ '--d': '1.2s' }} />
|
|
</div>
|
|
<div className="lp-sl-figure" style={{ '--d': '0.7s' }}>
|
|
<svg viewBox="0 0 120 84" preserveAspectRatio="none" role="presentation">
|
|
<g className="lp-sl-grid">
|
|
<line x1="6" y1="20" x2="114" y2="20" /><line x1="6" y1="42" x2="114" y2="42" />
|
|
<line x1="6" y1="64" x2="114" y2="64" />
|
|
</g>
|
|
<path className="lp-sl-band" d="M6 62 C 34 54, 66 34, 114 12 L114 32 C 66 52, 34 66, 6 74 Z" />
|
|
<path className="lp-sl-curve" d="M6 68 C 34 60, 66 40, 114 18" pathLength="100" />
|
|
<circle className="lp-sl-dot" cx="42" cy="57" r="2.8" style={{ '--d': '1.9s' }} />
|
|
<circle className="lp-sl-dot" cx="78" cy="39" r="2.8" style={{ '--d': '2.0s' }} />
|
|
<circle className="lp-sl-dot is-last" cx="114" cy="18" r="3.6" style={{ '--d': '2.1s' }} />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<span className="lp-sl-cite" style={{ '--d': '1.35s' }} />
|
|
</div>
|
|
|
|
{/* 3 — the numbers. */}
|
|
<div className="lp-slide is-data" style={{ '--i': 2 }}>
|
|
<span className="lp-sl-eyebrow" style={{ '--d': '0.25s' }} />
|
|
<span className="lp-sl-title" style={{ '--d': '0.4s' }} />
|
|
<div className="lp-sl-data">
|
|
<ul className="lp-sl-bars">
|
|
<li style={{ '--h': '46%', '--d': '0.7s' }} />
|
|
<li style={{ '--h': '72%', '--d': '0.82s' }} />
|
|
<li style={{ '--h': '58%', '--d': '0.94s' }} />
|
|
<li style={{ '--h': '92%', '--d': '1.06s' }} />
|
|
</ul>
|
|
<div className="lp-sl-ring" style={{ '--d': '0.85s' }}>
|
|
<svg viewBox="0 0 44 44" role="presentation">
|
|
<circle className="lp-sl-ring-track" cx="22" cy="22" r="18" />
|
|
<circle className="lp-sl-ring-fill" cx="22" cy="22" r="18" pathLength="100" />
|
|
<circle className="lp-sl-ring-second" cx="22" cy="22" r="18" pathLength="100" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<ul className="lp-sl-legend">
|
|
<li style={{ '--d': '1.25s' }} /><li style={{ '--d': '1.33s' }} /><li style={{ '--d': '1.41s' }} />
|
|
</ul>
|
|
</div>
|
|
|
|
{/* The pointer that puts things where they go, and the sheen that
|
|
crosses a slide once it is standing. */}
|
|
<span className="lp-sl-cursor" aria-hidden="true" />
|
|
<span className="lp-sl-sheen" aria-hidden="true" />
|
|
</div>
|
|
</div>
|
|
</figure>
|
|
|
|
{/* The deck as it fills: three thumbnails, the one being built lit. */}
|
|
<ul className="lp-strip" aria-hidden="true">
|
|
<li className="lp-thumb is-title" style={{ '--i': 0 }}>
|
|
<span className="lp-th-wash" /><span className="lp-th-bar w70" /><span className="lp-th-bar w44" />
|
|
</li>
|
|
<li className="lp-thumb is-figure" style={{ '--i': 1 }}>
|
|
<span className="lp-th-bar w52" />
|
|
<div className="lp-th-split"><span className="lp-th-lines" /><span className="lp-th-fig" /></div>
|
|
</li>
|
|
<li className="lp-thumb is-data" style={{ '--i': 2 }}>
|
|
<span className="lp-th-bar w52" />
|
|
<div className="lp-th-cols"><i style={{ '--h': '46%' }} /><i style={{ '--h': '72%' }} /><i style={{ '--h': '58%' }} /><i style={{ '--h': '92%' }} /></div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Main landing page ─────────────────────────────────────────────────────────
|
|
|
|
export default function LandingPage() {
|
|
const { user } = useAuth()
|
|
const calm = useCalmMotion()
|
|
//: One door. Accounts are made at the identity provider, so there is no
|
|
//: second mode for this to be in.
|
|
const [authOpen, setAuthOpen] = useState(false)
|
|
//: Where "Sign in" goes. On a site whose only way in is the provider there
|
|
//: is nothing to ask first, so the button is the provider's door rather than
|
|
//: a box containing one button — a modal whose entire content is a link is a
|
|
//: step that exists to be clicked through.
|
|
const [ssoOnly, setSsoOnly] = useState(false)
|
|
useEffect(() => {
|
|
let live = true
|
|
api.get('/auth/sso/config')
|
|
.then(res => {
|
|
if (live) setSsoOnly(res.data?.sso_enabled === true && res.data?.sso_only === true)
|
|
})
|
|
.catch(() => {})
|
|
return () => { live = false }
|
|
}, [])
|
|
const signIn = () => {
|
|
if (ssoOnly) window.location.href = '/api/auth/sso/login'
|
|
else setAuthOpen(true)
|
|
}
|
|
|
|
return (
|
|
<div className="lp-page">
|
|
|
|
{authOpen && !ssoOnly && <AuthModal onClose={() => setAuthOpen(false)} />}
|
|
|
|
<Navbar onSignIn={signIn} />
|
|
|
|
{/* ── Hero ───────────────────────────────────────────────────────────── */}
|
|
<section className="lp-hero">
|
|
<div className="lp-hero-inner">
|
|
{/* Deliberately not "pediatric". The domain already says whose site
|
|
this is, and the steps are coming — a line that names one exam
|
|
would have to be rewritten the week the next one is added. */}
|
|
<span className="lp-eyebrow">Clinical boards and exam preparation</span>
|
|
<h1>Sit the paper<br /><em>before you sit the paper.</em></h1>
|
|
<p className="lp-lead">
|
|
A question bank you take as timed blocks or as study, an analysis that
|
|
tells you where you actually stand, and an assistant that is only
|
|
permitted to answer out of your own library.
|
|
</p>
|
|
<Rotator calm={calm} />
|
|
<div className="lp-cta">
|
|
{user ? <>
|
|
<Link to="/" className="btn btn-primary">Dashboard</Link>
|
|
<Link to="/study/new" className="btn lp-cta-ghost">Start a session</Link>
|
|
<Link to="/sessions" className="btn lp-cta-ghost">Your analysis</Link>
|
|
</> : <>
|
|
{/* One button. "Create an account" led to a form that no longer
|
|
exists — an account is made by following an invitation from
|
|
the identity provider, not from here. */}
|
|
<button onClick={signIn} className="btn btn-primary">Sign in</button>
|
|
</>}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<PublicFigures />
|
|
|
|
{/* ── What it does ───────────────────────────────────────────────────── */}
|
|
<section className="lp-section">
|
|
<div className="lp-inner">
|
|
<ModeShowcase calm={calm} />
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Slides ─────────────────────────────────────────────────────────
|
|
Wordless on purpose. It is a deck being built out of the same
|
|
evidence, and watching it land says more than a paragraph claiming
|
|
it would. The one link is navigation, not explanation. */}
|
|
<section className="lp-section lp-section-raised">
|
|
<div className="lp-inner">
|
|
<SlideStudio calm={calm} />
|
|
<div className="lp-studio-cta">
|
|
{/* Straight to My Resources, which is where a deck is made — the
|
|
scribe reads the hash and opens there after sign-in. */}
|
|
<a href="https://app.pedshub.com/#resources" target="_blank" rel="noopener noreferrer"
|
|
className="btn lp-cta-ghost">Make a deck ↗</a>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Where the AI is ────────────────────────────────────────────────── */}
|
|
<section className="lp-section lp-section-dark">
|
|
<div className="lp-inner">
|
|
<div className="lp-head">
|
|
<span className="lp-eyebrow">Yes, it is AI</span>
|
|
<h2>And here is exactly where</h2>
|
|
<p>
|
|
The interesting part of a claim is what would show it to be wrong. So: this
|
|
is which parts of the site a language model touches, and which parts it is
|
|
kept out of.
|
|
</p>
|
|
</div>
|
|
<div className="lp-claims">
|
|
{CLAIMS.map(claim => (
|
|
<Reveal key={claim.title} className="lp-claim">
|
|
<h3>{claim.title}</h3>
|
|
<p>{claim.body}</p>
|
|
</Reveal>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ── Clinical tools ─────────────────────────────────────────────────── */}
|
|
<section className="lp-section lp-section-dark">
|
|
<div className="lp-inner lp-split">
|
|
<div>
|
|
<span className="lp-eyebrow">Also from PedsHub</span>
|
|
<h2>Clinical tools</h2>
|
|
<p>
|
|
The same material, pointed at the ward instead of the exam: well visit
|
|
planning from newborn to adolescence, developmental milestones, the full
|
|
vaccine schedule with a catch-up planner, weight-based dosing, and
|
|
emergency pathways for sepsis, status epilepticus, RSI, burns and
|
|
anaphylaxis.
|
|
</p>
|
|
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer"
|
|
className="btn btn-primary">
|
|
Open clinical tools ↗
|
|
</a>
|
|
</div>
|
|
<div className="lp-tiles">
|
|
{CLINICAL_TILES.map(([label, sub]) => (
|
|
<div key={label} className="lp-tile">
|
|
<div className="lp-tile-label">{label}</div>
|
|
<div className="lp-tile-sub">{sub}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<footer className="lp-footer">
|
|
<div className="lp-footer-inner">
|
|
<span className="lp-footer-brand">
|
|
🏥 PedsHub<span>© {new Date().getFullYear()}</span>
|
|
</span>
|
|
<div className="lp-footer-links">
|
|
<button onClick={signIn}>Sign In</button>
|
|
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer">Clinical Tools</a>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
|
|
</div>
|
|
)
|
|
}
|