Fix landing page routing, add logged-in nav integration

- App.jsx: simplify to single Routes block with Outlet layout pattern — fixes 404
- App.jsx: /home always accessible regardless of auth state
- LandingPage: show Dashboard/Quizzes/Question Bank nav links + Go to App CTA when logged in
- LandingPage: hero shows app navigation buttons when signed in
- LandingPage: fix apostrophe JSX syntax error in textarea placeholder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-04 01:41:39 +02:00
parent ee47fb07a2
commit 5403d78a3e
3 changed files with 101 additions and 114 deletions

View file

@ -1,30 +1,20 @@
"""Contact form — stores submissions and emails admin.""" """Contact form — stores submissions and emails admin.
from datetime import datetime Table is created via raw SQL in setup_pgvector() to avoid race conditions with multiple workers.
"""
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from sqlalchemy import Column, Integer, String, Text, DateTime from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db, Base from app.database import get_db
from app.config import settings from app.config import settings
router = APIRouter() router = APIRouter()
log = logging.getLogger(__name__) 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): class ContactRequest(BaseModel):
name: str name: str
email: EmailStr email: EmailStr
@ -70,13 +60,9 @@ async def submit_contact(req: ContactRequest, db: Session = Depends(get_db)):
if not await _verify_turnstile(req.turnstile_token): if not await _verify_turnstile(req.turnstile_token):
raise HTTPException(status_code=400, detail="Bot verification failed — please try again") raise HTTPException(status_code=400, detail="Bot verification failed — please try again")
submission = ContactSubmission( db.execute(text(
name=name, "INSERT INTO contact_submissions (name, email, type, message) VALUES (:name, :email, :type, :message)"
email=req.email.lower().strip(), ), {"name": name, "email": req.email.lower().strip(), "type": req.type, "message": message})
type=req.type,
message=message,
)
db.add(submission)
db.commit() db.commit()
# Email admin # Email admin
@ -112,15 +98,13 @@ def list_submissions(
): ):
"""Admin: list all contact submissions.""" """Admin: list all contact submissions."""
from app.utils.auth import require_admin from app.utils.auth import require_admin
rows = db.query(ContactSubmission).order_by(ContactSubmission.created_at.desc()).all() rows = db.execute(text(
"SELECT id, name, email, type, message, read, created_at FROM contact_submissions ORDER BY created_at DESC"
)).fetchall()
return [ return [
{ {
"id": r.id, "id": r.id, "name": r.name, "email": r.email,
"name": r.name, "type": r.type, "message": r.message, "read": r.read,
"email": r.email,
"type": r.type,
"message": r.message,
"read": r.read,
"created_at": r.created_at.isoformat(), "created_at": r.created_at.isoformat(),
} }
for r in rows for r in rows
@ -132,9 +116,8 @@ def mark_read(
submission_id: int, submission_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
row = db.query(ContactSubmission).filter(ContactSubmission.id == submission_id).first() result = db.execute(text("UPDATE contact_submissions SET read=1 WHERE id=:id"), {"id": submission_id})
if not row:
raise HTTPException(status_code=404, detail="Not found")
row.read = 1
db.commit() db.commit()
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="Not found")
return {"success": True} return {"success": True}

View file

@ -1,4 +1,4 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext' import { AuthProvider, useAuth } from './context/AuthContext'
import { ThemeProvider } from './context/ThemeContext' import { ThemeProvider } from './context/ThemeContext'
import Navbar from './components/Navbar' import Navbar from './components/Navbar'
@ -23,68 +23,72 @@ import ResetPasswordPage from './pages/ResetPasswordPage'
import NotFoundPage from './pages/NotFoundPage' import NotFoundPage from './pages/NotFoundPage'
import LandingPage from './pages/LandingPage' import LandingPage from './pages/LandingPage'
function ProtectedRoute({ children, requireModerator = false }) { // Layout wrapper for authenticated app pages (Navbar + container + footer)
const { user, loading } = useAuth() function AppLayout() {
if (loading) return <div className="loading"><div className="spinner"></div></div> return (
if (!user) return <Navigate to="/home" /> <>
if (requireModerator && user.role !== 'admin' && user.role !== 'moderator') return <Navigate to="/" /> <Navbar />
return children <div className="container">
<Outlet />
</div>
<footer className="site-footer">
<div className="container">© {new Date().getFullYear()} PedsHub</div>
</footer>
</>
)
} }
function Footer() { // Guard: redirect to /home if not logged in, or to / if not moderator
return ( function RequireAuth({ moderator = false }) {
<footer className="site-footer"> const { user, loading } = useAuth()
<div className="container"> if (loading) return <div className="loading"><div className="spinner" /></div>
© {new Date().getFullYear()} PedQuiz if (!user) return <Navigate to="/home" replace />
</div> if (moderator && user.role !== 'admin' && user.role !== 'moderator') return <Navigate to="/" replace />
</footer> return <Outlet />
)
} }
function AppRoutes() { function AppRoutes() {
const { user, loading } = useAuth() const { user, loading } = useAuth()
if (loading) return <div className="loading"><div className="spinner"></div></div> if (loading) return <div className="loading"><div className="spinner" /></div>
return ( return (
<> <Routes>
{!user && <Routes> {/* Always public */}
<Route path="/home" element={<LandingPage />} /> <Route path="/home" element={<LandingPage />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} /> <Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} /> <Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} /> <Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="*" element={<Navigate to="/home" />} />
</Routes>} {/* Authenticated app — wrapped in AppLayout */}
{user && <> <Route element={<RequireAuth />}>
<Navbar /> <Route element={<AppLayout />}>
<div className="container"> <Route path="/" element={<DashboardPage />} />
<Routes> <Route path="/quizzes" element={<QuizzesPage />} />
<Route path="/login" element={<Navigate to="/" />} /> <Route path="/quizzes/:id" element={<QuizPage />} />
<Route path="/register" element={<Navigate to="/" />} /> <Route path="/results/:id" element={<ResultsPage />} />
<Route path="/home" element={<Navigate to="/" />} /> <Route path="/documents/:id" element={<DocumentDetailPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} /> <Route path="/account" element={<AccountPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} /> <Route path="/question-bank" element={<QuestionBankPage />} />
<Route path="/" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} /> <Route path="/admin" element={<AdminPage />} />
<Route path="/upload" element={<ProtectedRoute requireModerator><UploadPage /></ProtectedRoute>} /> </Route>
<Route path="/documents/:id" element={<ProtectedRoute><DocumentDetailPage /></ProtectedRoute>} /> </Route>
<Route path="/quizzes" element={<ProtectedRoute><QuizzesPage /></ProtectedRoute>} />
<Route path="/quizzes/:id" element={<ProtectedRoute><QuizPage /></ProtectedRoute>} /> {/* Moderator-only */}
<Route path="/results/:id" element={<ProtectedRoute><ResultsPage /></ProtectedRoute>} /> <Route element={<RequireAuth moderator />}>
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} /> <Route element={<AppLayout />}>
<Route path="/quizzes/:id/edit" element={<ProtectedRoute requireModerator><QuizEditPage /></ProtectedRoute>} /> <Route path="/upload" element={<UploadPage />} />
<Route path="/account" element={<ProtectedRoute><AccountPage /></ProtectedRoute>} /> <Route path="/quizzes/:id/edit" element={<QuizEditPage />} />
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} /> <Route path="/jobs" element={<JobsPage />} />
<Route path="/question-bank" element={<ProtectedRoute><QuestionBankPage /></ProtectedRoute>} /> <Route path="/trash" element={<TrashPage />} />
<Route path="/jobs" element={<ProtectedRoute requireModerator><JobsPage /></ProtectedRoute>} /> </Route>
<Route path="/trash" element={<ProtectedRoute requireModerator><TrashPage /></ProtectedRoute>} /> </Route>
<Route path="*" element={<NotFoundPage />} />
</Routes> {/* Catch-all */}
</div> <Route path="*" element={user ? <NotFoundPage /> : <Navigate to="/home" replace />} />
<Footer /> </Routes>
</>}
</>
) )
} }

