diff --git a/.env.example b/.env.example
index 5466821..6170428 100644
--- a/.env.example
+++ b/.env.example
@@ -13,6 +13,9 @@ OPENAI_API_KEY=
OPENROUTER_API_KEY=
ANTHROPIC_API_KEY=
+# Google Cloud Translation Basic (v2, server-side only)
+GOOGLE_CLOUD_TRANSLATION_API_KEY=
+
# Stripe Billing
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
diff --git a/README.md b/README.md
index 90e8bbc..e5e1947 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,20 @@
+

+
# Lingora AI
-### Privacy-first, multi-model English learning for global learners
+### Privacy-first AI language learning with strict progression, real user data and multi-model support
[](https://github.com/Meranh05/LingoraAI/actions/workflows/ci.yml)
[](https://nextjs.org/)
-[](https://supabase.com/)
+[](https://react.dev/)
+[](https://supabase.com/)
[](https://www.typescriptlang.org/)
[](LICENSE)
[](https://github.com/Meranh05/LingoraAI/stargazers)
-**[English](#english) · [Tiếng Việt](#tiếng-việt) · [Setup](#quick-start) · [Architecture](#architecture)**
+[English](#english) · [Tiếng Việt](#tiếng-việt) · [Quick Start](#quick-start) · [Configuration](#configuration) · [Architecture](#architecture)

@@ -19,120 +22,200 @@
## English
-Lingora is an open-source AI English-learning platform built for document-based
-learning, adaptive practice and privacy-conscious personalization.
-
-It combines:
-
-- Google OAuth, magic-link and email/password authentication.
-- User/admin role-based access and server-side authorization.
-- Row Level Security so every learner only accesses their own private data.
-- Reading, writing, listening and speaking practice.
-- Document extraction, bilingual summaries, questions and vocabulary.
-- Learning roadmaps, skill mastery and progress analytics.
-- OpenAI, Gemini, Groq, OpenRouter, Anthropic and custom compatible APIs.
-- Stripe subscriptions with Basic, Plus and Pro plans in VND or USD.
-- A consent-based AI feedback pipeline for building anonymized training data.
-- Vietnamese, English, Japanese and Thai navigation.
-
-> Lingora does **not** silently train on private conversations. Training
-> candidates are created only from explicit feedback when consent is enabled,
-> anonymized, and reviewed by an administrator before export.
+Lingora is an open-source AI-assisted English learning platform built with
+Next.js, Supabase and Stripe. It combines structured learning stages,
+skill-based practice, document learning, gamification and configurable AI
+providers in one account-isolated application.
+
+### What is implemented
+
+- Google OAuth, magic link and email/password authentication.
+- Server-side `user` / `admin` authorization and Supabase Row Level Security.
+- Strict stage progression: completing stage A is required before stage B.
+- Stage sessions that only accept questions assigned to the active unit.
+- Server-calculated completion scores, one-time rewards and anti-spam controls.
+- Completion dialog with answers, correct answers, score and next-stage action.
+- Difficult-question review queue persisted per learner.
+- AI Tutor with saved conversations, document context and answer feedback.
+- Reading, listening, speaking, writing, translation, quiz and vocabulary tools.
+- Google Cloud Translation with automatic language detection and server-side keys.
+- Flashcards with spaced review and learner-owned vocabulary.
+- PDF, DOCX and TXT extraction with AI learning tools.
+- XP, tokens, daily/weekly challenges, levels and competition leaderboards.
+- Stripe plans, customer portal, subscription webhooks and token purchases.
+- Avatar uploads through Supabase Storage with ownership policies.
+- User preferences for sound, motion, mascot, compact mode and reminders.
+- Vietnamese, English, Japanese and Thai interface support.
+- Modern admin console for users, content, billing, AI and system diagnostics.
+
+> Lingora never silently trains on private conversations. Training candidates
+> require explicit feedback, learner consent, PII filtering and administrator
+> review before export.
## Tiếng Việt
-Lingora là nền tảng học tiếng Anh mã nguồn mở có AI, tập trung vào học theo tài
-liệu, luyện kỹ năng thích ứng và bảo vệ dữ liệu người dùng.
-
-### Điểm nổi bật
-
-- Đăng nhập Google, magic link, email và mật khẩu.
-- Phân quyền `user` / `admin`, khóa hoặc mở tài khoản.
-- RLS bảo đảm user chỉ đọc và sửa dữ liệu thuộc tài khoản của chính mình.
-- Lộ trình A1–B1, mục tiêu ngày, tiến độ từng kỹ năng.
-- Câu hỏi đọc, viết, nghe, nói; phản hồi và chấm điểm trực tiếp.
-- Upload PDF, DOCX, TXT; tóm tắt song ngữ, tạo quiz và bộ từ vựng.
-- Gia sư AI có memory cá nhân, feedback tốt/xấu và model gateway đa provider.
-- AI Lab xuất JSONL từ dữ liệu đã consent, khử định danh và được admin duyệt.
-- UI responsive cho desktop/mobile và điều hướng đa ngôn ngữ.
+Lingora là nền tảng học tiếng Anh mã nguồn mở có AI, sử dụng dữ liệu thật theo
+từng tài khoản và bảo vệ dữ liệu bằng Supabase RLS.
+
+### Tính năng chính
+
+- Đăng ký/đăng nhập bằng Google, magic link, email và mật khẩu.
+- Phân quyền `user` / `admin`; user chỉ truy cập dữ liệu của chính mình.
+- Lộ trình khóa tuần tự: hoàn thành chặng trước mới mở chặng tiếp theo.
+- Mỗi phiên chặng chỉ ghi nhận câu hỏi thuộc đúng chặng đang học.
+- Popup tổng kết điểm, câu trả lời, đáp án đúng, XP/token và nút qua chặng mới.
+- Thưởng hoàn thành chỉ nhận một lần; có idempotency và giới hạn chống spam.
+- Câu sai hoặc được đánh dấu khó được lưu vào danh sách ôn lại.
+- Gia sư AI đa provider, hội thoại lưu theo tài khoản và hỏi đáp theo tài liệu.
+- Luyện đọc, nghe, nói, viết, dịch thuật, quiz, từ vựng và flashcards.
+- Hệ thống level, nhiệm vụ, thi đua, bảng xếp hạng, XP và Lingora Token.
+- Upload ảnh đại diện thật bằng Supabase Storage.
+- Cài đặt âm thanh, hoạt ảnh, linh vật, giao diện thu gọn và nhắc học.
+- Admin Console quản lý người dùng, nội dung, AI, gói dịch vụ và hệ thống.
## Product Tour
| Authentication | Learning roadmap |
| --- | --- |
-|  |  |
+|  |  |
| Adaptive practice | Administration |
| --- | --- |
-|  |  |
+|  |  |
+
+### Live administration analytics
+
+The administration dashboard reads directly from Supabase and visualizes
+14-day learning activity, plan distribution, skill engagement and the current
+content inventory.
+
+
-Mobile roadmap
+Mobile roadmap preview
-
+
-## Feature Matrix
+## Modules
-| Area | Included |
+| Module | Capabilities |
| --- | --- |
-| Authentication | Google OAuth, magic link, password, PKCE callback, SSR cookies |
-| Authorization | Proxy session refresh, server DAL, admin API checks, RLS |
-| Learning | Roadmap, reading, writing, listening, speaking, vocabulary, quiz |
-| Documents | PDF/DOCX/TXT extraction, summary, questions, vocabulary |
-| AI | Multi-provider gateway, auto-detect, user memory, explicit feedback |
-| Admin | User role/status management, metrics, AI Lab, JSONL export |
-| Billing | Stripe Checkout, Customer Portal, webhook sync, quotas, revenue dashboard |
-| Privacy | Per-user rows, private storage, consent snapshot, PII filtering |
-| Internationalization | Vietnamese, English, Japanese and Thai navigation |
+| AI Tutor | Multi-model chat, document context, persisted sessions, feedback |
+| Roadmap | Sequential units, mastery gates, completion summaries, boss stages |
+| Skill Practice | Reading, listening, speaking and writing progress |
+| Documents | PDF/DOCX/TXT extraction, summaries, questions and vocabulary |
+| Vocabulary | Personal word library and review scheduling |
+| Flashcards | Learner-owned cards and spaced repetition |
+| Speaking | Microphone permission diagnostics and Web Speech transcription |
+| Quiz | Multiple choice, fill blank, dictation, matching and sentence order |
+| Progress | Skill mastery, attempts, study time, streaks and levels |
+| Competition | Opt-in leaderboard, weekly challenges and anti-spam scoring |
+| Economy | XP, Lingora Tokens, temporary unlocks and Stripe purchases |
+| Settings | Profile, avatar, locale, preferences, AI keys and diagnostics |
+| Admin | Users, content studio, billing metrics, AI Lab and system status |
+
+## Tech Stack
+
+- Next.js 16 App Router and React 19
+- TypeScript in strict mode
+- Tailwind CSS 4 and shadcn/Base UI components
+- Supabase Auth, PostgreSQL, RLS and Storage
+- Stripe Checkout, Billing Portal and webhooks
+- Gemini, Groq, OpenAI, OpenRouter, Anthropic and custom compatible endpoints
+- Vitest and GitHub Actions
## Quick Start
Requirements:
-- Node.js 20.16+ or 22+
+- Node.js 22 recommended
- pnpm 10+
-- A Supabase project for production authentication/data
+- A Supabase project
```bash
git clone https://github.com/Meranh05/LingoraAI.git
cd LingoraAI
pnpm install
+```
+
+Windows PowerShell:
+
+```powershell
Copy-Item .env.example .env.local
pnpm dev
```
+macOS/Linux:
+
+```bash
+cp .env.example .env.local
+pnpm dev
+```
+
Open [http://localhost:3000](http://localhost:3000).
-Lingora does not fabricate demo user data. Without Supabase variables, protected
-routes redirect to `/setup` and remain unavailable until the database is
-configured.
+Protected routes redirect to `/setup` until Supabase is configured. Lingora
+does not generate fake learner records when the backend is missing.
+
+## Configuration
+
+```dotenv
+NEXT_PUBLIC_APP_URL=http://localhost:3000
+
+NEXT_PUBLIC_SUPABASE_URL=
+NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
+SUPABASE_SECRET_KEY=
+
+GEMINI_API_KEY=
+GROQ_API_KEY=
+OPENAI_API_KEY=
+OPENROUTER_API_KEY=
+ANTHROPIC_API_KEY=
+GOOGLE_CLOUD_TRANSLATION_API_KEY=
+
+STRIPE_SECRET_KEY=
+STRIPE_WEBHOOK_SECRET=
+```
+
+`NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` is intended for browser use with RLS.
+`SUPABASE_SECRET_KEY`, AI provider keys and Stripe secrets must remain
+server-only.
+
+Users can alternatively enter an AI API key in Settings. Browser-entered keys
+are kept in `sessionStorage`, sent to a server Route Handler only when making a
+request, and are not persisted to Supabase.
## Supabase Setup
1. Create a Supabase project.
-2. Copy the project URL, publishable key and secret key to `.env.local`.
-3. Run migrations in order:
+2. Add its URL, publishable key and secret key to `.env.local`.
+3. Apply every SQL file in `supabase/migrations` in filename order.
+4. Enable the required providers in **Authentication → Sign In / Providers**.
+5. Add the following redirect URLs:
```text
-supabase/migrations/202606090001_initial_lingora.sql
-supabase/migrations/202606090002_auth_rbac_learning_ai.sql
-supabase/migrations/202606090003_real_learning_workflows.sql
-supabase/migrations/202606090004_security_hardening.sql
-supabase/migrations/202606100001_competition_leaderboard.sql
-supabase/migrations/202606100002_billing_subscriptions.sql
+http://localhost:3000/auth/callback
+https://your-domain.com/auth/callback
```
-4. Enable Email and Google in **Authentication → Providers**.
-5. Add callback URLs:
+For Google OAuth, register this callback URL in Google Cloud:
```text
-http://localhost:3000/auth/callback
-https://your-domain.com/auth/callback
+https://YOUR_PROJECT_REF.supabase.co/auth/v1/callback
```
-6. Register the first user, then promote that user once in SQL Editor:
+The migrations create:
+
+- User-owned learning tables and RLS policies.
+- Roadmaps, units, questions, sessions and strict completion rewards.
+- Competition, challenges, wallets, token unlocks and anti-spam functions.
+- Billing plans, subscriptions, transactions and webhook records.
+- AI feedback/training review tables.
+- Public avatar bucket with user-scoped upload/update/delete policies.
+
+Promote the first administrator once in Supabase SQL Editor:
```sql
update public.profiles
@@ -142,84 +225,119 @@ where id = (
);
```
-Never expose `SUPABASE_SECRET_KEY` to browser code.
+## AI Providers
+
+Lingora can automatically detect a provider from the selected provider, API key
+prefix, model name or custom Base URL.
-## Environment
+| Provider | Environment variable |
+| --- | --- |
+| Gemini | `GEMINI_API_KEY` |
+| Groq | `GROQ_API_KEY` |
+| OpenAI | `OPENAI_API_KEY` |
+| OpenRouter | `OPENROUTER_API_KEY` |
+| Anthropic | `ANTHROPIC_API_KEY` |
+| Compatible API | Custom Base URL and model in Settings |
+
+Retryable provider failures such as HTTP `429`, `500`, `502`, `503` and `504`
+use bounded retries and return actionable messages to the interface.
+
+### Google Cloud Translation
+
+The Translation workspace uses Google Cloud Translation Basic (v2) when
+`GOOGLE_CLOUD_TRANSLATION_API_KEY` is configured. The key stays on the server;
+the browser only calls Lingora's authenticated `/api/translation/google`
+route. If the key is absent, Lingora falls back to the configured AI provider.
+
+1. Create or select a project in Google Cloud Console.
+2. Enable **Cloud Translation API** and attach a billing account.
+3. Create an API key under **APIs & Services → Credentials**.
+4. Restrict the key to **Cloud Translation API**. For production, also apply
+ the network or application restrictions appropriate for the deployment.
+5. Add the key to `.env.local`, then restart the Next.js server:
```dotenv
-NEXT_PUBLIC_APP_URL=http://localhost:3000
+GOOGLE_CLOUD_TRANSLATION_API_KEY=your_server_side_key
+```
-NEXT_PUBLIC_SUPABASE_URL=
-NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
-SUPABASE_SECRET_KEY=
+The integration supports automatic source-language detection, explicit
+source/target selection, language swapping, copy-to-clipboard and private
+usage events. Translation text itself is not stored in learning-event
+metadata.
-OPENAI_API_KEY=
-GEMINI_API_KEY=
-GROQ_API_KEY=
-OPENROUTER_API_KEY=
-ANTHROPIC_API_KEY=
+## Speech Recognition
-STRIPE_SECRET_KEY=
-STRIPE_WEBHOOK_SECRET=
-```
+Speaking practice uses the browser Web Speech API.
+
+- Use a current Chrome or Edge browser.
+- The page must run on HTTPS or `localhost`.
+- Microphone permission must be allowed for the site.
+- Browser speech transcription may require Internet even after microphone
+ permission succeeds.
+- VPNs, firewalls or restricted networks can block the browser speech service.
+
+Settings includes diagnostics for network state, secure context, microphone,
+speech support, cookies and server AI providers.
## Stripe Billing
-Lingora uses Stripe Checkout for recurring subscriptions and Stripe Customer
-Portal for payment-method updates, invoices and cancellation.
+Lingora uses Stripe Checkout for subscriptions and token purchases, Stripe
+Customer Portal for account management, and signed webhooks for synchronization.
-1. Add the Stripe test secret key to `STRIPE_SECRET_KEY`.
-2. Create a webhook endpoint pointing to
- `https://your-domain.com/api/billing/webhook`.
-3. Subscribe it to `checkout.session.completed`,
- `customer.subscription.created`, `customer.subscription.updated`,
- `customer.subscription.deleted`, `invoice.paid` and
- `invoice.payment_failed`.
-4. Copy the webhook signing secret to `STRIPE_WEBHOOK_SECRET`.
-5. Configure Stripe Customer Portal in the Stripe Dashboard.
+Create a webhook endpoint:
-For local webhook testing:
+```text
+https://your-domain.com/api/billing/webhook
+```
-```bash
-stripe listen --forward-to localhost:3000/api/billing/webhook
+Subscribe it to:
+
+```text
+checkout.session.completed
+customer.subscription.created
+customer.subscription.updated
+customer.subscription.deleted
+invoice.paid
+invoice.payment_failed
```
-The default monthly catalog is Basic (`99,000 VND` / `$4.99`), Plus
-(`199,000 VND` / `$8.99`) and Pro (`399,000 VND` / `$16.99`). Edit
-`public.billing_plans` to change prices or limits.
+Local webhook forwarding:
-Every plan supports card payment and a one-time three-day trial without a card.
-Admin accounts also see a developer-only no-card Checkout button while the
-server uses an `sk_test_` Stripe Sandbox key.
+```bash
+stripe listen --forward-to localhost:3000/api/billing/webhook
+```
-Users may alternatively enter an AI key for the current browser tab. It is
-stored in `sessionStorage`, sent only to a server Route Handler, and never saved
-to Supabase.
+The seeded catalog includes Basic, Plus and Pro plans. Prices and limits are
+stored in `public.billing_plans` and can be managed from the database/admin
+workflow. Test-mode admin accounts can use the developer checkout flow; normal
+users can use card checkout or the one-time three-day trial flow.
## Architecture
```mermaid
flowchart LR
- Browser["Next.js UI"] --> Proxy["Supabase SSR Proxy"]
- Proxy --> Auth["Supabase Auth"]
- Browser --> Routes["Server Actions / Route Handlers"]
- Routes --> DAL["Authorization DAL"]
- DAL --> RLS["Postgres + RLS"]
- Routes --> Gateway["AI Provider Gateway"]
- Gateway --> Models["Gemini / Groq / OpenAI / OpenRouter / Anthropic"]
- RLS --> Feedback["Consented AI Feedback"]
- Feedback --> Anon["PII filtering + review queue"]
- Anon --> Export["Admin-approved JSONL export"]
+ UI["Next.js UI"] --> SSR["Supabase SSR auth"]
+ SSR --> Auth["Supabase Auth"]
+ UI --> API["Route Handlers / Server Actions"]
+ API --> Access["Server authorization"]
+ Access --> DB["PostgreSQL + RLS"]
+ API --> AI["AI provider gateway"]
+ AI --> Models["Gemini / Groq / OpenAI / OpenRouter / Anthropic"]
+ API --> Stripe["Stripe Checkout + Webhooks"]
+ DB --> Review["Consented feedback review"]
+ Review --> Export["Admin-approved anonymized export"]
```
-Security is enforced at multiple layers:
+Security is enforced through:
-1. Supabase validates SSR tokens with `getClaims()`.
-2. Pages and APIs verify viewer/admin permissions server-side.
-3. Postgres RLS enforces row ownership.
-4. Secret-key admin operations only run on the server.
+1. SSR token validation with Supabase `getClaims()`.
+2. Server-side authorization for pages and mutations.
+3. PostgreSQL RLS for user-owned rows.
+4. User-scoped Storage policies for avatar mutations.
+5. Server-only service, AI and Stripe secrets.
+6. Idempotency and database claims for rewards and payments.
-See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for additional details.
+See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Development
@@ -229,33 +347,35 @@ pnpm typecheck
pnpm test
pnpm build
-# Run everything
+# Run the complete quality gate
pnpm check
```
-## Roadmap
+The GitHub Actions workflow runs the same quality gate for pull requests and
+pushes to `main`.
+
+## Current Limitations
-- Real streaming responses and resumable chat sessions.
-- Speech-to-text provider adapters and pronunciation scoring.
-- Background document chunking and embeddings jobs.
-- Admin content authoring and training-candidate review workflow.
-- More complete translated content and locale-aware course packs.
-- Self-hosted model evaluation and fine-tuning recipes.
+- Web Speech availability depends on browser and network services.
+- AI responses are request/response based; token streaming is not implemented.
+- Document extraction runs in the request lifecycle rather than a background
+ worker queue.
+- Complete translated learning packs still depend on published content.
## Contributing
-Issues, translations, course content and provider adapters are welcome. Read
-[CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request.
+Bug fixes, translations, CEFR content, accessibility improvements and provider
+adapters are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a
+pull request.
-If Lingora is useful, consider starring the repository, sharing a screenshot,
-or opening a focused issue. Specific feedback is more useful than generic
-promotion.
+If Lingora is useful, star the repository, share a screenshot or open a focused
+issue with reproducible details.
## Security
-Please do not disclose vulnerabilities in public issues. Follow
-[SECURITY.md](SECURITY.md).
+Do not disclose authentication bypasses, RLS failures, secret exposure or
+cross-user access in public issues. Follow [SECURITY.md](SECURITY.md).
## License
-Distributed under the repository [LICENSE](LICENSE).
+Lingora is distributed under the [GNU General Public License v3.0](LICENSE).
diff --git a/docs/screenshots/admin-analytics.png b/docs/screenshots/admin-analytics.png
new file mode 100644
index 0000000..c574148
Binary files /dev/null and b/docs/screenshots/admin-analytics.png differ
diff --git a/src/app/ai-tutor/page.tsx b/src/app/ai-tutor/page.tsx
index 5a26d2a..e98cd62 100644
--- a/src/app/ai-tutor/page.tsx
+++ b/src/app/ai-tutor/page.tsx
@@ -9,5 +9,5 @@ export const metadata: Metadata = {
export default async function AiTutorPage() {
const viewer = await requireViewer();
- return
;
+ return
;
}
diff --git a/src/app/api/admin/content/route.ts b/src/app/api/admin/content/route.ts
index f89f392..d7fa1bb 100644
--- a/src/app/api/admin/content/route.ts
+++ b/src/app/api/admin/content/route.ts
@@ -22,7 +22,12 @@ const createSchema = z.discriminatedUnion("kind", [
slug: z.string().trim().min(2).max(80).regex(/^[a-z0-9-]+$/),
titleVi: z.string().trim().min(2).max(160),
titleEn: z.string().trim().max(160).optional(),
+ titleJa: z.string().trim().max(160).optional(),
+ titleTh: z.string().trim().max(160).optional(),
descriptionVi: z.string().trim().max(1000).optional(),
+ descriptionEn: z.string().trim().max(1000).optional(),
+ descriptionJa: z.string().trim().max(1000).optional(),
+ descriptionTh: z.string().trim().max(1000).optional(),
targetLevel: z.string().trim().min(1).max(20),
estimatedHours: z.coerce.number().int().min(1).max(1000),
published: z.boolean(),
@@ -32,7 +37,13 @@ const createSchema = z.discriminatedUnion("kind", [
pathId: z.uuid(),
position: z.coerce.number().int().min(1).max(1000),
titleVi: z.string().trim().min(2).max(160),
+ titleEn: z.string().trim().max(160).optional(),
+ titleJa: z.string().trim().max(160).optional(),
+ titleTh: z.string().trim().max(160).optional(),
descriptionVi: z.string().trim().max(1000).optional(),
+ descriptionEn: z.string().trim().max(1000).optional(),
+ descriptionJa: z.string().trim().max(1000).optional(),
+ descriptionTh: z.string().trim().max(1000).optional(),
skill,
level: z.string().trim().min(1).max(20),
estimatedMinutes: z.coerce.number().int().min(1).max(300),
@@ -41,7 +52,13 @@ const createSchema = z.discriminatedUnion("kind", [
kind: z.literal("question"),
unitId: z.uuid(),
promptVi: z.string().trim().min(2).max(3000),
+ promptEn: z.string().trim().max(3000).optional(),
+ promptJa: z.string().trim().max(3000).optional(),
+ promptTh: z.string().trim().max(3000).optional(),
explanationVi: z.string().trim().max(3000).optional(),
+ explanationEn: z.string().trim().max(3000).optional(),
+ explanationJa: z.string().trim().max(3000).optional(),
+ explanationTh: z.string().trim().max(3000).optional(),
passage: z.string().trim().max(10000).optional(),
audioUrl: z.string().trim().url().optional().or(z.literal("")),
skill,
@@ -55,7 +72,13 @@ const createSchema = z.discriminatedUnion("kind", [
kind: z.literal("challenge"),
slug: z.string().trim().min(2).max(80).regex(/^[a-z0-9-]+$/),
titleVi: z.string().trim().min(2).max(160),
+ titleEn: z.string().trim().max(160).optional(),
+ titleJa: z.string().trim().max(160).optional(),
+ titleTh: z.string().trim().max(160).optional(),
descriptionVi: z.string().trim().max(1000),
+ descriptionEn: z.string().trim().max(1000).optional(),
+ descriptionJa: z.string().trim().max(1000).optional(),
+ descriptionTh: z.string().trim().max(1000).optional(),
challengeType: z.enum(["daily", "weekly", "boss", "community"]),
difficulty: z.enum(["easy", "normal", "hard", "legendary"]),
eventType: z.string().trim().max(80).optional(),
@@ -89,8 +112,8 @@ export async function POST(request: Request) {
if (input.kind === "path") {
result = await admin.from("learning_paths").insert({
slug: input.slug,
- title: { vi: input.titleVi, en: input.titleEn || input.titleVi },
- description: { vi: input.descriptionVi || "" },
+ title: { vi: input.titleVi, en: input.titleEn || input.titleVi, ja: input.titleJa || input.titleEn || input.titleVi, th: input.titleTh || input.titleEn || input.titleVi },
+ description: { vi: input.descriptionVi || "", en: input.descriptionEn || input.descriptionVi || "", ja: input.descriptionJa || input.descriptionEn || input.descriptionVi || "", th: input.descriptionTh || input.descriptionEn || input.descriptionVi || "" },
target_level: input.targetLevel,
estimated_hours: input.estimatedHours,
is_published: input.published,
@@ -100,8 +123,8 @@ export async function POST(request: Request) {
result = await admin.from("learning_units").insert({
path_id: input.pathId,
position: input.position,
- title: { vi: input.titleVi, en: input.titleVi },
- description: { vi: input.descriptionVi || "" },
+ title: { vi: input.titleVi, en: input.titleEn || input.titleVi, ja: input.titleJa || input.titleEn || input.titleVi, th: input.titleTh || input.titleEn || input.titleVi },
+ description: { vi: input.descriptionVi || "", en: input.descriptionEn || input.descriptionVi || "", ja: input.descriptionJa || input.descriptionEn || input.descriptionVi || "", th: input.descriptionTh || input.descriptionEn || input.descriptionVi || "" },
skill: input.skill,
level: input.level,
estimated_minutes: input.estimatedMinutes,
@@ -133,8 +156,8 @@ export async function POST(request: Request) {
owner_id: null,
skill: input.skill,
question_type: input.questionType,
- prompt: { vi: input.promptVi, en: input.promptVi },
- explanation: { vi: input.explanationVi || "" },
+ prompt: { vi: input.promptVi, en: input.promptEn || input.promptVi, ja: input.promptJa || input.promptEn || input.promptVi, th: input.promptTh || input.promptEn || input.promptVi },
+ explanation: { vi: input.explanationVi || "", en: input.explanationEn || input.explanationVi || "", ja: input.explanationJa || input.explanationEn || input.explanationVi || "", th: input.explanationTh || input.explanationEn || input.explanationVi || "" },
passage: input.passage || null,
audio_url: input.audioUrl || null,
options: optionObjects.length ? optionObjects : null,
@@ -148,8 +171,8 @@ export async function POST(request: Request) {
ends.setDate(ends.getDate() + input.durationDays);
result = await admin.from("learning_challenges").insert({
slug: input.slug,
- title: { vi: input.titleVi, en: input.titleVi },
- description: { vi: input.descriptionVi, en: input.descriptionVi },
+ title: { vi: input.titleVi, en: input.titleEn || input.titleVi, ja: input.titleJa || input.titleEn || input.titleVi, th: input.titleTh || input.titleEn || input.titleVi },
+ description: { vi: input.descriptionVi, en: input.descriptionEn || input.descriptionVi, ja: input.descriptionJa || input.descriptionEn || input.descriptionVi, th: input.descriptionTh || input.descriptionEn || input.descriptionVi },
challenge_type: input.challengeType,
difficulty: input.difficulty,
event_type: input.eventType || null,
diff --git a/src/app/api/practice/attempt/route.ts b/src/app/api/practice/attempt/route.ts
index f704fe4..d100bca 100644
--- a/src/app/api/practice/attempt/route.ts
+++ b/src/app/api/practice/attempt/route.ts
@@ -1,6 +1,12 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getOptionalViewer } from "@/lib/auth";
+import {
+ answerWords,
+ normalizeAnswer,
+ orderedSentenceScore,
+ wordSimilarity,
+} from "@/lib/practice-scoring";
import { createAdminClient } from "@/lib/supabase/admin";
const schema = z.object({
@@ -9,33 +15,15 @@ const schema = z.object({
durationSeconds: z.number().int().min(0).max(14_400).default(0),
module: z.enum(["practice", "quiz", "competition"]).default("practice"),
challengeId: z.uuid().optional(),
+ unitId: z.uuid().optional(),
+ unitSessionId: z.uuid().optional(),
idempotencyKey: z.uuid(),
-});
-
-function normalize(value: unknown) {
- return String(value ?? "").trim().toLocaleLowerCase("en");
-}
-
-function words(value: unknown) {
- return normalize(value)
- .replace(/[^\p{L}\p{N}\s]/gu, "")
- .split(/\s+/)
- .filter(Boolean);
-}
-
-function similarity(expectedValue: unknown, actualValue: unknown) {
- const expected = words(expectedValue);
- const actual = words(actualValue);
- if (!expected.length) return 0;
- const remaining = [...actual];
- const matched = expected.filter((word) => {
- const index = remaining.indexOf(word);
- if (index < 0) return false;
- remaining.splice(index, 1);
- return true;
- }).length;
- return Math.round((matched / expected.length) * 100);
-}
+}).refine(
+ (value) =>
+ (!value.unitId && !value.unitSessionId) ||
+ Boolean(value.unitId && value.unitSessionId),
+ { message: "unitId và unitSessionId phải được gửi cùng nhau." },
+);
export async function POST(request: Request) {
const viewer = await getOptionalViewer();
@@ -44,12 +32,73 @@ export async function POST(request: Request) {
const admin = createAdminClient();
const { data: question, error: questionError } = await admin
.from("practice_questions")
- .select("id,skill,question_type,answer_key,explanation,options")
+ .select("id,unit_id,skill,question_type,answer_key,explanation,options")
.eq("id", input.questionId)
.single();
if (questionError || !question) {
return NextResponse.json({ error: "Không tìm thấy câu hỏi." }, { status: 404 });
}
+ if (input.unitId && question.unit_id !== input.unitId) {
+ return NextResponse.json(
+ { error: "Câu hỏi không thuộc chặng học hiện tại." },
+ { status: 403 },
+ );
+ }
+ if (input.unitId && input.unitSessionId) {
+ const { data: activeSession } = await admin
+ .from("learning_unit_sessions")
+ .select("id")
+ .eq("id", input.unitSessionId)
+ .eq("user_id", viewer.id)
+ .eq("unit_id", input.unitId)
+ .eq("status", "active")
+ .maybeSingle();
+ if (!activeSession) {
+ return NextResponse.json(
+ { error: "Phiên học không còn hoạt động. Hãy mở lại chặng." },
+ { status: 403 },
+ );
+ }
+ }
+ if (question.unit_id && input.module !== "competition") {
+ const { data: unit } = await admin
+ .from("learning_units")
+ .select("path_id,position")
+ .eq("id", question.unit_id)
+ .maybeSingle();
+ if (unit) {
+ const [{ data: enrollment }, { data: previous }] = await Promise.all([
+ admin
+ .from("user_path_enrollments")
+ .select("id")
+ .eq("user_id", viewer.id)
+ .eq("path_id", unit.path_id)
+ .maybeSingle(),
+ admin
+ .from("learning_units")
+ .select("id")
+ .eq("path_id", unit.path_id)
+ .lt("position", unit.position)
+ .order("position", { ascending: false })
+ .limit(1)
+ .maybeSingle(),
+ ]);
+ if (enrollment && previous) {
+ const { data: previousProgress } = await admin
+ .from("user_unit_progress")
+ .select("completed_at")
+ .eq("user_id", viewer.id)
+ .eq("unit_id", previous.id)
+ .maybeSingle();
+ if (!previousProgress?.completed_at) {
+ return NextResponse.json(
+ { error: "Hãy hoàn thành checkpoint trước để mở bài học này." },
+ { status: 403 },
+ );
+ }
+ }
+ }
+ }
const key = (question.answer_key ?? {}) as {
value?: unknown;
@@ -61,37 +110,39 @@ export async function POST(request: Request) {
const options = (question.options ?? []) as Array<{ id?: string; text?: string }>;
let score: number | null = null;
if (question.question_type === "multiple_choice" || question.question_type === "true_false") {
- const selected = options.find((option) => normalize(option.id) === normalize(input.answer));
+ const selected = options.find((option) => normalizeAnswer(option.id) === normalizeAnswer(input.answer));
score =
- normalize(input.answer) === normalize(key.value) ||
- normalize(selected?.text) === normalize(key.value)
+ normalizeAnswer(input.answer) === normalizeAnswer(key.value) ||
+ normalizeAnswer(selected?.text) === normalizeAnswer(key.value)
? 100
: 0;
} else if (question.question_type === "dictation") {
- score = similarity(key.text ?? key.value, input.answer);
+ score = wordSimilarity(key.text ?? key.value, input.answer);
} else if (question.question_type === "short_answer") {
- score = similarity(key.value ?? key.text, input.answer);
+ score = wordSimilarity(key.value ?? key.text, input.answer);
} else if (question.question_type === "fill_blank") {
- score = normalize(key.value ?? key.text) === normalize(input.answer) ? 100 : similarity(key.value ?? key.text, input.answer);
+ score = normalizeAnswer(key.value ?? key.text) === normalizeAnswer(input.answer)
+ ? 100
+ : wordSimilarity(key.value ?? key.text, input.answer);
} else if (question.question_type === "match_meaning") {
- const selected = options.find((option) => normalize(option.id) === normalize(input.answer));
+ const selected = options.find((option) => normalizeAnswer(option.id) === normalizeAnswer(input.answer));
score =
- normalize(input.answer) === normalize(key.value) ||
- normalize(selected?.text) === normalize(key.value)
+ normalizeAnswer(input.answer) === normalizeAnswer(key.value) ||
+ normalizeAnswer(selected?.text) === normalizeAnswer(key.value)
? 100
: 0;
} else if (question.question_type === "sentence_order") {
const ordered = Array.isArray(input.answer)
? input.answer.join(" ")
: input.answer;
- score = similarity(key.text ?? key.value, ordered);
+ score = orderedSentenceScore(key.text ?? key.value, ordered);
} else if (question.question_type === "speaking") {
const keywords = key.keywords ?? [];
score = keywords.length
- ? similarity(keywords.join(" "), input.answer)
- : Math.min(100, words(input.answer).length * 10);
+ ? wordSimilarity(keywords.join(" "), input.answer)
+ : Math.min(100, answerWords(input.answer).length * 10);
} else if (question.question_type === "essay") {
- const count = words(input.answer).length;
+ const count = answerWords(input.answer).length;
const minimum = key.min_words ?? 60;
const maximum = key.max_words ?? 180;
score =
@@ -128,6 +179,21 @@ export async function POST(request: Request) {
if (!secured) {
return NextResponse.json({ error: "Không thể ghi nhận lượt học." }, { status: 500 });
}
+ if (input.unitId && input.unitSessionId) {
+ const { error: sessionError } = await admin.rpc(
+ "attach_attempt_to_unit_session",
+ {
+ target_user_id: viewer.id,
+ target_session_id: input.unitSessionId,
+ target_unit_id: input.unitId,
+ target_question_id: question.id,
+ target_attempt_id: secured.attempt_id,
+ },
+ );
+ if (sessionError) {
+ return NextResponse.json({ error: sessionError.message }, { status: 403 });
+ }
+ }
if (input.module === "quiz" && score !== null) {
await admin.from("quiz_results").insert({
user_id: viewer.id,
@@ -147,5 +213,15 @@ export async function POST(request: Request) {
},
rewardEligible: secured.reward_eligible,
cooldownSeconds: secured.cooldown_seconds,
+ correctAnswer:
+ question.question_type === "multiple_choice" ||
+ question.question_type === "true_false" ||
+ question.question_type === "match_meaning"
+ ? options.find(
+ (option) =>
+ normalizeAnswer(option.id) === normalizeAnswer(key.value) ||
+ normalizeAnswer(option.text) === normalizeAnswer(key.value),
+ )?.text ?? String(key.value ?? "")
+ : String(key.text ?? key.value ?? ""),
});
}
diff --git a/src/app/api/practice/review/route.ts b/src/app/api/practice/review/route.ts
new file mode 100644
index 0000000..620b873
--- /dev/null
+++ b/src/app/api/practice/review/route.ts
@@ -0,0 +1,30 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { getOptionalViewer } from "@/lib/auth";
+import { createAdminClient } from "@/lib/supabase/admin";
+
+const schema = z.object({
+ questionId: z.uuid(),
+ difficult: z.boolean(),
+ score: z.number().min(0).max(100).default(0),
+});
+
+export async function POST(request: Request) {
+ const viewer = await getOptionalViewer();
+ if (!viewer) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const input = schema.parse(await request.json());
+ const admin = createAdminClient();
+ const { error } = await admin.rpc("set_question_review_state", {
+ target_user_id: viewer.id,
+ target_question_id: input.questionId,
+ target_difficult: input.difficult,
+ target_score: input.score,
+ });
+ if (error) {
+ return NextResponse.json({ error: error.message }, { status: 400 });
+ }
+ return NextResponse.json({ saved: true });
+}
diff --git a/src/app/api/profile/avatar/route.ts b/src/app/api/profile/avatar/route.ts
new file mode 100644
index 0000000..a18c545
--- /dev/null
+++ b/src/app/api/profile/avatar/route.ts
@@ -0,0 +1,86 @@
+import { NextResponse } from "next/server";
+import { getOptionalViewer } from "@/lib/auth";
+import { createAdminClient } from "@/lib/supabase/admin";
+
+const allowedTypes = new Map([
+ ["image/jpeg", "jpg"],
+ ["image/png", "png"],
+ ["image/webp", "webp"],
+ ["image/gif", "gif"],
+]);
+
+function hasValidImageSignature(type: string, bytes: Uint8Array) {
+ if (type === "image/jpeg") return bytes[0] === 0xff && bytes[1] === 0xd8;
+ if (type === "image/png") {
+ return (
+ bytes[0] === 0x89 &&
+ bytes[1] === 0x50 &&
+ bytes[2] === 0x4e &&
+ bytes[3] === 0x47
+ );
+ }
+ const header = new TextDecoder().decode(bytes.slice(0, 12));
+ if (type === "image/gif") return header.startsWith("GIF8");
+ if (type === "image/webp") {
+ return header.startsWith("RIFF") && header.slice(8, 12) === "WEBP";
+ }
+ return false;
+}
+
+export async function POST(request: Request) {
+ const viewer = await getOptionalViewer();
+ if (!viewer) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const formData = await request.formData();
+ const file = formData.get("avatar");
+ if (!(file instanceof File)) {
+ return NextResponse.json({ error: "Vui lòng chọn một ảnh." }, { status: 400 });
+ }
+ const extension = allowedTypes.get(file.type);
+ if (!extension) {
+ return NextResponse.json(
+ { error: "Chỉ hỗ trợ JPG, PNG, WebP hoặc GIF." },
+ { status: 400 },
+ );
+ }
+ if (file.size > 5 * 1024 * 1024) {
+ return NextResponse.json(
+ { error: "Ảnh đại diện không được vượt quá 5 MB." },
+ { status: 400 },
+ );
+ }
+ const fileBuffer = await file.arrayBuffer();
+ if (!hasValidImageSignature(file.type, new Uint8Array(fileBuffer))) {
+ return NextResponse.json(
+ { error: "Nội dung file không phải ảnh hợp lệ." },
+ { status: 400 },
+ );
+ }
+
+ const admin = createAdminClient();
+ const path = `${viewer.id}/avatar.${extension}`;
+ const { error: uploadError } = await admin.storage
+ .from("avatars")
+ .upload(path, fileBuffer, {
+ contentType: file.type,
+ cacheControl: "3600",
+ upsert: true,
+ });
+ if (uploadError) {
+ return NextResponse.json({ error: uploadError.message }, { status: 400 });
+ }
+
+ const { data } = admin.storage.from("avatars").getPublicUrl(path);
+ const avatarUrl = `${data.publicUrl}?v=${Date.now()}`;
+ const { error: profileError } = await admin
+ .from("profiles")
+ .update({ avatar_url: avatarUrl, updated_at: new Date().toISOString() })
+ .eq("id", viewer.id);
+ if (profileError) {
+ return NextResponse.json({ error: profileError.message }, { status: 400 });
+ }
+
+ return NextResponse.json({ avatarUrl });
+}
diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts
index 3186b02..e5962f5 100644
--- a/src/app/api/profile/route.ts
+++ b/src/app/api/profile/route.ts
@@ -9,6 +9,16 @@ const schema = z.object({
learningGoal: z.string().max(500).optional(),
dailyGoalMinutes: z.number().int().min(5).max(240).optional(),
aiTrainingConsent: z.boolean().optional(),
+ preferences: z
+ .object({
+ emailReminders: z.boolean(),
+ dailyReminder: z.boolean(),
+ weeklySummary: z.boolean(),
+ autoPlayAudio: z.boolean(),
+ showMascot: z.boolean(),
+ compactMode: z.boolean(),
+ })
+ .optional(),
});
export async function PATCH(request: Request) {
@@ -34,6 +44,9 @@ export async function PATCH(request: Request) {
consent_updated_at: new Date().toISOString(),
}
: {}),
+ ...(input.preferences !== undefined
+ ? { preferences: input.preferences }
+ : {}),
updated_at: new Date().toISOString(),
})
.eq("id", viewer.id);
diff --git a/src/app/api/roadmap/session/route.ts b/src/app/api/roadmap/session/route.ts
new file mode 100644
index 0000000..dbdb8a8
--- /dev/null
+++ b/src/app/api/roadmap/session/route.ts
@@ -0,0 +1,67 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { getOptionalViewer } from "@/lib/auth";
+import { createAdminClient } from "@/lib/supabase/admin";
+
+const requestSchema = z.discriminatedUnion("action", [
+ z.object({
+ action: z.literal("start"),
+ unitId: z.uuid(),
+ }),
+ z.object({
+ action: z.literal("finalize"),
+ sessionId: z.uuid(),
+ }),
+]);
+
+export async function POST(request: Request) {
+ const viewer = await getOptionalViewer();
+ if (!viewer) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const input = requestSchema.parse(await request.json());
+ const admin = createAdminClient();
+
+ if (input.action === "start") {
+ const { data, error } = await admin.rpc("start_learning_unit_session", {
+ target_user_id: viewer.id,
+ target_unit_id: input.unitId,
+ });
+ if (error) {
+ return NextResponse.json({ error: error.message }, { status: 403 });
+ }
+ return NextResponse.json(
+ { sessionId: data },
+ { headers: { "Cache-Control": "private, no-store" } },
+ );
+ }
+
+ const { data: result, error } = await admin.rpc(
+ "finalize_learning_unit_session",
+ {
+ target_user_id: viewer.id,
+ target_session_id: input.sessionId,
+ },
+ );
+ if (error) {
+ return NextResponse.json({ error: error.message }, { status: 400 });
+ }
+
+ let nextUnit: { id: string; title: Record
} | null = null;
+ const nextUnitId = (result as { nextUnitId?: string | null } | null)
+ ?.nextUnitId;
+ if (nextUnitId) {
+ const { data } = await admin
+ .from("learning_units")
+ .select("id,title")
+ .eq("id", nextUnitId)
+ .maybeSingle();
+ nextUnit = data as typeof nextUnit;
+ }
+
+ return NextResponse.json(
+ { result, nextUnit },
+ { headers: { "Cache-Control": "private, no-store" } },
+ );
+}
diff --git a/src/app/api/translation/google/route.ts b/src/app/api/translation/google/route.ts
new file mode 100644
index 0000000..287f3c7
--- /dev/null
+++ b/src/app/api/translation/google/route.ts
@@ -0,0 +1,132 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { getOptionalViewer } from "@/lib/auth";
+import { consumeAiQuota } from "@/lib/billing";
+import { hasFeatureAccess } from "@/lib/economy";
+import {
+ GoogleTranslationError,
+ translateWithGoogle,
+} from "@/lib/google-cloud-translate";
+import {
+ isTranslationLanguage,
+ translationLanguages,
+} from "@/lib/translation-languages";
+import { createClient } from "@/lib/supabase/server";
+
+const languageCodes = translationLanguages.map((language) => language.code) as [
+ string,
+ ...string[],
+];
+
+const schema = z.object({
+ text: z.string().trim().min(1).max(10_000),
+ source: z.enum(languageCodes).nullable().optional(),
+ target: z.enum(languageCodes),
+});
+
+export async function POST(request: Request) {
+ try {
+ const viewer = await getOptionalViewer();
+ if (!viewer) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ if (!(await hasFeatureAccess(viewer.id, "translation", "basic"))) {
+ return NextResponse.json(
+ {
+ error: "Dịch thuật yêu cầu gói Basic, Plus hoặc Pro.",
+ code: "PLAN_UPGRADE_REQUIRED",
+ requiredPlan: "basic",
+ },
+ { status: 403 },
+ );
+ }
+
+ const input = schema.parse(await request.json());
+ if (input.source && input.source === input.target) {
+ return NextResponse.json(
+ { error: "Ngôn ngữ nguồn và đích phải khác nhau." },
+ { status: 400 },
+ );
+ }
+ if (
+ (input.source && !isTranslationLanguage(input.source)) ||
+ !isTranslationLanguage(input.target)
+ ) {
+ return NextResponse.json(
+ { error: "Ngôn ngữ chưa được hỗ trợ." },
+ { status: 400 },
+ );
+ }
+ if (!process.env.GOOGLE_CLOUD_TRANSLATION_API_KEY?.trim()) {
+ return NextResponse.json(
+ {
+ error: "Google Cloud Translation chưa được cấu hình.",
+ code: "GOOGLE_TRANSLATION_NOT_CONFIGURED",
+ },
+ { status: 503 },
+ );
+ }
+
+ const usage = await consumeAiQuota(viewer.id);
+ if (!usage.allowed) {
+ return NextResponse.json(
+ {
+ error: `Bạn đã dùng hết ${usage.quota} lượt AI hôm nay.`,
+ code: "AI_QUOTA_EXCEEDED",
+ },
+ { status: 429 },
+ );
+ }
+
+ const result = await translateWithGoogle({
+ text: input.text,
+ source: input.source ?? undefined,
+ target: input.target,
+ });
+ const supabase = await createClient();
+ await supabase.from("learning_events").insert({
+ user_id: viewer.id,
+ event_type: "translation",
+ skill: "translation",
+ duration_seconds: 0,
+ metadata: {
+ provider: "google-cloud-translation",
+ source: result.detectedSourceLanguage,
+ target: input.target,
+ characters: input.text.length,
+ },
+ });
+
+ return NextResponse.json(
+ {
+ ...result,
+ provider: "google-cloud-translation",
+ usage: {
+ used: usage.used,
+ quota: usage.quota,
+ remaining: Math.max(0, usage.quota - usage.used),
+ },
+ },
+ { headers: { "Cache-Control": "private, no-store" } },
+ );
+ } catch (error) {
+ const message =
+ error instanceof z.ZodError
+ ? "Dữ liệu dịch không hợp lệ."
+ : error instanceof Error
+ ? error.message
+ : "Không thể dịch nội dung.";
+ const status =
+ error instanceof GoogleTranslationError
+ ? error.status
+ : error instanceof z.ZodError
+ ? 400
+ : 500;
+ const code =
+ error instanceof GoogleTranslationError ? error.code : undefined;
+ return NextResponse.json(
+ { error: message, code },
+ { status, headers: { "Cache-Control": "private, no-store" } },
+ );
+ }
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 57064ff..7fae219 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -201,6 +201,18 @@
border-top: 4px dotted rgb(125 211 252 / 75%);
}
+ .journey-route path:last-child {
+ animation: journey-dash 14s linear infinite;
+ }
+
+ .journey-cloud {
+ animation: journey-cloud 5s ease-in-out infinite;
+ }
+
+ .sticker-pop {
+ animation: sticker-pop 2.4s ease-in-out infinite;
+ }
+
html[data-motion="reduced"] *,
html[data-motion="reduced"] *::before,
html[data-motion="reduced"] *::after {
@@ -209,6 +221,15 @@
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
+
+ html[data-density="compact"] main {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+ }
+
+ html[data-density="compact"] main > div {
+ gap: 1rem;
+ }
}
@keyframes lumo-float {
@@ -239,3 +260,27 @@
transform: translateY(0);
}
}
+
+@keyframes journey-dash {
+ to {
+ stroke-dashoffset: -120;
+ }
+}
+
+@keyframes journey-cloud {
+ 0%, 100% {
+ transform: translateX(0) translateY(0);
+ }
+ 50% {
+ transform: translateX(8px) translateY(-3px);
+ }
+}
+
+@keyframes sticker-pop {
+ 0%, 100% {
+ transform: translateY(0) rotate(-3deg);
+ }
+ 50% {
+ transform: translateY(-4px) rotate(4deg);
+ }
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 7174495..5ed0131 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -37,11 +37,12 @@ export default async function RootLayout({
-
+
-
+
{children}
diff --git a/src/app/learn/[slug]/page.tsx b/src/app/learn/[slug]/page.tsx
index 843a574..d9b8186 100644
--- a/src/app/learn/[slug]/page.tsx
+++ b/src/app/learn/[slug]/page.tsx
@@ -1,4 +1,11 @@
+import Link from "next/link";
+import { LockKeyhole } from "lucide-react";
import { notFound, redirect } from "next/navigation";
+import { LearningWorkspace } from "@/components/learning-workspace";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { requireViewer } from "@/lib/auth";
+import { getUnitLessonData } from "@/lib/learning-data";
type Props = {
params: Promise<{ slug: string }>;
@@ -10,6 +17,38 @@ export default async function LearnPage({ params }: Props) {
"documents", "vocabulary", "flashcards", "reading", "listening",
"speaking", "writing", "translation", "quiz", "progress",
]);
- if (!supported.has(slug)) notFound();
- redirect(`/${slug}`);
+ if (supported.has(slug)) redirect(`/${slug}`);
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(slug)) {
+ notFound();
+ }
+ const viewer = await requireViewer();
+ const data = await getUnitLessonData(viewer, slug);
+ if (!data) notFound();
+ if (data.locked) {
+ return (
+
+
+
+
+
+ Checkpoint chưa được mở khóa
+
+ {data.reason === "not_enrolled"
+ ? "Đăng ký lộ trình trước khi bắt đầu bài học."
+ : "Đạt mastery của checkpoint trước để tiếp tục."}
+
+ }>
+ Quay lại lộ trình
+
+
+
+ );
+ }
+ return (
+
+ );
}
diff --git a/src/app/loading.tsx b/src/app/loading.tsx
new file mode 100644
index 0000000..a749329
--- /dev/null
+++ b/src/app/loading.tsx
@@ -0,0 +1,21 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+export default function Loading() {
+ return (
+
+
+
+ {Array.from({ length: 4 }, (_, index) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/app/practice/page.tsx b/src/app/practice/page.tsx
index cf4d8a5..2daf949 100644
--- a/src/app/practice/page.tsx
+++ b/src/app/practice/page.tsx
@@ -5,5 +5,5 @@ import { getLearningWorkspaceData } from "@/lib/learning-data";
export default async function PracticePage() {
const viewer = await requireViewer();
const data = await getLearningWorkspaceData(viewer, { kind: "practice" });
- return ;
+ return ;
}
diff --git a/src/components/account-settings.tsx b/src/components/account-settings.tsx
index 766f3f5..0158a22 100644
--- a/src/components/account-settings.tsx
+++ b/src/components/account-settings.tsx
@@ -1,7 +1,21 @@
"use client";
-import { useState } from "react";
-import { BrainCircuit, Globe2, Save, ShieldCheck, UserRound } from "lucide-react";
+import Image from "next/image";
+import { useRouter } from "next/navigation";
+import { ChangeEvent, useRef, useState } from "react";
+import {
+ BellRing,
+ BrainCircuit,
+ Camera,
+ Clock3,
+ Globe2,
+ Headphones,
+ Loader2,
+ Mail,
+ Save,
+ ShieldCheck,
+ UserRound,
+} from "lucide-react";
import { toast } from "sonner";
import type { Viewer } from "@/lib/auth";
import { localeNames, type Locale } from "@/lib/i18n";
@@ -27,6 +41,16 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useLocale } from "@/components/locale-provider";
+import { Switch } from "@/components/ui/switch";
+
+type Preferences = {
+ emailReminders: boolean;
+ dailyReminder: boolean;
+ weeklySummary: boolean;
+ autoPlayAudio: boolean;
+ showMascot: boolean;
+ compactMode: boolean;
+};
export function AccountSettings({
viewer,
@@ -37,14 +61,57 @@ export function AccountSettings({
learningGoal: string;
dailyGoalMinutes: number;
aiTrainingConsent: boolean;
+ preferences: Preferences;
};
}) {
+ const router = useRouter();
const { setLocale: setAppLocale } = useLocale();
const [fullName, setFullName] = useState(viewer.fullName);
const [locale, setLocale] = useState(viewer.locale as Locale);
const [goal, setGoal] = useState(initial.learningGoal);
const [minutes, setMinutes] = useState(initial.dailyGoalMinutes);
const [consent, setConsent] = useState(initial.aiTrainingConsent);
+ const [preferences, setPreferences] = useState(initial.preferences);
+ const [avatarUrl, setAvatarUrl] = useState(viewer.avatarUrl);
+ const [uploadingAvatar, setUploadingAvatar] = useState(false);
+ const fileInputRef = useRef(null);
+
+ function updatePreference(key: keyof Preferences, value: boolean) {
+ setPreferences((current) => ({ ...current, [key]: value }));
+ }
+
+ async function uploadAvatar(event: ChangeEvent) {
+ const file = event.target.files?.[0];
+ if (!file) return;
+ if (!["image/jpeg", "image/png", "image/webp", "image/gif"].includes(file.type)) {
+ toast.error("Chỉ hỗ trợ JPG, PNG, WebP hoặc GIF.");
+ return;
+ }
+ if (file.size > 5 * 1024 * 1024) {
+ toast.error("Ảnh đại diện không được vượt quá 5 MB.");
+ return;
+ }
+ const preview = URL.createObjectURL(file);
+ setAvatarUrl(preview);
+ setUploadingAvatar(true);
+ const formData = new FormData();
+ formData.set("avatar", file);
+ const response = await fetch("/api/profile/avatar", {
+ method: "POST",
+ body: formData,
+ });
+ const data = (await response.json()) as { avatarUrl?: string; error?: string };
+ setUploadingAvatar(false);
+ URL.revokeObjectURL(preview);
+ event.target.value = "";
+ if (!response.ok || !data.avatarUrl) {
+ setAvatarUrl(viewer.avatarUrl);
+ return toast.error(data.error ?? "Không thể tải ảnh đại diện.");
+ }
+ setAvatarUrl(data.avatarUrl);
+ toast.success("Đã cập nhật ảnh đại diện.");
+ router.refresh();
+ }
async function save() {
const response = await fetch("/api/profile", {
@@ -56,6 +123,7 @@ export function AccountSettings({
learningGoal: goal,
dailyGoalMinutes: minutes,
aiTrainingConsent: consent,
+ preferences,
}),
});
const data = (await response.json()) as { error?: string };
@@ -67,12 +135,13 @@ export function AccountSettings({
});
setAppLocale(locale);
toast.success("Đã lưu hồ sơ.");
+ router.refresh();
}
return (
-
-
-
+
+
+
Hồ sơ học tập
@@ -80,7 +149,54 @@ export function AccountSettings({
Lingora dùng mục tiêu và thời gian để điều chỉnh lộ trình.
-
+
+
+
+ {avatarUrl ? (
+
+ ) : (
+
+ {fullName.slice(0, 1).toUpperCase()}
+
+ )}
+ {uploadingAvatar ? (
+
+
+
+ ) : null}
+
+
+
Ảnh đại diện của bạn
+
+ JPG, PNG, WebP hoặc GIF. Kích thước tối đa 5 MB.
+
+
+
+
+
+
+
-
+
setMinutes(Number(event.target.value))}
/>
-
+
-
+
+
+
+ Nhắc học và nội dung
+
+
+ Chọn loại thông báo và cách phát nội dung trong quá trình học.
+
+
+
+ updatePreference("emailReminders", value)}
+ />
+ updatePreference("dailyReminder", value)}
+ />
+ updatePreference("weeklySummary", value)}
+ />
+ updatePreference("autoPlayAudio", value)}
+ />
+
+
+
+
Cải thiện Lingora AI
@@ -151,7 +311,7 @@ export function AccountSettings({
-
+
Hỗ trợ đa ngôn ngữ
@@ -166,3 +326,34 @@ export function AccountSettings({
);
}
+
+function PreferenceToggle({
+ icon: Icon,
+ title,
+ description,
+ checked,
+ onChange,
+}: {
+ icon: typeof Mail;
+ title: string;
+ description: string;
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/admin-content-studio.tsx b/src/components/admin-content-studio.tsx
index ebcafe3..718bf33 100644
--- a/src/components/admin-content-studio.tsx
+++ b/src/components/admin-content-studio.tsx
@@ -208,36 +208,50 @@ function CreatePathForm({ saving, onCreate }: { saving: boolean; onCreate: (valu
event.preventDefault();
const data = new FormData(event.currentTarget);
onCreate({
- slug: data.get("slug"), titleVi: data.get("titleVi"), titleEn: data.get("titleEn"),
- descriptionVi: data.get("descriptionVi"), targetLevel: data.get("targetLevel"),
+ slug: data.get("slug"), ...localizedForm(data, "title"), ...localizedForm(data, "description"),
+ targetLevel: data.get("targetLevel"),
estimatedHours: data.get("estimatedHours"), published: data.get("published") === "on",
});
}
- return
;
+ return
;
}
function CreateUnitForm({ paths, saving, onCreate }: { paths: StudioData["paths"]; saving: boolean; onCreate: (value: Record
) => void }) {
function submit(event: FormEvent) {
event.preventDefault(); const data = new FormData(event.currentTarget);
- onCreate({ pathId: data.get("pathId"), position: data.get("position"), titleVi: data.get("titleVi"), descriptionVi: data.get("descriptionVi"), skill: data.get("skill"), level: data.get("level"), estimatedMinutes: data.get("estimatedMinutes") });
+ onCreate({ pathId: data.get("pathId"), position: data.get("position"), ...localizedForm(data, "title"), ...localizedForm(data, "description"), skill: data.get("skill"), level: data.get("level"), estimatedMinutes: data.get("estimatedMinutes") });
}
- return ;
+ return ;
}
function CreateQuestionForm({ units, paths, saving, onCreate }: { units: StudioData["units"]; paths: StudioData["paths"]; saving: boolean; onCreate: (value: Record) => void }) {
function submit(event: FormEvent) {
event.preventDefault(); const data = new FormData(event.currentTarget);
- onCreate({ unitId: data.get("unitId"), promptVi: data.get("promptVi"), explanationVi: data.get("explanationVi"), passage: data.get("passage"), audioUrl: data.get("audioUrl"), skill: data.get("skill"), questionType: data.get("questionType"), difficulty: data.get("difficulty"), options: String(data.get("options") || "").split("\n").map((item) => item.trim()).filter(Boolean), answer: data.get("answer"), published: data.get("published") === "on" });
+ onCreate({ unitId: data.get("unitId"), ...localizedForm(data, "prompt"), ...localizedForm(data, "explanation"), passage: data.get("passage"), audioUrl: data.get("audioUrl"), skill: data.get("skill"), questionType: data.get("questionType"), difficulty: data.get("difficulty"), options: String(data.get("options") || "").split("\n").map((item) => item.trim()).filter(Boolean), answer: data.get("answer"), published: data.get("published") === "on" });
}
- return ;
+ return ;
}
function CreateChallengeForm({ saving, onCreate }: { saving: boolean; onCreate: (value: Record) => void }) {
function submit(event: FormEvent) {
event.preventDefault(); const data = new FormData(event.currentTarget);
- onCreate({ slug: data.get("slug"), titleVi: data.get("titleVi"), descriptionVi: data.get("descriptionVi"), challengeType: data.get("challengeType"), difficulty: data.get("difficulty"), eventType: data.get("eventType") || undefined, skill: data.get("skill") || undefined, target: data.get("target"), pointsReward: data.get("pointsReward"), tokenReward: data.get("tokenReward"), levelRequired: data.get("levelRequired"), badgeIcon: data.get("badgeIcon"), durationDays: data.get("durationDays"), published: data.get("published") === "on" });
+ onCreate({ slug: data.get("slug"), ...localizedForm(data, "title"), ...localizedForm(data, "description"), challengeType: data.get("challengeType"), difficulty: data.get("difficulty"), eventType: data.get("eventType") || undefined, skill: data.get("skill") || undefined, target: data.get("target"), pointsReward: data.get("pointsReward"), tokenReward: data.get("tokenReward"), levelRequired: data.get("levelRequired"), badgeIcon: data.get("badgeIcon"), durationDays: data.get("durationDays"), published: data.get("published") === "on" });
}
- return ;
+ return ;
+}
+
+function localizedForm(data: FormData, prefix: string) {
+ return {
+ [`${prefix}Vi`]: data.get(`${prefix}Vi`),
+ [`${prefix}En`]: data.get(`${prefix}En`),
+ [`${prefix}Ja`]: data.get(`${prefix}Ja`),
+ [`${prefix}Th`]: data.get(`${prefix}Th`),
+ };
+}
+
+function LocalizedFields({ prefix, label, required = false, multiline = false }: { prefix: string; label: string; required?: boolean; multiline?: boolean }) {
+ const Control = multiline ? Textarea : Input;
+ return
;
}
function ContentList({ title, empty, rows, onToggle, onDelete }: { title: string; empty: string; rows: Array<{ id: string; title: string; subtitle: string; published: boolean; table: string }>; onToggle: (table: string, id: string, published: boolean) => void; onDelete: (table: string, id: string, label: string) => void }) {
diff --git a/src/components/admin-shell.tsx b/src/components/admin-shell.tsx
index f322b7c..faea25b 100644
--- a/src/components/admin-shell.tsx
+++ b/src/components/admin-shell.tsx
@@ -15,7 +15,7 @@ import {
Users,
} from "lucide-react";
import type { Viewer } from "@/lib/auth";
-import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Sheet,
@@ -95,6 +95,9 @@ function Sidebar({ viewer }: { viewer: Viewer }) {
+ {viewer.avatarUrl ? (
+
+ ) : null}
{initials(viewer.fullName)}
diff --git a/src/components/ai-tutor.tsx b/src/components/ai-tutor.tsx
index e3ac73a..38371f4 100644
--- a/src/components/ai-tutor.tsx
+++ b/src/components/ai-tutor.tsx
@@ -2,7 +2,19 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import Link from "next/link";
-import { Bot, FileText, Send, Settings2, Sparkles, ThumbsDown, ThumbsUp, User } from "lucide-react";
+import {
+ Bot,
+ Check,
+ FileText,
+ RotateCcw,
+ Send,
+ Settings2,
+ Sparkles,
+ ThumbsDown,
+ ThumbsUp,
+ User,
+ WandSparkles,
+} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -26,12 +38,23 @@ import {
} from "@/components/ui/select";
import { useLocale } from "@/components/locale-provider";
import { useExperience } from "@/components/experience-provider";
+import { MascotSprite } from "@/components/lingora-mascot";
+import type { Viewer } from "@/lib/auth";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
type Message = { role: "user" | "assistant"; content: string };
+const quickPrompts = [
+ "Luyện hội thoại tiếng Anh hằng ngày",
+ "Sửa ngữ pháp câu tôi vừa viết",
+ "Cho tôi 5 từ vựng theo chủ đề",
+];
+
export function AiTutor({
+ viewer,
initial,
}: {
+ viewer: Viewer;
initial: {
documents: Array<{ id: string; file_name: string; status: string }>;
sessionId: string | null;
@@ -154,87 +177,150 @@ export function AiTutor({
}
}
+ function resetConversation() {
+ setMessages([welcomeMessage]);
+ setSessionId(undefined);
+ setLastError("");
+ setUpgradeRequired(false);
+ setInput("");
+ play("tap");
+ }
+
return (
-
-
-
+
+
+
+
-
-
-
- {t("tutor.title")}
-
-
- {t("tutor.subtitle")}
-
+
+
+
+
+
+
+
+ {t("tutor.title")}
+
+
+ Đang trực tuyến
+ ·
+ {t("tutor.subtitle")}
+
+
+
+
+
+
+ {t("tutor.autoModel")}
+
+
-
-
- {t("tutor.autoModel")}
-
{messages.map((message, index) => (
{message.role === "assistant" ? (
-
-
+
+
) : null}
-
- {!initial.messages.length &&
- index === 0 &&
- message.role === "assistant"
- ? t("tutor.welcome")
- : message.content}
- {message.role === "assistant" && index > 0 ? (
-
-
-
-
- ) : null}
+
+
+ {!initial.messages.length &&
+ index === 0 &&
+ message.role === "assistant"
+ ? t("tutor.welcome")
+ : message.content}
+ {message.role === "assistant" && index > 0 ? (
+
+ Hữu ích?
+
+
+
+ ) : null}
+
+
+ {message.role === "user" ? "Bạn" : "Lumo AI"}
+
{message.role === "user" ? (
-
-
-
+
+ {viewer.avatarUrl ? (
+
+ ) : null}
+
+
+
+
) : null}
))}
+ {messages.length === 1 && !loading ? (
+
+ {quickPrompts.map((prompt) => (
+
+ ))}
+
+ ) : null}
{loading ? (
-
-
-
+
+
+
- {t("tutor.thinking")}
+
+
+
+
+ {t("tutor.thinking")}
+
) : null}
{lastError ? (
@@ -254,14 +340,15 @@ export function AiTutor({