Compare commits

3 Commits
Author SHA1 Message Date
namdh 271fdb2521 feat: add guide 2026-08-22 08:24:38 +07:00
namdh dd9ed73a02 feat: add mock page 2026-08-22 08:24:06 +07:00
namdh b44092153d fix: parse login 2026-08-11 23:10:00 +07:00
33 changed files with 1973 additions and 136 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
# Copy to `.env` for local development (never commit `.env`).
VITE_API_BASE_URL=http://localhost:8080/api
VITE_API_BASE_URL=http://localhost:8080/api/v1
VITE_APP_NAME=APLP
+79
View File
@@ -0,0 +1,79 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
`aplp.frontend.react` — React frontend for APLP (Adaptive Personal Learning Platform), the learner-facing app (`aplp-web`). Thin client only: it calls the `aplp.backend.spring` REST API and holds no domain business rules (mastery/progress/adaptive decisions all come from the backend).
## Commands
```bash
npm run dev # Vite dev server
npm run build # tsc -b (typecheck) + vite build
npm run typecheck # tsc -b --noEmit
npm run lint # oxlint
npm run lint:fix
npm run format # prettier --write .
npm run format:check
npm test # vitest run (single run)
npm run test:watch # vitest watch mode
npm run test:coverage
```
Run a single test file: `npx vitest run src/features/auth/store/authStore.test.ts` (or `npx vitest src/path/to/File.test.tsx` for watch mode on one file).
Backend must be running locally for the app to function against real APIs (default `VITE_API_BASE_URL=http://localhost:8080/api/v1`, see `.env.example`).
## Architecture
```
src/
├── app/ # entry, router (App.tsx), layouts, top-level pages, providers (QueryProvider)
├── api/ # axios client (src/api/client.ts), tokenStorage
├── features/ # vertical feature modules: auth (built), learner/course/learning/assessment/adaptive/... (planned per docs/PHASES.md)
│ └── <feature>/
│ ├── api/ # calls to backend endpoints for this feature
│ ├── components/
│ ├── hooks/
│ ├── pages/ # route-level views
│ ├── store/ # zustand store, if the feature needs one
│ └── types.ts
├── shared/ # shared components (src/shared/components), config (src/shared/config/env.ts)
└── types/ # cross-feature types / DTO contracts (src/types/api.ts)
```
Path alias `@/``src/` (configured in `vite.config.ts` and `tsconfig`).
### Data flow
UI component → hooks (React Query for server state, Zustand for cross-app client state, `useState` for local state) → `src/api` axios wrapper → backend REST API.
- Server state (fetched/cached data) always goes through TanStack Query — never duplicate it into a Zustand store.
- All backend calls go through `src/api`; don't call axios/fetch directly from components.
### Auth flow
- Access token: in-memory only (`src/api/tokenStorage.ts`), never persisted — XSS mitigation. Cleared on reload.
- Refresh token: currently stored in `localStorage` by `tokenStorage.ts` (note: this differs from `docs/ARCHITECTURE.md`, which documents the original design as an HttpOnly cookie managed entirely by the backend — treat the code as source of truth, not the doc, if the two disagree).
- The axios client (`src/api/client.ts`) auto-handles `401`s: on a non-auth-endpoint 401 it calls the registered `RefreshHandler` once (deduped via a shared `refreshPromise`), retries the original request with the new token, and calls `UnauthorizedHandler` (logout) if refresh fails. Auth endpoints (`/auth/*`) are excluded from this retry to avoid loops.
- `registerAuthHandlers()` wires the auth feature's refresh/logout logic into the generic api client — the api layer stays auth-feature-agnostic.
- Route protection via `ProtectedRoute` (`src/features/auth/components/ProtectedRoute.tsx`), wrapping routes in `App.tsx`.
- `useAuthStore.getState().init()` runs once on app mount (`App.tsx`) to hydrate auth state.
## Conventions (from docs/CONVENTIONS.md)
- TypeScript everywhere; avoid `any`. Props via `interface`.
- Naming: components PascalCase, hooks `useX` camelCase, API functions camelCase, types/interfaces PascalCase.
- Test files sit next to source: `Component.test.tsx`.
- React Query keys: array form, e.g. `['course', id]`.
- Every fetch/save view handles loading, empty, error, success states, using the shared components in `src/shared/components` (`Spinner`, `EmptyState`, `ErrorState`, `ErrorBoundary`).
- Errors surfaced to users must be friendly messages derived from the backend's `ApiError` shape (`src/types/api.ts`) — never raw stack traces.
## Docs
`docs/ARCHITECTURE.md`, `docs/CONVENTIONS.md`, `docs/SETUP.md`, `docs/PHASES.md` — the latter maps each upcoming feature phase (learner profile, course, learning, assessment, adaptive, recommendation, assistance, account) to frontend work; check it before scaffolding a new `features/<name>` module.
## Design
Alway using `docs/DESIGN.md` file to code everything relevant to style and UI
+127
View File
@@ -0,0 +1,127 @@
# APLP Frontend — Architecture
| | |
| --- | --- |
| **Module** | `aplp.frontend.react` |
| **Status** | Phase 0 hoàn tất |
## 1. Role
Client mỏng cho `aplp.web`: gọi API của backend `aplp.backend.spring`, tập trung vào UI/UX.
**Frontend không**:
- Tính toán master/progress/adaptive (mọi quyết định từ backend).
- Chứa business rules.
- Lưu trữ dữ liệu business ngoài cache phục vụ UI.
## 2. Tech Stack (confirmed tại Phase 0)
| Lĩnh vực | Lựa chọn |
| --- | --- |
| Framework | React 19+ |
| Language | TypeScript |
| Build | Vite 8 |
| Routing | React Router 7 |
| HTTP client | Axios (abstraction) |
| Server-state | TanStack Query (React Query) |
| Client-state | Zustand |
| Styling | Tailwind CSS 4 |
| Testing | Vitest + Testing Library |
## 3. Folder Structure
```text
aplp.frontend.react/
├── docs/
├── public/
└── src/
├── app/ # app entry, router, providers, layout
├── api/ # api client, endpoints
├── features/ # feature modules (vertical per phase)
│ ├── auth/
│ ├── learner/
│ ├── course/
│ ├── learning/
│ ├── assessment/
│ ├── adaptive/
│ ├── recommendation/
│ ├── assistance/
│ └── account/
├── shared/ # shared components, hooks, utils
└── types/ # shared types / DTO contracts
```
## 4. Feature Structure
Mỗi feature module nên tự đóng gói:
```text
src/features/<feature>/
├── api/ # calls backend endpoints
├── components/
├── hooks/
├── pages/ # route-level views
└── types.ts # feature-local types
```
## 5. Data Flow
```
UI (component)
↓ hooks (React Query / Zustand)
↓ api/ axios wrapper (auth header, refresh token, error mapping)
↓ Backend REST API
```
- **Read**: React Query quản lý fetch/cache/server-state.
- **Write**: mutation qua React Query; optimistic update nếu cần.
- **Runtime UI state**: local/Zustand.
- Token: access token **in-memory** (`src/api/tokenStorage.ts`), refresh token HttpOnly cookie do backend quản lý (đã confirm Phase 0).
## 6. Auth Flow
```
Login → lưu access token → attach Authorization header
Access token hết hạn → gọi /auth/refresh → retry request
Logout → xóa token → redirect
```
- Trung tâm qua api client, không scatter trong từng page.
- Route bảo vệ qua ProtectedRoute / auth guard.
- Chi tiết (JWT/session) theo backend (OQ-010 / ARC-002).
## 7. Error Handling
- Api client chuẩn hóa lỗi (`ApiError` từ backend) → user-friendly message.
- Error boundary cho crash cấp tree.
- Loading/empty/error states component tái sử dụng.
## 8. Styling & Design
- Theme tokens dùng chung (colors, spacing, typography).
- Component pattern thống nhất (candidate: component library quyết định Phase 0).
## 9. Deployment
- Build tĩnh (Vite output) serve qua CDN/nginx hoặc tách container.
- Env qua `VITE_*` variables; không commit secret vào build config.
## 10. Guardrails
- **Không tính toán domain phía client** (adaptive, mastery, progress).
- **Không bypass auth guard**.
- **Không gọi backend API trực tiếp ngoài `src/api`** (except khi có lý do rõ).
- Loại bỏ dead code, không dùng type `any` tùy tiện (TypeScript).
- Dùng semantic versions; document trong repo.
## 11. Open Questions
| ID | Question | Impact | Status |
| --- | --- | --- | --- |
| FE-001 | Vite hay framework khác? | Setup | **Resolved: Vite** |
| FE-002 | TypeScript bắt buộc? | Quality | **Resolved: có** |
| FE-003 | Component library? | UX | **Resolved: Tailwind CSS** |
| FE-004 | Client-state tool? | Data flow | **Resolved: Zustand** |
| FE-005 | SSR/SSG? | SEO | **Resolved: không** |
| FE-008 | Cách quản lý token (memory/localStorage/refresh)? | Security | **Resolved: access token in-memory + refresh token HttpOnly cookie (backend)** |
| FE-009 | Có cần end-to-end test (Playwright)? | Testing | Open |
+82
View File
@@ -0,0 +1,82 @@
# APLP Frontend — Conventions
| | |
| --- | --- |
| **Status** | Phase 0 hoàn tất |
## 1. Language & Tooling
- **TypeScript** cho toàn bộ code. Không dùng `any` tùy tiện; ưu tiên explicit types.
- Lint: **oxlint** (đã confirm Phase 0); format: **Prettier**.
- Convention: file `.tsx` cho React components; kiểu dùng interface cho props.
## 2. Naming
| Loại | Quy ước |
| --- | --- |
| Component | PascalCase (`CourseCard.tsx`) |
| Hook | camelCase, prefix `use` (`useLearningProgress.ts`) |
| Page/route | PascalCase trong `pages/` |
| API function | camelCase (`fetchCourseDetail`) |
| Types/interface | PascalCase (`CourseDetailDto`, `ApiError`) |
## 3. Component Rules
- Component nhỏ, single responsibility.
- Props dùng interface; optional props explicit.
- Logic phức tạp nên tách vào hooks.
- Components dùng chung đặt `src/shared/components`; feature-specific vào `features/<feature>/components`.
- Không đặt business logic vào JSX.
## 4. State Management
- **Server state** → TanStack Query (query keys chuẩn: `['course', id]`).
- **UI/local state** → `useState`; nếu cần chia sẻ toàn app → Zustand store.
- Không đặt server data vào Zustand khi đã có React Query (tránh trùng source of truth).
- Tên store: domain concept (`useLearnerStore`).
## 5. API Layer
- Gọi backend qua `src/api` wrapper. Không gọi `fetch`/axios trực tiếp tại component.
- Response mapping/error handling nằm trong api layer.
- Có hằng số endpoint riêng, không đặt URL rải rác.
## 6. Error & Loading States
- Mọi view fetch/save cần xử lý: loading, empty, error, success.
- Dùng shared components (Spinner, EmptyState, ErrorState / ErrorBoundary).
- Hiển thị lỗi user-friendly từ `ApiError` của backend; không hiện stack trace.
## 7. Styling
- Dùng design tokens/theme (xác nhận stack Phase 0).
- Không hard-code color/margin lộn xộn; ưu tiên class utility/component thống nhất.
- Responsive theo grid/breakpoints chuẩn.
## 8. Testing
- Unit test components/hooks: Vitest.
- Render test với testing-library.
- E2E (Playwright) — optional, đánh giá sau.
- Test file đặt cạnh source (`Component.test.tsx`) hoặc `__tests__/` — chọn 1 để thống nhất.
## 9. Git & Collaboration
- Commit nhỏ, message rõ (theo style repo khi setup lại).
- Dùng feature branch + review.
- Lint + typecheck + test chạy qua (lệnh chuẩn `npm run lint`, `npm run typecheck`, `npm test`) trước khi merge.
## 10. Security
- **Không** lưu secret/token vào code hay log.
- Không put credentials vào env file committed (`.env``.env.example`).
- Không render user content bằng `dangerouslySetInnerHTML` trừ khi đã sanatize.
## 11. Open Questions
| ID | Question | Status |
| --- | --- | --- |
| CON-001 | ESLint config (airbnb/custom)? | **Resolved: oxlint (default config)** |
| CON-002 | Path alias (`@/`)? | **Resolved: có (`@` → `src`)** |
| CON-003 | Test file location convention? | **Resolved: đặt cạnh source (`Component.test.tsx`)** |
| CON-004 | Storybook có cần? | Open |
+722
View File
@@ -0,0 +1,722 @@
---
version: alpha
name: Gamified Learning UI
description: A playful, game-like design system for an English learning web application. Built around a bright learning world, chunky tactile 3D controls, soft rounded surfaces, clear progress signals, and lightweight game mechanics such as XP, levels, streaks, badges, and rewards. The interface feels like a friendly game rather than a traditional LMS — energetic and motivating without becoming childish or visually noisy.
colors:
primary: "#5B5FEF"
primary-dark: "#4548C9"
primary-light: "#E8E9FF"
secondary: "#FFB84D"
secondary-dark: "#E59A28"
secondary-light: "#FFF1D6"
success: "#35C978"
success-dark: "#239B58"
success-light: "#DDF7E8"
accent: "#FF6B6B"
accent-light: "#FFE3E3"
sky: "#55B9F3"
sky-light: "#E2F4FF"
canvas: "#F7F8FC"
surface: "#FFFFFF"
surface-soft: "#F0F2F8"
surface-hover: "#E9EBF5"
ink: "#20233A"
ink-secondary: "#62677F"
ink-muted: "#969BAE"
ink-inverse: "#FFFFFF"
border: "#E2E5EF"
shadow-color: "#252A52"
typography:
display-xl:
fontFamily: Nunito
fontSize: 64px
fontWeight: 900
lineHeight: 1.0
letterSpacing: -1px
display-lg:
fontFamily: Nunito
fontSize: 48px
fontWeight: 900
lineHeight: 1.05
letterSpacing: -0.5px
display-md:
fontFamily: Nunito
fontSize: 36px
fontWeight: 800
lineHeight: 1.1
letterSpacing: -0.25px
heading-lg:
fontFamily: Nunito
fontSize: 28px
fontWeight: 800
lineHeight: 1.2
letterSpacing: 0
heading-md:
fontFamily: Nunito
fontSize: 22px
fontWeight: 800
lineHeight: 1.25
letterSpacing: 0
heading-sm:
fontFamily: Nunito
fontSize: 18px
fontWeight: 800
lineHeight: 1.3
letterSpacing: 0
body-lg:
fontFamily: Nunito
fontSize: 18px
fontWeight: 600
lineHeight: 1.5
letterSpacing: 0
body:
fontFamily: Nunito
fontSize: 16px
fontWeight: 500
lineHeight: 1.5
letterSpacing: 0
body-sm:
fontFamily: Nunito
fontSize: 14px
fontWeight: 500
lineHeight: 1.45
letterSpacing: 0
button:
fontFamily: Nunito
fontSize: 16px
fontWeight: 800
lineHeight: 1.2
letterSpacing: 0
button-lg:
fontFamily: Nunito
fontSize: 18px
fontWeight: 800
lineHeight: 1.2
letterSpacing: 0
label:
fontFamily: Nunito
fontSize: 13px
fontWeight: 800
lineHeight: 1.2
letterSpacing: 0.2px
rounded:
xs: 8px
sm: 12px
md: 16px
lg: 20px
xl: 28px
xxl: 36px
pill: 999px
full: 50%
spacing:
xxs: 4px
xs: 8px
sm: 12px
md: 16px
lg: 20px
xl: 24px
xxl: 32px
xxxl: 40px
section: 64px
hero: 96px
components:
nav-bar:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
typography: "{typography.body}"
padding: "{spacing.md} {spacing.xl}"
borderBottom: "1px solid {colors.border}"
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.ink-inverse}"
typography: "{typography.button-lg}"
rounded: "{rounded.md}"
padding: "14px {spacing.xl}"
shadow: "0 5px 0 {colors.primary-dark}"
minHeight: "52px"
button-secondary:
backgroundColor: "{colors.secondary}"
textColor: "{colors.ink}"
typography: "{typography.button}"
rounded: "{rounded.md}"
padding: "12px {spacing.xl}"
shadow: "0 4px 0 {colors.secondary-dark}"
minHeight: "48px"
button-success:
backgroundColor: "{colors.success}"
textColor: "{colors.ink-inverse}"
typography: "{typography.button}"
rounded: "{rounded.md}"
padding: "12px {spacing.xl}"
shadow: "0 4px 0 {colors.success-dark}"
minHeight: "48px"
button-outline:
backgroundColor: "{colors.surface}"
textColor: "{colors.primary}"
borderColor: "{colors.primary}"
typography: "{typography.button}"
rounded: "{rounded.md}"
padding: "11px {spacing.xl}"
minHeight: "48px"
button-ghost:
backgroundColor: "transparent"
textColor: "{colors.ink-secondary}"
typography: "{typography.button}"
rounded: "{rounded.sm}"
padding: "{spacing.sm} {spacing.md}"
icon-button:
backgroundColor: "{colors.surface-soft}"
textColor: "{colors.ink}"
rounded: "{rounded.md}"
minSize: "44px"
hero:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.display-xl}"
padding: "{spacing.hero}"
decoration: "soft game-world shapes and floating 3D objects"
game-card:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
rounded: "{rounded.xl}"
padding: "{spacing.xl}"
borderColor: "{colors.border}"
shadow: "0 6px 0 rgba(37,42,82,0.08)"
game-card-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.ink-inverse}"
rounded: "{rounded.xl}"
padding: "{spacing.xl}"
shadow: "0 6px 0 {colors.primary-dark}"
lesson-card:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
rounded: "{rounded.lg}"
padding: "{spacing.xl}"
borderColor: "{colors.border}"
lesson-tile:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
rounded: "{rounded.lg}"
padding: "{spacing.lg}"
minHeight: "160px"
shadow: "0 5px 0 rgba(37,42,82,0.08)"
progress-card:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
rounded: "{rounded.lg}"
padding: "{spacing.lg}"
borderColor: "{colors.border}"
xp-card:
backgroundColor: "{colors.secondary-light}"
textColor: "{colors.ink}"
rounded: "{rounded.lg}"
padding: "{spacing.lg}"
streak-card:
backgroundColor: "{colors.accent-light}"
textColor: "{colors.ink}"
rounded: "{rounded.lg}"
padding: "{spacing.lg}"
level-card:
backgroundColor: "{colors.primary-light}"
textColor: "{colors.ink}"
rounded: "{rounded.xl}"
padding: "{spacing.xl}"
reward-card:
backgroundColor: "{colors.secondary}"
textColor: "{colors.ink}"
rounded: "{rounded.xl}"
padding: "{spacing.xl}"
shadow: "0 6px 0 {colors.secondary-dark}"
badge:
backgroundColor: "{colors.primary-light}"
textColor: "{colors.primary-dark}"
typography: "{typography.label}"
rounded: "{rounded.pill}"
padding: "6px 12px"
badge-success:
backgroundColor: "{colors.success-light}"
textColor: "{colors.success-dark}"
typography: "{typography.label}"
rounded: "{rounded.pill}"
padding: "6px 12px"
xp-badge:
backgroundColor: "{colors.secondary}"
textColor: "{colors.ink}"
typography: "{typography.label}"
rounded: "{rounded.pill}"
padding: "6px 12px"
level-badge:
backgroundColor: "{colors.primary}"
textColor: "{colors.ink-inverse}"
typography: "{typography.label}"
rounded: "{rounded.pill}"
padding: "6px 12px"
progress-bar:
trackColor: "{colors.surface-soft}"
fillColor: "{colors.primary}"
height: "12px"
rounded: "{rounded.pill}"
progress-bar-success:
trackColor: "{colors.surface-soft}"
fillColor: "{colors.success}"
height: "12px"
rounded: "{rounded.pill}"
answer-option:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
borderColor: "{colors.border}"
rounded: "{rounded.lg}"
padding: "{spacing.lg}"
minHeight: "64px"
shadow: "0 4px 0 rgba(37,42,82,0.06)"
answer-option-correct:
backgroundColor: "{colors.success-light}"
textColor: "{colors.success-dark}"
borderColor: "{colors.success}"
rounded: "{rounded.lg}"
padding: "{spacing.lg}"
answer-option-wrong:
backgroundColor: "{colors.accent-light}"
textColor: "{colors.accent}"
borderColor: "{colors.accent}"
rounded: "{rounded.lg}"
padding: "{spacing.lg}"
mascot-card:
backgroundColor: "{colors.sky-light}"
textColor: "{colors.ink}"
rounded: "{rounded.xxl}"
padding: "{spacing.xl}"
daily-challenge:
backgroundColor: "{colors.primary}"
textColor: "{colors.ink-inverse}"
rounded: "{rounded.xl}"
padding: "{spacing.xxl}"
shadow: "0 7px 0 {colors.primary-dark}"
empty-state:
backgroundColor: "{colors.surface-soft}"
textColor: "{colors.ink-secondary}"
rounded: "{rounded.xl}"
padding: "{spacing.xxxl}"
modal-card:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
rounded: "{rounded.xl}"
padding: "{spacing.xxl}"
shadow: "0 20px 60px rgba(32,35,58,0.18)"
toast:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
rounded: "{rounded.md}"
padding: "{spacing.sm} {spacing.md}"
shadow: "0 8px 24px rgba(32,35,58,0.15)"
footer:
backgroundColor: "{colors.surface-soft}"
textColor: "{colors.ink-secondary}"
typography: "{typography.body}"
padding: "{spacing.section}"
# ───────────────────────────────────────────────────────────
# GAME MECHANICS
# ───────────────────────────────────────────────────────────
xp-display:
icon: "⭐"
color: "{colors.secondary}"
typography: "{typography.heading-sm}"
streak-display:
icon: "🔥"
color: "{colors.accent}"
typography: "{typography.heading-sm}"
level-display:
icon: "🏆"
color: "{colors.primary}"
typography: "{typography.heading-sm}"
reward-popup:
backgroundColor: "{colors.surface}"
textColor: "{colors.ink}"
rounded: "{rounded.xl}"
padding: "{spacing.xxl}"
shadow: "0 12px 40px rgba(32,35,58,0.18)"
animation: "scale-in + bounce"
completion-state:
backgroundColor: "{colors.success-light}"
textColor: "{colors.success-dark}"
rounded: "{rounded.xl}"
padding: "{spacing.xxxl}"
animation: "celebration"
# ─────────────────────────────────────────────────────────────
# OVERVIEW
# ─────────────────────────────────────────────────────────────
## Overview
This design system transforms an English learning website into a lightweight game-like learning experience.
The product should feel like a friendly game that happens to teach English — not like a traditional LMS, school management system, or enterprise dashboard.
The visual language is built around:
- Playful 3D / tactile controls
- Large, obvious actions
- Soft rounded surfaces
- Bright but controlled colors
- Strong progress visualization
- XP, levels, streaks, badges, and rewards
- Friendly illustrations and optional mascot characters
- Minimal navigation
- Short learning interactions
- Clear success and failure feedback
The interface should be energetic enough to motivate children and students while remaining mature enough for teenagers and adults who are starting English from zero.
The core principle is:
> **Game-like, not childish.**
The interface should communicate:
> "Let's play and learn."
rather than:
> "Here is your educational dashboard."
# ─────────────────────────────────────────────────────────────
# DESIGN LANGUAGE
# ─────────────────────────────────────────────────────────────
## Design Direction
Primary style:
**Gamified Learning UI**
Visual style:
**Playful 3D / Tactile UI**
UX philosophy:
**Game-like learning**
Tone:
**Friendly · Encouraging · Simple · Rewarding**
The UI should make actions feel physical.
Buttons should appear slightly raised from the surface. When pressed, they should move downward and visually lose their elevation.
Cards should feel like game tiles.
Progress should feel like progression through a game world.
Rewards should feel immediate and satisfying.
## Core Principles
### 1. Action First
Every major screen should have one obvious primary action.
Examples:
- Continue
- Start Lesson
- Practice
- Check Answer
- Continue
- Claim Reward
Avoid screens where five actions compete for attention.
### 2. Game Before Dashboard
Do not expose complex LMS structures unless necessary.
Prefer:
Home → Activity → Result → Reward → Next Activity
over:
Dashboard → Course → Chapter → Lesson → Content → Quiz
### 3. Chunky Controls
Interactive elements should be larger than typical SaaS interfaces.
Recommended minimum:
- Primary CTA: 4852px
- Secondary CTA: 4448px
- Answer option: 6072px
- Icon button: 44px minimum
### 4. Tactile Feedback
Buttons should have visible depth.
Default:
`translateY(0)`
Pressed:
`translateY(4px)`
The shadow should reduce proportionally.
This creates a physical button effect without requiring heavy 3D rendering.
### 5. Reward Progress
Learning actions should produce visible progress.
Examples:
- +10 XP
- +1 streak
- Level progress
- Lesson completion
- Badge unlocked
- Daily challenge completed
Avoid rewarding every tiny interaction. Rewards should reinforce meaningful learning actions.
# ─────────────────────────────────────────────────────────────
# COLORS
# ─────────────────────────────────────────────────────────────
The palette moves away from Discord's dark gaming aesthetic.
The learning environment uses a bright neutral canvas with a small number of recognizable game colors.
### Primary
**Purple / Indigo**
`#5B5FEF`
Used for:
- Primary CTA
- Active navigation
- Main progress
- Level indicators
- Important interactive states
### Secondary
**Warm Yellow**
`#FFB84D`
Used for:
- XP
- Stars
- Rewards
- Achievements
- Celebration
### Success
**Green**
`#35C978`
Used for:
- Correct answers
- Completed lessons
- Positive progress
- Success actions
### Accent
**Coral**
`#FF6B6B`
Used for:
- Streak
- Attention
- Wrong answer
- Limited-time challenge indicators
### Sky
**Light Blue**
`#55B9F3`
Used for:
- Friendly illustrations
- Secondary learning areas
- Background decorations
### Canvas
`#F7F8FC`
The primary application background.
It should remain visually quiet so game elements stand out.
### Surface
`#FFFFFF`
Used for cards, lesson tiles, dialogs, and interactive surfaces.
# ─────────────────────────────────────────────────────────────
# TYPOGRAPHY
# ─────────────────────────────────────────────────────────────
Typography should feel friendly rather than corporate.
Recommended primary family:
**Nunito**
Alternative:
**Plus Jakarta Sans**
Display text should be bold and rounded.
Body text should remain highly readable.
Avoid:
- Condensed display fonts
- Aggressive gaming fonts
- Excessive ALL CAPS
- Decorative fonts for learning content
Unlike Discord, headlines do not need to shout.
A friendly:
> Learn 5 new words today
is preferable to:
> MASTER YOUR ENGLISH NOW!
# ─────────────────────────────────────────────────────────────
# LAYOUT
# ─────────────────────────────────────────────────────────────
## Grid
Use a centered application container.
Recommended maximum width:
`1200px`
Preferred content width:
`9601200px`
The layout should feel spacious.
Avoid dense dashboard layouts.
## Home Layout
Recommended hierarchy:
```text
Top Navigation
Greeting
Current Level / XP
Continue Learning
Main Lesson Card
Daily Challenge
Learning Activities
Vocabulary
Listening
Speaking
Reading
Achievements / Streak
+222
View File
@@ -0,0 +1,222 @@
# APLP Frontend (React) — Phases
| | |
| --- | --- |
| **Module** | `aplp.frontend.react` |
| **Role** | Web application cho learner (`aplp-web`) |
| **Status** | Draft |
| **Backend** | `aplp.backend.spring` (Java/Spring Boot) |
Frontend triển khai theo các phase của [APLP-Project-Phases](../../aplp.backend.spring/APLP-Project-Phases.md). Document này ánh xạ từng phase sang công việc frontend.
> Nguyên tắc: frontend là client mỏng (thin client), gọi API của backend. Frontend **không** chứa business rules của domain; chỉ chứa UX/UI logic.
---
## 1. Phase 0 — Foundation (Frontend)
### Objective
Dựng khung ứng dụng, auth flow, routing, chuẩn hóa giao tiếp API với backend.
> ✅ Phase 0 hoàn tất. Stack đã confirm (Vite, React 19, TS, React Router, axios, React Query, Zustand, Tailwind). Auth flow + api client + ProtectedRoute đang hoạt động.
### Features
| Feature | Priority |
| --- | --- |
| Khởi tạo React app (Vite \[candidate\] + TypeScript) | Must |
| Routing (react-router) | Must |
| Auth flow: login/logout/refresh + token storage | Must |
| API client layer (axios/fetch wrapper, error handling) | Must |
| Layout/App shell & ProtectedRoute | Must |
| Theme & UI base (candidate: Tailwind / component lib) | Should |
| State management base (candidate: Zustand/React Query) | Should |
| Environment config (`VITE_*`) | Must |
### Completion Criteria
- Đăng nhập/đăng xuất chạy được, token quản lý đúng, route bảo vệ hoạt động.
- API layer chuẩn, gọi được backend local.
---
## 2. Phase 1 — Identity & Learner
### Objective
Hiển thị và cho học viên quản lý hồ sơ, preferences, learning goals.
### Features
| Feature | Priority |
| --- | --- |
| Trang hồ sơ learner (view/edit) | Must |
| Form learning preferences | Should |
| Quản lý learning goals (CRUD) | Should |
---
## 3. Phase 2 — Content & Course
### Objective
Browsing + chi tiết course từ content model (backend/LMS).
### Features
| Feature | Priority |
| --- | --- |
| Course catalog & search cơ bản | Must |
| Course detail page (structure module/lesson) | Must |
| Enroll/select course | Should |
---
## 4. Phase 3 — Learning Experience
### Objective
Trải nghiệm học từ đầu tới cuối trong UI.
### Features
| Feature | Priority |
| --- | --- |
| Player/lesson view cho learning activity | Must |
| Start / continue / resume đúng lesson | Must |
| Navigation giữa lessons (next/prev, course map) | Must |
| Đánh dấu hoàn thành & feedback ngay | Must |
| Progress thanh / module state | Should |
---
## 5. Phase 4 — Assessment & Progress
### Objective
Learner làm assessment, xem kết quả và tiến độ trong UI.
### Features
| Feature | Priority |
| --- | --- |
| Màn hình làm assessment (câu hỏi, lựa chọn) | Must |
| Submit & hiển thị kết quả/score | Must |
| Hiển thị learning progress & history | Must |
| Retake flow (nếu business cho phép — OQ-005) | Should |
---
## 6. Phase 5 — Adaptive Learning
### Objective
Hiển thị learner state, mastery và hoạt động được đề xuất tiếp theo từ backend.
### Features
| Feature | Priority |
| --- | --- |
| Hiển thị mastery / knowledge state | Must |
| Hiển thị "next activity" đề xuất | Must |
| Prerequisite lock/unlock state trong UI | Should |
> Frontend chỉ **hiển thị** giao diện theo quyết định của backend; **không** tính toán adaptive bên client.
---
## 7. Phase 6 — Personalization & Recommendation
### Objective
Hiển thị các đề xuất/path được cá nhân hóa.
### Features
| Feature | Priority |
| --- | --- |
| Block "Recommended for you" | Should |
| Personalized path view | Could |
| Cho phép learner xác nhận/bỏ qua đề xuất | Should |
---
## 8. Phase 7 — Learning Assistance
### Objective
UI cho AI assistance: hint, explanation, Q&A, feedback (backend-driven).
### Features
| Feature | Priority |
| --- | --- |
| Hint/explanation panel trong lesson | Should |
| Chat/Q&A box (RAG backend) | Could |
| Feedback hiển thị sau assessment | Should |
---
## 9. Phase 8 — Productization
### Objective
UI cho subscription/plan, notification, account.
### Features
| Feature | Priority |
| --- | --- |
| Trang plan/subscription | Should |
| Notification UI | Should |
| Account management | Should |
---
## 10. Phase 9 — Scale & Evolution
### Objective
Performance, caching client, observability.
### Features
| Feature | Priority |
| --- | --- |
| Data fetching caching (React Query) | Should |
| Code splitting / lazy loading | Should |
| Error boundary & monitoring (sentry-like) | Should |
---
## 11. MVP (Frontend)
> **Một learner có thể dùng UI này học một course hoàn chỉnh.**
MVP frontend bao gồm:
```
Auth + App shell
+ Course catalog/detail
+ Lesson player
+ Progress display
+ Assessment UI
+ (adaptive simple — optional)
```
Ngoài MVP: AI assistance, recommendation nâng cao, subscription UI.
## 12. Open Questions (Frontend-specific)
> Phase 0 đã confirm: Vite, TypeScript, React Router, axios, TanStack Query + Zustand, Tailwind CSS, Vitest + Testing Library, no SSR, no PWA.
| ID | Question | Impact | Status |
| --- | --- | --- | --- |
| FE-001 | Vite hay CRA / framework khác? | Setup | **Resolved: Vite** |
| FE-002 | TypeScript có bắt buộc? | Quality | **Resolved: có** |
| FE-003 | Component library nào (Tailwind/MUI/etc)? | UX consistency | **Resolved: Tailwind CSS** |
| FE-004 | State management: React Query + Zustand/Redux? | Data flow | **Resolved: React Query + Zustand** |
| FE-005 | SSR/SSG có cần (SEO)? | Architecture | **Resolved: không cho MVP** |
| FE-006 | I18n cho UI? | UX | Open |
| FE-007 | Có cần PWA/offline? | UX | **Resolved: không cho MVP** |
+78
View File
@@ -0,0 +1,78 @@
# APLP Frontend — Setup
| | |
| --- | --- |
| **Status** | Phase 0 hoàn tất — repo đã khởi tạo Vite + React + TS |
## 1. Prerequisites
- Node.js 18+ (chọn LTS, xác nhận khi khởi tạo)
- npm/pnpm (chọn 1 — candidate npm)
- Backend chạy local (xem `docs/SETUP.md` của `aplp.backend.spring`)
## 2. Cấu trúc dự án dự kiến
```text
aplp.frontend.react/
├── README.md
├── docs/ # frontend docs
│ ├── PHASES.md # công việc frontend theo phase APLP
│ ├── ARCHITECTURE.md
│ ├── CONVENTIONS.md
│ └── SETUP.md
├── index.html
├── vite.config.ts
├── package.json
├── tsconfig.json
├── .env.example
├── public/
└── src/
├── app/
├── api/
├── features/...
├── shared/
└── types/
```
## 3. Khởi tạo (Phase 0)
> Repo hiện tại là template trống từ GitLab. Các bước dưới sẽ thực hiện khi bắt đầu Phase 0.
1. Khởi tạo React + TypeScript app (candidate: Vite): `npm create vite@latest . -- --template react-ts`
2. Thêm `react-router-dom`, axios, TanStack Query, Zustand, tooling lint/test.
3. Tạo config env mẫu `.env.example`: `VITE_API_BASE_URL=http://localhost:8080/api`
4. Dựng api client + auth flow + ProtectedRoute (an toàn tương ứng Phase 0).
5. Tạo folder structure theo `docs/ARCHITECTURE.md`.
6. Viết README chính thức thay template GitLab.
7. Kiểm tra: `npm run lint`, `npm run typecheck`, `npm test`, `npm run dev` build ok.
> ✅ Đã thực hiện xong. Xem `README.md` cho cấu trúc và scripts hiện tại.
## 4. Local Dev Flow
- Backend chạy local (port 8080 ví dụ).
- Chạy frontend: `npm run dev` → mở địa chỉ Vite.
- Env qua file `.env` (không commit; chỉ commit `.env.example`).
## 5. Environment Example
```env
VITE_API_BASE_URL=http://localhost:8080/api
VITE_APP_NAME=APLP
```
## 6. Verification Checklist (khi có code)
- [ ] `npm run lint` pass
- [ ] `npm run typecheck` pass
- [ ] `npm test` pass
- [ ] `npm run build` pass
- [ ] Login → protected route đúng, refresh token hoạt động
## 7. Open Questions
| ID | Question | Status |
| --- | --- | --- |
| SET-001 | npm hay pnpm? | Open (candidate npm) |
| SET-002 | Node version quản lý (nvm)? | Open |
| SET-003 | Deploy target (nginx/container/Vercel)? | Open |
+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

