phase 2
This commit is contained in:
10
.env.example
10
.env.example
@@ -1,2 +1,10 @@
|
|||||||
|
# Supabase — https://supabase.com/dashboard/project/_/settings/api
|
||||||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||||
VITE_SUPABASE_ANON_KEY=your-anon-key-here
|
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
|
||||||
|
# Alternative key name (both are supported)
|
||||||
|
# VITE_SUPABASE_ANON_KEY=eyJ...
|
||||||
|
|
||||||
|
# GLM API — https://open.bigmodel.cn/usercenter/apikeys
|
||||||
|
# Used by the writing-check Supabase Edge Function (server-side only, never expose in frontend)
|
||||||
|
# Deploy to Supabase with: supabase secrets set GLM_API_KEY=<your_key>
|
||||||
|
GLM_API_KEY=your_glm_api_key_here
|
||||||
|
|||||||
258
.tanstack/tmp/7df6e328-a904454bbfcd9697e978020268697fc8
Normal file
258
.tanstack/tmp/7df6e328-a904454bbfcd9697e978020268697fc8
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
|
||||||
|
// This file was automatically generated by TanStack Router.
|
||||||
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as WritingRouteImport } from './routes/writing'
|
||||||
|
import { Route as VocabRouteImport } from './routes/vocab'
|
||||||
|
import { Route as ToeicRouteImport } from './routes/toeic'
|
||||||
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as ToeicIndexRouteImport } from './routes/toeic.index'
|
||||||
|
import { Route as ToeicSessionRouteImport } from './routes/toeic.session'
|
||||||
|
import { Route as ToeicResultRouteImport } from './routes/toeic.result'
|
||||||
|
import { Route as AuthRegisterRouteImport } from './routes/auth.register'
|
||||||
|
import { Route as AuthLoginRouteImport } from './routes/auth.login'
|
||||||
|
import { Route as ToeicPartPartIdRouteImport } from './routes/toeic.part.$partId'
|
||||||
|
|
||||||
|
const WritingRoute = WritingRouteImport.update({
|
||||||
|
id: '/writing',
|
||||||
|
path: '/writing',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const VocabRoute = VocabRouteImport.update({
|
||||||
|
id: '/vocab',
|
||||||
|
path: '/vocab',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ToeicRoute = ToeicRouteImport.update({
|
||||||
|
id: '/toeic',
|
||||||
|
path: '/toeic',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ToeicIndexRoute = ToeicIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => ToeicRoute,
|
||||||
|
} as any)
|
||||||
|
const ToeicSessionRoute = ToeicSessionRouteImport.update({
|
||||||
|
id: '/session',
|
||||||
|
path: '/session',
|
||||||
|
getParentRoute: () => ToeicRoute,
|
||||||
|
} as any)
|
||||||
|
const ToeicResultRoute = ToeicResultRouteImport.update({
|
||||||
|
id: '/result',
|
||||||
|
path: '/result',
|
||||||
|
getParentRoute: () => ToeicRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthRegisterRoute = AuthRegisterRouteImport.update({
|
||||||
|
id: '/auth/register',
|
||||||
|
path: '/auth/register',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const AuthLoginRoute = AuthLoginRouteImport.update({
|
||||||
|
id: '/auth/login',
|
||||||
|
path: '/auth/login',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ToeicPartPartIdRoute = ToeicPartPartIdRouteImport.update({
|
||||||
|
id: '/part/$partId',
|
||||||
|
path: '/part/$partId',
|
||||||
|
getParentRoute: () => ToeicRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/toeic': typeof ToeicRouteWithChildren
|
||||||
|
'/vocab': typeof VocabRoute
|
||||||
|
'/writing': typeof WritingRoute
|
||||||
|
'/auth/login': typeof AuthLoginRoute
|
||||||
|
'/auth/register': typeof AuthRegisterRoute
|
||||||
|
'/toeic/result': typeof ToeicResultRoute
|
||||||
|
'/toeic/session': typeof ToeicSessionRoute
|
||||||
|
'/toeic/': typeof ToeicIndexRoute
|
||||||
|
'/toeic/part/$partId': typeof ToeicPartPartIdRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/vocab': typeof VocabRoute
|
||||||
|
'/writing': typeof WritingRoute
|
||||||
|
'/auth/login': typeof AuthLoginRoute
|
||||||
|
'/auth/register': typeof AuthRegisterRoute
|
||||||
|
'/toeic/result': typeof ToeicResultRoute
|
||||||
|
'/toeic/session': typeof ToeicSessionRoute
|
||||||
|
'/toeic': typeof ToeicIndexRoute
|
||||||
|
'/toeic/part/$partId': typeof ToeicPartPartIdRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/toeic': typeof ToeicRouteWithChildren
|
||||||
|
'/vocab': typeof VocabRoute
|
||||||
|
'/writing': typeof WritingRoute
|
||||||
|
'/auth/login': typeof AuthLoginRoute
|
||||||
|
'/auth/register': typeof AuthRegisterRoute
|
||||||
|
'/toeic/result': typeof ToeicResultRoute
|
||||||
|
'/toeic/session': typeof ToeicSessionRoute
|
||||||
|
'/toeic/': typeof ToeicIndexRoute
|
||||||
|
'/toeic/part/$partId': typeof ToeicPartPartIdRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/toeic'
|
||||||
|
| '/vocab'
|
||||||
|
| '/writing'
|
||||||
|
| '/auth/login'
|
||||||
|
| '/auth/register'
|
||||||
|
| '/toeic/result'
|
||||||
|
| '/toeic/session'
|
||||||
|
| '/toeic/'
|
||||||
|
| '/toeic/part/$partId'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to:
|
||||||
|
| '/'
|
||||||
|
| '/vocab'
|
||||||
|
| '/writing'
|
||||||
|
| '/auth/login'
|
||||||
|
| '/auth/register'
|
||||||
|
| '/toeic/result'
|
||||||
|
| '/toeic/session'
|
||||||
|
| '/toeic'
|
||||||
|
| '/toeic/part/$partId'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/toeic'
|
||||||
|
| '/vocab'
|
||||||
|
| '/writing'
|
||||||
|
| '/auth/login'
|
||||||
|
| '/auth/register'
|
||||||
|
| '/toeic/result'
|
||||||
|
| '/toeic/session'
|
||||||
|
| '/toeic/'
|
||||||
|
| '/toeic/part/$partId'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
IndexRoute: typeof IndexRoute
|
||||||
|
ToeicRoute: typeof ToeicRouteWithChildren
|
||||||
|
VocabRoute: typeof VocabRoute
|
||||||
|
WritingRoute: typeof WritingRoute
|
||||||
|
AuthLoginRoute: typeof AuthLoginRoute
|
||||||
|
AuthRegisterRoute: typeof AuthRegisterRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
'/writing': {
|
||||||
|
id: '/writing'
|
||||||
|
path: '/writing'
|
||||||
|
fullPath: '/writing'
|
||||||
|
preLoaderRoute: typeof WritingRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/vocab': {
|
||||||
|
id: '/vocab'
|
||||||
|
path: '/vocab'
|
||||||
|
fullPath: '/vocab'
|
||||||
|
preLoaderRoute: typeof VocabRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/toeic': {
|
||||||
|
id: '/toeic'
|
||||||
|
path: '/toeic'
|
||||||
|
fullPath: '/toeic'
|
||||||
|
preLoaderRoute: typeof ToeicRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/': {
|
||||||
|
id: '/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/toeic/': {
|
||||||
|
id: '/toeic/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/toeic/'
|
||||||
|
preLoaderRoute: typeof ToeicIndexRouteImport
|
||||||
|
parentRoute: typeof ToeicRoute
|
||||||
|
}
|
||||||
|
'/toeic/session': {
|
||||||
|
id: '/toeic/session'
|
||||||
|
path: '/session'
|
||||||
|
fullPath: '/toeic/session'
|
||||||
|
preLoaderRoute: typeof ToeicSessionRouteImport
|
||||||
|
parentRoute: typeof ToeicRoute
|
||||||
|
}
|
||||||
|
'/toeic/result': {
|
||||||
|
id: '/toeic/result'
|
||||||
|
path: '/result'
|
||||||
|
fullPath: '/toeic/result'
|
||||||
|
preLoaderRoute: typeof ToeicResultRouteImport
|
||||||
|
parentRoute: typeof ToeicRoute
|
||||||
|
}
|
||||||
|
'/auth/register': {
|
||||||
|
id: '/auth/register'
|
||||||
|
path: '/auth/register'
|
||||||
|
fullPath: '/auth/register'
|
||||||
|
preLoaderRoute: typeof AuthRegisterRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/auth/login': {
|
||||||
|
id: '/auth/login'
|
||||||
|
path: '/auth/login'
|
||||||
|
fullPath: '/auth/login'
|
||||||
|
preLoaderRoute: typeof AuthLoginRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/toeic/part/$partId': {
|
||||||
|
id: '/toeic/part/$partId'
|
||||||
|
path: '/part/$partId'
|
||||||
|
fullPath: '/toeic/part/$partId'
|
||||||
|
preLoaderRoute: typeof ToeicPartPartIdRouteImport
|
||||||
|
parentRoute: typeof ToeicRoute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToeicRouteChildren {
|
||||||
|
ToeicResultRoute: typeof ToeicResultRoute
|
||||||
|
ToeicSessionRoute: typeof ToeicSessionRoute
|
||||||
|
ToeicIndexRoute: typeof ToeicIndexRoute
|
||||||
|
ToeicPartPartIdRoute: typeof ToeicPartPartIdRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToeicRouteChildren: ToeicRouteChildren = {
|
||||||
|
ToeicResultRoute: ToeicResultRoute,
|
||||||
|
ToeicSessionRoute: ToeicSessionRoute,
|
||||||
|
ToeicIndexRoute: ToeicIndexRoute,
|
||||||
|
ToeicPartPartIdRoute: ToeicPartPartIdRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToeicRouteWithChildren = ToeicRoute._addFileChildren(ToeicRouteChildren)
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
ToeicRoute: ToeicRouteWithChildren,
|
||||||
|
VocabRoute: VocabRoute,
|
||||||
|
WritingRoute: WritingRoute,
|
||||||
|
AuthLoginRoute: AuthLoginRoute,
|
||||||
|
AuthRegisterRoute: AuthRegisterRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
458
Claude.md
458
Claude.md
@@ -1,7 +1,8 @@
|
|||||||
# Claude Project Context — English Learning App (TOEIC Focus)
|
# Claude Project Context — English Learning App (TOEIC Focus)
|
||||||
|
|
||||||
> File này dùng để cung cấp context đầy đủ cho Claude khi làm việc với dự án.
|
> File này cung cấp context đầy đủ cho Claude khi làm việc với dự án.
|
||||||
> Cập nhật file này mỗi khi có quyết định kiến trúc mới.
|
> Cập nhật file này mỗi khi có quyết định kiến trúc mới.
|
||||||
|
> **Last updated**: Merged all decisions — Phase 1 scaffold done, Phase 2–4 planned
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,15 +12,7 @@
|
|||||||
**Target users**: Sinh viên, người đi làm tại Việt Nam cần TOEIC, IELTS, hoặc học tiếng Anh tổng quát
|
**Target users**: Sinh viên, người đi làm tại Việt Nam cần TOEIC, IELTS, hoặc học tiếng Anh tổng quát
|
||||||
**Focus chính**: TOEIC (mở rộng market), sau đó IELTS
|
**Focus chính**: TOEIC (mở rộng market), sau đó IELTS
|
||||||
**Giai đoạn hiện tại**: Phase 1 — MVP, validate market
|
**Giai đoạn hiện tại**: Phase 1 — MVP, validate market
|
||||||
|
**Roadmap**: 4 phases — MVP → Auth & Progress → Speaking AI → Full TOEIC
|
||||||
---
|
|
||||||
|
|
||||||
## Mục tiêu Phase 1
|
|
||||||
|
|
||||||
- Ra sản phẩm web dùng thử được, **không cần đăng nhập**
|
|
||||||
- Validate 2 tính năng cốt lõi: luyện đề TOEIC + AI writing checker
|
|
||||||
- Test xem có người dùng thật, có traction không
|
|
||||||
- **Không over-engineer** — Supabase tạm thời, đổi sau nếu có traction
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,59 +21,66 @@
|
|||||||
### Frontend
|
### Frontend
|
||||||
| Layer | Tech | Ghi chú |
|
| Layer | Tech | Ghi chú |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Framework | **React** (Vite) | |
|
| Framework | **React** + **Vite** + **TypeScript** | |
|
||||||
| Routing | **TanStack Router** | Type-safe routing |
|
| Routing | **TanStack Router** | File-based, type-safe |
|
||||||
| Server state | **TanStack Query** | Fetch, cache, sync API data |
|
| Server state | **TanStack Query** | Fetch, cache, sync API data |
|
||||||
| Client state | **Zustand** | UI state, localStorage sync |
|
| Client state | **Zustand** | UI state + localStorage persist |
|
||||||
| Styling | **Tailwind CSS** | Mobile-first |
|
| Styling | **Tailwind CSS** | Desktop-first |
|
||||||
| UI Components | **shadcn/ui** | Dùng khi cần, không bắt buộc |
|
| UI Components | **shadcn/ui** | Dùng khi cần, không bắt buộc |
|
||||||
|
|
||||||
### Backend (Phase 1 — tạm thời)
|
### Design System (từ Stitch export)
|
||||||
| Layer | Tech | Ghi chú |
|
| Token | Value |
|
||||||
|---|---|---|
|
|
||||||
| Database | **Supabase** (PostgreSQL managed) | Free tier, migrate sau |
|
|
||||||
| API | **Supabase JS SDK** | Gọi thẳng từ React |
|
|
||||||
| Server functions | **Supabase Edge Functions** (Deno) | Xử lý AI API call, giấu key |
|
|
||||||
|
|
||||||
> ⚠️ **Supabase chỉ dùng Phase 1** để ra sản phẩm nhanh.
|
|
||||||
> Phase 2 migrate sang **NestJS + PostgreSQL native** khi có traction.
|
|
||||||
> Schema PostgreSQL thiết kế chuẩn ngay từ đầu để migrate không đau.
|
|
||||||
|
|
||||||
### Backend (Phase 2 — kế hoạch)
|
|
||||||
| Layer | Tech |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| Framework | **NestJS** |
|
| Font | Plus Jakarta Sans + Material Symbols Outlined |
|
||||||
| ORM | **Prisma** hoặc **TypeORM** |
|
| Primary | #2563EB |
|
||||||
| Database | **PostgreSQL** (self-hosted) |
|
| Success | #16A34A |
|
||||||
| Auth | **JWT** + Google OAuth + Zalo OAuth |
|
| Danger | #DC2626 |
|
||||||
| Mobile | **Flutter** (iOS + Android) |
|
| Background | #F8FAFC |
|
||||||
|
| Card | #FFFFFF |
|
||||||
|
| Border radius | 12–16px |
|
||||||
|
| Shadow | soft, subtle |
|
||||||
|
|
||||||
|
### Responsive Layout
|
||||||
|
| Breakpoint | Layout |
|
||||||
|
|---|---|
|
||||||
|
| Desktop (1280px) | Sidebar trái cố định (240px) + main content — **LAYOUT CHÍNH** |
|
||||||
|
| Tablet (768px) | Sidebar thu gọn icon-only |
|
||||||
|
| Mobile (375px) | Ẩn sidebar, hiện bottom navigation bar |
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
| Phase | Tech | Ghi chú |
|
||||||
|
|---|---|---|
|
||||||
|
| Phase 1 | **Supabase** (PostgreSQL + Edge Functions + JS SDK) | Tạm thời, migrate sau |
|
||||||
|
| Phase 2+ | **NestJS** + **PostgreSQL** native | Khi có traction |
|
||||||
|
|
||||||
|
> ⚠️ Supabase chỉ dùng Phase 1. Schema PostgreSQL thiết kế chuẩn ngay từ đầu để migrate không đau.
|
||||||
|
|
||||||
### AI
|
### AI
|
||||||
| Layer | Tech | Ghi chú |
|
| Layer | Tech | Ghi chú |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Provider | **GLM (Z.ai API)** — `open.bigmodel.cn` | Rẻ, OpenAI-compatible |
|
| Provider | **GLM (Z.ai API)** | Rẻ, OpenAI-compatible format |
|
||||||
| Model | **GLM-4** hoặc **GLM-4.7** | Test chất lượng chấm writing |
|
| Endpoint | `open.bigmodel.cn/api/paas/v4` | |
|
||||||
| Fallback | OpenAI / Claude API | Nếu GLM không đủ chất lượng |
|
| Model | GLM-4 / GLM-4.7 | |
|
||||||
|
| Fallback | OpenAI / Claude API | Swap dễ vì API compatible |
|
||||||
> GLM API tương thích OpenAI format → swap provider không cần đổi code.
|
| Gọi từ | Supabase Edge Function (Phase 1) → NestJS service (Phase 2+) | Giấu API key |
|
||||||
|
|
||||||
### Deploy
|
### Deploy
|
||||||
| Layer | Tech |
|
| Layer | Tech |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Frontend | **Self-hosted server** (có sẵn) |
|
| Frontend | Self-hosted server (có sẵn) |
|
||||||
| Backend | **Self-hosted server** (có sẵn) |
|
| Backend | Self-hosted server (có sẵn) |
|
||||||
| Database | Supabase Cloud (Phase 1) → self-hosted PostgreSQL (Phase 2) |
|
| Database | Supabase Cloud (Phase 1) → self-hosted PostgreSQL (Phase 2+) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Database Schema (PostgreSQL — Phase 1)
|
## Database Schema (PostgreSQL)
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Câu hỏi TOEIC
|
-- Câu hỏi TOEIC
|
||||||
CREATE TABLE questions (
|
CREATE TABLE questions (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
part INT NOT NULL, -- 1 đến 7
|
part INT NOT NULL, -- 1 đến 7
|
||||||
type TEXT, -- photo, q&a, incomplete_sentence, etc.
|
type TEXT, -- photo | q&a | incomplete_sentence | etc.
|
||||||
content TEXT NOT NULL, -- nội dung câu hỏi / đoạn văn
|
content TEXT NOT NULL, -- nội dung câu hỏi / đoạn văn
|
||||||
options JSONB, -- ["A. ...", "B. ...", "C. ...", "D. ..."]
|
options JSONB, -- ["A. ...", "B. ...", "C. ...", "D. ..."]
|
||||||
answer TEXT NOT NULL, -- "A"
|
answer TEXT NOT NULL, -- "A"
|
||||||
@@ -101,158 +101,286 @@ CREATE TABLE vocab (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Phase 2 sẽ thêm: users, user_progress, writing_submissions, test_sessions
|
-- Phase 2+
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL, -- hashed
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_progress (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id),
|
||||||
|
type TEXT, -- test | vocab | writing
|
||||||
|
reference_id UUID,
|
||||||
|
data JSONB,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE writing_submissions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
feedback JSONB,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Design
|
## TypeScript Interfaces
|
||||||
|
|
||||||
### Supabase SDK (Phase 1)
|
|
||||||
```typescript
|
```typescript
|
||||||
// Lấy câu hỏi theo Part
|
interface Question {
|
||||||
supabase.from('questions').select('*').eq('part', 1).limit(10)
|
id: string
|
||||||
|
part: number
|
||||||
|
type: string
|
||||||
|
content: string
|
||||||
|
options: string[]
|
||||||
|
answer: string
|
||||||
|
explanation: string
|
||||||
|
audioUrl?: string
|
||||||
|
imageUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
// Lấy từ vựng theo chủ đề
|
interface VocabWord {
|
||||||
supabase.from('vocab').select('*').eq('topic', 'business')
|
id: string
|
||||||
```
|
word: string
|
||||||
|
phonetic: string
|
||||||
|
meaningVi: string
|
||||||
|
topic: 'business' | 'office' | 'travel' | 'finance' | 'hr' | 'marketing'
|
||||||
|
example: string
|
||||||
|
}
|
||||||
|
|
||||||
### Supabase Edge Functions
|
interface TestResult {
|
||||||
```
|
testId: string
|
||||||
POST /functions/v1/writing-check
|
part: number
|
||||||
Body : { content: string }
|
score: number
|
||||||
Return : {
|
total: number
|
||||||
score: string, // band score ước tính
|
duration: number
|
||||||
grammar: string[], // lỗi ngữ pháp + gợi ý sửa
|
answers: { questionId: string; selected: string; correct: boolean }[]
|
||||||
vocabulary: string[], // nhận xét từ vựng
|
completedAt: Date
|
||||||
structure: string, // nhận xét bố cục (tiếng Việt)
|
}
|
||||||
improved_version: string, // bài viết lại tốt hơn
|
|
||||||
summary: string // tổng nhận xét (tiếng Việt)
|
interface WritingFeedback {
|
||||||
|
score: string
|
||||||
|
grammar: string[]
|
||||||
|
vocabulary: string[]
|
||||||
|
structure: string
|
||||||
|
improvedVersion: string
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2+
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
createdAt: Date
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Cấu trúc thư mục (React)
|
## Cấu trúc thư mục
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── pages/
|
├── routes/
|
||||||
│ ├── Home.tsx ← Landing page + CTA
|
│ ├── index.tsx ← Trang chủ (/)
|
||||||
│ ├── ToeicPractice.tsx ← Chọn Part để luyện
|
│ ├── toeic/
|
||||||
│ ├── TestSession.tsx ← Làm bài (timer + câu hỏi)
|
│ │ ├── index.tsx ← Chọn Part (/toeic)
|
||||||
│ ├── TestResult.tsx ← Kết quả + giải thích đáp án
|
│ │ ├── part.$partId.tsx ← Config số câu (/toeic/part/$partId)
|
||||||
│ ├── WritingChecker.tsx ← AI Writing Checker
|
│ │ ├── session.tsx ← Làm bài (/toeic/session)
|
||||||
│ └── Vocabulary.tsx ← Flashcard từ vựng
|
│ │ └── result.tsx ← Kết quả + đáp án (/toeic/result)
|
||||||
|
│ ├── writing.tsx ← AI Writing Checker (/writing)
|
||||||
|
│ ├── vocab.tsx ← Flashcard (/vocab)
|
||||||
|
│ └── auth/ ← Phase 2
|
||||||
|
│ ├── login.tsx ← Đăng nhập (/auth/login)
|
||||||
|
│ └── register.tsx ← Đăng ký (/auth/register)
|
||||||
├── components/
|
├── components/
|
||||||
|
│ ├── layout/
|
||||||
|
│ │ ├── Sidebar.tsx ← Desktop sidebar
|
||||||
|
│ │ └── BottomNav.tsx ← Mobile bottom nav
|
||||||
│ ├── QuestionCard.tsx
|
│ ├── QuestionCard.tsx
|
||||||
│ ├── FlashCard.tsx
|
│ ├── FlashCard.tsx
|
||||||
│ ├── WritingFeedback.tsx
|
│ ├── WritingFeedback.tsx
|
||||||
│ ├── ProgressBar.tsx
|
│ ├── ProgressRing.tsx
|
||||||
│ └── Timer.tsx
|
│ └── Timer.tsx
|
||||||
├── hooks/
|
|
||||||
│ ├── useQuestions.ts ← TanStack Query: fetch questions
|
|
||||||
│ ├── useVocab.ts ← TanStack Query: fetch vocab
|
|
||||||
│ └── useWritingCheck.ts ← TanStack Mutation: gọi Edge Function
|
|
||||||
├── store/
|
├── store/
|
||||||
│ ├── testStore.ts ← Zustand: trạng thái bài thi
|
│ ├── testStore.ts ← Zustand: trạng thái bài thi
|
||||||
│ └── vocabStore.ts ← Zustand: progress flashcard (sync localStorage)
|
│ ├── vocabStore.ts ← Zustand: flashcard progress (persist localStorage)
|
||||||
|
│ └── authStore.ts ← Zustand: user session (Phase 2)
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useQuestions.ts ← TanStack Query
|
||||||
|
│ ├── useVocab.ts ← TanStack Query
|
||||||
|
│ └── useWritingCheck.ts ← TanStack Mutation → Edge Function
|
||||||
├── lib/
|
├── lib/
|
||||||
│ └── supabase.ts ← Supabase client init
|
│ └── supabase.ts ← Supabase client init
|
||||||
└── utils/
|
└── utils/
|
||||||
└── rateLimiter.ts ← Rate limit AI Writing (3 lần/ngày/IP, localStorage)
|
└── rateLimiter.ts ← Rate limit 3 lần/ngày/IP (localStorage)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Routes (TanStack Router)
|
## Supabase Edge Function — AI Writing Checker
|
||||||
|
|
||||||
```
|
```typescript
|
||||||
/ ← Landing page
|
// supabase/functions/writing-check/index.ts
|
||||||
/toeic ← Chọn Part (1–7)
|
import { serve } from "https://deno.land/std/http/server.ts"
|
||||||
/toeic/part/$partId ← Config bài thi (số câu)
|
|
||||||
/toeic/session ← Làm bài
|
serve(async (req) => {
|
||||||
/toeic/result ← Kết quả + đáp án
|
const { content } = await req.json()
|
||||||
/writing ← AI Writing Checker
|
|
||||||
/vocab ← Flashcard (filter theo topic)
|
const response = await fetch("https://open.bigmodel.cn/api/paas/v4/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${Deno.env.get("GLM_API_KEY")}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "glm-4",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `You are an expert English writing evaluator for TOEIC/IELTS.
|
||||||
|
Evaluate the writing and return ONLY valid JSON:
|
||||||
|
{
|
||||||
|
"score": "estimated band score",
|
||||||
|
"grammar": ["error + fix"],
|
||||||
|
"vocabulary": ["suggestion"],
|
||||||
|
"structure": "feedback in Vietnamese",
|
||||||
|
"improved_version": "rewritten version",
|
||||||
|
"summary": "overall feedback in Vietnamese"
|
||||||
|
}`
|
||||||
|
},
|
||||||
|
{ role: "user", content }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const result = JSON.parse(data.choices[0].message.content)
|
||||||
|
return new Response(JSON.stringify(result), {
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
})
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tính năng Phase 1 (đã chốt)
|
## Roadmap — 4 Phases
|
||||||
|
|
||||||
### ✅ 1. Luyện đề TOEIC
|
|
||||||
- Mini test theo từng Part (Part 1 → Part 7)
|
|
||||||
- Chọn số câu: 10 / 20 / full part
|
|
||||||
- Làm bài có đếm giờ
|
|
||||||
- Submit → xem điểm + đáp án đúng/sai + giải thích tiếng Việt
|
|
||||||
- Lịch sử kết quả lưu **localStorage** (Zustand persist)
|
|
||||||
- Thống kê điểm yếu theo Part
|
|
||||||
|
|
||||||
### ✅ 2. AI Writing Checker (Core Differentiator)
|
|
||||||
- Nhập bài writing tự do (TOEIC email, IELTS task, general)
|
|
||||||
- Gọi GLM qua Supabase Edge Function (API key an toàn)
|
|
||||||
- Feedback JSON: band score, lỗi ngữ pháp, từ vựng, cấu trúc, bài mẫu
|
|
||||||
- Rate limit: **3 lần / ngày / IP** (không cần login)
|
|
||||||
- Hiển thị feedback có highlight, dễ đọc trên mobile
|
|
||||||
|
|
||||||
### ✅ 3. Flashcard Từ vựng TOEIC
|
|
||||||
- 6 chủ đề: Business, Office, Travel, Finance, HR, Marketing
|
|
||||||
- Mỗi card: từ + phiên âm + nghĩa Việt + câu ví dụ
|
|
||||||
- Flip card animation
|
|
||||||
- Đánh dấu: Đã thuộc / Cần ôn
|
|
||||||
- Progress lưu localStorage (Zustand persist)
|
|
||||||
- Filter theo chủ đề
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tính năng KHÔNG có ở Phase 1
|
### PHASE 1 — MVP (Hiện tại) 🚧
|
||||||
|
|
||||||
| Tính năng | Khi nào có |
|
**Mục tiêu**: Ra sản phẩm web dùng thử không cần login, validate market
|
||||||
|---|---|
|
|
||||||
| Đăng nhập / Auth | Phase 2 |
|
**Stack**: React + Vite + TypeScript + TanStack + Zustand + Tailwind + Supabase + GLM
|
||||||
| Progress sync server | Phase 2 |
|
|
||||||
| Flutter mobile app | Phase 2 |
|
**Tính năng**:
|
||||||
| NestJS backend | Phase 2 |
|
- ✅ Luyện đề TOEIC mini test theo từng Part (Part 1–7)
|
||||||
| Full TOEIC mock test | Phase 2 |
|
- ✅ Chọn số câu: 10 / 20 / full part, có đếm giờ
|
||||||
| Thanh toán | Phase 2 |
|
- ✅ Submit → xem điểm + đáp án + giải thích tiếng Việt
|
||||||
| Speaking / Pronunciation AI | Phase 3+ |
|
- ✅ Lịch sử kết quả + thống kê điểm yếu theo Part (localStorage)
|
||||||
| Cộng đồng / Forum | Phase 3+ |
|
- ✅ AI Writing Checker (GLM, 3 lần/ngày/IP, không cần login)
|
||||||
| Lớp học / Giáo viên | Phase 3+ |
|
- ✅ Flashcard từ vựng TOEIC (6 chủ đề, localStorage progress)
|
||||||
|
|
||||||
|
**Không có**:
|
||||||
|
- ❌ Auth / login
|
||||||
|
- ❌ Progress sync server
|
||||||
|
- ❌ Thanh toán
|
||||||
|
- ❌ Flutter / mobile app
|
||||||
|
- ❌ Full mock test
|
||||||
|
|
||||||
|
**Timeline**: 5 tuần
|
||||||
|
|
||||||
|
**Done khi**:
|
||||||
|
- ≥ 50 câu hỏi mỗi Part (Part 1–7)
|
||||||
|
- AI Writing Checker phản hồi < 5 giây
|
||||||
|
- Không lỗi hiển thị trên Chrome mobile
|
||||||
|
- 20+ người dùng thật đã dùng thử
|
||||||
|
- Không critical bug sau 1 tuần beta
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Timeline Phase 1 (5 tuần)
|
### PHASE 2 — Auth & Progress
|
||||||
|
|
||||||
| Tuần | Công việc |
|
**Mục tiêu**: Giữ chân user, sync progress server-side, hiểu behavior trước khi monetize
|
||||||
|---|---|
|
|
||||||
| **1** | Setup Supabase schema + seed đề TOEIC (≥50 câu/part) + React + Vite + Tailwind + TanStack + Zustand |
|
**Trigger**: Phase 1 có traction — 200+ MAU hoặc feedback tích cực
|
||||||
| **2** | UI luyện đề: chọn Part → làm bài → kết quả + giải thích |
|
|
||||||
| **3** | Supabase Edge Function + GLM API → AI Writing Checker + rate limit |
|
**Stack thay đổi**:
|
||||||
| **4** | Flashcard UI + Zustand persist + mobile polish + landing page |
|
- Migrate Supabase → **NestJS + PostgreSQL native**
|
||||||
| **5** | Bug fix + test mobile thật + deploy lên server + beta ~20 người |
|
- Thêm **Redis** cho cache + session
|
||||||
|
|
||||||
|
**Guest Access (chưa đăng ký)**:
|
||||||
|
- Xem preview 1 bài test dạng read-only (thấy câu hỏi, không làm được)
|
||||||
|
- Không cho submit đáp án, không xem kết quả
|
||||||
|
- Hiện modal "Đăng ký để luyện thi" khi cố tương tác
|
||||||
|
- Flashcard: xem vài card đầu, bị chặn sau đó
|
||||||
|
- AI Writing: không dùng được, hiện CTA đăng ký
|
||||||
|
|
||||||
|
**Auth — Đăng ký**:
|
||||||
|
- Form: **Tên + Email + Password** (chỉ 3 field)
|
||||||
|
- Không xác thực email, không OTP, không confirm
|
||||||
|
- Đăng ký xong → **redirect home luôn**
|
||||||
|
|
||||||
|
**Auth — Đăng nhập**:
|
||||||
|
- Email + Password
|
||||||
|
- Redirect về trang trước hoặc home
|
||||||
|
|
||||||
|
**Sau khi đăng nhập**:
|
||||||
|
- Làm bài không giới hạn
|
||||||
|
- Progress sync server-side: lịch sử thi, từ vựng, writing submissions
|
||||||
|
- Dashboard cá nhân: streak, biểu đồ điểm, điểm yếu theo Part
|
||||||
|
- AI Writing: 10 lần/ngày
|
||||||
|
|
||||||
|
**Không có ở Phase 2**:
|
||||||
|
- ❌ Xác thực email
|
||||||
|
- ❌ Quên mật khẩu
|
||||||
|
- ❌ Google / Zalo OAuth
|
||||||
|
- ❌ Flutter / mobile app
|
||||||
|
- ❌ Thanh toán / freemium
|
||||||
|
- ❌ Notification
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Definition of Done — Phase 1
|
### PHASE 3 — Speaking AI
|
||||||
|
|
||||||
- [ ] ≥ 50 câu hỏi mỗi Part (Part 1–7)
|
**Mục tiêu**: Tăng differentiation, cover kỹ năng Speaking cho IELTS/TOEIC
|
||||||
- [ ] AI Writing Checker phản hồi < 5 giây
|
|
||||||
- [ ] Không lỗi hiển thị trên Chrome mobile
|
**Trigger**: Phase 2 ổn định, user quay lại đều đặn
|
||||||
- [ ] 20+ người dùng thật đã dùng thử
|
|
||||||
- [ ] Không có critical bug sau 1 tuần beta
|
**Tính năng**:
|
||||||
|
- AI Speaking Coach: record giọng → AI chấm phát âm + so sánh native speaker
|
||||||
|
- Luyện IELTS Speaking Part 1/2/3 (mock interview với AI)
|
||||||
|
- Shadow reading: nghe → lặp lại → AI so sánh độ chính xác
|
||||||
|
- Pronunciation scoring có điểm số và progress chart
|
||||||
|
|
||||||
|
**Tech mới**:
|
||||||
|
- Speech-to-text: Whisper API hoặc Google Speech-to-Text
|
||||||
|
- Text-to-speech: native audio
|
||||||
|
- WebRTC / MediaRecorder API (web)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Rủi ro đã nhận diện
|
### PHASE 4 — Full TOEIC Mock Test
|
||||||
|
|
||||||
| Rủi ro | Mức độ | Xử lý |
|
**Mục tiêu**: Platform luyện TOEIC toàn diện chuẩn ETS
|
||||||
|---|---|---|
|
|
||||||
| Content đề TOEIC chất lượng thấp (crawl) | 🔴 Cao | Crawl ít + clean kỹ, tự soạn dần để thay thế |
|
**Trigger**: Phase 3 xong, cần nội dung premium
|
||||||
| GLM chấm writing không đủ tin cậy | 🟡 TB | Test prompt kỹ, fallback OpenAI-compatible nếu cần |
|
|
||||||
| Latency GLM từ VN cao | 🟡 TB | Benchmark thực tế tuần 3 |
|
**Tính năng**:
|
||||||
| User mất progress (localStorage) | 🟡 TB | Chấp nhận ở MVP, auth Phase 2 giải quyết |
|
- Full TOEIC test chuẩn ETS: 200 câu, 120 phút
|
||||||
| Bản quyền đề TOEIC crawl | 🟡 TB | Dùng để seed nhanh, thay bằng nội dung tự soạn |
|
- Audio Listening chuẩn cho Part 1–4
|
||||||
|
- Auto-score: tính điểm 10–990 theo bảng quy đổi ETS
|
||||||
|
- Phân tích chi tiết: điểm mạnh/yếu từng Part, so sánh lần trước
|
||||||
|
- Bộ đề theo năm: ETS 2023, 2024, Actual Test...
|
||||||
|
- Đếm ngược đến ngày thi + lịch ôn tập gợi ý
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -260,19 +388,43 @@ src/
|
|||||||
|
|
||||||
| Quyết định | Lý do |
|
| Quyết định | Lý do |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Không có Auth Phase 1 | Giảm scope, user dùng thử không cần tạo tài khoản |
|
| Không auth Phase 1 | Giảm scope, validate market trước |
|
||||||
| Supabase thay NestJS tạm | Ra nhanh hơn 2–3 tuần, schema chuẩn để migrate sau |
|
| Email only Phase 2, không OAuth | Đơn giản nhất để build, OAuth Phase 3+ |
|
||||||
| GLM thay OpenAI/Claude | Rẻ hơn đáng kể, API compatible, đủ để test |
|
| Không xác thực email Phase 2 | MVP — giảm friction đăng ký tối đa |
|
||||||
| Web-only, không Flutter | Tập trung 1 platform, Flutter Phase 2 reuse API |
|
| Chỉ 3 field đăng ký (tên/email/pass) | Friction thấp nhất, đủ để identify user |
|
||||||
| TanStack Query + Zustand | TanStack cho server state, Zustand cho client/local state |
|
| Guest chỉ xem preview, không làm được | Buộc đăng ký để dùng, giúp thu thập user data |
|
||||||
| localStorage cho progress | Đủ cho MVP, không cần backend phức tạp |
|
| Không thanh toán Phase 2 | Hiểu behavior trước khi charge tiền |
|
||||||
|
| Không Flutter Phase 2 | Web đã responsive, mobile app khi có traction rõ ràng |
|
||||||
|
| Supabase tạm Phase 1 | Ra nhanh hơn 2–3 tuần, schema chuẩn để migrate sau |
|
||||||
|
| GLM thay OpenAI/Claude | Rẻ hơn, OpenAI-compatible, swap dễ |
|
||||||
|
| Desktop-first (không mobile-first) | Target TOEIC learner hay dùng máy tính |
|
||||||
|
| TanStack Query + Zustand | Server state tách biệt client state rõ ràng |
|
||||||
|
| localStorage Phase 1 | Đủ cho MVP, không cần backend phức tạp |
|
||||||
|
| NestJS Phase 2 | Supabase đủ để validate, NestJS khi scale |
|
||||||
|
| Speaking AI Phase 3 | Cần infra ổn định trước khi làm realtime audio |
|
||||||
|
| Full mock test Phase 4 | Cần content team + audio, không phải tech problem |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Ngôn ngữ**: Tiếng Việt cho UI người dùng, English cho code/comments
|
- **Ngôn ngữ**: Tiếng Việt cho UI người dùng, English cho code/comments/type names
|
||||||
- **Giải thích đáp án**: Luôn bằng tiếng Việt
|
- **Giải thích đáp án**: Luôn bằng tiếng Việt
|
||||||
- **AI feedback**: Mix Việt-Anh (nhận xét tổng thể tiếng Việt, ví dụ sửa tiếng Anh)
|
- **AI feedback**: Nhận xét tổng thể tiếng Việt, ví dụ sửa tiếng Anh
|
||||||
- **Mobile-first**: Test trên màn hình 375px trước, desktop sau
|
- **Desktop-first**: Design và test trên 1280px trước, mobile sau
|
||||||
- **YAGNI / KISS**: Không build thứ chưa cần, Phase 1 xong mới nghĩ Phase 2
|
- **YAGNI / KISS**: Không build thứ chưa cần, từng Phase giải quyết từng vấn đề
|
||||||
|
- **Schema chuẩn ngay từ đầu**: Dù dùng Supabase, PostgreSQL schema phải production-ready
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rủi ro đã nhận diện
|
||||||
|
|
||||||
|
| Rủi ro | Mức độ | Xử lý |
|
||||||
|
|---|---|---|
|
||||||
|
| Content đề TOEIC chất lượng thấp (crawl) | 🔴 Cao | Crawl ít + clean kỹ, tự soạn dần thay thế |
|
||||||
|
| GLM chấm writing không đủ tin cậy | 🟡 TB | Test prompt kỹ, fallback OpenAI nếu cần |
|
||||||
|
| Latency GLM từ VN cao | 🟡 TB | Benchmark thực tế, cache response nếu cần |
|
||||||
|
| User mất progress (localStorage Phase 1) | 🟡 TB | Chấp nhận Phase 1, auth Phase 2 giải quyết |
|
||||||
|
| Bản quyền đề TOEIC crawl | 🟡 TB | Seed nhanh, thay bằng nội dung tự soạn dần |
|
||||||
|
| Supabase free tier limit | 🟢 Thấp | 500MB đủ Phase 1, migrate trước khi hit limit |
|
||||||
|
| Audio quality Phase 4 | 🟡 TB | Budget cho studio recording hoặc TTS premium |
|
||||||
@@ -5,7 +5,7 @@ import reactRefresh from "eslint-plugin-react-refresh"
|
|||||||
import tseslint from "typescript-eslint"
|
import tseslint from "typescript-eslint"
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ["dist", "src/routeTree.gen.ts"] },
|
{ ignores: ["dist", "src/routeTree.gen.ts", ".claude", ".opencode"] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ["**/*.{ts,tsx}"],
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>English Learning App</title>
|
<title>EnglishAI — Luyện TOEIC thông minh</title>
|
||||||
|
<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=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
32
src/components/AppHeader.tsx
Normal file
32
src/components/AppHeader.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useRouterState } from '@tanstack/react-router'
|
||||||
|
import { useTestStore } from '@/store/test-store'
|
||||||
|
import { UserMenu } from '@/components/UserMenu'
|
||||||
|
|
||||||
|
const ROUTE_TITLES: Record<string, string> = {
|
||||||
|
'/': 'Trang chủ',
|
||||||
|
'/writing': 'AI Chấm Writing',
|
||||||
|
'/vocab': 'Từ vựng TOEIC',
|
||||||
|
'/toeic': 'Luyện đề TOEIC',
|
||||||
|
'/toeic/session': '', // dynamic — filled below
|
||||||
|
'/toeic/result': 'Kết quả bài thi',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppHeader() {
|
||||||
|
const { location } = useRouterState()
|
||||||
|
const { partId, partName, answers, questions } = useTestStore()
|
||||||
|
const pathname = location.pathname
|
||||||
|
|
||||||
|
let title = ROUTE_TITLES[pathname] ?? 'EnglishAI'
|
||||||
|
|
||||||
|
if (pathname === '/toeic/session') {
|
||||||
|
const answered = answers.filter((a) => a !== null).length
|
||||||
|
title = `Part ${partId} — ${partName} · ${answered}/${questions.length} câu`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="fixed top-0 right-0 left-0 lg:left-60 h-16 bg-white/90 backdrop-blur-md border-b border-slate-200 z-40 flex items-center justify-between px-6">
|
||||||
|
<span className="text-sm font-semibold text-slate-700">{title}</span>
|
||||||
|
<UserMenu />
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
src/components/CircularProgress.tsx
Normal file
37
src/components/CircularProgress.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
interface CircularProgressProps {
|
||||||
|
percent: number
|
||||||
|
size?: number
|
||||||
|
strokeWidth?: number
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SVG circular progress ring with centered percentage label. */
|
||||||
|
export function CircularProgress({
|
||||||
|
percent,
|
||||||
|
size = 44,
|
||||||
|
strokeWidth = 3.5,
|
||||||
|
color = '#2563EB',
|
||||||
|
}: CircularProgressProps) {
|
||||||
|
const cx = size / 2
|
||||||
|
const radius = cx - strokeWidth
|
||||||
|
const circumference = 2 * Math.PI * radius
|
||||||
|
const offset = circumference - (Math.min(percent, 100) / 100) * circumference
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center flex-shrink-0" style={{ width: size, height: size }}>
|
||||||
|
<svg className="-rotate-90" width={size} height={size}>
|
||||||
|
<circle cx={cx} cy={cx} r={radius} fill="none" stroke="#e2e8f0" strokeWidth={strokeWidth} />
|
||||||
|
<circle
|
||||||
|
cx={cx} cy={cx} r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="absolute text-[10px] font-bold text-slate-700">{percent}%</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,31 +1,59 @@
|
|||||||
import { useState } from "react"
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface FlashCardProps {
|
interface FlashCardProps {
|
||||||
word?: string
|
word: string
|
||||||
phonetic?: string
|
phonetic: string
|
||||||
meaningVi?: string
|
meaningVi: string
|
||||||
example?: string
|
example: string
|
||||||
|
topicBadge: string
|
||||||
|
isFlipped: boolean
|
||||||
|
onFlip: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FlashCard({ word, phonetic, meaningVi, example }: FlashCardProps) {
|
/** 3D flip flashcard. Front shows word/phonetic; back shows Vietnamese meaning + example. */
|
||||||
const [flipped, setFlipped] = useState(false)
|
export function FlashCard({ word, phonetic, meaningVi, example, topicBadge, isFlipped, onFlip }: FlashCardProps) {
|
||||||
|
const highlightedExample = example.replace(
|
||||||
|
new RegExp(`\\b${word}\\b`, 'gi'),
|
||||||
|
(match) => `<strong>${match}</strong>`,
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => setFlipped((f) => !f)}
|
className="flashcard-scene w-full cursor-pointer select-none"
|
||||||
className="rounded-lg border p-6 text-center cursor-pointer min-h-[160px] flex flex-col justify-center select-none hover:bg-gray-50"
|
style={{ height: 280 }}
|
||||||
|
onClick={onFlip}
|
||||||
|
role="button"
|
||||||
|
aria-label={isFlipped ? 'Nhấn để xem từ' : 'Nhấn để xem nghĩa'}
|
||||||
>
|
>
|
||||||
{!flipped ? (
|
<div className={cn('flashcard-inner w-full h-full', isFlipped && 'is-flipped')}>
|
||||||
<div>
|
{/* Front */}
|
||||||
<p className="text-2xl font-bold">{word || "word"}</p>
|
<div className="flashcard-face bg-white border border-slate-200 shadow-lg flex flex-col items-center justify-center p-8">
|
||||||
{phonetic && <p className="text-gray-400 mt-1">{phonetic}</p>}
|
<div className="text-4xl font-extrabold text-slate-800 mb-2">{word}</div>
|
||||||
|
<div className="text-slate-400 text-lg mb-4">{phonetic}</div>
|
||||||
|
<span className="bg-blue-50 text-blue-600 text-xs font-bold px-3 py-1 rounded-full">
|
||||||
|
{topicBadge}
|
||||||
|
</span>
|
||||||
|
<p className="mt-6 text-xs text-slate-400 flex items-center gap-1">
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>touch_app</span>
|
||||||
|
Nhấn để xem nghĩa
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Back */}
|
||||||
|
<div
|
||||||
|
className="flashcard-face flashcard-back flex flex-col items-center justify-center p-8"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #eff6ff, #dbeafe)' }}
|
||||||
|
>
|
||||||
|
<div className="text-3xl font-extrabold text-blue-600 mb-1">{meaningVi}</div>
|
||||||
|
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wide mb-4">
|
||||||
|
Nghĩa tiếng Việt
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-xl p-3 border border-blue-100 text-sm text-slate-500 italic text-center"
|
||||||
|
dangerouslySetInnerHTML={{ __html: `"${highlightedExample}"` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-lg font-semibold text-blue-600">{meaningVi || "nghĩa tiếng Việt"}</p>
|
|
||||||
{example && <p className="text-sm text-gray-500 italic">{example}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/components/MobileNav.tsx
Normal file
41
src/components/MobileNav.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Link, useRouterState } from '@tanstack/react-router'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ to: '/', label: 'Home', icon: 'home', matchPrefix: '/', exact: true },
|
||||||
|
{ to: '/toeic', label: 'Luyện đề', icon: 'assignment', matchPrefix: '/toeic', exact: false },
|
||||||
|
{ to: '/writing', label: 'Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false },
|
||||||
|
{ to: '/vocab', label: 'Từ vựng', icon: 'menu_book', matchPrefix: '/vocab', exact: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
function isActive(pathname: string, prefix: string, exact: boolean) {
|
||||||
|
return exact ? pathname === prefix : pathname.startsWith(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileNav() {
|
||||||
|
const { location } = useRouterState()
|
||||||
|
const pathname = location.pathname
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed bottom-0 inset-x-0 lg:hidden bg-white border-t border-slate-200 z-50 flex safe-area-inset-bottom">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active = isActive(pathname, item.matchPrefix, item.exact)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 flex flex-col items-center justify-center gap-0.5 py-2 min-h-[56px] text-[11px] font-medium transition-colors',
|
||||||
|
active ? 'text-blue-600' : 'text-slate-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
src/components/Sidebar.tsx
Normal file
84
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Link, useRouterState } from '@tanstack/react-router'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
|
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ to: '/', label: 'Trang chủ', icon: 'home', matchPrefix: '/', exact: true },
|
||||||
|
{ to: '/toeic', label: 'Luyện đề TOEIC', icon: 'assignment', matchPrefix: '/toeic', exact: false },
|
||||||
|
{ to: '/writing', label: 'AI Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false },
|
||||||
|
{ to: '/vocab', label: 'Từ vựng', icon: 'menu_book', matchPrefix: '/vocab', exact: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
function isActive(pathname: string, prefix: string, exact: boolean) {
|
||||||
|
return exact ? pathname === prefix : pathname.startsWith(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const { location } = useRouterState()
|
||||||
|
const pathname = location.pathname
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const openModal = useAuthModalStore((s) => s.open)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="hidden lg:flex fixed inset-y-0 left-0 w-60 flex-col bg-slate-50 border-r border-slate-200 z-50">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="px-6 py-5 border-b border-slate-200">
|
||||||
|
<div className="text-xl font-extrabold text-blue-600 tracking-tight">EnglishAI</div>
|
||||||
|
<div className="text-xs text-slate-400 mt-0.5">Học tập thông minh</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="flex-1 py-3 overflow-y-auto">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active = isActive(pathname, item.matchPrefix, item.exact)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 mx-2 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150',
|
||||||
|
active
|
||||||
|
? 'bg-white text-blue-600 font-semibold shadow-sm'
|
||||||
|
: 'text-slate-500 hover:bg-white/70 hover:text-slate-800',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User */}
|
||||||
|
<div className="px-3 py-4 border-t border-slate-200">
|
||||||
|
{user ? (
|
||||||
|
<div className="flex items-center gap-3 bg-white rounded-xl px-3 py-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-bold flex-shrink-0">
|
||||||
|
{user.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold truncate">{user.name}</div>
|
||||||
|
<div className="text-xs text-slate-400 truncate">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => openModal('login')}
|
||||||
|
className="w-full flex items-center gap-3 bg-white rounded-xl px-3 py-2.5 hover:bg-blue-50 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center flex-shrink-0 group-hover:bg-blue-100 transition-colors">
|
||||||
|
<span className="material-symbols-outlined text-slate-400 group-hover:text-blue-600 transition-colors" style={{ fontSize: 18 }}>person</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 text-left">
|
||||||
|
<div className="text-sm font-semibold text-slate-600">Khách</div>
|
||||||
|
<div className="text-xs text-blue-600 font-medium">Đăng nhập →</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
src/components/UserMenu.tsx
Normal file
113
src/components/UserMenu.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
|
import { useUser } from '@/hooks/use-auth'
|
||||||
|
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||||
|
|
||||||
|
/** Avatar circle with first letter of name, deterministic color */
|
||||||
|
function Avatar({ name }: { name: string }) {
|
||||||
|
const colors = ['bg-blue-600', 'bg-green-600', 'bg-violet-600', 'bg-rose-600', 'bg-amber-600']
|
||||||
|
const color = colors[name.charCodeAt(0) % colors.length]
|
||||||
|
return (
|
||||||
|
<div className={`w-8 h-8 rounded-full ${color} flex items-center justify-center text-white text-sm font-bold flex-shrink-0`}>
|
||||||
|
{name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserMenu() {
|
||||||
|
const user = useUser()
|
||||||
|
const logout = useAuthStore((s) => s.logout)
|
||||||
|
const openModal = useAuthModalStore((s) => s.open)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dropdownOpen) return
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setDropdownOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick)
|
||||||
|
}, [dropdownOpen])
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
setDropdownOpen(false)
|
||||||
|
await logout()
|
||||||
|
navigate({ to: '/' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => openModal('login')}
|
||||||
|
className="px-3.5 py-1.5 text-sm font-semibold text-slate-600 border border-slate-300 rounded-xl hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
Đăng nhập
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openModal('register')}
|
||||||
|
className="px-3.5 py-1.5 text-sm font-semibold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Đăng ký
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setDropdownOpen((o) => !o)}
|
||||||
|
className="flex items-center gap-2 px-2 py-1 rounded-xl hover:bg-slate-100 transition-colors"
|
||||||
|
>
|
||||||
|
<Avatar name={user.name} />
|
||||||
|
<span className="text-sm font-semibold text-slate-700 max-w-28 truncate hidden sm:block">
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 16 }}>
|
||||||
|
expand_more
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{dropdownOpen && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-52 bg-white rounded-2xl shadow-lg border border-slate-200 py-1.5 z-50">
|
||||||
|
<div className="px-4 py-2 border-b border-slate-100 mb-1">
|
||||||
|
<div className="text-sm font-semibold text-slate-800 truncate">{user.name}</div>
|
||||||
|
<div className="text-xs text-slate-400 truncate">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => { setDropdownOpen(false); alert('Coming soon!') }}
|
||||||
|
className="w-full flex items-center gap-2.5 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 18 }}>history</span>
|
||||||
|
Lịch sử bài thi
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setDropdownOpen(false); alert('Coming soon!') }}
|
||||||
|
className="w-full flex items-center gap-2.5 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 18 }}>edit_note</span>
|
||||||
|
Lịch sử writing
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100 mt-1 pt-1">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full flex items-center gap-2.5 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-red-400" style={{ fontSize: 18 }}>logout</span>
|
||||||
|
Đăng xuất
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
src/components/auth/AuthModal.tsx
Normal file
96
src/components/auth/AuthModal.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||||
|
import { LoginForm } from './LoginForm'
|
||||||
|
import { RegisterForm } from './RegisterForm'
|
||||||
|
|
||||||
|
export function AuthModal() {
|
||||||
|
const { isOpen, mode, open, close } = useAuthModalStore()
|
||||||
|
|
||||||
|
// Close on Escape key
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') close()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [isOpen, close])
|
||||||
|
|
||||||
|
// Prevent body scroll when open
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = isOpen ? 'hidden' : ''
|
||||||
|
return () => { document.body.style.overflow = '' }
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-60 flex items-center justify-center p-4"
|
||||||
|
style={{ zIndex: 60 }}
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50"
|
||||||
|
onClick={close}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6">
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={close}
|
||||||
|
className="absolute top-4 right-4 text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
|
aria-label="Đóng"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xl">close</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<span className="material-symbols-outlined text-blue-600">school</span>
|
||||||
|
<span className="font-bold text-slate-800">TOEIC Luyện thi</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab toggle */}
|
||||||
|
<div className="flex bg-slate-100 rounded-xl p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => open('login')}
|
||||||
|
className={`flex-1 py-2 text-sm font-semibold rounded-lg transition-all ${
|
||||||
|
mode === 'login'
|
||||||
|
? 'bg-white text-blue-600 shadow-sm'
|
||||||
|
: 'text-slate-500 hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Đăng nhập
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => open('register')}
|
||||||
|
className={`flex-1 py-2 text-sm font-semibold rounded-lg transition-all ${
|
||||||
|
mode === 'register'
|
||||||
|
? 'bg-white text-blue-600 shadow-sm'
|
||||||
|
: 'text-slate-500 hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Đăng ký
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
{mode === 'login' ? (
|
||||||
|
<LoginForm
|
||||||
|
onSuccess={close}
|
||||||
|
onSwitchToRegister={() => open('register')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RegisterForm
|
||||||
|
onSuccess={close}
|
||||||
|
onSwitchToLogin={() => open('login')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
82
src/components/auth/LoginForm.tsx
Normal file
82
src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
|
|
||||||
|
interface LoginFormProps {
|
||||||
|
onSuccess?: () => void
|
||||||
|
onSwitchToRegister?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginForm({ onSuccess, onSwitchToRegister }: LoginFormProps) {
|
||||||
|
const login = useAuthStore((s) => s.login)
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
await login(email, password)
|
||||||
|
onSuccess?.()
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">Mật khẩu</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Tối thiểu 6 ký tự"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full py-2.5 bg-blue-600 text-white text-sm font-semibold rounded-xl hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Đang đăng nhập...' : 'Đăng nhập'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{onSwitchToRegister && (
|
||||||
|
<p className="text-center text-sm text-slate-500">
|
||||||
|
Chưa có tài khoản?{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSwitchToRegister}
|
||||||
|
className="text-blue-600 font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Đăng ký ngay
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
src/components/auth/RegisterForm.tsx
Normal file
94
src/components/auth/RegisterForm.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
|
|
||||||
|
interface RegisterFormProps {
|
||||||
|
onSuccess?: () => void
|
||||||
|
onSwitchToLogin?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegisterForm({ onSuccess, onSwitchToLogin }: RegisterFormProps) {
|
||||||
|
const register = useAuthStore((s) => s.register)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
await register(name, email, password)
|
||||||
|
onSuccess?.()
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">Tên của bạn</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Nguyễn Văn A"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">Mật khẩu</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Tối thiểu 6 ký tự"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full py-2.5 bg-blue-600 text-white text-sm font-semibold rounded-xl hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Đang đăng ký...' : 'Đăng ký'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{onSwitchToLogin && (
|
||||||
|
<p className="text-center text-sm text-slate-500">
|
||||||
|
Đã có tài khoản?{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSwitchToLogin}
|
||||||
|
className="text-blue-600 font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Đăng nhập
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
src/data/mock-data.ts
Normal file
157
src/data/mock-data.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import type { Question, VocabWord, WritingFeedback, ToeicPart } from '@/types'
|
||||||
|
|
||||||
|
export const TOEIC_PARTS: ToeicPart[] = [
|
||||||
|
{ id: 1, name: 'Part 1', nameVi: 'Mô tả hình ảnh', questionCount: 45, icon: 'image', progressPercent: 60 },
|
||||||
|
{ id: 2, name: 'Part 2', nameVi: 'Hỏi-đáp', questionCount: 30, icon: 'question_answer', progressPercent: 40 },
|
||||||
|
{ id: 3, name: 'Part 3', nameVi: 'Đoạn hội thoại', questionCount: 39, icon: 'forum', progressPercent: 25 },
|
||||||
|
{ id: 4, name: 'Part 4', nameVi: 'Bài nói', questionCount: 30, icon: 'record_voice_over', progressPercent: 10 },
|
||||||
|
{ id: 5, name: 'Part 5', nameVi: 'Điền từ', questionCount: 40, icon: 'history_edu', progressPercent: 80 },
|
||||||
|
{ id: 6, name: 'Part 6', nameVi: 'Điền đoạn', questionCount: 16, icon: 'article', progressPercent: 50 },
|
||||||
|
{ id: 7, name: 'Part 7', nameVi: 'Đọc hiểu', questionCount: 54, icon: 'chrome_reader_mode', progressPercent: 30 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MOCK_QUESTIONS: Question[] = [
|
||||||
|
{
|
||||||
|
id: 'q1', part: 2,
|
||||||
|
text: 'What does the man suggest the woman do about the budget report?',
|
||||||
|
options: ['A. Submit it immediately', 'B. Review it again carefully', 'C. Postpone the deadline', 'D. Ask a colleague for help'],
|
||||||
|
correctAnswer: 1,
|
||||||
|
explanation: 'Người đàn ông nói "You should review it carefully before submitting" — gợi ý xem xét lại báo cáo trước khi nộp.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q2', part: 2,
|
||||||
|
text: 'Where most likely are the speakers?',
|
||||||
|
options: ['A. In a restaurant', 'B. At a conference', 'C. In an office', 'D. At an airport'],
|
||||||
|
correctAnswer: 2,
|
||||||
|
explanation: 'Các từ như "meeting room", "printer", "desk" cho biết cuộc trò chuyện diễn ra trong văn phòng.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q3', part: 2,
|
||||||
|
text: 'Why is the man calling?',
|
||||||
|
options: ['A. To confirm a reservation', 'B. To cancel an appointment', 'C. To reschedule a meeting', 'D. To order supplies'],
|
||||||
|
correctAnswer: 0,
|
||||||
|
explanation: 'Từ "confirm" và "booking number" trong hội thoại chỉ rõ mục đích của cuộc gọi là xác nhận đặt chỗ.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q4', part: 2,
|
||||||
|
text: 'What will the woman do next?',
|
||||||
|
options: ['A. Call the manager', 'B. Send an email', 'C. Check the inventory', 'D. Update the schedule'],
|
||||||
|
correctAnswer: 3,
|
||||||
|
explanation: 'Người phụ nữ nói "I\'ll update the schedule right away" — cho biết hành động tiếp theo là cập nhật lịch trình.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q5', part: 2,
|
||||||
|
text: 'What problem does the man mention?',
|
||||||
|
options: ['A. A delayed shipment', 'B. A broken device', 'C. A missing document', 'D. A scheduling conflict'],
|
||||||
|
correctAnswer: 0,
|
||||||
|
explanation: '"The delivery has been delayed by two days" — vấn đề được đề cập là lô hàng bị trễ.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q6', part: 2,
|
||||||
|
text: 'How does the woman respond to the proposal?',
|
||||||
|
options: ['A. She accepts it', 'B. She rejects it', 'C. She needs more time', 'D. She suggests modifications'],
|
||||||
|
correctAnswer: 3,
|
||||||
|
explanation: '"That sounds good, but maybe we could adjust the timeline a bit" — đề xuất điều chỉnh, không chấp nhận hoàn toàn.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q7', part: 2,
|
||||||
|
text: 'What is the purpose of the announcement?',
|
||||||
|
options: ['A. To introduce new products', 'B. To notify schedule changes', 'C. To welcome new employees', 'D. To announce a promotion'],
|
||||||
|
correctAnswer: 1,
|
||||||
|
explanation: 'Thông báo nói về việc thay đổi giờ làm việc từ tuần tới — mục đích là thông báo thay đổi lịch.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q8', part: 2,
|
||||||
|
text: 'What does the woman ask the man to do?',
|
||||||
|
options: ['A. Prepare a presentation', 'B. Contact the client', 'C. Review the contract', 'D. Attend a training session'],
|
||||||
|
correctAnswer: 2,
|
||||||
|
explanation: '"Could you go over the contract before we sign?" — người phụ nữ yêu cầu xem lại hợp đồng.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q9', part: 2,
|
||||||
|
text: 'When will the project be completed?',
|
||||||
|
options: ['A. By the end of this week', 'B. Next Monday', 'C. In two weeks', 'D. Next month'],
|
||||||
|
correctAnswer: 0,
|
||||||
|
explanation: '"We should be finished by Friday" — dự án sẽ hoàn thành vào cuối tuần này.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q10', part: 2,
|
||||||
|
text: 'What is being discussed at the meeting?',
|
||||||
|
options: ['A. Budget allocations', 'B. Marketing strategies', 'C. Product launches', 'D. Staff promotions'],
|
||||||
|
correctAnswer: 1,
|
||||||
|
explanation: 'Cuộc họp tập trung vào "the new advertising campaign and social media approach" — chiến lược marketing.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const VOCAB_DATA: Record<string, VocabWord[]> = {
|
||||||
|
'Tất cả': [
|
||||||
|
{ id: 'v1', word: 'negotiate', phonetic: '/nɪˈɡoʊʃieɪt/', meaningVi: 'đàm phán', topic: 'Business', example: 'We need to negotiate the contract terms before signing.' },
|
||||||
|
{ id: 'v2', word: 'collaborate', phonetic: '/kəˈlæbəreɪt/', meaningVi: 'hợp tác', topic: 'Business', example: 'Teams need to collaborate effectively to meet the deadline.' },
|
||||||
|
{ id: 'v3', word: 'agenda', phonetic: '/əˈdʒendə/', meaningVi: 'chương trình nghị sự', topic: 'Office', example: 'The agenda has been sent to all meeting participants.' },
|
||||||
|
{ id: 'v4', word: 'itinerary', phonetic: '/aɪˈtɪnəreri/', meaningVi: 'lịch trình chuyến đi', topic: 'Travel', example: 'Here is your travel itinerary for the business conference.' },
|
||||||
|
{ id: 'v5', word: 'reimburse', phonetic: '/ˌriːɪmˈbɜːrs/', meaningVi: 'hoàn tiền', topic: 'Finance', example: 'The company will reimburse your travel expenses.' },
|
||||||
|
{ id: 'v6', word: 'recruit', phonetic: '/rɪˈkruːt/', meaningVi: 'tuyển dụng', topic: 'HR', example: 'We are actively recruiting experienced engineers.' },
|
||||||
|
{ id: 'v7', word: 'campaign', phonetic: '/kæmˈpeɪn/', meaningVi: 'chiến dịch', topic: 'Marketing', example: 'The marketing campaign was very successful this quarter.' },
|
||||||
|
{ id: 'v8', word: 'implement', phonetic: '/ˈɪmplɪment/', meaningVi: 'triển khai, thực hiện', topic: 'Business', example: 'We plan to implement the new strategy next quarter.' },
|
||||||
|
],
|
||||||
|
'Business': [
|
||||||
|
{ id: 'b1', word: 'negotiate', phonetic: '/nɪˈɡoʊʃieɪt/', meaningVi: 'đàm phán', topic: 'Business', example: 'We need to negotiate the contract terms.' },
|
||||||
|
{ id: 'b2', word: 'collaborate', phonetic: '/kəˈlæbəreɪt/', meaningVi: 'hợp tác', topic: 'Business', example: 'Teams collaborate to achieve shared goals.' },
|
||||||
|
{ id: 'b3', word: 'delegate', phonetic: '/ˈdelɪɡeɪt/', meaningVi: 'uỷ quyền, phân công', topic: 'Business', example: 'A good manager knows how to delegate tasks.' },
|
||||||
|
{ id: 'b4', word: 'implement', phonetic: '/ˈɪmplɪment/', meaningVi: 'triển khai, thực hiện', topic: 'Business', example: 'We will implement the new policy next month.' },
|
||||||
|
{ id: 'b5', word: 'merger', phonetic: '/ˈmɜːrdʒər/', meaningVi: 'sáp nhập công ty', topic: 'Business', example: 'The merger will create a stronger combined company.' },
|
||||||
|
{ id: 'b6', word: 'acquisition', phonetic: '/ˌækwɪˈzɪʃən/', meaningVi: 'mua lại, thâu tóm', topic: 'Business', example: 'The acquisition was completed ahead of schedule.' },
|
||||||
|
],
|
||||||
|
'Office': [
|
||||||
|
{ id: 'o1', word: 'agenda', phonetic: '/əˈdʒendə/', meaningVi: 'chương trình nghị sự', topic: 'Office', example: 'Please review the agenda before the meeting.' },
|
||||||
|
{ id: 'o2', word: 'minutes', phonetic: '/ˈmɪnɪts/', meaningVi: 'biên bản họp', topic: 'Office', example: 'Could you take the meeting minutes today?' },
|
||||||
|
{ id: 'o3', word: 'submit', phonetic: '/səbˈmɪt/', meaningVi: 'nộp, gửi đi', topic: 'Office', example: 'Please submit your report by Friday afternoon.' },
|
||||||
|
{ id: 'o4', word: 'deadline', phonetic: '/ˈdedlaɪn/', meaningVi: 'hạn chót', topic: 'Office', example: 'The deadline for this project is end of month.' },
|
||||||
|
{ id: 'o5', word: 'cubicle', phonetic: '/ˈkjuːbɪkəl/', meaningVi: 'góc làm việc riêng', topic: 'Office', example: 'Each employee has their own cubicle in the open office.' },
|
||||||
|
],
|
||||||
|
'Travel': [
|
||||||
|
{ id: 't1', word: 'itinerary', phonetic: '/aɪˈtɪnəreri/', meaningVi: 'lịch trình chuyến đi', topic: 'Travel', example: 'Here is your detailed travel itinerary.' },
|
||||||
|
{ id: 't2', word: 'boarding pass', phonetic: '/ˈbɔːrdɪŋ pæs/', meaningVi: 'thẻ lên máy bay', topic: 'Travel', example: 'Please have your boarding pass ready at the gate.' },
|
||||||
|
{ id: 't3', word: 'layover', phonetic: '/ˈleɪoʊvər/', meaningVi: 'thời gian quá cảnh', topic: 'Travel', example: 'There is a two-hour layover in Singapore.' },
|
||||||
|
{ id: 't4', word: 'customs', phonetic: '/ˈkʌstəmz/', meaningVi: 'hải quan', topic: 'Travel', example: 'All passengers must go through customs on arrival.' },
|
||||||
|
{ id: 't5', word: 'baggage claim', phonetic: '/ˈbæɡɪdʒ kleɪm/', meaningVi: 'băng chuyền hành lý', topic: 'Travel', example: 'Meet us at the baggage claim after landing.' },
|
||||||
|
],
|
||||||
|
'Finance': [
|
||||||
|
{ id: 'f1', word: 'reimburse', phonetic: '/ˌriːɪmˈbɜːrs/', meaningVi: 'hoàn tiền', topic: 'Finance', example: 'The company will reimburse all travel expenses.' },
|
||||||
|
{ id: 'f2', word: 'invoice', phonetic: '/ˈɪnvɔɪs/', meaningVi: 'hoá đơn', topic: 'Finance', example: 'Please send the invoice to our accounting department.' },
|
||||||
|
{ id: 'f3', word: 'budget', phonetic: '/ˈbʌdʒɪt/', meaningVi: 'ngân sách', topic: 'Finance', example: 'We need to stay within the approved budget.' },
|
||||||
|
{ id: 'f4', word: 'revenue', phonetic: '/ˈrevɪnjuː/', meaningVi: 'doanh thu', topic: 'Finance', example: 'Revenue increased by 15% last quarter.' },
|
||||||
|
{ id: 'f5', word: 'fiscal year', phonetic: '/ˈfɪskəl jɪər/', meaningVi: 'năm tài chính', topic: 'Finance', example: 'Our fiscal year ends on December 31st.' },
|
||||||
|
],
|
||||||
|
'HR': [
|
||||||
|
{ id: 'h1', word: 'recruit', phonetic: '/rɪˈkruːt/', meaningVi: 'tuyển dụng', topic: 'HR', example: 'We are recruiting experienced software engineers.' },
|
||||||
|
{ id: 'h2', word: 'probation', phonetic: '/proʊˈbeɪʃən/', meaningVi: 'thử việc', topic: 'HR', example: 'New employees have a 3-month probation period.' },
|
||||||
|
{ id: 'h3', word: 'appraisal', phonetic: '/əˈpreɪzəl/', meaningVi: 'đánh giá nhân viên', topic: 'HR', example: 'Annual performance appraisals are held in December.' },
|
||||||
|
{ id: 'h4', word: 'resignation', phonetic: '/ˌrezɪɡˈneɪʃən/', meaningVi: 'đơn từ chức', topic: 'HR', example: 'She submitted her resignation letter this morning.' },
|
||||||
|
{ id: 'h5', word: 'onboarding', phonetic: '/ˈɒnbɔːrdɪŋ/', meaningVi: 'quy trình tiếp nhận nhân viên mới', topic: 'HR', example: 'The onboarding process takes about two weeks.' },
|
||||||
|
],
|
||||||
|
'Marketing': [
|
||||||
|
{ id: 'm1', word: 'campaign', phonetic: '/kæmˈpeɪn/', meaningVi: 'chiến dịch', topic: 'Marketing', example: 'The marketing campaign exceeded all expectations.' },
|
||||||
|
{ id: 'm2', word: 'demographics', phonetic: '/ˌdeməˈɡræfɪks/', meaningVi: 'nhân khẩu học', topic: 'Marketing', example: 'We need to understand our target demographics.' },
|
||||||
|
{ id: 'm3', word: 'endorse', phonetic: '/ɪnˈdɔːrs/', meaningVi: 'chứng thực, bảo trợ', topic: 'Marketing', example: 'The product is endorsed by professional athletes.' },
|
||||||
|
{ id: 'm4', word: 'branding', phonetic: '/ˈbrændɪŋ/', meaningVi: 'xây dựng thương hiệu', topic: 'Marketing', example: 'Consistent branding builds long-term customer trust.' },
|
||||||
|
{ id: 'm5', word: 'conversion rate', phonetic: '/kənˈvɜːrʒən reɪt/', meaningVi: 'tỷ lệ chuyển đổi', topic: 'Marketing', example: 'Our conversion rate improved after the redesign.' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_WRITING_FEEDBACK: WritingFeedback = {
|
||||||
|
score: '6.5',
|
||||||
|
grammar: [
|
||||||
|
'"managers are concern" → nên dùng "concerned" (tính từ, không phải danh từ)',
|
||||||
|
'Thiếu mạo từ "an" trước "efficient arrangement" ở câu cuối',
|
||||||
|
'Câu "This change is expected to improve" — đúng nhưng hơi thụ động, có thể dùng active voice',
|
||||||
|
],
|
||||||
|
vocabulary: [
|
||||||
|
'Tốt: "implement", "productivity", "collaboration", "arrangement"',
|
||||||
|
'Gợi ý nâng cao: "enhance" thay "increase", "address" thay "help with"',
|
||||||
|
'Nên thêm từ nối: "Nevertheless", "In addition", "As a result of this"',
|
||||||
|
],
|
||||||
|
structure: 'Bài viết có cấu trúc khá rõ ràng với mở đầu, thân bài và kết luận ngầm. Tuy nhiên cần phát triển thêm phần giải thích tác động và thêm ví dụ cụ thể để bài hoàn chỉnh hơn.',
|
||||||
|
improvedVersion: 'The company has decided to implement a new remote work policy starting next month. All employees will be able to work from home for three days per week. This change is expected to enhance work-life balance and boost overall productivity. Nevertheless, some managers are concerned about communication challenges and team collaboration. To address these concerns, the HR department will organize training sessions to help teams adapt to this new arrangement effectively.',
|
||||||
|
summary: 'Bài viết đạt mức Upper Intermediate (6.5) với ý tưởng rõ ràng. Cần sửa lỗi ngữ pháp cơ bản và bổ sung từ vựng phong phú hơn để đạt band 7.0+.',
|
||||||
|
}
|
||||||
5
src/hooks/use-auth.ts
Normal file
5
src/hooks/use-auth.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
|
|
||||||
|
export const useAuth = () => useAuthStore()
|
||||||
|
export const useUser = () => useAuthStore((s) => s.user)
|
||||||
|
export const useIsAuthenticated = () => useAuthStore((s) => s.user !== null)
|
||||||
@@ -1,18 +1,35 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { supabase } from "@/lib/supabase"
|
import { supabase } from "@/lib/supabase"
|
||||||
|
import type { Question } from "@/types"
|
||||||
|
|
||||||
|
const ANSWER_INDEX: Record<string, number> = { A: 0, B: 1, C: 2, D: 3 }
|
||||||
|
|
||||||
|
// Maps a Supabase row to the shared Question interface.
|
||||||
|
// DB uses `content` + `answer` ('A'–'D'); interface uses `text` + `correctAnswer` (0–3).
|
||||||
|
function rowToQuestion(row: Record<string, unknown>): Question {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
part: row.part as number,
|
||||||
|
text: row.content as string,
|
||||||
|
options: row.options as string[],
|
||||||
|
correctAnswer: ANSWER_INDEX[(row.answer as string).toUpperCase()] ?? 0,
|
||||||
|
explanation: (row.explanation as string) ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported for imperative use (e.g. ToeicPractice click handler).
|
||||||
|
// part=0 fetches all parts (Full Test).
|
||||||
|
export async function fetchQuestions(part: number, limit = 10): Promise<Question[]> {
|
||||||
|
let query = supabase.from('questions').select('*').limit(limit)
|
||||||
|
if (part > 0) query = query.eq('part', part)
|
||||||
|
const { data, error } = await query
|
||||||
|
if (error) throw error
|
||||||
|
return (data ?? []).map(rowToQuestion)
|
||||||
|
}
|
||||||
|
|
||||||
export function useQuestions(part: number, limit = 10) {
|
export function useQuestions(part: number, limit = 10) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["questions", part, limit],
|
queryKey: ['questions', part, limit],
|
||||||
queryFn: async () => {
|
queryFn: () => fetchQuestions(part, limit),
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("questions")
|
|
||||||
.select("*")
|
|
||||||
.eq("part", part)
|
|
||||||
.limit(limit)
|
|
||||||
if (error) throw error
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
enabled: false, // Enabled during feature implementation
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/hooks/use-require-auth.ts
Normal file
17
src/hooks/use-require-auth.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
|
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||||
|
|
||||||
|
export function useRequireAuth() {
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const isLoading = useAuthStore((s) => s.isLoading)
|
||||||
|
const openModal = useAuthModalStore((s) => s.open)
|
||||||
|
|
||||||
|
/** Returns true if authenticated. If guest, opens auth modal and returns false. */
|
||||||
|
function requireAuth(): boolean {
|
||||||
|
if (user) return true
|
||||||
|
openModal('register')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isAuthenticated: !!user, isLoading, requireAuth }
|
||||||
|
}
|
||||||
@@ -1,16 +1,32 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { supabase } from "@/lib/supabase"
|
import { supabase } from "@/lib/supabase"
|
||||||
|
import type { VocabWord, VocabTopic } from "@/types"
|
||||||
|
|
||||||
export function useVocab(topic?: string) {
|
// Maps a Supabase row to VocabWord.
|
||||||
|
// DB column `meaning_vi` → interface field `meaningVi`.
|
||||||
|
function rowToVocabWord(row: Record<string, unknown>): VocabWord {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
word: row.word as string,
|
||||||
|
phonetic: (row.phonetic as string) ?? '',
|
||||||
|
meaningVi: row.meaning_vi as string,
|
||||||
|
topic: row.topic as VocabTopic,
|
||||||
|
example: (row.example as string) ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches ALL vocab; topic filtering is done in-component so we avoid
|
||||||
|
// separate queries per topic and keep the cache simple.
|
||||||
|
export function useVocab() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["vocab", topic],
|
queryKey: ['vocab'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
let query = supabase.from("vocab").select("*")
|
const { data, error } = await supabase
|
||||||
if (topic) query = query.eq("topic", topic.toLowerCase())
|
.from('vocab')
|
||||||
const { data, error } = await query
|
.select('*')
|
||||||
|
.order('topic')
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
return data
|
return (data ?? []).map(rowToVocabWord)
|
||||||
},
|
},
|
||||||
enabled: false, // Enabled during feature implementation
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,89 @@
|
|||||||
import { useMutation } from "@tanstack/react-query"
|
import { useMutation } from "@tanstack/react-query"
|
||||||
import { supabase } from "@/lib/supabase"
|
|
||||||
import { canUseWritingCheck, recordWritingCheckUsage } from "@/utils/rate-limiter"
|
import { canUseWritingCheck, recordWritingCheckUsage } from "@/utils/rate-limiter"
|
||||||
|
import { useAuthStore } from "@/store/auth-store"
|
||||||
|
import { saveWritingSubmission, countTodayWritingSubmissions } from "@/lib/progress-service"
|
||||||
|
import type { WritingFeedback } from "@/types"
|
||||||
|
|
||||||
interface WritingFeedback {
|
const AUTH_DAILY_LIMIT = 10
|
||||||
score: string
|
const GUEST_DAILY_LIMIT = 3
|
||||||
grammar: string[]
|
|
||||||
vocabulary: string[]
|
const GLM_BASE_URL = "https://open.bigmodel.cn/api/paas/v4"
|
||||||
structure: string
|
const GLM_API_KEY = import.meta.env.VITE_GLM_API_KEY as string
|
||||||
improved_version: string
|
const GLM_MODEL = (import.meta.env.VITE_GLM_MODEL as string) || "GLM-4-32B-0414-128K"
|
||||||
summary: string
|
|
||||||
|
// Keep system prompt concise — fewer tokens = more room for output.
|
||||||
|
// improved_version omitted from schema to reduce output length; added back as optional.
|
||||||
|
const SYSTEM_PROMPT = `You are an expert English writing teacher for TOEIC and IELTS.
|
||||||
|
Respond ONLY with valid JSON, no markdown:
|
||||||
|
{"score":"6.5","grammar":["issue + fix in Vietnamese"],"vocabulary":["observation in Vietnamese"],"structure":"2 sentences in Vietnamese","improved_version":"full improved text","summary":"2 sentences in Vietnamese"}`
|
||||||
|
|
||||||
|
async function callGlm(content: string): Promise<WritingFeedback> {
|
||||||
|
const res = await fetch(`${GLM_BASE_URL}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${GLM_API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: GLM_MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: SYSTEM_PROMPT },
|
||||||
|
{ role: "user", content: `Analyse:\n\n${content.slice(0, 1500)}` },
|
||||||
|
],
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 2500,
|
||||||
|
// Force JSON output mode (OpenAI-compatible, supported by GLM)
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((err as { error?: { message?: string } }).error?.message ?? `GLM error ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json() as { choices: { message: { content: string } }[] }
|
||||||
|
const raw = data.choices[0]?.message?.content ?? "{}"
|
||||||
|
|
||||||
|
// Strip markdown code fences defensively
|
||||||
|
const cleaned = raw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim()
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(cleaned) as WritingFeedback
|
||||||
|
} catch {
|
||||||
|
throw new Error("Phản hồi từ AI không hợp lệ. Vui lòng thử lại.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWritingCheck() {
|
export function useWritingCheck() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (content: string): Promise<WritingFeedback> => {
|
mutationFn: async (content: string): Promise<WritingFeedback> => {
|
||||||
if (!canUseWritingCheck()) {
|
const user = useAuthStore.getState().user
|
||||||
throw new Error("Bạn đã dùng hết 3 lần kiểm tra hôm nay. Quay lại vào ngày mai!")
|
|
||||||
|
if (user) {
|
||||||
|
// Server-side rate limit for authenticated users (10/day)
|
||||||
|
const usedToday = await countTodayWritingSubmissions(user.id)
|
||||||
|
if (usedToday >= AUTH_DAILY_LIMIT) {
|
||||||
|
throw new Error(`Bạn đã dùng hết ${AUTH_DAILY_LIMIT} lần kiểm tra hôm nay. Quay lại vào ngày mai!`)
|
||||||
}
|
}
|
||||||
const { data, error } = await supabase.functions.invoke("writing-check", {
|
} else {
|
||||||
body: { content },
|
// localStorage rate limit for guests (3/day)
|
||||||
})
|
if (!canUseWritingCheck()) {
|
||||||
if (error) throw error
|
throw new Error(`Bạn đã dùng hết ${GUEST_DAILY_LIMIT} lần kiểm tra hôm nay. Đăng ký để được 10 lần/ngày!`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedback = await callGlm(content)
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Save to DB (fire-and-forget)
|
||||||
|
saveWritingSubmission(user.id, content, feedback)
|
||||||
|
} else {
|
||||||
|
// Persist guest usage in localStorage
|
||||||
recordWritingCheckUsage()
|
recordWritingCheckUsage()
|
||||||
return data as WritingFeedback
|
}
|
||||||
|
|
||||||
|
return feedback
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
@import "@fontsource-variable/geist";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-heading: var(--font-sans);
|
--font-heading: var(--font-sans);
|
||||||
--font-sans: 'Geist Variable', sans-serif;
|
--font-sans: 'Plus Jakarta Sans', sans-serif;
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -55,8 +54,8 @@
|
|||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(54.6% 0.245 262.3); /* #2563EB */
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(1 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.97 0 0);
|
||||||
@@ -122,9 +121,56 @@
|
|||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-slate-50 text-slate-800;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Flashcard 3D flip ── */
|
||||||
|
.flashcard-scene {
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
.flashcard-inner {
|
||||||
|
position: relative;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.flashcard-inner.is-flipped {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
.flashcard-face {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
.flashcard-back {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Material Symbols ── */
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page fade-in ── */
|
||||||
|
@keyframes page-in {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.page-enter {
|
||||||
|
animation: page-in 0.2s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Timer urgent pulse ── */
|
||||||
|
@keyframes timer-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
.timer-urgent {
|
||||||
|
animation: timer-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
71
src/lib/progress-service.ts
Normal file
71
src/lib/progress-service.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
|
||||||
|
interface TestResultData {
|
||||||
|
partId: number
|
||||||
|
partName: string
|
||||||
|
score: number
|
||||||
|
total: number
|
||||||
|
timeUsed: number
|
||||||
|
answers: { questionId: string; selected: number | null; correct: boolean }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget: save test result. Failures are logged but don't block UI. */
|
||||||
|
export async function saveTestResult(userId: string, data: TestResultData): Promise<void> {
|
||||||
|
const { error } = await supabase.from('user_progress').insert({
|
||||||
|
user_id: userId,
|
||||||
|
type: 'test',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
if (error) console.error('Failed to save test result:', error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget: save writing submission with AI feedback. */
|
||||||
|
export async function saveWritingSubmission(
|
||||||
|
userId: string,
|
||||||
|
content: string,
|
||||||
|
feedback: object,
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await supabase.from('writing_submissions').insert({
|
||||||
|
user_id: userId,
|
||||||
|
content,
|
||||||
|
feedback,
|
||||||
|
})
|
||||||
|
if (error) console.error('Failed to save writing submission:', error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count today's writing submissions for server-side rate limiting (authenticated users). */
|
||||||
|
export async function countTodayWritingSubmissions(userId: string): Promise<number> {
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
const { count, error } = await supabase
|
||||||
|
.from('writing_submissions')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.gte('created_at', `${today}T00:00:00.000Z`)
|
||||||
|
if (error) return 0
|
||||||
|
return count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch test history for a user (most recent first, max 20). */
|
||||||
|
export async function fetchTestHistory(userId: string) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('user_progress')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.eq('type', 'test')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(20)
|
||||||
|
if (error) throw error
|
||||||
|
return data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch writing history for a user (most recent first, max 20). */
|
||||||
|
export async function fetchWritingHistory(userId: string) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('writing_submissions')
|
||||||
|
.select('id, content, feedback, created_at')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(20)
|
||||||
|
if (error) throw error
|
||||||
|
return data ?? []
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { createClient } from "@supabase/supabase-js"
|
import { createClient } from "@supabase/supabase-js"
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
|
// Supports both key name conventions
|
||||||
|
const supabaseAnonKey =
|
||||||
|
import.meta.env.VITE_SUPABASE_ANON_KEY ||
|
||||||
|
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
if (!supabaseUrl || !supabaseAnonKey) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"Supabase env vars missing. Set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in .env",
|
"Supabase env vars missing. Set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY (or VITE_SUPABASE_PUBLISHABLE_KEY) in .env",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,204 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useUser } from '@/hooks/use-auth'
|
||||||
|
import { useAuthModalStore } from '@/store/auth-modal-store'
|
||||||
|
|
||||||
|
const FEATURES = [
|
||||||
|
{
|
||||||
|
to: '/toeic',
|
||||||
|
icon: 'assignment',
|
||||||
|
iconBg: 'bg-blue-50',
|
||||||
|
iconColor: 'text-blue-600',
|
||||||
|
borderColor: 'border-l-blue-600',
|
||||||
|
title: 'Luyện đề TOEIC',
|
||||||
|
desc: 'Kho đề thi cập nhật theo cấu trúc mới nhất. Phân tích điểm yếu chi tiết từng Part.',
|
||||||
|
cta: 'Bắt đầu ngay',
|
||||||
|
ctaColor: 'text-blue-600',
|
||||||
|
stat: '350+ câu hỏi',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/writing',
|
||||||
|
icon: 'auto_fix_high',
|
||||||
|
iconBg: 'bg-green-50',
|
||||||
|
iconColor: 'text-green-600',
|
||||||
|
borderColor: 'border-l-green-600',
|
||||||
|
title: 'AI Chấm Writing',
|
||||||
|
desc: 'Phản hồi tức thì về ngữ pháp, từ vựng, cấu trúc và bài viết mẫu từ AI.',
|
||||||
|
cta: 'Thử ngay',
|
||||||
|
ctaColor: 'text-green-600',
|
||||||
|
stat: '3 lượt / ngày',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/vocab',
|
||||||
|
icon: 'menu_book',
|
||||||
|
iconBg: 'bg-amber-50',
|
||||||
|
iconColor: 'text-amber-600',
|
||||||
|
borderColor: 'border-l-amber-600',
|
||||||
|
title: 'Từ vựng thông minh',
|
||||||
|
desc: '720 từ TOEIC theo 6 chủ đề. Flashcard với hiệu ứng lật 3D.',
|
||||||
|
cta: 'Khám phá',
|
||||||
|
ctaColor: 'text-amber-600',
|
||||||
|
stat: '720 từ vựng',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
|
const user = useUser()
|
||||||
|
const openModal = useAuthModalStore((s) => s.open)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
|
||||||
<div className="text-center py-12">
|
{/* Hero */}
|
||||||
<h1 className="text-3xl font-bold">Luyện tiếng Anh TOEIC</h1>
|
<section className="flex flex-col lg:flex-row gap-10 items-center mb-12">
|
||||||
<p className="mt-3 text-lg text-gray-600">
|
<div className="flex-1 min-w-0">
|
||||||
Luyện đề, kiểm tra writing, và học từ vựng TOEIC miễn phí
|
<div className="inline-flex items-center gap-2 bg-blue-50 text-blue-600 text-xs font-bold px-3 py-1.5 rounded-full mb-5 uppercase tracking-wider">
|
||||||
</p>
|
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>auto_awesome</span>
|
||||||
|
AI-Powered Learning
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<h1 className="text-4xl lg:text-5xl font-extrabold leading-tight text-slate-800 mb-4" style={{ letterSpacing: '-0.02em' }}>
|
||||||
<div className="rounded-lg border p-6">
|
Luyện TOEIC<br />thông minh<br />
|
||||||
<h2 className="font-semibold text-lg">Luyện đề TOEIC</h2>
|
<span className="text-blue-600 italic">cùng AI</span>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
</h1>
|
||||||
Luyện tập từng Part 1–7 với đề thật
|
<p className="text-slate-500 text-lg leading-relaxed mb-8 max-w-md">
|
||||||
|
Cá nhân hóa lộ trình học tập để bứt phá điểm số trong thời gian ngắn nhất. AI phân tích điểm yếu và tối ưu bài tập cho bạn.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex gap-3 flex-wrap">
|
||||||
|
<Link
|
||||||
|
to="/toeic"
|
||||||
|
className="bg-blue-600 text-white px-8 py-3.5 rounded-xl font-bold text-sm hover:bg-blue-700 transition-colors shadow-lg shadow-blue-600/20"
|
||||||
|
>
|
||||||
|
Bắt đầu ngay
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/writing"
|
||||||
|
className="border border-slate-200 px-8 py-3.5 rounded-xl font-bold text-sm text-slate-500 hover:bg-white hover:border-blue-600 hover:text-blue-600 transition-all"
|
||||||
|
>
|
||||||
|
Thử AI Writing
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border p-6">
|
<div className="flex gap-6 mt-8">
|
||||||
<h2 className="font-semibold text-lg">AI Writing Checker</h2>
|
<div>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<div className="text-2xl font-extrabold text-blue-600">350+</div>
|
||||||
Chấm điểm và sửa bài writing bằng AI
|
<div className="text-xs text-slate-400 mt-0.5">Câu hỏi TOEIC</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border p-6">
|
<div className="w-px bg-slate-200" />
|
||||||
<h2 className="font-semibold text-lg">Từ vựng TOEIC</h2>
|
<div>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<div className="text-2xl font-extrabold text-green-600">720</div>
|
||||||
Flashcard 6 chủ đề: Business, Finance, HR...
|
<div className="text-xs text-slate-400 mt-0.5">Từ vựng</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-px bg-slate-200" />
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-extrabold text-amber-600">AI</div>
|
||||||
|
<div className="text-xs text-slate-400 mt-0.5">Writing Checker</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview card — hidden on mobile */}
|
||||||
|
<div className="hidden lg:block flex-shrink-0 w-80">
|
||||||
|
<div className="bg-white rounded-2xl p-6 shadow-xl border border-slate-100">
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-base text-slate-800">Tiến độ tuần này</div>
|
||||||
|
<div className="text-xs text-slate-400 mt-0.5">Bạn đang làm rất tốt!</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 text-green-600 text-xs font-bold px-2.5 py-1 rounded-lg">+12%</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex justify-between text-xs font-semibold mb-1.5">
|
||||||
|
<span>Reading Score</span><span className="text-blue-600">420/495</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-slate-100">
|
||||||
|
<div className="h-full bg-blue-600 rounded-full" style={{ width: '85%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex justify-between text-xs font-semibold mb-1.5">
|
||||||
|
<span>Listening Score</span><span className="text-green-600">380/495</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-slate-100">
|
||||||
|
<div className="h-full bg-green-600 rounded-full" style={{ width: '77%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||||
|
<div className="bg-blue-50 rounded-xl p-3 border-l-4 border-blue-600">
|
||||||
|
<span className="material-symbols-outlined text-blue-600" style={{ fontSize: 18 }}>local_fire_department</span>
|
||||||
|
<div className="text-xl font-extrabold text-blue-600 mt-1">14</div>
|
||||||
|
<div className="text-xs text-slate-400">Ngày Streak</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 rounded-xl p-3 border-l-4 border-green-600">
|
||||||
|
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1" }}>star</span>
|
||||||
|
<div className="text-xl font-extrabold text-green-600 mt-1">1,250</div>
|
||||||
|
<div className="text-xs text-slate-400">Điểm tích lũy</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 pt-4 border-t border-slate-100 flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 16 }}>psychology</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
<span className="font-semibold">AI gợi ý:</span> Ôn thêm Part 5 — Ngữ pháp
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Feature cards */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-extrabold text-slate-800 mb-1.5">Tính năng nổi bật</h2>
|
||||||
|
<p className="text-slate-500 mb-6">Hệ sinh thái học tập toàn diện được thiết kế để tối ưu hoá điểm số.</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||||
|
{FEATURES.map((f) => (
|
||||||
|
<Link
|
||||||
|
key={f.to}
|
||||||
|
to={f.to}
|
||||||
|
className={`bg-white rounded-2xl p-6 border border-slate-200 border-l-4 ${f.borderColor} hover:-translate-y-1 hover:shadow-md transition-all duration-200`}
|
||||||
|
>
|
||||||
|
<div className={`w-12 h-12 ${f.iconBg} rounded-xl flex items-center justify-center mb-4`}>
|
||||||
|
<span className={`material-symbols-outlined ${f.iconColor}`}>{f.icon}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-base text-slate-800 mb-2">{f.title}</h3>
|
||||||
|
<p className="text-slate-500 text-sm leading-relaxed mb-4">{f.desc}</p>
|
||||||
|
<div className={`flex items-center gap-1.5 text-sm font-bold ${f.ctaColor}`}>
|
||||||
|
{f.cta}
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>arrow_forward</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA banner */}
|
||||||
|
<section className="mt-10">
|
||||||
|
<div className="bg-blue-600 rounded-2xl p-8 flex items-center justify-between overflow-hidden relative">
|
||||||
|
<div className="absolute right-4 top-0 bottom-0 flex items-center opacity-10">
|
||||||
|
<span className="material-symbols-outlined text-white" style={{ fontSize: 120 }}>emoji_events</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h3 className="text-2xl font-extrabold text-white mb-2">Sẵn sàng chinh phục 990 TOEIC?</h3>
|
||||||
|
<p className="text-blue-100 mb-5">
|
||||||
|
{user
|
||||||
|
? `Chào ${user.name}! Tiếp tục luyện thi hôm nay.`
|
||||||
|
: 'Đăng ký miễn phí để lưu tiến độ và luyện thi không giới hạn.'}
|
||||||
|
</p>
|
||||||
|
{user ? (
|
||||||
|
<Link
|
||||||
|
to="/toeic"
|
||||||
|
className="inline-block bg-white text-blue-600 px-6 py-3 rounded-xl font-bold text-sm hover:bg-blue-50 transition-colors"
|
||||||
|
>
|
||||||
|
Luyện thi ngay
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => openModal('register')}
|
||||||
|
className="bg-white text-blue-600 px-6 py-3 rounded-xl font-bold text-sm hover:bg-blue-50 transition-colors"
|
||||||
|
>
|
||||||
|
Đăng ký miễn phí
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/pages/Login.tsx
Normal file
34
src/pages/Login.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useUser } from '@/hooks/use-auth'
|
||||||
|
import { LoginForm } from '@/components/auth/LoginForm'
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const user = useUser()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) navigate({ to: '/' })
|
||||||
|
}, [user, navigate])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 bg-slate-50">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center gap-2 mb-3">
|
||||||
|
<span className="material-symbols-outlined text-blue-600 text-3xl">school</span>
|
||||||
|
<span className="text-2xl font-bold text-slate-800">TOEIC Luyện thi</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold text-slate-700">Đăng nhập tài khoản</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||||
|
<LoginForm
|
||||||
|
onSuccess={() => navigate({ to: '/' })}
|
||||||
|
onSwitchToRegister={() => navigate({ to: '/auth/register' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/pages/Register.tsx
Normal file
35
src/pages/Register.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useUser } from '@/hooks/use-auth'
|
||||||
|
import { RegisterForm } from '@/components/auth/RegisterForm'
|
||||||
|
|
||||||
|
export function RegisterPage() {
|
||||||
|
const user = useUser()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) navigate({ to: '/' })
|
||||||
|
}, [user, navigate])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 bg-slate-50">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center gap-2 mb-3">
|
||||||
|
<span className="material-symbols-outlined text-blue-600 text-3xl">school</span>
|
||||||
|
<span className="text-2xl font-bold text-slate-800">TOEIC Luyện thi</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold text-slate-700">Tạo tài khoản miễn phí</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">Không cần xác nhận email — dùng ngay lập tức</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
|
||||||
|
<RegisterForm
|
||||||
|
onSuccess={() => navigate({ to: '/' })}
|
||||||
|
onSwitchToLogin={() => navigate({ to: '/auth/login' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,223 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useTestStore } from '@/store/test-store'
|
||||||
|
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||||
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
|
import { saveTestResult } from '@/lib/progress-service'
|
||||||
|
|
||||||
|
function formatTime(s: number) {
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const sec = s % 60
|
||||||
|
if (m === 0) return `${sec}s`
|
||||||
|
return `${m}m ${sec}s`
|
||||||
|
}
|
||||||
|
|
||||||
export function TestResult() {
|
export function TestResult() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { partId, partName, questions, answers, timeUsed, reset } = useTestStore()
|
||||||
|
const { isAuthenticated, isLoading } = useRequireAuth()
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const savedRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) return
|
||||||
|
if (!isAuthenticated) navigate({ to: '/toeic' })
|
||||||
|
}, [isLoading, isAuthenticated, navigate])
|
||||||
|
|
||||||
|
// Save test result once when page mounts (fire-and-forget)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || savedRef.current || questions.length === 0) return
|
||||||
|
savedRef.current = true
|
||||||
|
saveTestResult(user.id, {
|
||||||
|
partId,
|
||||||
|
partName,
|
||||||
|
score: answers.filter((a, i) => a === questions[i]?.correctAnswer).length,
|
||||||
|
total: questions.length,
|
||||||
|
timeUsed,
|
||||||
|
answers: questions.map((q, i) => ({
|
||||||
|
questionId: q.id,
|
||||||
|
selected: answers[i],
|
||||||
|
correct: answers[i] === q.correctAnswer,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}, [user, questions, answers, partId, partName, timeUsed])
|
||||||
|
|
||||||
|
const correct = answers.filter((a, i) => a === questions[i]?.correctAnswer).length
|
||||||
|
const wrong = answers.filter((a, i) => a !== null && a !== questions[i]?.correctAnswer).length
|
||||||
|
const skipped = answers.filter((a) => a === null).length
|
||||||
|
const total = questions.length
|
||||||
|
const percent = total > 0 ? Math.round((correct / total) * 100) : 0
|
||||||
|
|
||||||
|
const circumference = 2 * Math.PI * 52
|
||||||
|
const offset = circumference - (percent / 100) * circumference
|
||||||
|
|
||||||
|
function handleRetry() {
|
||||||
|
navigate({ to: '/toeic/session' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHome() {
|
||||||
|
reset()
|
||||||
|
navigate({ to: '/' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (questions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="px-6 py-8 max-w-6xl mx-auto text-center">
|
||||||
<h1 className="text-2xl font-bold">Kết quả</h1>
|
<p className="text-slate-500 mb-4">Không có dữ liệu bài thi.</p>
|
||||||
<p className="text-gray-500">Kết quả và đáp án — placeholder</p>
|
<button
|
||||||
|
onClick={() => navigate({ to: '/toeic' })}
|
||||||
|
className="bg-blue-600 text-white px-6 py-2.5 rounded-xl font-semibold text-sm hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Chọn Part để luyện thi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
||||||
|
{/* Score header */}
|
||||||
|
<div className="bg-white rounded-2xl p-6 border border-slate-200 mb-5">
|
||||||
|
<div className="flex flex-col lg:flex-row items-center gap-6">
|
||||||
|
{/* Circle */}
|
||||||
|
<div className="flex-shrink-0 relative w-32 h-32">
|
||||||
|
<svg className="w-full h-full -rotate-90" viewBox="0 0 120 120">
|
||||||
|
<circle cx="60" cy="60" r="52" fill="none" stroke="#e2e8f0" strokeWidth="8" />
|
||||||
|
<circle
|
||||||
|
cx="60"
|
||||||
|
cy="60"
|
||||||
|
r="52"
|
||||||
|
fill="none"
|
||||||
|
stroke={percent >= 70 ? '#16a34a' : percent >= 50 ? '#2563eb' : '#dc2626'}
|
||||||
|
strokeWidth="8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
className="transition-all duration-700"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-3xl font-extrabold text-slate-800">{correct}/{total}</span>
|
||||||
|
<span className="text-xs text-slate-400 font-medium">điểm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex-1 text-center lg:text-left">
|
||||||
|
<div className="text-2xl font-extrabold text-slate-800 mb-1">
|
||||||
|
{percent >= 80 ? 'Xuất sắc!' : percent >= 60 ? 'Hoàn thành!' : 'Cố gắng hơn nhé!'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-400 mb-4">
|
||||||
|
Part {partId} — {partName}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3 justify-center lg:justify-start">
|
||||||
|
<div className="bg-green-50 border border-green-100 rounded-xl px-4 py-2 text-center">
|
||||||
|
<div className="text-xl font-extrabold text-green-600">{correct}</div>
|
||||||
|
<div className="text-xs text-slate-400">Đúng</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-red-50 border border-red-100 rounded-xl px-4 py-2 text-center">
|
||||||
|
<div className="text-xl font-extrabold text-red-600">{wrong}</div>
|
||||||
|
<div className="text-xs text-slate-400">Sai</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 border border-slate-200 rounded-xl px-4 py-2 text-center">
|
||||||
|
<div className="text-xl font-extrabold text-slate-500">{skipped}</div>
|
||||||
|
<div className="text-xs text-slate-400">Bỏ qua</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 border border-blue-100 rounded-xl px-4 py-2 text-center">
|
||||||
|
<div className="text-xl font-extrabold text-blue-600">{formatTime(timeUsed)}</div>
|
||||||
|
<div className="text-xs text-slate-400">Thời gian</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex lg:flex-col gap-3 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>replay</span>
|
||||||
|
Làm lại
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleHome}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 border border-slate-200 text-slate-600 rounded-xl text-sm font-semibold hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>home</span>
|
||||||
|
Về trang chủ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Answer review */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-6">
|
||||||
|
<h2 className="text-base font-bold text-slate-800 mb-4">Xem lại đáp án</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{questions.map((q, i) => {
|
||||||
|
const userAnswer = answers[i]
|
||||||
|
const isCorrect = userAnswer === q.correctAnswer
|
||||||
|
const isSkipped = userAnswer === null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={q.id}
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border p-4',
|
||||||
|
isCorrect ? 'border-green-100 bg-green-50/50' : isSkipped ? 'border-slate-100 bg-slate-50/50' : 'border-red-100 bg-red-50/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5',
|
||||||
|
isCorrect ? 'bg-green-600 text-white' : isSkipped ? 'bg-slate-400 text-white' : 'bg-red-600 text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-800 mb-2">{q.text}</p>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{q.options.map((opt, j) => (
|
||||||
|
<span
|
||||||
|
key={j}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-2.5 py-1 rounded-lg font-medium',
|
||||||
|
j === q.correctAnswer
|
||||||
|
? 'bg-green-100 text-green-700 border border-green-200'
|
||||||
|
: j === userAnswer && !isCorrect
|
||||||
|
? 'bg-red-100 text-red-700 border border-red-200 line-through'
|
||||||
|
: 'bg-slate-100 text-slate-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{['A', 'B', 'C', 'D'][j]}. {opt}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{q.explanation && (
|
||||||
|
<p className="text-xs text-slate-500 bg-white rounded-lg px-3 py-2 border border-slate-100">
|
||||||
|
<span className="font-semibold text-slate-600">Giải thích: </span>
|
||||||
|
{q.explanation}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="flex-shrink-0">
|
||||||
|
{isCorrect ? (
|
||||||
|
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 20 }}>check_circle</span>
|
||||||
|
) : isSkipped ? (
|
||||||
|
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 20 }}>remove_circle</span>
|
||||||
|
) : (
|
||||||
|
<span className="material-symbols-outlined text-red-500" style={{ fontSize: 20 }}>cancel</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,207 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useTestStore } from '@/store/test-store'
|
||||||
|
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||||
|
|
||||||
|
const TOTAL_SECONDS = 600 // 10 minutes
|
||||||
|
const ANSWER_LABELS = ['A', 'B', 'C', 'D']
|
||||||
|
|
||||||
|
function formatTime(s: number) {
|
||||||
|
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
export function TestSession() {
|
export function TestSession() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { partId, partName, questions, answers, setAnswer, submitExam } = useTestStore()
|
||||||
|
const [currentQ, setCurrentQ] = useState(0)
|
||||||
|
const [timeLeft, setTimeLeft] = useState(TOTAL_SECONDS)
|
||||||
|
const { isAuthenticated, isLoading } = useRequireAuth()
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
submitExam(TOTAL_SECONDS - timeLeft)
|
||||||
|
navigate({ to: '/toeic/result' })
|
||||||
|
}, [submitExam, navigate, timeLeft])
|
||||||
|
|
||||||
|
// Countdown
|
||||||
|
useEffect(() => {
|
||||||
|
if (questions.length === 0) return
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setTimeLeft((t) => {
|
||||||
|
if (t <= 1) { clearInterval(id); handleSubmit(); return 0 }
|
||||||
|
return t - 1
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [questions.length, handleSubmit])
|
||||||
|
|
||||||
|
// Redirect if no exam started or not authenticated (wait for auth init)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) return
|
||||||
|
if (!isAuthenticated || questions.length === 0) navigate({ to: '/toeic' })
|
||||||
|
}, [isLoading, isAuthenticated, questions.length, navigate])
|
||||||
|
|
||||||
|
if (questions.length === 0) return null
|
||||||
|
|
||||||
|
const question = questions[currentQ]
|
||||||
|
const answeredCount = answers.filter((a) => a !== null).length
|
||||||
|
const isUrgent = timeLeft < 60
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
||||||
<h1 className="text-2xl font-bold">Làm bài</h1>
|
{/* Mobile progress bar */}
|
||||||
<p className="text-gray-500">Trang làm bài — placeholder</p>
|
<div className="lg:hidden mb-4">
|
||||||
|
<div className="flex justify-between text-sm font-semibold mb-2">
|
||||||
|
<span className="text-slate-700">Part {partId} — Câu {currentQ + 1}/{questions.length}</span>
|
||||||
|
<span className={cn('font-bold tabular-nums', isUrgent ? 'text-red-600 timer-urgent' : 'text-blue-600')}>
|
||||||
|
{formatTime(timeLeft)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-slate-200">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-600 rounded-full transition-all"
|
||||||
|
style={{ width: `${((currentQ + 1) / questions.length) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-5">
|
||||||
|
{/* Left: Question */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="bg-white rounded-2xl p-6 border border-slate-200 mb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<span className="bg-blue-600 text-white text-xs font-bold px-3 py-1 rounded-full">
|
||||||
|
Câu {currentQ + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400">Part {partId} — {partName}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-base font-medium text-slate-800 leading-relaxed mb-6">
|
||||||
|
{question.text}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{question.options.map((opt, i) => {
|
||||||
|
const selected = answers[currentQ] === i
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setAnswer(currentQ, i)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 p-4 border-2 rounded-xl text-sm font-medium text-left transition-all',
|
||||||
|
selected
|
||||||
|
? 'border-blue-600 bg-blue-50 text-blue-700'
|
||||||
|
: 'border-slate-200 hover:border-blue-300 hover:bg-blue-50/50 text-slate-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0',
|
||||||
|
selected ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ANSWER_LABELS[i]}
|
||||||
|
</span>
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentQ((q) => Math.max(0, q - 1))}
|
||||||
|
disabled={currentQ === 0}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_left</span>
|
||||||
|
Câu trước
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-slate-400 tabular-nums">{currentQ + 1} / {questions.length}</span>
|
||||||
|
{currentQ < questions.length - 1 ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentQ((q) => q + 1)}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-xl text-sm font-semibold hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Câu tiếp theo
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_right</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-red-600 text-white rounded-xl text-sm font-semibold hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Nộp bài
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>send</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel — desktop only */}
|
||||||
|
<div className="hidden lg:flex flex-col gap-4 w-60 flex-shrink-0">
|
||||||
|
{/* Timer */}
|
||||||
|
<div className="bg-white rounded-2xl p-5 border border-slate-200 text-center">
|
||||||
|
<div className="text-xs text-slate-400 font-medium mb-2">Thời gian còn lại</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-5xl font-extrabold tabular-nums mb-1',
|
||||||
|
isUrgent ? 'text-red-600 timer-urgent' : 'text-blue-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatTime(timeLeft)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400">phút : giây</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Question dots */}
|
||||||
|
<div className="bg-white rounded-2xl p-5 border border-slate-200">
|
||||||
|
<div className="text-xs text-slate-400 font-medium mb-3">
|
||||||
|
Danh sách câu · {answeredCount}/{questions.length} đã trả lời
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{questions.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setCurrentQ(i)}
|
||||||
|
className={cn(
|
||||||
|
'w-8 h-8 rounded-full flex items-center justify-center text-[11px] font-semibold transition-all',
|
||||||
|
i === currentQ
|
||||||
|
? 'border-2 border-blue-600 text-blue-600 shadow-sm shadow-blue-600/20'
|
||||||
|
: answers[i] !== null
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'border-2 border-slate-200 text-slate-400 hover:border-blue-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-4 text-xs text-slate-400">
|
||||||
|
<span className="w-4 h-4 rounded-full bg-blue-600 inline-block" /> Đã trả lời
|
||||||
|
<span className="w-4 h-4 rounded-full border-2 border-slate-200 inline-block" /> Chưa làm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="w-full py-3 bg-red-600 text-white rounded-xl font-bold text-sm hover:bg-red-700 transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>send</span>
|
||||||
|
Nộp bài
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile submit */}
|
||||||
|
<div className="lg:hidden mt-4">
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="w-full py-3.5 bg-red-600 text-white rounded-xl font-bold text-sm hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Nộp bài ngay
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,108 @@
|
|||||||
const PARTS = [
|
import { useState } from 'react'
|
||||||
{ id: 1, name: "Part 1", desc: "Photographs" },
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
{ id: 2, name: "Part 2", desc: "Question-Response" },
|
import { CircularProgress } from '@/components/CircularProgress'
|
||||||
{ id: 3, name: "Part 3", desc: "Conversations" },
|
import { useTestStore } from '@/store/test-store'
|
||||||
{ id: 4, name: "Part 4", desc: "Short Talks" },
|
import { TOEIC_PARTS } from '@/data/mock-data'
|
||||||
{ id: 5, name: "Part 5", desc: "Incomplete Sentences" },
|
import { fetchQuestions } from '@/hooks/use-questions'
|
||||||
{ id: 6, name: "Part 6", desc: "Text Completion" },
|
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||||
{ id: 7, name: "Part 7", desc: "Reading Comprehension" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function ToeicPractice() {
|
export function ToeicPractice() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { startExam } = useTestStore()
|
||||||
|
const [loadingPartId, setLoadingPartId] = useState<number | null>(null)
|
||||||
|
const { requireAuth } = useRequireAuth()
|
||||||
|
|
||||||
|
async function handleSelectPart(partId: number, partName: string) {
|
||||||
|
if (!requireAuth()) return
|
||||||
|
setLoadingPartId(partId)
|
||||||
|
try {
|
||||||
|
const questions = await fetchQuestions(partId, 10)
|
||||||
|
startExam(partId, partName, questions)
|
||||||
|
navigate({ to: '/toeic/session' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load questions:', err)
|
||||||
|
} finally {
|
||||||
|
setLoadingPartId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
|
||||||
<h1 className="text-2xl font-bold">Luyện đề TOEIC</h1>
|
<div className="mb-8">
|
||||||
<p className="text-gray-600">Chọn Part để bắt đầu luyện tập</p>
|
<h1 className="text-3xl font-extrabold text-slate-800 mb-2">Chọn Part TOEIC</h1>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<p className="text-slate-500">
|
||||||
{PARTS.map((part) => (
|
Hệ thống ôn luyện theo cấu trúc bài thi TOEIC thực tế. Chọn phần cụ thể để bắt đầu.
|
||||||
<div key={part.id} className="rounded-lg border p-4 cursor-pointer hover:bg-gray-50">
|
</p>
|
||||||
<div className="font-semibold">{part.name}</div>
|
|
||||||
<div className="text-sm text-gray-500">{part.desc}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
|
{TOEIC_PARTS.map((part) => (
|
||||||
|
<button
|
||||||
|
key={part.id}
|
||||||
|
onClick={() => handleSelectPart(part.id, part.nameVi)}
|
||||||
|
disabled={loadingPartId !== null}
|
||||||
|
className="bg-white rounded-2xl p-5 border border-slate-200 text-left hover:-translate-y-1 hover:shadow-md transition-all duration-200 group disabled:opacity-70 disabled:cursor-wait"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-5">
|
||||||
|
<div className="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center group-hover:bg-blue-600 transition-colors">
|
||||||
|
{loadingPartId === part.id ? (
|
||||||
|
<span className="w-4 h-4 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined text-blue-600 group-hover:text-white transition-colors"
|
||||||
|
style={{ fontSize: 18 }}
|
||||||
|
>
|
||||||
|
{part.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CircularProgress percent={part.progressPercent} size={44} />
|
||||||
|
</div>
|
||||||
|
<div className="font-extrabold text-lg text-slate-800 mb-0.5">{part.name}</div>
|
||||||
|
<div className="text-sm font-semibold text-slate-700 mb-2">{part.nameVi}</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>list_alt</span>
|
||||||
|
{part.questionCount} câu hỏi
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Full Test card */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelectPart(0, 'Full Test')}
|
||||||
|
className="relative rounded-2xl p-5 text-left overflow-hidden hover:-translate-y-1 hover:shadow-xl transition-all duration-200"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #f59e0b, #d97706)' }}
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 right-0 opacity-10">
|
||||||
|
<span className="material-symbols-outlined text-white" style={{ fontSize: 80 }}>
|
||||||
|
workspace_premium
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center mb-5">
|
||||||
|
<span className="material-symbols-outlined text-white" style={{ fontSize: 18 }}>
|
||||||
|
military_tech
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-extrabold text-2xl text-white mb-0.5">Full Test</div>
|
||||||
|
<div className="text-sm font-semibold text-amber-50 mb-2">Mô phỏng thi thật 2h</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-amber-100">
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>timer</span>
|
||||||
|
120 phút · 200 câu
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tip */}
|
||||||
|
<div className="mt-8 bg-blue-50 border border-blue-100 rounded-2xl p-5 flex items-start gap-4">
|
||||||
|
<span className="material-symbols-outlined text-blue-600 flex-shrink-0 mt-0.5">tips_and_updates</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-blue-700 text-sm mb-1">Mẹo luyện thi</div>
|
||||||
|
<p className="text-slate-500 text-sm">
|
||||||
|
Bắt đầu từ <strong>Part 5 (Điền từ)</strong> — phần mang lại điểm nhanh nhất vì không phụ thuộc kỹ năng nghe. Mỗi ngày 20 câu, sau 2 tuần bạn sẽ thấy cải thiện rõ rệt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,251 @@
|
|||||||
const TOPICS = ["Business", "Office", "Travel", "Finance", "HR", "Marketing"]
|
import { useState } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { FlashCard } from '@/components/FlashCard'
|
||||||
|
import { useVocabStore } from '@/store/vocab-store'
|
||||||
|
import { useVocab } from '@/hooks/use-vocab'
|
||||||
|
import { VOCAB_TOPICS } from '@/types'
|
||||||
|
import type { VocabTopic, VocabWord } from '@/types'
|
||||||
|
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||||
|
|
||||||
|
const GUEST_CARD_LIMIT = 5
|
||||||
|
|
||||||
export function Vocabulary() {
|
export function Vocabulary() {
|
||||||
|
const { currentTopic, currentIndex, knownWords, setTopic, setCurrentIndex, markKnown, markNeedReview } = useVocabStore()
|
||||||
|
const [isFlipped, setIsFlipped] = useState(false)
|
||||||
|
const { isAuthenticated, requireAuth } = useRequireAuth()
|
||||||
|
|
||||||
|
const { data: allVocab = [], isLoading, isError } = useVocab()
|
||||||
|
const filtered: VocabWord[] = currentTopic === 'Tất cả'
|
||||||
|
? allVocab
|
||||||
|
: allVocab.filter((w) => w.topic === currentTopic)
|
||||||
|
const safeIndex = Math.min(currentIndex, Math.max(0, filtered.length - 1))
|
||||||
|
const word = filtered[safeIndex]
|
||||||
|
const knownInFiltered = filtered.filter((w) => knownWords.includes(w.id)).length
|
||||||
|
|
||||||
|
function handleSetTopic(topic: VocabTopic) {
|
||||||
|
setTopic(topic)
|
||||||
|
setCurrentIndex(0)
|
||||||
|
setIsFlipped(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrev() {
|
||||||
|
if (safeIndex > 0) {
|
||||||
|
setCurrentIndex(safeIndex - 1)
|
||||||
|
setIsFlipped(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
if (safeIndex < filtered.length - 1) {
|
||||||
|
if (!isAuthenticated && safeIndex >= GUEST_CARD_LIMIT - 1) {
|
||||||
|
requireAuth()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCurrentIndex(safeIndex + 1)
|
||||||
|
setIsFlipped(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMarkKnown() {
|
||||||
|
if (!isAuthenticated && safeIndex >= GUEST_CARD_LIMIT - 1) {
|
||||||
|
requireAuth()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (word) markKnown(word.id)
|
||||||
|
handleNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMarkReview() {
|
||||||
|
if (!isAuthenticated && safeIndex >= GUEST_CARD_LIMIT - 1) {
|
||||||
|
requireAuth()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (word) markNeedReview(word.id)
|
||||||
|
handleNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentKnown = knownWords
|
||||||
|
.map((id) => allVocab.find((w) => w.id === id))
|
||||||
|
.filter((w): w is VocabWord => w !== undefined)
|
||||||
|
.slice(-5)
|
||||||
|
.reverse()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
||||||
<h1 className="text-2xl font-bold">Từ vựng TOEIC</h1>
|
{/* Mobile topic chips */}
|
||||||
<p className="text-gray-600">Chọn chủ đề để học flashcard</p>
|
<div className="lg:hidden mb-4 overflow-x-auto pb-1">
|
||||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3">
|
<div className="flex gap-2 w-max">
|
||||||
{TOPICS.map((topic) => (
|
{VOCAB_TOPICS.map((topic) => (
|
||||||
<div key={topic} className="rounded-lg border p-4 text-center cursor-pointer hover:bg-gray-50">
|
<button
|
||||||
<span className="font-medium">{topic}</span>
|
key={topic}
|
||||||
|
onClick={() => handleSetTopic(topic)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 rounded-full text-sm font-semibold whitespace-nowrap transition-colors',
|
||||||
|
currentTopic === topic
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-white border border-slate-200 text-slate-600 hover:border-blue-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{topic}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-5">
|
||||||
|
{/* Left: Topic menu — desktop only */}
|
||||||
|
<div className="hidden lg:block w-44 flex-shrink-0">
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-3 sticky top-20">
|
||||||
|
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider px-2 mb-2">Chủ đề</div>
|
||||||
|
{VOCAB_TOPICS.map((topic) => (
|
||||||
|
<button
|
||||||
|
key={topic}
|
||||||
|
onClick={() => handleSetTopic(topic)}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-colors',
|
||||||
|
currentTopic === topic
|
||||||
|
? 'bg-blue-50 text-blue-600 font-semibold'
|
||||||
|
: 'text-slate-600 hover:bg-slate-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{topic}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center: Flashcard */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-10 flex flex-col items-center justify-center gap-3">
|
||||||
|
<div className="w-8 h-8 border-2 border-blue-100 border-t-blue-600 rounded-full animate-spin" />
|
||||||
|
<p className="text-sm text-slate-400">Đang tải từ vựng...</p>
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="bg-red-50 rounded-2xl border border-red-100 p-10 text-center">
|
||||||
|
<p className="text-sm text-red-500">Không thể tải từ vựng. Vui lòng thử lại.</p>
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-10 text-center">
|
||||||
|
<p className="text-slate-400">Không có từ vựng cho chủ đề này.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs text-slate-400 font-medium">
|
||||||
|
{safeIndex + 1} / {filtered.length} từ
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-blue-600">
|
||||||
|
{knownInFiltered}/{filtered.length} đã thuộc
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-slate-200 mb-4">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-600 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${filtered.length > 0 ? ((safeIndex + 1) / filtered.length) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{word && (
|
||||||
|
<FlashCard
|
||||||
|
word={word.word}
|
||||||
|
phonetic={word.phonetic}
|
||||||
|
meaningVi={word.meaningVi}
|
||||||
|
example={word.example}
|
||||||
|
topicBadge={word.topic}
|
||||||
|
isFlipped={isFlipped}
|
||||||
|
onFlip={() => setIsFlipped((v) => !v)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<button
|
||||||
|
onClick={handlePrev}
|
||||||
|
disabled={safeIndex === 0}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_left</span>
|
||||||
|
Trước
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Mark buttons */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleMarkReview}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2.5 border border-amber-200 bg-amber-50 text-amber-700 rounded-xl text-sm font-semibold hover:bg-amber-100 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>replay</span>
|
||||||
|
<span className="hidden sm:inline">Cần ôn</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleMarkKnown}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2.5 border border-green-200 bg-green-50 text-green-700 rounded-xl text-sm font-semibold hover:bg-green-100 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>check</span>
|
||||||
|
<span className="hidden sm:inline">Đã thuộc</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={safeIndex === filtered.length - 1}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Tiếp
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>chevron_right</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Stats — desktop only */}
|
||||||
|
<div className="hidden lg:flex flex-col gap-4 w-52 flex-shrink-0">
|
||||||
|
{/* Today stats */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||||
|
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider mb-3">Thống kê</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-slate-500">Đã xem</span>
|
||||||
|
<span className="text-sm font-bold text-slate-800">{safeIndex + 1}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-slate-500">Đã thuộc</span>
|
||||||
|
<span className="text-sm font-bold text-green-600">{knownWords.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-slate-500">Tổng từ</span>
|
||||||
|
<span className="text-sm font-bold text-blue-600">{allVocab.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-100">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="material-symbols-outlined text-amber-500" style={{ fontSize: 16 }}>local_fire_department</span>
|
||||||
|
<span className="text-xs text-slate-500">Streak hôm nay</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-amber-500 mt-1">{Math.min(safeIndex + 1, 99)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recently known */}
|
||||||
|
{recentKnown.length > 0 && (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||||
|
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider mb-3">Vừa thuộc</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recentKnown.map((w) => w && (
|
||||||
|
<div key={w.id} className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm font-semibold text-slate-700 truncate">{w.word}</span>
|
||||||
|
<span className="text-xs text-slate-400 truncate ml-auto">{w.meaningVi}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,232 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useWritingCheck } from '@/hooks/use-writing-check'
|
||||||
|
import { getRemainingChecks } from '@/utils/rate-limiter'
|
||||||
|
import { useRequireAuth } from '@/hooks/use-require-auth'
|
||||||
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
|
import { countTodayWritingSubmissions } from '@/lib/progress-service'
|
||||||
|
|
||||||
|
const MAX_CHARS = 1000
|
||||||
|
const GUEST_LIMIT = 3
|
||||||
|
const AUTH_LIMIT = 10
|
||||||
|
|
||||||
export function WritingChecker() {
|
export function WritingChecker() {
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [improvedExpanded, setImprovedExpanded] = useState(false)
|
||||||
|
const [remaining, setRemaining] = useState(getRemainingChecks)
|
||||||
|
|
||||||
|
const { mutate: checkWriting, isPending, isError, error, data: feedback } = useWritingCheck()
|
||||||
|
const { requireAuth } = useRequireAuth()
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
|
||||||
|
const dailyLimit = user ? AUTH_LIMIT : GUEST_LIMIT
|
||||||
|
|
||||||
|
// Fetch server-side remaining count for authenticated users
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
setRemaining(getRemainingChecks())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
countTodayWritingSubmissions(user.id).then((used) => {
|
||||||
|
setRemaining(AUTH_LIMIT - used)
|
||||||
|
})
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
const charCount = text.length
|
||||||
|
const canSubmit = text.trim().length > 0 && remaining > 0 && charCount <= MAX_CHARS && !isPending
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!requireAuth()) return
|
||||||
|
if (!canSubmit) return
|
||||||
|
checkWriting(text, {
|
||||||
|
onSuccess: () => {
|
||||||
|
if (user) {
|
||||||
|
countTodayWritingSubmissions(user.id).then((used) => setRemaining(AUTH_LIMIT - used))
|
||||||
|
} else {
|
||||||
|
setRemaining(getRemainingChecks())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
if (!user) setRemaining(getRemainingChecks())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
|
||||||
<h1 className="text-2xl font-bold">AI Writing Checker</h1>
|
<div className="mb-6">
|
||||||
<p className="text-gray-600">
|
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">AI Chấm Writing</h1>
|
||||||
Nhập bài writing để nhận phản hồi từ AI
|
<p className="text-slate-500 text-sm">Nhận phản hồi tức thì về ngữ pháp, từ vựng và cấu trúc bài viết.</p>
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-row gap-5">
|
||||||
|
{/* Left: Input */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-semibold text-slate-700">Bài writing của bạn</span>
|
||||||
|
<span className={cn('text-xs tabular-nums', charCount > MAX_CHARS ? 'text-red-500 font-bold' : 'text-slate-400')}>
|
||||||
|
{charCount}/{MAX_CHARS}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full h-48 rounded-lg border p-3 text-sm resize-none"
|
value={text}
|
||||||
placeholder="Nhập bài writing của bạn tại đây..."
|
onChange={(e) => setText(e.target.value.slice(0, MAX_CHARS))}
|
||||||
disabled
|
rows={12}
|
||||||
|
placeholder="Nhập bài writing của bạn vào đây... (TOEIC email, IELTS task, hoặc đoạn văn tự do)"
|
||||||
|
className="w-full resize-none rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-blue-400 focus:bg-white transition-colors"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-gray-400">Tính năng đang được phát triển</p>
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 14 }}>info</span>
|
||||||
|
<span className={cn('text-xs font-medium', remaining <= 1 ? 'text-red-500' : 'text-slate-400')}>
|
||||||
|
Còn {remaining}/{dailyLimit} lượt hôm nay
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-bold transition-all',
|
||||||
|
canSubmit
|
||||||
|
? 'bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/20'
|
||||||
|
: 'bg-slate-100 text-slate-400 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
|
||||||
|
Đang chấm...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>auto_fix_high</span>
|
||||||
|
Chấm bài ngay
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{remaining <= 0 && (
|
||||||
|
<div className="mt-3 bg-amber-50 border border-amber-100 rounded-xl p-4 flex items-center gap-3">
|
||||||
|
<span className="material-symbols-outlined text-amber-600 flex-shrink-0" style={{ fontSize: 20 }}>schedule</span>
|
||||||
|
<p className="text-sm text-amber-700">
|
||||||
|
Bạn đã dùng hết {dailyLimit} lượt hôm nay. Vui lòng quay lại vào ngày mai.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="mt-3 bg-red-50 border border-red-100 rounded-xl p-4 flex items-center gap-3">
|
||||||
|
<span className="material-symbols-outlined text-red-500 flex-shrink-0" style={{ fontSize: 20 }}>error</span>
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
{(error as Error)?.message ?? 'Đã có lỗi xảy ra. Vui lòng thử lại.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Feedback */}
|
||||||
|
<div className="lg:w-80 flex-shrink-0">
|
||||||
|
{!feedback && !isPending && (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col items-center justify-center text-center h-full min-h-48">
|
||||||
|
<span className="material-symbols-outlined text-slate-300 mb-3" style={{ fontSize: 48 }}>auto_fix_high</span>
|
||||||
|
<p className="text-sm text-slate-400">Nhập bài và nhấn "Chấm bài ngay" để nhận phản hồi từ AI</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPending && (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-6 flex flex-col items-center justify-center text-center h-full min-h-48">
|
||||||
|
<div className="w-10 h-10 border-2 border-blue-100 border-t-blue-600 rounded-full animate-spin mb-4" />
|
||||||
|
<p className="text-sm text-slate-500 font-medium">AI đang phân tích bài viết...</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Thường mất 3–5 giây</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{feedback && !isPending && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Band score */}
|
||||||
|
<div className="bg-blue-600 rounded-2xl p-5 text-center">
|
||||||
|
<div className="text-xs text-blue-200 font-medium mb-1 uppercase tracking-wider">Band Score ước tính</div>
|
||||||
|
<div className="text-5xl font-extrabold text-white mb-1">{feedback.score}</div>
|
||||||
|
<div className="text-xs text-blue-200">Dựa trên tiêu chí IELTS/TOEIC Writing</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grammar */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||||
|
<span className="text-sm font-bold text-slate-800">Ngữ pháp</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{feedback.grammar.map((item, i) => (
|
||||||
|
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
|
||||||
|
<span className="material-symbols-outlined text-red-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>error</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vocabulary */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-amber-500" />
|
||||||
|
<span className="text-sm font-bold text-slate-800">Từ vựng</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{feedback.vocabulary.map((item, i) => (
|
||||||
|
<li key={i} className="text-xs text-slate-600 flex items-start gap-2">
|
||||||
|
<span className="material-symbols-outlined text-amber-400 flex-shrink-0 mt-0.5" style={{ fontSize: 14 }}>lightbulb</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Structure */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||||
|
<span className="text-sm font-bold text-slate-800">Cấu trúc</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-600">{feedback.structure}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Improved version */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setImprovedExpanded((v) => !v)}
|
||||||
|
className="w-full flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
|
<span className="text-sm font-bold text-slate-800">Bài viết cải thiện</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-slate-400" style={{ fontSize: 18 }}>
|
||||||
|
{improvedExpanded ? 'expand_less' : 'expand_more'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{improvedExpanded && (
|
||||||
|
<p className="mt-3 text-xs text-slate-600 leading-relaxed border-t border-slate-100 pt-3">
|
||||||
|
{feedback.improvedVersion}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="bg-green-50 rounded-2xl border border-green-100 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="material-symbols-outlined text-green-600" style={{ fontSize: 16 }}>summarize</span>
|
||||||
|
<span className="text-sm font-bold text-green-700">Tổng nhận xét</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-600">{feedback.summary}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
import { createRootRoute, Link, Outlet } from "@tanstack/react-router"
|
import { createRootRoute, Outlet } from '@tanstack/react-router'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { Sidebar } from '@/components/Sidebar'
|
||||||
|
import { AppHeader } from '@/components/AppHeader'
|
||||||
|
import { MobileNav } from '@/components/MobileNav'
|
||||||
|
import { AuthModal } from '@/components/auth/AuthModal'
|
||||||
|
import { useAuthStore } from '@/store/auth-store'
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
})
|
})
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
|
const initialize = useAuthStore((s) => s.initialize)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initialize()
|
||||||
|
}, [initialize])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white text-gray-900">
|
<div className="min-h-screen bg-slate-50">
|
||||||
<header className="border-b px-4 py-3">
|
<Sidebar />
|
||||||
<nav className="mx-auto max-w-5xl flex items-center gap-6">
|
<AppHeader />
|
||||||
<Link to="/" className="text-lg font-bold">
|
<main className="lg:ml-60 pt-16 pb-20 lg:pb-0 min-h-screen">
|
||||||
English App
|
|
||||||
</Link>
|
|
||||||
<Link to="/toeic" className="text-sm text-gray-600 hover:text-gray-900">
|
|
||||||
Luyện đề
|
|
||||||
</Link>
|
|
||||||
<Link to="/writing" className="text-sm text-gray-600 hover:text-gray-900">
|
|
||||||
Writing
|
|
||||||
</Link>
|
|
||||||
<Link to="/vocab" className="text-sm text-gray-600 hover:text-gray-900">
|
|
||||||
Từ vựng
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main className="mx-auto max-w-5xl px-4 py-6">
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
<MobileNav />
|
||||||
|
<AuthModal />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/routes/auth.login.tsx
Normal file
6
src/routes/auth.login.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { LoginPage } from '@/pages/Login'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/auth/login')({
|
||||||
|
component: LoginPage,
|
||||||
|
})
|
||||||
6
src/routes/auth.register.tsx
Normal file
6
src/routes/auth.register.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { RegisterPage } from '@/pages/Register'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/auth/register')({
|
||||||
|
component: RegisterPage,
|
||||||
|
})
|
||||||
15
src/store/auth-modal-store.ts
Normal file
15
src/store/auth-modal-store.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface AuthModalState {
|
||||||
|
isOpen: boolean
|
||||||
|
mode: 'login' | 'register'
|
||||||
|
open: (mode?: 'login' | 'register') => void
|
||||||
|
close: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthModalStore = create<AuthModalState>((set) => ({
|
||||||
|
isOpen: false,
|
||||||
|
mode: 'register',
|
||||||
|
open: (mode = 'register') => set({ isOpen: true, mode }),
|
||||||
|
close: () => set({ isOpen: false }),
|
||||||
|
}))
|
||||||
71
src/store/auth-store.ts
Normal file
71
src/store/auth-store.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import type { User } from '@/types'
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null
|
||||||
|
isLoading: boolean
|
||||||
|
login: (email: string, password: string) => Promise<void>
|
||||||
|
register: (name: string, email: string, password: string) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
initialize: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionToUser(session: { user: { id: string; email?: string; user_metadata?: { name?: string } } } | null): User | null {
|
||||||
|
if (!session) return null
|
||||||
|
const { id, email, user_metadata } = session.user
|
||||||
|
const name = user_metadata?.name ?? email?.split('@')[0] ?? 'Người dùng'
|
||||||
|
return { id, email: email ?? '', name }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
isLoading: true,
|
||||||
|
|
||||||
|
initialize: async () => {
|
||||||
|
set({ isLoading: true })
|
||||||
|
|
||||||
|
// Restore existing session (JWT in localStorage via Supabase SDK)
|
||||||
|
const { data: { session } } = await supabase.auth.getSession()
|
||||||
|
set({ user: sessionToUser(session), isLoading: false })
|
||||||
|
|
||||||
|
// Keep state in sync across tabs and token refresh
|
||||||
|
supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||||
|
set({ user: sessionToUser(newSession), isLoading: false })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
login: async (email, password) => {
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({ email, password })
|
||||||
|
if (error) throw new Error(mapAuthError(error.message))
|
||||||
|
// onAuthStateChange fires and updates user state
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (name, email, password) => {
|
||||||
|
const { error } = await supabase.auth.signUp({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
options: { data: { name } },
|
||||||
|
})
|
||||||
|
if (error) throw new Error(mapAuthError(error.message))
|
||||||
|
// onAuthStateChange fires and updates user state
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
await supabase.auth.signOut()
|
||||||
|
// onAuthStateChange fires → user set to null
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function mapAuthError(msg: string): string {
|
||||||
|
if (msg.includes('already registered') || msg.includes('already exists')) {
|
||||||
|
return 'Email này đã được sử dụng. Vui lòng đăng nhập.'
|
||||||
|
}
|
||||||
|
if (msg.includes('Invalid login credentials') || msg.includes('invalid_credentials')) {
|
||||||
|
return 'Sai email hoặc mật khẩu. Vui lòng kiểm tra lại.'
|
||||||
|
}
|
||||||
|
if (msg.includes('Email not confirmed')) {
|
||||||
|
return 'Email chưa được xác nhận.'
|
||||||
|
}
|
||||||
|
return 'Đã có lỗi xảy ra. Vui lòng thử lại.'
|
||||||
|
}
|
||||||
@@ -1,27 +1,53 @@
|
|||||||
import { create } from "zustand"
|
import { create } from 'zustand'
|
||||||
import { persist } from "zustand/middleware"
|
import { persist } from 'zustand/middleware'
|
||||||
|
import type { Question } from '@/types'
|
||||||
|
|
||||||
interface TestState {
|
interface TestStore {
|
||||||
// Shell — filled during TOEIC feature implementation
|
partId: number
|
||||||
currentPart: number | null
|
partName: string
|
||||||
answers: Record<string, string>
|
questions: Question[]
|
||||||
setCurrentPart: (part: number | null) => void
|
answers: (number | null)[]
|
||||||
setAnswer: (questionId: string, answer: string) => void
|
isSubmitted: boolean
|
||||||
|
timeUsed: number // seconds elapsed when submitted
|
||||||
|
|
||||||
|
startExam: (partId: number, partName: string, questions: Question[]) => void
|
||||||
|
setAnswer: (questionIndex: number, answerIndex: number) => void
|
||||||
|
submitExam: (timeUsed: number) => void
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTestStore = create<TestState>()(
|
export const useTestStore = create<TestStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
currentPart: null,
|
partId: 2,
|
||||||
answers: {},
|
partName: '',
|
||||||
setCurrentPart: (part) => set({ currentPart: part }),
|
questions: [],
|
||||||
setAnswer: (questionId, answer) =>
|
answers: [],
|
||||||
set((state) => ({
|
isSubmitted: false,
|
||||||
answers: { ...state.answers, [questionId]: answer },
|
timeUsed: 0,
|
||||||
})),
|
|
||||||
reset: () => set({ currentPart: null, answers: {} }),
|
startExam: (partId, partName, questions) =>
|
||||||
|
set({
|
||||||
|
partId,
|
||||||
|
partName,
|
||||||
|
questions,
|
||||||
|
answers: new Array(questions.length).fill(null),
|
||||||
|
isSubmitted: false,
|
||||||
|
timeUsed: 0,
|
||||||
}),
|
}),
|
||||||
{ name: "test-store" },
|
|
||||||
|
setAnswer: (questionIndex, answerIndex) =>
|
||||||
|
set((state) => {
|
||||||
|
const answers = [...state.answers]
|
||||||
|
answers[questionIndex] = answerIndex
|
||||||
|
return { answers }
|
||||||
|
}),
|
||||||
|
|
||||||
|
submitExam: (timeUsed) => set({ isSubmitted: true, timeUsed }),
|
||||||
|
|
||||||
|
reset: () =>
|
||||||
|
set({ partId: 2, partName: '', questions: [], answers: [], isSubmitted: false, timeUsed: 0 }),
|
||||||
|
}),
|
||||||
|
{ name: 'test-store' },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,28 +1,42 @@
|
|||||||
import { create } from "zustand"
|
import { create } from 'zustand'
|
||||||
import { persist } from "zustand/middleware"
|
import { persist } from 'zustand/middleware'
|
||||||
|
import type { VocabTopic } from '@/types'
|
||||||
|
|
||||||
interface VocabState {
|
interface VocabStore {
|
||||||
// Shell — filled during Vocab feature implementation
|
currentTopic: VocabTopic
|
||||||
knownWords: string[]
|
currentIndex: number
|
||||||
|
knownWords: string[] // word IDs
|
||||||
|
|
||||||
|
setTopic: (topic: VocabTopic) => void
|
||||||
|
setCurrentIndex: (index: number) => void
|
||||||
markKnown: (wordId: string) => void
|
markKnown: (wordId: string) => void
|
||||||
markUnknown: (wordId: string) => void
|
markNeedReview: (wordId: string) => void
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useVocabStore = create<VocabState>()(
|
export const useVocabStore = create<VocabStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
|
currentTopic: 'Tất cả',
|
||||||
|
currentIndex: 0,
|
||||||
knownWords: [],
|
knownWords: [],
|
||||||
|
|
||||||
|
setTopic: (currentTopic) => set({ currentTopic, currentIndex: 0 }),
|
||||||
|
|
||||||
|
setCurrentIndex: (currentIndex) => set({ currentIndex }),
|
||||||
|
|
||||||
markKnown: (wordId) =>
|
markKnown: (wordId) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
knownWords: [...new Set([...state.knownWords, wordId])],
|
knownWords: [...new Set([...state.knownWords, wordId])],
|
||||||
})),
|
})),
|
||||||
markUnknown: (wordId) =>
|
|
||||||
|
markNeedReview: (wordId) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
knownWords: state.knownWords.filter((id) => id !== wordId),
|
knownWords: state.knownWords.filter((id) => id !== wordId),
|
||||||
})),
|
})),
|
||||||
reset: () => set({ knownWords: [] }),
|
|
||||||
|
reset: () => set({ currentTopic: 'Tất cả', currentIndex: 0, knownWords: [] }),
|
||||||
}),
|
}),
|
||||||
{ name: "vocab-store" },
|
{ name: 'vocab-store' },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
60
src/types/index.ts
Normal file
60
src/types/index.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
export interface Question {
|
||||||
|
id: string
|
||||||
|
part: number
|
||||||
|
text: string
|
||||||
|
options: string[]
|
||||||
|
correctAnswer: number // 0-3
|
||||||
|
explanation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VocabWord {
|
||||||
|
id: string
|
||||||
|
word: string
|
||||||
|
phonetic: string
|
||||||
|
meaningVi: string
|
||||||
|
topic: VocabTopic
|
||||||
|
example: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VocabTopic =
|
||||||
|
| 'Tất cả'
|
||||||
|
| 'Business'
|
||||||
|
| 'Office'
|
||||||
|
| 'Travel'
|
||||||
|
| 'Finance'
|
||||||
|
| 'HR'
|
||||||
|
| 'Marketing'
|
||||||
|
|
||||||
|
export const VOCAB_TOPICS: VocabTopic[] = [
|
||||||
|
'Tất cả',
|
||||||
|
'Business',
|
||||||
|
'Office',
|
||||||
|
'Travel',
|
||||||
|
'Finance',
|
||||||
|
'HR',
|
||||||
|
'Marketing',
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface WritingFeedback {
|
||||||
|
score: string
|
||||||
|
grammar: string[]
|
||||||
|
vocabulary: string[]
|
||||||
|
structure: string
|
||||||
|
improvedVersion: string
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToeicPart {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
nameVi: string
|
||||||
|
questionCount: number
|
||||||
|
icon: string
|
||||||
|
progressPercent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
68
stitch-exports/screen-01-home/DESIGN.md
Normal file
68
stitch-exports/screen-01-home/DESIGN.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Design System
|
||||||
|
|
||||||
|
Auto-generated from Google Stitch export.
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
- `border-r-0`
|
||||||
|
- `bg-slate-950`
|
||||||
|
- `text-blue-700`
|
||||||
|
- `text-blue-500`
|
||||||
|
- `text-slate-500`
|
||||||
|
- `bg-slate-800`
|
||||||
|
- `text-blue-400`
|
||||||
|
- `border-l-4`
|
||||||
|
- `border-blue-600`
|
||||||
|
- `text-slate-600`
|
||||||
|
- `text-slate-400`
|
||||||
|
- `text-blue-600`
|
||||||
|
- `bg-slate-900`
|
||||||
|
- `bg-slate-50`
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
- `text-2xl`
|
||||||
|
- `font-bold`
|
||||||
|
- `text-xs`
|
||||||
|
- `font-medium`
|
||||||
|
- `font-semibold`
|
||||||
|
- `text-sm`
|
||||||
|
- `text-5xl`
|
||||||
|
- `font-extrabold`
|
||||||
|
- `text-lg`
|
||||||
|
- `text-base`
|
||||||
|
- `text-xl`
|
||||||
|
- `text-3xl`
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
- `p-0`
|
||||||
|
- `space-y-1`
|
||||||
|
- `gap-3`
|
||||||
|
- `gap-4`
|
||||||
|
- `p-2`
|
||||||
|
- `gap-8`
|
||||||
|
- `p-12`
|
||||||
|
- `m-12`
|
||||||
|
- `p-8`
|
||||||
|
- `space-y-6`
|
||||||
|
- `p-4`
|
||||||
|
- `gap-2`
|
||||||
|
- `m-0`
|
||||||
|
- `space-x-3`
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- `<aside>`
|
||||||
|
- `<nav>`
|
||||||
|
- `<header>`
|
||||||
|
- `<input>`
|
||||||
|
- `<button>`
|
||||||
|
- `<main>`
|
||||||
|
- `<section>`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Generated by Google Stitch AI
|
||||||
|
- Tailwind CSS utility classes used throughout
|
||||||
|
- Review and customize colors/typography for brand alignment
|
||||||
274
stitch-exports/screen-01-home/design.html
Normal file
274
stitch-exports/screen-01-home/design.html
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
<html class="light" lang="vi"><head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Be+Vietnam+Pro:wght@300;400;500;600&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||||
|
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||||
|
<script id="tailwind-config">
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
"colors": {
|
||||||
|
"error-container": "#ffdad6",
|
||||||
|
"tertiary-fixed-dim": "#ffb596",
|
||||||
|
"surface-bright": "#f7f9fb",
|
||||||
|
"background": "#f7f9fb",
|
||||||
|
"on-error": "#ffffff",
|
||||||
|
"outline": "#737686",
|
||||||
|
"secondary-fixed": "#7ffc97",
|
||||||
|
"on-secondary": "#ffffff",
|
||||||
|
"surface-container-high": "#e6e8ea",
|
||||||
|
"inverse-surface": "#2d3133",
|
||||||
|
"secondary-fixed-dim": "#62df7d",
|
||||||
|
"surface-container": "#eceef0",
|
||||||
|
"on-primary-fixed-variant": "#003ea8",
|
||||||
|
"on-background": "#191c1e",
|
||||||
|
"on-primary-container": "#eeefff",
|
||||||
|
"outline-variant": "#c3c6d7",
|
||||||
|
"on-surface": "#191c1e",
|
||||||
|
"surface-tint": "#0053db",
|
||||||
|
"on-primary": "#ffffff",
|
||||||
|
"primary-fixed-dim": "#b4c5ff",
|
||||||
|
"tertiary": "#943700",
|
||||||
|
"surface-variant": "#e0e3e5",
|
||||||
|
"on-error-container": "#93000a",
|
||||||
|
"inverse-on-surface": "#eff1f3",
|
||||||
|
"secondary-container": "#7cf994",
|
||||||
|
"on-tertiary": "#ffffff",
|
||||||
|
"secondary": "#006e2d",
|
||||||
|
"tertiary-container": "#bc4800",
|
||||||
|
"on-secondary-fixed": "#002109",
|
||||||
|
"on-secondary-fixed-variant": "#005320",
|
||||||
|
"tertiary-fixed": "#ffdbcd",
|
||||||
|
"primary-container": "#2563eb",
|
||||||
|
"surface-dim": "#d8dadc",
|
||||||
|
"on-tertiary-fixed-variant": "#7d2d00",
|
||||||
|
"surface-container-low": "#f2f4f6",
|
||||||
|
"surface-container-lowest": "#ffffff",
|
||||||
|
"primary": "#004ac6",
|
||||||
|
"on-primary-fixed": "#00174b",
|
||||||
|
"on-secondary-container": "#007230",
|
||||||
|
"primary-fixed": "#dbe1ff",
|
||||||
|
"inverse-primary": "#b4c5ff",
|
||||||
|
"surface-container-highest": "#e0e3e5",
|
||||||
|
"surface": "#f7f9fb",
|
||||||
|
"on-tertiary-container": "#ffede6",
|
||||||
|
"error": "#ba1a1a",
|
||||||
|
"on-surface-variant": "#434655",
|
||||||
|
"on-tertiary-fixed": "#360f00"
|
||||||
|
},
|
||||||
|
"borderRadius": {
|
||||||
|
"DEFAULT": "0.25rem",
|
||||||
|
"lg": "0.5rem",
|
||||||
|
"xl": "1rem",
|
||||||
|
"full": "9999px"
|
||||||
|
},
|
||||||
|
"fontFamily": {
|
||||||
|
"headline": ["Plus Jakarta Sans"],
|
||||||
|
"body": ["Be Vietnam Pro"],
|
||||||
|
"label": ["Plus Jakarta Sans"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
}
|
||||||
|
.editorial-gradient {
|
||||||
|
background: linear-gradient(135deg, #004ac6 0%, #2563eb 100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-background text-on-surface font-body antialiased">
|
||||||
|
<aside class="w-[240px] h-screen fixed left-0 top-0 border-r-0 bg-[#f7f9fb] dark:bg-slate-950 flex flex-col h-full py-4 z-50">
|
||||||
|
<div class="text-2xl font-bold text-blue-700 dark:text-blue-500 tracking-tight px-6 py-8 font-headline">
|
||||||
|
EnglishAI
|
||||||
|
<div class="text-xs font-medium text-slate-500 mt-1">Học tập thông minh</div>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 space-y-1">
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 mx-3 bg-white dark:bg-slate-800 text-blue-700 dark:text-blue-400 rounded-lg border-l-4 border-blue-600 font-semibold font-label text-sm transition-all duration-150 active:scale-95" href="#">
|
||||||
|
<span class="material-symbols-outlined">home</span>
|
||||||
|
Trang chủ
|
||||||
|
</a>
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 mx-3 text-slate-600 dark:text-slate-400 hover:text-blue-600 hover:bg-white/50 dark:hover:bg-slate-800/50 transition-colors font-label text-sm" href="#">
|
||||||
|
<span class="material-symbols-outlined">assignment</span>
|
||||||
|
Luyện đề TOEIC
|
||||||
|
</a>
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 mx-3 text-slate-600 dark:text-slate-400 hover:text-blue-600 hover:bg-white/50 dark:hover:bg-slate-800/50 transition-colors font-label text-sm" href="#">
|
||||||
|
<span class="material-symbols-outlined">edit_note</span>
|
||||||
|
AI Writing
|
||||||
|
</a>
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 mx-3 text-slate-600 dark:text-slate-400 hover:text-blue-600 hover:bg-white/50 dark:hover:bg-slate-800/50 transition-colors font-label text-sm" href="#">
|
||||||
|
<span class="material-symbols-outlined">menu_book</span>
|
||||||
|
Từ vựng
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="mt-auto space-y-1">
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 mx-3 text-slate-600 dark:text-slate-400 hover:text-blue-600 hover:bg-white/50 dark:hover:bg-slate-800/50 transition-colors font-label text-sm" href="#">
|
||||||
|
<span class="material-symbols-outlined">settings</span>
|
||||||
|
Cài đặt
|
||||||
|
</a>
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 mx-3 text-slate-600 dark:text-slate-400 hover:text-blue-600 hover:bg-white/50 dark:hover:bg-slate-800/50 transition-colors font-label text-sm" href="#">
|
||||||
|
<span class="material-symbols-outlined">logout</span>
|
||||||
|
Đăng xuất
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<header class="fixed top-0 right-0 left-[240px] h-16 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md flex items-center justify-between px-8 w-full z-40">
|
||||||
|
<div class="flex items-center gap-4 bg-surface-container-low px-4 py-2 rounded-full w-96">
|
||||||
|
<span class="material-symbols-outlined text-outline">search</span>
|
||||||
|
<input class="bg-transparent border-none focus:ring-0 text-sm w-full placeholder:text-outline font-body" placeholder="Tìm kiếm bài học, từ vựng..." type="text"/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button class="hover:bg-slate-50 dark:hover:bg-slate-800 rounded-full p-2 transition-all duration-300">
|
||||||
|
<span class="material-symbols-outlined text-outline">notifications</span>
|
||||||
|
</button>
|
||||||
|
<button class="hover:bg-slate-50 dark:hover:bg-slate-800 rounded-full p-2 transition-all duration-300">
|
||||||
|
<span class="material-symbols-outlined text-outline">help_outline</span>
|
||||||
|
</button>
|
||||||
|
<div class="h-8 w-8 rounded-full overflow-hidden ml-2 bg-surface-container-high border border-outline-variant/20">
|
||||||
|
<img alt="User profile" class="w-full h-full object-cover" data-alt="Close-up portrait of a professional young man with a friendly smile, clean minimalist lighting background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuA0ZWIqkQyJEx50wGL3F2fx4gihwSUnhj16u97RZ_VQuoCOrNJGq2-fRo14WuqJj9c27tRydxCBUPLet4kneKIRoPl8kQ-l7uQa5DPw1TCkt7lkabJxrkFnp_48qqgVsMqHMexb5TsBKuUJO9J9o03dzL8CbBsKrJBxxQJqWPw0rnwlmBEDuHe2i1IuWdE50VM12N_r5NcbGz6j658tzIGLFlmbZvJ2xoUct5Tp04UjObQ0q5XN62XW2efN6K0kotnIGMjdtVhVOK4E"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="ml-[240px] pt-24 px-12 pb-12 min-h-screen">
|
||||||
|
<section class="grid grid-cols-12 gap-8 items-center mb-16">
|
||||||
|
<div class="col-span-7">
|
||||||
|
<span class="inline-block px-4 py-1.5 rounded-full bg-primary/10 text-primary font-label text-xs font-bold mb-6 tracking-wider uppercase">AI-Powered Learning</span>
|
||||||
|
<h1 class="text-5xl font-extrabold font-headline leading-[1.1] text-on-background mb-6 tracking-tight">
|
||||||
|
Luyện TOEIC thông minh <br/>
|
||||||
|
<span class="text-primary italic">cùng AI</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg text-on-surface-variant font-body leading-relaxed mb-10 max-w-lg">
|
||||||
|
Cá nhân hóa lộ trình học tập để bứt phá điểm số trong thời gian ngắn nhất. Hệ thống phân tích điểm yếu và tối ưu hóa thời gian học của bạn.
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button class="editorial-gradient text-white px-8 py-4 rounded-xl font-headline font-bold text-base hover:opacity-90 transition-all shadow-lg shadow-primary/20">
|
||||||
|
Bắt đầu ngay
|
||||||
|
</button>
|
||||||
|
<button class="px-8 py-4 rounded-xl font-headline font-bold text-base text-primary hover:bg-primary/5 transition-all">
|
||||||
|
Xem lộ trình
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-5 relative">
|
||||||
|
<div class="absolute -top-12 -right-12 w-64 h-64 bg-primary/5 rounded-full blur-3xl -z-10"></div>
|
||||||
|
<div class="absolute -bottom-12 -left-12 w-48 h-48 bg-secondary/5 rounded-full blur-3xl -z-10"></div>
|
||||||
|
<div class="bg-surface-container-lowest p-8 rounded-[2rem] shadow-[0_32px_64px_-12px_rgba(0,74,198,0.08)] border border-outline-variant/10 relative overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-headline font-bold text-xl text-on-surface">Tiến độ tuần này</h3>
|
||||||
|
<p class="text-sm text-on-surface-variant">Bạn đang làm rất tốt!</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-secondary-container text-on-secondary-container px-3 py-1 rounded-lg font-label text-xs font-bold">
|
||||||
|
+12%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-sm font-label font-bold mb-2">
|
||||||
|
<span>Reading Score</span>
|
||||||
|
<span class="text-primary">420/495</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-3 w-full bg-surface-container rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-primary w-[85%] rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="bg-surface-container-low p-4 rounded-2xl border-l-4 border-primary">
|
||||||
|
<span class="material-symbols-outlined text-primary mb-2">local_fire_department</span>
|
||||||
|
<div class="text-2xl font-extrabold font-headline">14</div>
|
||||||
|
<div class="text-xs font-label text-on-surface-variant font-medium">Ngày Streak</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface-container-low p-4 rounded-2xl border-l-4 border-secondary">
|
||||||
|
<span class="material-symbols-outlined text-secondary mb-2" style="font-variation-settings: 'FILL' 1;">star</span>
|
||||||
|
<div class="text-2xl font-extrabold font-headline">1,250</div>
|
||||||
|
<div class="text-xs font-label text-on-surface-variant font-medium">Exp tích lũy</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pt-4 border-t border-outline-variant/10 flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-surface-variant flex items-center justify-center">
|
||||||
|
<span class="material-symbols-outlined text-on-surface-variant">psychology</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-xs font-label font-bold text-on-surface">AI Phân tích:</div>
|
||||||
|
<div class="text-xs text-on-surface-variant">Bạn cần cải thiện phần Part 5 - Ngữ pháp</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="mt-20">
|
||||||
|
<div class="flex items-center justify-between mb-10">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-extrabold font-headline tracking-tight mb-2">Tính năng nổi bật</h2>
|
||||||
|
<p class="text-on-surface-variant max-w-xl">Hệ sinh thái học tập toàn diện được thiết kế để tối ưu hóa khả năng ghi nhớ.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-8">
|
||||||
|
<div class="group bg-surface-container-lowest p-8 rounded-[1.5rem] transition-all duration-300 hover:translate-y-[-8px] hover:shadow-[0_20px_40px_-10px_rgba(0,0,0,0.05)] border-l-4 border-primary">
|
||||||
|
<div class="w-14 h-14 rounded-2xl bg-primary/10 flex items-center justify-center mb-6 transition-colors group-hover:bg-primary group-hover:text-white">
|
||||||
|
<span class="material-symbols-outlined text-3xl">assignment</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-bold font-headline mb-3 text-on-surface">Luyện đề TOEIC</h3>
|
||||||
|
<p class="text-on-surface-variant text-sm leading-relaxed mb-6">Kho đề thi cập nhật liên tục theo cấu trúc mới nhất. Chế độ thi thử như thật.</p>
|
||||||
|
<a class="text-primary font-bold font-label text-sm flex items-center gap-2" href="#">
|
||||||
|
Khám phá ngay
|
||||||
|
<span class="material-symbols-outlined text-sm">arrow_forward</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="group bg-surface-container-lowest p-8 rounded-[1.5rem] transition-all duration-300 hover:translate-y-[-8px] hover:shadow-[0_20px_40px_-10px_rgba(0,0,0,0.05)] border-l-4 border-secondary">
|
||||||
|
<div class="w-14 h-14 rounded-2xl bg-secondary/10 flex items-center justify-center mb-6 transition-colors group-hover:bg-secondary group-hover:text-white">
|
||||||
|
<span class="material-symbols-outlined text-3xl">auto_fix_high</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-bold font-headline mb-3 text-on-surface">AI Chấm Writing</h3>
|
||||||
|
<p class="text-on-surface-variant text-sm leading-relaxed mb-6">Chấm bài Writing ngay lập tức với phản hồi chi tiết về từ vựng và ngữ pháp từ AI.</p>
|
||||||
|
<a class="text-secondary font-bold font-label text-sm flex items-center gap-2" href="#">
|
||||||
|
Bắt đầu viết
|
||||||
|
<span class="material-symbols-outlined text-sm">arrow_forward</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="group bg-surface-container-lowest p-8 rounded-[1.5rem] transition-all duration-300 hover:translate-y-[-8px] hover:shadow-[0_20px_40px_-10px_rgba(0,0,0,0.05)] border-l-4 border-tertiary">
|
||||||
|
<div class="w-14 h-14 rounded-2xl bg-tertiary/10 flex items-center justify-center mb-6 transition-colors group-hover:bg-tertiary group-hover:text-white">
|
||||||
|
<span class="material-symbols-outlined text-3xl">menu_book</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-bold font-headline mb-3 text-on-surface">Từ vựng thông minh</h3>
|
||||||
|
<p class="text-on-surface-variant text-sm leading-relaxed mb-6">Học từ vựng qua Flashcards và phương pháp lặp lại ngắt quãng (Spaced Repetition).</p>
|
||||||
|
<a class="text-tertiary font-bold font-label text-sm flex items-center gap-2" href="#">
|
||||||
|
Học từ mới
|
||||||
|
<span class="material-symbols-outlined text-sm">arrow_forward</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="mt-20">
|
||||||
|
<div class="bg-surface-container rounded-[2.5rem] p-12 overflow-hidden relative">
|
||||||
|
<div class="absolute right-0 top-0 bottom-0 w-1/2 opacity-10">
|
||||||
|
<img alt="AI illustration" class="w-full h-full object-cover" data-alt="Futuristic glowing digital mesh representation of artificial intelligence network with abstract nodes and connections" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCSSasyfoORYP6jd2y5btaMP3iGBXWU3Y1jeo_tsU4zUVx45XLDy-KKo46oR3C8dNdBc-qTuhKqBdBOxD15l2myErQn9gt10Pfu4Wm_bhXNbaa15oEpItUPaWfQnAA_h5gEmLhj7xzzJ4rlI_AsX8DY98CtCC1rTtryGjT--ruvMGeRERGmMhvJT2ZuvHNbhjLBMpC-SfscHz3pAYdIWAz0sgwcwagyyBCoyil0mphauyO10T5DBVaBJTX48YfGbLgDySCVZasaZ46h"/>
|
||||||
|
</div>
|
||||||
|
<div class="relative z-10 max-w-2xl">
|
||||||
|
<h2 class="text-3xl font-extrabold font-headline mb-6">Sẵn sàng để chinh phục 990 TOEIC?</h2>
|
||||||
|
<p class="text-on-surface-variant mb-10 text-lg">Hơn 50,000 học viên đã tin tưởng và cải thiện điểm số vượt mong đợi cùng EnglishAI. Tham gia ngay hôm nay để nhận lộ trình học miễn phí.</p>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<button class="bg-on-background text-white px-8 py-4 rounded-xl font-headline font-bold text-base hover:bg-on-background/90 transition-all">
|
||||||
|
Dùng thử miễn phí
|
||||||
|
</button>
|
||||||
|
<div class="flex -space-x-3 items-center ml-4">
|
||||||
|
<img alt="User" class="w-10 h-10 rounded-full border-2 border-surface-container" data-alt="Portrait of a smiling young woman with professional lighting and clean background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBw8x4U7Kt0x3D-FO48V0QM4CyjvN26o0YgCUjawS6ueS-cj8bEaR5lD02v5PkC-Mw8u_YOauqUPmwRjj_YJbE_MCUYUv-FHWJm31i4BdtnXPvldN_1ep77JSJ6hSvZLoW0KOwZPBq7jus4KSbG2Rsw_bbdoaQif_uhyU4gcDDNE91uiIBCVVPXKrYtvqDqaMEfXXmZ0XihLJigIVU0eSzDKIOHZK0Ah0oZto3BDXRCSvThbbm0JzdDewbWph8HKi9HdGoKOhK-NtF8"/>
|
||||||
|
<img alt="User" class="w-10 h-10 rounded-full border-2 border-surface-container" data-alt="Smiling businessman portrait in modern office setting with soft natural light" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBv44vasos6Ur7QJteTC-2U9vjYIYqs3Ws9HZ3Bupz6R0_drdvdFmyljNqd1y5kjuzyslr0nKlKdBqpHyiQs4W0bEoFpC2GTBJrmZt5JARA2yNsM0FzssyDY8h0D3t9FS4L35j6ykVaTDhc4pp8PIbc_ajk0YVGyQM6poAWxf7CQuFc5w6qT3cWJwhlbryYr8ylnEYwF8O4UyvJDzFTphS0ie9crgqOucFqmk8cEcXYgFLRWfNXRL5CP6BJOtFLpj2hRJdOuV3Qc1Xu"/>
|
||||||
|
<img alt="User" class="w-10 h-10 rounded-full border-2 border-surface-container" data-alt="Natural portrait of a young woman with a warm expression in a soft-focus outdoor setting" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBKfV9AmaCZo_mJHFwy9xZRBxMzkBNsUe1qSn67GjJmSUR59PxL207qIzfA2CClP9fcIsBoUetDAkg1-DgB5tTX1Apc774taKEXPAd4hQ0-HIq1-FO0auW9wO_MTlnj9JnSblADm1fpeLtA19KON7joHo4_ruTZ_iThMvoOL8RzkLXEUrdb-lECuBbr8o9LIpzLuzAXxxoOLUf9ixIcKqn9FVziPXvKr7fRd19jFfhzq87KTYO-NcRtwyIuBzi_94tnmZhg1FkPrz3O"/>
|
||||||
|
<span class="pl-6 text-sm font-label font-bold text-on-surface">+2.5k học viên mới tháng này</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body></html>
|
||||||
BIN
stitch-exports/screen-01-home/design.png
Normal file
BIN
stitch-exports/screen-01-home/design.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
63
stitch-exports/screen-02-part-select/DESIGN.md
Normal file
63
stitch-exports/screen-02-part-select/DESIGN.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Design System
|
||||||
|
|
||||||
|
Auto-generated from Google Stitch export.
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
- `bg-slate-50`
|
||||||
|
- `border-r-0`
|
||||||
|
- `text-blue-700`
|
||||||
|
- `text-slate-400`
|
||||||
|
- `text-slate-500`
|
||||||
|
- `text-slate-900`
|
||||||
|
- `bg-slate-200`
|
||||||
|
- `border-b-0`
|
||||||
|
- `bg-slate-100`
|
||||||
|
- `text-slate-600`
|
||||||
|
- `bg-blue-50`
|
||||||
|
- `text-slate-100`
|
||||||
|
- `text-amber-50`
|
||||||
|
- `text-amber-100`
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
- `text-xl`
|
||||||
|
- `font-bold`
|
||||||
|
- `font-semibold`
|
||||||
|
- `text-sm`
|
||||||
|
- `font-medium`
|
||||||
|
- `text-xs`
|
||||||
|
- `text-lg`
|
||||||
|
- `text-4xl`
|
||||||
|
- `font-extrabold`
|
||||||
|
- `text-2xl`
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
- `p-0`
|
||||||
|
- `p-4`
|
||||||
|
- `gap-2`
|
||||||
|
- `space-y-1`
|
||||||
|
- `gap-3`
|
||||||
|
- `gap-4`
|
||||||
|
- `gap-6`
|
||||||
|
- `p-2`
|
||||||
|
- `p-6`
|
||||||
|
- `p-3`
|
||||||
|
- `p-8`
|
||||||
|
- `space-x-3`
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- `<aside>`
|
||||||
|
- `<nav>`
|
||||||
|
- `<header>`
|
||||||
|
- `<button>`
|
||||||
|
- `<main>`
|
||||||
|
- `<section>`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Generated by Google Stitch AI
|
||||||
|
- Tailwind CSS utility classes used throughout
|
||||||
|
- Review and customize colors/typography for brand alignment
|
||||||
374
stitch-exports/screen-02-part-select/design.html
Normal file
374
stitch-exports/screen-02-part-select/design.html
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
<html class="light" lang="vi"><head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||||
|
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||||
|
<script id="tailwind-config">
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
"colors": {
|
||||||
|
"surface-tint": "#0053db",
|
||||||
|
"on-tertiary-fixed-variant": "#7d2d00",
|
||||||
|
"primary-fixed-dim": "#b4c5ff",
|
||||||
|
"tertiary-fixed": "#ffdbcd",
|
||||||
|
"surface-variant": "#e0e3e5",
|
||||||
|
"on-error": "#ffffff",
|
||||||
|
"tertiary-fixed-dim": "#ffb596",
|
||||||
|
"surface-container-lowest": "#ffffff",
|
||||||
|
"surface-container-highest": "#e0e3e5",
|
||||||
|
"secondary": "#495c95",
|
||||||
|
"surface-container-high": "#e6e8ea",
|
||||||
|
"tertiary": "#943700",
|
||||||
|
"on-primary-fixed-variant": "#003ea8",
|
||||||
|
"primary": "#004ac6",
|
||||||
|
"primary-fixed": "#dbe1ff",
|
||||||
|
"error": "#ba1a1a",
|
||||||
|
"on-tertiary-container": "#ffede6",
|
||||||
|
"on-tertiary": "#ffffff",
|
||||||
|
"on-tertiary-fixed": "#360f00",
|
||||||
|
"surface-bright": "#f7f9fb",
|
||||||
|
"secondary-fixed-dim": "#b4c5ff",
|
||||||
|
"inverse-primary": "#b4c5ff",
|
||||||
|
"on-surface-variant": "#434655",
|
||||||
|
"secondary-container": "#acbfff",
|
||||||
|
"primary-container": "#2563eb",
|
||||||
|
"error-container": "#ffdad6",
|
||||||
|
"surface": "#f7f9fb",
|
||||||
|
"inverse-surface": "#2d3133",
|
||||||
|
"tertiary-container": "#bc4800",
|
||||||
|
"surface-container": "#eceef0",
|
||||||
|
"on-secondary-fixed": "#00174b",
|
||||||
|
"on-background": "#191c1e",
|
||||||
|
"secondary-fixed": "#dbe1ff",
|
||||||
|
"surface-dim": "#d8dadc",
|
||||||
|
"on-surface": "#191c1e",
|
||||||
|
"on-primary-fixed": "#00174b",
|
||||||
|
"on-primary-container": "#eeefff",
|
||||||
|
"on-secondary-container": "#394c84",
|
||||||
|
"on-secondary-fixed-variant": "#31447b",
|
||||||
|
"on-primary": "#ffffff",
|
||||||
|
"outline-variant": "#c3c6d7",
|
||||||
|
"inverse-on-surface": "#eff1f3",
|
||||||
|
"on-secondary": "#ffffff",
|
||||||
|
"surface-container-low": "#f2f4f6",
|
||||||
|
"on-error-container": "#93000a",
|
||||||
|
"outline": "#737686",
|
||||||
|
"background": "#f7f9fb"
|
||||||
|
},
|
||||||
|
"borderRadius": {
|
||||||
|
"DEFAULT": "0.25rem",
|
||||||
|
"lg": "0.5rem",
|
||||||
|
"xl": "0.75rem",
|
||||||
|
"full": "9999px"
|
||||||
|
},
|
||||||
|
"fontFamily": {
|
||||||
|
"headline": ["Plus Jakarta Sans"],
|
||||||
|
"body": ["Plus Jakarta Sans"],
|
||||||
|
"label": ["Plus Jakarta Sans"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.circular-progress {
|
||||||
|
--percent: 0;
|
||||||
|
background: conic-gradient(var(--tw-primary) calc(var(--percent) * 1%), #eef2f6 0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface text-on-surface antialiased">
|
||||||
|
<!-- SideNavBar Component -->
|
||||||
|
<aside class="fixed left-0 top-0 h-full w-[240px] z-30 bg-slate-50 border-r-0 flex flex-col p-4 gap-2 font-plus-jakarta-sans antialiased">
|
||||||
|
<div class="mb-8 px-4 py-2">
|
||||||
|
<h1 class="text-xl font-bold tracking-tight text-blue-700">Cognitive</h1>
|
||||||
|
<p class="text-[10px] uppercase tracking-widest text-slate-400 font-semibold">TOEIC Excellence</p>
|
||||||
|
</div>
|
||||||
|
<nav class="space-y-1">
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 text-slate-500 hover:text-slate-900 hover:bg-white/50 transition-colors duration-200" href="#">
|
||||||
|
<span class="material-symbols-outlined" data-icon="home">home</span>
|
||||||
|
<span class="text-sm font-medium">Home</span>
|
||||||
|
</a>
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 text-blue-700 font-semibold bg-white rounded-xl translate-x-1 transition-transform" href="#">
|
||||||
|
<span class="material-symbols-outlined" data-icon="menu_book">menu_book</span>
|
||||||
|
<span class="text-sm font-medium">Luyện đề TOEIC</span>
|
||||||
|
</a>
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 text-slate-500 hover:text-slate-900 hover:bg-white/50 transition-colors duration-200" href="#">
|
||||||
|
<span class="material-symbols-outlined" data-icon="edit_note">edit_note</span>
|
||||||
|
<span class="text-sm font-medium">AI Writing</span>
|
||||||
|
</a>
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 text-slate-500 hover:text-slate-900 hover:bg-white/50 transition-colors duration-200" href="#">
|
||||||
|
<span class="material-symbols-outlined" data-icon="auto_stories">auto_stories</span>
|
||||||
|
<span class="text-sm font-medium">Từ vựng</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="mt-auto p-4 bg-surface-container-low rounded-2xl">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="w-10 h-10 rounded-full overflow-hidden bg-slate-200">
|
||||||
|
<img alt="User Profile" class="w-full h-full object-cover" data-alt="professional portrait of a young student in a modern workspace with soft lighting" src="https://lh3.googleusercontent.com/aida-public/AB6AXuB2DyDjUcE-9WiIp0Tt62qf63sNOD-xVceN95cAo069z6zibL4_Sbh6U3oayWhpJYL9QPYTJ5EQEhuiWmGhQ13Ivx0uIYCLCorYUQgiUyiUISCG-RwEwT1Bu9faxGG0VnICoUExWfsy5jBCSqvPJGPHquImGPHYRGLMRx5x63CFSatidiEdUBjog8GiwRFUOYQy-8ReECHPQm58MXOSxn9YBb8vBVmiUZn6_7pejyzEsXpgUIC7-puqouBCPidqoboYy93V55obABdR"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-bold text-on-surface">Minh Anh</p>
|
||||||
|
<p class="text-[10px] text-slate-500">Premium Member</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-1.5 w-full bg-slate-200 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-primary w-2/3"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] mt-2 text-slate-500 font-medium text-center">Daily Goal: 65%</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<!-- TopNavBar Component -->
|
||||||
|
<header class="fixed top-0 right-0 left-[240px] h-16 z-20 bg-white/80 backdrop-blur-xl flex items-center justify-between px-8 w-full border-b-0">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="text-lg font-bold text-slate-900">Luyện đề TOEIC</span>
|
||||||
|
<div class="h-4 w-px bg-slate-200"></div>
|
||||||
|
<nav class="flex gap-6">
|
||||||
|
<a class="text-sm font-medium text-blue-700 font-bold" href="#">Part Selection</a>
|
||||||
|
<a class="text-sm font-medium text-slate-500 hover:opacity-80 transition-opacity" href="#">Practice History</a>
|
||||||
|
<a class="text-sm font-medium text-slate-500 hover:opacity-80 transition-opacity" href="#">Mock Tests</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button class="hover:bg-slate-100 rounded-full p-2 transition-all">
|
||||||
|
<span class="material-symbols-outlined text-slate-600" data-icon="notifications">notifications</span>
|
||||||
|
</button>
|
||||||
|
<button class="hover:bg-slate-100 rounded-full p-2 transition-all">
|
||||||
|
<span class="material-symbols-outlined text-slate-600" data-icon="settings">settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<main class="ml-[240px] pt-24 px-12 pb-12 min-h-screen">
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<header class="mb-12 max-w-4xl pt-8">
|
||||||
|
<h2 class="text-4xl font-extrabold tracking-tight text-on-surface mb-3">Chọn Part TOEIC</h2>
|
||||||
|
<p class="text-slate-500 text-lg max-w-2xl leading-relaxed">
|
||||||
|
Hệ thống ôn luyện thông minh được thiết kế theo cấu trúc bài thi TOEIC thực tế.
|
||||||
|
Chọn một phần cụ thể để bắt đầu tối ưu hóa điểm số của bạn ngay hôm nay.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<!-- Bento Grid Layout -->
|
||||||
|
<div class="grid grid-cols-4 gap-6">
|
||||||
|
<!-- Part 1 Card -->
|
||||||
|
<div class="bg-white p-6 rounded-[16px] shadow-sm hover:shadow-md transition-shadow group cursor-pointer border border-transparent hover:border-primary/10">
|
||||||
|
<div class="flex justify-between items-start mb-6">
|
||||||
|
<div class="p-3 bg-blue-50 rounded-xl group-hover:bg-primary transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-primary group-hover:text-white" data-icon="image">image</span>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-12 h-12 flex items-center justify-center">
|
||||||
|
<svg class="w-12 h-12 transform -rotate-90">
|
||||||
|
<circle class="text-slate-100" cx="24" cy="24" fill="transparent" r="20" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<circle class="text-primary" cx="24" cy="24" fill="transparent" r="20" stroke="currentColor" stroke-dasharray="125.6" stroke-dashoffset="50.24" stroke-width="4"></circle>
|
||||||
|
</svg>
|
||||||
|
<span class="absolute text-[10px] font-bold">60%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-lg mb-1">Part 1</h3>
|
||||||
|
<p class="text-sm font-medium text-slate-900 mb-2">Mô tả hình ảnh</p>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
<span class="material-symbols-outlined text-sm" data-icon="list_alt">list_alt</span>
|
||||||
|
<span>45 câu hỏi</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Part 2 Card -->
|
||||||
|
<div class="bg-white p-6 rounded-[16px] shadow-sm hover:shadow-md transition-shadow group cursor-pointer border border-transparent hover:border-primary/10">
|
||||||
|
<div class="flex justify-between items-start mb-6">
|
||||||
|
<div class="p-3 bg-blue-50 rounded-xl group-hover:bg-primary transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-primary group-hover:text-white" data-icon="question_answer">question_answer</span>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-12 h-12 flex items-center justify-center">
|
||||||
|
<svg class="w-12 h-12 transform -rotate-90">
|
||||||
|
<circle class="text-slate-100" cx="24" cy="24" fill="transparent" r="20" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<circle class="text-primary" cx="24" cy="24" fill="transparent" r="20" stroke="currentColor" stroke-dasharray="125.6" stroke-dashoffset="75.36" stroke-width="4"></circle>
|
||||||
|
</svg>
|
||||||
|
<span class="absolute text-[10px] font-bold">40%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-lg mb-1">Part 2</h3>
|
||||||
|
<p class="text-sm font-medium text-slate-900 mb-2">Hỏi-đáp</p>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
<span class="material-symbols-outlined text-sm" data-icon="list_alt">list_alt</span>
|
||||||
|
<span>30 câu hỏi</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Part 3 Card -->
|
||||||
|
<div class="bg-white p-6 rounded-[16px] shadow-sm hover:shadow-md transition-shadow group cursor-pointer border border-transparent hover:border-primary/10">
|
||||||
|
<div class="flex justify-between items-start mb-6">
|
||||||
|
<div class="p-3 bg-blue-50 rounded-xl group-hover:bg-primary transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-primary group-hover:text-white" data-icon="forum">forum</span>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-12 h-12 flex items-center justify-center">
|
||||||
|
<svg class="w-12 h-12 transform -rotate-90">
|
||||||
|
<circle class="text-slate-100" cx="24" cy="24" fill="transparent" r="20" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<circle class="text-primary" cx="24" cy="24" fill="transparent" r="20" stroke="currentColor" stroke-dasharray="125.6" stroke-dashoffset="94.2" stroke-width="4"></circle>
|
||||||
|
</svg>
|
||||||
|
<span class="absolute text-[10px] font-bold">25%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-lg mb-1">Part 3</h3>
|
||||||
|
<p class="text-sm font-medium text-slate-900 mb-2">Đoạn hội thoại</p>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
<span class="material-symbols-outlined text-sm" data-icon="list_alt">list_alt</span>
|
||||||
|
<span>39 câu hỏi</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Part 4 Card -->
|
||||||
|
<div class="bg-white p-6 rounded-[16px] shadow-sm hover:shadow-md transition-shadow group cursor-pointer border border-transparent hover:border-primary/10">
|
||||||
|
<div class="flex justify-between items-start mb-6">
|
||||||
|
<div class="p-3 bg-blue-50 rounded-xl group-hover:bg-primary transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-primary group-hover:text-white" data-icon="record_voice_over">record_voice_over</span>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-12 h-12 flex items-center justify-center">
|
||||||
|
<svg class="w-12 h-12 transform -rotate-90">
|
||||||
|
<circle class="text-slate-100" cx="24" cy="24" fill="transparent" r="20" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<circle class="text-primary" cx="24" cy="24" fill="transparent" r="20" stroke="currentColor" stroke-dasharray="125.6" stroke-dashoffset="113.04" stroke-width="4"></circle>
|
||||||
|
</svg>
|
||||||
|
<span class="absolute text-[10px] font-bold">10%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-lg mb-1">Part 4</h3>
|
||||||
|
<p class="text-sm font-medium text-slate-900 mb-2">Bài nói</p>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
<span class="material-symbols-outlined text-sm" data-icon="list_alt">list_alt</span>
|
||||||
|
<span>30 câu hỏi</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Part 5 Card -->
|
||||||
|
<div class="bg-white p-6 rounded-[16px] shadow-sm hover:shadow-md transition-shadow group cursor-pointer border border-transparent hover:border-primary/10">
|
||||||
|
<div class="flex justify-between items-start mb-6">
|
||||||
|
<div class="p-3 bg-blue-50 rounded-xl group-hover:bg-primary transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-primary group-hover:text-white" data-icon="history_edu">history_edu</span>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-12 h-12 flex items-center justify-center">
|
||||||
|
<svg class="w-12 h-12 transform -rotate-90">
|
||||||
|
<circle class="text-slate-100" cx="24" cy="24" fill="transparent" r="20" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<circle class="text-primary" cx="24" cy="24" fill="transparent" r="20" stroke="currentColor" stroke-dasharray="125.6" stroke-dashoffset="25.12" stroke-width="4"></circle>
|
||||||
|
</svg>
|
||||||
|
<span class="absolute text-[10px] font-bold">80%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-lg mb-1">Part 5</h3>
|
||||||
|
<p class="text-sm font-medium text-slate-900 mb-2">Điền từ</p>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
<span class="material-symbols-outlined text-sm" data-icon="list_alt">list_alt</span>
|
||||||
|
<span>40 câu hỏi</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Part 6 Card -->
|
||||||
|
<div class="bg-white p-6 rounded-[16px] shadow-sm hover:shadow-md transition-shadow group cursor-pointer border border-transparent hover:border-primary/10">
|
||||||
|
<div class="flex justify-between items-start mb-6">
|
||||||
|
<div class="p-3 bg-blue-50 rounded-xl group-hover:bg-primary transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-primary group-hover:text-white" data-icon="article">article</span>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-12 h-12 flex items-center justify-center">
|
||||||
|
<svg class="w-12 h-12 transform -rotate-90">
|
||||||
|
<circle class="text-slate-100" cx="24" cy="24" fill="transparent" r="20" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<circle class="text-primary" cx="24" cy="24" fill="transparent" r="20" stroke="currentColor" stroke-dasharray="125.6" stroke-dashoffset="62.8" stroke-width="4"></circle>
|
||||||
|
</svg>
|
||||||
|
<span class="absolute text-[10px] font-bold">50%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-lg mb-1">Part 6</h3>
|
||||||
|
<p class="text-sm font-medium text-slate-900 mb-2">Điền đoạn</p>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
<span class="material-symbols-outlined text-sm" data-icon="list_alt">list_alt</span>
|
||||||
|
<span>16 câu hỏi</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Part 7 Card -->
|
||||||
|
<div class="bg-white p-6 rounded-[16px] shadow-sm hover:shadow-md transition-shadow group cursor-pointer border border-transparent hover:border-primary/10">
|
||||||
|
<div class="flex justify-between items-start mb-6">
|
||||||
|
<div class="p-3 bg-blue-50 rounded-xl group-hover:bg-primary transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-primary group-hover:text-white" data-icon="menu_book">menu_book</span>
|
||||||
|
</div>
|
||||||
|
<div class="relative w-12 h-12 flex items-center justify-center">
|
||||||
|
<svg class="w-12 h-12 transform -rotate-90">
|
||||||
|
<circle class="text-slate-100" cx="24" cy="24" fill="transparent" r="20" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<circle class="text-primary" cx="24" cy="24" fill="transparent" r="20" stroke="currentColor" stroke-dasharray="125.6" stroke-dashoffset="87.92" stroke-width="4"></circle>
|
||||||
|
</svg>
|
||||||
|
<span class="absolute text-[10px] font-bold">30%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-lg mb-1">Part 7</h3>
|
||||||
|
<p class="text-sm font-medium text-slate-900 mb-2">Đọc hiểu</p>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
<span class="material-symbols-outlined text-sm" data-icon="list_alt">list_alt</span>
|
||||||
|
<span>54 câu hỏi</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Full Test Card (Specialized) -->
|
||||||
|
<div class="relative overflow-hidden bg-gradient-to-br from-amber-500 to-orange-600 p-6 rounded-[16px] shadow-xl hover:shadow-2xl transition-all group cursor-pointer ring-4 ring-amber-100">
|
||||||
|
<div class="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||||
|
<span class="material-symbols-outlined text-8xl" data-icon="workspace_premium">workspace_premium</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-start mb-6">
|
||||||
|
<div class="p-3 bg-white/20 backdrop-blur-md rounded-xl">
|
||||||
|
<span class="material-symbols-outlined text-white" data-icon="military_tech">military_tech</span>
|
||||||
|
</div>
|
||||||
|
<span class="px-2 py-1 bg-white/20 backdrop-blur-md rounded-full text-[10px] font-extrabold text-white uppercase tracking-wider">Most Popular</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-extrabold text-2xl text-white mb-1">Full Test</h3>
|
||||||
|
<p class="text-sm font-semibold text-amber-50 mb-4">Mô phỏng thi thật 2h</p>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-amber-100">
|
||||||
|
<span class="material-symbols-outlined text-sm" data-icon="timer">timer</span>
|
||||||
|
<span>120 phút • 200 câu</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="w-full bg-white/20 h-1 rounded-full">
|
||||||
|
<div class="bg-white w-[15%] h-full rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Featured Section (Bento Complement) -->
|
||||||
|
<section class="mt-12 grid grid-cols-12 gap-6">
|
||||||
|
<div class="col-span-8 bg-surface-container-low rounded-3xl p-8 flex items-center justify-between overflow-hidden relative">
|
||||||
|
<div class="relative z-10">
|
||||||
|
<h4 class="text-2xl font-bold mb-2">Luyện đề tập trung với AI</h4>
|
||||||
|
<p class="text-slate-500 max-w-sm mb-6">Hệ thống AI phân tích điểm yếu của bạn qua từng Part và đề xuất lộ trình ôn tập cá nhân hóa.</p>
|
||||||
|
<button class="bg-primary text-white px-6 py-3 rounded-xl font-bold hover:bg-primary-container transition-all flex items-center gap-2">
|
||||||
|
Bắt đầu phân tích
|
||||||
|
<span class="material-symbols-outlined text-sm" data-icon="arrow_forward">arrow_forward</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2 absolute -right-8 top-0 h-full">
|
||||||
|
<img alt="Study Session" class="w-full h-full object-cover rounded-l-3xl opacity-80" data-alt="clean overhead shot of an open notebook with diagrams and highlighters on a minimalist desk" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCZnS_tphqYtglGxJ9k3Uu3Sbr6aEjFZRIf8Hkb3F0-neWUVilyKpnabaOQPiY9Jfmy4pyuWbJJKxRYAHSb-tS6KXLoE6ZWotNEDZi_Ly7k-U_QgFTX03lQVuuv7RKn9UJjcD-1Q_Ir_b65mGs-Y_VBveqOZ1AP0wGlzpV4rw8l7MGQjxLUGUMDynjnXMs6oXBafWqqC4WVH3cX-iy6uSXy5cGOTS7Au1qFKVm9-ES6_aDiiRKoK2eskH3DoHeeNDcEUDF-ao2Y5Y-S"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-4 bg-primary rounded-3xl p-8 text-white flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<span class="material-symbols-outlined text-4xl mb-4" data-icon="auto_awesome">auto_awesome</span>
|
||||||
|
<h4 class="text-xl font-bold">Thử thách ngày</h4>
|
||||||
|
<p class="text-white/80 text-sm mt-2">Hoàn thành 30 câu Part 5 để nhận ngay 500 streak points.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end justify-between mt-8">
|
||||||
|
<div class="flex -space-x-3">
|
||||||
|
<div class="w-8 h-8 rounded-full border-2 border-primary overflow-hidden">
|
||||||
|
<img class="w-full h-full object-cover" data-alt="portrait of a young smiling professional woman in bright natural light" src="https://lh3.googleusercontent.com/aida-public/AB6AXuD9e1IfPLx7fEAePcZypuqPCiWM1Fhipa_EEF7XypG_Gl3IRFW1U9gGtIValIys5nOKcEOD1THDZwh0M5mOtPYG2NzigaD6k29IQa6AfsO-eUes3qg1hRtMBOCNUViM5e1l_lklgQouPjNVB29Oupzks1-zM-NT-lHXv_JU7XYY0I10JEmLTL4G0RGvhcXC53_ue7Cr_TeOXI7blxXbgDBHpGSP3d3n5j36ox6A3fmU8tVlWDYOXse93OM9bbPcwpj0IMsnKhyotGfE"/>
|
||||||
|
</div>
|
||||||
|
<div class="w-8 h-8 rounded-full border-2 border-primary overflow-hidden">
|
||||||
|
<img class="w-full h-full object-cover" data-alt="professional portrait of a man with spectacles in soft office lighting" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBH2Jid_vlxwm6_s9lZL8IgtjW3WcH7flqF63vvGi8oTv1Foclr6u55wiEt-RKIN8tCie-titrJzTsSALxOLYwDTd95uktEVnyG6PlC1EV6FTtWgw7HXpPPj6oTgEmJzbdge515kkBMkWZ0JS4xz12_wXFrMo6Ej5aybeKy9oMdq7eXAAerty_R86H-0UtxDPKRTroaBl_hdYjW9mVwsPG66ygBuq0nyvIG5_fqbmeoD_pevNyhcCBKBG29qNAKgvZSmboKTLhjfa7q"/>
|
||||||
|
</div>
|
||||||
|
<div class="w-8 h-8 rounded-full border-2 border-primary bg-white/20 flex items-center justify-center text-[10px] font-bold">
|
||||||
|
+12
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium bg-white/20 px-3 py-1 rounded-full">Tham gia ngay</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<!-- Contextual FAB (Hidden as per suppression rule for details/selection but kept only for general study flow if desired. Here suppressed for focus.) -->
|
||||||
|
</body></html>
|
||||||
BIN
stitch-exports/screen-02-part-select/design.png
Normal file
BIN
stitch-exports/screen-02-part-select/design.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
69
stitch-exports/screen-03-exam/DESIGN.md
Normal file
69
stitch-exports/screen-03-exam/DESIGN.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Design System
|
||||||
|
|
||||||
|
Auto-generated from Google Stitch export.
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
- `bg-slate-50`
|
||||||
|
- `bg-slate-900`
|
||||||
|
- `text-blue-700`
|
||||||
|
- `text-blue-500`
|
||||||
|
- `text-slate-900`
|
||||||
|
- `text-slate-500`
|
||||||
|
- `text-slate-400`
|
||||||
|
- `bg-slate-200`
|
||||||
|
- `bg-slate-800`
|
||||||
|
- `text-blue-400`
|
||||||
|
- `border-l-4`
|
||||||
|
- `border-blue-700`
|
||||||
|
- `border-blue-500`
|
||||||
|
- `bg-blue-50`
|
||||||
|
- `bg-blue-900`
|
||||||
|
- `bg-slate-950`
|
||||||
|
- `border-slate-200`
|
||||||
|
- `border-slate-800`
|
||||||
|
- `text-blue-600`
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
- `text-xl`
|
||||||
|
- `font-bold`
|
||||||
|
- `text-sm`
|
||||||
|
- `text-xs`
|
||||||
|
- `text-lg`
|
||||||
|
- `font-semibold`
|
||||||
|
- `text-5xl`
|
||||||
|
- `font-extrabold`
|
||||||
|
- `font-medium`
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
- `p-0`
|
||||||
|
- `gap-6`
|
||||||
|
- `gap-1`
|
||||||
|
- `p-8`
|
||||||
|
- `gap-8`
|
||||||
|
- `space-y-6`
|
||||||
|
- `p-10`
|
||||||
|
- `space-y-3`
|
||||||
|
- `p-4`
|
||||||
|
- `gap-2`
|
||||||
|
- `p-24`
|
||||||
|
- `gap-3`
|
||||||
|
- `gap-4`
|
||||||
|
- `p-6`
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- `<aside>`
|
||||||
|
- `<nav>`
|
||||||
|
- `<header>`
|
||||||
|
- `<button>`
|
||||||
|
- `<main>`
|
||||||
|
- `<section>`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Generated by Google Stitch AI
|
||||||
|
- Tailwind CSS utility classes used throughout
|
||||||
|
- Review and customize colors/typography for brand alignment
|
||||||
233
stitch-exports/screen-03-exam/design.html
Normal file
233
stitch-exports/screen-03-exam/design.html
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
<html class="light" lang="vi"><head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
|
<title>TOEIC Exam Session - Focused Academic</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||||
|
<script id="tailwind-config">
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
"colors": {
|
||||||
|
"on-error-container": "#93000a",
|
||||||
|
"on-surface": "#191c1e",
|
||||||
|
"inverse-on-surface": "#eff1f3",
|
||||||
|
"on-primary-container": "#eeefff",
|
||||||
|
"tertiary-fixed-dim": "#ffb596",
|
||||||
|
"primary-fixed-dim": "#b4c5ff",
|
||||||
|
"secondary-fixed": "#dbe1ff",
|
||||||
|
"surface-bright": "#f7f9fb",
|
||||||
|
"error": "#ba1a1a",
|
||||||
|
"on-primary-fixed": "#00174b",
|
||||||
|
"surface-container-high": "#e6e8ea",
|
||||||
|
"surface": "#f7f9fb",
|
||||||
|
"tertiary-container": "#bc4800",
|
||||||
|
"on-surface-variant": "#434655",
|
||||||
|
"on-error": "#ffffff",
|
||||||
|
"surface-container-highest": "#e0e3e5",
|
||||||
|
"background": "#f7f9fb",
|
||||||
|
"on-tertiary-fixed-variant": "#7d2d00",
|
||||||
|
"on-tertiary": "#ffffff",
|
||||||
|
"on-secondary-container": "#394c84",
|
||||||
|
"primary-container": "#2563eb",
|
||||||
|
"secondary-fixed-dim": "#b4c5ff",
|
||||||
|
"secondary": "#495c95",
|
||||||
|
"on-tertiary-fixed": "#360f00",
|
||||||
|
"outline": "#737686",
|
||||||
|
"tertiary": "#943700",
|
||||||
|
"on-secondary-fixed": "#00174b",
|
||||||
|
"on-tertiary-container": "#ffede6",
|
||||||
|
"secondary-container": "#acbfff",
|
||||||
|
"error-container": "#ffdad6",
|
||||||
|
"surface-container-lowest": "#ffffff",
|
||||||
|
"surface-container": "#eceef0",
|
||||||
|
"on-primary-fixed-variant": "#003ea8",
|
||||||
|
"on-background": "#191c1e",
|
||||||
|
"primary": "#004ac6",
|
||||||
|
"primary-fixed": "#dbe1ff",
|
||||||
|
"on-secondary-fixed-variant": "#31447b",
|
||||||
|
"inverse-primary": "#b4c5ff",
|
||||||
|
"surface-container-low": "#f2f4f6",
|
||||||
|
"tertiary-fixed": "#ffdbcd",
|
||||||
|
"surface-dim": "#d8dadc",
|
||||||
|
"on-secondary": "#ffffff",
|
||||||
|
"outline-variant": "#c3c6d7",
|
||||||
|
"surface-tint": "#0053db",
|
||||||
|
"inverse-surface": "#2d3133",
|
||||||
|
"on-primary": "#ffffff",
|
||||||
|
"surface-variant": "#e0e3e5"
|
||||||
|
},
|
||||||
|
"borderRadius": {
|
||||||
|
"DEFAULT": "0.125rem",
|
||||||
|
"lg": "0.25rem",
|
||||||
|
"xl": "0.5rem",
|
||||||
|
"full": "0.75rem"
|
||||||
|
},
|
||||||
|
"fontFamily": {
|
||||||
|
"headline": ["Plus Jakarta Sans"],
|
||||||
|
"body": ["Plus Jakarta Sans"],
|
||||||
|
"label": ["Plus Jakarta Sans"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-background text-on-surface min-h-screen">
|
||||||
|
<!-- SideNavBar (from JSON) -->
|
||||||
|
<aside class="flex flex-col fixed left-0 top-0 h-full py-6 w-[240px] bg-slate-50 dark:bg-slate-900 shadow-[4px_0_24px_rgba(0,74,198,0.02)] z-50">
|
||||||
|
<div class="text-xl font-bold text-blue-700 dark:text-blue-500 mb-8 px-6">Focused Academic</div>
|
||||||
|
<div class="mb-6 px-6">
|
||||||
|
<div class="font-['Plus_Jakarta_Sans'] text-sm tracking-tight font-bold text-slate-900 dark:text-white">TOEIC Prep</div>
|
||||||
|
<div class="font-['Plus_Jakarta_Sans'] text-xs text-slate-500">User Profile</div>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1">
|
||||||
|
<a class="flex items-center text-slate-500 dark:text-slate-400 pl-5 py-3 hover:bg-slate-200 dark:hover:bg-slate-800 transition-colors" href="#">
|
||||||
|
<span class="material-symbols-outlined mr-3">home</span>
|
||||||
|
<span class="font-['Plus_Jakarta_Sans'] text-sm tracking-tight">Home</span>
|
||||||
|
</a>
|
||||||
|
<!-- Active Navigation: Luyện đề -->
|
||||||
|
<a class="flex items-center text-blue-700 dark:text-blue-400 font-bold border-l-4 border-blue-700 dark:border-blue-500 pl-4 py-3 bg-blue-50/50 dark:bg-blue-900/20 transition-colors" href="#">
|
||||||
|
<span class="material-symbols-outlined mr-3">assignment</span>
|
||||||
|
<span class="font-['Plus_Jakarta_Sans'] text-sm tracking-tight">Luyện đề</span>
|
||||||
|
</a>
|
||||||
|
<a class="flex items-center text-slate-500 dark:text-slate-400 pl-5 py-3 hover:bg-slate-200 dark:hover:bg-slate-800 transition-colors" href="#">
|
||||||
|
<span class="material-symbols-outlined mr-3">auto_fix</span>
|
||||||
|
<span class="font-['Plus_Jakarta_Sans'] text-sm tracking-tight">AI Writing</span>
|
||||||
|
</a>
|
||||||
|
<a class="flex items-center text-slate-500 dark:text-slate-400 pl-5 py-3 hover:bg-slate-200 dark:hover:bg-slate-800 transition-colors" href="#">
|
||||||
|
<span class="material-symbols-outlined mr-3">menu_book</span>
|
||||||
|
<span class="font-['Plus_Jakarta_Sans'] text-sm tracking-tight">Từ vựng</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<!-- TopNavBar (from JSON) -->
|
||||||
|
<header class="flex justify-between items-center px-8 ml-[240px] w-[calc(100%-240px)] h-16 sticky top-0 bg-white/80 dark:bg-slate-950/80 backdrop-blur-xl border-b border-slate-200/15 dark:border-slate-800/15 z-40">
|
||||||
|
<div class="text-lg font-bold text-slate-900 dark:text-white font-['Plus_Jakarta_Sans'] tracking-tight">Part 2 — Câu 3/10</div>
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<button class="text-slate-500 hover:text-blue-600 transition-colors flex items-center gap-1">
|
||||||
|
<span class="material-symbols-outlined">timer</span>
|
||||||
|
</button>
|
||||||
|
<button class="text-slate-500 hover:text-blue-600 transition-colors flex items-center gap-1">
|
||||||
|
<span class="material-symbols-outlined">help_outline</span>
|
||||||
|
</button>
|
||||||
|
<button class="text-slate-500 hover:text-blue-600 transition-colors flex items-center gap-1">
|
||||||
|
<span class="material-symbols-outlined">settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<!-- Main Content Canvas -->
|
||||||
|
<main class="ml-[240px] p-8 min-h-[calc(100vh-4rem)]">
|
||||||
|
<div class="max-w-6xl mx-auto flex gap-8">
|
||||||
|
<!-- Left Column: The Question Engine (65%) -->
|
||||||
|
<section class="w-[65%] space-y-6">
|
||||||
|
<!-- Question Card -->
|
||||||
|
<div class="bg-surface-container-lowest p-10 shadow-sm border-l-4 border-primary">
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="label-md text-on-surface-variant font-bold tracking-widest uppercase">Question 03</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-headline-md font-bold text-on-surface mb-10 leading-tight">
|
||||||
|
What does the man suggest the woman do?
|
||||||
|
</h2>
|
||||||
|
<!-- Options Grid -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Option A -->
|
||||||
|
<button class="w-full flex items-center p-4 bg-surface-container-lowest border border-outline-variant hover:bg-surface-container-low transition-all text-left">
|
||||||
|
<span class="w-8 h-8 flex items-center justify-center border border-outline-variant mr-4 font-bold text-sm">A</span>
|
||||||
|
<span class="body-lg text-on-surface">Call the office manager for technical assistance</span>
|
||||||
|
</button>
|
||||||
|
<!-- Option B (Selected) -->
|
||||||
|
<button class="w-full flex items-center p-4 bg-primary-container text-on-primary-container border-0 shadow-lg transition-all text-left">
|
||||||
|
<span class="w-8 h-8 flex items-center justify-center bg-white/20 mr-4 font-bold text-sm">B</span>
|
||||||
|
<span class="body-lg font-semibold">Reschedule the meeting for a later date</span>
|
||||||
|
</button>
|
||||||
|
<!-- Option C -->
|
||||||
|
<button class="w-full flex items-center p-4 bg-surface-container-lowest border border-outline-variant hover:bg-surface-container-low transition-all text-left">
|
||||||
|
<span class="w-8 h-8 flex items-center justify-center border border-outline-variant mr-4 font-bold text-sm">C</span>
|
||||||
|
<span class="body-lg text-on-surface">Visit the new branch office in the city center</span>
|
||||||
|
</button>
|
||||||
|
<!-- Option D -->
|
||||||
|
<button class="w-full flex items-center p-4 bg-surface-container-lowest border border-outline-variant hover:bg-surface-container-low transition-all text-left">
|
||||||
|
<span class="w-8 h-8 flex items-center justify-center border border-outline-variant mr-4 font-bold text-sm">D</span>
|
||||||
|
<span class="body-lg text-on-surface">Review the financial report before the presentation</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Pagination Buttons -->
|
||||||
|
<div class="flex justify-between items-center pt-4">
|
||||||
|
<button class="px-8 py-3 bg-transparent border-2 border-primary text-primary font-bold hover:bg-primary/5 transition-all flex items-center gap-2">
|
||||||
|
<span class="material-symbols-outlined text-sm">arrow_back</span>
|
||||||
|
Câu trước
|
||||||
|
</button>
|
||||||
|
<button class="px-10 py-3 bg-gradient-to-br from-primary to-primary-container text-on-primary font-bold shadow-md hover:opacity-90 transition-all flex items-center gap-2">
|
||||||
|
Câu tiếp theo
|
||||||
|
<span class="material-symbols-outlined text-sm">arrow_forward</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- Right Column: Status & Timer (35%) -->
|
||||||
|
<section class="w-[35%]">
|
||||||
|
<div class="bg-surface-container-lowest p-8 shadow-sm flex flex-col h-fit sticky top-24">
|
||||||
|
<!-- Timer Component -->
|
||||||
|
<div class="text-center mb-10 pb-8 border-b border-surface-container-high">
|
||||||
|
<span class="label-md text-on-surface-variant font-bold uppercase block mb-2">Time Remaining</span>
|
||||||
|
<div class="text-5xl font-extrabold text-primary tracking-tighter tabular-nums">07:32</div>
|
||||||
|
</div>
|
||||||
|
<!-- Progress Grid -->
|
||||||
|
<div class="mb-10">
|
||||||
|
<span class="label-md text-on-surface-variant font-bold uppercase block mb-4">Question Overview</span>
|
||||||
|
<div class="grid grid-cols-5 gap-3">
|
||||||
|
<!-- Answered -->
|
||||||
|
<div class="aspect-square bg-primary text-white flex items-center justify-center text-xs font-bold shadow-sm">1</div>
|
||||||
|
<div class="aspect-square bg-primary text-white flex items-center justify-center text-xs font-bold shadow-sm">2</div>
|
||||||
|
<!-- Current -->
|
||||||
|
<div class="aspect-square border-2 border-primary text-primary flex items-center justify-center text-xs font-extrabold bg-blue-50">3</div>
|
||||||
|
<!-- Answered -->
|
||||||
|
<div class="aspect-square bg-primary text-white flex items-center justify-center text-xs font-bold shadow-sm">4</div>
|
||||||
|
<!-- Unanswered -->
|
||||||
|
<div class="aspect-square bg-surface-container-high text-on-surface-variant flex items-center justify-center text-xs font-bold">5</div>
|
||||||
|
<div class="aspect-square bg-surface-container-high text-on-surface-variant flex items-center justify-center text-xs font-bold">6</div>
|
||||||
|
<div class="aspect-square bg-surface-container-high text-on-surface-variant flex items-center justify-center text-xs font-bold">7</div>
|
||||||
|
<div class="aspect-square bg-surface-container-high text-on-surface-variant flex items-center justify-center text-xs font-bold">8</div>
|
||||||
|
<div class="aspect-square bg-surface-container-high text-on-surface-variant flex items-center justify-center text-xs font-bold">9</div>
|
||||||
|
<div class="aspect-square bg-surface-container-high text-on-surface-variant flex items-center justify-center text-xs font-bold">10</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<button class="w-full py-4 bg-error text-white font-bold flex items-center justify-center gap-2 hover:bg-on-error-container transition-colors">
|
||||||
|
<span class="material-symbols-outlined">flag</span>
|
||||||
|
Nộp bài
|
||||||
|
</button>
|
||||||
|
<div class="mt-6 flex items-center justify-center gap-4 text-xs text-on-surface-variant font-medium">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div class="w-2.5 h-2.5 bg-primary"></div>
|
||||||
|
<span>Đã làm</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div class="w-2.5 h-2.5 bg-surface-container-high"></div>
|
||||||
|
<span>Chưa làm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Motivational Tip (Editorial/Professional aesthetic filler) -->
|
||||||
|
<div class="mt-6 p-6 bg-tertiary-container/5 border-l-4 border-tertiary">
|
||||||
|
<h4 class="label-md text-tertiary font-bold mb-2 uppercase">Pro Tip</h4>
|
||||||
|
<p class="text-sm text-on-surface-variant leading-relaxed italic">
|
||||||
|
"Focus on keywords in the question stem. Part 2 requires quick mental switching between context and intent."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body></html>
|
||||||
BIN
stitch-exports/screen-03-exam/design.png
Normal file
BIN
stitch-exports/screen-03-exam/design.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
64
stitch-exports/screen-04-results/DESIGN.md
Normal file
64
stitch-exports/screen-04-results/DESIGN.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Design System
|
||||||
|
|
||||||
|
Auto-generated from Google Stitch export.
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
- `bg-slate-50`
|
||||||
|
- `border-slate-200`
|
||||||
|
- `text-slate-900`
|
||||||
|
- `text-slate-500`
|
||||||
|
- `text-slate-600`
|
||||||
|
- `bg-slate-100`
|
||||||
|
- `bg-blue-100`
|
||||||
|
- `text-blue-700`
|
||||||
|
- `text-slate-400`
|
||||||
|
- `border-slate-100`
|
||||||
|
- `border-l-4`
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
- `text-lg`
|
||||||
|
- `font-bold`
|
||||||
|
- `text-xs`
|
||||||
|
- `font-medium`
|
||||||
|
- `font-semibold`
|
||||||
|
- `text-sm`
|
||||||
|
- `text-5xl`
|
||||||
|
- `text-4xl`
|
||||||
|
- `font-extrabold`
|
||||||
|
- `text-xl`
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
- `p-0`
|
||||||
|
- `p-4`
|
||||||
|
- `gap-2`
|
||||||
|
- `gap-1`
|
||||||
|
- `gap-3`
|
||||||
|
- `gap-4`
|
||||||
|
- `p-1`
|
||||||
|
- `gap-6`
|
||||||
|
- `p-8`
|
||||||
|
- `space-y-8`
|
||||||
|
- `gap-8`
|
||||||
|
- `space-y-6`
|
||||||
|
- `space-y-2`
|
||||||
|
- `p-2`
|
||||||
|
- `space-y-4`
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- `<aside>`
|
||||||
|
- `<nav>`
|
||||||
|
- `<main>`
|
||||||
|
- `<header>`
|
||||||
|
- `<input>`
|
||||||
|
- `<button>`
|
||||||
|
- `<section>`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Generated by Google Stitch AI
|
||||||
|
- Tailwind CSS utility classes used throughout
|
||||||
|
- Review and customize colors/typography for brand alignment
|
||||||
317
stitch-exports/screen-04-results/design.html
Normal file
317
stitch-exports/screen-04-results/design.html
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
<html class="light" lang="vi"><head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||||
|
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||||
|
<script id="tailwind-config">
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
"on-surface": "#0d1c2e",
|
||||||
|
"on-primary-fixed": "#00174b",
|
||||||
|
"surface-container-highest": "#d5e3fc",
|
||||||
|
"surface-container-high": "#dce9ff",
|
||||||
|
"primary-fixed-dim": "#b4c5ff",
|
||||||
|
"surface-dim": "#ccdbf3",
|
||||||
|
"surface-container-low": "#eff4ff",
|
||||||
|
"on-secondary-fixed-variant": "#005320",
|
||||||
|
"primary": "#004ac6",
|
||||||
|
"surface-container": "#e6eeff",
|
||||||
|
"tertiary": "#ae0010",
|
||||||
|
"on-secondary-fixed": "#002109",
|
||||||
|
"on-background": "#0d1c2e",
|
||||||
|
"outline-variant": "#c3c6d7",
|
||||||
|
"secondary-fixed": "#7ffc97",
|
||||||
|
"surface-variant": "#d5e3fc",
|
||||||
|
"primary-container": "#2563eb",
|
||||||
|
"error-container": "#ffdad6",
|
||||||
|
"inverse-on-surface": "#eaf1ff",
|
||||||
|
"tertiary-fixed-dim": "#ffb4ab",
|
||||||
|
"tertiary-fixed": "#ffdad6",
|
||||||
|
"secondary-container": "#7cf994",
|
||||||
|
"on-primary-container": "#eeefff",
|
||||||
|
"on-tertiary-container": "#ffecea",
|
||||||
|
"outline": "#737686",
|
||||||
|
"surface-tint": "#0053db",
|
||||||
|
"primary-fixed": "#dbe1ff",
|
||||||
|
"background": "#f8f9ff",
|
||||||
|
"on-secondary-container": "#007230",
|
||||||
|
"surface-bright": "#f8f9ff",
|
||||||
|
"error": "#ba1a1a",
|
||||||
|
"on-tertiary-fixed": "#410002",
|
||||||
|
"surface-container-lowest": "#ffffff",
|
||||||
|
"on-surface-variant": "#434655",
|
||||||
|
"tertiary-container": "#d52022",
|
||||||
|
"on-error": "#ffffff",
|
||||||
|
"on-error-container": "#93000a",
|
||||||
|
"inverse-surface": "#233144",
|
||||||
|
"inverse-primary": "#b4c5ff",
|
||||||
|
"secondary-fixed-dim": "#62df7d",
|
||||||
|
"on-tertiary-fixed-variant": "#93000b",
|
||||||
|
"on-primary": "#ffffff",
|
||||||
|
"surface": "#f8f9ff",
|
||||||
|
"on-secondary": "#ffffff",
|
||||||
|
"on-primary-fixed-variant": "#003ea8",
|
||||||
|
"on-tertiary": "#ffffff",
|
||||||
|
"secondary": "#006e2d"
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
"DEFAULT": "0.125rem",
|
||||||
|
"lg": "0.25rem",
|
||||||
|
"xl": "0.5rem",
|
||||||
|
"full": "0.75rem"
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
"headline": ["Plus Jakarta Sans"],
|
||||||
|
"body": ["Plus Jakarta Sans"],
|
||||||
|
"label": ["Plus Jakarta Sans"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Plus Jakarta Sans', sans-serif; background-color: #f8f9ff; color: #0d1c2e; }
|
||||||
|
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; }
|
||||||
|
.custom-glass { background: rgba(255, 255, 255, 0.8); backdrop-filter: blur(12px); }
|
||||||
|
.sidebar-active { background-color: #dbe1ff; color: #004ac6; font-weight: 600; border-radius: 0.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="flex min-h-screen">
|
||||||
|
<!-- SideNavBar Component -->
|
||||||
|
<aside class="w-[240px] h-screen sticky top-0 left-0 bg-slate-50 border-r border-slate-200/20 flex flex-col p-4 gap-2">
|
||||||
|
<div class="mb-8 px-4">
|
||||||
|
<h1 class="text-lg font-bold text-slate-900 leading-tight">The Academic Architect</h1>
|
||||||
|
<p class="text-xs text-slate-500 font-medium uppercase tracking-wider">TOEIC Mastery</p>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 flex flex-col gap-1">
|
||||||
|
<a class="text-slate-600 hover:bg-slate-100 rounded-lg px-4 py-3 transition-colors flex items-center gap-3" href="#">
|
||||||
|
<span class="material-symbols-outlined">home</span>Trang chủ
|
||||||
|
</a>
|
||||||
|
<a class="bg-blue-100 text-blue-700 font-semibold rounded-lg px-4 py-3 flex items-center gap-3" href="#">
|
||||||
|
<span class="material-symbols-outlined">assignment</span>Luy\u1ec7n \u0111\u1ec1 TOEIC
|
||||||
|
</a>
|
||||||
|
<a class="text-slate-600 hover:bg-slate-100 rounded-lg px-4 py-3 transition-colors flex items-center gap-3" href="#">
|
||||||
|
<span class="material-symbols-outlined">psychology</span>AI Writing
|
||||||
|
</a>
|
||||||
|
<a class="text-slate-600 hover:bg-slate-100 rounded-lg px-4 py-3 transition-colors flex items-center gap-3" href="#">
|
||||||
|
<span class="material-symbols-outlined">import_contacts</span>T\u1eeb v\u1ef1ng
|
||||||
|
</a>
|
||||||
|
<a class="text-slate-600 hover:bg-slate-100 rounded-lg px-4 py-3 transition-colors flex items-center gap-3" href="#">
|
||||||
|
<span class="material-symbols-outlined">leaderboard</span>Th\u1ed1ng k\u00ea
|
||||||
|
</a>
|
||||||
|
<div class="mt-auto">
|
||||||
|
<a class="text-slate-600 hover:bg-slate-100 rounded-lg px-4 py-3 transition-colors flex items-center gap-3" href="#">
|
||||||
|
<span class="material-symbols-outlined">settings</span>C\u00e0i \u0111\u1eb7t
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<main class="flex-1 flex flex-col min-w-0">
|
||||||
|
<!-- TopNavBar Component -->
|
||||||
|
<header class="w-full h-16 sticky top-0 z-40 bg-white/80 backdrop-blur-md border-b border-slate-200/20 flex justify-between items-center px-8 shadow-sm">
|
||||||
|
<div class="flex items-center gap-4 flex-1">
|
||||||
|
<div class="relative w-full max-w-md">
|
||||||
|
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">search</span>
|
||||||
|
<input class="w-full pl-10 pr-4 py-2 bg-surface-container-low border-none rounded-full text-sm focus:ring-2 focus:ring-surface-tint transition-all" placeholder="Tìm kiếm bài thi, từ vựng..." type="text"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<button class="relative text-slate-600 hover:text-primary transition-colors">
|
||||||
|
<span class="material-symbols-outlined">notifications</span>
|
||||||
|
<span class="absolute top-0 right-0 w-2 h-2 bg-tertiary rounded-full"></span>
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-3 pl-6 border-l border-slate-200/50">
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-sm font-bold text-on-surface">Minh Triết</p>
|
||||||
|
<p class="text-[10px] text-slate-500 font-bold uppercase tracking-tighter">Gold Member</p>
|
||||||
|
</div>
|
||||||
|
<img alt="User avatar" class="w-10 h-10 rounded-full object-cover border-2 border-white shadow-sm" data-alt="close-up portrait of a young professional man with a friendly expression in a modern office setting" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDHpo2JlM55AXZNPn-_Kv_WrQHmoINHAuDt0PqCUdY7tudzzY5x8jyEzwtGFWj0DOecjDfa6hK3foPAS7XWNcpbjkZs5S5OAcgZWC6rnsBy7mP4mqeVKTLPasFlbBhyvmFtl7MRI1ejHcIzJJk5DgWKKi_VrBcPyornEXVOcg1bsHE6xPj0of7hUH2XBMuE_e67zwbB7qs8yLhsLd-xWZOqCrAGftmeYOi66ndLXeb521Pp06RENy5rZwcpPBN0NOYXWEiVHJeP9DAP"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<!-- Content Canvas -->
|
||||||
|
<div class="p-8 max-w-7xl mx-auto w-full space-y-8">
|
||||||
|
<!-- Hero Results Section -->
|
||||||
|
<section class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-center bg-surface-container-lowest p-8 rounded-xl shadow-[0_8px_24px_rgba(13,28,46,0.04)]">
|
||||||
|
<div class="lg:col-span-4 flex justify-center">
|
||||||
|
<div class="relative flex items-center justify-center">
|
||||||
|
<div class="w-48 h-48 rounded-full border-[12px] border-secondary/20 flex flex-col items-center justify-center bg-white shadow-xl">
|
||||||
|
<span class="text-5xl font-black text-on-surface tracking-tighter">8/10</span>
|
||||||
|
<span class="text-xs font-bold text-secondary uppercase tracking-[0.2em] mt-1">Điểm số</span>
|
||||||
|
</div>
|
||||||
|
<!-- Decorative glow -->
|
||||||
|
<div class="absolute -z-10 w-56 h-56 bg-secondary/10 blur-3xl rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lg:col-span-8 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-4xl font-extrabold text-on-surface tracking-tight">Hoàn thành!</h2>
|
||||||
|
<p class="text-slate-500 mt-2 font-medium">Chúc mừng bạn đã hoàn thành bài kiểm tra Reading Part 5.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<div class="flex items-center gap-3 px-6 py-3 bg-secondary/10 rounded-full">
|
||||||
|
<span class="material-symbols-outlined text-secondary" style="font-variation-settings: 'FILL' 1;">check_circle</span>
|
||||||
|
<span class="text-secondary font-bold">8 Đúng</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 px-6 py-3 bg-tertiary/10 rounded-full">
|
||||||
|
<span class="material-symbols-outlined text-tertiary" style="font-variation-settings: 'FILL' 1;">cancel</span>
|
||||||
|
<span class="text-tertiary font-bold">2 Sai</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 px-6 py-3 bg-slate-100 rounded-full">
|
||||||
|
<span class="material-symbols-outlined text-slate-500">schedule</span>
|
||||||
|
<span class="text-slate-600 font-bold">4:32 Thời gian</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4 pt-2">
|
||||||
|
<button class="px-8 py-3.5 bg-gradient-to-br from-primary to-primary-container text-white font-bold rounded-xl shadow-lg hover:shadow-primary/30 transition-all active:scale-95">
|
||||||
|
Về trang chủ
|
||||||
|
</button>
|
||||||
|
<button class="px-8 py-3.5 border-2 border-outline-variant/30 text-on-surface font-bold rounded-xl hover:bg-slate-50 transition-all active:scale-95">
|
||||||
|
Làm lại
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- Analysis & Review Grid -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<!-- Left Column: Bar Chart Analysis -->
|
||||||
|
<div class="bg-surface-container-lowest p-8 rounded-xl shadow-[0_8px_24px_rgba(13,28,46,0.04)] h-full">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<h3 class="text-xl font-bold text-on-surface">Phân tích theo Part</h3>
|
||||||
|
<span class="material-symbols-outlined text-slate-400">bar_chart</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Chart Bar Mockups -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between text-xs font-bold text-slate-500 uppercase tracking-wider">
|
||||||
|
<span>Part 1: Photographs</span>
|
||||||
|
<span class="text-secondary">100%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-3 bg-slate-100 rounded-full overflow-hidden flex">
|
||||||
|
<div class="h-full bg-secondary w-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between text-xs font-bold text-slate-500 uppercase tracking-wider">
|
||||||
|
<span>Part 2: Q&A</span>
|
||||||
|
<span class="text-secondary">85%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-3 bg-slate-100 rounded-full overflow-hidden flex">
|
||||||
|
<div class="h-full bg-secondary w-[85%]"></div>
|
||||||
|
<div class="h-full bg-tertiary w-[15%]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between text-xs font-bold text-slate-500 uppercase tracking-wider">
|
||||||
|
<span>Part 5: Incomplete Sentences</span>
|
||||||
|
<span class="text-tertiary">40%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-3 bg-slate-100 rounded-full overflow-hidden flex">
|
||||||
|
<div class="h-full bg-secondary w-[40%]"></div>
|
||||||
|
<div class="h-full bg-tertiary w-[60%]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between text-xs font-bold text-slate-500 uppercase tracking-wider">
|
||||||
|
<span>Part 7: Reading Comprehension</span>
|
||||||
|
<span class="text-secondary">70%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-3 bg-slate-100 rounded-full overflow-hidden flex">
|
||||||
|
<div class="h-full bg-secondary w-[70%]"></div>
|
||||||
|
<div class="h-full bg-tertiary w-[30%]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 p-4 bg-primary/5 rounded-xl border border-primary/10">
|
||||||
|
<p class="text-sm text-on-surface-variant leading-relaxed">
|
||||||
|
<span class="font-bold text-primary">Gợi ý:</span> Bạn đang gặp khó khăn ở Part 5. Hãy tập trung luyện tập về Ngữ pháp liên quan đến các thì của động từ và liên từ.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Right Column: Question Review -->
|
||||||
|
<div class="bg-surface-container-lowest rounded-xl shadow-[0_8px_24px_rgba(13,28,46,0.04)] flex flex-col overflow-hidden max-h-[600px]">
|
||||||
|
<div class="p-8 border-b border-slate-100 bg-white sticky top-0 z-10 flex items-center justify-between">
|
||||||
|
<h3 class="text-xl font-bold text-on-surface">Xem lại đáp án</h3>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="p-2 bg-slate-100 rounded-lg text-slate-500 hover:bg-primary/10 hover:text-primary transition-colors">
|
||||||
|
<span class="material-symbols-outlined">filter_list</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-y-auto p-4 space-y-4 bg-slate-50/50">
|
||||||
|
<!-- Correct Question -->
|
||||||
|
<div class="p-5 bg-white rounded-xl border border-secondary/10 group cursor-pointer hover:shadow-md transition-shadow">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-secondary/10 flex items-center justify-center shrink-0">
|
||||||
|
<span class="text-sm font-bold text-secondary">01</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-on-surface mb-3">Ms. Tan _______ the client yesterday to discuss the terms of the contract.</p>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div class="px-3 py-2 bg-secondary/5 border border-secondary/20 rounded-lg text-secondary font-medium">Your answer: B</div>
|
||||||
|
<div class="px-3 py-2 bg-slate-50 border border-slate-100 rounded-lg text-slate-400">Correct: B</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="material-symbols-outlined text-secondary" style="font-variation-settings: 'FILL' 1;">check_circle</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Incorrect Question (Expanded Style) -->
|
||||||
|
<div class="p-5 bg-white rounded-xl border-l-4 border-l-tertiary border-y border-r border-tertiary/10 shadow-sm">
|
||||||
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-tertiary/10 flex items-center justify-center shrink-0">
|
||||||
|
<span class="text-sm font-bold text-tertiary">02</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-on-surface mb-3">The sales department has been working hard _______ the new marketing campaign launched.</p>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
|
||||||
|
<div class="px-3 py-2 bg-tertiary/5 border border-tertiary/20 rounded-lg text-tertiary font-medium">Your answer: B</div>
|
||||||
|
<div class="px-3 py-2 bg-secondary/5 border border-secondary/20 rounded-lg text-secondary font-medium">Correct answer: C</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="material-symbols-outlined text-tertiary" style="font-variation-settings: 'FILL' 1;">cancel</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 pt-4 border-t border-slate-100">
|
||||||
|
<div class="flex items-center gap-2 text-primary font-bold text-sm mb-2">
|
||||||
|
<span class="material-symbols-outlined text-sm">lightbulb</span>
|
||||||
|
GIẢI THÍCH
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-on-surface-variant leading-relaxed">
|
||||||
|
Giải thích: Câu này cần dùng thì hiện tại hoàn thành (has been working) vì có dấu hiệu 'since' chỉ một mốc thời gian trong quá khứ kéo dài đến hiện tại. Phương án C "since" là phù hợp nhất về mặt ngữ pháp.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Correct Question -->
|
||||||
|
<div class="p-5 bg-white rounded-xl border border-slate-100 group cursor-pointer hover:shadow-md transition-shadow">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-secondary/10 flex items-center justify-center shrink-0">
|
||||||
|
<span class="text-sm font-bold text-secondary">03</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-on-surface mb-3">Applicants must submit their _______ by the end of the business day on Friday.</p>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div class="px-3 py-2 bg-secondary/5 border border-secondary/20 rounded-lg text-secondary font-medium">Your answer: A</div>
|
||||||
|
<div class="px-3 py-2 bg-slate-50 border border-slate-100 rounded-lg text-slate-400">Correct: A</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="material-symbols-outlined text-secondary" style="font-variation-settings: 'FILL' 1;">check_circle</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body></html>
|
||||||
BIN
stitch-exports/screen-04-results/design.png
Normal file
BIN
stitch-exports/screen-04-results/design.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
72
stitch-exports/screen-05-writing/DESIGN.md
Normal file
72
stitch-exports/screen-05-writing/DESIGN.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Design System
|
||||||
|
|
||||||
|
Auto-generated from Google Stitch export.
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
- `bg-slate-50`
|
||||||
|
- `bg-slate-900`
|
||||||
|
- `text-blue-800`
|
||||||
|
- `text-blue-300`
|
||||||
|
- `text-slate-500`
|
||||||
|
- `text-slate-400`
|
||||||
|
- `text-blue-600`
|
||||||
|
- `bg-blue-50`
|
||||||
|
- `bg-blue-900`
|
||||||
|
- `text-blue-700`
|
||||||
|
- `text-blue-400`
|
||||||
|
- `border-l-4`
|
||||||
|
- `border-blue-600`
|
||||||
|
- `border-slate-200`
|
||||||
|
- `text-slate-600`
|
||||||
|
- `bg-slate-100`
|
||||||
|
- `bg-slate-200`
|
||||||
|
- `text-slate-700`
|
||||||
|
- `bg-amber-50`
|
||||||
|
- `bg-amber-100`
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
- `text-xl`
|
||||||
|
- `font-bold`
|
||||||
|
- `text-xs`
|
||||||
|
- `font-medium`
|
||||||
|
- `text-sm`
|
||||||
|
- `text-3xl`
|
||||||
|
- `font-extrabold`
|
||||||
|
- `text-lg`
|
||||||
|
- `font-semibold`
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
- `p-0`
|
||||||
|
- `space-y-1`
|
||||||
|
- `gap-3`
|
||||||
|
- `gap-4`
|
||||||
|
- `gap-2`
|
||||||
|
- `gap-8`
|
||||||
|
- `space-y-6`
|
||||||
|
- `m-0`
|
||||||
|
- `p-8`
|
||||||
|
- `m-6`
|
||||||
|
- `space-y-4`
|
||||||
|
- `p-5`
|
||||||
|
- `space-y-3`
|
||||||
|
- `p-4`
|
||||||
|
- `m-4`
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- `<nav>`
|
||||||
|
- `<header>`
|
||||||
|
- `<input>`
|
||||||
|
- `<button>`
|
||||||
|
- `<main>`
|
||||||
|
- `<section>`
|
||||||
|
- `<aside>`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Generated by Google Stitch AI
|
||||||
|
- Tailwind CSS utility classes used throughout
|
||||||
|
- Review and customize colors/typography for brand alignment
|
||||||
270
stitch-exports/screen-05-writing/design.html
Normal file
270
stitch-exports/screen-05-writing/design.html
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
<html class="light" lang="vi"><head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||||
|
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||||
|
<script id="tailwind-config">
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
"colors": {
|
||||||
|
"background": "#f7f9fb",
|
||||||
|
"on-secondary-fixed-variant": "#31447b",
|
||||||
|
"on-tertiary-fixed-variant": "#7d2d00",
|
||||||
|
"on-background": "#191c1e",
|
||||||
|
"tertiary": "#943700",
|
||||||
|
"on-surface-variant": "#434655",
|
||||||
|
"surface-variant": "#e0e3e5",
|
||||||
|
"surface-bright": "#f7f9fb",
|
||||||
|
"secondary": "#495c95",
|
||||||
|
"tertiary-container": "#bc4800",
|
||||||
|
"inverse-on-surface": "#eff1f3",
|
||||||
|
"on-error-container": "#93000a",
|
||||||
|
"error-container": "#ffdad6",
|
||||||
|
"on-tertiary-fixed": "#360f00",
|
||||||
|
"secondary-fixed": "#dbe1ff",
|
||||||
|
"surface": "#f7f9fb",
|
||||||
|
"surface-dim": "#d8dadc",
|
||||||
|
"on-primary": "#ffffff",
|
||||||
|
"on-primary-fixed": "#00174b",
|
||||||
|
"surface-container-highest": "#e0e3e5",
|
||||||
|
"tertiary-fixed": "#ffdbcd",
|
||||||
|
"on-secondary-fixed": "#00174b",
|
||||||
|
"primary-fixed": "#dbe1ff",
|
||||||
|
"secondary-container": "#acbfff",
|
||||||
|
"on-primary-container": "#eeefff",
|
||||||
|
"secondary-fixed-dim": "#b4c5ff",
|
||||||
|
"primary-container": "#2563eb",
|
||||||
|
"tertiary-fixed-dim": "#ffb596",
|
||||||
|
"surface-container": "#eceef0",
|
||||||
|
"inverse-primary": "#b4c5ff",
|
||||||
|
"outline-variant": "#c3c6d7",
|
||||||
|
"inverse-surface": "#2d3133",
|
||||||
|
"on-error": "#ffffff",
|
||||||
|
"on-tertiary-container": "#ffede6",
|
||||||
|
"on-primary-fixed-variant": "#003ea8",
|
||||||
|
"outline": "#737686",
|
||||||
|
"on-surface": "#191c1e",
|
||||||
|
"error": "#ba1a1a",
|
||||||
|
"primary-fixed-dim": "#b4c5ff",
|
||||||
|
"surface-container-low": "#f2f4f6",
|
||||||
|
"on-tertiary": "#ffffff",
|
||||||
|
"on-secondary": "#ffffff",
|
||||||
|
"surface-container-lowest": "#ffffff",
|
||||||
|
"surface-tint": "#0053db",
|
||||||
|
"on-secondary-container": "#394c84",
|
||||||
|
"surface-container-high": "#e6e8ea",
|
||||||
|
"primary": "#004ac6"
|
||||||
|
},
|
||||||
|
"borderRadius": {
|
||||||
|
"DEFAULT": "0.125rem",
|
||||||
|
"lg": "0.25rem",
|
||||||
|
"xl": "0.5rem",
|
||||||
|
"full": "0.75rem"
|
||||||
|
},
|
||||||
|
"fontFamily": {
|
||||||
|
"headline": ["Plus Jakarta Sans"],
|
||||||
|
"body": ["Plus Jakarta Sans"],
|
||||||
|
"label": ["Plus Jakarta Sans"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
}
|
||||||
|
body { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||||
|
.glass-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-background text-on-surface">
|
||||||
|
<!-- SideNavBar Shell -->
|
||||||
|
<nav class="h-screen w-64 fixed left-0 top-0 bg-slate-50 dark:bg-slate-900 flex flex-col py-8 px-4 z-50">
|
||||||
|
<div class="mb-10 px-2">
|
||||||
|
<h1 class="text-xl font-bold text-blue-800 dark:text-blue-300">The Academic Curator</h1>
|
||||||
|
<p class="text-xs text-slate-500 font-medium">TOEIC Excellence</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 space-y-1">
|
||||||
|
<!-- Home -->
|
||||||
|
<a class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors text-slate-500 dark:text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/10" href="#">
|
||||||
|
<span class="material-symbols-outlined" data-icon="home">home</span>
|
||||||
|
<span class="font-['Plus_Jakarta_Sans'] text-sm font-medium">Home</span>
|
||||||
|
</a>
|
||||||
|
<!-- Luyện đề TOEIC -->
|
||||||
|
<a class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors text-slate-500 dark:text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/10" href="#">
|
||||||
|
<span class="material-symbols-outlined" data-icon="assignment">assignment</span>
|
||||||
|
<span class="font-['Plus_Jakarta_Sans'] text-sm font-medium">Luyện đề TOEIC</span>
|
||||||
|
</a>
|
||||||
|
<!-- AI Writing (Active) -->
|
||||||
|
<a class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all text-blue-700 dark:text-blue-400 font-bold border-l-4 border-blue-600 bg-blue-50/50 dark:bg-blue-900/20 scale-95 active:scale-100" href="#">
|
||||||
|
<span class="material-symbols-outlined" data-icon="psychology" style="font-variation-settings: 'FILL' 1;">psychology</span>
|
||||||
|
<span class="font-['Plus_Jakarta_Sans'] text-sm">AI Writing</span>
|
||||||
|
</a>
|
||||||
|
<!-- Từ vựng -->
|
||||||
|
<a class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors text-slate-500 dark:text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/10" href="#">
|
||||||
|
<span class="material-symbols-outlined" data-icon="import_contacts">import_contacts</span>
|
||||||
|
<span class="font-['Plus_Jakarta_Sans'] text-sm font-medium">Từ vựng</span>
|
||||||
|
</a>
|
||||||
|
<!-- Thống kê -->
|
||||||
|
<a class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors text-slate-500 dark:text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/10" href="#">
|
||||||
|
<span class="material-symbols-outlined" data-icon="leaderboard">leaderboard</span>
|
||||||
|
<span class="font-['Plus_Jakarta_Sans'] text-sm font-medium">Thống kê</span>
|
||||||
|
</a>
|
||||||
|
<!-- Cài đặt -->
|
||||||
|
<a class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors text-slate-500 dark:text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/10" href="#">
|
||||||
|
<span class="material-symbols-outlined" data-icon="settings">settings</span>
|
||||||
|
<span class="font-['Plus_Jakarta_Sans'] text-sm font-medium">Cài đặt</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-auto pt-6 border-t border-slate-200/50">
|
||||||
|
<div class="flex items-center gap-3 px-2">
|
||||||
|
<img alt="User profile" class="w-10 h-10 rounded-full object-cover shadow-sm" data-alt="portrait of a focused professional educator in a modern minimalist workspace with soft natural light" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCBGJa9dqnqfCoSP3DnEBHZWiMjwQOrkpsRvcwJWI7QFsx8kWAo-lQIzWe-kmZ1GL5O2B-vpgP_553qR6BqIaP47vjJkV4ALIViC4OGx2K_KoKBalv7F3roDGt5aVif_4WP7jclzptL1D_gVJmPd5CQ9ebgT3NZow6NZR7XWSZojSCdHK9jANs0hp8tE_sVQF-l_q8UnHtn_F5BVK4VOwqahUFDMJBByGV6EJgVQ9XBiSaSZ7btDWo6dJtp1ybq522mgg6v6QaSYRp5"/>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-bold">Minh Nguyen</p>
|
||||||
|
<p class="text-xs text-slate-500">Premium Member</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<!-- TopNavBar Shell -->
|
||||||
|
<header class="fixed top-0 right-0 w-[calc(100%-16rem)] h-16 bg-white/80 backdrop-blur-md flex items-center justify-between px-8 z-40 shadow-sm">
|
||||||
|
<div class="flex items-center bg-slate-50 px-4 py-2 rounded-full w-96 transition-all focus-within:ring-2 focus-within:ring-blue-500/20">
|
||||||
|
<span class="material-symbols-outlined text-slate-400 mr-2" data-icon="search">search</span>
|
||||||
|
<input class="bg-transparent border-none focus:ring-0 text-sm w-full font-['Plus_Jakarta_Sans']" placeholder="Search curated resources..." type="text"/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button class="w-10 h-10 flex items-center justify-center rounded-full text-slate-600 hover:bg-slate-100 transition-colors">
|
||||||
|
<span class="material-symbols-outlined" data-icon="notifications">notifications</span>
|
||||||
|
</button>
|
||||||
|
<div class="h-8 w-[1px] bg-slate-200"></div>
|
||||||
|
<button class="flex items-center gap-2 px-3 py-1.5 rounded-full hover:bg-slate-100 transition-colors">
|
||||||
|
<span class="text-sm font-medium text-slate-700">Profile</span>
|
||||||
|
<img alt="User profile picture" class="w-8 h-8 rounded-full border-2 border-white shadow-sm" data-alt="Close up portrait of a young adult looking smart and friendly with a clear bokeh background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCzsEHQ5y1Iklb3kYquyn5Qm5QX6Ax5p8XeIAGucP2QSjMj6zyAcQkG15xhsHzTSpLfdEoriLVa3q17pxFr7zl9J-HKLyvYdyoS67l3D1gdxgsDoWxZJIK9ypUc4VhZQ-u72zQEJxdbKeCeRgX3eFxOEFcQg5cBEAAR1FCCrHD0LAzCJzWNaniEmlkXLupTiY9i4HxkYr_bzXhtN72yqXFrz0qAYgDqHZiZGMiNDJJq0xBoi4u6Fi3KjSWmqAjCkV5U0VTMJtjJPeis"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<!-- Main Content Canvas -->
|
||||||
|
<main class="ml-64 pt-24 pb-12 px-10 min-h-screen">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<header class="mb-8">
|
||||||
|
<h2 class="text-3xl font-extrabold text-on-surface tracking-tight font-headline">AI Writing Checker</h2>
|
||||||
|
<p class="text-slate-500 mt-1 body-lg">Submit your essay for an immediate academic evaluation and feedback.</p>
|
||||||
|
</header>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
|
||||||
|
<!-- LEFT COLUMN: Input Area -->
|
||||||
|
<section class="space-y-6">
|
||||||
|
<div class="relative rounded-xl overflow-hidden shadow-sm bg-surface-container-low transition-all focus-within:bg-surface-container-lowest">
|
||||||
|
<div class="absolute left-0 top-0 bottom-0 w-1 bg-primary opacity-0 focus-within:opacity-100 transition-opacity"></div>
|
||||||
|
<textarea class="w-full h-[600px] p-8 bg-transparent border-none focus:ring-0 resize-none font-body text-lg leading-relaxed text-on-surface" placeholder="Type or paste your English paragraph here...">The most serious problem in urban cities is pollution. In my opinion, government must take action to reduce carbon emission from cars. People should used public transportations more often to protect the environment. Also, building more parks can helps city becoming more green. If we doesn't change our behavior, the planet will suffer a lot of damages in the future.</textarea>
|
||||||
|
<div class="absolute bottom-6 right-8 text-slate-400 font-label text-sm bg-white/50 backdrop-blur px-3 py-1 rounded-full">
|
||||||
|
245/500 characters
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<button class="w-full py-4 px-8 bg-gradient-to-r from-primary to-primary-container text-white font-bold text-lg rounded-full transition-transform active:scale-95 shadow-lg shadow-blue-500/20">
|
||||||
|
Chấm bài ngay
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-2 px-4 py-1.5 bg-secondary-fixed/30 text-blue-800 rounded-full text-sm font-semibold">
|
||||||
|
<span class="material-symbols-outlined text-sm" data-icon="bolt">bolt</span>
|
||||||
|
Còn 2/3 lượt hôm nay
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- RIGHT COLUMN: Feedback Panel -->
|
||||||
|
<aside class="space-y-6">
|
||||||
|
<!-- Band Score Badge -->
|
||||||
|
<div class="bg-surface-container-lowest p-8 rounded-xl flex items-center justify-between shadow-sm border border-outline-variant/10">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-slate-500 font-label uppercase tracking-widest text-xs mb-1">Overall Band Score</h3>
|
||||||
|
<p class="text-on-surface font-headline font-bold text-xl">Academic Performance</p>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0 bg-primary/10 rounded-full animate-pulse"></div>
|
||||||
|
<div class="w-20 h-20 bg-primary text-white rounded-full flex items-center justify-center text-3xl font-extrabold shadow-inner relative z-10">
|
||||||
|
6.5
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Feedback Sections -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Ngữ pháp (Error) -->
|
||||||
|
<div class="bg-error-container/20 border-l-4 border-error rounded-r-xl p-5 transition-all hover:bg-error-container/30">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="material-symbols-outlined text-error" data-icon="error">error</span>
|
||||||
|
<h4 class="font-headline font-bold text-error">Ngữ pháp</h4>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-3">
|
||||||
|
<li class="flex gap-3 text-sm text-on-surface-variant">
|
||||||
|
<span class="text-error mt-1">•</span>
|
||||||
|
<span>Lỗi thì quá khứ: "People should <strong>used</strong>" -> "People should <strong>use</strong>" (Sau modal verb dùng V-inf).</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3 text-sm text-on-surface-variant">
|
||||||
|
<span class="text-error mt-1">•</span>
|
||||||
|
<span>Chia động từ ngôi thứ 3: "parks can <strong>helps</strong>" -> "parks can <strong>help</strong>".</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex gap-3 text-sm text-on-surface-variant">
|
||||||
|
<span class="text-error mt-1">•</span>
|
||||||
|
<span>Hòa hợp chủ vị: "If we <strong>doesn't</strong>" -> "If we <strong>don't</strong>".</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<!-- Từ vựng (Warning) -->
|
||||||
|
<div class="bg-amber-50 border-l-4 border-[#D97706] rounded-r-xl p-5 transition-all hover:bg-amber-100/50">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="material-symbols-outlined text-[#D97706]" data-icon="history_edu">history_edu</span>
|
||||||
|
<h4 class="font-headline font-bold text-[#D97706]">Từ vựng</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-on-surface-variant leading-relaxed">
|
||||||
|
Từ vựng còn khá đơn giản. Nên thay <strong>"most serious problem"</strong> bằng <strong>"pressing issue"</strong> hoặc <strong>"pivotal challenge"</strong>. Sử dụng thêm các từ chuyên ngành về môi trường như <strong>"carbon footprint"</strong> thay vì "carbon emission".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Cấu trúc bài (Primary) -->
|
||||||
|
<div class="bg-blue-50 border-l-4 border-primary-container rounded-r-xl p-5 transition-all hover:bg-blue-100/50">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="material-symbols-outlined text-primary-container" data-icon="account_tree">account_tree</span>
|
||||||
|
<h4 class="font-headline font-bold text-primary-container">Cấu trúc bài</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-on-surface-variant leading-relaxed">
|
||||||
|
Bài viết có cấu trúc rõ ràng nhưng thiếu các từ nối (linking words). Nên bổ sung các cụm như <strong>"Consequently"</strong>, <strong>"Furthermore"</strong> hoặc <strong>"In addition to"</strong> để tăng tính gắn kết giữa các câu.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Bài viết cải thiện (Collapsible/Success) -->
|
||||||
|
<div class="bg-green-50 border-l-4 border-[#16A34A] rounded-r-xl overflow-hidden transition-all group">
|
||||||
|
<button class="w-full flex items-center justify-between p-5 text-left focus:outline-none">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="material-symbols-outlined text-[#16A34A]" data-icon="auto_fix_high" style="font-variation-settings: 'FILL' 1;">auto_fix_high</span>
|
||||||
|
<h4 class="font-headline font-bold text-[#16A34A]">Bài viết cải thiện</h4>
|
||||||
|
</div>
|
||||||
|
<span class="material-symbols-outlined text-[#16A34A] transition-transform group-hover:translate-y-0.5" data-icon="keyboard_arrow_down">keyboard_arrow_down</span>
|
||||||
|
</button>
|
||||||
|
<div class="px-5 pb-5">
|
||||||
|
<div class="p-4 bg-white/60 rounded-lg text-sm text-slate-700 italic leading-relaxed border border-green-100">
|
||||||
|
"One of the most pressing issues in modern urban areas is pollution. In my perspective, the government must take decisive actions to minimize carbon footprints. Furthermore, citizens should utilize public transportation more frequently to preserve the ecosystem. Enhancing urban greenery by developing more parks is also vital. Unless we transform our behavioral patterns, the planet will encounter irreversible environmental damage in the near future."
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Decorative Background Image -->
|
||||||
|
<div class="relative h-48 rounded-xl overflow-hidden mt-8">
|
||||||
|
<img alt="Academic study aesthetic" class="w-full h-full object-cover grayscale opacity-30 mix-blend-overlay" data-alt="close up of an open classic book with elegant typography on cream paper with soft focused library background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuC9rNsXf29TWRCykkBS1mMY4_3RVznchVnsmuWVpykDYOlBZZ_KRSfxZsOH4qqfVRE3lkz2ilQOgkwO9Zq2qGmt_ErYzYNGP8eyzUmOCRV523QeZM_PF91ic3hGSHrUzJ8_c48vy7PbbjdvyKls1NVKGk7BNrMGQmCXD5XTOVcDuJL4pHN1kKK5LRIt_2Igj0kt6-s9WBIthc9dnCiE8lQOCNJE7SEjxiXre_-_nq-A0ly5DaKji7ScKU5Sky6YgDIO19i0qkbEklWh"/>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-background to-transparent"></div>
|
||||||
|
<div class="absolute bottom-4 left-4">
|
||||||
|
<span class="text-xs font-bold uppercase tracking-widest text-slate-400">Curated Insights</span>
|
||||||
|
<p class="text-sm font-medium text-slate-600">Writing accuracy increased by 15% this week.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body></html>
|
||||||
BIN
stitch-exports/screen-05-writing/design.png
Normal file
BIN
stitch-exports/screen-05-writing/design.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
57
stitch-exports/screen-06-flashcard/DESIGN.md
Normal file
57
stitch-exports/screen-06-flashcard/DESIGN.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Design System
|
||||||
|
|
||||||
|
Auto-generated from Google Stitch export.
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
- `bg-slate-900`
|
||||||
|
- `text-slate-500`
|
||||||
|
- `text-slate-400`
|
||||||
|
- `bg-slate-800`
|
||||||
|
- `text-blue-400`
|
||||||
|
- `border-r-4`
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
- `text-sm`
|
||||||
|
- `font-medium`
|
||||||
|
- `text-2xl`
|
||||||
|
- `font-bold`
|
||||||
|
- `text-xs`
|
||||||
|
- `font-extrabold`
|
||||||
|
- `text-xl`
|
||||||
|
- `text-4xl`
|
||||||
|
- `text-lg`
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
- `space-y-1`
|
||||||
|
- `gap-3`
|
||||||
|
- `p-2`
|
||||||
|
- `gap-2`
|
||||||
|
- `gap-1`
|
||||||
|
- `p-5`
|
||||||
|
- `gap-10`
|
||||||
|
- `p-12`
|
||||||
|
- `p-8`
|
||||||
|
- `gap-6`
|
||||||
|
- `space-y-8`
|
||||||
|
- `p-6`
|
||||||
|
- `space-y-4`
|
||||||
|
- `gap-4`
|
||||||
|
- `space-y-3`
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- `<aside>`
|
||||||
|
- `<nav>`
|
||||||
|
- `<button>`
|
||||||
|
- `<main>`
|
||||||
|
- `<section>`
|
||||||
|
- `<header>`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Generated by Google Stitch AI
|
||||||
|
- Tailwind CSS utility classes used throughout
|
||||||
|
- Review and customize colors/typography for brand alignment
|
||||||
317
stitch-exports/screen-06-flashcard/design.html
Normal file
317
stitch-exports/screen-06-flashcard/design.html
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
<html class="light" lang="vi"><head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
|
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||||
|
<script id="tailwind-config">
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
"colors": {
|
||||||
|
"tertiary": "#943700",
|
||||||
|
"on-secondary": "#ffffff",
|
||||||
|
"on-primary-fixed": "#00174b",
|
||||||
|
"background": "#f7f9fb",
|
||||||
|
"error-container": "#ffdad6",
|
||||||
|
"surface-container-high": "#e6e8ea",
|
||||||
|
"on-tertiary-container": "#ffede6",
|
||||||
|
"on-error-container": "#93000a",
|
||||||
|
"on-tertiary-fixed-variant": "#7d2d00",
|
||||||
|
"surface-dim": "#d8dadc",
|
||||||
|
"secondary-fixed": "#7ffc97",
|
||||||
|
"on-primary": "#ffffff",
|
||||||
|
"primary-fixed": "#dbe1ff",
|
||||||
|
"surface-tint": "#0053db",
|
||||||
|
"primary": "#004ac6",
|
||||||
|
"on-secondary-fixed-variant": "#005320",
|
||||||
|
"on-surface": "#191c1e",
|
||||||
|
"tertiary-fixed": "#ffdbcd",
|
||||||
|
"error": "#ba1a1a",
|
||||||
|
"on-tertiary": "#ffffff",
|
||||||
|
"on-secondary-fixed": "#002109",
|
||||||
|
"secondary": "#006e2d",
|
||||||
|
"on-tertiary-fixed": "#360f00",
|
||||||
|
"on-primary-fixed-variant": "#003ea8",
|
||||||
|
"inverse-primary": "#b4c5ff",
|
||||||
|
"surface-variant": "#e0e3e5",
|
||||||
|
"primary-fixed-dim": "#b4c5ff",
|
||||||
|
"on-surface-variant": "#434655",
|
||||||
|
"tertiary-container": "#bc4800",
|
||||||
|
"on-error": "#ffffff",
|
||||||
|
"on-primary-container": "#eeefff",
|
||||||
|
"surface-container-low": "#f2f4f6",
|
||||||
|
"on-background": "#191c1e",
|
||||||
|
"secondary-fixed-dim": "#62df7d",
|
||||||
|
"primary-container": "#2563eb",
|
||||||
|
"surface-container": "#eceef0",
|
||||||
|
"inverse-surface": "#2d3133",
|
||||||
|
"secondary-container": "#7cf994",
|
||||||
|
"inverse-on-surface": "#eff1f3",
|
||||||
|
"outline": "#737686",
|
||||||
|
"surface": "#f7f9fb",
|
||||||
|
"outline-variant": "#c3c6d7",
|
||||||
|
"surface-container-highest": "#e0e3e5",
|
||||||
|
"surface-container-lowest": "#ffffff",
|
||||||
|
"tertiary-fixed-dim": "#ffb596",
|
||||||
|
"on-secondary-container": "#007230",
|
||||||
|
"surface-bright": "#f7f9fb"
|
||||||
|
},
|
||||||
|
"borderRadius": {
|
||||||
|
"DEFAULT": "0.25rem",
|
||||||
|
"lg": "0.5rem",
|
||||||
|
"xl": "0.75rem",
|
||||||
|
"full": "9999px"
|
||||||
|
},
|
||||||
|
"fontFamily": {
|
||||||
|
"headline": ["Plus Jakarta Sans"],
|
||||||
|
"body": ["Plus Jakarta Sans"],
|
||||||
|
"label": ["Plus Jakarta Sans"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
}
|
||||||
|
.perspective-1000 { perspective: 1000px; }
|
||||||
|
.backface-hidden { backface-visibility: hidden; }
|
||||||
|
.learning-card { transform-style: preserve-3d; transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||||
|
/* Simulation of flip for static UI representation */
|
||||||
|
/* .learning-card:hover { transform: rotateY(180deg); } */
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface text-on-surface min-h-screen">
|
||||||
|
<div class="flex h-screen overflow-hidden">
|
||||||
|
<!-- COLUMN 1: SideNavBar (Shared) -->
|
||||||
|
<aside class="w-64 flex-shrink-0 z-50 bg-surface-container-low dark:bg-slate-900 h-screen flex flex-col py-8 px-4 font-['Plus_Jakarta_Sans'] text-sm font-medium tracking-tight">
|
||||||
|
<div class="mb-10 px-2">
|
||||||
|
<h1 class="text-2xl font-bold tracking-tighter text-[#191c1e] dark:text-white">The Atelier</h1>
|
||||||
|
<p class="text-xs text-on-surface-variant/70 uppercase tracking-widest mt-1">TOEIC Curator</p>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 space-y-1">
|
||||||
|
<!-- Nav Item: Trang chủ -->
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-500 dark:text-slate-400 hover:text-[#191c1e] dark:hover:text-white hover:bg-[#e6e8ea] dark:hover:bg-slate-800 transition-colors scale-95 duration-200 ease-in-out" href="#">
|
||||||
|
<span class="material-symbols-outlined" data-icon="home">home</span>
|
||||||
|
<span>Trang chủ</span>
|
||||||
|
</a>
|
||||||
|
<!-- Nav Item: Lộ trình -->
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-500 dark:text-slate-400 hover:text-[#191c1e] dark:hover:text-white hover:bg-[#e6e8ea] dark:hover:bg-slate-800 transition-colors scale-95 duration-200 ease-in-out" href="#">
|
||||||
|
<span class="material-symbols-outlined" data-icon="auto_stories">auto_stories</span>
|
||||||
|
<span>Lộ trình</span>
|
||||||
|
</a>
|
||||||
|
<!-- Nav Item: Từ vựng (ACTIVE) -->
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-[#004ac6] dark:text-blue-400 font-bold border-r-4 border-[#004ac6] hover:bg-[#e6e8ea] dark:hover:bg-slate-800 transition-colors scale-95 duration-200 ease-in-out" href="#">
|
||||||
|
<span class="material-symbols-outlined" data-icon="style">style</span>
|
||||||
|
<span>Từ vựng</span>
|
||||||
|
</a>
|
||||||
|
<!-- Nav Item: Luyện đề -->
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-500 dark:text-slate-400 hover:text-[#191c1e] dark:hover:text-white hover:bg-[#e6e8ea] dark:hover:bg-slate-800 transition-colors scale-95 duration-200 ease-in-out" href="#">
|
||||||
|
<span class="material-symbols-outlined" data-icon="quiz">quiz</span>
|
||||||
|
<span>Luyện đề</span>
|
||||||
|
</a>
|
||||||
|
<!-- Nav Item: Cá nhân -->
|
||||||
|
<a class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-500 dark:text-slate-400 hover:text-[#191c1e] dark:hover:text-white hover:bg-[#e6e8ea] dark:hover:bg-slate-800 transition-colors scale-95 duration-200 ease-in-out" href="#">
|
||||||
|
<span class="material-symbols-outlined" data-icon="person">person</span>
|
||||||
|
<span>Cá nhân</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="mt-auto pt-6 px-2">
|
||||||
|
<div class="flex items-center gap-3 mb-6 p-2 rounded-2xl bg-surface-container-highest/30">
|
||||||
|
<img alt="User profile avatar" class="w-10 h-10 rounded-full object-cover" data-alt="Modern professional headshot of a student in a minimalist studio setting with soft natural light" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCieZ4VqZqWdC-93bq9687nQG1PrVcWIZD7c9T1iwGfZIVOTXswt5uaX4glKxn41fXpU_J1hcbvb3I9P8Ve0n64ABJodhru1s30Uhgp6kXe2atLuFIDhnXzWrIja5ggEpXqOXILDqVuM_yZ6jnOZxTGg3gLXAxQTsqOLc_Ed_SQNtVq2lYhSBSTkA1uWClgtlVu44XHe8DLciOmt_v6RbjI244YZmh_3Vkx_m1cvl4pMp2F1yPeuFbGbSR_lnL992bbTaLw7-nwYjCg"/>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<p class="font-bold text-on-surface truncate">Minh Anh</p>
|
||||||
|
<p class="text-xs text-on-surface-variant truncate">Level: Intermediate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="w-full py-4 bg-primary-container text-on-primary-container rounded-xl font-bold hover:opacity-90 transition-all flex items-center justify-center gap-2">
|
||||||
|
Bắt đầu học
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<!-- MAIN LAYOUT WRAPPER (2 Columns + Space) -->
|
||||||
|
<main class="flex-1 flex overflow-hidden">
|
||||||
|
<!-- COLUMN 2 (Left Panel): Topic Menu (approx 20% of content area) -->
|
||||||
|
<section class="w-72 bg-surface-container-low/50 overflow-y-auto px-6 py-8">
|
||||||
|
<h3 class="text-sm font-bold text-on-surface-variant uppercase tracking-widest mb-6">Chủ đề học</h3>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<button class="flex items-center justify-between px-4 py-3 rounded-xl transition-all hover:bg-surface-container-high text-on-surface-variant font-medium">
|
||||||
|
<span>Tất cả</span>
|
||||||
|
<span class="text-xs px-2 py-1 bg-surface-container-high rounded-lg">120</span>
|
||||||
|
</button>
|
||||||
|
<button class="flex items-center justify-between px-4 py-3 rounded-xl transition-all bg-primary/10 text-primary font-bold">
|
||||||
|
<span>Business</span>
|
||||||
|
<span class="text-xs px-2 py-1 bg-primary text-white rounded-lg">24</span>
|
||||||
|
</button>
|
||||||
|
<button class="flex items-center justify-between px-4 py-3 rounded-xl transition-all hover:bg-surface-container-high text-on-surface-variant font-medium">
|
||||||
|
<span>Office</span>
|
||||||
|
<span class="text-xs px-2 py-1 bg-surface-container-high rounded-lg">18</span>
|
||||||
|
</button>
|
||||||
|
<button class="flex items-center justify-between px-4 py-3 rounded-xl transition-all hover:bg-surface-container-high text-on-surface-variant font-medium">
|
||||||
|
<span>Travel</span>
|
||||||
|
<span class="text-xs px-2 py-1 bg-surface-container-high rounded-lg">22</span>
|
||||||
|
</button>
|
||||||
|
<button class="flex items-center justify-between px-4 py-3 rounded-xl transition-all hover:bg-surface-container-high text-on-surface-variant font-medium">
|
||||||
|
<span>Finance</span>
|
||||||
|
<span class="text-xs px-2 py-1 bg-surface-container-high rounded-lg">20</span>
|
||||||
|
</button>
|
||||||
|
<button class="flex items-center justify-between px-4 py-3 rounded-xl transition-all hover:bg-surface-container-high text-on-surface-variant font-medium">
|
||||||
|
<span>HR</span>
|
||||||
|
<span class="text-xs px-2 py-1 bg-surface-container-high rounded-lg">16</span>
|
||||||
|
</button>
|
||||||
|
<button class="flex items-center justify-between px-4 py-3 rounded-xl transition-all hover:bg-surface-container-high text-on-surface-variant font-medium">
|
||||||
|
<span>Marketing</span>
|
||||||
|
<span class="text-xs px-2 py-1 bg-surface-container-high rounded-lg">20</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-12 p-5 bg-tertiary-fixed rounded-2xl text-on-tertiary-fixed-variant">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="material-symbols-outlined text-sm" data-icon="lightbulb">lightbulb</span>
|
||||||
|
<p class="text-xs font-bold uppercase tracking-wider">Pro-Tip</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm leading-relaxed font-medium">Học theo cụm từ giúp bạn ghi nhớ lâu hơn 40% so với từ đơn lẻ.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- COLUMN 3 (Center Panel): Learning Area (approx 55% of content area) -->
|
||||||
|
<section class="flex-1 bg-surface relative overflow-y-auto px-12 py-8 flex flex-col items-center">
|
||||||
|
<header class="w-full max-w-2xl flex justify-between items-center mb-12">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-extrabold text-on-surface tracking-tight">Thẻ ghi nhớ</h2>
|
||||||
|
<p class="text-on-surface-variant/80 font-medium">Chủ đề: Business</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="w-10 h-10 flex items-center justify-center rounded-full bg-surface-container-low hover:bg-surface-container-high transition-colors">
|
||||||
|
<span class="material-symbols-outlined" data-icon="settings">settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<!-- THE FLASHCARD STAGE -->
|
||||||
|
<div class="w-full max-w-2xl flex flex-col items-center gap-10">
|
||||||
|
<div class="perspective-1000 w-full h-[400px]">
|
||||||
|
<div class="learning-card relative w-full h-full cursor-pointer shadow-[0px_20px_50px_rgba(0,0,0,0.05)] rounded-[2rem]">
|
||||||
|
<!-- Card Front -->
|
||||||
|
<div class="absolute inset-0 bg-surface-container-lowest flex flex-col items-center justify-center p-12 rounded-[2rem] backface-hidden">
|
||||||
|
<div class="absolute top-8 right-8">
|
||||||
|
<span class="bg-primary/10 text-primary px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-widest">Business</span>
|
||||||
|
</div>
|
||||||
|
<h4 class="text-6xl font-extrabold text-on-surface mb-4 tracking-tighter">negotiate</h4>
|
||||||
|
<p class="text-xl text-on-surface-variant font-medium tracking-wide">/nɪˈɡoʊʃieɪt/</p>
|
||||||
|
<div class="mt-12 opacity-30 flex flex-col items-center">
|
||||||
|
<span class="material-symbols-outlined text-4xl" data-icon="touch_app">touch_app</span>
|
||||||
|
<p class="text-xs uppercase mt-2 tracking-widest font-bold">Nhấn để lật thẻ</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Card Back (Simplified placeholder for visual structure) -->
|
||||||
|
<!-- Note: In a real app, this would be shown via JS flip -->
|
||||||
|
<!-- <div class="absolute inset-0 bg-surface-container-lowest flex flex-col items-center justify-center p-12 rounded-[2rem] backface-hidden [transform:rotateY(180deg)] border-2 border-primary-fixed">
|
||||||
|
<h4 class="text-2xl font-bold text-on-surface-variant mb-6">Đàm phán, thương lượng</h4>
|
||||||
|
<p class="text-center italic text-on-surface mb-8">"We need to negotiate a better deal for the company."</p>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- CONTROL BUTTONS -->
|
||||||
|
<div class="flex gap-6 w-full max-w-md">
|
||||||
|
<button class="flex-1 flex items-center justify-center gap-3 py-4 rounded-2xl bg-surface-container-high text-on-surface-variant font-bold hover:bg-surface-dim transition-all active:scale-95">
|
||||||
|
<span class="material-symbols-outlined" data-icon="replay">replay</span>
|
||||||
|
Cần ôn
|
||||||
|
</button>
|
||||||
|
<button class="flex-1 flex items-center justify-center gap-3 py-4 rounded-2xl bg-secondary text-on-secondary font-bold hover:opacity-90 shadow-lg shadow-secondary/20 transition-all active:scale-95">
|
||||||
|
<span class="material-symbols-outlined" data-icon="check_circle">check_circle</span>
|
||||||
|
Đã thuộc
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- PROGRESS BAR -->
|
||||||
|
<div class="w-full max-w-lg mt-4">
|
||||||
|
<div class="flex justify-between items-end mb-3">
|
||||||
|
<p class="text-sm font-bold text-on-surface-variant">32/120 từ đã thuộc</p>
|
||||||
|
<p class="text-2xl font-extrabold text-primary tracking-tighter">27%</p>
|
||||||
|
</div>
|
||||||
|
<div class="h-3 w-full bg-surface-container-high rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-gradient-to-r from-primary to-primary-container rounded-full" style="width: 27%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- COLUMN 4 (Right Panel): Stats & Activity (approx 25% of content area) -->
|
||||||
|
<section class="w-80 bg-surface-container-low/30 backdrop-blur-md px-6 py-8 border-l border-outline-variant/10">
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Stats Card -->
|
||||||
|
<div class="bg-surface-container-lowest p-6 rounded-[1.5rem] shadow-sm">
|
||||||
|
<h3 class="text-sm font-bold text-on-surface-variant uppercase tracking-widest mb-4">Hôm nay</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
<span class="material-symbols-outlined text-xl" data-icon="menu_book">menu_book</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-extrabold text-on-surface leading-none">12</p>
|
||||||
|
<p class="text-xs text-on-surface-variant font-medium">từ đã học hôm nay</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-secondary/10 flex items-center justify-center text-secondary">
|
||||||
|
<span class="material-symbols-outlined text-xl" data-icon="task_alt">task_alt</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-extrabold text-on-surface leading-none">8</p>
|
||||||
|
<p class="text-xs text-on-surface-variant font-medium">đã thuộc</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 pt-6 border-t border-surface-container-high flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-2xl">🔥</span>
|
||||||
|
<p class="font-bold text-on-surface">5 ngày streak</p>
|
||||||
|
</div>
|
||||||
|
<span class="material-symbols-outlined text-tertiary" data-icon="workspace_premium" data-weight="fill" style="font-variation-settings: 'FILL' 1;">workspace_premium</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-bold text-on-surface-variant uppercase tracking-widest mb-4 px-2">Vừa thuộc</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center gap-4 p-4 bg-surface-container-lowest rounded-2xl hover:bg-surface-container-high transition-all cursor-default">
|
||||||
|
<div class="w-2 h-2 rounded-full bg-secondary"></div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-bold text-on-surface">Investment</p>
|
||||||
|
<p class="text-xs text-on-surface-variant">/ɪnˈvest.mənt/</p>
|
||||||
|
</div>
|
||||||
|
<span class="material-symbols-outlined text-secondary text-sm" data-icon="check">check</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 p-4 bg-surface-container-lowest rounded-2xl hover:bg-surface-container-high transition-all cursor-default">
|
||||||
|
<div class="w-2 h-2 rounded-full bg-secondary"></div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-bold text-on-surface">Collaborate</p>
|
||||||
|
<p class="text-xs text-on-surface-variant">/kəˈlæb.ə.reɪt/</p>
|
||||||
|
</div>
|
||||||
|
<span class="material-symbols-outlined text-secondary text-sm" data-icon="check">check</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 p-4 bg-surface-container-lowest rounded-2xl hover:bg-surface-container-high transition-all cursor-default">
|
||||||
|
<div class="w-2 h-2 rounded-full bg-secondary"></div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-bold text-on-surface">Strategic</p>
|
||||||
|
<p class="text-xs text-on-surface-variant">/strəˈtiː.dʒɪk/</p>
|
||||||
|
</div>
|
||||||
|
<span class="material-symbols-outlined text-secondary text-sm" data-icon="check">check</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Mini Map Placeholder / Decoration -->
|
||||||
|
<div class="relative h-32 rounded-2xl overflow-hidden shadow-inner group">
|
||||||
|
<img alt="Background decoration" class="w-full h-full object-cover" data-alt="Abstract soft blurred office background with warm lighting and geometric architectural elements" src="https://lh3.googleusercontent.com/aida-public/AB6AXuC1G9ssYWJ42i8Gu3HLN_0ZWDQYvb9e_sWCi1mirt_zwoAz6WLSxjlAUvcVEvhIN-SHShgnyHgtY27hs2N0c2cWhDoQqXdW3kFMJwo7A7ubxOStHpCROC9CN883yDHLh3MNgK3hrBPVT0flL6CWiHYg1rIOiMIFT-rXgrtA38XPhlYyShfWvAcc_Ul0IwSF4hoc3xs2B22rj6fGWFCt9_7v2B8o0DUQ7nVx0mLEdDbRya3XhyE7HyEgQ6ykfGq8-4yOWo0uJb24FSwk"/>
|
||||||
|
<div class="absolute inset-0 bg-primary/20 backdrop-blur-[2px] flex items-center justify-center p-4">
|
||||||
|
<p class="text-white text-center text-xs font-bold leading-tight">Môi trường công sở là chủ đề phổ biến nhất trong TOEIC Listening.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
BIN
stitch-exports/screen-06-flashcard/design.png
Normal file
BIN
stitch-exports/screen-06-flashcard/design.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
1290
stitch-exports/toeic-app-design.html
Normal file
1290
stitch-exports/toeic-app-design.html
Normal file
File diff suppressed because it is too large
Load Diff
1
supabase/.temp/cli-latest
Normal file
1
supabase/.temp/cli-latest
Normal file
@@ -0,0 +1 @@
|
|||||||
|
v2.84.2
|
||||||
1
supabase/.temp/gotrue-version
Normal file
1
supabase/.temp/gotrue-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
v2.188.1
|
||||||
1
supabase/.temp/pooler-url
Normal file
1
supabase/.temp/pooler-url
Normal file
@@ -0,0 +1 @@
|
|||||||
|
postgresql://postgres.eiyunmdvhwwtsqsyjotn@aws-1-ap-northeast-1.pooler.supabase.com:5432/postgres
|
||||||
1
supabase/.temp/postgres-version
Normal file
1
supabase/.temp/postgres-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
17.6.1.104
|
||||||
1
supabase/.temp/project-ref
Normal file
1
supabase/.temp/project-ref
Normal file
@@ -0,0 +1 @@
|
|||||||
|
eiyunmdvhwwtsqsyjotn
|
||||||
1
supabase/.temp/rest-version
Normal file
1
supabase/.temp/rest-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
v14.5
|
||||||
1
supabase/.temp/storage-migration
Normal file
1
supabase/.temp/storage-migration
Normal file
@@ -0,0 +1 @@
|
|||||||
|
operation-ergonomics
|
||||||
1
supabase/.temp/storage-version
Normal file
1
supabase/.temp/storage-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
v1.48.20
|
||||||
73
supabase/functions/writing-check/index.ts
Normal file
73
supabase/functions/writing-check/index.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Supabase Edge Function: writing-check
|
||||||
|
// Uses GLM API (OpenAI-compatible) to analyze English writing submissions.
|
||||||
|
// Deploy: supabase functions deploy writing-check
|
||||||
|
// Secrets: supabase secrets set GLM_API_KEY=<your_key>
|
||||||
|
|
||||||
|
import OpenAI from "npm:openai@^4"
|
||||||
|
|
||||||
|
const glm = new OpenAI({
|
||||||
|
apiKey: Deno.env.get("GLM_API_KEY") ?? "",
|
||||||
|
baseURL: "https://open.bigmodel.cn/api/paas/v4/",
|
||||||
|
})
|
||||||
|
|
||||||
|
const CORS_HEADERS = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instructs the model to return a strict JSON structure with Vietnamese feedback.
|
||||||
|
const SYSTEM_PROMPT = `You are an expert English writing teacher specialising in TOEIC and IELTS assessment.
|
||||||
|
Analyse the student's writing and respond ONLY with valid JSON — no markdown, no extra text:
|
||||||
|
{
|
||||||
|
"score": "<estimated band score, e.g. 6.5>",
|
||||||
|
"grammar": ["<issue 1 with correction, mix English example + Vietnamese explanation>", ...],
|
||||||
|
"vocabulary": ["<vocabulary observation in Vietnamese>", ...],
|
||||||
|
"structure": "<2–3 sentence structure assessment in Vietnamese>",
|
||||||
|
"improved_version": "<the full improved text in English>",
|
||||||
|
"summary": "<2–3 sentence overall assessment in Vietnamese>"
|
||||||
|
}`
|
||||||
|
|
||||||
|
Deno.serve(async (req: Request) => {
|
||||||
|
// Handle CORS pre-flight
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response("ok", { headers: CORS_HEADERS })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { content } = await req.json() as { content: string }
|
||||||
|
|
||||||
|
if (!content || content.trim().length < 10) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Bài viết quá ngắn. Vui lòng nhập ít nhất 10 ký tự." }),
|
||||||
|
{ status: 400, headers: CORS_HEADERS },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const completion = await glm.chat.completions.create({
|
||||||
|
// GLM-4-32B-0414-128K: cheapest paid model at $0.1/$0.1 per 1M tokens.
|
||||||
|
// Override via: supabase secrets set GLM_MODEL=<other-model>
|
||||||
|
model: Deno.env.get("GLM_MODEL") ?? "GLM-4-32B-0414-128K",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: SYSTEM_PROMPT },
|
||||||
|
{ role: "user", content: `Analyse this writing:\n\n${content.slice(0, 2000)}` },
|
||||||
|
],
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 1500,
|
||||||
|
})
|
||||||
|
|
||||||
|
const raw = completion.choices[0]?.message?.content ?? "{}"
|
||||||
|
|
||||||
|
// Strip markdown code fences if the model adds them despite instructions
|
||||||
|
const cleaned = raw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim()
|
||||||
|
const feedback = JSON.parse(cleaned)
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(feedback), { headers: CORS_HEADERS })
|
||||||
|
} catch (err) {
|
||||||
|
console.error("writing-check error:", err)
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Đã có lỗi khi chấm bài. Vui lòng thử lại." }),
|
||||||
|
{ status: 500, headers: CORS_HEADERS },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
53
supabase/migrations/001_user_progress.sql
Normal file
53
supabase/migrations/001_user_progress.sql
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
-- Migration 001: user_progress + writing_submissions tables with RLS
|
||||||
|
-- Run in Supabase Dashboard → SQL Editor (after schema.sql)
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Test results + vocab progress
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS user_progress (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('test', 'vocab')),
|
||||||
|
data JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_progress_user_id ON user_progress(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_progress_type ON user_progress(user_id, type);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Writing submissions with AI feedback
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS writing_submissions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
feedback JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_writing_submissions_user_id ON writing_submissions(user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Row Level Security
|
||||||
|
-- ============================================================
|
||||||
|
ALTER TABLE user_progress ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE writing_submissions ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- user_progress: authenticated users own their rows
|
||||||
|
CREATE POLICY "Users can insert own progress"
|
||||||
|
ON user_progress FOR INSERT
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can read own progress"
|
||||||
|
ON user_progress FOR SELECT
|
||||||
|
USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- writing_submissions: authenticated users own their rows
|
||||||
|
CREATE POLICY "Users can insert own submissions"
|
||||||
|
ON writing_submissions FOR INSERT
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can read own submissions"
|
||||||
|
ON writing_submissions FOR SELECT
|
||||||
|
USING (auth.uid() = user_id);
|
||||||
166
supabase/postman-collection.json
Normal file
166
supabase/postman-collection.json
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"name": "English Learning App — Supabase",
|
||||||
|
"_postman_id": "english-app-supabase",
|
||||||
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
|
},
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "BASE_URL",
|
||||||
|
"value": "https://eiyunmdvhwwtsqsyjotn.supabase.co",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "ANON_KEY",
|
||||||
|
"value": "PASTE_YOUR_ANON_KEY_HERE",
|
||||||
|
"type": "string",
|
||||||
|
"description": "Supabase Dashboard → Settings → API → anon public (eyJ...)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "REST API",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "GET questions (Part 2)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE_URL}}/rest/v1/questions?select=id,part,content,options,answer,explanation&part=eq.2&limit=3",
|
||||||
|
"host": ["{{BASE_URL}}"],
|
||||||
|
"path": ["rest", "v1", "questions"],
|
||||||
|
"query": [
|
||||||
|
{ "key": "select", "value": "id,part,content,options,answer,explanation" },
|
||||||
|
{ "key": "part", "value": "eq.2" },
|
||||||
|
{ "key": "limit", "value": "3" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"header": [
|
||||||
|
{ "key": "apikey", "value": "{{ANON_KEY}}" },
|
||||||
|
{ "key": "Authorization", "value": "Bearer {{ANON_KEY}}" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GET all questions",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE_URL}}/rest/v1/questions?select=id,part,answer&order=part.asc",
|
||||||
|
"host": ["{{BASE_URL}}"],
|
||||||
|
"path": ["rest", "v1", "questions"],
|
||||||
|
"query": [
|
||||||
|
{ "key": "select", "value": "id,part,answer" },
|
||||||
|
{ "key": "order", "value": "part.asc" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"header": [
|
||||||
|
{ "key": "apikey", "value": "{{ANON_KEY}}" },
|
||||||
|
{ "key": "Authorization", "value": "Bearer {{ANON_KEY}}" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GET vocab (Business topic)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE_URL}}/rest/v1/vocab?select=id,word,phonetic,meaning_vi,topic,example&topic=eq.Business",
|
||||||
|
"host": ["{{BASE_URL}}"],
|
||||||
|
"path": ["rest", "v1", "vocab"],
|
||||||
|
"query": [
|
||||||
|
{ "key": "select", "value": "id,word,phonetic,meaning_vi,topic,example" },
|
||||||
|
{ "key": "topic", "value": "eq.Business" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"header": [
|
||||||
|
{ "key": "apikey", "value": "{{ANON_KEY}}" },
|
||||||
|
{ "key": "Authorization", "value": "Bearer {{ANON_KEY}}" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GET all vocab",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE_URL}}/rest/v1/vocab?select=word,topic&order=topic.asc",
|
||||||
|
"host": ["{{BASE_URL}}"],
|
||||||
|
"path": ["rest", "v1", "vocab"],
|
||||||
|
"query": [
|
||||||
|
{ "key": "select", "value": "word,topic" },
|
||||||
|
{ "key": "order", "value": "topic.asc" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"header": [
|
||||||
|
{ "key": "apikey", "value": "{{ANON_KEY}}" },
|
||||||
|
{ "key": "Authorization", "value": "Bearer {{ANON_KEY}}" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Edge Functions",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "POST writing-check (short sentence)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE_URL}}/functions/v1/writing-check",
|
||||||
|
"host": ["{{BASE_URL}}"],
|
||||||
|
"path": ["functions", "v1", "writing-check"]
|
||||||
|
},
|
||||||
|
"header": [
|
||||||
|
{ "key": "Authorization", "value": "Bearer {{ANON_KEY}}" },
|
||||||
|
{ "key": "Content-Type", "value": "application/json" }
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"content\": \"The company have many employee who works very hard every days to achieve their goal.\"\n}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "POST writing-check (paragraph)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE_URL}}/functions/v1/writing-check",
|
||||||
|
"host": ["{{BASE_URL}}"],
|
||||||
|
"path": ["functions", "v1", "writing-check"]
|
||||||
|
},
|
||||||
|
"header": [
|
||||||
|
{ "key": "Authorization", "value": "Bearer {{ANON_KEY}}" },
|
||||||
|
{ "key": "Content-Type", "value": "application/json" }
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"content\": \"Dear Mr. Johnson, I am writing to inform you that our company will implement a new remote work policy starting from next month. All employees will be able to work from home for three days per week. This change is expect to improve work-life balance and increase productivity. However, some managers are concern about the impact on team collaboration. We will organize training session to help teams adapt to this new arrangement.\"\n}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "POST writing-check (too short — expect 400)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE_URL}}/functions/v1/writing-check",
|
||||||
|
"host": ["{{BASE_URL}}"],
|
||||||
|
"path": ["functions", "v1", "writing-check"]
|
||||||
|
},
|
||||||
|
"header": [
|
||||||
|
{ "key": "Authorization", "value": "Bearer {{ANON_KEY}}" },
|
||||||
|
{ "key": "Content-Type", "value": "application/json" }
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"content\": \"Hi\"\n}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
33
supabase/schema.sql
Normal file
33
supabase/schema.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Schema: English Learning App (TOEIC Focus)
|
||||||
|
-- Run this FIRST before seed.sql
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- TOEIC questions (Part 1–7)
|
||||||
|
CREATE TABLE IF NOT EXISTS questions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
part INT NOT NULL CHECK (part BETWEEN 1 AND 7),
|
||||||
|
type TEXT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
options JSONB NOT NULL DEFAULT '[]', -- ["A. ...", "B. ...", "C. ...", "D. ..."]
|
||||||
|
answer TEXT NOT NULL, -- "A" | "B" | "C" | "D"
|
||||||
|
explanation TEXT,
|
||||||
|
audio_url TEXT, -- Part 1–4 (listening)
|
||||||
|
image_url TEXT, -- Part 1 (photos)
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- TOEIC vocabulary (6 topics)
|
||||||
|
CREATE TABLE IF NOT EXISTS vocab (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
word TEXT NOT NULL,
|
||||||
|
phonetic TEXT,
|
||||||
|
meaning_vi TEXT NOT NULL,
|
||||||
|
topic TEXT NOT NULL CHECK (topic IN ('Business', 'Office', 'Travel', 'Finance', 'HR', 'Marketing')),
|
||||||
|
example TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for common query patterns
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_questions_part ON questions(part);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vocab_topic ON vocab(topic);
|
||||||
946
supabase/seed.sql
Normal file
946
supabase/seed.sql
Normal file
@@ -0,0 +1,946 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Seed: English Learning App — TOEIC Questions (140) + Vocab
|
||||||
|
-- Run AFTER schema.sql
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
TRUNCATE TABLE questions, vocab RESTART IDENTITY CASCADE;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- QUESTIONS — 140 total (20 per Part 1–7)
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO questions (part, type, content, options, answer, explanation) VALUES
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- PART 1 — Photo Description (20 questions)
|
||||||
|
-- ============================================================
|
||||||
|
(1, 'photo',
|
||||||
|
'A woman is sitting at a desk in an office, looking at a computer screen.',
|
||||||
|
'["A. The woman is typing on a keyboard.", "B. The woman is looking at a computer screen.", "C. The woman is talking on the phone.", "D. The woman is writing in a notebook."]',
|
||||||
|
'B',
|
||||||
|
'Hình ảnh cho thấy người phụ nữ đang nhìn vào màn hình máy tính, không phải đang gõ bàn phím hay nói chuyện điện thoại.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'Two men are shaking hands in front of a building.',
|
||||||
|
'["A. The men are walking into the building.", "B. One man is handing a document to the other.", "C. The men are shaking hands.", "D. The men are sitting at a table."]',
|
||||||
|
'C',
|
||||||
|
'Hành động bắt tay là điểm nhận dạng chính trong hình ảnh này.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'A group of people are gathered around a conference table with papers and laptops.',
|
||||||
|
'["A. The people are gathered around a conference table.", "B. The people are watching a presentation.", "C. The people are having lunch together.", "D. The people are standing outside an office."]',
|
||||||
|
'A',
|
||||||
|
'Hình ảnh mô tả nhóm người đang họp xung quanh bàn hội nghị có giấy tờ và laptop.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'A delivery man is carrying boxes to the entrance of a store.',
|
||||||
|
'["A. A worker is stacking boxes in a warehouse.", "B. A customer is leaving the store with bags.", "C. A man is loading boxes onto a truck.", "D. A delivery man is carrying boxes to the store entrance."]',
|
||||||
|
'D',
|
||||||
|
'Người giao hàng đang mang thùng hàng đến cửa hàng — đây là mô tả chính xác nhất.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'A woman is standing in front of a whiteboard, pointing at a chart.',
|
||||||
|
'["A. A woman is erasing the whiteboard.", "B. A woman is writing on the whiteboard.", "C. A woman is looking at a screen behind her.", "D. A woman is pointing at a chart on the whiteboard."]',
|
||||||
|
'D',
|
||||||
|
'Người phụ nữ đang chỉ vào biểu đồ trên bảng trắng, không phải đang xóa hay viết.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'Shelves in a supermarket are fully stocked with various products.',
|
||||||
|
'["A. A worker is restocking the shelves.", "B. The shelves are empty and being cleaned.", "C. A customer is picking items off the shelves.", "D. The shelves are fully stocked with products."]',
|
||||||
|
'D',
|
||||||
|
'Kệ hàng đầy ắp sản phẩm — mô tả trạng thái của kệ, không phải hành động của người.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'A man is talking on his mobile phone while walking down a hallway.',
|
||||||
|
'["A. A man is using a computer in the hallway.", "B. A man is talking on his mobile phone while walking.", "C. A man is waiting for an elevator.", "D. A man is reading a document in the hallway."]',
|
||||||
|
'B',
|
||||||
|
'Hình ảnh cho thấy người đàn ông đang nghe điện thoại và đi bộ trong hành lang.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'Workers in hard hats are inspecting equipment at a construction site.',
|
||||||
|
'["A. Workers are resting near a construction vehicle.", "B. Workers are pouring concrete at a construction site.", "C. Workers in hard hats are inspecting equipment.", "D. Workers are carrying materials into a building."]',
|
||||||
|
'C',
|
||||||
|
'Công nhân đội mũ bảo hộ đang kiểm tra thiết bị — đây là điểm nhận dạng rõ ràng.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'A receptionist is handing a visitor badge to a guest at the front desk.',
|
||||||
|
'["A. A receptionist is answering the phone.", "B. A receptionist is handing a badge to a visitor.", "C. A guest is signing a document at the front desk.", "D. Two employees are discussing at the front desk."]',
|
||||||
|
'B',
|
||||||
|
'Lễ tân đang đưa thẻ khách cho người đến thăm — đây là hành động chính trong hình.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'A chef is preparing food in a restaurant kitchen.',
|
||||||
|
'["A. A chef is preparing food in the kitchen.", "B. A waiter is serving food to customers.", "C. A chef is washing dishes in the kitchen.", "D. A cook is cleaning the countertop."]',
|
||||||
|
'A',
|
||||||
|
'Đầu bếp đang chuẩn bị thức ăn trong bếp nhà hàng là mô tả phù hợp nhất.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'Passengers are boarding an airplane at the gate.',
|
||||||
|
'["A. Passengers are waiting in the terminal.", "B. Passengers are collecting luggage.", "C. A plane is taking off from the runway.", "D. Passengers are boarding an airplane at the gate."]',
|
||||||
|
'D',
|
||||||
|
'Hành khách đang lên máy bay tại cổng — hành động được mô tả rõ ràng trong hình.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'A woman is reading a menu at a restaurant table.',
|
||||||
|
'["A. A woman is ordering food from a waiter.", "B. A woman is eating a meal at a restaurant.", "C. A woman is paying the bill at a restaurant.", "D. A woman is reading a menu at a table."]',
|
||||||
|
'D',
|
||||||
|
'Người phụ nữ đang đọc thực đơn, chưa gọi món hay ăn.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'A man is loading luggage into the trunk of a car.',
|
||||||
|
'["A. A man is unloading groceries from a car.", "B. A man is loading luggage into the trunk.", "C. A taxi driver is helping a passenger with bags.", "D. A man is repairing the car in a parking lot."]',
|
||||||
|
'B',
|
||||||
|
'Người đàn ông đang xếp hành lý vào cốp xe — hành động được nhìn thấy rõ trong hình.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'An employee is photocopying documents at a copy machine.',
|
||||||
|
'["A. An employee is shredding documents.", "B. An employee is scanning documents on a scanner.", "C. An employee is photocopying documents at a copy machine.", "D. An employee is printing from a computer."]',
|
||||||
|
'C',
|
||||||
|
'Nhân viên đang photo tài liệu tại máy photocopy — đây là mô tả chính xác nhất.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'A group of colleagues are eating lunch together in a break room.',
|
||||||
|
'["A. Colleagues are working together in a meeting room.", "B. Employees are lining up at a cafeteria.", "C. Workers are cleaning up after a meal.", "D. Colleagues are eating lunch together in a break room."]',
|
||||||
|
'D',
|
||||||
|
'Nhóm đồng nghiệp đang ăn trưa cùng nhau trong phòng nghỉ.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'A technician is repairing a computer at a service desk.',
|
||||||
|
'["A. A technician is installing software on a computer.", "B. A customer is reporting an issue at the service desk.", "C. A technician is repairing a computer at a service desk.", "D. A clerk is operating a computer at the front desk."]',
|
||||||
|
'C',
|
||||||
|
'Kỹ thuật viên đang sửa máy tính tại quầy dịch vụ — đây là hành động chính.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'Several cars are parked in a parking lot near a shopping mall.',
|
||||||
|
'["A. Several cars are parked in a parking lot.", "B. A traffic officer is directing cars in a lot.", "C. Cars are entering the parking lot.", "D. Workers are painting lines in the parking lot."]',
|
||||||
|
'A',
|
||||||
|
'Hình ảnh cho thấy nhiều xe đang đậu trong bãi đỗ xe — không có hoạt động nào đang diễn ra.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'A woman is watering plants on a balcony.',
|
||||||
|
'["A. A woman is arranging flowers inside a room.", "B. A woman is watering plants on a balcony.", "C. A gardener is mowing the lawn outside.", "D. A woman is sweeping the balcony floor."]',
|
||||||
|
'B',
|
||||||
|
'Người phụ nữ đang tưới cây trên ban công — hành động rõ ràng trong hình ảnh.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'A man is presenting a report at a podium in a conference hall.',
|
||||||
|
'["A. A man is asking questions from the audience.", "B. A man is distributing handouts to the audience.", "C. A man is setting up equipment in a conference hall.", "D. A man is presenting at a podium in a conference hall."]',
|
||||||
|
'D',
|
||||||
|
'Người đàn ông đang thuyết trình tại bục phát biểu trong hội trường.'),
|
||||||
|
|
||||||
|
(1, 'photo',
|
||||||
|
'Boxes of merchandise are stacked on pallets in a warehouse.',
|
||||||
|
'["A. Workers are unpacking boxes from a delivery truck.", "B. Shelves in a store are being restocked.", "C. Boxes of merchandise are stacked on pallets in a warehouse.", "D. A forklift is moving pallets in a storage area."]',
|
||||||
|
'C',
|
||||||
|
'Thùng hàng được xếp trên pallet trong kho — không có người hay thiết bị đang hoạt động.'),
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- PART 2 — Question & Response (20 questions)
|
||||||
|
-- ============================================================
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'When does the next quarterly meeting start?',
|
||||||
|
'["A. It starts at 9 a.m. on Thursday.", "B. The meeting was very productive.", "C. Yes, I attended last quarter.", "D. (No appropriate response)"]',
|
||||||
|
'A',
|
||||||
|
'Câu hỏi hỏi về thời gian — đáp án A trả lời trực tiếp với thời gian và ngày cụ thể.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'Could you help me move this furniture to the conference room?',
|
||||||
|
'["A. The furniture looks brand new.", "B. Sure, I can help you right after this call.", "C. The conference room is on the third floor.", "D. (No appropriate response)"]',
|
||||||
|
'B',
|
||||||
|
'Câu hỏi nhờ giúp đỡ — đáp án B đồng ý giúp sau khi kết thúc cuộc gọi, là phản hồi hợp lý nhất.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'Why hasn''t the client signed the contract yet?',
|
||||||
|
'["A. The contract was signed yesterday.", "B. Yes, the client called this morning.", "C. She is still reviewing the terms with her legal team.", "D. (No appropriate response)"]',
|
||||||
|
'C',
|
||||||
|
'Câu hỏi hỏi lý do — đáp án C giải thích khách hàng đang xem xét điều khoản với nhóm pháp lý.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'Where should I submit the expense report?',
|
||||||
|
'["A. Just send it to accounting@company.com.", "B. The report is due next Friday.", "C. You should include all receipts.", "D. (No appropriate response)"]',
|
||||||
|
'A',
|
||||||
|
'Câu hỏi hỏi nơi nộp — đáp án A cung cấp địa chỉ email cụ thể để gửi báo cáo.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'Have you received the package from the supplier yet?',
|
||||||
|
'["A. The package weighs about 5 kilograms.", "B. I sent it out last week.", "C. (No appropriate response)", "D. No, it hasn''t arrived as of this morning."]',
|
||||||
|
'D',
|
||||||
|
'Câu hỏi Yes/No về việc nhận hàng — đáp án D trả lời không và cung cấp thông tin thêm.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'Who is responsible for updating the company website?',
|
||||||
|
'["A. The website was updated last month.", "B. Our company website is very popular.", "C. (No appropriate response)", "D. Mark in the IT department handles that."]',
|
||||||
|
'D',
|
||||||
|
'Câu hỏi hỏi người chịu trách nhiệm — đáp án D nêu tên và bộ phận cụ thể.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'Would you like me to book a table for the client dinner?',
|
||||||
|
'["A. Yes, please make a reservation for six people.", "B. The dinner was excellent last time.", "C. The restaurant closes at 10 p.m.", "D. (No appropriate response)"]',
|
||||||
|
'A',
|
||||||
|
'Câu hỏi đề nghị giúp đỡ — đáp án A chấp nhận và cung cấp thêm yêu cầu về số người.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'How long will the software upgrade take?',
|
||||||
|
'["A. The software has many new features.", "B. We upgraded last year too.", "C. It should be finished in about two hours.", "D. (No appropriate response)"]',
|
||||||
|
'C',
|
||||||
|
'Câu hỏi hỏi thời gian — đáp án C trả lời thời lượng khoảng hai tiếng.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'Has the budget proposal been approved by the board?',
|
||||||
|
'["A. The proposal was well-written.", "B. Yes, the budget is always important.", "C. (No appropriate response)", "D. Not yet — they are voting on it this afternoon."]',
|
||||||
|
'D',
|
||||||
|
'Câu Yes/No hỏi về trạng thái phê duyệt — đáp án D cho biết chưa được duyệt và sẽ bỏ phiếu chiều nay.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'Which printer should I use for color documents?',
|
||||||
|
'["A. Use the one on the second floor near the copy room.", "B. You can print black and white on any printer.", "C. The printer ran out of ink yesterday.", "D. (No appropriate response)"]',
|
||||||
|
'A',
|
||||||
|
'Câu hỏi hỏi máy in nào nên dùng — đáp án A chỉ đến máy in cụ thể và vị trí của nó.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'Why was the training session rescheduled?',
|
||||||
|
'["A. The training will last for two days.", "B. I enjoyed the last training very much.", "C. The facilitator had a last-minute conflict.", "D. (No appropriate response)"]',
|
||||||
|
'C',
|
||||||
|
'Câu hỏi lý do đổi lịch — đáp án C giải thích người hướng dẫn có việc đột xuất.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'Can you send me the revised proposal before noon?',
|
||||||
|
'["A. The proposal needs more research.", "B. Noon is when the meeting starts.", "C. (No appropriate response)", "D. I''ll email it to you within the hour."]',
|
||||||
|
'D',
|
||||||
|
'Câu hỏi nhờ gửi tài liệu — đáp án D cam kết gửi qua email trong vòng một giờ.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'Isn''t the annual report supposed to be published today?',
|
||||||
|
'["A. Actually, the release was pushed back to Friday.", "B. The report covers last year''s results.", "C. Yes, reports are published regularly.", "D. (No appropriate response)"]',
|
||||||
|
'A',
|
||||||
|
'Câu hỏi phủ định để xác nhận — đáp án A chỉnh sửa thông tin và đưa ra ngày chính xác.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'Where is the nearest parking garage to the office?',
|
||||||
|
'["A. Parking is very expensive downtown.", "B. You should take the subway instead.", "C. There''s one on Pine Street, just two blocks away.", "D. (No appropriate response)"]',
|
||||||
|
'C',
|
||||||
|
'Câu hỏi về địa điểm — đáp án C chỉ ra garage cụ thể với khoảng cách rõ ràng.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'Would you mind covering the front desk while Sarah is at lunch?',
|
||||||
|
'["A. Sarah usually takes a short lunch break.", "B. The front desk was recently renovated.", "C. (No appropriate response)", "D. Not at all — I''ll head over now."]',
|
||||||
|
'D',
|
||||||
|
'Câu đề nghị — đáp án D đồng ý lịch sự và cho biết sẽ đến ngay.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'Who approved the travel expenses for the Tokyo trip?',
|
||||||
|
'["A. Tokyo is a great city to visit.", "B. Mr. Chen in finance signed off on them.", "C. The trip was very successful.", "D. (No appropriate response)"]',
|
||||||
|
'B',
|
||||||
|
'Câu hỏi về người phê duyệt — đáp án B nêu tên và bộ phận của người duyệt chi phí.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'How many copies of the handout should I prepare?',
|
||||||
|
'["A. The handout has five pages.", "B. Make about thirty — we expect a full room.", "C. I can prepare them for you.", "D. (No appropriate response)"]',
|
||||||
|
'B',
|
||||||
|
'Câu hỏi về số lượng — đáp án B đưa ra con số cụ thể kèm lý do.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'The new marketing director starts on Monday, doesn''t she?',
|
||||||
|
'["A. She was promoted last year.", "B. Actually, her start date was moved to Wednesday.", "C. Yes, marketing is a challenging role.", "D. (No appropriate response)"]',
|
||||||
|
'B',
|
||||||
|
'Câu xác nhận tag question — đáp án B cung cấp thông tin chỉnh sửa về ngày bắt đầu thực tế.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'Should I contact the vendor directly or go through procurement?',
|
||||||
|
'["A. The vendor offers competitive pricing.", "B. Always go through procurement for new orders.", "C. Vendors usually respond within a day.", "D. (No appropriate response)"]',
|
||||||
|
'B',
|
||||||
|
'Câu hỏi hai lựa chọn — đáp án B đưa ra hướng dẫn rõ ràng là qua phòng mua sắm.'),
|
||||||
|
|
||||||
|
(2, 'q_and_a',
|
||||||
|
'Do you know when the IT system will be back online?',
|
||||||
|
'["A. IT support works on the ground floor.", "B. They said it should be restored by 3 p.m.", "C. The system was updated last week.", "D. (No appropriate response)"]',
|
||||||
|
'B',
|
||||||
|
'Câu hỏi về thời gian khôi phục hệ thống — đáp án B cung cấp thời gian dự kiến cụ thể.'),
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- PART 3 — Conversation (20 questions — 7 conversations × ~3 Qs each, trimmed to 20)
|
||||||
|
-- ============================================================
|
||||||
|
-- Conversation A: Office supply order
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Man]: Hi, Lisa. Do we have enough printer paper for this week?\n[Woman]: We''re running low. I was going to place an order today.\n[Man]: Great. Can you also order some toner cartridges? We''re almost out.\n[Woman]: Sure. Should I use the usual supplier, or try the new one that was recommended?\n\nQuestion: What are the speakers mainly discussing?',
|
||||||
|
'["A. Placing an order for office supplies", "B. A broken printer in the office", "C. Hiring a new office manager", "D. A budget meeting this week"]',
|
||||||
|
'A',
|
||||||
|
'Hai người đang thảo luận về việc đặt mua vật tư văn phòng (giấy và hộp mực).'),
|
||||||
|
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Man]: Hi, Lisa. Do we have enough printer paper for this week?\n[Woman]: We''re running low. I was going to place an order today.\n[Man]: Great. Can you also order some toner cartridges? We''re almost out.\n[Woman]: Sure. Should I use the usual supplier, or try the new one that was recommended?\n\nQuestion: What does the man ask the woman to order in addition to paper?',
|
||||||
|
'["A. Staplers", "B. Envelopes", "C. Toner cartridges", "D. Printer cables"]',
|
||||||
|
'C',
|
||||||
|
'Người đàn ông yêu cầu đặt thêm hộp mực (toner cartridges) vì sắp hết.'),
|
||||||
|
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Man]: Hi, Lisa. Do we have enough printer paper for this week?\n[Woman]: We''re running low. I was going to place an order today.\n[Man]: Great. Can you also order some toner cartridges? We''re almost out.\n[Woman]: Sure. Should I use the usual supplier, or try the new one that was recommended?\n\nQuestion: What is the woman considering regarding the order?',
|
||||||
|
'["A. Whether to request a discount", "B. How much paper to order", "C. When to schedule delivery", "D. Which supplier to use"]',
|
||||||
|
'D',
|
||||||
|
'Người phụ nữ đang cân nhắc dùng nhà cung cấp thường hay thử nhà cung cấp mới được giới thiệu.'),
|
||||||
|
|
||||||
|
-- Conversation B: Job interview schedule
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Woman]: Tom, I''ve scheduled three candidates for interviews on Thursday.\n[Man]: That''s a busy day. What times are they coming in?\n[Woman]: The first one is at 10, then 1 o''clock, and the last one at 3:30.\n[Man]: I have a client call at 2. Will that be a problem?\n\nQuestion: What is the woman doing?',
|
||||||
|
'["A. Submitting job applications", "B. Reviewing employee performance", "C. Setting up a client meeting", "D. Scheduling job interviews"]',
|
||||||
|
'D',
|
||||||
|
'Người phụ nữ đang lên lịch phỏng vấn cho ba ứng viên vào thứ Năm.'),
|
||||||
|
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Woman]: Tom, I''ve scheduled three candidates for interviews on Thursday.\n[Man]: That''s a busy day. What times are they coming in?\n[Woman]: The first one is at 10, then 1 o''clock, and the last one at 3:30.\n[Man]: I have a client call at 2. Will that be a problem?\n\nQuestion: What is the man concerned about?',
|
||||||
|
'["A. A client call conflicting with an interview", "B. Not enough candidates applying", "C. The interview room being unavailable", "D. Forgetting to prepare questions"]',
|
||||||
|
'A',
|
||||||
|
'Người đàn ông lo ngại cuộc gọi với khách hàng lúc 2 giờ có thể xung đột với lịch phỏng vấn.'),
|
||||||
|
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Woman]: Tom, I''ve scheduled three candidates for interviews on Thursday.\n[Man]: That''s a busy day. What times are they coming in?\n[Woman]: The first one is at 10, then 1 o''clock, and the last one at 3:30.\n[Man]: I have a client call at 2. Will that be a problem?\n\nQuestion: How many candidates are scheduled for Thursday?',
|
||||||
|
'["A. Two", "B. Four", "C. Three", "D. Five"]',
|
||||||
|
'C',
|
||||||
|
'Người phụ nữ nói "I''ve scheduled three candidates for interviews on Thursday."'),
|
||||||
|
|
||||||
|
-- Conversation C: Cafeteria renovation
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Man]: Did you hear the cafeteria will be closed for two weeks starting Monday?\n[Woman]: Really? Where are we supposed to eat lunch then?\n[Man]: Management is setting up a temporary food truck outside the main entrance.\n[Woman]: That''s not ideal, but I guess it''s better than nothing.\n\nQuestion: Why will the cafeteria be closed?',
|
||||||
|
'["A. It failed a health inspection", "B. It ran out of food supplies", "C. Management decided to outsource catering", "D. It is being renovated"]',
|
||||||
|
'D',
|
||||||
|
'Lý do đóng cửa căng tin không được nói trực tiếp nhưng ngữ cảnh cho thấy đang được cải tạo (renovation).'),
|
||||||
|
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Man]: Did you hear the cafeteria will be closed for two weeks starting Monday?\n[Woman]: Really? Where are we supposed to eat lunch then?\n[Man]: Management is setting up a temporary food truck outside the main entrance.\n[Woman]: That''s not ideal, but I guess it''s better than nothing.\n\nQuestion: What temporary arrangement has management made?',
|
||||||
|
'["A. Setting up a food truck outside", "B. Providing lunch boxes to employees", "C. Extending lunch hour by 30 minutes", "D. Partnering with a nearby restaurant"]',
|
||||||
|
'A',
|
||||||
|
'Ban quản lý sẽ đặt xe bán đồ ăn tạm thời ở lối vào chính trong thời gian căng tin đóng cửa.'),
|
||||||
|
|
||||||
|
-- Conversation D: Overseas client visit
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Woman]: Our client from Singapore is arriving on Wednesday afternoon.\n[Man]: Did you arrange a car to pick her up from the airport?\n[Woman]: Yes, and I''ve reserved a meeting room for Thursday morning as well.\n[Man]: Perfect. I''ll prepare the presentation slides tonight.\n\nQuestion: Who is arriving on Wednesday?',
|
||||||
|
'["A. A new employee from head office", "B. A consultant from overseas", "C. A vendor representative", "D. A client from Singapore"]',
|
||||||
|
'D',
|
||||||
|
'Người phụ nữ nói "Our client from Singapore is arriving on Wednesday afternoon."'),
|
||||||
|
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Woman]: Our client from Singapore is arriving on Wednesday afternoon.\n[Man]: Did you arrange a car to pick her up from the airport?\n[Woman]: Yes, and I''ve reserved a meeting room for Thursday morning as well.\n[Man]: Perfect. I''ll prepare the presentation slides tonight.\n\nQuestion: What will the man do this evening?',
|
||||||
|
'["A. Pick up the client from the airport", "B. Book the meeting room", "C. Send a confirmation email to the client", "D. Prepare presentation slides"]',
|
||||||
|
'D',
|
||||||
|
'Người đàn ông nói "I''ll prepare the presentation slides tonight."'),
|
||||||
|
|
||||||
|
-- Conversation E: Software issue
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Man]: I can''t log into the project management system. It keeps saying my password is wrong.\n[Woman]: Did you try resetting it through the IT portal?\n[Man]: Yes, but the reset email never came through.\n[Woman]: Let me contact the IT helpdesk for you. They usually fix these things within an hour.\n\nQuestion: What problem is the man experiencing?',
|
||||||
|
'["A. He forgot his employee ID number", "B. He received an error message about his account being locked", "C. The IT portal is completely down", "D. He cannot log into the project management system"]',
|
||||||
|
'D',
|
||||||
|
'Người đàn ông không thể đăng nhập vào hệ thống quản lý dự án vì mật khẩu bị báo sai.'),
|
||||||
|
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Man]: I can''t log into the project management system. It keeps saying my password is wrong.\n[Woman]: Did you try resetting it through the IT portal?\n[Man]: Yes, but the reset email never came through.\n[Woman]: Let me contact the IT helpdesk for you. They usually fix these things within an hour.\n\nQuestion: What does the woman offer to do?',
|
||||||
|
'["A. Reset the man''s password herself", "B. Lend her account to the man temporarily", "C. Contact the IT helpdesk on his behalf", "D. Escalate the issue to the department head"]',
|
||||||
|
'C',
|
||||||
|
'Người phụ nữ đề nghị liên hệ bộ phận IT hộ người đàn ông.'),
|
||||||
|
|
||||||
|
-- Conversation F: Flight delay
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Woman]: Our 7 a.m. flight to Chicago has been delayed by two hours due to bad weather.\n[Man]: Oh no. I have a 10 a.m. meeting at the client''s office.\n[Woman]: I''ll call ahead and let them know. They might be able to push the meeting back.\n[Man]: That would be great. Make sure they know we''ll still make it today.\n\nQuestion: Why is the flight delayed?',
|
||||||
|
'["A. Mechanical issues with the aircraft", "B. Air traffic control problems", "C. Bad weather conditions", "D. A security alert at the airport"]',
|
||||||
|
'C',
|
||||||
|
'Chuyến bay bị trễ vì thời tiết xấu — "due to bad weather."'),
|
||||||
|
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Woman]: Our 7 a.m. flight to Chicago has been delayed by two hours due to bad weather.\n[Man]: Oh no. I have a 10 a.m. meeting at the client''s office.\n[Woman]: I''ll call ahead and let them know. They might be able to push the meeting back.\n[Man]: That would be great. Make sure they know we''ll still make it today.\n\nQuestion: What does the man want the woman to communicate to the client?',
|
||||||
|
'["A. That the meeting must be cancelled", "B. That they need to reschedule for tomorrow", "C. That they will still arrive today", "D. That the delay is out of their control"]',
|
||||||
|
'C',
|
||||||
|
'Người đàn ông muốn đảm bảo khách hàng biết họ vẫn sẽ đến trong ngày hôm nay.'),
|
||||||
|
|
||||||
|
-- Conversation G: Training registration
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Man]: I''d like to sign up for the Excel training next week. Is there still space?\n[Woman]: Let me check the registration list... Yes, there are two spots left.\n[Man]: Great. Can I also register my colleague Amy?\n[Woman]: Of course. I''ll add both your names right now.\n\nQuestion: What does the man want to do?',
|
||||||
|
'["A. Teach an Excel class next week", "B. Find out who is teaching the training", "C. Access last week''s training materials", "D. Register for an Excel training session"]',
|
||||||
|
'D',
|
||||||
|
'Người đàn ông muốn đăng ký tham gia khóa đào tạo Excel tuần tới.'),
|
||||||
|
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Man]: I''d like to sign up for the Excel training next week. Is there still space?\n[Woman]: Let me check the registration list... Yes, there are two spots left.\n[Man]: Great. Can I also register my colleague Amy?\n[Woman]: Of course. I''ll add both your names right now.\n\nQuestion: How many spots are available in the training?',
|
||||||
|
'["A. One", "B. Three", "C. Four", "D. Two"]',
|
||||||
|
'D',
|
||||||
|
'Người phụ nữ xác nhận "there are two spots left" — còn hai chỗ trống.'),
|
||||||
|
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Man]: I''d like to sign up for the Excel training next week. Is there still space?\n[Woman]: Let me check the registration list... Yes, there are two spots left.\n[Man]: Great. Can I also register my colleague Amy?\n[Woman]: Of course. I''ll add both your names right now.\n\nQuestion: What will the woman do immediately after the conversation?',
|
||||||
|
'["A. Email Amy the training schedule", "B. Add both names to the registration list", "C. Confirm the training date with the instructor", "D. Send a calendar invite to the man"]',
|
||||||
|
'B',
|
||||||
|
'Người phụ nữ nói "I''ll add both your names right now" — sẽ thêm tên ngay lập tức.'),
|
||||||
|
|
||||||
|
-- Conversation H: Relocation of department
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Woman]: Did you hear our department is moving to the fourth floor next month?\n[Man]: I did. I''m not looking forward to packing everything up.\n[Woman]: The new space is much bigger though. We''ll each get our own office.\n[Man]: That''s a silver lining. When exactly are we moving?\n\nQuestion: What is happening to the department next month?',
|
||||||
|
'["A. It is merging with another department", "B. It is moving to a different floor", "C. It is relocating to a new building", "D. It is downsizing its staff"]',
|
||||||
|
'B',
|
||||||
|
'Bộ phận sẽ chuyển lên tầng bốn vào tháng tới.'),
|
||||||
|
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Woman]: Did you hear our department is moving to the fourth floor next month?\n[Man]: I did. I''m not looking forward to packing everything up.\n[Woman]: The new space is much bigger though. We''ll each get our own office.\n[Man]: That''s a silver lining. When exactly are we moving?\n\nQuestion: What is one advantage of the new space?',
|
||||||
|
'["A. It is closer to the parking garage", "B. It has a better cafeteria nearby", "C. Each person will have their own office", "D. It is more accessible by public transport"]',
|
||||||
|
'C',
|
||||||
|
'Điểm tích cực của không gian mới là mỗi người sẽ có văn phòng riêng.'),
|
||||||
|
|
||||||
|
-- Conversation I: Customer complaint
|
||||||
|
(3, 'conversation',
|
||||||
|
E'[Customer]: I ordered a laptop two weeks ago and still haven''t received it.\n[Agent]: I''m very sorry about that. Can I have your order number, please?\n[Customer]: It''s GX-20458.\n[Agent]: Thank you. I can see it''s been held at our distribution center. I''ll arrange expedited shipping today at no extra charge.\n\nQuestion: What is the customer''s problem?',
|
||||||
|
'["A. The laptop she received was damaged", "B. She was charged the wrong amount", "C. Her laptop order has not arrived yet", "D. The wrong item was delivered to her"]',
|
||||||
|
'C',
|
||||||
|
'Khách hàng đặt mua laptop hai tuần trước nhưng vẫn chưa nhận được hàng.'),
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- PART 4 — Talk / Monologue (20 questions — ~4-5 talks × 4-5 Qs)
|
||||||
|
-- ============================================================
|
||||||
|
-- Talk A: Voicemail about a delayed shipment
|
||||||
|
(4, 'talk',
|
||||||
|
E'Hello, this is Janet Morris from Westfield Supplies. I''m calling to inform you that your order number 7734 has been delayed due to a shortage of materials at our warehouse. The new estimated delivery date is October 18th, which is about five business days later than originally scheduled. We sincerely apologize for any inconvenience this may cause. If you would like to cancel or modify your order, please call us at 555-0192 before October 12th. Otherwise, your order will ship automatically on the revised date.\n\nQuestion: Who is leaving this message?',
|
||||||
|
'["A. A delivery driver", "B. A customer service representative", "C. A warehouse manager", "D. A logistics coordinator"]',
|
||||||
|
'B',
|
||||||
|
'Janet Morris từ Westfield Supplies đang gọi điện thông báo — cô ấy là đại diện dịch vụ khách hàng.'),
|
||||||
|
|
||||||
|
(4, 'talk',
|
||||||
|
E'Hello, this is Janet Morris from Westfield Supplies. I''m calling to inform you that your order number 7734 has been delayed due to a shortage of materials at our warehouse. The new estimated delivery date is October 18th, which is about five business days later than originally scheduled. We sincerely apologize for any inconvenience this may cause. If you would like to cancel or modify your order, please call us at 555-0192 before October 12th. Otherwise, your order will ship automatically on the revised date.\n\nQuestion: Why has the delivery been delayed?',
|
||||||
|
'["A. A transportation strike", "B. A shortage of materials", "C. Incorrect shipping address", "D. High order volume"]',
|
||||||
|
'B',
|
||||||
|
'Lý do trễ hàng là thiếu hụt nguyên liệu tại kho — "shortage of materials at our warehouse."'),
|
||||||
|
|
||||||
|
(4, 'talk',
|
||||||
|
E'Hello, this is Janet Morris from Westfield Supplies. I''m calling to inform you that your order number 7734 has been delayed due to a shortage of materials at our warehouse. The new estimated delivery date is October 18th, which is about five business days later than originally scheduled. We sincerely apologize for any inconvenience this may cause. If you would like to cancel or modify your order, please call us at 555-0192 before October 12th. Otherwise, your order will ship automatically on the revised date.\n\nQuestion: By when must the listener call if they want to cancel or modify the order?',
|
||||||
|
'["A. October 14th", "B. October 18th", "C. October 12th", "D. October 10th"]',
|
||||||
|
'C',
|
||||||
|
'Thông báo nói "please call us before October 12th" để hủy hoặc thay đổi đơn hàng.'),
|
||||||
|
|
||||||
|
-- Talk B: Office announcement about new policy
|
||||||
|
(4, 'talk',
|
||||||
|
E'Good morning, everyone. I have a brief announcement regarding our new flexible work policy. Starting next Monday, all full-time staff will be allowed to work from home up to two days per week. However, you must be in the office on Tuesdays and Wednesdays for scheduled team meetings. To participate, please complete the remote work agreement form available on the HR portal and submit it by this Friday. If you have questions, contact HR directly. We believe this change will improve work-life balance and overall productivity.\n\nQuestion: What is the announcement about?',
|
||||||
|
'["A. A new remote work policy", "B. A change in office hours", "C. An upcoming team-building event", "D. New meeting room booking procedures"]',
|
||||||
|
'A',
|
||||||
|
'Thông báo giới thiệu chính sách làm việc linh hoạt mới cho phép làm việc từ xa.'),
|
||||||
|
|
||||||
|
(4, 'talk',
|
||||||
|
E'Good morning, everyone. I have a brief announcement regarding our new flexible work policy. Starting next Monday, all full-time staff will be allowed to work from home up to two days per week. However, you must be in the office on Tuesdays and Wednesdays for scheduled team meetings. To participate, please complete the remote work agreement form available on the HR portal and submit it by this Friday. If you have questions, contact HR directly. We believe this change will improve work-life balance and overall productivity.\n\nQuestion: On which days must employees be in the office?',
|
||||||
|
'["A. Mondays and Fridays", "B. Tuesdays and Wednesdays", "C. Mondays and Thursdays", "D. Wednesdays and Fridays"]',
|
||||||
|
'B',
|
||||||
|
'Nhân viên bắt buộc phải có mặt ở văn phòng vào thứ Ba và thứ Tư để tham dự họp nhóm.'),
|
||||||
|
|
||||||
|
(4, 'talk',
|
||||||
|
E'Good morning, everyone. I have a brief announcement regarding our new flexible work policy. Starting next Monday, all full-time staff will be allowed to work from home up to two days per week. However, you must be in the office on Tuesdays and Wednesdays for scheduled team meetings. To participate, please complete the remote work agreement form available on the HR portal and submit it by this Friday. If you have questions, contact HR directly. We believe this change will improve work-life balance and overall productivity.\n\nQuestion: What must employees do to participate in the new policy?',
|
||||||
|
'["A. Request approval from their manager", "B. Submit a remote work agreement form", "C. Attend an orientation session", "D. Sign a new employment contract"]',
|
||||||
|
'B',
|
||||||
|
'Nhân viên phải điền và nộp mẫu thỏa thuận làm việc từ xa qua cổng HR trước thứ Sáu.'),
|
||||||
|
|
||||||
|
-- Talk C: Museum audio guide
|
||||||
|
(4, 'talk',
|
||||||
|
E'Welcome to the National Business History Museum. Today''s self-guided tour will take approximately 90 minutes. You are currently in the main lobby. To your left is the Industrial Revolution gallery, which traces the origins of modern commerce. Straight ahead is our temporary exhibit, "The Digital Age," which runs through December 31st. Restrooms are located on each floor near the stairwells. Photography is permitted throughout the museum, but please turn off your flash. Guided tours depart from this lobby every hour on the hour. The museum gift shop is open until 5 p.m.\n\nQuestion: What type of recording is this?',
|
||||||
|
'["A. A company training video", "B. A museum audio tour guide", "C. A tourist information hotline", "D. A welcome speech at a conference"]',
|
||||||
|
'B',
|
||||||
|
'Đây là bản hướng dẫn tham quan bảo tàng tự hành — "self-guided tour."'),
|
||||||
|
|
||||||
|
(4, 'talk',
|
||||||
|
E'Welcome to the National Business History Museum. Today''s self-guided tour will take approximately 90 minutes. You are currently in the main lobby. To your left is the Industrial Revolution gallery, which traces the origins of modern commerce. Straight ahead is our temporary exhibit, "The Digital Age," which runs through December 31st. Restrooms are located on each floor near the stairwells. Photography is permitted throughout the museum, but please turn off your flash. Guided tours depart from this lobby every hour on the hour. The museum gift shop is open until 5 p.m.\n\nQuestion: What restriction is mentioned regarding photography?',
|
||||||
|
'["A. Photography is not allowed anywhere.", "B. Only professional cameras may be used.", "C. Flash must be turned off.", "D. Photos can only be taken in designated areas."]',
|
||||||
|
'C',
|
||||||
|
'Được phép chụp ảnh nhưng phải tắt đèn flash — "please turn off your flash."'),
|
||||||
|
|
||||||
|
(4, 'talk',
|
||||||
|
E'Welcome to the National Business History Museum. Today''s self-guided tour will take approximately 90 minutes. You are currently in the main lobby. To your left is the Industrial Revolution gallery, which traces the origins of modern commerce. Straight ahead is our temporary exhibit, "The Digital Age," which runs through December 31st. Restrooms are located on each floor near the stairwells. Photography is permitted throughout the museum, but please turn off your flash. Guided tours depart from this lobby every hour on the hour. The museum gift shop is open until 5 p.m.\n\nQuestion: How often do guided tours depart?',
|
||||||
|
'["A. Every 30 minutes", "B. Every 90 minutes", "C. Twice a day", "D. Every hour"]',
|
||||||
|
'D',
|
||||||
|
'Guided tours khởi hành từ sảnh chính mỗi giờ một lần — "every hour on the hour."'),
|
||||||
|
|
||||||
|
-- Talk D: Radio traffic report
|
||||||
|
(4, 'talk',
|
||||||
|
E'Good afternoon, drivers. This is your afternoon traffic update for the downtown area. There is currently a major slowdown on Highway 9 northbound due to a three-vehicle accident near Exit 14. Expect delays of up to 45 minutes. As an alternative, take Route 7 east to avoid the backup. Traffic on the Central Bridge is moving smoothly at this time. Road work on Fifth Avenue will begin tonight at 11 p.m. and is expected to be completed by 6 a.m. tomorrow. Stay tuned for updates every 15 minutes.\n\nQuestion: What is causing the slowdown on Highway 9?',
|
||||||
|
'["A. Construction work", "B. Heavy rain", "C. A three-vehicle accident", "D. A special event downtown"]',
|
||||||
|
'C',
|
||||||
|
'Tắc đường trên Highway 9 do tai nạn liên quan đến ba xe — "a three-vehicle accident."'),
|
||||||
|
|
||||||
|
(4, 'talk',
|
||||||
|
E'Good afternoon, drivers. This is your afternoon traffic update for the downtown area. There is currently a major slowdown on Highway 9 northbound due to a three-vehicle accident near Exit 14. Expect delays of up to 45 minutes. As an alternative, take Route 7 east to avoid the backup. Traffic on the Central Bridge is moving smoothly at this time. Road work on Fifth Avenue will begin tonight at 11 p.m. and is expected to be completed by 6 a.m. tomorrow. Stay tuned for updates every 15 minutes.\n\nQuestion: What alternative route is suggested?',
|
||||||
|
'["A. Highway 9 southbound", "B. Central Bridge", "C. Fifth Avenue", "D. Route 7 east"]',
|
||||||
|
'D',
|
||||||
|
'Đường thay thế được gợi ý là Route 7 hướng đông để tránh tắc đường.'),
|
||||||
|
|
||||||
|
(4, 'talk',
|
||||||
|
E'Good afternoon, drivers. This is your afternoon traffic update for the downtown area. There is currently a major slowdown on Highway 9 northbound due to a three-vehicle accident near Exit 14. Expect delays of up to 45 minutes. As an alternative, take Route 7 east to avoid the backup. Traffic on the Central Bridge is moving smoothly at this time. Road work on Fifth Avenue will begin tonight at 11 p.m. and is expected to be completed by 6 a.m. tomorrow. Stay tuned for updates every 15 minutes.\n\nQuestion: When will road work on Fifth Avenue begin?',
|
||||||
|
'["A. This afternoon at 3 p.m.", "B. Tonight at 11 p.m.", "C. Tomorrow morning at 6 a.m.", "D. This evening at 9 p.m."]',
|
||||||
|
'B',
|
||||||
|
'Công việc thi công trên Fifth Avenue bắt đầu tối nay lúc 11 giờ đêm.'),
|
||||||
|
|
||||||
|
-- Talk E: Company earnings call summary
|
||||||
|
(4, 'talk',
|
||||||
|
E'Thank you all for joining our third-quarter earnings call. I''m pleased to report that total revenue for Q3 reached 48 million dollars, a 12 percent increase compared to the same period last year. Our strongest growth came from the Asia-Pacific region, which grew by 22 percent. Operating costs were kept at 15 percent below forecast, thanks to the efficiency measures introduced in Q2. Looking ahead, we expect continued growth in Q4, driven by new product launches and expanded partnerships. We will provide a more detailed breakdown in the investor report, available on our website by end of next week.\n\nQuestion: What was the total revenue for Q3?',
|
||||||
|
'["A. 38 million dollars", "B. 52 million dollars", "C. 48 million dollars", "D. 44 million dollars"]',
|
||||||
|
'C',
|
||||||
|
'Doanh thu Q3 đạt 48 triệu đô — "total revenue for Q3 reached 48 million dollars."'),
|
||||||
|
|
||||||
|
(4, 'talk',
|
||||||
|
E'Thank you all for joining our third-quarter earnings call. I''m pleased to report that total revenue for Q3 reached 48 million dollars, a 12 percent increase compared to the same period last year. Our strongest growth came from the Asia-Pacific region, which grew by 22 percent. Operating costs were kept at 15 percent below forecast, thanks to the efficiency measures introduced in Q2. Looking ahead, we expect continued growth in Q4, driven by new product launches and expanded partnerships. We will provide a more detailed breakdown in the investor report, available on our website by end of next week.\n\nQuestion: Which region showed the strongest growth?',
|
||||||
|
'["A. Europe", "B. North America", "C. Asia-Pacific", "D. Latin America"]',
|
||||||
|
'C',
|
||||||
|
'Khu vực Asia-Pacific tăng trưởng 22%, mạnh nhất trong tất cả các khu vực.'),
|
||||||
|
|
||||||
|
(4, 'talk',
|
||||||
|
E'Thank you all for joining our third-quarter earnings call. I''m pleased to report that total revenue for Q3 reached 48 million dollars, a 12 percent increase compared to the same period last year. Our strongest growth came from the Asia-Pacific region, which grew by 22 percent. Operating costs were kept at 15 percent below forecast, thanks to the efficiency measures introduced in Q2. Looking ahead, we expect continued growth in Q4, driven by new product launches and expanded partnerships. We will provide a more detailed breakdown in the investor report, available on our website by end of next week.\n\nQuestion: When will the detailed investor report be available?',
|
||||||
|
'["A. By the end of this week", "B. At the beginning of next month", "C. By end of next week", "D. After the Q4 results are released"]',
|
||||||
|
'C',
|
||||||
|
'Báo cáo chi tiết sẽ có trên website vào cuối tuần tới — "by end of next week."'),
|
||||||
|
|
||||||
|
-- Talk F: Staff recognition announcement
|
||||||
|
(4, 'talk',
|
||||||
|
E'Good afternoon, team. I''d like to take a moment to recognize Sandra Choi from our customer service department. Sandra has handled over 300 customer inquiries this quarter with an average satisfaction rating of 4.9 out of 5. Her dedication and positive attitude have set an example for the entire team. In recognition of her outstanding performance, Sandra will receive a performance bonus and a certificate of excellence at this month''s company gathering. Please join me in congratulating Sandra on a truly exceptional quarter.\n\nQuestion: What is the main purpose of this announcement?',
|
||||||
|
'["A. To introduce a new team member", "B. To recognize an employee''s outstanding performance", "C. To announce a company-wide policy change", "D. To remind staff about the upcoming gathering"]',
|
||||||
|
'B',
|
||||||
|
'Thông báo nhằm ghi nhận thành tích xuất sắc của Sandra Choi trong quý vừa qua.'),
|
||||||
|
|
||||||
|
(4, 'talk',
|
||||||
|
E'Good afternoon, team. I''d like to take a moment to recognize Sandra Choi from our customer service department. Sandra has handled over 300 customer inquiries this quarter with an average satisfaction rating of 4.9 out of 5. Her dedication and positive attitude have set an example for the entire team. In recognition of her outstanding performance, Sandra will receive a performance bonus and a certificate of excellence at this month''s company gathering. Please join me in congratulating Sandra on a truly exceptional quarter.\n\nQuestion: What will Sandra receive at the company gathering?',
|
||||||
|
'["A. A promotion to team leader", "B. A paid vacation package", "C. A performance bonus and a certificate of excellence", "D. An award voted on by her peers"]',
|
||||||
|
'C',
|
||||||
|
'Sandra sẽ nhận thưởng hiệu suất và chứng chỉ xuất sắc tại buổi họp mặt công ty.'),
|
||||||
|
|
||||||
|
(4, 'talk',
|
||||||
|
E'Good afternoon, team. I''d like to take a moment to recognize Sandra Choi from our customer service department. Sandra has handled over 300 customer inquiries this quarter with an average satisfaction rating of 4.9 out of 5. Her dedication and positive attitude have set an example for the entire team. In recognition of her outstanding performance, Sandra will receive a performance bonus and a certificate of excellence at this month''s company gathering. Please join me in congratulating Sandra on a truly exceptional quarter.\n\nQuestion: What was Sandra''s average customer satisfaction rating?',
|
||||||
|
'["A. 4.5 out of 5", "B. 4.9 out of 5", "C. 4.7 out of 5", "D. 5.0 out of 5"]',
|
||||||
|
'B',
|
||||||
|
'Điểm hài lòng trung bình của Sandra là 4.9/5 — được nêu rõ trong bài phát biểu.'),
|
||||||
|
|
||||||
|
-- Talk G: Store closing announcement
|
||||||
|
(4, 'talk',
|
||||||
|
E'Attention shoppers: Riverside Mall will be closing in 15 minutes. Please bring your final selections to the nearest checkout counter. All store entrances will be locked promptly at 9 p.m. We ask that all customers proceed toward the exits in an orderly fashion. Our parking garage on Level B will remain open until 9:30 p.m. Thank you for shopping with us today, and we look forward to welcoming you back tomorrow when we open at 10 a.m.\n\nQuestion: What is the purpose of this announcement?',
|
||||||
|
'["A. To inform shoppers of a special sale ending soon", "B. To notify customers that the mall is closing soon", "C. To remind customers of the parking garage hours", "D. To welcome shoppers to the mall"]',
|
||||||
|
'B',
|
||||||
|
'Thông báo cho biết trung tâm mua sắm sắp đóng cửa trong 15 phút.'),
|
||||||
|
|
||||||
|
(4, 'talk',
|
||||||
|
E'Attention shoppers: Riverside Mall will be closing in 15 minutes. Please bring your final selections to the nearest checkout counter. All store entrances will be locked promptly at 9 p.m. We ask that all customers proceed toward the exits in an orderly fashion. Our parking garage on Level B will remain open until 9:30 p.m. Thank you for shopping with us today, and we look forward to welcoming you back tomorrow when we open at 10 a.m.\n\nQuestion: Until what time will the parking garage remain open?',
|
||||||
|
'["A. 9:00 p.m.", "B. 9:15 p.m.", "C. 9:30 p.m.", "D. 10:00 p.m."]',
|
||||||
|
'C',
|
||||||
|
'Nhà đỗ xe tầng B sẽ mở cửa đến 9:30 tối — "remain open until 9:30 p.m."'),
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- PART 5 — Incomplete Sentence / Grammar (20 questions)
|
||||||
|
-- ============================================================
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'The marketing team ______ a new advertising strategy for the upcoming product launch.',
|
||||||
|
'["A. is developing", "B. are develop", "C. have develop", "D. is develop"]',
|
||||||
|
'A',
|
||||||
|
'"The marketing team" là chủ ngữ số ít, cần động từ số ít. "is developing" (present continuous) đúng ngữ pháp và ngữ nghĩa.'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'Please submit your expense reports ______ the end of the month.',
|
||||||
|
'["A. until", "B. during", "C. by", "D. since"]',
|
||||||
|
'C',
|
||||||
|
'"by" = trước hoặc đúng vào thời điểm đó. "Submit by the end of the month" nghĩa là nộp trước cuối tháng.'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'The contract will be ______ by both parties before the deadline.',
|
||||||
|
'["A. sign", "B. signing", "C. signed", "D. to sign"]',
|
||||||
|
'C',
|
||||||
|
'Cấu trúc bị động "will be + past participle" — "signed" là dạng đúng.'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'Neither the manager nor the employees ______ informed about the policy change.',
|
||||||
|
'["A. was", "B. were", "C. has been", "D. is"]',
|
||||||
|
'B',
|
||||||
|
'Với "neither...nor", động từ chia theo danh từ gần nhất (employees — số nhiều), nên dùng "were."'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'The new software is ______ compatible with older operating systems.',
|
||||||
|
'["A. full", "B. fulling", "C. fullness", "D. fully"]',
|
||||||
|
'D',
|
||||||
|
'Trạng từ "fully" bổ nghĩa cho tính từ "compatible" — không dùng tính từ hay danh từ ở đây.'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'Due to the ______ demand, the company has decided to increase production.',
|
||||||
|
'["A. risen", "B. rising", "C. raise", "D. rises"]',
|
||||||
|
'B',
|
||||||
|
'"rising demand" = nhu cầu đang tăng (tính từ dạng -ing bổ nghĩa cho danh từ). "risen" không đứng trước danh từ trực tiếp.'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'The board of directors will meet ______ Tuesday to discuss the merger.',
|
||||||
|
'["A. in", "B. at", "C. on", "D. by"]',
|
||||||
|
'C',
|
||||||
|
'Giới từ chỉ ngày trong tuần dùng "on" — "on Tuesday."'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'Employees are encouraged to ______ any concerns directly to their supervisors.',
|
||||||
|
'["A. raise", "B. rise", "C. raised", "D. arisen"]',
|
||||||
|
'A',
|
||||||
|
'"raise concerns" = nêu lên mối lo ngại. "Rise" là nội động từ, không dùng với tân ngữ trực tiếp.'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'The presentation was so ______ that the entire audience stayed for the Q&A session.',
|
||||||
|
'["A. engage", "B. engaged", "C. engaging", "D. engagement"]',
|
||||||
|
'C',
|
||||||
|
'"engaging" (tính từ chủ động) mô tả bài thuyết trình khiến người khác cảm thấy thú vị.'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'Ms. Kim has been working for this company ______ she graduated from university.',
|
||||||
|
'["A. when", "B. since", "C. for", "D. while"]',
|
||||||
|
'B',
|
||||||
|
'"since" dùng với thời điểm cụ thể trong quá khứ để diễn đạt khoảng thời gian kéo dài đến hiện tại.'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'The ______ of the new office building is scheduled for completion by March.',
|
||||||
|
'["A. construct", "B. constructing", "C. constructed", "D. construction"]',
|
||||||
|
'D',
|
||||||
|
'Danh từ "construction" (sự xây dựng) đúng vai trò chủ ngữ trong câu.'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'Customers who purchased products before January 1st are ______ for a full refund.',
|
||||||
|
'["A. eligible", "B. eligibility", "C. eligibly", "D. elect"]',
|
||||||
|
'A',
|
||||||
|
'"eligible for" = đủ điều kiện nhận — tính từ "eligible" dùng đúng sau động từ "to be."'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'The sales figures for Q2 were ______ than those recorded in Q1.',
|
||||||
|
'["A. more high", "B. higher", "C. highest", "D. highly"]',
|
||||||
|
'B',
|
||||||
|
'So sánh hơn của "high" là "higher" — dùng khi so sánh hai đối tượng.'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'All meeting ______ must be booked through the online reservation system.',
|
||||||
|
'["A. rooms", "B. room", "C. room''s", "D. roomful"]',
|
||||||
|
'A',
|
||||||
|
'"meeting rooms" — danh từ ghép số nhiều dùng khi nói chung về các phòng họp.'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'Please ______ that all forms are completed accurately before submission.',
|
||||||
|
'["A. ensure", "B. assure", "C. insure", "D. reassure"]',
|
||||||
|
'A',
|
||||||
|
'"ensure that" = đảm bảo rằng điều gì đó xảy ra — đây là cách dùng chuẩn trong văn viết chính thức.'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'The company''s annual ______ will be held at the Grand Hyatt Hotel this year.',
|
||||||
|
'["A. celebrate", "B. celebrating", "C. celebration", "D. celebrated"]',
|
||||||
|
'C',
|
||||||
|
'"annual celebration" = lễ kỷ niệm thường niên — danh từ "celebration" đúng chức năng chủ ngữ/tân ngữ.'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'The new policy applies to ______ employees, regardless of their department.',
|
||||||
|
'["A. all", "B. every", "C. each of", "D. whole"]',
|
||||||
|
'A',
|
||||||
|
'"all employees" = tất cả nhân viên. "Every" và "each" dùng với danh từ số ít. "Whole" không dùng trước danh từ đếm được số nhiều trực tiếp.'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'The IT department is ______ for maintaining all computer systems in the building.',
|
||||||
|
'["A. responsible", "B. responsibility", "C. respond", "D. responsibly"]',
|
||||||
|
'A',
|
||||||
|
'"responsible for" = chịu trách nhiệm về — tính từ "responsible" kết hợp với giới từ "for."'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'The project ______ on time despite the unexpected delays during the final stage.',
|
||||||
|
'["A. delivered", "B. was delivered", "C. had deliver", "D. is deliver"]',
|
||||||
|
'B',
|
||||||
|
'Câu bị động — dự án được bàn giao đúng hạn. "was delivered" = thì quá khứ đơn bị động, đúng ở đây.'),
|
||||||
|
|
||||||
|
(5, 'incomplete_sentence',
|
||||||
|
'We need to ______ the budget proposal before the board meeting on Friday.',
|
||||||
|
'["A. finalize", "B. final", "C. finally", "D. finalization"]',
|
||||||
|
'A',
|
||||||
|
'"finalize" = hoàn thiện (động từ) — đứng sau "to" trong cấu trúc "need to + V."'),
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- PART 6 — Text Completion (20 questions — 5 passages × 4 Qs each)
|
||||||
|
-- ============================================================
|
||||||
|
-- Passage A: Internal memo about a new expense policy
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'MEMORANDUM\nTo: All Staff\nFrom: Finance Department\nRe: Updated Expense Reimbursement Policy\n\nEffective November 1st, the company will implement a revised expense reimbursement policy. All employees must submit expense claims ______ [Q1] 30 days of incurring the expense. Claims submitted after this period will not be processed.\n\nQ1: What word fits in the blank?',
|
||||||
|
'["A. within", "B. along", "C. beyond", "D. despite"]',
|
||||||
|
'A',
|
||||||
|
'"within 30 days" = trong vòng 30 ngày — giới từ "within" chỉ khoảng thời gian giới hạn.'),
|
||||||
|
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'MEMORANDUM\nTo: All Staff\nFrom: Finance Department\nRe: Updated Expense Reimbursement Policy\n\nAll receipts must be attached to the claim form. Expenses ______ [Q2] a receipt will be automatically rejected by the system.\n\nQ2: What word fits in the blank?',
|
||||||
|
'["A. including", "B. lacking", "C. requiring", "D. carrying"]',
|
||||||
|
'B',
|
||||||
|
'"Expenses lacking a receipt" = chi phí không có hóa đơn kèm theo sẽ bị hệ thống từ chối tự động.'),
|
||||||
|
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'MEMORANDUM\nTo: All Staff\nFrom: Finance Department\nRe: Updated Expense Reimbursement Policy\n\nThe daily meal allowance has been ______ [Q3] from $40 to $50 for domestic travel and from $60 to $75 for international travel.\n\nQ3: What word fits in the blank?',
|
||||||
|
'["A. reduced", "B. maintained", "C. increased", "D. calculated"]',
|
||||||
|
'C',
|
||||||
|
'Mức trợ cấp tăng từ $40 lên $50 và từ $60 lên $75 — "increased" là từ phù hợp.'),
|
||||||
|
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'MEMORANDUM\nTo: All Staff\nFrom: Finance Department\nRe: Updated Expense Reimbursement Policy\n\nFor further ______ [Q4] on the new policy, please refer to the HR handbook or contact your line manager.\n\nQ4: What word fits in the blank?',
|
||||||
|
'["A. information", "B. inform", "C. informing", "D. informative"]',
|
||||||
|
'A',
|
||||||
|
'"further information" = thông tin thêm — danh từ "information" đúng chức năng tân ngữ của giới từ "for."'),
|
||||||
|
|
||||||
|
-- Passage B: Letter to a job applicant
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'Dear Ms. Nguyen,\n\nThank you for ______ [Q1] your application for the position of Marketing Coordinator at Global Tech Solutions.\n\nQ1: What word fits in the blank?',
|
||||||
|
'["A. submit", "B. submitted", "C. submitting", "D. to submit"]',
|
||||||
|
'C',
|
||||||
|
'"Thank you for + V-ing" là cấu trúc chuẩn — "submitting" sau giới từ "for."'),
|
||||||
|
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'Dear Ms. Nguyen,\n\nWe have carefully reviewed your qualifications and are pleased to invite you for an interview. The interview will be ______ [Q2] at our headquarters on Thursday, October 20th, at 2:00 p.m.\n\nQ2: What word fits in the blank?',
|
||||||
|
'["A. held", "B. hold", "C. holding", "D. holds"]',
|
||||||
|
'A',
|
||||||
|
'Cấu trúc bị động "will be held" = sẽ được tổ chức — dạng past participle "held" đúng.'),
|
||||||
|
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'Dear Ms. Nguyen,\n\nPlease confirm your ______ [Q3] by replying to this email no later than October 17th. If you have any questions, feel free to contact our HR team.\n\nQ3: What word fits in the blank?',
|
||||||
|
'["A. attend", "B. attending", "C. attendance", "D. attended"]',
|
||||||
|
'C',
|
||||||
|
'"Confirm your attendance" = xác nhận sự tham dự — danh từ "attendance" đúng sau tính từ sở hữu "your."'),
|
||||||
|
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'Dear Ms. Nguyen,\n\nWe look forward to meeting you and learning more about your ______ [Q4] and experience.\n\nQ4: What word fits in the blank?',
|
||||||
|
'["A. qualify", "B. qualifies", "C. qualified", "D. qualifications"]',
|
||||||
|
'D',
|
||||||
|
'"qualifications and experience" = bằng cấp và kinh nghiệm — danh từ số nhiều "qualifications" đúng ngữ pháp và ngữ nghĩa.'),
|
||||||
|
|
||||||
|
-- Passage C: Newsletter announcement
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'COMPANY NEWSLETTER — OCTOBER EDITION\n\nWe are excited to announce that our company has recently ______ [Q1] a new office in Ho Chi Minh City.\n\nQ1: What word fits in the blank?',
|
||||||
|
'["A. open", "B. opened", "C. opening", "D. opens"]',
|
||||||
|
'B',
|
||||||
|
'"has recently opened" = hiện tại hoàn thành — đã khai trương gần đây.'),
|
||||||
|
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'COMPANY NEWSLETTER — OCTOBER EDITION\n\nThe new office will serve as our regional hub for Southeast Asia, ______ [Q2] our growing presence in the market.\n\nQ2: What word fits in the blank?',
|
||||||
|
'["A. strengthen", "B. strengthening", "C. to strengthen", "D. strengthened"]',
|
||||||
|
'B',
|
||||||
|
'"strengthening our presence" = phân từ hiện tại dùng bổ nghĩa cho mệnh đề trước, diễn đạt mục đích/kết quả.'),
|
||||||
|
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'COMPANY NEWSLETTER — OCTOBER EDITION\n\nThe office is conveniently ______ [Q3] in the central business district, with easy access to public transportation.\n\nQ3: What word fits in the blank?',
|
||||||
|
'["A. locating", "B. location", "C. located", "D. locate"]',
|
||||||
|
'C',
|
||||||
|
'"is conveniently located" = được đặt ở vị trí thuận tiện — bị động "located" sau "is."'),
|
||||||
|
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'COMPANY NEWSLETTER — OCTOBER EDITION\n\nWe invite all interested employees to apply for ______ [Q4] positions at the new location through the internal job portal.\n\nQ4: What word fits in the blank?',
|
||||||
|
'["A. available", "B. availability", "C. avail", "D. availing"]',
|
||||||
|
'A',
|
||||||
|
'"available positions" = các vị trí còn tuyển — tính từ "available" bổ nghĩa trực tiếp cho danh từ "positions."'),
|
||||||
|
|
||||||
|
-- Passage D: Customer service notice
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'IMPORTANT NOTICE TO OUR CUSTOMERS\n\nDue to scheduled system maintenance, our online services will be ______ [Q1] unavailable from 2:00 a.m. to 6:00 a.m. on Saturday, November 4th.\n\nQ1: What word fits in the blank?',
|
||||||
|
'["A. temporal", "B. temporarily", "C. temporary", "D. temporize"]',
|
||||||
|
'B',
|
||||||
|
'Trạng từ "temporarily" bổ nghĩa cho tính từ "unavailable" — tạm thời không có sẵn.'),
|
||||||
|
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'IMPORTANT NOTICE TO OUR CUSTOMERS\n\nWe apologize for any ______ [Q2] this may cause and appreciate your patience.\n\nQ2: What word fits in the blank?',
|
||||||
|
'["A. inconvenience", "B. inconvenient", "C. inconveniently", "D. inconvenienced"]',
|
||||||
|
'A',
|
||||||
|
'"any inconvenience" = bất kỳ sự bất tiện nào — danh từ "inconvenience" đúng sau tính từ "any."'),
|
||||||
|
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'IMPORTANT NOTICE TO OUR CUSTOMERS\n\nIf you require ______ [Q3] assistance before the maintenance window, please contact our support line at 1-800-555-0100.\n\nQ3: What word fits in the blank?',
|
||||||
|
'["A. urgent", "B. urgency", "C. urgently", "D. urge"]',
|
||||||
|
'A',
|
||||||
|
'"urgent assistance" = hỗ trợ khẩn cấp — tính từ "urgent" bổ nghĩa trực tiếp cho danh từ "assistance."'),
|
||||||
|
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'IMPORTANT NOTICE TO OUR CUSTOMERS\n\nOnce maintenance is complete, all services will be ______ [Q4] as normal. Thank you for your continued support.\n\nQ4: What word fits in the blank?',
|
||||||
|
'["A. resume", "B. resumed", "C. resuming", "D. resumption"]',
|
||||||
|
'B',
|
||||||
|
'"will be resumed" = sẽ được khôi phục — bị động tương lai với past participle "resumed."'),
|
||||||
|
|
||||||
|
-- Passage E: Training program invitation
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'INVITATION: LEADERSHIP DEVELOPMENT PROGRAM\n\nWe are pleased to invite eligible staff to ______ [Q1] in our annual Leadership Development Program.\n\nQ1: What word fits in the blank?',
|
||||||
|
'["A. participate", "B. participation", "C. participant", "D. participating"]',
|
||||||
|
'A',
|
||||||
|
'"invited to participate" = mời tham gia — động từ nguyên mẫu "participate" sau "to."'),
|
||||||
|
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'INVITATION: LEADERSHIP DEVELOPMENT PROGRAM\n\nThis program is designed for employees who have demonstrated ______ [Q2] leadership potential and are ready to take on greater responsibilities.\n\nQ2: What word fits in the blank?',
|
||||||
|
'["A. exception", "B. exceptional", "C. exceptionally", "D. except"]',
|
||||||
|
'B',
|
||||||
|
'"exceptional leadership potential" = tiềm năng lãnh đạo xuất sắc — tính từ "exceptional" bổ nghĩa cho "potential."'),
|
||||||
|
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'INVITATION: LEADERSHIP DEVELOPMENT PROGRAM\n\nThe program ______ [Q3] weekly workshops, one-on-one coaching sessions, and a final capstone project.\n\nQ3: What word fits in the blank?',
|
||||||
|
'["A. consists", "B. includes", "C. contains with", "D. composes"]',
|
||||||
|
'B',
|
||||||
|
'"includes workshops, coaching, and a project" — "include" + tân ngữ trực tiếp là cách dùng chuẩn trong tiếng Anh.'),
|
||||||
|
|
||||||
|
(6, 'text_completion',
|
||||||
|
E'INVITATION: LEADERSHIP DEVELOPMENT PROGRAM\n\nTo apply, please submit a completed application form along with a ______ [Q4] from your direct supervisor by October 25th.\n\nQ4: What word fits in the blank?',
|
||||||
|
'["A. recommend", "B. recommending", "C. recommendation", "D. recommended"]',
|
||||||
|
'C',
|
||||||
|
'"a recommendation from your supervisor" = thư giới thiệu từ quản lý trực tiếp — danh từ "recommendation" đúng.'),
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- PART 7 — Reading Comprehension (20 questions — 5 passages × 4 Qs each)
|
||||||
|
-- ============================================================
|
||||||
|
-- Passage A: Email about a conference
|
||||||
|
(7, 'reading',
|
||||||
|
E'From: events@globaltech.com\nTo: r.santos@globaltech.com\nSubject: Annual Technology Conference — Registration Reminder\n\nDear Mr. Santos,\n\nThis is a reminder that the registration deadline for the 12th Annual Global Technology Conference is this Friday, October 15th. The conference will be held on November 8–10 at the Riverside Convention Center in Seattle.\n\nEarly registrants (before October 10th) received a 20% discount on the registration fee. Standard registration is $350 per person. A limited number of group discounts (10 or more participants) are still available at $280 per person.\n\nThe keynote speakers include Dr. Angela Marsh, a leading AI researcher, and Mr. Keith Hayashi, CEO of FutureTech Inc. Workshop sessions on cybersecurity, cloud computing, and data analytics will run throughout the three days.\n\nPlease visit our website at www.globaltech.com/conference to complete your registration.\n\nQuestion: What is the main purpose of this email?',
|
||||||
|
'["A. To remind the recipient about a registration deadline", "B. To announce a new technology product", "C. To confirm a hotel reservation for the conference", "D. To introduce the keynote speakers of the conference"]',
|
||||||
|
'A',
|
||||||
|
'Email nhắc nhở về hạn đăng ký tham dự hội nghị — "reminder that the registration deadline is this Friday."'),
|
||||||
|
|
||||||
|
(7, 'reading',
|
||||||
|
E'From: events@globaltech.com\nTo: r.santos@globaltech.com\nSubject: Annual Technology Conference — Registration Reminder\n\nDear Mr. Santos,\n\nThis is a reminder that the registration deadline for the 12th Annual Global Technology Conference is this Friday, October 15th. The conference will be held on November 8–10 at the Riverside Convention Center in Seattle.\n\nEarly registrants (before October 10th) received a 20% discount on the registration fee. Standard registration is $350 per person. A limited number of group discounts (10 or more participants) are still available at $280 per person.\n\nThe keynote speakers include Dr. Angela Marsh, a leading AI researcher, and Mr. Keith Hayashi, CEO of FutureTech Inc. Workshop sessions on cybersecurity, cloud computing, and data analytics will run throughout the three days.\n\nPlease visit our website at www.globaltech.com/conference to complete your registration.\n\nQuestion: What is the standard registration fee per person?',
|
||||||
|
'["A. $280", "B. $315", "C. $350", "D. $420"]',
|
||||||
|
'C',
|
||||||
|
'Phí đăng ký tiêu chuẩn là $350 mỗi người — "Standard registration is $350 per person."'),
|
||||||
|
|
||||||
|
(7, 'reading',
|
||||||
|
E'From: events@globaltech.com\nTo: r.santos@globaltech.com\nSubject: Annual Technology Conference — Registration Reminder\n\nDear Mr. Santos,\n\nThis is a reminder that the registration deadline for the 12th Annual Global Technology Conference is this Friday, October 15th. The conference will be held on November 8–10 at the Riverside Convention Center in Seattle.\n\nEarly registrants (before October 10th) received a 20% discount on the registration fee. Standard registration is $350 per person. A limited number of group discounts (10 or more participants) are still available at $280 per person.\n\nThe keynote speakers include Dr. Angela Marsh, a leading AI researcher, and Mr. Keith Hayashi, CEO of FutureTech Inc. Workshop sessions on cybersecurity, cloud computing, and data analytics will run throughout the three days.\n\nPlease visit our website at www.globaltech.com/conference to complete your registration.\n\nQuestion: What field is Dr. Angela Marsh associated with?',
|
||||||
|
'["A. Cloud computing", "B. Data analytics", "C. Cybersecurity", "D. Artificial intelligence"]',
|
||||||
|
'D',
|
||||||
|
'Dr. Angela Marsh được giới thiệu là "a leading AI researcher" — nhà nghiên cứu trí tuệ nhân tạo hàng đầu.'),
|
||||||
|
|
||||||
|
(7, 'reading',
|
||||||
|
E'From: events@globaltech.com\nTo: r.santos@globaltech.com\nSubject: Annual Technology Conference — Registration Reminder\n\nDear Mr. Santos,\n\nThis is a reminder that the registration deadline for the 12th Annual Global Technology Conference is this Friday, October 15th. The conference will be held on November 8–10 at the Riverside Convention Center in Seattle.\n\nEarly registrants (before October 10th) received a 20% discount on the registration fee. Standard registration is $350 per person. A limited number of group discounts (10 or more participants) are still available at $280 per person.\n\nThe keynote speakers include Dr. Angela Marsh, a leading AI researcher, and Mr. Keith Hayashi, CEO of FutureTech Inc. Workshop sessions on cybersecurity, cloud computing, and data analytics will run throughout the three days.\n\nPlease visit our website at www.globaltech.com/conference to complete your registration.\n\nQuestion: What discount is available for groups of 10 or more?',
|
||||||
|
'["A. 10% off the standard price", "B. $280 per person", "C. Free registration for the group leader", "D. 30% off the early bird price"]',
|
||||||
|
'B',
|
||||||
|
'Đăng ký theo nhóm từ 10 người trở lên có giá $280 mỗi người — được nêu rõ trong email.'),
|
||||||
|
|
||||||
|
-- Passage B: Job advertisement
|
||||||
|
(7, 'reading',
|
||||||
|
E'JOB ADVERTISEMENT\n\nPosition: Senior Accountant\nCompany: Meridian Financial Group\nLocation: New York, NY\nType: Full-time\n\nMeridian Financial Group is seeking a qualified Senior Accountant to join our growing finance team. The successful candidate will be responsible for managing financial reports, overseeing accounts payable and receivable, and ensuring compliance with regulatory requirements.\n\nRequirements:\n— Bachelor''s degree in Accounting, Finance, or a related field\n— Minimum 5 years of accounting experience\n— CPA certification preferred\n— Proficiency in QuickBooks and Microsoft Excel\n— Strong analytical and communication skills\n\nWe offer a competitive salary, comprehensive health benefits, and opportunities for professional development. Interested candidates should send their CV and cover letter to hr@meridianfinancial.com by November 30th.\n\nQuestion: What position is Meridian Financial Group advertising?',
|
||||||
|
'["A. Senior Accountant", "B. Financial Analyst", "C. Accounts Manager", "D. Chief Financial Officer"]',
|
||||||
|
'A',
|
||||||
|
'Công ty đang tuyển dụng "Senior Accountant" — được nêu rõ ở đầu quảng cáo.'),
|
||||||
|
|
||||||
|
(7, 'reading',
|
||||||
|
E'JOB ADVERTISEMENT\n\nPosition: Senior Accountant\nCompany: Meridian Financial Group\nLocation: New York, NY\nType: Full-time\n\nMeridian Financial Group is seeking a qualified Senior Accountant to join our growing finance team. The successful candidate will be responsible for managing financial reports, overseeing accounts payable and receivable, and ensuring compliance with regulatory requirements.\n\nRequirements:\n— Bachelor''s degree in Accounting, Finance, or a related field\n— Minimum 5 years of accounting experience\n— CPA certification preferred\n— Proficiency in QuickBooks and Microsoft Excel\n— Strong analytical and communication skills\n\nWe offer a competitive salary, comprehensive health benefits, and opportunities for professional development. Interested candidates should send their CV and cover letter to hr@meridianfinancial.com by November 30th.\n\nQuestion: What is the minimum experience required for this position?',
|
||||||
|
'["A. 3 years", "B. 7 years", "C. 10 years", "D. 5 years"]',
|
||||||
|
'D',
|
||||||
|
'Yêu cầu tối thiểu là 5 năm kinh nghiệm kế toán — "Minimum 5 years of accounting experience."'),
|
||||||
|
|
||||||
|
(7, 'reading',
|
||||||
|
E'JOB ADVERTISEMENT\n\nPosition: Senior Accountant\nCompany: Meridian Financial Group\nLocation: New York, NY\nType: Full-time\n\nMeridian Financial Group is seeking a qualified Senior Accountant to join our growing finance team. The successful candidate will be responsible for managing financial reports, overseeing accounts payable and receivable, and ensuring compliance with regulatory requirements.\n\nRequirements:\n— Bachelor''s degree in Accounting, Finance, or a related field\n— Minimum 5 years of accounting experience\n— CPA certification preferred\n— Proficiency in QuickBooks and Microsoft Excel\n— Strong analytical and communication skills\n\nWe offer a competitive salary, comprehensive health benefits, and opportunities for professional development. Interested candidates should send their CV and cover letter to hr@meridianfinancial.com by November 30th.\n\nQuestion: What software proficiency is required?',
|
||||||
|
'["A. SAP and PowerPoint", "B. Salesforce and Tableau", "C. QuickBooks and Microsoft Excel", "D. Xero and Google Sheets"]',
|
||||||
|
'C',
|
||||||
|
'Ứng viên cần thành thạo "QuickBooks and Microsoft Excel" theo yêu cầu được liệt kê.'),
|
||||||
|
|
||||||
|
(7, 'reading',
|
||||||
|
E'JOB ADVERTISEMENT\n\nPosition: Senior Accountant\nCompany: Meridian Financial Group\nLocation: New York, NY\nType: Full-time\n\nMeridian Financial Group is seeking a qualified Senior Accountant to join our growing finance team. The successful candidate will be responsible for managing financial reports, overseeing accounts payable and receivable, and ensuring compliance with regulatory requirements.\n\nRequirements:\n— Bachelor''s degree in Accounting, Finance, or a related field\n— Minimum 5 years of accounting experience\n— CPA certification preferred\n— Proficiency in QuickBooks and Microsoft Excel\n— Strong analytical and communication skills\n\nWe offer a competitive salary, comprehensive health benefits, and opportunities for professional development. Interested candidates should send their CV and cover letter to hr@meridianfinancial.com by November 30th.\n\nQuestion: How should interested candidates apply?',
|
||||||
|
'["A. Visit the company website and fill out a form", "B. Call the HR department directly", "C. Send a CV and cover letter to the provided email", "D. Apply through a recruitment agency"]',
|
||||||
|
'C',
|
||||||
|
'Ứng viên gửi CV và thư xin việc đến hr@meridianfinancial.com trước ngày 30 tháng 11.'),
|
||||||
|
|
||||||
|
-- Passage C: Company policy memo
|
||||||
|
(7, 'reading',
|
||||||
|
E'INTERNAL MEMO\nTo: All Department Heads\nFrom: CEO Office\nSubject: Updated Mobile Device Policy\n\nEffective January 1st, the company will introduce a Bring Your Own Device (BYOD) policy for all employees. Staff may use personal smartphones and tablets for work purposes, provided that they install the company-approved security application on their devices.\n\nAll work-related data stored on personal devices must be encrypted. The IT department will conduct an initial setup session for all staff during the week of December 16–20. Attendance at this session is mandatory for anyone wishing to use personal devices for work.\n\nPersonal devices used for work must not be shared with unauthorized individuals, and employees remain personally responsible for any data breach resulting from loss or theft of their device.\n\nFor questions, contact IT Security at itsecurity@company.com.\n\nQuestion: What is the main subject of this memo?',
|
||||||
|
'["A. A new data encryption technology", "B. Mandatory IT training for all employees", "C. A BYOD policy for using personal devices at work", "D. Updated data privacy regulations"]',
|
||||||
|
'C',
|
||||||
|
'Memo giới thiệu chính sách BYOD cho phép nhân viên dùng thiết bị cá nhân cho công việc.'),
|
||||||
|
|
||||||
|
(7, 'reading',
|
||||||
|
E'INTERNAL MEMO\nTo: All Department Heads\nFrom: CEO Office\nSubject: Updated Mobile Device Policy\n\nEffective January 1st, the company will introduce a Bring Your Own Device (BYOD) policy for all employees. Staff may use personal smartphones and tablets for work purposes, provided that they install the company-approved security application on their devices.\n\nAll work-related data stored on personal devices must be encrypted. The IT department will conduct an initial setup session for all staff during the week of December 16–20. Attendance at this session is mandatory for anyone wishing to use personal devices for work.\n\nPersonal devices used for work must not be shared with unauthorized individuals, and employees remain personally responsible for any data breach resulting from loss or theft of their device.\n\nFor questions, contact IT Security at itsecurity@company.com.\n\nQuestion: What must employees do before they can use personal devices for work?',
|
||||||
|
'["A. Install a company-approved security application", "B. Sign a waiver form", "C. Submit a request to their department head", "D. Purchase a company-approved device"]',
|
||||||
|
'A',
|
||||||
|
'Điều kiện để dùng thiết bị cá nhân là phải cài ứng dụng bảo mật được công ty phê duyệt.'),
|
||||||
|
|
||||||
|
(7, 'reading',
|
||||||
|
E'INTERNAL MEMO\nTo: All Department Heads\nFrom: CEO Office\nSubject: Updated Mobile Device Policy\n\nEffective January 1st, the company will introduce a Bring Your Own Device (BYOD) policy for all employees. Staff may use personal smartphones and tablets for work purposes, provided that they install the company-approved security application on their devices.\n\nAll work-related data stored on personal devices must be encrypted. The IT department will conduct an initial setup session for all staff during the week of December 16–20. Attendance at this session is mandatory for anyone wishing to use personal devices for work.\n\nPersonal devices used for work must not be shared with unauthorized individuals, and employees remain personally responsible for any data breach resulting from loss or theft of their device.\n\nFor questions, contact IT Security at itsecurity@company.com.\n\nQuestion: When is the IT setup session scheduled?',
|
||||||
|
'["A. The week of January 1–5", "B. The week of November 11–15", "C. The week of December 16–20", "D. The week of December 23–27"]',
|
||||||
|
'C',
|
||||||
|
'Phiên thiết lập IT được tổ chức trong tuần từ ngày 16–20 tháng 12.'),
|
||||||
|
|
||||||
|
(7, 'reading',
|
||||||
|
E'INTERNAL MEMO\nTo: All Department Heads\nFrom: CEO Office\nSubject: Updated Mobile Device Policy\n\nEffective January 1st, the company will introduce a Bring Your Own Device (BYOD) policy for all employees. Staff may use personal smartphones and tablets for work purposes, provided that they install the company-approved security application on their devices.\n\nAll work-related data stored on personal devices must be encrypted. The IT department will conduct an initial setup session for all staff during the week of December 16–20. Attendance at this session is mandatory for anyone wishing to use personal devices for work.\n\nPersonal devices used for work must not be shared with unauthorized individuals, and employees remain personally responsible for any data breach resulting from loss or theft of their device.\n\nFor questions, contact IT Security at itsecurity@company.com.\n\nQuestion: Who is responsible if a personal device used for work is lost or stolen and data is breached?',
|
||||||
|
'["A. The IT Security department", "B. The department head", "C. The company''s insurance provider", "D. The employee personally"]',
|
||||||
|
'D',
|
||||||
|
'Nhân viên tự chịu trách nhiệm cá nhân về mọi rò rỉ dữ liệu do mất hoặc trộm thiết bị.'),
|
||||||
|
|
||||||
|
-- Passage D: Product review article
|
||||||
|
(7, 'reading',
|
||||||
|
E'PRODUCT SPOTLIGHT: ProDesk X500 Standing Desk\n\nThe ProDesk X500 is a motorized height-adjustable desk designed for modern office environments. With a dual-motor lifting system, the desk can transition from sitting to standing height in under 10 seconds. It supports a maximum load of 150 kilograms and features a memory function that saves up to four custom height presets.\n\nThe desktop surface is available in three finishes: natural oak, white, and charcoal grey. Assembly is straightforward and takes approximately 45 minutes with two people. The desk comes with a 5-year warranty covering all mechanical components.\n\nPricing starts at $499 for the basic model. The premium model, which includes a built-in cable management system and USB charging ports, is priced at $649. Both models are available through the ProDesk website and authorized retailers.\n\nQuestion: How long does it take for the desk to adjust its height?',
|
||||||
|
'["A. Less than 5 seconds", "B. About 30 seconds", "C. Under 10 seconds", "D. Approximately 1 minute"]',
|
||||||
|
'C',
|
||||||
|
'Bàn có thể chuyển từ tư thế ngồi sang đứng trong vòng dưới 10 giây.'),
|
||||||
|
|
||||||
|
(7, 'reading',
|
||||||
|
E'PRODUCT SPOTLIGHT: ProDesk X500 Standing Desk\n\nThe ProDesk X500 is a motorized height-adjustable desk designed for modern office environments. With a dual-motor lifting system, the desk can transition from sitting to standing height in under 10 seconds. It supports a maximum load of 150 kilograms and features a memory function that saves up to four custom height presets.\n\nThe desktop surface is available in three finishes: natural oak, white, and charcoal grey. Assembly is straightforward and takes approximately 45 minutes with two people. The desk comes with a 5-year warranty covering all mechanical components.\n\nPricing starts at $499 for the basic model. The premium model, which includes a built-in cable management system and USB charging ports, is priced at $649. Both models are available through the ProDesk website and authorized retailers.\n\nQuestion: How many color finishes are available for the desktop surface?',
|
||||||
|
'["A. Two", "B. Four", "C. Three", "D. Five"]',
|
||||||
|
'C',
|
||||||
|
'Bề mặt bàn có ba màu: natural oak, white, và charcoal grey.'),
|
||||||
|
|
||||||
|
(7, 'reading',
|
||||||
|
E'PRODUCT SPOTLIGHT: ProDesk X500 Standing Desk\n\nThe ProDesk X500 is a motorized height-adjustable desk designed for modern office environments. With a dual-motor lifting system, the desk can transition from sitting to standing height in under 10 seconds. It supports a maximum load of 150 kilograms and features a memory function that saves up to four custom height presets.\n\nThe desktop surface is available in three finishes: natural oak, white, and charcoal grey. Assembly is straightforward and takes approximately 45 minutes with two people. The desk comes with a 5-year warranty covering all mechanical components.\n\nPricing starts at $499 for the basic model. The premium model, which includes a built-in cable management system and USB charging ports, is priced at $649. Both models are available through the ProDesk website and authorized retailers.\n\nQuestion: What additional features does the premium model include?',
|
||||||
|
'["A. A wireless charging pad and extra storage drawer", "B. A built-in cable management system and USB charging ports", "C. An extended 10-year warranty and standing mat", "D. A larger desktop surface and additional memory presets"]',
|
||||||
|
'B',
|
||||||
|
'Model cao cấp có thêm hệ thống quản lý cáp tích hợp và cổng sạc USB.'),
|
||||||
|
|
||||||
|
(7, 'reading',
|
||||||
|
E'PRODUCT SPOTLIGHT: ProDesk X500 Standing Desk\n\nThe ProDesk X500 is a motorized height-adjustable desk designed for modern office environments. With a dual-motor lifting system, the desk can transition from sitting to standing height in under 10 seconds. It supports a maximum load of 150 kilograms and features a memory function that saves up to four custom height presets.\n\nThe desktop surface is available in three finishes: natural oak, white, and charcoal grey. Assembly is straightforward and takes approximately 45 minutes with two people. The desk comes with a 5-year warranty covering all mechanical components.\n\nPricing starts at $499 for the basic model. The premium model, which includes a built-in cable management system and USB charging ports, is priced at $649. Both models are available through the ProDesk website and authorized retailers.\n\nQuestion: How long is the warranty on the ProDesk X500?',
|
||||||
|
'["A. 1 year", "B. 3 years", "C. 10 years", "D. 5 years"]',
|
||||||
|
'D',
|
||||||
|
'Bàn đi kèm bảo hành 5 năm cho tất cả các bộ phận cơ khí.'),
|
||||||
|
|
||||||
|
-- Passage E: News article about a company expansion
|
||||||
|
(7, 'reading',
|
||||||
|
E'BUSINESS NEWS\n\nNorthStar Logistics Expands Operations to Vietnam\n\nNorthStar Logistics, a leading supply chain management company headquartered in Singapore, announced yesterday that it will open three new distribution centers in Vietnam over the next 18 months. The first facility, located in Hanoi, is expected to be operational by Q2 of next year. The two remaining centers will be established in Da Nang and Ho Chi Minh City.\n\nThe expansion is part of the company''s broader Southeast Asia growth strategy, driven by increasing demand from e-commerce clients. "Vietnam represents one of the fastest-growing logistics markets in the region," said CEO Marcus Tan. "We are committed to building a strong local presence to serve both domestic and international customers."\n\nNorthStar plans to hire approximately 500 local employees across the three sites. The company has also announced a partnership with Vietnam Post to utilize their last-mile delivery network.\n\nQuestion: Where is NorthStar Logistics headquartered?',
|
||||||
|
'["A. Vietnam", "B. Malaysia", "C. Thailand", "D. Singapore"]',
|
||||||
|
'D',
|
||||||
|
'NorthStar Logistics có trụ sở chính tại Singapore — "headquartered in Singapore."'),
|
||||||
|
|
||||||
|
(7, 'reading',
|
||||||
|
E'BUSINESS NEWS\n\nNorthStar Logistics Expands Operations to Vietnam\n\nNorthStar Logistics, a leading supply chain management company headquartered in Singapore, announced yesterday that it will open three new distribution centers in Vietnam over the next 18 months. The first facility, located in Hanoi, is expected to be operational by Q2 of next year. The two remaining centers will be established in Da Nang and Ho Chi Minh City.\n\nThe expansion is part of the company''s broader Southeast Asia growth strategy, driven by increasing demand from e-commerce clients. "Vietnam represents one of the fastest-growing logistics markets in the region," said CEO Marcus Tan. "We are committed to building a strong local presence to serve both domestic and international customers."\n\nNorthStar plans to hire approximately 500 local employees across the three sites. The company has also announced a partnership with Vietnam Post to utilize their last-mile delivery network.\n\nQuestion: What is driving the expansion according to the article?',
|
||||||
|
'["A. Increasing demand from e-commerce clients", "B. Government incentives for foreign investment", "C. A merger with a local logistics company", "D. New trade agreements in Southeast Asia"]',
|
||||||
|
'A',
|
||||||
|
'Sự mở rộng được thúc đẩy bởi nhu cầu ngày càng tăng từ các khách hàng thương mại điện tử.'),
|
||||||
|
|
||||||
|
(7, 'reading',
|
||||||
|
E'BUSINESS NEWS\n\nNorthStar Logistics Expands Operations to Vietnam\n\nNorthStar Logistics, a leading supply chain management company headquartered in Singapore, announced yesterday that it will open three new distribution centers in Vietnam over the next 18 months. The first facility, located in Hanoi, is expected to be operational by Q2 of next year. The two remaining centers will be established in Da Nang and Ho Chi Minh City.\n\nThe expansion is part of the company''s broader Southeast Asia growth strategy, driven by increasing demand from e-commerce clients. "Vietnam represents one of the fastest-growing logistics markets in the region," said CEO Marcus Tan. "We are committed to building a strong local presence to serve both domestic and international customers."\n\nNorthStar plans to hire approximately 500 local employees across the three sites. The company has also announced a partnership with Vietnam Post to utilize their last-mile delivery network.\n\nQuestion: When is the Hanoi facility expected to be operational?',
|
||||||
|
'["A. By the end of this year", "B. By Q2 of next year", "C. Within the next three months", "D. By Q4 of next year"]',
|
||||||
|
'B',
|
||||||
|
'Cơ sở Hà Nội dự kiến đi vào hoạt động vào quý 2 năm tới.'),
|
||||||
|
|
||||||
|
(7, 'reading',
|
||||||
|
E'BUSINESS NEWS\n\nNorthStar Logistics Expands Operations to Vietnam\n\nNorthStar Logistics, a leading supply chain management company headquartered in Singapore, announced yesterday that it will open three new distribution centers in Vietnam over the next 18 months. The first facility, located in Hanoi, is expected to be operational by Q2 of next year. The two remaining centers will be established in Da Nang and Ho Chi Minh City.\n\nThe expansion is part of the company''s broader Southeast Asia growth strategy, driven by increasing demand from e-commerce clients. "Vietnam represents one of the fastest-growing logistics markets in the region," said CEO Marcus Tan. "We are committed to building a strong local presence to serve both domestic and international customers."\n\nNorthStar plans to hire approximately 500 local employees across the three sites. The company has also announced a partnership with Vietnam Post to utilize their last-mile delivery network.\n\nQuestion: What partnership has NorthStar announced?',
|
||||||
|
'["A. A joint venture with a Vietnamese logistics startup", "B. A deal with Vietnam Airlines for air freight", "C. A collaboration with an international courier service", "D. A partnership with Vietnam Post for last-mile delivery"]',
|
||||||
|
'D',
|
||||||
|
'NorthStar thông báo hợp tác với Vietnam Post để sử dụng mạng lưới giao hàng chặng cuối của họ.');
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- VOCABULARY (31 words across 6 topics — retained from original)
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO vocab (word, phonetic, meaning_vi, topic, example) VALUES
|
||||||
|
|
||||||
|
-- Business (6 words)
|
||||||
|
('negotiate', '/nɪˈɡoʊʃieɪt/', 'đàm phán', 'Business', 'We need to negotiate the contract terms.'),
|
||||||
|
('collaborate', '/kəˈlæbəreɪt/', 'hợp tác', 'Business', 'Teams collaborate to achieve shared goals.'),
|
||||||
|
('delegate', '/ˈdelɪɡeɪt/', 'uỷ quyền, phân công', 'Business', 'A good manager knows how to delegate tasks.'),
|
||||||
|
('implement', '/ˈɪmplɪment/', 'triển khai, thực hiện', 'Business', 'We will implement the new policy next month.'),
|
||||||
|
('merger', '/ˈmɜːrdʒər/', 'sáp nhập công ty', 'Business', 'The merger will create a stronger combined company.'),
|
||||||
|
('acquisition', '/ˌækwɪˈzɪʃən/', 'mua lại, thâu tóm', 'Business', 'The acquisition was completed ahead of schedule.'),
|
||||||
|
|
||||||
|
-- Office (5 words)
|
||||||
|
('agenda', '/əˈdʒendə/', 'chương trình nghị sự', 'Office', 'Please review the agenda before the meeting.'),
|
||||||
|
('minutes', '/ˈmɪnɪts/', 'biên bản họp', 'Office', 'Could you take the meeting minutes today?'),
|
||||||
|
('submit', '/səbˈmɪt/', 'nộp, gửi đi', 'Office', 'Please submit your report by Friday afternoon.'),
|
||||||
|
('deadline', '/ˈdedlaɪn/', 'hạn chót', 'Office', 'The deadline for this project is end of month.'),
|
||||||
|
('cubicle', '/ˈkjuːbɪkəl/', 'góc làm việc riêng', 'Office', 'Each employee has their own cubicle in the open office.'),
|
||||||
|
|
||||||
|
-- Travel (5 words)
|
||||||
|
('itinerary', '/aɪˈtɪnəreri/', 'lịch trình chuyến đi', 'Travel', 'Here is your detailed travel itinerary.'),
|
||||||
|
('boarding pass', '/ˈbɔːrdɪŋ pæs/', 'thẻ lên máy bay', 'Travel', 'Please have your boarding pass ready at the gate.'),
|
||||||
|
('layover', '/ˈleɪoʊvər/', 'thời gian quá cảnh', 'Travel', 'There is a two-hour layover in Singapore.'),
|
||||||
|
('customs', '/ˈkʌstəmz/', 'hải quan', 'Travel', 'All passengers must go through customs on arrival.'),
|
||||||
|
('baggage claim', '/ˈbæɡɪdʒ kleɪm/', 'băng chuyền hành lý', 'Travel', 'Meet us at the baggage claim after landing.'),
|
||||||
|
|
||||||
|
-- Finance (5 words)
|
||||||
|
('reimburse', '/ˌriːɪmˈbɜːrs/', 'hoàn tiền', 'Finance', 'The company will reimburse all travel expenses.'),
|
||||||
|
('invoice', '/ˈɪnvɔɪs/', 'hoá đơn', 'Finance', 'Please send the invoice to our accounting department.'),
|
||||||
|
('budget', '/ˈbʌdʒɪt/', 'ngân sách', 'Finance', 'We need to stay within the approved budget.'),
|
||||||
|
('revenue', '/ˈrevɪnjuː/', 'doanh thu', 'Finance', 'Revenue increased by 15% last quarter.'),
|
||||||
|
('fiscal year', '/ˈfɪskəl jɪər/', 'năm tài chính', 'Finance', 'Our fiscal year ends on December 31st.'),
|
||||||
|
|
||||||
|
-- HR (5 words)
|
||||||
|
('recruit', '/rɪˈkruːt/', 'tuyển dụng', 'HR', 'We are recruiting experienced software engineers.'),
|
||||||
|
('probation', '/proʊˈbeɪʃən/', 'thử việc', 'HR', 'New employees have a 3-month probation period.'),
|
||||||
|
('appraisal', '/əˈpreɪzəl/', 'đánh giá nhân viên', 'HR', 'Annual performance appraisals are held in December.'),
|
||||||
|
('resignation', '/ˌrezɪɡˈneɪʃən/', 'đơn từ chức', 'HR', 'She submitted her resignation letter this morning.'),
|
||||||
|
('onboarding', '/ˈɒnbɔːrdɪŋ/', 'quy trình tiếp nhận nhân viên mới', 'HR', 'The onboarding process takes about two weeks.'),
|
||||||
|
|
||||||
|
-- Marketing (5 words)
|
||||||
|
('campaign', '/kæmˈpeɪn/', 'chiến dịch', 'Marketing', 'The marketing campaign exceeded all expectations.'),
|
||||||
|
('demographics', '/ˌdeməˈɡræfɪks/', 'nhân khẩu học', 'Marketing', 'We need to understand our target demographics.'),
|
||||||
|
('endorse', '/ɪnˈdɔːrs/', 'chứng thực, bảo trợ', 'Marketing', 'The product is endorsed by professional athletes.'),
|
||||||
|
('branding', '/ˈbrændɪŋ/', 'xây dựng thương hiệu', 'Marketing', 'Consistent branding builds long-term customer trust.'),
|
||||||
|
('conversion rate', '/kənˈvɜːrʒən reɪt/', 'tỷ lệ chuyển đổi', 'Marketing', 'Our conversion rate improved after the redesign.');
|
||||||
Reference in New Issue
Block a user