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
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Nunito:wght@500;600;700;800;900&display=swap"
rel="stylesheet"
/>
<title>APLP — Adaptive Personal Learning Platform</title>
</head>
<body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+8 -9
View File
@@ -1,23 +1,22 @@
/**
* In-memory token storage.
* Token storage.
*
* Access token is kept in memory only (never persisted to localStorage /
* sessionStorage) to reduce XSS exposure. The backend issues the refresh token
* in the login/refresh response and expects it back in the request body, so we
* keep it in memory alongside the access token for the session's lifetime.
* Access token: in-memory only (XSS mitigation).
* Refresh token: localStorage so it survives page reloads.
*/
const REFRESH_KEY = 'rt';
let accessToken: string | null = null;
let refreshToken: string | null = null;
export const tokenStorage = {
get: (): string | null => accessToken,
getRefreshToken: (): string | null => refreshToken,
getRefreshToken: (): string | null => localStorage.getItem(REFRESH_KEY),
set: (access: string, refresh: string | null): void => {
accessToken = access;
refreshToken = refresh;
if (refresh) localStorage.setItem(REFRESH_KEY, refresh);
},
clear: (): void => {
accessToken = null;
refreshToken = null;
localStorage.removeItem(REFRESH_KEY);
},
};
+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>
+100 -12
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 className="space-y-8">
<div>
<h1 className="text-2xl font-semibold text-gray-900">
<h1 className="text-3xl font-black text-ink">
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>
)}
<p className="mt-1 font-medium text-ink-secondary">
{env.VITE_APP_NAME} your adaptive learning workspace.
</p>
</div>
<LessonGrid />
</div>
);
}
+10 -4
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">
<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>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useState } from 'react';
import { Button } from '@/shared/components/Button';
import { Modal } from '@/shared/components/Modal';
import { useLogout } from '../hooks/useLogout';
import { useAuthStore } from '../store/authStore';
// ponytail: Gravatar's default-image endpoint seeds off whatever string sits in the
// path — no real MD5 needed for a mocked identicon. Swap in a proper md5(email) hash
// when real Gravatar photos (not just the generated identicon) are wanted.
function gravatarUrl(email: string, size: number) {
return `https://www.gravatar.com/avatar/${encodeURIComponent(email.trim().toLowerCase())}?d=identicon&s=${size}`;
}
const PLACEHOLDER = 'Not provided';
// ponytail: hardcoded until learner/progress feature exposes a real API; swap then.
const MOCK_PROGRESS = { level: 4, xp: 320, streak: 7 };
export function UserMenu() {
const user = useAuthStore((s) => s.user);
const logout = useLogout();
const [open, setOpen] = useState(false);
if (!user) return null;
const fields = [
{ label: 'Full name', value: user.name },
{ label: 'Date of birth', value: PLACEHOLDER },
{ label: 'Email', value: user.email },
{ label: 'Phone', value: PLACEHOLDER },
];
return (
<>
<button type="button" onClick={() => setOpen(true)} className="flex items-center gap-1.5">
<span className="flex h-10 items-center justify-center rounded-full bg-accent px-3 text-xs font-extrabold text-ink-inverse">
🔥{MOCK_PROGRESS.streak}
</span>
<span className="flex h-10 items-center justify-center rounded-full bg-secondary px-3 text-xs font-extrabold text-ink">
{MOCK_PROGRESS.xp}
</span>
<div className="relative h-10 w-10">
<img
src={gravatarUrl(user.email, 80)}
alt=""
className="h-10 w-10 rounded-lg border border-border"
/>
<span className="absolute -bottom-1 -right-1 inline-flex items-center rounded-full bg-primary px-1.5 py-0.5 text-xs font-extrabold text-ink-inverse">
{MOCK_PROGRESS.level}
</span>
</div>
</button>
<Modal open={open} onClose={() => setOpen(false)} title="User profile">
<div className="relative flex flex-col items-center gap-4 px-6 py-8">
<button
type="button"
onClick={() => setOpen(false)}
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(user.email, 160)}
alt=""
className="h-20 w-20 rounded-full border-4 border-primary-light"
/>
<dl className="w-full space-y-3 text-sm">
{fields.map((f) => (
<div key={f.label} className="flex items-center justify-between gap-4 border-b border-border pb-2">
<dt className="text-ink-secondary">{f.label}</dt>
<dd className="truncate font-semibold text-ink">{f.value}</dd>
</div>
))}
</dl>
<Button
type="button"
variant="outline"
onClick={() => {
setOpen(false);
logout.mutate();
}}
disabled={logout.isPending}
className="min-h-11 w-full text-sm"
>
{logout.isPending ? 'Signing out…' : 'Sign out'}
</Button>
</div>
</Modal>
</>
);
}
+1 -1
View File
@@ -7,6 +7,6 @@ export function useLogin() {
const login = useAuthStore((s) => s.login);
return useMutation({
mutationFn: (credentials: LoginCredentials) => login(credentials.email, credentials.password),
mutationFn: (credentials: LoginCredentials) => login(credentials.username, credentials.password),
});
}
+4 -4
View File
@@ -51,7 +51,7 @@ describe('LoginPage', () => {
renderLogin();
expect(screen.getByTestId('login-form')).toBeInTheDocument();
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Username')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});
@@ -60,12 +60,12 @@ describe('LoginPage', () => {
const user = userEvent.setup();
renderLogin();
await user.type(screen.getByLabelText('Email'), 'learner@aplp.io');
await user.type(screen.getByLabelText('Username'), 'learner');
await user.type(screen.getByLabelText('Password'), 'secret');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(mockedAuthApi.login).toHaveBeenCalledWith({
email: 'learner@aplp.io',
username: 'learner',
password: 'secret',
});
@@ -77,7 +77,7 @@ describe('LoginPage', () => {
const user = userEvent.setup();
renderLogin();
await user.type(screen.getByLabelText('Email'), 'learner@aplp.io');
await user.type(screen.getByLabelText('Username'), 'learner');
await user.type(screen.getByLabelText('Password'), 'wrong');
await user.click(screen.getByRole('button', { name: /sign in/i }));
+54 -25
View File
@@ -1,7 +1,8 @@
import { useState, type FormEvent } from 'react';
import { useState, type SubmitEvent } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import env from '@/shared/config/env';
import { Button } from '@/shared/components/Button';
import { useLogin } from '../hooks/useLogin';
import { useAuthStore } from '../store/authStore';
@@ -9,7 +10,7 @@ export function LoginPage() {
const user = useAuthStore((s) => s.user);
const { state } = useLocation();
const login = useLogin();
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const from = (state as { from?: string } | null)?.from ?? '/';
@@ -18,37 +19,68 @@ export function LoginPage() {
return <Navigate to={from} replace />;
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
event.preventDefault();
login.mutate({ email, password });
login.mutate({ username, password });
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-sm rounded-lg border border-gray-200 bg-white p-8 shadow-sm">
<h1 className="mb-1 text-2xl font-semibold text-gray-900">{env.VITE_APP_NAME}</h1>
<p className="mb-6 text-sm text-gray-500">Sign in to your learning account</p>
<div className="flex min-h-screen items-center justify-center bg-[url('/img/login.png')] bg-cover bg-center px-4">
<div className="w-full max-w-sm rounded-3xl border border-border bg-surface p-8 shadow-[0_20px_60px_rgba(32,35,58,0.1)]">
<h1 className="mb-1 text-2xl font-extrabold text-ink text-center">{env.VITE_APP_NAME}</h1>
<p className="mb-6 text-sm font-medium text-ink-secondary text-center">Let's learn together!</p>
<form onSubmit={handleSubmit} className="space-y-4" data-testid="login-form">
<div>
<label htmlFor="email" className="mb-1 block text-sm font-medium text-gray-700">
Email
<label htmlFor="username" className="mb-1 block text-sm font-bold text-ink">
Username
</label>
<div className="relative">
<svg
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-secondary"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<input
id="email"
type="email"
autoComplete="email"
id="username"
type="text"
autoComplete="username"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full rounded-xl border-2 border-border py-2.5 pl-10 pr-4 text-sm focus:border-primary focus:outline-none"
/>
</div>
</div>
<div>
<label htmlFor="password" className="mb-1 block text-sm font-medium text-gray-700">
<label htmlFor="password" className="mb-1 block text-sm font-bold text-ink">
Password
</label>
<div className="relative">
<svg
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-secondary"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<input
id="password"
type="password"
@@ -56,23 +88,20 @@ export function LoginPage() {
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
className="w-full rounded-xl border-2 border-border py-2.5 pl-10 pr-4 text-sm focus:border-primary focus:outline-none"
/>
</div>
</div>
{login.isError && (
<p role="alert" className="text-sm text-red-600">
<p role="alert" className="text-sm font-semibold text-accent">
{login.error instanceof Error ? login.error.message : 'Login failed'}
</p>
)}
<button
type="submit"
disabled={login.isPending}
className="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
>
<Button type="submit" disabled={login.isPending} className="w-full">
{login.isPending ? 'Signing in' : 'Sign in'}
</button>
</Button>
</form>
</div>
</div>
+4 -4
View File
@@ -34,10 +34,10 @@ describe('authStore', () => {
});
it('login stores token and user', async () => {
await useAuthStore.getState().login('learner@aplp.io', 'secret');
await useAuthStore.getState().login('learner', 'secret');
expect(mockedAuthApi.login).toHaveBeenCalledWith({
email: 'learner@aplp.io',
username: 'learner',
password: 'secret',
});
expect(tokenStorage.get()).toBe('access-123');
@@ -48,7 +48,7 @@ describe('authStore', () => {
it('login failure leaves user signed out', async () => {
mockedAuthApi.login.mockRejectedValue(new Error('invalid credentials'));
await expect(useAuthStore.getState().login('learner@aplp.io', 'wrong')).rejects.toThrow(
await expect(useAuthStore.getState().login('learner', 'wrong')).rejects.toThrow(
'invalid credentials',
);
expect(tokenStorage.get()).toBeNull();
@@ -56,7 +56,7 @@ describe('authStore', () => {
});
it('logout clears token and user even when API call fails', async () => {
await useAuthStore.getState().login('learner@aplp.io', 'secret');
await useAuthStore.getState().login('learner', 'secret');
mockedAuthApi.logout.mockRejectedValue(new Error('network'));
await useAuthStore.getState().logout();
+3 -3
View File
@@ -10,7 +10,7 @@ interface AuthState {
user: AuthUser | null;
/** Whether a session bootstrap is in progress. */
isInitializing: boolean;
login: (email: string, password: string) => Promise<void>;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
/** Bootstrap session: try refresh on app start. */
init: () => Promise<void>;
@@ -20,8 +20,8 @@ export const useAuthStore = create<AuthState>()((set) => ({
user: null,
isInitializing: true,
login: async (email, password) => {
const { accessToken, refreshToken, user } = await authApi.login({ email, password });
login: async (username, password) => {
const { accessToken, refreshToken, user } = await authApi.login({ username, password });
tokenStorage.set(accessToken, refreshToken);
set({ user, isInitializing: false });
},
+1 -1
View File
@@ -5,7 +5,7 @@ export interface AuthUser {
}
export interface LoginCredentials {
email: string;
username: string;
password: string;
}
+49
View File
@@ -1 +1,50 @@
@import 'tailwindcss';
@theme {
--font-display: 'Nunito', system-ui, sans-serif;
--color-primary: #5b5fef;
--color-primary-dark: #4548c9;
--color-primary-light: #e8e9ff;
--color-secondary: #ffb84d;
--color-secondary-dark: #e59a28;
--color-secondary-light: #fff1d6;
--color-success: #35c978;
--color-success-dark: #239b58;
--color-success-light: #ddf7e8;
--color-accent: #ff6b6b;
--color-accent-light: #ffe3e3;
--color-sky: #55b9f3;
--color-sky-light: #e2f4ff;
--color-canvas: #f7f8fc;
--color-surface: #ffffff;
--color-surface-soft: #f0f2f8;
--color-surface-hover: #e9ebf5;
--color-ink: #20233a;
--color-ink-secondary: #62677f;
--color-ink-muted: #969bae;
--color-ink-inverse: #ffffff;
--color-border: #e2e5ef;
}
html {
font-size: 18px;
}
body {
font-family: var(--font-display);
background-color: var(--color-canvas);
background-image:
radial-gradient(circle at 8% -8%, var(--color-primary-light) 0%, transparent 38%),
radial-gradient(circle at 92% 8%, var(--color-sky-light) 0%, transparent 32%),
radial-gradient(circle at 50% 105%, var(--color-secondary-light) 0%, transparent 42%);
background-attachment: fixed;
color: var(--color-ink);
}
+54
View File
@@ -0,0 +1,54 @@
import type { ButtonHTMLAttributes } from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'success' | 'outline' | 'ghost';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
}
const base =
'relative z-10 inline-flex items-center justify-center rounded-2xl font-extrabold disabled:opacity-50 disabled:pointer-events-none';
const variants: Record<ButtonVariant, string> = {
primary: 'bg-primary text-ink-inverse px-6 py-3.5 min-h-13 hover:brightness-105',
secondary: 'bg-secondary text-ink px-6 py-3 min-h-12 hover:brightness-105',
success: 'bg-success text-ink-inverse px-6 py-3 min-h-12 hover:brightness-105',
outline: 'bg-surface text-primary border-2 border-primary px-6 py-2.5 min-h-12 hover:bg-primary-light',
ghost: 'bg-transparent text-ink-secondary rounded-xl px-4 py-3 hover:bg-surface-soft',
};
// Wrapper press: translate whole group (button + lip) down on active.
// Lip collapses bottom offset to 0 → 3D depth disappears when pressed (DESIGN.md §4).
const wrapperPress: Partial<Record<ButtonVariant, string>> = {
primary: 'active:translate-y-[5px]',
secondary: 'active:translate-y-[4px]',
success: 'active:translate-y-[4px]',
};
// Static "base" layer behind the button — gives the 3D depth lip without animating
// box-shadow (which isn't GPU-composited and looked janky). On press the lip
// collapses to bottom-0 so the button sinks and loses its elevation (DESIGN.md §4).
// Uses group-active because this span is a sibling of button, not its parent.
// Lip sits flush behind-and-below the button: top aligns with button top (z-10 covers it),
// bottom extends 5px below → only the protruding bottom strip is visible = 3D ledge below.
// On press: wrapper translates down, lip top stays flush, bottom collapses to 0 → ledge gone.
const depthLayer: Partial<Record<ButtonVariant, string>> = {
primary: 'absolute inset-x-0 top-0 -bottom-[5px] rounded-2xl bg-primary-dark transition-[bottom] duration-75 ease-out group-active:-bottom-0',
secondary: 'absolute inset-x-0 top-0 -bottom-[4px] rounded-2xl bg-secondary-dark transition-[bottom] duration-75 ease-out group-active:-bottom-0',
success: 'absolute inset-x-0 top-0 -bottom-[4px] rounded-2xl bg-success-dark transition-[bottom] duration-75 ease-out group-active:-bottom-0',
};
export function Button({ variant = 'primary', className = '', ...props }: ButtonProps) {
const layer = depthLayer[variant];
const press = wrapperPress[variant] ?? '';
// ponytail: string-matching "w-full" to size the wrapper is a heuristic, not general —
// fine for this app's handful of call sites; revisit if more layout classes need forwarding.
const stretch = className.includes('w-full') ? 'w-full' : '';
return (
<span className={`group relative inline-flex transform-gpu transition-transform duration-75 ease-out ${press} ${stretch}`}>
{layer && <span aria-hidden className={layer} />}
<button className={`${base} ${variants[variant]} ${className}`} {...props} />
</span>
);
}
+3 -3
View File
@@ -1,8 +1,8 @@
export function EmptyState({ title, description }: { title: string; description?: string }) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-300 bg-white px-6 py-12 text-center">
<p className="text-sm font-medium text-gray-700">{title}</p>
{description && <p className="mt-1 text-sm text-gray-500">{description}</p>}
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-border bg-surface-soft px-6 py-12 text-center">
<p className="text-sm font-bold text-ink-secondary">{title}</p>
{description && <p className="mt-1 text-sm font-medium text-ink-muted">{description}</p>}
</div>
);
}
+4 -2
View File
@@ -25,8 +25,10 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
if (this.state.hasError) {
return (
this.props.fallback ?? (
<div className="flex min-h-screen items-center justify-center px-4 text-center">
<p className="text-sm text-gray-600">Something went wrong. Please reload the page.</p>
<div className="flex min-h-screen items-center justify-center bg-canvas px-4 text-center">
<p className="text-sm font-semibold text-ink-secondary">
Something went wrong. Please reload the page.
</p>
</div>
)
);
+3 -3
View File
@@ -7,9 +7,9 @@ interface ErrorStateProps {
export function ErrorState({ title = 'Something went wrong', error }: ErrorStateProps) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-red-200 bg-red-50 px-6 py-8 text-center">
<p className="text-sm font-medium text-red-700">{title}</p>
{error && <p className="mt-1 text-sm text-red-600">{error.message}</p>}
<div className="flex flex-col items-center justify-center rounded-2xl border-2 border-accent bg-accent-light px-6 py-8 text-center">
<p className="text-sm font-bold text-accent">{title}</p>
{error && <p className="mt-1 text-sm font-medium text-accent">{error.message}</p>}
</div>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { Spinner } from '@/shared/components/Spinner';
export function FullPageSpinner({ label = 'Loading' }: { label?: string }) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex min-h-screen items-center justify-center bg-canvas">
<Spinner label={label} />
</div>
);
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useRef, type ReactNode } from 'react';
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
export function Modal({ open, onClose, title, children }: ModalProps) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog
ref={ref}
onClose={onClose}
onCancel={onClose}
onClick={(e) => e.target === ref.current && onClose()}
aria-label={title}
className="m-auto w-full max-w-sm rounded-3xl border border-border bg-surface p-0 shadow-xl backdrop:bg-ink/40 backdrop:backdrop-blur-sm"
>
{children}
</dialog>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
export function Spinner({ label = 'Loading' }: { label?: string }) {
return (
<span role="status" className="inline-flex items-center gap-2 text-sm text-gray-500">
<svg className="h-4 w-4 animate-spin text-indigo-600" viewBox="0 0 24 24" fill="none">
<span role="status" className="inline-flex items-center gap-2 text-sm font-semibold text-ink-secondary">
<svg className="h-4 w-4 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
<circle
className="opacity-25"
cx="12"