diff --git a/backend/app/services/paper_processor.py b/backend/app/services/paper_processor.py index 86dc4e2..aed6397 100644 --- a/backend/app/services/paper_processor.py +++ b/backend/app/services/paper_processor.py @@ -144,6 +144,7 @@ Solution requirements: - Mark common mistakes with
...
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 - Inline formula: $x^2$ no line break - Matrix: \\begin{{bmatrix}} ... \\end{{bmatrix}} @@ -174,6 +175,7 @@ Rules: - Keep each item matched to the same question_number - All text must be in English - 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 long questions, show a complete derivation or reasoning chain - Use
    or numbered steps in solution when appropriate diff --git a/backend/regen_comp2711h_trio.py b/backend/regen_comp2711h_trio.py new file mode 100644 index 0000000..1aa8d51 --- /dev/null +++ b/backend/regen_comp2711h_trio.py @@ -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()) diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 44b0b7d..080d5dd 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,5 +1,7 @@ +import { useState } from "react"; import { Link } from "react-router-dom"; import { useAuth } from "@/contexts/AuthContext"; +import PricingModal from "@/components/shared/PricingModal"; export default function Header({ courseCode, @@ -9,8 +11,10 @@ export default function Header({ paperTitle?: string; }) { const { user, signOut } = useAuth(); + const [showPricing, setShowPricing] = useState(false); return ( + <>
    PastPaper Master @@ -48,6 +52,15 @@ export default function Header({ {user ? (
    {user.email} +
    + {showPricing && setShowPricing(false)} />} + ); } diff --git a/frontend/src/components/shared/PricingModal.tsx b/frontend/src/components/shared/PricingModal.tsx new file mode 100644 index 0000000..e3ede83 --- /dev/null +++ b/frontend/src/components/shared/PricingModal.tsx @@ -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 ( +
    +
    e.stopPropagation()} + > + {/* Decorative top bar */} +
    + + {/* Close */} + + +
    + {/* Header */} +
    +

    + Choose Your Plan +

    +

    + Unlock smarter exam prep. All prices in HKD per month. +

    +
    + + {/* Plans grid */} +
    + + {/* ── Free ── */} +
    + {/* Hover glow */} +
    + +
    +

    Free

    +

    Get started

    +
    +
    + $0 + / month +
    +
      + {[ + "Browse public papers", + "View AI solutions", + "Basic error book", + ].map((f, i) => ( +
    • + + + + {f} +
    • + ))} +
    + +
    + + {/* ── Standard (current, with early bird) ── */} +
    + {/* Animated shimmer */} +
    +
    +
    + + {/* Badge */} + + Your Plan + + +
    +

    Standard

    +

    Most popular

    +
    +
    + $19.9 +
    +
    + $9.9 + / month +
    +
    + + EARLY BIRD PRICE + +
    +
      + {[ + "Unlimited paper uploads", + "AI question extraction", + "Step-by-step solutions", + "Photo auto-grading", + "Similar question retrieval", + "Course analytics", + "Error book + review", + ].map((f, i) => ( +
    • + + + + {f} +
    • + ))} +
    + +
    + + {/* ── Exam ── */} +
    + {/* Hover glow */} +
    + + + Best Value + + +
    +

    Exam

    +

    Finals season

    +
    +
    + $29.9 + / month +
    +
      + {[ + "Everything in Standard", + "AI Tutor (RAG-powered)", + "Priority processing", + "Unlimited variant generation", + "Cross-course analytics", + "Export & print", + ].map((f, i) => ( +
    • + + + + {f} +
    • + ))} +
    + +
    +
    + +

    + All registered users enjoy Early Bird pricing during beta. + Payment integration coming soon. +

    +
    +
    +
    + ); +} diff --git a/frontend/src/pages/AnalyticsPage.tsx b/frontend/src/pages/AnalyticsPage.tsx index 0a23883..42d9c61 100644 --- a/frontend/src/pages/AnalyticsPage.tsx +++ b/frontend/src/pages/AnalyticsPage.tsx @@ -141,10 +141,10 @@ export default function AnalyticsPage() { {analytics.high_yield_topics.length === 0 ? (
    No data yet.
    ) : ( -
      +
        {analytics.high_yield_topics.map((t, i) => ( -
      • - {i + 1} +
      • + {i + 1} {t}
      • ))} @@ -328,57 +328,91 @@ function InteractiveChart({ topicData, typeData, diffData }: { .join(", "); return ( -
        +
        {/* Tab switcher */} -
        +
        {(["topic", "type", "difficulty"] as const).map((t) => ( ))}
        - {/* Pie */} -
        -
        -
        -
        - {hovered !== null ? ( -
        -
        {segments[hovered].value}
        -
        {segments[hovered].pct.toFixed(0)}%
        -
        - ) : ( -
        -
        {total}
        -
        total
        -
        - )} + {/* Pie — SVG donut */} +
        +
        + + {segments.map((s, i) => { + 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 ( + setHovered(i)} + onMouseLeave={() => setHovered(null)} + /> + ); + })} + + {/* Center label */} +
        +
        + {hovered !== null ? ( + <> +
        {segments[hovered].value}
        +
        {segments[hovered].pct.toFixed(1)}%
        + + ) : ( + <> +
        {total}
        +
        total
        + + )} +
        {/* Legend */} -
        - {segments.map((s, i) => ( -
        setHovered(i)} - onMouseLeave={() => setHovered(null)} - className={`flex items-center gap-2 px-2 py-1 rounded-lg cursor-default transition-colors ${ - hovered === i ? "bg-gray-50" : "" - }`} - > - - {s.label} - {s.value} -
        - ))} +
        + {segments.map((s, i) => { + const isHov = hovered === i; + return ( +
        setHovered(i)} + onMouseLeave={() => setHovered(null)} + className={`flex items-center gap-2.5 px-2.5 py-1.5 rounded-lg cursor-default transition-all duration-200 ${ + isHov ? "bg-gray-100 scale-[1.02]" : hovered !== null ? "opacity-50" : "" + }`} + > + + + {s.label} + + + {s.value} + +
        + ); + })}
        @@ -391,11 +425,14 @@ function QuestionCard({ question: q }: { question: QItem }) { const cleanPreview = (q.preview || "") .replace(/^Problem\s+\d+\s*\[.*?\]\s*/i, "") .replace(/^(True\/False Questions?\s*)?Indicate whether.*?(answer\.\s*)/i, "") + .replace(/```\w*\n?/g, "") + .replace(/<[^>]*>/g, "") + .replace(/\s+/g, " ") .trim(); return ( + 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"> {q.question_number} @@ -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 }) { return ( ); } +const KPI_ACCENTS: Record = { + 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 }) { + const accent = KPI_ACCENTS[label] ?? { border: "border-l-gray-400", bg: "from-gray-50/60 to-white", text: "text-gray-600" }; return ( -
        -
        {value}
        +
        +
        {value}
        {label}
        ); @@ -456,7 +503,7 @@ function KpiCard({ label, value }: { label: string; value: string | number }) { function Panel({ title, children }: { title: string; children: React.ReactNode }) { return ( -
        +

        {title}

        {children}
        diff --git a/frontend/src/pages/ErrorBookPage.tsx b/frontend/src/pages/ErrorBookPage.tsx index 0e3529c..ffe5cc6 100644 --- a/frontend/src/pages/ErrorBookPage.tsx +++ b/frontend/src/pages/ErrorBookPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useRef } from "react"; import { Link } from "react-router-dom"; import Header from "@/components/layout/Header"; @@ -31,6 +31,38 @@ const DIFF_COLORS: Record = { 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() { const { user } = useAuth(); const [entries, setEntries] = useState([]); @@ -111,7 +143,7 @@ export default function ErrorBookPage() { {!user && (
        -
        🔒
        +
        🔒

        Sign in to unlock your Error Book

        Sign in @@ -123,7 +155,7 @@ export default function ErrorBookPage() { {user && !loading && !error && filteredEntries.length === 0 && favoriteVariants.length === 0 && (
        -
        🎉
        +
        🎉

        No mistakes yet. Keep practicing!

        )} @@ -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 diffColor = DIFF_COLORS[question.difficulty ?? ""] ?? ""; - // Clean preview: strip boilerplate + // Clean preview: strip boilerplate and markdown code fences const preview = (question.question_text || "") .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 ( -
        +
        {/* Header */}
        - + {question.question_number}
        @@ -240,13 +282,26 @@ function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMast
        {showFeedback && ( -
        +
        )} @@ -256,14 +311,23 @@ function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMast {/* Actions */}
        {paperId && ( - - Open paper → + + + + + Open paper )} - -
        @@ -272,11 +336,18 @@ function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMast } 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 text = color === "red" ? "text-red-700" : "text-blue-700"; + const isRed = color === "red"; return ( -
        -
        {value}
        +
        +
        {value}
        {label}
        ); diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 0b55eb8..5d8adfb 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -2,8 +2,57 @@ import { useEffect, useRef, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { listPapers, myPapers } from "@/lib/api"; import { useAuth } from "@/contexts/AuthContext"; +import PricingModal from "@/components/shared/PricingModal"; 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(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(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 ( +
        +
        {value}
        +
        {label}
        +
        + ); +} + function getWorkedIds(userId: string): string[] { try { const raw = localStorage.getItem(`worked_papers_${userId}`); @@ -147,6 +196,7 @@ function Dropdown({ export default function HomePage() { const navigate = useNavigate(); const { user, signOut } = useAuth(); + const [showPricing, setShowPricing] = useState(false); const [papers, setPapers] = useState([]); const [papersLoading, setPapersLoading] = useState(false); const [myUploadedPapers, setMyUploadedPapers] = useState([]); @@ -159,9 +209,12 @@ export default function HomePage() { const [analyzing, setAnalyzing] = useState(false); const inputRef = useRef(null); + // Derive course options dynamically from loaded papers + const courseOptions = Array.from(new Set(papers.map((p) => p.course_code))).sort(); + // Autocomplete suggestions const suggestions = courseInput.trim() - ? COURSE_OPTIONS.filter((c) => + ? courseOptions.filter((c) => c.toLowerCase().includes(courseInput.trim().toLowerCase()) ) : []; @@ -236,16 +289,63 @@ export default function HomePage() { return (
        + {/* Injected animation styles */} + {/* ══════ Nav ══════ */}