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."""
|
||||
from datetime import datetime
|
||||
"""Contact form — stores submissions and emails admin.
|
||||
Table is created via raw SQL in setup_pgvector() to avoid race conditions with multiple workers.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db, Base
|
||||
from app.database import get_db
|
||||
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
|
||||
|
|
@ -70,13 +60,9 @@ async def submit_contact(req: ContactRequest, db: Session = Depends(get_db)):
|
|||
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.execute(text(
|
||||
"INSERT INTO contact_submissions (name, email, type, message) VALUES (:name, :email, :type, :message)"
|
||||
), {"name": name, "email": req.email.lower().strip(), "type": req.type, "message": message})
|
||||
db.commit()
|
||||
|
||||
# Email admin
|
||||
|
|
@ -112,15 +98,13 @@ def list_submissions(
|
|||
):
|
||||
"""Admin: list all contact submissions."""
|
||||
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 [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"email": r.email,
|
||||
"type": r.type,
|
||||
"message": r.message,
|
||||
"read": r.read,
|
||||
"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
|
||||
|
|
@ -132,9 +116,8 @@ 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
|
||||
result = db.execute(text("UPDATE contact_submissions SET read=1 WHERE id=:id"), {"id": submission_id})
|
||||
db.commit()
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
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 { ThemeProvider } from './context/ThemeContext'
|
||||
import Navbar from './components/Navbar'
|
||||
|
|
@ -23,68 +23,72 @@ 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="/home" />
|
||||
if (requireModerator && user.role !== 'admin' && user.role !== 'moderator') return <Navigate to="/" />
|
||||
return children
|
||||
// Layout wrapper for authenticated app pages (Navbar + container + footer)
|
||||
function AppLayout() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="container">
|
||||
<Outlet />
|
||||
</div>
|
||||
<footer className="site-footer">
|
||||
<div className="container">© {new Date().getFullYear()} PedsHub</div>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div className="container">
|
||||
© {new Date().getFullYear()} PedQuiz
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
// Guard: redirect to /home if not logged in, or to / if not moderator
|
||||
function RequireAuth({ moderator = false }) {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return <div className="loading"><div className="spinner" /></div>
|
||||
if (!user) return <Navigate to="/home" replace />
|
||||
if (moderator && user.role !== 'admin' && user.role !== 'moderator') return <Navigate to="/" replace />
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
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 (
|
||||
<>
|
||||
{!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={<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 />} />
|
||||
<Route path="/" element={<ProtectedRoute><DashboardPage /></ProtectedRoute>} />
|
||||
<Route path="/upload" element={<ProtectedRoute requireModerator><UploadPage /></ProtectedRoute>} />
|
||||
<Route path="/documents/:id" element={<ProtectedRoute><DocumentDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/quizzes" element={<ProtectedRoute><QuizzesPage /></ProtectedRoute>} />
|
||||
<Route path="/quizzes/:id" element={<ProtectedRoute><QuizPage /></ProtectedRoute>} />
|
||||
<Route path="/results/:id" element={<ProtectedRoute><ResultsPage /></ProtectedRoute>} />
|
||||
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
|
||||
<Route path="/quizzes/:id/edit" element={<ProtectedRoute requireModerator><QuizEditPage /></ProtectedRoute>} />
|
||||
<Route path="/account" element={<ProtectedRoute><AccountPage /></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute><SettingsPage /></ProtectedRoute>} />
|
||||
<Route path="/question-bank" element={<ProtectedRoute><QuestionBankPage /></ProtectedRoute>} />
|
||||
<Route path="/jobs" element={<ProtectedRoute requireModerator><JobsPage /></ProtectedRoute>} />
|
||||
<Route path="/trash" element={<ProtectedRoute requireModerator><TrashPage /></ProtectedRoute>} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<Footer />
|
||||
</>}
|
||||
</>
|
||||
<Routes>
|
||||
{/* Always public */}
|
||||
<Route path="/home" element={<LandingPage />} />
|
||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
|
||||
<Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} />
|
||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
|
||||
{/* Authenticated app — wrapped in AppLayout */}
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/quizzes" element={<QuizzesPage />} />
|
||||
<Route path="/quizzes/:id" element={<QuizPage />} />
|
||||
<Route path="/results/:id" element={<ResultsPage />} />
|
||||
<Route path="/documents/:id" element={<DocumentDetailPage />} />
|
||||
<Route path="/account" element={<AccountPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/question-bank" element={<QuestionBankPage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Moderator-only */}
|
||||
<Route element={<RequireAuth moderator />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/upload" element={<UploadPage />} />
|
||||
<Route path="/quizzes/:id/edit" element={<QuizEditPage />} />
|
||||
<Route path="/jobs" element={<JobsPage />} />
|
||||
<Route path="/trash" element={<TrashPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Catch-all */}
|
||||
<Route path="*" element={user ? <NotFoundPage /> : <Navigate to="/home" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
// ── Turnstile widget ──────────────────────────────────────────────────────────
|
||||
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>
|
||||
<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...'}
|
||||
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' }}
|
||||
/>
|
||||
|
|
@ -157,7 +158,11 @@ function ContactForm() {
|
|||
}
|
||||
|
||||
// ── Main landing page ─────────────────────────────────────────────────────────
|
||||
const navLink = { fontSize: '0.85rem', color: '#94a3b8', textDecoration: 'none', padding: '6px 10px', borderRadius: 6 }
|
||||
|
||||
export default function LandingPage() {
|
||||
const { user } = useAuth()
|
||||
|
||||
// Load Turnstile script once
|
||||
useEffect(() => {
|
||||
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' }}>
|
||||
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 style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
{user && <>
|
||||
<Link to="/" style={navLink}>Dashboard</Link>
|
||||
<Link to="/quizzes" style={navLink}>Quizzes</Link>
|
||||
<Link to="/question-bank" style={navLink}>Question Bank</Link>
|
||||
</>}
|
||||
<a href="https://peds.danvics.com" target="_blank" rel="noopener noreferrer" style={navLink}>AI Scribe ↗</a>
|
||||
<a href="#contact" style={navLink}>Contact</a>
|
||||
{user
|
||||
? <Link to="/" className="btn btn-primary" style={{ fontSize: '0.85rem', padding: '7px 16px', borderRadius: 8, textDecoration: 'none' }}>Go to App</Link>
|
||||
: <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>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
@ -235,12 +233,14 @@ export default function LandingPage() {
|
|||
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>
|
||||
{user ? <>
|
||||
<Link to="/" className="btn btn-primary" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10 }}>Dashboard</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>
|
||||
<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>
|
||||
</> : <>
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue