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:
parent
ee47fb07a2
commit
5403d78a3e
3 changed files with 101 additions and 114 deletions
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue