feat: add mock page
This commit is contained in:
+5
-1
@@ -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 />} />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user