Files
edbridge-scholars/src/pages/student/Home.tsx

542 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from "react";
import { useAuthStore } from "../../stores/authStore";
import { CheckCircle, Play, Search } from "lucide-react";
import { api } from "../../utils/api";
import type { PracticeSheet } from "../../types/sheet";
import { formatStatus } from "../../lib/utils";
import { useNavigate } from "react-router-dom";
import { SearchOverlay } from "../../components/SearchOverlay";
import { InfoHeader } from "../../components/InfoHeader";
import { InventoryButton } from "../../components/InventoryButton";
// ─── Shared blob/dot background (same as break/results screens) ────────────────
const DOTS = [
{ size: 12, color: "#f97316", top: "8%", left: "6%", delay: "0s" },
{ size: 8, color: "#a855f7", top: "22%", left: "2%", delay: "1s" },
{ size: 10, color: "#22c55e", top: "55%", left: "4%", delay: "0.5s" },
{ size: 14, color: "#3b82f6", top: "10%", right: "5%", delay: "1.5s" },
{ size: 8, color: "#f43f5e", top: "40%", right: "3%", delay: "0.8s" },
{ size: 10, color: "#eab308", top: "70%", right: "7%", delay: "0.3s" },
];
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.home-screen {
min-height: 100vh;
background: #fffbf4;
font-family: 'Nunito', sans-serif;
position: relative;
overflow-x: hidden;
}
/* ── Blobs ── */
.h-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; }
.h-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:hWobble1 14s ease-in-out infinite; }
.h-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:hWobble2 16s ease-in-out infinite; }
.h-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:hWobble1 18s ease-in-out infinite reverse; }
.h-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:hWobble2 12s ease-in-out infinite; }
@keyframes hWobble1 {
0%,100%{border-radius:60% 40% 70% 30%/50% 60% 40% 50%;transform:translate(0,0) rotate(0deg);}
50%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(12px,16px) rotate(8deg);}
}
@keyframes hWobble2 {
0%,100%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(0,0) rotate(0deg);}
50%{border-radius:60% 40% 70% 30%/40% 60% 40% 60%;transform:translate(-10px,12px) rotate(-6deg);}
}
/* ── Floating dots ── */
.h-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:hFloat 6s ease-in-out infinite; }
@keyframes hFloat {
0%,100%{transform:translateY(0) rotate(0deg);}
50%{transform:translateY(-14px) rotate(180deg);}
}
/* ── Inner scroll container ── */
.home-inner {
position: relative;
z-index: 1;
max-width: 580px;
margin: 0 auto;
padding: 2rem 1.25rem 4rem;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
/* ── Section titles ── */
.h-section-title {
font-size: 1.2rem; font-weight: 900; color: #1e1b4b;
letter-spacing: -0.01em; margin-bottom: 0.75rem;
}
/* ── Search bar ── */
.h-search-wrap {
position: relative;
animation: hPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) 0.05s both;
}
.h-search-input {
width: 100%; box-sizing: border-box;
padding: 0.85rem 1rem 0.85rem 2.8rem;
background: white; border: 2.5px solid #f3f4f6;
border-radius: 18px;
font-family: 'Nunito', sans-serif;
font-size: 0.9rem; font-weight: 700; color: #9ca3af;
box-shadow: 0 4px 14px rgba(0,0,0,0.05);
cursor: pointer; outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.h-search-input:focus { border-color:#c084fc; box-shadow:0 4px 20px rgba(192,132,252,0.15); }
.h-search-icon {
position: absolute; left: 0.9rem; top: 50%;
transform: translateY(-50%); pointer-events:none;
}
/* ── In-progress card ── */
.h-inprogress-card {
background: white;
border: 2.5px solid #c4b5fd;
border-radius: 22px;
padding: 1.1rem 1.25rem;
box-shadow: 0 4px 16px rgba(167,139,250,0.12);
display: flex; align-items: center; justify-content: space-between;
gap: 1rem; cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.h-inprogress-card:hover { transform:translateY(-2px);box-shadow:0 8px 24px rgba(167,139,250,0.2); }
.h-inprogress-info { display:flex;flex-direction:column;gap:0.25rem;flex:1;min-width:0; }
.h-inprogress-title {
font-size: 0.95rem; font-weight: 900; color: #1e1b4b;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.h-inprogress-badge {
font-size: 0.65rem; font-weight: 800; letter-spacing:0.1em;
text-transform:uppercase; color:#a855f7;
background:#f3e8ff; border-radius:100px; padding:0.2rem 0.6rem;
width: fit-content;
}
.h-play-btn {
width: 44px; height: 44px; border-radius: 50%; border: none; cursor: pointer;
background: linear-gradient(135deg, #a855f7, #7c3aed);
display:flex;align-items:center;justify-content:center;
box-shadow: 0 4px 0 #5b21b6aa;
transition: transform 0.1s ease, box-shadow 0.1s ease;
flex-shrink:0;
}
.h-play-btn:hover { transform:translateY(-2px);box-shadow:0 6px 0 #5b21b6aa; }
.h-play-btn:active { transform:translateY(2px);box-shadow:0 2px 0 #5b21b6aa; }
/* ── Empty state ── */
.h-empty {
background:white; border:2.5px dashed #e5e7eb; border-radius:22px;
padding: 1.75rem; text-align:center;
font-size:0.9rem; font-weight:700; color:#9ca3af;
}
.h-empty-emoji { font-size:2rem; display:block; margin-bottom:0.5rem; }
/* ── Tabs ── */
.h-tabs-list {
display:flex; border-bottom: 2px solid #f3f4f6;
gap: 0; margin-bottom:1rem;
}
.h-tab-btn {
flex:1; padding:0.65rem 0; text-align:center;
font-family:'Nunito',sans-serif; font-size:0.82rem; font-weight:800;
color:#9ca3af; background:transparent; border:none; border-bottom: 3px solid transparent;
margin-bottom:-2px; cursor:pointer;
transition: color 0.2s ease, border-color 0.2s ease;
}
.h-tab-btn.active { color:#1e1b4b; border-bottom-color:#a855f7; }
/* ── Practice sheet ── */
.h-sheet-grid {
display:grid; gap:0.85rem;
grid-template-columns: 1fr;
}
@media(min-width:520px){ .h-sheet-grid { grid-template-columns:1fr 1fr; } }
.h-sheet-card {
background:white; border:2.5px solid #f3f4f6; border-radius:22px;
padding:1.1rem; box-shadow:0 4px 14px rgba(0,0,0,0.04);
display:flex; flex-direction:column; gap:0.6rem;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.h-sheet-card:hover { transform:translateY(-2px);box-shadow:0 8px 20px rgba(0,0,0,0.07); }
.h-sheet-title { font-size:0.95rem;font-weight:900;color:#1e1b4b;line-height:1.2; }
.h-sheet-desc { font-size:0.78rem;font-weight:600;color:#9ca3af;font-family:'Nunito Sans',sans-serif; }
.h-sheet-meta {
display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.4rem;
}
.h-status-pill {
font-size:0.65rem;font-weight:800;letter-spacing:0.08em;text-transform:uppercase;
border-radius:100px;padding:0.25rem 0.65rem;
}
.h-status-pill.inprogress { background:#f3e8ff;color:#9333ea; }
.h-status-pill.notstarted { background:#f3f4f6;color:#6b7280; }
.h-status-pill.completed { background:#dcfce7;color:#16a34a; }
.h-modules-badge {
font-size:0.65rem;font-weight:800;
background:#ede9fe;color:#7c3aed;border-radius:100px;padding:0.25rem 0.65rem;
}
.h-time-badge {
font-size:0.72rem;font-weight:700;color:#9ca3af;display:flex;align-items:center;gap:0.3rem;
}
.h-start-btn {
width:100%;margin-top:auto;
background:#f97316;color:white;border:none;border-radius:100px;
padding:0.75rem;font-family:'Nunito',sans-serif;
font-size:0.9rem;font-weight:800;cursor:pointer;
box-shadow:0 4px 0 #c2560e,0 6px 16px rgba(249,115,22,0.2);
transition:transform 0.1s ease,box-shadow 0.1s ease;
}
.h-start-btn:hover { transform:translateY(-2px);box-shadow:0 6px 0 #c2560e,0 10px 20px rgba(249,115,22,0.25); }
.h-start-btn:active { transform:translateY(2px); box-shadow:0 2px 0 #c2560e,0 3px 8px rgba(249,115,22,0.15); }
/* ── Tips section ── */
.h-tips-list { display:flex;flex-direction:column;gap:0.6rem; }
.h-tip-row {
display:flex;align-items:flex-start;gap:0.65rem;
background:white;border:2.5px solid #f3f4f6;border-radius:16px;
padding:0.75rem 1rem;box-shadow:0 2px 8px rgba(0,0,0,0.03);
}
.h-tip-icon { flex-shrink:0;margin-top:1px; }
.h-tip-text { font-size:0.85rem;font-weight:700;color:#374151;line-height:1.4; }
/* ── Load more ── */
.h-load-more-btn {
width: 100%; margin-top: 0.25rem;
padding: 0.75rem;
background: white; border: 2.5px solid #f3f4f6;
border-radius: 100px; cursor: pointer;
font-family: 'Nunito', sans-serif;
font-size: 0.82rem; font-weight: 800; color: #9ca3af;
display: flex; align-items: center; justify-content: center; gap: 0.4rem;
box-shadow: 0 3px 10px rgba(0,0,0,0.04);
transition: all 0.2s ease;
}
.h-load-more-btn:hover { border-color: #c4b5fd; color: #a855f7; background: #fdf4ff; transform: translateY(-1px); box-shadow: 0 6px 14px rgba(0,0,0,0.06); }
.h-load-more-btn:active { transform: translateY(1px); }
.h-sheet-count {
text-align: center;
font-family: 'Nunito Sans', sans-serif;
font-size: 0.72rem; font-weight: 600; color: #d1d5db;
margin-top: 0.5rem;
}
.h-sheet-count span { font-weight: 800; color: #9ca3af; }
@keyframes hPopIn {
from{opacity:0;transform:scale(0.92) translateY(10px);}
to{opacity:1;transform:scale(1) translateY(0);}
}
.h-anim { animation: hPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
.h-anim-1 { animation-delay:0.05s; }
.h-anim-2 { animation-delay:0.1s; }
.h-anim-3 { animation-delay:0.15s; }
.h-anim-4 { animation-delay:0.2s; }
.h-anim-5 { animation-delay:0.25s; }
/* Desktop / wide tweaks */
@media (min-width: 900px) {
.home-inner { max-width: var(--content-max); padding: 3rem 1.5rem 6rem; }
.h-sheet-grid { grid-template-columns: repeat(3, 1fr); gap: 1rem; }
/* nudge blobs so they align visually with the centered container */
.h-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 120px); top: -120px; width: 300px; height: 300px; }
.h-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 20px); bottom: -80px; width: 220px; height: 220px; }
.h-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 40px); top: 10%; width: 260px; height: 260px; }
.h-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 10px); bottom: 6%; width: 180px; height: 180px; }
}
`;
// ─── Sheet card ───────────────────────────────────────────────────────────────
const SheetCard = ({
sheet,
onStart,
}: {
sheet: PracticeSheet;
onStart: (id: string) => void;
}) => {
const statusClass =
sheet.user_status === "IN_PROGRESS"
? "inprogress"
: sheet.user_status === "COMPLETED"
? "completed"
: "notstarted";
return (
<div className="h-sheet-card">
<p className="h-sheet-title">{sheet.title}</p>
{sheet.description && <p className="h-sheet-desc">{sheet.description}</p>}
<div className="h-sheet-meta">
<span className={`h-status-pill ${statusClass}`}>
{formatStatus(sheet.user_status)}
</span>
<span className="h-modules-badge">
📚 {sheet.modules_count} modules
</span>
</div>
<p className="h-time-badge"> {sheet.time_limit} min</p>
<button className="h-start-btn" onClick={() => onStart(sheet.id)}>
{sheet.user_status === "COMPLETED" ? "Retry →" : "Start →"}
</button>
</div>
);
};
// ─── Tips data ────────────────────────────────────────────────────────────────
const TIPS = [
"Practice regularly with official SAT materials",
"Review your mistakes and learn from them",
"Focus on your weak areas first",
"Take full-length practice tests",
"Get plenty of rest before test day",
];
// ─── Main component ───────────────────────────────────────────────────────────
const PAGE_SIZE = 6;
export const Home = () => {
const user = useAuthStore((state) => state.user);
const navigate = useNavigate();
const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]);
const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]);
const [inProgressSheets, setInProgressSheets] = useState<PracticeSheet[]>([]);
const [completedSheets, setCompletedSheets] = useState<PracticeSheet[]>([]);
const [activeTab, setActiveTab] = useState<
"all" | "NOT_STARTED" | "COMPLETED"
>("all");
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
useEffect(() => {
const sort = (sheets: PracticeSheet[]) => {
setNotStartedSheets(
sheets.filter((s) => s.user_status === "NOT_STARTED"),
);
setInProgressSheets(
sheets.filter((s) => s.user_status === "IN_PROGRESS"),
);
setCompletedSheets(sheets.filter((s) => s.user_status === "COMPLETED"));
};
const fetch = async () => {
if (!user) return;
try {
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return;
const {
state: { token },
} = JSON.parse(authStorage);
if (!token) return;
const sheets = await api.getPracticeSheets(token, 1, 10);
setPracticeSheets(sheets.data);
sort(sheets.data);
} catch (e) {
console.error(e);
}
};
fetch();
}, [user]);
const handleStart = (id: string) => navigate(`/student/practice/${id}`);
const allTabSheets =
activeTab === "all"
? practiceSheets
: activeTab === "NOT_STARTED"
? notStartedSheets
: completedSheets;
const tabSheets = allTabSheets.slice(0, visibleCount);
const hasMore = visibleCount < allTabSheets.length;
const remaining = allTabSheets.length - visibleCount;
const handleTabChange = (tab: "all" | "NOT_STARTED" | "COMPLETED") => {
setActiveTab(tab);
setVisibleCount(PAGE_SIZE);
};
return (
<div className="home-screen">
<style>{STYLES}</style>
{/* Blobs */}
<div className="h-blob h-blob-1" />
<div className="h-blob h-blob-2" />
<div className="h-blob h-blob-3" />
<div className="h-blob h-blob-4" />
{/* Dots */}
{DOTS.map((d, i) => (
<div
key={i}
className="h-dot"
style={
{
width: d.size,
height: d.size,
background: d.color,
top: d.top,
left: d.left,
right: d.right,
animationDelay: d.delay,
animationDuration: `${3.5 + i * 0.4}s`,
} as React.CSSProperties
}
/>
))}
<div className="home-inner">
{/* ── Header ── */}
<InfoHeader
mode="DEFAULT"
onViewAll={() => navigate("/student/quests")}
/>
{/* ── Search ── */}
<div className="h-search-wrap h-anim h-anim-1">
<span className="h-search-icon">
<Search size={18} color="#9ca3af" />
</span>
<input
className="h-search-input"
placeholder="Search practice sheets..."
readOnly
onFocus={() => setIsSearchOpen(true)}
/>
</div>
{/* ── In progress ── */}
<section className="h-anim h-anim-2">
<p className="h-section-title">📌 Pick up where you left off</p>
{inProgressSheets.length > 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.65rem",
}}
>
{inProgressSheets.map((sheet) => (
<div
key={sheet.id}
className="h-inprogress-card"
onClick={() => handleStart(sheet.id)}
>
<div className="h-inprogress-info">
<p className="h-inprogress-title">{sheet.title}</p>
<span className="h-inprogress-badge">In Progress</span>
</div>
<button
className="h-play-btn"
onClick={(e) => {
e.stopPropagation();
handleStart(sheet.id);
}}
>
<Play size={18} color="white" fill="white" />
</button>
</div>
))}
</div>
) : (
<div className="h-empty">
<span className="h-empty-emoji">🎯</span>
No sheets in progress start one below!
</div>
)}
</section>
{/* ── All sheets with tabs ── */}
<section className="h-anim h-anim-3">
<p className="h-section-title">📋 Practice Sheets</p>
{/* Tab buttons */}
<div className="h-tabs-list">
{(["all", "NOT_STARTED", "COMPLETED"] as const).map((tab) => (
<button
key={tab}
className={`h-tab-btn${activeTab === tab ? " active" : ""}`}
onClick={() => handleTabChange(tab)}
>
{tab === "all"
? "All"
: tab === "NOT_STARTED"
? "Not Started"
: "Completed"}
</button>
))}
</div>
{allTabSheets.length > 0 ? (
<>
<div className="h-sheet-grid">
{tabSheets.map((sheet) => (
<SheetCard
key={sheet.id}
sheet={sheet}
onStart={handleStart}
/>
))}
</div>
{hasMore ? (
<button
className="h-load-more-btn"
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
>
Show {Math.min(remaining, PAGE_SIZE)} more
</button>
) : allTabSheets.length > PAGE_SIZE ? (
<p className="h-sheet-count">
Showing all <span>{allTabSheets.length}</span> sheets
</p>
) : null}
</>
) : (
<div className="h-empty">
<span className="h-empty-emoji">🔍</span>
Nothing here yet!
</div>
)}
</section>
{/* ── Tips ── */}
<section className="h-anim h-anim-4">
<p className="h-section-title">💡 SAT Prep Tips</p>
<div className="h-tips-list">
{TIPS.map((tip, i) => (
<div key={i} className="h-tip-row">
<CheckCircle size={18} color="#a855f7" className="h-tip-icon" />
<p className="h-tip-text">{tip}</p>
</div>
))}
</div>
</section>
</div>
{isSearchOpen && (
<SearchOverlay
sheets={practiceSheets}
onClose={() => {
setIsSearchOpen(false);
setSearchQuery("");
}}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
)}
</div>
);
};