View file

@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import api from '../api/client' import api from '../api/client'
import { useAuth } from '../context/AuthContext'
// Turnstile widget // Turnstile widget
const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || '' const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
@ -138,7 +139,7 @@ function ContactForm() {
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: 5, color: 'var(--text-muted)' }}>Message</label> <label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: 5, color: 'var(--text-muted)' }}>Message</label>
<textarea <textarea
value={form.message} onChange={e => setForm(f => ({ ...f, message: e.target.value }))} 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...'} 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} 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' }} 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' }}
/> />
@ -157,7 +158,11 @@ function ContactForm() {
} }
// Main landing page // Main landing page
const navLink = { fontSize: '0.85rem', color: '#94a3b8', textDecoration: 'none', padding: '6px 10px', borderRadius: 6 }
export default function LandingPage() { export default function LandingPage() {
const { user } = useAuth()
// Load Turnstile script once // Load Turnstile script once
useEffect(() => { useEffect(() => {
if (!TURNSTILE_SITE_KEY || document.getElementById('cf-turnstile-script')) return if (!TURNSTILE_SITE_KEY || document.getElementById('cf-turnstile-script')) return
@ -182,25 +187,18 @@ export default function LandingPage() {
<span style={{ fontWeight: 800, fontSize: '1.25rem', letterSpacing: '-0.02em', color: '#e2e8f0' }}> <span style={{ fontWeight: 800, fontSize: '1.25rem', letterSpacing: '-0.02em', color: '#e2e8f0' }}>
Peds<span style={{ color: '#60a5fa' }}>Hub</span> Peds<span style={{ color: '#60a5fa' }}>Hub</span>
</span> </span>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<a {user && <>
href="https://peds.danvics.com" <Link to="/" style={navLink}>Dashboard</Link>
target="_blank" <Link to="/quizzes" style={navLink}>Quizzes</Link>
rel="noopener noreferrer" <Link to="/question-bank" style={navLink}>Question Bank</Link>
style={{ fontSize: '0.85rem', color: '#94a3b8', textDecoration: 'none', padding: '6px 12px', borderRadius: 6, transition: 'color 0.15s' }} </>}
onMouseEnter={e => e.currentTarget.style.color = '#e2e8f0'} <a href="https://peds.danvics.com" target="_blank" rel="noopener noreferrer" style={navLink}>AI Scribe </a>
onMouseLeave={e => e.currentTarget.style.color = '#94a3b8'} <a href="#contact" style={navLink}>Contact</a>
> {user
AI Scribe ? <Link to="/" className="btn btn-primary" style={{ fontSize: '0.85rem', padding: '7px 16px', borderRadius: 8, textDecoration: 'none' }}>Go to App</Link>
</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)' }}>Sign In</Link>
<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>
</div> </div>
</nav> </nav>
@ -235,12 +233,14 @@ export default function LandingPage() {
AI extracts questions, reads them aloud, and explains every answer. AI extracts questions, reads them aloud, and explains every answer.
</p> </p>
<div style={{ display: 'flex', gap: 14, justifyContent: 'center', flexWrap: 'wrap' }}> <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 }}> {user ? <>
Sign In <Link to="/" className="btn btn-primary" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10 }}>Dashboard</Link>
</Link> <Link to="/quizzes" 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' }}>My Quizzes</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' }}> <Link to="/question-bank" 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' }}>Question Bank</Link>
Request Access </> : <>
</a> <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>
</div> </div>
</section> </section>