+1 -1
View File
@@ -34,7 +34,7 @@ describe('api client', () => {
}
it('attaches Bearer token from storage on request', async () => {
tokenStorage.set('tok-1');
tokenStorage.set('tok-1', null);
const handlers = client.interceptors.request.handlers;
const config = { headers: new axios.AxiosHeaders() };
+10 -6
View File
@@ -1,18 +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 refresh token is handled by the
* backend (HttpOnly cookie), so the client never touches it directly.
* Access token: in-memory only (XSS mitigation).
* Refresh token: localStorage so it survives page reloads.
*/
const REFRESH_KEY = 'rt';
let accessToken: string | null = null;
export const tokenStorage = {
get: (): string | null => accessToken,
set: (token: string): void => {
accessToken = token;
getRefreshToken: (): string | null => localStorage.getItem(REFRESH_KEY),
set: (access: string, refresh: string | null): void => {
accessToken = access;
if (refresh) localStorage.setItem(REFRESH_KEY, refresh);
},
clear: (): void => {
accessToken = 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>
);
}
+36 -12
View File
@@ -1,36 +1,60 @@
import client from '@/api/client';
import type { ApiListResponse, ApiResponse } from '@/types/api';
import type { ApiListResponse } from '@/types/api';
import type { AuthUser, LoginCredentials, LoginResponse, RefreshResponse } from '../types';
/**
* Endpoint constants live here (not scattered in components).
* Contract per backend `aplp.backend.spring` (candidate, confirm at Phase 0).
* Contract per backend `aplp.backend.spring`.
*/
const ENDPOINTS = {
login: '/auth/login',
logout: '/auth/logout',
refresh: '/auth/refresh',
me: '/auth/me',
me: '/learners/me',
} as const;
/** AuthResponse as returned by the Spring backend (no `data` wrapper). */
interface AuthResponseDto {
accessToken: string;
refreshToken: string;
tokenType: string;
expiresIn: number;
user: { id: number; email: string; displayName: string };
}
/** LearnerProfileResponse as returned by `GET /learners/me`. */
interface LearnerProfileDto {
id: number;
userId: number;
displayName: string;
}
function toAuthUser(user: AuthResponseDto['user']): AuthUser {
return { id: String(user.id), email: user.email, name: user.displayName };
}
function toAuthResult(data: AuthResponseDto): LoginResponse {
return { accessToken: data.accessToken, refreshToken: data.refreshToken, user: toAuthUser(data.user) };
}
export const authApi = {
async login(credentials: LoginCredentials): Promise<LoginResponse> {
const { data } = await client.post<ApiResponse<LoginResponse>>(ENDPOINTS.login, credentials);
return data.data;
const { data } = await client.post<AuthResponseDto>(ENDPOINTS.login, credentials);
return toAuthResult(data);
},
async logout(): Promise<void> {
await client.post(ENDPOINTS.logout);
async logout(refreshToken: string): Promise<void> {
await client.post(ENDPOINTS.logout, { refreshToken });
},
async refresh(): Promise<RefreshResponse> {
const { data } = await client.post<ApiResponse<RefreshResponse>>(ENDPOINTS.refresh);
return data.data;
async refresh(refreshToken: string): Promise<RefreshResponse> {
const { data } = await client.post<AuthResponseDto>(ENDPOINTS.refresh, { refreshToken });
return toAuthResult(data);
},
async getMe(): Promise<AuthUser> {
const { data } = await client.get<ApiResponse<AuthUser>>(ENDPOINTS.me);
return data.data;
const { data } = await client.get<LearnerProfileDto>(ENDPOINTS.me);
return { id: String(data.id), email: '', name: data.displayName };
},
async listUsers(): Promise<ApiListResponse<AuthUser>> {
+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
@@ -16,7 +16,7 @@ export function useAuthUser() {
});
return {
user: query.data ?? user,
user: query.data ? { ...query.data, email: user?.email ?? query.data.email } : user,
isLoading: query.isLoading,
isError: query.isError,
};
+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),
});
}
+5 -4
View File
@@ -42,6 +42,7 @@ describe('LoginPage', () => {
vi.clearAllMocks();
mockedAuthApi.login.mockResolvedValue({
accessToken: 'access-123',
refreshToken: 'refresh-123',
user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' },
});
});
@@ -50,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();
});
@@ -59,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',
});
@@ -76,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>
+19 -4
View File
@@ -23,19 +23,21 @@ describe('authStore', () => {
vi.clearAllMocks();
mockedAuthApi.login.mockResolvedValue({
accessToken: 'access-123',
refreshToken: 'refresh-123',
user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' },
});
mockedAuthApi.refresh.mockResolvedValue({
accessToken: 'access-refreshed',
refreshToken: 'refresh-refreshed',
user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' },
});
});
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');
@@ -46,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();
@@ -54,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();
@@ -64,15 +66,28 @@ describe('authStore', () => {
});
it('init restores session from refresh token', async () => {
tokenStorage.set('old-access', 'refresh-123');
await useAuthStore.getState().init();
expect(mockedAuthApi.refresh).toHaveBeenCalledOnce();
expect(mockedAuthApi.refresh).toHaveBeenCalledWith('refresh-123');
expect(tokenStorage.get()).toBe('access-refreshed');
expect(useAuthStore.getState().user?.id).toBe('u1');
expect(useAuthStore.getState().isInitializing).toBe(false);
});
it('init skips refresh when no refresh token is stored', async () => {
await useAuthStore.getState().init();
expect(mockedAuthApi.refresh).not.toHaveBeenCalled();
expect(tokenStorage.get()).toBeNull();
expect(useAuthStore.getState().user).toBeNull();
expect(useAuthStore.getState().isInitializing).toBe(false);
});
it('init handles expired refresh token gracefully', async () => {
tokenStorage.set('old-access', 'refresh-expired');
mockedAuthApi.refresh.mockRejectedValue(new Error('401'));
await useAuthStore.getState().init();
+17 -9
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,15 +20,15 @@ export const useAuthStore = create<AuthState>()((set) => ({
user: null,
isInitializing: true,
login: async (email, password) => {
const { accessToken, user } = await authApi.login({ email, password });
tokenStorage.set(accessToken);
login: async (username, password) => {
const { accessToken, refreshToken, user } = await authApi.login({ username, password });
tokenStorage.set(accessToken, refreshToken);
set({ user, isInitializing: false });
},
logout: async () => {
try {
await authApi.logout();
await authApi.logout(tokenStorage.getRefreshToken() ?? '');
} catch {
// Best-effort: always clear the local session, even if the API call fails.
} finally {
@@ -38,9 +38,15 @@ export const useAuthStore = create<AuthState>()((set) => ({
},
init: async () => {
const refreshToken = tokenStorage.getRefreshToken();
if (!refreshToken) {
tokenStorage.clear();
set({ user: null, isInitializing: false });
return;
}
try {
const { accessToken, user } = await authApi.refresh();
tokenStorage.set(accessToken);
const { accessToken, refreshToken: nextRefreshToken, user } = await authApi.refresh(refreshToken);
tokenStorage.set(accessToken, nextRefreshToken);
set({ user, isInitializing: false });
} catch {
tokenStorage.clear();
@@ -55,9 +61,11 @@ export const useAuthStore = create<AuthState>()((set) => ({
*/
registerAuthHandlers(
async () => {
const refreshToken = tokenStorage.getRefreshToken();
if (!refreshToken) return null;
try {
const { accessToken } = await authApi.refresh();
tokenStorage.set(accessToken);
const { accessToken, refreshToken: nextRefreshToken } = await authApi.refresh(refreshToken);
tokenStorage.set(accessToken, nextRefreshToken);
return accessToken;
} catch {
return null;
+3 -1
View File
@@ -5,16 +5,18 @@ export interface AuthUser {
}
export interface LoginCredentials {
email: string;
username: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
user: AuthUser;
}
export interface RefreshResponse {
accessToken: string;
refreshToken: string;
user: AuthUser;
}
+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"