feat: guest access, pricing modal, UI polish, LaTeX prompt fix

- Remove login gate, allow guest browsing with Sign in link
- Add favicon (book logo)
- Add pricing modal (Free/Standard/Exam) with hover animations
- Dynamic course list from DB instead of hardcoded
- Enforce LaTeX in AI trio generation prompt
- UI improvements: homepage animations, analytics donut chart, error book cards
- Fix error book locked state for guests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zhao
2026-04-21 18:40:22 +07:00
parent 7a09167261
commit 10fa2b74ef
7 changed files with 580 additions and 91 deletions

View File

@@ -144,6 +144,7 @@ Solution requirements:
- Mark common mistakes with <div class="common-error">...</div> - Mark common mistakes with <div class="common-error">...</div>
KaTeX formula rules: KaTeX formula rules:
- CRITICAL: ALL math expressions MUST use LaTeX inside $ or $$. NEVER use Unicode symbols like ⁿ, ≥, ≠, ², ×, ∑, ∈. Use $n$, $\geq$, $\neq$, $^2$, $\times$, $\sum$, $\in$ instead.
- Block formula: $$ on its own line, with blank lines before and after - Block formula: $$ on its own line, with blank lines before and after
- Inline formula: $x^2$ no line break - Inline formula: $x^2$ no line break
- Matrix: \\begin{{bmatrix}} ... \\end{{bmatrix}} - Matrix: \\begin{{bmatrix}} ... \\end{{bmatrix}}
@@ -174,6 +175,7 @@ Rules:
- Keep each item matched to the same question_number - Keep each item matched to the same question_number
- All text must be in English - All text must be in English
- HTML only, KaTeX compatible - HTML only, KaTeX compatible
- CRITICAL LaTeX requirement: ALL mathematical expressions MUST use LaTeX notation wrapped in $ (inline) or $$ (display block). NEVER use Unicode math symbols like ⁿ, ≥, ≠, ², ×, ∑, ∈, ⊆, etc. Instead use $n$, $\geq$, $\neq$, $^2$, $\times$, $\sum$, $\in$, $\subseteq$, etc. Every variable, number in a formula, operator, and equation must be inside $ delimiters.
- For MC questions, explain why the correct option is right and why the others are wrong - For MC questions, explain why the correct option is right and why the others are wrong
- For long questions, show a complete derivation or reasoning chain - For long questions, show a complete derivation or reasoning chain
- Use <ol> or numbered steps in solution when appropriate - Use <ol> or numbered steps in solution when appropriate

View File

@@ -0,0 +1,47 @@
"""
One-off script: clear AI trio for COMP2711H paper and regenerate with LaTeX-enforced prompt.
Run inside the backend Docker container or locally with .env loaded.
"""
import asyncio
import json
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
from app.services.supabase_client import get_supabase
from app.services.paper_processor import _resume_ai_trio
PAPER_ID = "5ee87a62-65bf-4952-be40-fcdf9ba7ca63" # COMP2711H 2025 fall final
async def main():
sb = get_supabase()
# 1. Clear existing AI trio fields so _resume_ai_trio will regenerate them
questions = sb.table("paper_questions").select("id, question_number, solution").eq("paper_id", PAPER_ID).execute().data
print(f"Found {len(questions)} questions for paper {PAPER_ID[:8]}")
cleared = 0
for q in questions:
if q.get("solution"):
sb.table("paper_questions").update({
"knowledge_reminder": "",
"ai_hint": "",
"solution": "",
}).eq("id", q["id"]).execute()
cleared += 1
q["solution"] = None # so _resume_ai_trio picks it up
print(f"Cleared AI trio for {cleared} questions. Regenerating...")
# 2. Regenerate
await _resume_ai_trio(sb, PAPER_ID, questions)
print("Done! AI trio regenerated with LaTeX-enforced prompt.")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -1,5 +1,7 @@
import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import PricingModal from "@/components/shared/PricingModal";
export default function Header({ export default function Header({
courseCode, courseCode,
@@ -9,8 +11,10 @@ export default function Header({
paperTitle?: string; paperTitle?: string;
}) { }) {
const { user, signOut } = useAuth(); const { user, signOut } = useAuth();
const [showPricing, setShowPricing] = useState(false);
return ( return (
<>
<header className="h-14 border-b border-gray-200 bg-white flex items-center px-6 shrink-0"> <header className="h-14 border-b border-gray-200 bg-white flex items-center px-6 shrink-0">
<Link to="/" className="text-lg font-bold text-blue-600 mr-6"> <Link to="/" className="text-lg font-bold text-blue-600 mr-6">
PastPaper Master PastPaper Master
@@ -48,6 +52,15 @@ export default function Header({
{user ? ( {user ? (
<div className="flex items-center gap-3 pl-4 border-l border-gray-200"> <div className="flex items-center gap-3 pl-4 border-l border-gray-200">
<span className="text-xs text-gray-400">{user.email}</span> <span className="text-xs text-gray-400">{user.email}</span>
<button
onClick={() => setShowPricing(true)}
className="flex items-center gap-1 text-[11px] font-semibold px-2 py-0.5 rounded-full bg-green-50 text-green-700 border border-green-200 hover:bg-green-100 transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
Standard
</button>
<button <button
onClick={signOut} onClick={signOut}
className="text-xs text-gray-500 hover:text-gray-800 px-2 py-1 rounded hover:bg-gray-100" className="text-xs text-gray-500 hover:text-gray-800 px-2 py-1 rounded hover:bg-gray-100"
@@ -65,5 +78,7 @@ export default function Header({
)} )}
</div> </div>
</header> </header>
{showPricing && <PricingModal onClose={() => setShowPricing(false)} />}
</>
); );
} }

View File

@@ -0,0 +1,203 @@
import { useEffect, useState } from "react";
export default function PricingModal({ onClose }: { onClose: () => void }) {
const [visible, setVisible] = useState(false);
// Animate in
useEffect(() => {
requestAnimationFrame(() => setVisible(true));
}, []);
const handleClose = () => {
setVisible(false);
setTimeout(onClose, 200);
};
return (
<div
className={`fixed inset-0 z-50 flex items-center justify-center transition-all duration-200 ${
visible ? "bg-black/50 backdrop-blur-sm" : "bg-black/0"
}`}
onClick={handleClose}
>
<div
className={`bg-gradient-to-b from-slate-50 to-white rounded-2xl shadow-2xl max-w-[820px] w-full mx-4 relative overflow-hidden transition-all duration-300 ${
visible ? "opacity-100 scale-100 translate-y-0" : "opacity-0 scale-95 translate-y-4"
}`}
onClick={(e) => e.stopPropagation()}
>
{/* Decorative top bar */}
<div className="h-1.5 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500" />
{/* Close */}
<button
onClick={handleClose}
className="absolute top-5 right-5 w-8 h-8 flex items-center justify-center rounded-full bg-gray-100 text-gray-400 hover:bg-gray-200 hover:text-gray-600 hover:rotate-90 transition-all duration-200 text-lg"
>
&times;
</button>
<div className="px-8 pt-7 pb-8">
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">
Choose Your Plan
</h2>
<p className="text-sm text-gray-400 mt-1.5">
Unlock smarter exam prep. All prices in HKD per month.
</p>
</div>
{/* Plans grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 items-stretch">
{/* ── Free ── */}
<div className="group relative rounded-2xl border border-gray-200 bg-white p-6 flex flex-col shadow-sm hover:shadow-md hover:border-gray-300 hover:-translate-y-1 transition-all duration-300 cursor-default">
{/* Hover glow */}
<div className="absolute inset-0 rounded-2xl bg-gradient-to-b from-gray-50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
<div className="relative mb-5">
<h3 className="text-lg font-bold text-gray-800 group-hover:text-gray-900 transition-colors">Free</h3>
<p className="text-xs text-gray-400 mt-0.5">Get started</p>
</div>
<div className="relative mb-5">
<span className="text-4xl font-extrabold text-gray-900">$0</span>
<span className="text-sm text-gray-400 ml-1">/ month</span>
</div>
<ul className="relative space-y-2.5 flex-1 mb-6">
{[
"Browse public papers",
"View AI solutions",
"Basic error book",
].map((f, i) => (
<li
key={f}
className="flex items-start gap-2 text-sm text-gray-600 group-hover:text-gray-700 transition-colors"
style={{ transitionDelay: `${i * 30}ms` }}
>
<svg className="w-4 h-4 text-gray-300 group-hover:text-gray-400 shrink-0 mt-0.5 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{f}
</li>
))}
</ul>
<button
disabled
className="relative w-full py-2.5 rounded-xl text-sm font-semibold bg-gray-100 text-gray-400 cursor-default"
>
Free Forever
</button>
</div>
{/* ── Standard (current, with early bird) ── */}
<div className="group relative rounded-2xl border-2 border-indigo-500 bg-white p-6 flex flex-col shadow-lg shadow-indigo-100 scale-[1.03] hover:shadow-xl hover:shadow-indigo-200 hover:-translate-y-1 transition-all duration-300">
{/* Animated shimmer */}
<div className="absolute inset-0 rounded-2xl overflow-hidden pointer-events-none">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-indigo-50/80 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-1000 ease-in-out" />
</div>
{/* Badge */}
<span className="absolute -top-3.5 left-1/2 -translate-x-1/2 text-[11px] font-bold px-4 py-1 rounded-full bg-indigo-600 text-white shadow-md whitespace-nowrap group-hover:shadow-lg group-hover:bg-indigo-700 transition-all duration-200">
Your Plan
</span>
<div className="relative mb-5 mt-1">
<h3 className="text-lg font-bold text-gray-800">Standard</h3>
<p className="text-xs text-gray-400 mt-0.5">Most popular</p>
</div>
<div className="relative mb-1">
<span className="text-sm text-gray-400 line-through">$19.9</span>
</div>
<div className="relative mb-1 flex items-baseline gap-2">
<span className="text-4xl font-extrabold text-indigo-600 group-hover:text-indigo-700 transition-colors">$9.9</span>
<span className="text-sm text-gray-400">/ month</span>
</div>
<div className="relative mb-5">
<span className="inline-block text-[10px] font-bold px-2.5 py-0.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-400 text-white tracking-wide group-hover:from-amber-500 group-hover:to-orange-500 group-hover:shadow-sm transition-all duration-200 animate-pulse">
EARLY BIRD PRICE
</span>
</div>
<ul className="relative space-y-2.5 flex-1 mb-6">
{[
"Unlimited paper uploads",
"AI question extraction",
"Step-by-step solutions",
"Photo auto-grading",
"Similar question retrieval",
"Course analytics",
"Error book + review",
].map((f, i) => (
<li
key={f}
className="flex items-start gap-2 text-sm text-gray-700 group-hover:translate-x-0.5 transition-transform duration-200"
style={{ transitionDelay: `${i * 30}ms` }}
>
<svg className="w-4 h-4 text-indigo-500 group-hover:text-indigo-600 shrink-0 mt-0.5 transition-colors group-hover:scale-110 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{f}
</li>
))}
</ul>
<button
disabled
className="relative w-full py-2.5 rounded-xl text-sm font-bold bg-indigo-600 text-white cursor-default opacity-90"
>
Current Plan
</button>
</div>
{/* ── Exam ── */}
<div className="group relative rounded-2xl border border-gray-200 bg-white p-6 flex flex-col shadow-sm hover:shadow-lg hover:border-amber-300 hover:-translate-y-1 transition-all duration-300 cursor-pointer">
{/* Hover glow */}
<div className="absolute inset-0 rounded-2xl bg-gradient-to-b from-amber-50/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
<span className="absolute -top-3.5 left-1/2 -translate-x-1/2 text-[11px] font-bold px-4 py-1 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-md whitespace-nowrap group-hover:shadow-lg group-hover:scale-105 transition-all duration-200">
Best Value
</span>
<div className="relative mb-5 mt-1">
<h3 className="text-lg font-bold text-gray-800 group-hover:text-gray-900 transition-colors">Exam</h3>
<p className="text-xs text-gray-400 mt-0.5">Finals season</p>
</div>
<div className="relative mb-5">
<span className="text-4xl font-extrabold text-gray-900 group-hover:text-amber-600 transition-colors duration-300">$29.9</span>
<span className="text-sm text-gray-400 ml-1">/ month</span>
</div>
<ul className="relative space-y-2.5 flex-1 mb-6">
{[
"Everything in Standard",
"AI Tutor (RAG-powered)",
"Priority processing",
"Unlimited variant generation",
"Cross-course analytics",
"Export & print",
].map((f, i) => (
<li
key={f}
className="flex items-start gap-2 text-sm text-gray-700 group-hover:translate-x-0.5 transition-transform duration-200"
style={{ transitionDelay: `${i * 30}ms` }}
>
<svg className="w-4 h-4 text-amber-500 group-hover:text-amber-600 shrink-0 mt-0.5 transition-all group-hover:scale-110" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{f}
</li>
))}
</ul>
<button className="relative w-full py-2.5 rounded-xl text-sm font-bold bg-gray-900 text-white hover:bg-amber-600 hover:shadow-lg hover:shadow-amber-200 active:scale-[0.98] transition-all duration-200">
Upgrade
</button>
</div>
</div>
<p className="text-center text-xs text-gray-400 mt-7">
All registered users enjoy Early Bird pricing during beta.
Payment integration coming soon.
</p>
</div>
</div>
</div>
);
}

View File

@@ -141,10 +141,10 @@ export default function AnalyticsPage() {
{analytics.high_yield_topics.length === 0 ? ( {analytics.high_yield_topics.length === 0 ? (
<div className="text-sm text-gray-400">No data yet.</div> <div className="text-sm text-gray-400">No data yet.</div>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-1">
{analytics.high_yield_topics.map((t, i) => ( {analytics.high_yield_topics.map((t, i) => (
<li key={t} className="flex items-center gap-3 text-sm text-gray-700"> <li key={t} className="flex items-center gap-3 text-sm text-gray-700 px-2 py-1.5 rounded-lg hover:bg-red-50/50 transition-colors duration-150 cursor-default">
<span className="w-6 h-6 rounded-full bg-red-50 text-red-600 flex items-center justify-center text-xs font-semibold">{i + 1}</span> <span className="w-6 h-6 rounded-full bg-gradient-to-br from-red-400 to-rose-500 text-white flex items-center justify-center text-xs font-bold shadow-sm">{i + 1}</span>
<span>{t}</span> <span>{t}</span>
</li> </li>
))} ))}
@@ -328,57 +328,91 @@ function InteractiveChart({ topicData, typeData, diffData }: {
.join(", "); .join(", ");
return ( return (
<section className="bg-white border border-gray-200 rounded-2xl p-5"> <section className="bg-white border border-gray-200 rounded-2xl p-5 shadow-sm">
{/* Tab switcher */} {/* Tab switcher */}
<div className="flex gap-1 mb-4"> <div className="flex gap-1 mb-4 bg-gray-100 p-1 rounded-lg">
{(["topic", "type", "difficulty"] as const).map((t) => ( {(["topic", "type", "difficulty"] as const).map((t) => (
<button key={t} onClick={() => { setView(t); setHovered(null); }} <button key={t} onClick={() => { setView(t); setHovered(null); }}
className={`text-xs px-3 py-1.5 rounded-lg font-medium transition-colors ${ className={`text-xs px-3 py-1.5 rounded-md font-medium transition-all duration-200 ${
view === t ? "bg-gray-900 text-white" : "bg-gray-100 text-gray-500 hover:text-gray-700" view === t ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700 hover:bg-gray-50"
}`}> }`}>
{t === "topic" ? "Topics" : t === "type" ? "Types" : "Difficulty"} {t === "topic" ? "Topics" : t === "type" ? "Types" : "Difficulty"}
</button> </button>
))} ))}
</div> </div>
{/* Pie */} {/* Pie — SVG donut */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-5">
<div className="relative w-36 h-36 shrink-0"> <div className="relative w-40 h-40 shrink-0">
<div <svg viewBox="0 0 100 100" className="w-full h-full -rotate-90 drop-shadow-md">
className="w-full h-full rounded-full" {segments.map((s, i) => {
style={{ background: `conic-gradient(${gradient})` }} const r = 38;
const circumference = 2 * Math.PI * r;
const dashLen = (s.pct / 100) * circumference;
const dashOffset = -((s.start / 100) * circumference);
const isHov = hovered === i;
return (
<circle
key={s.label}
cx="50" cy="50" r={r}
fill="none"
stroke={s.color}
strokeWidth={isHov ? 14 : 11}
strokeDasharray={`${dashLen - 1} ${circumference - dashLen + 1}`}
strokeDashoffset={dashOffset}
strokeLinecap="butt"
className="transition-all duration-300 cursor-pointer"
style={{ filter: isHov ? `drop-shadow(0 0 6px ${s.color}60)` : "none", opacity: hovered !== null && !isHov ? 0.5 : 1 }}
onMouseEnter={() => setHovered(i)}
onMouseLeave={() => setHovered(null)}
/> />
<div className="absolute inset-3 bg-white rounded-full flex items-center justify-center"> );
})}
</svg>
{/* Center label */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
{hovered !== null ? ( {hovered !== null ? (
<div className="text-center"> <>
<div className="text-lg font-bold text-gray-900">{segments[hovered].value}</div> <div className="text-xl font-bold text-gray-900 transition-all duration-200">{segments[hovered].value}</div>
<div className="text-[9px] text-gray-400">{segments[hovered].pct.toFixed(0)}%</div> <div className="text-[10px] text-gray-400 font-medium">{segments[hovered].pct.toFixed(1)}%</div>
</div> </>
) : ( ) : (
<div className="text-center"> <>
<div className="text-lg font-bold text-gray-900">{total}</div> <div className="text-xl font-bold text-gray-900">{total}</div>
<div className="text-[9px] text-gray-400">total</div> <div className="text-[10px] text-gray-400 font-medium">total</div>
</div> </>
)} )}
</div> </div>
</div> </div>
</div>
{/* Legend */} {/* Legend */}
<div className="flex-1 space-y-1 max-h-36 overflow-y-auto"> <div className="flex-1 space-y-0.5 max-h-40 overflow-y-auto">
{segments.map((s, i) => ( {segments.map((s, i) => {
const isHov = hovered === i;
return (
<div <div
key={s.label} key={s.label}
onMouseEnter={() => setHovered(i)} onMouseEnter={() => setHovered(i)}
onMouseLeave={() => setHovered(null)} onMouseLeave={() => setHovered(null)}
className={`flex items-center gap-2 px-2 py-1 rounded-lg cursor-default transition-colors ${ className={`flex items-center gap-2.5 px-2.5 py-1.5 rounded-lg cursor-default transition-all duration-200 ${
hovered === i ? "bg-gray-50" : "" isHov ? "bg-gray-100 scale-[1.02]" : hovered !== null ? "opacity-50" : ""
}`} }`}
> >
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: s.color }} /> <span
<span className="text-xs text-gray-700 flex-1 truncate">{s.label}</span> className="w-3 h-3 rounded shrink-0 transition-transform duration-200"
<span className="text-xs text-gray-400 tabular-nums">{s.value}</span> style={{ backgroundColor: s.color, transform: isHov ? "scale(1.3)" : "scale(1)" }}
/>
<span className={`text-xs flex-1 truncate transition-colors duration-200 ${isHov ? "text-gray-900 font-medium" : "text-gray-700"}`}>
{s.label}
</span>
<span className={`text-xs tabular-nums transition-colors duration-200 ${isHov ? "text-gray-900 font-semibold" : "text-gray-400"}`}>
{s.value}
</span>
</div> </div>
))} );
})}
</div> </div>
</div> </div>
</section> </section>
@@ -391,11 +425,14 @@ function QuestionCard({ question: q }: { question: QItem }) {
const cleanPreview = (q.preview || "") const cleanPreview = (q.preview || "")
.replace(/^Problem\s+\d+\s*\[.*?\]\s*/i, "") .replace(/^Problem\s+\d+\s*\[.*?\]\s*/i, "")
.replace(/^(True\/False Questions?\s*)?Indicate whether.*?(answer\.\s*)/i, "") .replace(/^(True\/False Questions?\s*)?Indicate whether.*?(answer\.\s*)/i, "")
.replace(/```\w*\n?/g, "")
.replace(/<[^>]*>/g, "")
.replace(/\s+/g, " ")
.trim(); .trim();
return ( return (
<Link to={`/paper/${q.paper_id}`} <Link to={`/paper/${q.paper_id}`}
className="flex items-start gap-3 bg-gray-50 border border-gray-200 rounded-xl px-3.5 py-2.5 hover:border-blue-300 hover:bg-white hover:shadow-sm transition-all group"> className="flex items-start gap-3 bg-gray-50 border border-gray-200 border-l-2 border-l-transparent rounded-xl px-3.5 py-2.5 hover:border-blue-300 hover:border-l-blue-500 hover:bg-white hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 group">
<span className="shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-lg bg-blue-600 text-white text-xs font-bold mt-0.5"> <span className="shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-lg bg-blue-600 text-white text-xs font-bold mt-0.5">
{q.question_number} {q.question_number}
</span> </span>
@@ -437,18 +474,28 @@ function FilterRow({ label, children }: { label: string; children: React.ReactNo
function Pill({ label, active, color, onClick }: { label: string; active: boolean; color?: string; onClick: () => void }) { function Pill({ label, active, color, onClick }: { label: string; active: boolean; color?: string; onClick: () => void }) {
return ( return (
<button onClick={onClick} <button onClick={onClick}
className={`text-[10px] px-2 py-1 rounded-full border font-medium transition-colors whitespace-nowrap ${ className={`text-[10px] px-2 py-1 rounded-full border font-medium transition-all duration-150 whitespace-nowrap ${
active ? (color ?? "bg-blue-50 text-blue-700 border-blue-200") : "bg-white text-gray-400 border-gray-200 hover:text-gray-600" active
? `${color ?? "bg-blue-50 text-blue-700 border-blue-200"} shadow-sm scale-105`
: "bg-white text-gray-400 border-gray-200 hover:text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:shadow-sm active:scale-95"
}`}> }`}>
{label} {label}
</button> </button>
); );
} }
const KPI_ACCENTS: Record<string, { border: string; bg: string; text: string }> = {
Papers: { border: "border-l-blue-500", bg: "from-blue-50/60 to-white", text: "text-blue-600" },
Questions: { border: "border-l-violet-500", bg: "from-violet-50/60 to-white", text: "text-violet-600" },
Topics: { border: "border-l-emerald-500", bg: "from-emerald-50/60 to-white", text: "text-emerald-600" },
"Avg Difficulty": { border: "border-l-amber-500", bg: "from-amber-50/60 to-white", text: "text-amber-600" },
};
function KpiCard({ label, value }: { label: string; value: string | number }) { function KpiCard({ label, value }: { label: string; value: string | number }) {
const accent = KPI_ACCENTS[label] ?? { border: "border-l-gray-400", bg: "from-gray-50/60 to-white", text: "text-gray-600" };
return ( return (
<div className="bg-white border border-gray-200 rounded-2xl p-5"> <div className={`bg-gradient-to-br ${accent.bg} border border-gray-200 border-l-4 ${accent.border} rounded-2xl p-5 hover:-translate-y-1 hover:shadow-md transition-all duration-200`}>
<div className="text-2xl font-semibold text-gray-900">{value}</div> <div className={`text-3xl font-bold ${accent.text}`}>{value}</div>
<div className="text-xs uppercase tracking-wide text-gray-400 mt-2">{label}</div> <div className="text-xs uppercase tracking-wide text-gray-400 mt-2">{label}</div>
</div> </div>
); );
@@ -456,7 +503,7 @@ function KpiCard({ label, value }: { label: string; value: string | number }) {
function Panel({ title, children }: { title: string; children: React.ReactNode }) { function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<section className="bg-white border border-gray-200 rounded-2xl p-5"> <section className="bg-white border border-gray-200 rounded-2xl p-5 shadow-sm">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">{title}</h2> <h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">{title}</h2>
{children} {children}
</section> </section>

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState, useRef } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import Header from "@/components/layout/Header"; import Header from "@/components/layout/Header";
@@ -31,6 +31,38 @@ const DIFF_COLORS: Record<string, string> = {
hard: "text-red-600", hard: "text-red-600",
}; };
/* --- Keyframe styles injected once --- */
const styleId = "error-book-animations";
if (typeof document !== "undefined" && !document.getElementById(styleId)) {
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
@keyframes eb-pulse-badge {
0%, 100% { box-shadow: 0 0 0 0 rgba(220,38,38,0.45); }
50% { box-shadow: 0 0 0 6px rgba(220,38,38,0); }
}
@keyframes eb-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes eb-count-up {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes eb-feedback-expand {
from { opacity: 0; }
to { opacity: 1; }
}
.eb-pulse-badge { animation: eb-pulse-badge 2s ease-in-out infinite; }
.eb-float { animation: eb-float 3s ease-in-out infinite; }
.eb-count-up { animation: eb-count-up 0.4s ease-out both; }
.eb-feedback-enter {
animation: eb-feedback-expand 0.3s ease-out both;
}
`;
document.head.appendChild(style);
}
export default function ErrorBookPage() { export default function ErrorBookPage() {
const { user } = useAuth(); const { user } = useAuth();
const [entries, setEntries] = useState<UserAttempt[]>([]); const [entries, setEntries] = useState<UserAttempt[]>([]);
@@ -111,7 +143,7 @@ export default function ErrorBookPage() {
{!user && ( {!user && (
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center"> <div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
<div className="text-3xl mb-3">🔒</div> <div className="text-3xl mb-3 eb-float">🔒</div>
<p className="text-gray-500 mb-4">Sign in to unlock your Error Book</p> <p className="text-gray-500 mb-4">Sign in to unlock your Error Book</p>
<Link to="/login" className="inline-block px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition-colors"> <Link to="/login" className="inline-block px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition-colors">
Sign in Sign in
@@ -123,7 +155,7 @@ export default function ErrorBookPage() {
{user && !loading && !error && filteredEntries.length === 0 && favoriteVariants.length === 0 && ( {user && !loading && !error && filteredEntries.length === 0 && favoriteVariants.length === 0 && (
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center"> <div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
<div className="text-3xl mb-3">🎉</div> <div className="text-3xl mb-3 eb-float">🎉</div>
<p className="text-gray-500">No mistakes yet. Keep practicing!</p> <p className="text-gray-500">No mistakes yet. Keep practicing!</p>
</div> </div>
)} )}
@@ -177,18 +209,28 @@ function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMast
const typeColor = TYPE_COLORS[question.question_type] ?? "bg-gray-100 text-gray-600"; const typeColor = TYPE_COLORS[question.question_type] ?? "bg-gray-100 text-gray-600";
const diffColor = DIFF_COLORS[question.difficulty ?? ""] ?? ""; const diffColor = DIFF_COLORS[question.difficulty ?? ""] ?? "";
// Clean preview: strip boilerplate // Clean preview: strip boilerplate and markdown code fences
const preview = (question.question_text || "") const preview = (question.question_text || "")
.replace(/^Problem\s+\d+\s*\[.*?\]\s*/i, "") .replace(/^Problem\s+\d+\s*\[.*?\]\s*/i, "")
.slice(0, 200); .replace(/```\w*\n?/g, "")
.replace(/<[^>]*>/g, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 250);
const isUnmastered = !entry.mastered;
return ( return (
<article className="bg-white border border-gray-200 rounded-xl overflow-hidden"> <article
className="bg-white border border-gray-200 rounded-xl overflow-hidden transition-all duration-300 ease-out hover:-translate-y-0.5 hover:shadow-lg border-l-[3px] border-l-red-400"
>
{/* Header */} {/* Header */}
<div className="px-5 pt-4 pb-3"> <div className="px-5 pt-4 pb-3">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center justify-center w-9 h-9 rounded-lg bg-red-600 text-white text-sm font-bold"> <span
className={`inline-flex items-center justify-center w-10 h-10 rounded-lg bg-red-600 text-white text-sm font-bold shadow-md ${isUnmastered ? "eb-pulse-badge" : ""}`}
>
{question.question_number} {question.question_number}
</span> </span>
<div> <div>
@@ -240,13 +282,26 @@ function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMast
<div className="border-t border-gray-100"> <div className="border-t border-gray-100">
<button <button
onClick={() => setShowFeedback((v) => !v)} onClick={() => setShowFeedback((v) => !v)}
className="w-full flex items-center justify-between px-5 py-2.5 text-xs font-medium text-blue-700 bg-blue-50/50 hover:bg-blue-50" className="w-full flex items-center justify-between px-5 py-2.5 text-xs font-medium text-blue-700 bg-blue-50/50 hover:bg-blue-50 transition-colors duration-200"
> >
<span>AI Feedback</span> <span className="flex items-center gap-1.5">
<span>{showFeedback ? "▲" : "▼"}</span> <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5.002 5.002 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
AI Feedback
</span>
<span
className="transition-transform duration-300"
style={{ display: "inline-block", transform: showFeedback ? "rotate(0deg)" : "rotate(180deg)" }}
>
</span>
</button> </button>
{showFeedback && ( {showFeedback && (
<div className="px-5 py-4 bg-white"> <div
className="px-5 py-4 eb-feedback-enter"
style={{ background: "linear-gradient(180deg, rgba(239,246,255,0.5) 0%, rgba(255,255,255,1) 100%)" }}
>
<KaTeXRenderer html={entry.feedback} className="text-sm text-gray-700 leading-relaxed" /> <KaTeXRenderer html={entry.feedback} className="text-sm text-gray-700 leading-relaxed" />
</div> </div>
)} )}
@@ -256,14 +311,23 @@ function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMast
{/* Actions */} {/* Actions */}
<div className="border-t border-gray-100 px-5 py-2.5 flex items-center gap-4 bg-gray-50/50"> <div className="border-t border-gray-100 px-5 py-2.5 flex items-center gap-4 bg-gray-50/50">
{paperId && ( {paperId && (
<Link to={`/paper/${paperId}`} className="text-xs font-medium text-blue-600 hover:text-blue-700"> <Link to={`/paper/${paperId}`} className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors duration-200">
Open paper <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open paper
</Link> </Link>
)} )}
<button onClick={onMastered} className="text-xs font-medium text-green-600 hover:text-green-700"> <button onClick={onMastered} className="inline-flex items-center gap-1 text-xs font-medium text-green-600 hover:text-green-800 transition-colors duration-200">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Mark mastered Mark mastered
</button> </button>
<button onClick={onRemove} className="text-xs font-medium text-gray-400 hover:text-gray-600"> <button onClick={onRemove} className="inline-flex items-center gap-1 text-xs font-medium text-gray-400 hover:text-red-500 transition-colors duration-200">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Remove Remove
</button> </button>
</div> </div>
@@ -272,11 +336,18 @@ function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMast
} }
function StatCard({ label, value, color }: { label: string; value: number; color: string }) { function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
const bg = color === "red" ? "bg-red-50 border-red-200" : "bg-blue-50 border-blue-200"; const isRed = color === "red";
const text = color === "red" ? "text-red-700" : "text-blue-700";
return ( return (
<div className={`border rounded-xl px-4 py-2.5 ${bg}`}> <div
<div className={`text-xl font-bold ${text}`}>{value}</div> className="border rounded-xl px-4 py-2.5 transition-all duration-300 hover:-translate-y-0.5 hover:shadow-md cursor-default"
style={{
background: isRed
? "linear-gradient(135deg, #fef2f2 0%, #fff1f2 100%)"
: "linear-gradient(135deg, #eff6ff 0%, #eef2ff 100%)",
borderColor: isRed ? "#fecaca" : "#bfdbfe",
}}
>
<div className={`text-xl font-bold eb-count-up ${isRed ? "text-red-700" : "text-blue-700"}`}>{value}</div>
<div className="text-[10px] uppercase tracking-wide text-gray-400 mt-0.5">{label}</div> <div className="text-[10px] uppercase tracking-wide text-gray-400 mt-0.5">{label}</div>
</div> </div>
); );

View File

@@ -2,8 +2,57 @@ import { useEffect, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { listPapers, myPapers } from "@/lib/api"; import { listPapers, myPapers } from "@/lib/api";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import PricingModal from "@/components/shared/PricingModal";
import type { Paper } from "@/types/api"; import type { Paper } from "@/types/api";
/* ── Count-up animation hook ── */
function useCountUp(target: number, duration = 1500, start = false): number {
const [value, setValue] = useState(0);
const rafRef = useRef<number>(0);
useEffect(() => {
if (!start || target === 0) { setValue(target); return; }
let startTime: number | null = null;
const step = (ts: number) => {
if (!startTime) startTime = ts;
const progress = Math.min((ts - startTime) / duration, 1);
// ease-out cubic
const eased = 1 - Math.pow(1 - progress, 3);
setValue(Math.round(eased * target));
if (progress < 1) rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
return () => cancelAnimationFrame(rafRef.current);
}, [target, duration, start]);
return value;
}
/* ── Animated stat component ── */
function AnimatedStat({ target, label }: { target: number; label: string }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const value = useCountUp(target, 1500, visible);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) { setVisible(true); obs.disconnect(); } },
{ threshold: 0.3 }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
return (
<div ref={ref} className="text-center">
<div className="text-2xl font-bold text-white" style={fontMono}>{value}</div>
<div className="text-xs text-indigo-300" style={fontSora}>{label}</div>
</div>
);
}
function getWorkedIds(userId: string): string[] { function getWorkedIds(userId: string): string[] {
try { try {
const raw = localStorage.getItem(`worked_papers_${userId}`); const raw = localStorage.getItem(`worked_papers_${userId}`);
@@ -147,6 +196,7 @@ function Dropdown({
export default function HomePage() { export default function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, signOut } = useAuth(); const { user, signOut } = useAuth();
const [showPricing, setShowPricing] = useState(false);
const [papers, setPapers] = useState<Paper[]>([]); const [papers, setPapers] = useState<Paper[]>([]);
const [papersLoading, setPapersLoading] = useState(false); const [papersLoading, setPapersLoading] = useState(false);
const [myUploadedPapers, setMyUploadedPapers] = useState<Paper[]>([]); const [myUploadedPapers, setMyUploadedPapers] = useState<Paper[]>([]);
@@ -159,9 +209,12 @@ export default function HomePage() {
const [analyzing, setAnalyzing] = useState(false); const [analyzing, setAnalyzing] = useState(false);
const inputRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLDivElement>(null);
// Derive course options dynamically from loaded papers
const courseOptions = Array.from(new Set(papers.map((p) => p.course_code))).sort();
// Autocomplete suggestions // Autocomplete suggestions
const suggestions = courseInput.trim() const suggestions = courseInput.trim()
? COURSE_OPTIONS.filter((c) => ? courseOptions.filter((c) =>
c.toLowerCase().includes(courseInput.trim().toLowerCase()) c.toLowerCase().includes(courseInput.trim().toLowerCase())
) )
: []; : [];
@@ -236,16 +289,63 @@ export default function HomePage() {
return ( return (
<div className="min-h-screen" style={{ background: "#FAFAFA" }}> <div className="min-h-screen" style={{ background: "#FAFAFA" }}>
{/* Injected animation styles */}
<style>{`
@keyframes gradientShift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.hero-gradient-text {
background: linear-gradient(135deg, #A5B4FC, #6EE7B7, #A5B4FC, #818CF8);
background-size: 300% 300%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: gradientShift 6s ease infinite;
}
.feature-card {
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
position: relative;
overflow: hidden;
}
.feature-card::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(99,102,241,0.04), rgba(14,165,233,0.04));
opacity: 0;
transition: opacity 0.25s ease;
pointer-events: none;
}
.feature-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(99,102,241,0.10), 0 2px 8px rgba(0,0,0,0.06);
border-color: #A5B4FC;
}
.feature-card:hover::after {
opacity: 1;
}
.feature-card:hover .feature-icon-bg {
transform: scale(1.12);
}
.feature-icon-bg {
transition: transform 0.25s ease;
}
.paper-card-hover {
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
border-left: 3px solid transparent;
}
.paper-card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99,102,241,0.08);
border-left-color: #6366F1;
}
`}</style>
{/* ══════ Nav ══════ */} {/* ══════ Nav ══════ */}
<nav className="bg-white border-b border-slate-200"> <nav className="bg-white border-b border-slate-200">
<div className="max-w-[1200px] mx-auto px-6 h-14 flex items-center justify-between"> <div className="px-6 h-14 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <img src="/favicon.jpg" alt="KnowIt" className="w-8 h-8 object-contain" />
className="w-8 h-8 flex items-center justify-center text-white text-sm font-bold"
style={{ background: "#6366F1", borderRadius: 0 }}
>
PM
</div>
<span className="text-lg font-bold text-slate-800" style={fontSora}> <span className="text-lg font-bold text-slate-800" style={fontSora}>
PastPaper Master PastPaper Master
</span> </span>
@@ -270,6 +370,15 @@ export default function HomePage() {
{user ? ( {user ? (
<div className="flex items-center gap-3 pl-3 border-l border-slate-200"> <div className="flex items-center gap-3 pl-3 border-l border-slate-200">
<span className="text-xs text-slate-400 max-w-[140px] truncate" style={fontMono}>{user.email}</span> <span className="text-xs text-slate-400 max-w-[140px] truncate" style={fontMono}>{user.email}</span>
<button
onClick={() => setShowPricing(true)}
className="flex items-center gap-1 text-[11px] font-semibold px-2 py-0.5 rounded-full bg-green-50 text-green-700 border border-green-200 hover:bg-green-100 transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
Standard
</button>
<button <button
onClick={() => void signOut()} onClick={() => void signOut()}
className="text-xs text-slate-400 hover:text-red-500 transition-colors" className="text-xs text-slate-400 hover:text-red-500 transition-colors"
@@ -300,7 +409,7 @@ export default function HomePage() {
style={fontSora} style={fontSora}
> >
The Smartest Way to<br /> The Smartest Way to<br />
<span style={{ color: "#A5B4FC" }}>Master Past Papers</span> <span className="hero-gradient-text">Master Past Papers</span>
</h1> </h1>
<p className="text-indigo-200 text-base mb-10 max-w-xl mx-auto" style={fontSora}> <p className="text-indigo-200 text-base mb-10 max-w-xl mx-auto" style={fontSora}>
Upload any HKUST past paper. AI breaks down every question with analysis, Upload any HKUST past paper. AI breaks down every question with analysis,
@@ -322,7 +431,7 @@ export default function HomePage() {
onChange={(e) => { onChange={(e) => {
const v = e.target.value.toUpperCase(); const v = e.target.value.toUpperCase();
setCourseInput(v); setCourseInput(v);
setCourseFilter(COURSE_OPTIONS.includes(v) ? v : null); setCourseFilter(courseOptions.includes(v) ? v : null);
setShowSuggestions(true); setShowSuggestions(true);
}} }}
onFocus={() => setShowSuggestions(true)} onFocus={() => setShowSuggestions(true)}
@@ -513,18 +622,11 @@ export default function HomePage() {
)} )}
</div> </div>
{/* Quick stats — real data */} {/* Quick stats — real data with count-up animation */}
<div className="flex justify-center gap-8 mt-10"> <div className="flex justify-center gap-8 mt-10">
{[ <AnimatedStat target={papers.filter(p => p.status === "ready").length} label="Past Papers" />
[String(papers.filter(p => p.status === "ready").length), "Past Papers"], <AnimatedStat target={papers.reduce((s, p) => s + (p.question_count || 0), 0)} label="Questions Analyzed" />
[String(papers.reduce((s, p) => s + (p.question_count || 0), 0)), "Questions Analyzed"], <AnimatedStat target={new Set(papers.filter(p => p.status === "ready").map(p => p.course_code)).size} label="Courses" />
[String(new Set(papers.filter(p => p.status === "ready").map(p => p.course_code)).size), "Courses"],
].map(([num, label]) => (
<div key={label} className="text-center">
<div className="text-2xl font-bold text-white" style={fontMono}>{num}</div>
<div className="text-xs text-indigo-300" style={fontSora}>{label}</div>
</div>
))}
</div> </div>
</div> </div>
@@ -551,11 +653,11 @@ export default function HomePage() {
{FEATURES.map((f) => ( {FEATURES.map((f) => (
<div <div
key={f.title} key={f.title}
className="bg-white border border-slate-200 p-5 hover:border-slate-300 transition-colors group" className="feature-card bg-white border border-slate-200 p-5 group"
style={{ borderRadius: 0 }} style={{ borderRadius: 0 }}
> >
<div <div
className="w-10 h-10 flex items-center justify-center text-white mb-4" className="feature-icon-bg w-10 h-10 flex items-center justify-center text-white mb-4"
style={{ background: f.color, borderRadius: 0 }} style={{ background: f.color, borderRadius: 0 }}
> >
{f.icon} {f.icon}
@@ -597,7 +699,7 @@ export default function HomePage() {
<Link <Link
key={p.id} key={p.id}
to={p.status === "ready" ? `/paper/${p.id}` : "#"} to={p.status === "ready" ? `/paper/${p.id}` : "#"}
className="flex items-center justify-between bg-white border border-slate-200 px-4 py-3 hover:border-indigo-300 transition-colors" className="paper-card-hover flex items-center justify-between bg-white border border-slate-200 px-4 py-3"
style={{ borderRadius: 0 }} style={{ borderRadius: 0 }}
> >
<div> <div>
@@ -633,7 +735,7 @@ export default function HomePage() {
<Link <Link
key={p.id} key={p.id}
to={`/paper/${p.id}`} to={`/paper/${p.id}`}
className="flex items-center justify-between bg-white border border-slate-200 px-4 py-3 hover:border-indigo-300 transition-colors" className="paper-card-hover flex items-center justify-between bg-white border border-slate-200 px-4 py-3"
style={{ borderRadius: 0 }} style={{ borderRadius: 0 }}
> >
<div> <div>
@@ -700,6 +802,8 @@ export default function HomePage() {
</div> </div>
</div> </div>
</footer> </footer>
{showPricing && <PricingModal onClose={() => setShowPricing(false)} />}
</div> </div>
); );
} }