feat: add mock page

This commit is contained in:
2026-08-22 08:24:06 +07:00
parent b44092153d
commit dd9ed73a02
23 changed files with 590 additions and 118 deletions
+5 -1
View File
@@ -4,6 +4,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
import { AppLayout } from '@/app/layouts/AppLayout';
import { HomePage } from '@/app/pages/HomePage';
import { NotFoundPage } from '@/app/pages/NotFoundPage';
import { RankingPage } from '@/app/pages/RankingPage';
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute';
import { LoginPage } from '@/features/auth/pages/LoginPage';
import { useAuthStore } from '@/features/auth/store/authStore';
@@ -19,7 +20,10 @@ export default function App() {
<Route element={<ProtectedRoute />}>
<Route element={<AppLayout />}>
<Route index element={<HomePage />} />
<Route path="/learn" element={<div>Learn (placeholder)</div>} />
<Route path="/vocabulary" element={<div>Vocabulary (placeholder)</div>} />
<Route path="/assignment" element={<div>Assignment (placeholder)</div>} />
<Route path="/ranking" element={<RankingPage />} />
<Route path="/progress" element={<div>Progress (placeholder)</div>} />
</Route>
</Route>
<Route path="/404" element={<NotFoundPage />} />
+26 -27
View File
@@ -1,44 +1,43 @@
import { Link, Outlet } from 'react-router-dom';
import { Link, NavLink, Outlet } from 'react-router-dom';
import env from '@/shared/config/env';
import { useLogout } from '@/features/auth/hooks/useLogout';
import { useAuthStore } from '@/features/auth/store/authStore';
import { UserMenu } from '@/features/auth/components/UserMenu';
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
`rounded-full px-3 py-1.5 transition-colors ${
isActive ? 'bg-primary-light text-primary' : 'text-ink-secondary hover:bg-surface-soft hover:text-ink'
}`;
export function AppLayout() {
const user = useAuthStore((s) => s.user);
const logout = useLogout();
return (
<div className="min-h-screen bg-gray-50">
<header className="border-b border-gray-200 bg-white">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
<div className="min-h-screen">
<header className="border-b border-border bg-surface">
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4">
<div className="flex items-center gap-6">
<Link to="/" className="text-lg font-semibold text-gray-900">
<Link to="/" className="text-lg font-extrabold text-ink">
{env.VITE_APP_NAME}
</Link>
<nav className="flex items-center gap-4 text-sm text-gray-600">
<Link to="/" className="hover:text-gray-900">
<nav className="flex items-center gap-1 text-sm font-bold">
<NavLink to="/" end className={navLinkClass}>
Home
</Link>
<Link to="/learn" className="hover:text-gray-900">
Learn
</Link>
</NavLink>
<NavLink to="/vocabulary" className={navLinkClass}>
Vocabulary
</NavLink>
<NavLink to="/assignment" className={navLinkClass}>
Assignment
</NavLink>
<NavLink to="/ranking" className={navLinkClass}>
Ranking
</NavLink>
</nav>
</div>
<div className="flex items-center gap-4">
{user && <span className="text-sm text-gray-500">{user.email}</span>}
<button
type="button"
onClick={() => logout.mutate()}
disabled={logout.isPending}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50"
>
{logout.isPending ? 'Signing out…' : 'Sign out'}
</button>
<div className="flex items-center gap-2">
<UserMenu />
</div>
</div>
</header>
<main className="mx-auto max-w-5xl px-4 py-8">
<main className="mx-auto max-w-6xl px-4 py-8">
<Outlet />
</main>
</div>
+103 -15
View File
@@ -1,26 +1,114 @@
import { useState } from 'react';
import env from '@/shared/config/env';
import { Button } from '@/shared/components/Button';
import { useAuthUser } from '@/features/auth/hooks/useAuthUser';
// ponytail: hardcoded until course/lesson API exists; swap for real lessons of the current unit then.
const MOCK_LESSONS = [
{ id: 1, name: 'Everyday Greetings', description: 'Say hello and introduce yourself with confidence.', emoji: '👋', progress: 65 },
{ id: 2, name: 'Numbers & Counting', description: 'Master 1 to 100 and everyday quantities.', emoji: '🔢' },
{ id: 3, name: 'Family Members', description: 'Talk about parents, siblings, and relatives.', emoji: '👨‍👩‍👧' },
{ id: 4, name: 'Colors & Shapes', description: 'Describe the world around you.', emoji: '🎨' },
{ id: 5, name: 'Daily Routines', description: 'Talk about your day from morning to night.', emoji: '⏰' },
{ id: 6, name: 'Food & Drinks', description: 'Order meals and talk about your favorite dishes.', emoji: '🍜' },
{ id: 7, name: 'Weather Talk', description: 'Small talk about sun, rain, and seasons.', emoji: '⛅' },
{ id: 8, name: 'Directions', description: 'Ask for and give directions around town.', emoji: '🧭' },
{ id: 9, name: 'Shopping Basics', description: 'Buy things and handle money confidently.', emoji: '🛍️' },
];
const LESSONS_PER_PAGE = 6;
function LessonCard({ lesson, active }: { lesson: (typeof MOCK_LESSONS)[number]; active: boolean }) {
return (
<div
className={`flex flex-col overflow-hidden rounded-xl border shadow-[0_5px_0_rgba(37,42,82,0.08)] ${
active ? 'border-primary bg-primary-light' : 'border-border bg-surface'
}`}
>
<div className={`flex h-28 items-center justify-center text-5xl ${active ? 'bg-primary/15' : 'bg-surface-soft'}`}>
{lesson.emoji}
</div>
<div className="flex flex-1 flex-col p-4">
{active && (
<span className="mb-1.5 inline-flex w-fit rounded-full bg-primary px-2.5 py-0.5 text-xs font-extrabold text-ink-inverse">
In Progress
</span>
)}
<p className="text-sm font-extrabold text-ink">{lesson.name}</p>
<p className="my-1 flex-1 text-xs font-medium text-ink-secondary">{lesson.description}</p>
<div className="my-3 h-2 rounded-full border border-border bg-surface">
<div className="h-full rounded-full bg-primary" style={{ width: `${lesson.progress ?? 0}%` }} />
</div>
<Button variant={active ? 'primary' : 'secondary'} className="w-full text-sm">
{active ? 'Continue' : 'Start'}
</Button>
</div>
</div>
);
}
function LessonGrid() {
const [page, setPage] = useState(0);
const pageCount = Math.ceil(MOCK_LESSONS.length / LESSONS_PER_PAGE);
const lessons = MOCK_LESSONS.slice(page * LESSONS_PER_PAGE, page * LESSONS_PER_PAGE + LESSONS_PER_PAGE);
return (
<div>
<h3 className="mb-3 text-lg font-extrabold text-ink">Lessons in this Unit</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{lessons.map((lesson) => (
<LessonCard key={lesson.id} lesson={lesson} active={lesson.id === 1} />
))}
</div>
{pageCount > 1 && (
<div className="mt-4 flex items-center justify-center gap-3">
<button
type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
aria-label="Previous page"
className="flex h-8 w-8 items-center justify-center rounded-full text-ink-secondary hover:bg-surface-soft disabled:opacity-30"
>
</button>
{Array.from({ length: pageCount }, (_, i) => (
<button
key={i}
type="button"
onClick={() => setPage(i)}
aria-label={`Page ${i + 1}`}
className={`h-2.5 w-2.5 rounded-full ${i === page ? 'bg-primary' : 'bg-surface-soft'}`}
/>
))}
<button
type="button"
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
disabled={page === pageCount - 1}
aria-label="Next page"
className="flex h-8 w-8 items-center justify-center rounded-full text-ink-secondary hover:bg-surface-soft disabled:opacity-30"
>
</button>
</div>
)}
</div>
);
}
export function HomePage() {
const { user, isLoading, isError } = useAuthUser();
return (
<div>
<h1 className="text-2xl font-semibold text-gray-900">
Welcome{user ? `, ${user.name}` : ''} 👋
</h1>
<p className="mt-2 text-gray-600">{env.VITE_APP_NAME} your adaptive learning workspace.</p>
<div className="mt-6 rounded-lg border border-gray-200 bg-white p-6 text-sm text-gray-600">
{isLoading && <p>Loading profile</p>}
{isError && <p>Could not load profile details.</p>}
{user && !isLoading && (
<ul className="space-y-1">
<li>ID: {user.id}</li>
<li>Email: {user.email}</li>
<li>Name: {user.name}</li>
</ul>
)}
<div className="space-y-8">
<div>
<h1 className="text-3xl font-black text-ink">
Welcome{user ? `, ${user.name}` : ''} 👋
</h1>
<p className="mt-1 font-medium text-ink-secondary">
{env.VITE_APP_NAME} your adaptive learning workspace.
</p>
</div>
<LessonGrid />
</div>
);
}
+12 -6
View File
@@ -2,12 +2,18 @@ import { Link } from 'react-router-dom';
export function NotFoundPage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-50 px-4 text-center">
<h1 className="text-4xl font-semibold text-gray-900">404</h1>
<p className="mt-2 text-gray-600">This page does not exist.</p>
<Link to="/" className="mt-4 text-sm font-medium text-indigo-600 hover:text-indigo-700">
Back home
</Link>
<div className="flex min-h-screen flex-col items-center justify-center bg-sky-light px-4 text-center">
<div className="rounded-3xl bg-surface p-10">
<p className="text-5xl">🧭</p>
<h1 className="mt-3 text-4xl font-black text-ink">404</h1>
<p className="mt-2 font-medium text-ink-secondary">This page does not exist.</p>
<Link
to="/"
className="mt-5 inline-flex min-h-12 items-center justify-center rounded-2xl border-2 border-primary bg-surface px-6 py-2.5 font-extrabold text-primary hover:bg-primary-light"
>
Back home
</Link>
</div>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { useState } from 'react';
import { Button } from '@/shared/components/Button';
import { Modal } from '@/shared/components/Modal';
// ponytail: hardcoded until leaderboard API exists; swap for real ranking data then.
const MOCK_LEADERBOARD = [
{ rank: 1, name: 'Minh Anh', level: 12, star: 45, streak: 30, createdAt: '2024-02-14' },
{ rank: 2, name: 'Quốc Bảo', level: 11, star: 41, streak: 18, createdAt: '2024-03-02' },
{ rank: 3, name: 'Thu Hà', level: 10, star: 38, streak: 22, createdAt: '2024-01-20' },
{ rank: 4, name: 'Đức Long', level: 9, star: 33, streak: 7, createdAt: '2024-05-11' },
{ rank: 5, name: 'Ngọc Linh', level: 8, star: 29, streak: 12, createdAt: '2024-04-08' },
{ rank: 6, name: 'Bạn', level: 7, star: 25, streak: 5, createdAt: '2024-06-01' },
];
const RANK_MEDAL: Record<number, string> = { 1: '🥇', 2: '🥈', 3: '🥉' };
const rankBadgeClass = (rank: number) =>
rank === 1
? 'bg-secondary text-ink'
: rank === 2
? 'bg-surface-soft text-ink'
: rank === 3
? 'bg-accent-light text-accent'
: 'bg-surface-soft text-ink-secondary';
// ponytail: same identicon trick as UserMenu — seeded by name since ranking has no email.
function gravatarUrl(seed: string, size: number) {
return `https://www.gravatar.com/avatar/${encodeURIComponent(seed.trim().toLowerCase())}?d=identicon&s=${size}`;
}
export function RankingPage() {
const [selected, setSelected] = useState<(typeof MOCK_LEADERBOARD)[number] | null>(null);
return (
<div className="mx-auto max-w-2xl">
<h1 className="text-3xl font-black text-ink">🏆 Ranking</h1>
<div className="mt-6 rounded-xl border border-border bg-surface p-5 shadow-[0_5px_0_rgba(37,42,82,0.08)]">
<div className="space-y-1">
<div className="grid grid-cols-[2rem_1fr_3.25rem_4.25rem_3.25rem_2rem] gap-2 px-2 pb-2 text-xs font-bold uppercase tracking-wide text-ink-muted">
<span>Rank</span>
<span>Name</span>
<span className="whitespace-nowrap text-center">Level</span>
<span className="whitespace-nowrap text-center">Streak</span>
<span className="whitespace-nowrap text-center">Star</span>
<span />
</div>
{MOCK_LEADERBOARD.map((entry) => (
<div
key={entry.rank}
className={`grid grid-cols-[2rem_1fr_3.25rem_4.25rem_3.25rem_2rem] items-center gap-2 rounded-lg px-2 py-2 ${entry.name === 'Bạn' ? 'bg-primary-light' : ''}`}
>
<span className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-black ${rankBadgeClass(entry.rank)}`}>
{RANK_MEDAL[entry.rank] ?? entry.rank}
</span>
<span className="truncate text-sm font-bold text-ink">{entry.name}</span>
<span className="text-center text-sm font-bold text-ink-secondary">Lv.{entry.level}</span>
<span className="text-center text-sm font-bold text-ink-secondary">🔥 {entry.streak}</span>
<span className="text-center text-sm font-extrabold text-primary"> {entry.star}</span>
<button
type="button"
onClick={() => setSelected(entry)}
aria-label={`Xem hồ sơ ${entry.name}`}
className="flex h-7 w-7 items-center justify-center rounded-full text-ink-muted hover:bg-surface-soft hover:text-ink"
>
👤
</button>
</div>
))}
</div>
</div>
<Modal open={selected !== null} onClose={() => setSelected(null)} title="User profile">
{selected && (
<div className="relative flex flex-col items-center gap-4 px-6 py-8">
<button
type="button"
onClick={() => setSelected(null)}
aria-label="Close"
className="absolute top-3 right-3 rounded-full p-1.5 text-ink-muted hover:bg-surface-soft hover:text-ink"
>
</button>
<img
src={gravatarUrl(selected.name, 160)}
alt=""
className="h-20 w-20 rounded-full border-4 border-primary-light"
/>
<dl className="w-full space-y-3 text-sm">
<div className="flex items-center justify-between gap-4 border-b border-border pb-2">
<dt className="text-ink-secondary">Display name</dt>
<dd className="truncate font-semibold text-ink">{selected.name}</dd>
</div>
<div className="flex items-center justify-between gap-4 border-b border-border pb-2">
<dt className="text-ink-secondary">Created at</dt>
<dd className="truncate font-semibold text-ink">{selected.createdAt}</dd>
</div>
</dl>
<Button type="button" variant="outline" onClick={() => setSelected(null)} className="min-h-11 w-full text-sm">
Close
</Button>
</div>
)}
</Modal>
</div>
);
}