Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
271fdb2521 | ||
|
|
dd9ed73a02 | ||
|
|
b44092153d |
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
# Copy to `.env` for local development (never commit `.env`).
|
# 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
|
VITE_APP_NAME=APLP
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 |
|
||||||
@@ -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
@@ -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: 48–52px
|
||||||
|
- Secondary CTA: 44–48px
|
||||||
|
- Answer option: 60–72px
|
||||||
|
- 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:
|
||||||
|
|
||||||
|
`960–1200px`
|
||||||
|
|
||||||
|
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
@@ -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** |
|
||||||
@@ -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 |
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>APLP — Adaptive Personal Learning Platform</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -34,7 +34,7 @@ describe('api client', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('attaches Bearer token from storage on request', async () => {
|
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 handlers = client.interceptors.request.handlers;
|
||||||
const config = { headers: new axios.AxiosHeaders() };
|
const config = { headers: new axios.AxiosHeaders() };
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -1,18 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* In-memory token storage.
|
* Token storage.
|
||||||
*
|
*
|
||||||
* Access token is kept in memory only (never persisted to localStorage /
|
* Access token: in-memory only (XSS mitigation).
|
||||||
* sessionStorage) to reduce XSS exposure. The refresh token is handled by the
|
* Refresh token: localStorage so it survives page reloads.
|
||||||
* backend (HttpOnly cookie), so the client never touches it directly.
|
|
||||||
*/
|
*/
|
||||||
|
const REFRESH_KEY = 'rt';
|
||||||
|
|
||||||
let accessToken: string | null = null;
|
let accessToken: string | null = null;
|
||||||
|
|
||||||
export const tokenStorage = {
|
export const tokenStorage = {
|
||||||
get: (): string | null => accessToken,
|
get: (): string | null => accessToken,
|
||||||
set: (token: string): void => {
|
getRefreshToken: (): string | null => localStorage.getItem(REFRESH_KEY),
|
||||||
accessToken = token;
|
set: (access: string, refresh: string | null): void => {
|
||||||
|
accessToken = access;
|
||||||
|
if (refresh) localStorage.setItem(REFRESH_KEY, refresh);
|
||||||
},
|
},
|
||||||
clear: (): void => {
|
clear: (): void => {
|
||||||
accessToken = null;
|
accessToken = null;
|
||||||
|
localStorage.removeItem(REFRESH_KEY);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+5
-1
@@ -4,6 +4,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
|
|||||||
import { AppLayout } from '@/app/layouts/AppLayout';
|
import { AppLayout } from '@/app/layouts/AppLayout';
|
||||||
import { HomePage } from '@/app/pages/HomePage';
|
import { HomePage } from '@/app/pages/HomePage';
|
||||||
import { NotFoundPage } from '@/app/pages/NotFoundPage';
|
import { NotFoundPage } from '@/app/pages/NotFoundPage';
|
||||||
|
import { RankingPage } from '@/app/pages/RankingPage';
|
||||||
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute';
|
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute';
|
||||||
import { LoginPage } from '@/features/auth/pages/LoginPage';
|
import { LoginPage } from '@/features/auth/pages/LoginPage';
|
||||||
import { useAuthStore } from '@/features/auth/store/authStore';
|
import { useAuthStore } from '@/features/auth/store/authStore';
|
||||||
@@ -19,7 +20,10 @@ export default function App() {
|
|||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
<Route index element={<HomePage />} />
|
<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>
|
</Route>
|
||||||
<Route path="/404" element={<NotFoundPage />} />
|
<Route path="/404" element={<NotFoundPage />} />
|
||||||
|
|||||||
@@ -1,44 +1,43 @@
|
|||||||
import { Link, Outlet } from 'react-router-dom';
|
import { Link, NavLink, Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
import env from '@/shared/config/env';
|
import env from '@/shared/config/env';
|
||||||
import { useLogout } from '@/features/auth/hooks/useLogout';
|
import { UserMenu } from '@/features/auth/components/UserMenu';
|
||||||
import { useAuthStore } from '@/features/auth/store/authStore';
|
|
||||||
|
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() {
|
export function AppLayout() {
|
||||||
const user = useAuthStore((s) => s.user);
|
|
||||||
const logout = useLogout();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen">
|
||||||
<header className="border-b border-gray-200 bg-white">
|
<header className="border-b border-border bg-surface">
|
||||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
|
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4">
|
||||||
<div className="flex items-center gap-6">
|
<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}
|
{env.VITE_APP_NAME}
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-4 text-sm text-gray-600">
|
<nav className="flex items-center gap-1 text-sm font-bold">
|
||||||
<Link to="/" className="hover:text-gray-900">
|
<NavLink to="/" end className={navLinkClass}>
|
||||||
Home
|
Home
|
||||||
</Link>
|
</NavLink>
|
||||||
<Link to="/learn" className="hover:text-gray-900">
|
<NavLink to="/vocabulary" className={navLinkClass}>
|
||||||
Learn
|
Vocabulary
|
||||||
</Link>
|
</NavLink>
|
||||||
|
<NavLink to="/assignment" className={navLinkClass}>
|
||||||
|
Assignment
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/ranking" className={navLinkClass}>
|
||||||
|
Ranking
|
||||||
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2">
|
||||||
{user && <span className="text-sm text-gray-500">{user.email}</span>}
|
<UserMenu />
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto max-w-5xl px-4 py-8">
|
<main className="mx-auto max-w-6xl px-4 py-8">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+103
-15
@@ -1,26 +1,114 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import env from '@/shared/config/env';
|
import env from '@/shared/config/env';
|
||||||
|
import { Button } from '@/shared/components/Button';
|
||||||
import { useAuthUser } from '@/features/auth/hooks/useAuthUser';
|
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() {
|
export function HomePage() {
|
||||||
const { user, isLoading, isError } = useAuthUser();
|
const { user, isLoading, isError } = useAuthUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-8">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">
|
<div>
|
||||||
Welcome{user ? `, ${user.name}` : ''} 👋
|
<h1 className="text-3xl font-black text-ink">
|
||||||
</h1>
|
Welcome{user ? `, ${user.name}` : ''} 👋
|
||||||
<p className="mt-2 text-gray-600">{env.VITE_APP_NAME} — your adaptive learning workspace.</p>
|
</h1>
|
||||||
<div className="mt-6 rounded-lg border border-gray-200 bg-white p-6 text-sm text-gray-600">
|
<p className="mt-1 font-medium text-ink-secondary">
|
||||||
{isLoading && <p>Loading profile…</p>}
|
{env.VITE_APP_NAME} — your adaptive learning workspace.
|
||||||
{isError && <p>Could not load profile details.</p>}
|
</p>
|
||||||
{user && !isLoading && (
|
|
||||||
<ul className="space-y-1">
|
|
||||||
<li>ID: {user.id}</li>
|
|
||||||
<li>Email: {user.email}</li>
|
|
||||||
<li>Name: {user.name}</li>
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LessonGrid />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import { Link } from 'react-router-dom';
|
|||||||
|
|
||||||
export function NotFoundPage() {
|
export function NotFoundPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-50 px-4 text-center">
|
<div className="flex min-h-screen flex-col items-center justify-center bg-sky-light px-4 text-center">
|
||||||
<h1 className="text-4xl font-semibold text-gray-900">404</h1>
|
<div className="rounded-3xl bg-surface p-10">
|
||||||
<p className="mt-2 text-gray-600">This page does not exist.</p>
|
<p className="text-5xl">🧭</p>
|
||||||
<Link to="/" className="mt-4 text-sm font-medium text-indigo-600 hover:text-indigo-700">
|
<h1 className="mt-3 text-4xl font-black text-ink">404</h1>
|
||||||
Back home
|
<p className="mt-2 font-medium text-ink-secondary">This page does not exist.</p>
|
||||||
</Link>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/shared/components/Button';
|
||||||
|
import { Modal } from '@/shared/components/Modal';
|
||||||
|
|
||||||
|
// ponytail: hardcoded until leaderboard API exists; swap for real ranking data then.
|
||||||
|
const MOCK_LEADERBOARD = [
|
||||||
|
{ rank: 1, name: 'Minh Anh', level: 12, star: 45, streak: 30, createdAt: '2024-02-14' },
|
||||||
|
{ rank: 2, name: 'Quốc Bảo', level: 11, star: 41, streak: 18, createdAt: '2024-03-02' },
|
||||||
|
{ rank: 3, name: 'Thu Hà', level: 10, star: 38, streak: 22, createdAt: '2024-01-20' },
|
||||||
|
{ rank: 4, name: 'Đức Long', level: 9, star: 33, streak: 7, createdAt: '2024-05-11' },
|
||||||
|
{ rank: 5, name: 'Ngọc Linh', level: 8, star: 29, streak: 12, createdAt: '2024-04-08' },
|
||||||
|
{ rank: 6, name: 'Bạn', level: 7, star: 25, streak: 5, createdAt: '2024-06-01' },
|
||||||
|
];
|
||||||
|
const RANK_MEDAL: Record<number, string> = { 1: '🥇', 2: '🥈', 3: '🥉' };
|
||||||
|
const rankBadgeClass = (rank: number) =>
|
||||||
|
rank === 1
|
||||||
|
? 'bg-secondary text-ink'
|
||||||
|
: rank === 2
|
||||||
|
? 'bg-surface-soft text-ink'
|
||||||
|
: rank === 3
|
||||||
|
? 'bg-accent-light text-accent'
|
||||||
|
: 'bg-surface-soft text-ink-secondary';
|
||||||
|
|
||||||
|
// ponytail: same identicon trick as UserMenu — seeded by name since ranking has no email.
|
||||||
|
function gravatarUrl(seed: string, size: number) {
|
||||||
|
return `https://www.gravatar.com/avatar/${encodeURIComponent(seed.trim().toLowerCase())}?d=identicon&s=${size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RankingPage() {
|
||||||
|
const [selected, setSelected] = useState<(typeof MOCK_LEADERBOARD)[number] | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<h1 className="text-3xl font-black text-ink">🏆 Ranking</h1>
|
||||||
|
<div className="mt-6 rounded-xl border border-border bg-surface p-5 shadow-[0_5px_0_rgba(37,42,82,0.08)]">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="grid grid-cols-[2rem_1fr_3.25rem_4.25rem_3.25rem_2rem] gap-2 px-2 pb-2 text-xs font-bold uppercase tracking-wide text-ink-muted">
|
||||||
|
<span>Rank</span>
|
||||||
|
<span>Name</span>
|
||||||
|
<span className="whitespace-nowrap text-center">Level</span>
|
||||||
|
<span className="whitespace-nowrap text-center">Streak</span>
|
||||||
|
<span className="whitespace-nowrap text-center">Star</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{MOCK_LEADERBOARD.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.rank}
|
||||||
|
className={`grid grid-cols-[2rem_1fr_3.25rem_4.25rem_3.25rem_2rem] items-center gap-2 rounded-lg px-2 py-2 ${entry.name === 'Bạn' ? 'bg-primary-light' : ''}`}
|
||||||
|
>
|
||||||
|
<span className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-black ${rankBadgeClass(entry.rank)}`}>
|
||||||
|
{RANK_MEDAL[entry.rank] ?? entry.rank}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-sm font-bold text-ink">{entry.name}</span>
|
||||||
|
<span className="text-center text-sm font-bold text-ink-secondary">Lv.{entry.level}</span>
|
||||||
|
<span className="text-center text-sm font-bold text-ink-secondary">🔥 {entry.streak}</span>
|
||||||
|
<span className="text-center text-sm font-extrabold text-primary">⭐ {entry.star}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(entry)}
|
||||||
|
aria-label={`Xem hồ sơ ${entry.name}`}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-full text-ink-muted hover:bg-surface-soft hover:text-ink"
|
||||||
|
>
|
||||||
|
👤
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal open={selected !== null} onClose={() => setSelected(null)} title="User profile">
|
||||||
|
{selected && (
|
||||||
|
<div className="relative flex flex-col items-center gap-4 px-6 py-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
aria-label="Close"
|
||||||
|
className="absolute top-3 right-3 rounded-full p-1.5 text-ink-muted hover:bg-surface-soft hover:text-ink"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={gravatarUrl(selected.name, 160)}
|
||||||
|
alt=""
|
||||||
|
className="h-20 w-20 rounded-full border-4 border-primary-light"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<dl className="w-full space-y-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-border pb-2">
|
||||||
|
<dt className="text-ink-secondary">Display name</dt>
|
||||||
|
<dd className="truncate font-semibold text-ink">{selected.name}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-border pb-2">
|
||||||
|
<dt className="text-ink-secondary">Created at</dt>
|
||||||
|
<dd className="truncate font-semibold text-ink">{selected.createdAt}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" onClick={() => setSelected(null)} className="min-h-11 w-full text-sm">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,36 +1,60 @@
|
|||||||
import client from '@/api/client';
|
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';
|
import type { AuthUser, LoginCredentials, LoginResponse, RefreshResponse } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Endpoint constants live here (not scattered in components).
|
* 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 = {
|
const ENDPOINTS = {
|
||||||
login: '/auth/login',
|
login: '/auth/login',
|
||||||
logout: '/auth/logout',
|
logout: '/auth/logout',
|
||||||
refresh: '/auth/refresh',
|
refresh: '/auth/refresh',
|
||||||
me: '/auth/me',
|
me: '/learners/me',
|
||||||
} as const;
|
} 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 = {
|
export const authApi = {
|
||||||
async login(credentials: LoginCredentials): Promise<LoginResponse> {
|
async login(credentials: LoginCredentials): Promise<LoginResponse> {
|
||||||
const { data } = await client.post<ApiResponse<LoginResponse>>(ENDPOINTS.login, credentials);
|
const { data } = await client.post<AuthResponseDto>(ENDPOINTS.login, credentials);
|
||||||
return data.data;
|
return toAuthResult(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async logout(): Promise<void> {
|
async logout(refreshToken: string): Promise<void> {
|
||||||
await client.post(ENDPOINTS.logout);
|
await client.post(ENDPOINTS.logout, { refreshToken });
|
||||||
},
|
},
|
||||||
|
|
||||||
async refresh(): Promise<RefreshResponse> {
|
async refresh(refreshToken: string): Promise<RefreshResponse> {
|
||||||
const { data } = await client.post<ApiResponse<RefreshResponse>>(ENDPOINTS.refresh);
|
const { data } = await client.post<AuthResponseDto>(ENDPOINTS.refresh, { refreshToken });
|
||||||
return data.data;
|
return toAuthResult(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getMe(): Promise<AuthUser> {
|
async getMe(): Promise<AuthUser> {
|
||||||
const { data } = await client.get<ApiResponse<AuthUser>>(ENDPOINTS.me);
|
const { data } = await client.get<LearnerProfileDto>(ENDPOINTS.me);
|
||||||
return data.data;
|
return { id: String(data.id), email: '', name: data.displayName };
|
||||||
},
|
},
|
||||||
|
|
||||||
async listUsers(): Promise<ApiListResponse<AuthUser>> {
|
async listUsers(): Promise<ApiListResponse<AuthUser>> {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export function useAuthUser() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: query.data ?? user,
|
user: query.data ? { ...query.data, email: user?.email ?? query.data.email } : user,
|
||||||
isLoading: query.isLoading,
|
isLoading: query.isLoading,
|
||||||
isError: query.isError,
|
isError: query.isError,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ export function useLogin() {
|
|||||||
const login = useAuthStore((s) => s.login);
|
const login = useAuthStore((s) => s.login);
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (credentials: LoginCredentials) => login(credentials.email, credentials.password),
|
mutationFn: (credentials: LoginCredentials) => login(credentials.username, credentials.password),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ describe('LoginPage', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockedAuthApi.login.mockResolvedValue({
|
mockedAuthApi.login.mockResolvedValue({
|
||||||
accessToken: 'access-123',
|
accessToken: 'access-123',
|
||||||
|
refreshToken: 'refresh-123',
|
||||||
user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' },
|
user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -50,7 +51,7 @@ describe('LoginPage', () => {
|
|||||||
renderLogin();
|
renderLogin();
|
||||||
|
|
||||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Email')).toBeInTheDocument();
|
expect(screen.getByLabelText('Username')).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -59,12 +60,12 @@ describe('LoginPage', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderLogin();
|
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.type(screen.getByLabelText('Password'), 'secret');
|
||||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||||
|
|
||||||
expect(mockedAuthApi.login).toHaveBeenCalledWith({
|
expect(mockedAuthApi.login).toHaveBeenCalledWith({
|
||||||
email: 'learner@aplp.io',
|
username: 'learner',
|
||||||
password: 'secret',
|
password: 'secret',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ describe('LoginPage', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderLogin();
|
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.type(screen.getByLabelText('Password'), 'wrong');
|
||||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type SubmitEvent } from 'react';
|
||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import env from '@/shared/config/env';
|
import env from '@/shared/config/env';
|
||||||
|
import { Button } from '@/shared/components/Button';
|
||||||
import { useLogin } from '../hooks/useLogin';
|
import { useLogin } from '../hooks/useLogin';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ export function LoginPage() {
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const { state } = useLocation();
|
const { state } = useLocation();
|
||||||
const login = useLogin();
|
const login = useLogin();
|
||||||
const [email, setEmail] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
const from = (state as { from?: string } | null)?.from ?? '/';
|
const from = (state as { from?: string } | null)?.from ?? '/';
|
||||||
@@ -18,61 +19,89 @@ export function LoginPage() {
|
|||||||
return <Navigate to={from} replace />;
|
return <Navigate to={from} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
login.mutate({ email, password });
|
login.mutate({ username, password });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
<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-lg border border-gray-200 bg-white p-8 shadow-sm">
|
<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-semibold text-gray-900">{env.VITE_APP_NAME}</h1>
|
<h1 className="mb-1 text-2xl font-extrabold text-ink text-center">{env.VITE_APP_NAME}</h1>
|
||||||
<p className="mb-6 text-sm text-gray-500">Sign in to your learning account</p>
|
<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">
|
<form onSubmit={handleSubmit} className="space-y-4" data-testid="login-form">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="mb-1 block text-sm font-medium text-gray-700">
|
<label htmlFor="username" className="mb-1 block text-sm font-bold text-ink">
|
||||||
Email
|
Username
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="relative">
|
||||||
id="email"
|
<svg
|
||||||
type="email"
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-secondary"
|
||||||
autoComplete="email"
|
width="18"
|
||||||
required
|
height="18"
|
||||||
value={email}
|
viewBox="0 0 24 24"
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
fill="none"
|
||||||
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"
|
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="username"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
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>
|
||||||
|
|
||||||
<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
|
Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="relative">
|
||||||
id="password"
|
<svg
|
||||||
type="password"
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-secondary"
|
||||||
autoComplete="current-password"
|
width="18"
|
||||||
required
|
height="18"
|
||||||
value={password}
|
viewBox="0 0 24 24"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
fill="none"
|
||||||
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"
|
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"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(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>
|
||||||
|
|
||||||
{login.isError && (
|
{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'}
|
{login.error instanceof Error ? login.error.message : 'Login failed'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<Button type="submit" disabled={login.isPending} className="w-full">
|
||||||
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"
|
|
||||||
>
|
|
||||||
{login.isPending ? 'Signing in…' : 'Sign in'}
|
{login.isPending ? 'Signing in…' : 'Sign in'}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,19 +23,21 @@ describe('authStore', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockedAuthApi.login.mockResolvedValue({
|
mockedAuthApi.login.mockResolvedValue({
|
||||||
accessToken: 'access-123',
|
accessToken: 'access-123',
|
||||||
|
refreshToken: 'refresh-123',
|
||||||
user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' },
|
user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' },
|
||||||
});
|
});
|
||||||
mockedAuthApi.refresh.mockResolvedValue({
|
mockedAuthApi.refresh.mockResolvedValue({
|
||||||
accessToken: 'access-refreshed',
|
accessToken: 'access-refreshed',
|
||||||
|
refreshToken: 'refresh-refreshed',
|
||||||
user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' },
|
user: { id: 'u1', email: 'learner@aplp.io', name: 'Learner' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('login stores token and user', async () => {
|
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({
|
expect(mockedAuthApi.login).toHaveBeenCalledWith({
|
||||||
email: 'learner@aplp.io',
|
username: 'learner',
|
||||||
password: 'secret',
|
password: 'secret',
|
||||||
});
|
});
|
||||||
expect(tokenStorage.get()).toBe('access-123');
|
expect(tokenStorage.get()).toBe('access-123');
|
||||||
@@ -46,7 +48,7 @@ describe('authStore', () => {
|
|||||||
it('login failure leaves user signed out', async () => {
|
it('login failure leaves user signed out', async () => {
|
||||||
mockedAuthApi.login.mockRejectedValue(new Error('invalid credentials'));
|
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',
|
'invalid credentials',
|
||||||
);
|
);
|
||||||
expect(tokenStorage.get()).toBeNull();
|
expect(tokenStorage.get()).toBeNull();
|
||||||
@@ -54,7 +56,7 @@ describe('authStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('logout clears token and user even when API call fails', async () => {
|
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'));
|
mockedAuthApi.logout.mockRejectedValue(new Error('network'));
|
||||||
|
|
||||||
await useAuthStore.getState().logout();
|
await useAuthStore.getState().logout();
|
||||||
@@ -64,15 +66,28 @@ describe('authStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('init restores session from refresh token', async () => {
|
it('init restores session from refresh token', async () => {
|
||||||
|
tokenStorage.set('old-access', 'refresh-123');
|
||||||
|
|
||||||
await useAuthStore.getState().init();
|
await useAuthStore.getState().init();
|
||||||
|
|
||||||
expect(mockedAuthApi.refresh).toHaveBeenCalledOnce();
|
expect(mockedAuthApi.refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(mockedAuthApi.refresh).toHaveBeenCalledWith('refresh-123');
|
||||||
expect(tokenStorage.get()).toBe('access-refreshed');
|
expect(tokenStorage.get()).toBe('access-refreshed');
|
||||||
expect(useAuthStore.getState().user?.id).toBe('u1');
|
expect(useAuthStore.getState().user?.id).toBe('u1');
|
||||||
expect(useAuthStore.getState().isInitializing).toBe(false);
|
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 () => {
|
it('init handles expired refresh token gracefully', async () => {
|
||||||
|
tokenStorage.set('old-access', 'refresh-expired');
|
||||||
mockedAuthApi.refresh.mockRejectedValue(new Error('401'));
|
mockedAuthApi.refresh.mockRejectedValue(new Error('401'));
|
||||||
|
|
||||||
await useAuthStore.getState().init();
|
await useAuthStore.getState().init();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface AuthState {
|
|||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
/** Whether a session bootstrap is in progress. */
|
/** Whether a session bootstrap is in progress. */
|
||||||
isInitializing: boolean;
|
isInitializing: boolean;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
/** Bootstrap session: try refresh on app start. */
|
/** Bootstrap session: try refresh on app start. */
|
||||||
init: () => Promise<void>;
|
init: () => Promise<void>;
|
||||||
@@ -20,15 +20,15 @@ export const useAuthStore = create<AuthState>()((set) => ({
|
|||||||
user: null,
|
user: null,
|
||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
|
|
||||||
login: async (email, password) => {
|
login: async (username, password) => {
|
||||||
const { accessToken, user } = await authApi.login({ email, password });
|
const { accessToken, refreshToken, user } = await authApi.login({ username, password });
|
||||||
tokenStorage.set(accessToken);
|
tokenStorage.set(accessToken, refreshToken);
|
||||||
set({ user, isInitializing: false });
|
set({ user, isInitializing: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
try {
|
try {
|
||||||
await authApi.logout();
|
await authApi.logout(tokenStorage.getRefreshToken() ?? '');
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort: always clear the local session, even if the API call fails.
|
// Best-effort: always clear the local session, even if the API call fails.
|
||||||
} finally {
|
} finally {
|
||||||
@@ -38,9 +38,15 @@ export const useAuthStore = create<AuthState>()((set) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
init: async () => {
|
init: async () => {
|
||||||
|
const refreshToken = tokenStorage.getRefreshToken();
|
||||||
|
if (!refreshToken) {
|
||||||
|
tokenStorage.clear();
|
||||||
|
set({ user: null, isInitializing: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { accessToken, user } = await authApi.refresh();
|
const { accessToken, refreshToken: nextRefreshToken, user } = await authApi.refresh(refreshToken);
|
||||||
tokenStorage.set(accessToken);
|
tokenStorage.set(accessToken, nextRefreshToken);
|
||||||
set({ user, isInitializing: false });
|
set({ user, isInitializing: false });
|
||||||
} catch {
|
} catch {
|
||||||
tokenStorage.clear();
|
tokenStorage.clear();
|
||||||
@@ -55,9 +61,11 @@ export const useAuthStore = create<AuthState>()((set) => ({
|
|||||||
*/
|
*/
|
||||||
registerAuthHandlers(
|
registerAuthHandlers(
|
||||||
async () => {
|
async () => {
|
||||||
|
const refreshToken = tokenStorage.getRefreshToken();
|
||||||
|
if (!refreshToken) return null;
|
||||||
try {
|
try {
|
||||||
const { accessToken } = await authApi.refresh();
|
const { accessToken, refreshToken: nextRefreshToken } = await authApi.refresh(refreshToken);
|
||||||
tokenStorage.set(accessToken);
|
tokenStorage.set(accessToken, nextRefreshToken);
|
||||||
return accessToken;
|
return accessToken;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ export interface AuthUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginCredentials {
|
export interface LoginCredentials {
|
||||||
email: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RefreshResponse {
|
export interface RefreshResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,50 @@
|
|||||||
@import 'tailwindcss';
|
@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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
export function EmptyState({ title, description }: { title: string; description?: string }) {
|
export function EmptyState({ title, description }: { title: string; description?: string }) {
|
||||||
return (
|
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">
|
<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-medium text-gray-700">{title}</p>
|
<p className="text-sm font-bold text-ink-secondary">{title}</p>
|
||||||
{description && <p className="mt-1 text-sm text-gray-500">{description}</p>}
|
{description && <p className="mt-1 text-sm font-medium text-ink-muted">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
this.props.fallback ?? (
|
this.props.fallback ?? (
|
||||||
<div className="flex min-h-screen items-center justify-center px-4 text-center">
|
<div className="flex min-h-screen items-center justify-center bg-canvas px-4 text-center">
|
||||||
<p className="text-sm text-gray-600">Something went wrong. Please reload the page.</p>
|
<p className="text-sm font-semibold text-ink-secondary">
|
||||||
|
Something went wrong. Please reload the page.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ interface ErrorStateProps {
|
|||||||
|
|
||||||
export function ErrorState({ title = 'Something went wrong', error }: ErrorStateProps) {
|
export function ErrorState({ title = 'Something went wrong', error }: ErrorStateProps) {
|
||||||
return (
|
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">
|
<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-medium text-red-700">{title}</p>
|
<p className="text-sm font-bold text-accent">{title}</p>
|
||||||
{error && <p className="mt-1 text-sm text-red-600">{error.message}</p>}
|
{error && <p className="mt-1 text-sm font-medium text-accent">{error.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Spinner } from '@/shared/components/Spinner';
|
|||||||
|
|
||||||
export function FullPageSpinner({ label = 'Loading' }: { label?: string }) {
|
export function FullPageSpinner({ label = 'Loading' }: { label?: string }) {
|
||||||
return (
|
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} />
|
<Spinner label={label} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export function Spinner({ label = 'Loading' }: { label?: string }) {
|
export function Spinner({ label = 'Loading' }: { label?: string }) {
|
||||||
return (
|
return (
|
||||||
<span role="status" className="inline-flex items-center gap-2 text-sm text-gray-500">
|
<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-indigo-600" viewBox="0 0 24 24" fill="none">
|
<svg className="h-4 w-4 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
|
||||||
<circle
|
<circle
|
||||||
className="opacity-25"
|
className="opacity-25"
|
||||||
cx="12"
|
cx="12"
|
||||||
|
|||||||
Reference in New Issue
Block a user