Add PedsHub landing page with contact form and AI Scribe section
Frontend: - LandingPage.jsx: hero, 6 feature cards, AI Scribe section (links peds.danvics.com), contact form - Contact form: name/email/type toggle (question vs moderator app), Turnstile anti-bot (optional) - App.jsx: /home route for unauthenticated users, auth pages reachable pre-login, logged-in /home → / Backend: - contact.py: POST /api/contact (public), stores in DB, emails admin; GET /submissions (admin) - main.py: create contact_submissions table, register contact router - main.py: add pedshub.com + www.pedshub.com to CORS origins - config.py: TURNSTILE_SECRET_KEY, ADMIN_EMAIL settings Env vars to set: VITE_TURNSTILE_SITE_KEY=<from cloudflare.com/turnstile> (frontend) TURNSTILE_SECRET_KEY=<secret> (backend .env) ADMIN_EMAIL=you@example.com (backend .env) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4cbaa529f0
commit
ee47fb07a2
5 changed files with 547 additions and 5 deletions
|
|
@ -38,5 +38,8 @@ class Settings(BaseSettings):
|
|||
UPLOAD_DIR: str = "./uploads"
|
||||
MAX_UPLOAD_SIZE: int = 524288000 # 500MB
|
||||
|
||||
TURNSTILE_SECRET_KEY: str = "" # Cloudflare Turnstile — leave blank to disable captcha
|
||||
ADMIN_EMAIL: str = "" # Where contact form submissions are emailed
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
|
|||
|
||||
from app.config import settings
|
||||
from app.database import engine, Base, SessionLocal
|
||||
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach
|
||||
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact
|
||||
from app.utils.auth import get_password_hash
|
||||
from app.utils.scheduler import start_scheduler, stop_scheduler
|
||||
|
||||
|
|
@ -230,6 +230,18 @@ def setup_pgvector():
|
|||
"""))
|
||||
# Unthrottle flag for users (exempt from AI/TTS rate limits)
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS is_unthrottled INTEGER DEFAULT 0"))
|
||||
# Contact form submissions table
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS contact_submissions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
email VARCHAR NOT NULL,
|
||||
type VARCHAR NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
read INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
|
@ -307,7 +319,7 @@ app = FastAPI(
|
|||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["https://quiz.danvics.com"],
|
||||
allow_origins=["https://quiz.danvics.com", "https://pedshub.com", "https://www.pedshub.com"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
|
@ -333,6 +345,7 @@ app.include_router(questions.router, prefix="/api/questions", tags=["questions"]
|
|||
app.include_router(question_categories.router, prefix="/api/question-categories", tags=["question-categories"])
|
||||
app.include_router(favorites.router, prefix="/api/favorites", tags=["favorites"])
|
||||
app.include_router(teach.router, prefix="/api/teach", tags=["teach"])
|
||||
app.include_router(contact.router, prefix="/api/contact", tags=["contact"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
|
|
|||
140
backend/app/routers/contact.py
Normal file
140
backend/app/routers/contact.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""Contact form — stores submissions and emails admin."""
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db, Base
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContactSubmission(Base):
|
||||
__tablename__ = "contact_submissions"
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String, nullable=False)
|
||||
email = Column(String, nullable=False)
|
||||
type = Column(String, nullable=False) # "question" | "moderator"
|
||||
message = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
read = Column(Integer, default=0)
|
||||
|
||||
|
||||
class ContactRequest(BaseModel):
|
||||
name: str
|
||||
email: EmailStr
|
||||
type: str # "question" | "moderator"
|
||||
message: str
|
||||
turnstile_token: str | None = None
|
||||
|
||||
|
||||
async def _verify_turnstile(token: str) -> bool:
|
||||
"""Verify Cloudflare Turnstile token. Returns True if valid or if Turnstile not configured."""
|
||||
secret = getattr(settings, "TURNSTILE_SECRET_KEY", None)
|
||||
if not secret:
|
||||
return True # Not configured — allow in dev/no-captcha mode
|
||||
try:
|
||||
import httpx
|
||||
resp = httpx.post(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
data={"secret": secret, "response": token},
|
||||
timeout=10,
|
||||
)
|
||||
return resp.json().get("success", False)
|
||||
except Exception as e:
|
||||
log.warning(f"Turnstile verification failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def submit_contact(req: ContactRequest, db: Session = Depends(get_db)):
|
||||
"""Public endpoint — no auth required."""
|
||||
if req.type not in ("question", "moderator"):
|
||||
raise HTTPException(status_code=400, detail="Type must be 'question' or 'moderator'")
|
||||
|
||||
name = req.name.strip()[:120]
|
||||
message = req.message.strip()[:2000]
|
||||
if not name or not message:
|
||||
raise HTTPException(status_code=400, detail="Name and message are required")
|
||||
|
||||
# Verify Turnstile if configured
|
||||
secret = getattr(settings, "TURNSTILE_SECRET_KEY", None)
|
||||
if secret:
|
||||
if not req.turnstile_token:
|
||||
raise HTTPException(status_code=400, detail="Bot verification required")
|
||||
if not await _verify_turnstile(req.turnstile_token):
|
||||
raise HTTPException(status_code=400, detail="Bot verification failed — please try again")
|
||||
|
||||
submission = ContactSubmission(
|
||||
name=name,
|
||||
email=req.email.lower().strip(),
|
||||
type=req.type,
|
||||
message=message,
|
||||
)
|
||||
db.add(submission)
|
||||
db.commit()
|
||||
|
||||
# Email admin
|
||||
_email_admin(name, req.email, req.type, message)
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
def _email_admin(name: str, email: str, type_: str, message: str):
|
||||
try:
|
||||
from app.services.email_service import send_email
|
||||
label = "Moderator Application" if type_ == "moderator" else "Contact Form Question"
|
||||
body = (
|
||||
f"<h2>New {label} — PedsHub</h2>"
|
||||
f"<p><strong>From:</strong> {name} <{email}></p>"
|
||||
f"<p><strong>Message:</strong></p>"
|
||||
f"<blockquote style='border-left:4px solid #2563eb;padding-left:12px;color:#334155'>"
|
||||
f"{message.replace(chr(10), '<br>')}"
|
||||
f"</blockquote>"
|
||||
)
|
||||
admin_email = getattr(settings, "ADMIN_EMAIL", None) or getattr(settings, "SMTP_FROM", None)
|
||||
if admin_email:
|
||||
send_email(to=admin_email, subject=f"[PedsHub] {label} from {name}", html=body)
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to email admin about contact submission: {e}")
|
||||
|
||||
|
||||
# ── Admin view of submissions ─────────────────────────────────────────────────
|
||||
|
||||
@router.get("/submissions")
|
||||
def list_submissions(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Admin: list all contact submissions."""
|
||||
from app.utils.auth import require_admin
|
||||
rows = db.query(ContactSubmission).order_by(ContactSubmission.created_at.desc()).all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"email": r.email,
|
||||
"type": r.type,
|
||||
"message": r.message,
|
||||
"read": r.read,
|
||||
"created_at": r.created_at.isoformat(),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.put("/submissions/{submission_id}/read")
|
||||
def mark_read(
|
||||
submission_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
row = db.query(ContactSubmission).filter(ContactSubmission.id == submission_id).first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
row.read = 1
|
||||
db.commit()
|
||||
return {"success": True}
|
||||
|
|
@ -21,11 +21,12 @@ import VerifyEmailPage from './pages/VerifyEmailPage'
|
|||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||
import NotFoundPage from './pages/NotFoundPage'
|
||||
import LandingPage from './pages/LandingPage'
|
||||
|
||||
function ProtectedRoute({ children, requireModerator = false }) {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return <div className="loading"><div className="spinner"></div></div>
|
||||
if (!user) return <Navigate to="/login" />
|
||||
if (!user) return <Navigate to="/home" />
|
||||
if (requireModerator && user.role !== 'admin' && user.role !== 'moderator') return <Navigate to="/" />
|
||||
return children
|
||||
}
|
||||
|
|
@ -46,11 +47,22 @@ function AppRoutes() {
|
|||
|
||||
return (
|
||||
<>
|
||||
{!user && <Routes>
|
||||
<Route path="/home" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="*" element={<Navigate to="/home" />} />
|
||||
</Routes>}
|
||||
{user && <>
|
||||
<Navbar />
|
||||
<div className="container">
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? <Navigate to="/" /> : <LoginPage />} />
|
||||
<Route path="/register" element={user ? <Navigate to="/" /> : <RegisterPage />} />
|
||||
<Route path="/login" element={<Navigate to="/" />} />
|
||||
<Route path="/register" element={<Navigate to="/" />} />
|
||||
<Route path="/home" element={<Navigate to="/" />} />
|
||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
|
|
@ -71,6 +83,7 @@ function AppRoutes() {
|
|||
</Routes>
|
||||
</div>
|
||||
<Footer />
|
||||
</>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
373
frontend/src/pages/LandingPage.jsx
Normal file
373
frontend/src/pages/LandingPage.jsx
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
|
||||
// ── Turnstile widget ──────────────────────────────────────────────────────────
|
||||
const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
|
||||
|
||||
function TurnstileWidget({ onVerify }) {
|
||||
const ref = useRef(null)
|
||||
useEffect(() => {
|
||||
if (!TURNSTILE_SITE_KEY || !window.turnstile) return
|
||||
const id = window.turnstile.render(ref.current, {
|
||||
sitekey: TURNSTILE_SITE_KEY,
|
||||
callback: onVerify,
|
||||
})
|
||||
return () => { try { window.turnstile.remove(id) } catch {} }
|
||||
}, [])
|
||||
if (!TURNSTILE_SITE_KEY) return null
|
||||
return <div ref={ref} style={{ margin: '8px 0' }} />
|
||||
}
|
||||
|
||||
// ── Feature cards data ────────────────────────────────────────────────────────
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: '📄',
|
||||
title: 'Quiz from Any PDF',
|
||||
desc: 'Upload PREP materials, textbook chapters, or lecture slides. AI extracts questions with answers and explanations — no formatting required.',
|
||||
},
|
||||
{
|
||||
icon: '🎓',
|
||||
title: 'AI Tutor Built In',
|
||||
desc: 'Ask anything mid-study. The AI tutor knows the current question, the correct answer, and pulls in related questions from your bank for context.',
|
||||
},
|
||||
{
|
||||
icon: '🔊',
|
||||
title: 'Audio Mode',
|
||||
desc: 'Every question read aloud with natural-sounding voices. Study during rounds, commuting, or on a run — no screen needed.',
|
||||
},
|
||||
{
|
||||
icon: '🏦',
|
||||
title: 'Question Bank',
|
||||
desc: 'All questions in one searchable place. Filter by category, topic, or search semantically — "hyponatremia in neonates" just works.',
|
||||
},
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Track Your Progress',
|
||||
desc: 'Every attempt saved. Review past scores, see where you struggled, and focus your study time where it counts.',
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Fast by Design',
|
||||
desc: 'Study mode loads instantly. The AI works in the background — it never holds up the quiz. No spinners on the critical path.',
|
||||
},
|
||||
]
|
||||
|
||||
// ── Contact form ──────────────────────────────────────────────────────────────
|
||||
function ContactForm() {
|
||||
const [form, setForm] = useState({ name: '', email: '', type: 'question', message: '' })
|
||||
const [turnstileToken, setTurnstileToken] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const hasTurnstile = !!TURNSTILE_SITE_KEY
|
||||
const canSubmit = form.name && form.email && form.message && (!hasTurnstile || turnstileToken)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.post('/contact', { ...form, turnstile_token: turnstileToken || null })
|
||||
setSent(true)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Something went wrong. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (sent) return (
|
||||
<div style={{ textAlign: 'center', padding: '32px 0' }}>
|
||||
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>✓</div>
|
||||
<h3 style={{ fontSize: '1.2rem', marginBottom: 8 }}>Message received</h3>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||
{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} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: 5, color: 'var(--text-muted)' }}>Name</label>
|
||||
<input
|
||||
value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Your name" required maxLength={120}
|
||||
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: 5, color: 'var(--text-muted)' }}>Email</label>
|
||||
<input
|
||||
type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||
placeholder="you@example.com" required
|
||||
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: 5, color: 'var(--text-muted)' }}>Type</label>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
{[['question', '💬 Question'], ['moderator', '🛡 Apply as Moderator']].map(([val, label]) => (
|
||||
<button
|
||||
key={val} type="button"
|
||||
onClick={() => setForm(f => ({ ...f, type: val }))}
|
||||
style={{
|
||||
flex: 1, padding: '8px 12px', borderRadius: 8, fontSize: '0.85rem', cursor: 'pointer',
|
||||
fontWeight: form.type === val ? 700 : 400,
|
||||
background: form.type === val ? 'var(--primary)' : 'var(--bg)',
|
||||
color: form.type === val ? 'var(--primary-fg)' : 'var(--text)',
|
||||
border: `1px solid ${form.type === val ? 'var(--primary)' : 'var(--border)'}`,
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
{form.type === 'moderator' && (
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 6 }}>
|
||||
Moderators can upload PDFs and manage quiz content. Tell us about your background and how you'd like to contribute.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: 5, color: 'var(--text-muted)' }}>Message</label>
|
||||
<textarea
|
||||
value={form.message} onChange={e => setForm(f => ({ ...f, message: e.target.value }))}
|
||||
placeholder={form.type === 'moderator' ? 'Tell us about your background, specialty, and how you'd like to help...' : 'Your question or message...'}
|
||||
required rows={4} maxLength={2000}
|
||||
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<TurnstileWidget onVerify={setTurnstileToken} />
|
||||
{error && <div style={{ color: 'var(--wrong-fg)', fontSize: '0.85rem', background: 'var(--wrong-bg)', padding: '8px 12px', borderRadius: 6 }}>{error}</div>}
|
||||
<button
|
||||
type="submit" disabled={loading || !canSubmit}
|
||||
className="btn btn-primary"
|
||||
style={{ alignSelf: 'flex-start', minWidth: 140 }}
|
||||
>
|
||||
{loading ? 'Sending…' : 'Send Message'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main landing page ─────────────────────────────────────────────────────────
|
||||
export default function LandingPage() {
|
||||
// Load Turnstile script once
|
||||
useEffect(() => {
|
||||
if (!TURNSTILE_SITE_KEY || document.getElementById('cf-turnstile-script')) return
|
||||
const s = document.createElement('script')
|
||||
s.id = 'cf-turnstile-script'
|
||||
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
|
||||
s.async = true
|
||||
document.head.appendChild(s)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg)' }}>
|
||||
|
||||
{/* ── Navbar ─────────────────────────────────────────────────────────── */}
|
||||
<nav style={{
|
||||
position: 'sticky', top: 0, zIndex: 100,
|
||||
background: 'var(--navbar-bg)', color: 'var(--navbar-fg)',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
}}>
|
||||
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '0 24px', height: 56, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontWeight: 800, fontSize: '1.25rem', letterSpacing: '-0.02em', color: '#e2e8f0' }}>
|
||||
Peds<span style={{ color: '#60a5fa' }}>Hub</span>
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<a
|
||||
href="https://peds.danvics.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: '0.85rem', color: '#94a3b8', textDecoration: 'none', padding: '6px 12px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#e2e8f0'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#94a3b8'}
|
||||
>
|
||||
AI Scribe ↗
|
||||
</a>
|
||||
<a href="#contact" style={{ fontSize: '0.85rem', color: '#94a3b8', textDecoration: 'none', padding: '6px 12px', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#e2e8f0'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#94a3b8'}
|
||||
>Contact</a>
|
||||
<Link to="/login" style={{ fontSize: '0.875rem', color: '#e2e8f0', textDecoration: 'none', padding: '7px 16px', borderRadius: 8, border: '1px solid rgba(255,255,255,0.18)', transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,255,255,0.08)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>Sign In</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ── Hero ───────────────────────────────────────────────────────────── */}
|
||||
<section style={{
|
||||
background: 'linear-gradient(135deg, #0f172a 0%, #1e3a5f 50%, #0f172a 100%)',
|
||||
color: 'white',
|
||||
padding: '96px 24px 88px',
|
||||
textAlign: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* subtle grid */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, opacity: 0.04,
|
||||
backgroundImage: 'linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)',
|
||||
backgroundSize: '48px 48px',
|
||||
}} />
|
||||
<div style={{ position: 'relative', maxWidth: 720, margin: '0 auto' }}>
|
||||
<div style={{ display: 'inline-block', background: 'rgba(96,165,250,0.15)', border: '1px solid rgba(96,165,250,0.3)', borderRadius: 20, padding: '4px 14px', fontSize: '0.78rem', fontWeight: 600, color: '#93c5fd', marginBottom: 28, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
|
||||
Pediatric Learning Platform
|
||||
</div>
|
||||
<h1 style={{ fontSize: 'clamp(2.2rem, 5vw, 3.4rem)', fontWeight: 800, lineHeight: 1.15, margin: '0 0 22px', letterSpacing: '-0.03em' }}>
|
||||
Turn your textbooks<br />
|
||||
<span style={{ background: 'linear-gradient(90deg, #60a5fa, #a78bfa)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
|
||||
into tests
|
||||
</span>
|
||||
</h1>
|
||||
<p style={{ fontSize: '1.15rem', color: '#94a3b8', lineHeight: 1.7, marginBottom: 40, maxWidth: 560, margin: '0 auto 40px' }}>
|
||||
Upload any PDF — PREP materials, lecture notes, textbook chapters.
|
||||
AI extracts questions, reads them aloud, and explains every answer.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 14, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<Link to="/login" className="btn btn-primary" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10 }}>
|
||||
Sign In
|
||||
</Link>
|
||||
<a href="#contact" className="btn" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10, background: 'rgba(255,255,255,0.08)', color: 'white', border: '1px solid rgba(255,255,255,0.18)', textDecoration: 'none' }}>
|
||||
Request Access
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Features ───────────────────────────────────────────────────────── */}
|
||||
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '80px 24px' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 56 }}>
|
||||
<h2 style={{ fontSize: 'clamp(1.6rem, 3vw, 2.2rem)', fontWeight: 800, letterSpacing: '-0.02em', margin: '0 0 12px' }}>
|
||||
Everything you need to study smarter
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '1.05rem', maxWidth: 480, margin: '0 auto' }}>
|
||||
Built specifically for pediatric board prep and clinical learning.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 20 }}>
|
||||
{FEATURES.map((f, i) => (
|
||||
<div key={i} className="card" style={{ padding: '24px', transition: 'transform 0.2s, box-shadow 0.2s' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 8px 32px rgba(0,0,0,0.12)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'none'; e.currentTarget.style.boxShadow = '' }}
|
||||
>
|
||||
<div style={{ fontSize: '2rem', marginBottom: 12 }}>{f.icon}</div>
|
||||
<h3 style={{ fontWeight: 700, fontSize: '1rem', margin: '0 0 8px' }}>{f.title}</h3>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem', lineHeight: 1.65, margin: 0 }}>{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── AI Scribe section ──────────────────────────────────────────────── */}
|
||||
<section style={{ background: 'var(--navbar-bg)', color: 'var(--navbar-fg)', padding: '80px 24px' }}>
|
||||
<div style={{ maxWidth: 1000, margin: '0 auto', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 56, alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ display: 'inline-block', background: 'rgba(96,165,250,0.15)', border: '1px solid rgba(96,165,250,0.3)', borderRadius: 20, padding: '4px 14px', fontSize: '0.78rem', fontWeight: 600, color: '#93c5fd', marginBottom: 20, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
|
||||
Also from PedsHub
|
||||
</div>
|
||||
<h2 style={{ fontSize: 'clamp(1.6rem, 3vw, 2rem)', fontWeight: 800, letterSpacing: '-0.02em', margin: '0 0 16px', color: '#f1f5f9' }}>
|
||||
Pediatric AI Scribe
|
||||
</h2>
|
||||
<p style={{ color: '#94a3b8', lineHeight: 1.7, marginBottom: 28, fontSize: '0.95rem' }}>
|
||||
A clinical assistant built for pediatric providers. Voice-to-note documentation,
|
||||
age-specific well visit workflows, developmental milestones, vaccine schedules,
|
||||
catch-up planners, and automatic ICD-10 billing codes — all in one tool.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 32 }}>
|
||||
{[
|
||||
'🩺 Well visit planner from newborn through adolescence',
|
||||
'📋 Developmental milestone tracking across 4 domains',
|
||||
'💉 Full AAP/ACIP vaccine schedule with catch-up planner',
|
||||
'🎙 AI scribe — speak, get a structured note',
|
||||
'💊 Automatic ICD-10 and CPT billing codes',
|
||||
].map((item, i) => (
|
||||
<div key={i} style={{ fontSize: '0.88rem', color: '#cbd5e1', display: 'flex', gap: 8, alignItems: 'flex-start' }}>
|
||||
<span style={{ flexShrink: 0 }}>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<a
|
||||
href="https://peds.danvics.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary"
|
||||
style={{ display: 'inline-block', textDecoration: 'none', padding: '11px 24px', borderRadius: 10 }}
|
||||
>
|
||||
Open AI Scribe ↗
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
{[
|
||||
{ icon: '📅', label: 'Well Visits', sub: '2wk → 18yr' },
|
||||
{ icon: '🧠', label: 'Milestones', sub: '2mo → 5yr' },
|
||||
{ icon: '💉', label: 'Vaccines', sub: 'Full schedule' },
|
||||
{ icon: '🎙', label: 'AI Scribe', sub: 'Voice-to-note' },
|
||||
{ icon: '💊', label: 'ICD-10', sub: 'Auto-suggested' },
|
||||
{ icon: '📋', label: 'SOAP Notes', sub: 'Structured' },
|
||||
].map((item, i) => (
|
||||
<div key={i} style={{
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: 10, padding: '16px',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,255,255,0.09)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'rgba(255,255,255,0.05)'}
|
||||
>
|
||||
<div style={{ fontSize: '1.5rem', marginBottom: 6 }}>{item.icon}</div>
|
||||
<div style={{ fontWeight: 700, fontSize: '0.85rem', color: '#e2e8f0' }}>{item.label}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748b' }}>{item.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Contact form ───────────────────────────────────────────────────── */}
|
||||
<section id="contact" style={{ maxWidth: 680, margin: '0 auto', padding: '80px 24px' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 40 }}>
|
||||
<h2 style={{ fontSize: 'clamp(1.5rem, 3vw, 2rem)', fontWeight: 800, letterSpacing: '-0.02em', margin: '0 0 12px' }}>
|
||||
Get in touch
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.95rem' }}>
|
||||
Have a question? Want to contribute as a moderator? We'd love to hear from you.
|
||||
</p>
|
||||
</div>
|
||||
<div className="card" style={{ padding: '32px' }}>
|
||||
<ContactForm />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Footer ─────────────────────────────────────────────────────────── */}
|
||||
<footer style={{
|
||||
background: 'var(--navbar-bg)', color: '#475569',
|
||||
padding: '32px 24px',
|
||||
borderTop: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<div style={{ maxWidth: 1100, margin: '0 auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
|
||||
<span style={{ fontWeight: 700, color: '#94a3b8' }}>
|
||||
Peds<span style={{ color: '#60a5fa' }}>Hub</span>
|
||||
<span style={{ fontWeight: 400, marginLeft: 16 }}>© {new Date().getFullYear()}</span>
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 24, fontSize: '0.85rem' }}>
|
||||
<Link to="/login" style={{ color: '#475569', textDecoration: 'none' }}>Sign In</Link>
|
||||
<a href="#contact" style={{ color: '#475569', textDecoration: 'none' }}>Contact</a>
|
||||
<a href="https://peds.danvics.com" target="_blank" rel="noopener noreferrer" style={{ color: '#475569', textDecoration: 'none' }}>AI Scribe</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue