Could not load profile details.
);
}
diff --git a/src/app/pages/NotFoundPage.tsx b/src/app/pages/NotFoundPage.tsx
index 77529c0..adccc19 100644
--- a/src/app/pages/NotFoundPage.tsx
+++ b/src/app/pages/NotFoundPage.tsx
@@ -2,12 +2,18 @@ import { Link } from 'react-router-dom';
export function NotFoundPage() {
return (
-
-
404
-
This page does not exist.
-
- Back home
-
+
+
+
🧭
+
404
+
This page does not exist.
+
+ Back home
+
+
);
}
diff --git a/src/app/pages/RankingPage.tsx b/src/app/pages/RankingPage.tsx
new file mode 100644
index 0000000..5fd2c34
--- /dev/null
+++ b/src/app/pages/RankingPage.tsx
@@ -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
= { 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 (
+
+
🏆 Ranking
+
+
+
+ Rank
+ Name
+ Level
+ Streak
+ Star
+
+
+ {MOCK_LEADERBOARD.map((entry) => (
+
+
+ {RANK_MEDAL[entry.rank] ?? entry.rank}
+
+ {entry.name}
+ Lv.{entry.level}
+ 🔥 {entry.streak}
+ ⭐ {entry.star}
+
+
+ ))}
+
+
+
+
setSelected(null)} title="User profile">
+ {selected && (
+
+
+
+

+
+
+
+
- Display name
+ - {selected.name}
+
+
+
- Created at
+ - {selected.createdAt}
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/features/auth/components/UserMenu.tsx b/src/features/auth/components/UserMenu.tsx
new file mode 100644
index 0000000..b5538b3
--- /dev/null
+++ b/src/features/auth/components/UserMenu.tsx
@@ -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 (
+ <>
+
+
+ setOpen(false)} title="User profile">
+
+
+
+

+
+
+ {fields.map((f) => (
+
+
- {f.label}
+ - {f.value}
+
+ ))}
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/features/auth/hooks/useLogin.ts b/src/features/auth/hooks/useLogin.ts
index 191155f..1832ec8 100644
--- a/src/features/auth/hooks/useLogin.ts
+++ b/src/features/auth/hooks/useLogin.ts
@@ -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),
});
}
diff --git a/src/features/auth/pages/LoginPage.test.tsx b/src/features/auth/pages/LoginPage.test.tsx
index a90c797..b383166 100644
--- a/src/features/auth/pages/LoginPage.test.tsx
+++ b/src/features/auth/pages/LoginPage.test.tsx
@@ -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 }));
diff --git a/src/features/auth/pages/LoginPage.tsx b/src/features/auth/pages/LoginPage.tsx
index 85b192b..5d871ea 100644
--- a/src/features/auth/pages/LoginPage.tsx
+++ b/src/features/auth/pages/LoginPage.tsx
@@ -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,61 +19,89 @@ export function LoginPage() {
return ;
}
- function handleSubmit(event: FormEvent) {
+ function handleSubmit(event: SubmitEvent) {
event.preventDefault();
- login.mutate({ email, password });
+ login.mutate({ username, password });
}
return (
-
-
-
{env.VITE_APP_NAME}
-
Sign in to your learning account
+
+
+
{env.VITE_APP_NAME}
+
Let's learn together!
diff --git a/src/features/auth/store/authStore.test.ts b/src/features/auth/store/authStore.test.ts
index 3a9c08d..5fc1bb3 100644
--- a/src/features/auth/store/authStore.test.ts
+++ b/src/features/auth/store/authStore.test.ts
@@ -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();
diff --git a/src/features/auth/store/authStore.ts b/src/features/auth/store/authStore.ts
index c12974a..03f6d1f 100644
--- a/src/features/auth/store/authStore.ts
+++ b/src/features/auth/store/authStore.ts
@@ -10,7 +10,7 @@ interface AuthState {
user: AuthUser | null;
/** Whether a session bootstrap is in progress. */
isInitializing: boolean;
- login: (email: string, password: string) => Promise
;
+ login: (username: string, password: string) => Promise;
logout: () => Promise;
/** Bootstrap session: try refresh on app start. */
init: () => Promise;
@@ -20,8 +20,8 @@ export const useAuthStore = create()((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 });
},
diff --git a/src/features/auth/types.ts b/src/features/auth/types.ts
index c16f520..694ff2d 100644
--- a/src/features/auth/types.ts
+++ b/src/features/auth/types.ts
@@ -5,7 +5,7 @@ export interface AuthUser {
}
export interface LoginCredentials {
- email: string;
+ username: string;
password: string;
}
diff --git a/src/index.css b/src/index.css
index d4b5078..51f4c44 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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);
+}
diff --git a/src/shared/components/Button.tsx b/src/shared/components/Button.tsx
new file mode 100644
index 0000000..b6c3d33
--- /dev/null
+++ b/src/shared/components/Button.tsx
@@ -0,0 +1,54 @@
+import type { ButtonHTMLAttributes } from 'react';
+
+type ButtonVariant = 'primary' | 'secondary' | 'success' | 'outline' | 'ghost';
+
+interface ButtonProps extends ButtonHTMLAttributes {
+ 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 = {
+ 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> = {
+ 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> = {
+ 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 (
+
+ {layer && }
+
+
+ );
+}
diff --git a/src/shared/components/EmptyState.tsx b/src/shared/components/EmptyState.tsx
index 9a6dab5..ce5d274 100644
--- a/src/shared/components/EmptyState.tsx
+++ b/src/shared/components/EmptyState.tsx
@@ -1,8 +1,8 @@
export function EmptyState({ title, description }: { title: string; description?: string }) {
return (
-
-
{title}
- {description &&
{description}
}
+
+
{title}
+ {description &&
{description}
}
);
}
diff --git a/src/shared/components/ErrorBoundary.tsx b/src/shared/components/ErrorBoundary.tsx
index 6a9ee70..aea4812 100644
--- a/src/shared/components/ErrorBoundary.tsx
+++ b/src/shared/components/ErrorBoundary.tsx
@@ -25,8 +25,10 @@ export class ErrorBoundary extends Component
- Something went wrong. Please reload the page.
+
+
+ Something went wrong. Please reload the page.
+
)
);
diff --git a/src/shared/components/ErrorState.tsx b/src/shared/components/ErrorState.tsx
index 90fe7b9..e9581fd 100644
--- a/src/shared/components/ErrorState.tsx
+++ b/src/shared/components/ErrorState.tsx
@@ -7,9 +7,9 @@ interface ErrorStateProps {
export function ErrorState({ title = 'Something went wrong', error }: ErrorStateProps) {
return (
-
-
{title}
- {error &&
{error.message}
}
+
+
{title}
+ {error &&
{error.message}
}
);
}
diff --git a/src/shared/components/FullPageSpinner.tsx b/src/shared/components/FullPageSpinner.tsx
index 5dfb2af..54c3459 100644
--- a/src/shared/components/FullPageSpinner.tsx
+++ b/src/shared/components/FullPageSpinner.tsx
@@ -2,7 +2,7 @@ import { Spinner } from '@/shared/components/Spinner';
export function FullPageSpinner({ label = 'Loading' }: { label?: string }) {
return (
-
+
);
diff --git a/src/shared/components/Modal.tsx b/src/shared/components/Modal.tsx
new file mode 100644
index 0000000..1907301
--- /dev/null
+++ b/src/shared/components/Modal.tsx
@@ -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
(null);
+
+ useEffect(() => {
+ const dialog = ref.current;
+ if (!dialog) return;
+ if (open && !dialog.open) dialog.showModal();
+ if (!open && dialog.open) dialog.close();
+ }, [open]);
+
+ return (
+
+ );
+}
diff --git a/src/shared/components/Spinner.tsx b/src/shared/components/Spinner.tsx
index afd79c7..bb63ee2 100644
--- a/src/shared/components/Spinner.tsx
+++ b/src/shared/components/Spinner.tsx
@@ -1,7 +1,7 @@
export function Spinner({ label = 'Loading' }: { label?: string }) {
return (
-
-