diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..0839f12
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,2 @@
+VITE_SUPABASE_URL=https://your-project.supabase.co
+VITE_SUPABASE_ANON_KEY=your-anon-key-here
diff --git a/.gitignore b/.gitignore
index 1663e65..398c5c8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -70,6 +70,9 @@ __pycache__
prompt.md
ck.md
+# TanStack Router generated
+src/routeTree.gen.ts
+
# Generated runtime layout for release/install smoke tests
/.claude/
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..15addee
--- /dev/null
+++ b/components.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "base-nova",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/index.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "menuColor": "default",
+ "menuAccent": "subtle",
+ "registries": {}
+}
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..f6e0f68
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,28 @@
+import js from "@eslint/js"
+import globals from "globals"
+import reactHooks from "eslint-plugin-react-hooks"
+import reactRefresh from "eslint-plugin-react-refresh"
+import tseslint from "typescript-eslint"
+
+export default tseslint.config(
+ { ignores: ["dist", "src/routeTree.gen.ts"] },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ files: ["**/*.{ts,tsx}"],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ plugins: {
+ "react-hooks": reactHooks,
+ "react-refresh": reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ "react-refresh/only-export-components": [
+ "warn",
+ { allowConstantExport: true },
+ ],
+ },
+ },
+)
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..c385bae
--- /dev/null
+++ b/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ English Learning App
+
+
+
+
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c8028b5
--- /dev/null
+++ b/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "english-learning-app",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "lint": "eslint ."
+ },
+ "dependencies": {
+ "@base-ui/react": "^1.3.0",
+ "@fontsource-variable/geist": "^5.2.8",
+ "@supabase/supabase-js": "^2.103.0",
+ "@tanstack/react-query": "^5.99.0",
+ "@tanstack/react-router": "^1.168.16",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^1.8.0",
+ "react": "^19.2.5",
+ "react-dom": "^19.2.5",
+ "shadcn": "^4.2.0",
+ "tailwind-merge": "^3.5.0",
+ "tw-animate-css": "^1.4.0",
+ "zustand": "^5.0.12"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.4",
+ "@tailwindcss/vite": "^4.2.2",
+ "@tanstack/router-plugin": "^1.167.15",
+ "@types/node": "^25.6.0",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "eslint": "^9.39.4",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "globals": "^17.4.0",
+ "tailwindcss": "^4.2.2",
+ "typescript": "^6.0.2",
+ "typescript-eslint": "^8.58.1",
+ "vite": "^8.0.8"
+ }
+}
diff --git a/src/components/FlashCard.tsx b/src/components/FlashCard.tsx
new file mode 100644
index 0000000..aa3f280
--- /dev/null
+++ b/src/components/FlashCard.tsx
@@ -0,0 +1,31 @@
+import { useState } from "react"
+
+interface FlashCardProps {
+ word?: string
+ phonetic?: string
+ meaningVi?: string
+ example?: string
+}
+
+export function FlashCard({ word, phonetic, meaningVi, example }: FlashCardProps) {
+ const [flipped, setFlipped] = useState(false)
+
+ return (
+ setFlipped((f) => !f)}
+ className="rounded-lg border p-6 text-center cursor-pointer min-h-[160px] flex flex-col justify-center select-none hover:bg-gray-50"
+ >
+ {!flipped ? (
+
+
{word || "word"}
+ {phonetic &&
{phonetic}
}
+
+ ) : (
+
+
{meaningVi || "nghĩa tiếng Việt"}
+ {example &&
{example}
}
+
+ )}
+
+ )
+}
diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx
new file mode 100644
index 0000000..317f0aa
--- /dev/null
+++ b/src/components/ProgressBar.tsx
@@ -0,0 +1,26 @@
+interface ProgressBarProps {
+ value?: number
+ max?: number
+ label?: string
+}
+
+export function ProgressBar({ value = 0, max = 100, label }: ProgressBarProps) {
+ const pct = Math.min(100, Math.round((value / max) * 100))
+
+ return (
+
+ {label && (
+
+ {label}
+ {value}/{max}
+
+ )}
+
+
+ )
+}
diff --git a/src/components/QuestionCard.tsx b/src/components/QuestionCard.tsx
new file mode 100644
index 0000000..c3a9bf3
--- /dev/null
+++ b/src/components/QuestionCard.tsx
@@ -0,0 +1,29 @@
+interface QuestionCardProps {
+ question?: string
+ options?: string[]
+ selectedAnswer?: string
+ onSelect?: (answer: string) => void
+}
+
+export function QuestionCard({ question, options, selectedAnswer, onSelect }: QuestionCardProps) {
+ return (
+
+
{question || "Question placeholder"}
+ {options && (
+
+ {options.map((opt) => (
+ - onSelect?.(opt[0])}
+ className={`rounded border p-2 text-sm cursor-pointer hover:bg-gray-50 ${
+ selectedAnswer === opt[0] ? "border-blue-500 bg-blue-50" : ""
+ }`}
+ >
+ {opt}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/components/Timer.tsx b/src/components/Timer.tsx
new file mode 100644
index 0000000..ad41f16
--- /dev/null
+++ b/src/components/Timer.tsx
@@ -0,0 +1,14 @@
+interface TimerProps {
+ seconds?: number
+}
+
+export function Timer({ seconds = 0 }: TimerProps) {
+ const mins = Math.floor(seconds / 60)
+ const secs = seconds % 60
+
+ return (
+
+ {String(mins).padStart(2, "0")}:{String(secs).padStart(2, "0")}
+
+ )
+}
diff --git a/src/components/WritingFeedback.tsx b/src/components/WritingFeedback.tsx
new file mode 100644
index 0000000..ad34567
--- /dev/null
+++ b/src/components/WritingFeedback.tsx
@@ -0,0 +1,44 @@
+interface WritingFeedbackProps {
+ score?: string
+ grammar?: string[]
+ vocabulary?: string[]
+ structure?: string
+ summary?: string
+ improvedVersion?: string
+}
+
+export function WritingFeedback({ score, grammar, vocabulary, structure, summary }: WritingFeedbackProps) {
+ if (!score) return null
+
+ return (
+
+
+ Band score:
+ {score}
+
+ {summary &&
{summary}
}
+ {grammar && grammar.length > 0 && (
+
+
Ngữ pháp:
+
+ {grammar.map((item, i) => - {item}
)}
+
+
+ )}
+ {vocabulary && vocabulary.length > 0 && (
+
+
Từ vựng:
+
+ {vocabulary.map((item, i) => - {item}
)}
+
+
+ )}
+ {structure && (
+
+
Bố cục:
+
{structure}
+
+ )}
+
+ )
+}
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..09df753
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,58 @@
+import { Button as ButtonPrimitive } from "@base-ui/react/button"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ outline:
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost:
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ destructive:
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default:
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ icon: "size-8",
+ "icon-xs":
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm":
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
+ "icon-lg": "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: ButtonPrimitive.Props & VariantProps) {
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/src/hooks/use-questions.ts b/src/hooks/use-questions.ts
new file mode 100644
index 0000000..91a0b69
--- /dev/null
+++ b/src/hooks/use-questions.ts
@@ -0,0 +1,18 @@
+import { useQuery } from "@tanstack/react-query"
+import { supabase } from "@/lib/supabase"
+
+export function useQuestions(part: number, limit = 10) {
+ return useQuery({
+ queryKey: ["questions", part, limit],
+ queryFn: async () => {
+ 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
+ })
+}
diff --git a/src/hooks/use-vocab.ts b/src/hooks/use-vocab.ts
new file mode 100644
index 0000000..904b6b4
--- /dev/null
+++ b/src/hooks/use-vocab.ts
@@ -0,0 +1,16 @@
+import { useQuery } from "@tanstack/react-query"
+import { supabase } from "@/lib/supabase"
+
+export function useVocab(topic?: string) {
+ return useQuery({
+ queryKey: ["vocab", topic],
+ queryFn: async () => {
+ let query = supabase.from("vocab").select("*")
+ if (topic) query = query.eq("topic", topic.toLowerCase())
+ const { data, error } = await query
+ if (error) throw error
+ return data
+ },
+ enabled: false, // Enabled during feature implementation
+ })
+}
diff --git a/src/hooks/use-writing-check.ts b/src/hooks/use-writing-check.ts
new file mode 100644
index 0000000..fc4aacf
--- /dev/null
+++ b/src/hooks/use-writing-check.ts
@@ -0,0 +1,28 @@
+import { useMutation } from "@tanstack/react-query"
+import { supabase } from "@/lib/supabase"
+import { canUseWritingCheck, recordWritingCheckUsage } from "@/utils/rate-limiter"
+
+interface WritingFeedback {
+ score: string
+ grammar: string[]
+ vocabulary: string[]
+ structure: string
+ improved_version: string
+ summary: string
+}
+
+export function useWritingCheck() {
+ return useMutation({
+ mutationFn: async (content: string): Promise => {
+ if (!canUseWritingCheck()) {
+ 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!")
+ }
+ const { data, error } = await supabase.functions.invoke("writing-check", {
+ body: { content },
+ })
+ if (error) throw error
+ recordWritingCheckUsage()
+ return data as WritingFeedback
+ },
+ })
+}
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..fb3c7e9
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,130 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+@import "@fontsource-variable/geist";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --font-heading: var(--font-sans);
+ --font-sans: 'Geist Variable', sans-serif;
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --color-foreground: var(--foreground);
+ --color-background: var(--background);
+ --radius-sm: calc(var(--radius) * 0.6);
+ --radius-md: calc(var(--radius) * 0.8);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) * 1.4);
+ --radius-2xl: calc(var(--radius) * 1.8);
+ --radius-3xl: calc(var(--radius) * 2.2);
+ --radius-4xl: calc(var(--radius) * 2.6);
+}
+
+:root {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.87 0 0);
+ --chart-2: oklch(0.556 0 0);
+ --chart-3: oklch(0.439 0 0);
+ --chart-4: oklch(0.371 0 0);
+ --chart-5: oklch(0.269 0 0);
+ --radius: 0.625rem;
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.87 0 0);
+ --chart-2: oklch(0.556 0 0);
+ --chart-3: oklch(0.439 0 0);
+ --chart-4: oklch(0.371 0 0);
+ --chart-5: oklch(0.269 0 0);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+ html {
+ @apply font-sans;
+ }
+}
\ No newline at end of file
diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts
new file mode 100644
index 0000000..2609f6b
--- /dev/null
+++ b/src/lib/query-client.ts
@@ -0,0 +1,10 @@
+import { QueryClient } from "@tanstack/react-query"
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ retry: 1,
+ },
+ },
+})
diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts
new file mode 100644
index 0000000..8b9434c
--- /dev/null
+++ b/src/lib/supabase.ts
@@ -0,0 +1,15 @@
+import { createClient } from "@supabase/supabase-js"
+
+const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
+const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
+
+if (!supabaseUrl || !supabaseAnonKey) {
+ console.warn(
+ "Supabase env vars missing. Set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in .env",
+ )
+}
+
+export const supabase = createClient(
+ supabaseUrl || "https://placeholder.supabase.co",
+ supabaseAnonKey || "placeholder-key",
+)
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..74d657c
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,23 @@
+import React from "react"
+import ReactDOM from "react-dom/client"
+import { RouterProvider, createRouter } from "@tanstack/react-router"
+import { QueryClientProvider } from "@tanstack/react-query"
+import { routeTree } from "./routeTree.gen"
+import { queryClient } from "./lib/query-client"
+import "./index.css"
+
+const router = createRouter({ routeTree })
+
+declare module "@tanstack/react-router" {
+ interface Register {
+ router: typeof router
+ }
+}
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+
+
+ ,
+)
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
new file mode 100644
index 0000000..93c54b7
--- /dev/null
+++ b/src/pages/Home.tsx
@@ -0,0 +1,32 @@
+export function Home() {
+ return (
+
+
+
Luyện tiếng Anh TOEIC
+
+ Luyện đề, kiểm tra writing, và học từ vựng TOEIC miễn phí
+
+
+
+
+
Luyện đề TOEIC
+
+ Luyện tập từng Part 1–7 với đề thật
+
+
+
+
AI Writing Checker
+
+ Chấm điểm và sửa bài writing bằng AI
+
+
+
+
Từ vựng TOEIC
+
+ Flashcard 6 chủ đề: Business, Finance, HR...
+
+
+
+
+ )
+}
diff --git a/src/pages/TestResult.tsx b/src/pages/TestResult.tsx
new file mode 100644
index 0000000..f49c0a5
--- /dev/null
+++ b/src/pages/TestResult.tsx
@@ -0,0 +1,8 @@
+export function TestResult() {
+ return (
+
+
Kết quả
+
Kết quả và đáp án — placeholder
+
+ )
+}
diff --git a/src/pages/TestSession.tsx b/src/pages/TestSession.tsx
new file mode 100644
index 0000000..7922f43
--- /dev/null
+++ b/src/pages/TestSession.tsx
@@ -0,0 +1,8 @@
+export function TestSession() {
+ return (
+
+
Làm bài
+
Trang làm bài — placeholder
+
+ )
+}
diff --git a/src/pages/ToeicPractice.tsx b/src/pages/ToeicPractice.tsx
new file mode 100644
index 0000000..5b61e43
--- /dev/null
+++ b/src/pages/ToeicPractice.tsx
@@ -0,0 +1,26 @@
+const PARTS = [
+ { id: 1, name: "Part 1", desc: "Photographs" },
+ { id: 2, name: "Part 2", desc: "Question-Response" },
+ { id: 3, name: "Part 3", desc: "Conversations" },
+ { id: 4, name: "Part 4", desc: "Short Talks" },
+ { id: 5, name: "Part 5", desc: "Incomplete Sentences" },
+ { id: 6, name: "Part 6", desc: "Text Completion" },
+ { id: 7, name: "Part 7", desc: "Reading Comprehension" },
+]
+
+export function ToeicPractice() {
+ return (
+
+
Luyện đề TOEIC
+
Chọn Part để bắt đầu luyện tập
+
+ {PARTS.map((part) => (
+
+
{part.name}
+
{part.desc}
+
+ ))}
+
+
+ )
+}
diff --git a/src/pages/Vocabulary.tsx b/src/pages/Vocabulary.tsx
new file mode 100644
index 0000000..0971431
--- /dev/null
+++ b/src/pages/Vocabulary.tsx
@@ -0,0 +1,17 @@
+const TOPICS = ["Business", "Office", "Travel", "Finance", "HR", "Marketing"]
+
+export function Vocabulary() {
+ return (
+
+
Từ vựng TOEIC
+
Chọn chủ đề để học flashcard
+
+ {TOPICS.map((topic) => (
+
+ {topic}
+
+ ))}
+
+
+ )
+}
diff --git a/src/pages/WritingChecker.tsx b/src/pages/WritingChecker.tsx
new file mode 100644
index 0000000..30eba38
--- /dev/null
+++ b/src/pages/WritingChecker.tsx
@@ -0,0 +1,16 @@
+export function WritingChecker() {
+ return (
+
+
AI Writing Checker
+
+ Nhập bài writing để nhận phản hồi từ AI
+
+
+
Tính năng đang được phát triển
+
+ )
+}
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
new file mode 100644
index 0000000..4214a2e
--- /dev/null
+++ b/src/routes/__root.tsx
@@ -0,0 +1,31 @@
+import { createRootRoute, Link, Outlet } from "@tanstack/react-router"
+
+export const Route = createRootRoute({
+ component: RootLayout,
+})
+
+function RootLayout() {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
new file mode 100644
index 0000000..9a9d880
--- /dev/null
+++ b/src/routes/index.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router"
+import { Home } from "@/pages/Home"
+
+export const Route = createFileRoute("/")({
+ component: Home,
+})
diff --git a/src/routes/toeic.index.tsx b/src/routes/toeic.index.tsx
new file mode 100644
index 0000000..94cccf6
--- /dev/null
+++ b/src/routes/toeic.index.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router"
+import { ToeicPractice } from "@/pages/ToeicPractice"
+
+export const Route = createFileRoute("/toeic/")({
+ component: ToeicPractice,
+})
diff --git a/src/routes/toeic.part.$partId.tsx b/src/routes/toeic.part.$partId.tsx
new file mode 100644
index 0000000..729e6db
--- /dev/null
+++ b/src/routes/toeic.part.$partId.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from "@tanstack/react-router"
+
+export const Route = createFileRoute("/toeic/part/$partId")({
+ component: ToeicPartConfig,
+})
+
+function ToeicPartConfig() {
+ const { partId } = Route.useParams()
+ return (
+
+
TOEIC Part {partId}
+
Cấu hình bài thi — placeholder
+
+ )
+}
diff --git a/src/routes/toeic.result.tsx b/src/routes/toeic.result.tsx
new file mode 100644
index 0000000..b712eaa
--- /dev/null
+++ b/src/routes/toeic.result.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router"
+import { TestResult } from "@/pages/TestResult"
+
+export const Route = createFileRoute("/toeic/result")({
+ component: TestResult,
+})
diff --git a/src/routes/toeic.session.tsx b/src/routes/toeic.session.tsx
new file mode 100644
index 0000000..60790a3
--- /dev/null
+++ b/src/routes/toeic.session.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router"
+import { TestSession } from "@/pages/TestSession"
+
+export const Route = createFileRoute("/toeic/session")({
+ component: TestSession,
+})
diff --git a/src/routes/toeic.tsx b/src/routes/toeic.tsx
new file mode 100644
index 0000000..e908573
--- /dev/null
+++ b/src/routes/toeic.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute, Outlet } from "@tanstack/react-router"
+
+export const Route = createFileRoute("/toeic")({
+ component: ToeicLayout,
+})
+
+function ToeicLayout() {
+ return
+}
diff --git a/src/routes/vocab.tsx b/src/routes/vocab.tsx
new file mode 100644
index 0000000..f2ff853
--- /dev/null
+++ b/src/routes/vocab.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router"
+import { Vocabulary } from "@/pages/Vocabulary"
+
+export const Route = createFileRoute("/vocab")({
+ component: Vocabulary,
+})
diff --git a/src/routes/writing.tsx b/src/routes/writing.tsx
new file mode 100644
index 0000000..6a55393
--- /dev/null
+++ b/src/routes/writing.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from "@tanstack/react-router"
+import { WritingChecker } from "@/pages/WritingChecker"
+
+export const Route = createFileRoute("/writing")({
+ component: WritingChecker,
+})
diff --git a/src/store/test-store.ts b/src/store/test-store.ts
new file mode 100644
index 0000000..1964e87
--- /dev/null
+++ b/src/store/test-store.ts
@@ -0,0 +1,27 @@
+import { create } from "zustand"
+import { persist } from "zustand/middleware"
+
+interface TestState {
+ // Shell — filled during TOEIC feature implementation
+ currentPart: number | null
+ answers: Record
+ setCurrentPart: (part: number | null) => void
+ setAnswer: (questionId: string, answer: string) => void
+ reset: () => void
+}
+
+export const useTestStore = create()(
+ persist(
+ (set) => ({
+ currentPart: null,
+ answers: {},
+ setCurrentPart: (part) => set({ currentPart: part }),
+ setAnswer: (questionId, answer) =>
+ set((state) => ({
+ answers: { ...state.answers, [questionId]: answer },
+ })),
+ reset: () => set({ currentPart: null, answers: {} }),
+ }),
+ { name: "test-store" },
+ ),
+)
diff --git a/src/store/vocab-store.ts b/src/store/vocab-store.ts
new file mode 100644
index 0000000..4d2ae14
--- /dev/null
+++ b/src/store/vocab-store.ts
@@ -0,0 +1,28 @@
+import { create } from "zustand"
+import { persist } from "zustand/middleware"
+
+interface VocabState {
+ // Shell — filled during Vocab feature implementation
+ knownWords: string[]
+ markKnown: (wordId: string) => void
+ markUnknown: (wordId: string) => void
+ reset: () => void
+}
+
+export const useVocabStore = create()(
+ persist(
+ (set) => ({
+ knownWords: [],
+ markKnown: (wordId) =>
+ set((state) => ({
+ knownWords: [...new Set([...state.knownWords, wordId])],
+ })),
+ markUnknown: (wordId) =>
+ set((state) => ({
+ knownWords: state.knownWords.filter((id) => id !== wordId),
+ })),
+ reset: () => set({ knownWords: [] }),
+ }),
+ { name: "vocab-store" },
+ ),
+)
diff --git a/src/utils/rate-limiter.ts b/src/utils/rate-limiter.ts
new file mode 100644
index 0000000..13ffe1f
--- /dev/null
+++ b/src/utils/rate-limiter.ts
@@ -0,0 +1,49 @@
+const DAILY_LIMIT = 3
+const STORAGE_KEY = "writing-check-usage"
+
+interface UsageRecord {
+ date: string
+ count: number
+}
+
+export function canUseWritingCheck(): boolean {
+ const record = getUsageRecord()
+ const today = getToday()
+ if (record.date !== today) return true
+ return record.count < DAILY_LIMIT
+}
+
+export function recordWritingCheckUsage(): void {
+ const today = getToday()
+ const record = getUsageRecord()
+ if (record.date !== today) {
+ setUsageRecord({ date: today, count: 1 })
+ } else {
+ setUsageRecord({ date: today, count: record.count + 1 })
+ }
+}
+
+export function getRemainingChecks(): number {
+ const record = getUsageRecord()
+ const today = getToday()
+ if (record.date !== today) return DAILY_LIMIT
+ return Math.max(0, DAILY_LIMIT - record.count)
+}
+
+function getToday(): string {
+ return new Date().toISOString().split("T")[0]
+}
+
+function getUsageRecord(): UsageRecord {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY)
+ if (!raw) return { date: "", count: 0 }
+ return JSON.parse(raw) as UsageRecord
+ } catch {
+ return { date: "", count: 0 }
+ }
+}
+
+function setUsageRecord(record: UsageRecord): void {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(record))
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..377b9ec
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1,10 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_SUPABASE_URL: string
+ readonly VITE_SUPABASE_ANON_KEY: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..a95e4ee
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..c36d52a
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ],
+ "compilerOptions": {
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..1dba6de
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..609ba14
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,21 @@
+import path from "path"
+import tailwindcss from "@tailwindcss/vite"
+import react from "@vitejs/plugin-react"
+import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
+import { defineConfig } from "vite"
+
+export default defineConfig({
+ plugins: [
+ TanStackRouterVite({
+ target: "react",
+ autoCodeSplitting: true,
+ }),
+ react(),
+ tailwindcss(),
+ ],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+})