# GoodGo Frontend: Hướng dẫn triển khai i18n & A11y theo từng file ## 📋 Ánh xạ file đầy đủ ### GIAI ĐOẠN 1: THIẾT LẬP HẠ TẦNG #### 1. `middleware.ts` (QUAN TRỌNG) **Hiện tại:** Chỉ định tuyến xác thực **Thay đổi:** - Thêm phát hiện locale từ pathname URL - Thêm lưu trữ locale dựa trên cookie - Chuyển hướng các đường dẫn `/en/*` và `/vi/*` một cách phù hợp - Thêm dự phòng dựa trên header Accept-Language **Pseudo-code:** ```typescript export function middleware(request: NextRequest) { const pathname = request.nextUrl.pathname; // Extract locale from URL: /en/*, /vi/*, or default const locale = extractLocale(pathname) || getPreferredLocale(request); // ... existing auth logic ... // If no locale prefix, add it if (!locale) { return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url)); } // Set locale cookie for client-side response.cookies.set('goodgo_locale', locale); } ``` #### 2. `app/layout.tsx` (QUAN TRỌNG) **Hiện tại:** Các provider chính, metadata tiếng Việt được hardcode **Thay đổi:** - Thay đổi `lang="vi"` thành locale động - Cập nhật metadata để hỗ trợ i18n - Bao bọc bằng `NextIntlClientProvider` từ next-intl - Giữ nguyên các provider hiện có (ThemeProvider, QueryProvider, AuthProvider) **Các thay đổi chính:** ```typescript import { getLocale, getTranslations } from 'next-intl/server'; export async function generateMetadata(): Promise { const locale = getLocale(); const t = getTranslations(); return { title: t('common.site_name'), description: t('common.site_description'), openGraph: { locale: locale === 'en' ? 'en_US' : 'vi_VN', ... } }; } ``` #### 3. `i18n/config.ts` (MỚI) **Tạo file mới** với cấu hình i18n: ```typescript export const locales = ['en', 'vi'] as const; export const defaultLocale = 'vi'; export const timeZone = 'Asia/Ho_Chi_Minh'; ``` #### 4. `public/locales/en.json` (MỚI - FILE LỚN) **Cấu trúc:** ```json { "common": { "site_name": "GoodGo", "site_description": "Smart real estate platform in Vietnam", "home": "Home", "search": "Search", "dashboard": "Dashboard", "logout": "Logout", "loading": "Loading...", "error": "An error occurred", "try_again": "Try again" }, "auth": { "login": "Login", "register": "Register", "phone": "Phone number", "password": "Password", "confirm_password": "Confirm password", "no_account": "Don't have an account?", "sign_up": "Sign up", "sign_in": "Sign in", "oauth_failed": "Social login failed. Please try again.", "access_denied": "You denied access. Please try again.", "invalid_request": "Invalid login request. Please try again.", "server_error": "Server error. Please try again later.", "show_password": "Show", "hide_password": "Hide" }, "property": { "apartment": "Apartment", "house": "House", "villa": "Villa", "land": "Land", "office": "Office", "shophouse": "Shophouse", "price": "Price", "area": "Area", "bedrooms": "Bedrooms", "bathrooms": "Bathrooms", "direction": "Direction" }, "transaction": { "sale": "Sale", "rent": "Rent", "buy": "Buy", "lease": "Lease" }, "direction": { "north": "North", "south": "South", "east": "East", "west": "West", "northeast": "Northeast", "northwest": "Northwest", "southeast": "Southeast", "southwest": "Southwest" }, "status": { "draft": "Draft", "pending_review": "Pending review", "active": "Active", "reserved": "Reserved", "sold": "Sold", "rented": "Rented", "expired": "Expired", "rejected": "Rejected" }, "validation": { "required": "This field is required", "min_length": "Minimum {count} characters", "invalid_phone": "Invalid phone number", "invalid_email": "Invalid email", "passwords_match": "Passwords must match" }, "navigation": { "home": "Home", "search": "Search", "create_listing": "Create listing", "my_listings": "My listings", "analytics": "Analytics", "valuation": "Valuation", "profile": "Profile", "subscription": "Subscription", "payments": "Payments", "admin_dashboard": "Admin", "admin_users": "Users", "admin_kyc": "KYC", "admin_moderation": "Moderation" }, "landing": { "hero_title": "Find your perfect real estate", "hero_subtitle": "Smart real estate platform in Vietnam", "search_placeholder": "Enter area, project, or keyword...", "featured_listings": "Featured listings", "districts": "Popular districts", "stats_title": "GoodGo in numbers", "stats_listings": "Listings", "stats_users": "Users", "stats_transactions": "Successful transactions", "stats_cities": "Cities", "cta_title": "Have a property to list?", "cta_subtitle": "List for free today, reach thousands of potential buyers", "cta_register": "Sign up free", "cta_search": "Search now" } } ``` #### 5. `public/locales/vi.json` (MỚI - FILE LỚN) Cấu trúc giống en.json nhưng với bản dịch tiếng Việt. --- ### GIAI ĐOẠN 2: CẬP NHẬT COMPONENT CỐT LÕI #### Các file cần tích hợp Translation Hook ##### Các file Layout 1. **`app/(public)/layout.tsx`** ```typescript // Current: Hardcoded nav items const navItems = [ { href: '/', label: 'Trang chủ' }, { href: '/search', label: 'Tìm kiếm' }, ]; // After i18n: export default function PublicLayout() { const t = useTranslations('navigation'); const navItems = [ { href: '/', label: t('home') }, { href: '/search', label: t('search') }, ]; // Also update footer content } ``` 2. **`app/(dashboard)/layout.tsx`** ```typescript // Update all 8 nav items to use t('navigation.item_name') // Update theme toggle aria-label const toggleLabel = theme === 'light' ? t('common.toggle_dark_mode') : t('common.toggle_light_mode'); ``` 3. **`app/(auth)/layout.tsx`** Cập nhật mọi thông báo lỗi hoặc nhãn để sử dụng bản dịch. ##### Các file Page 1. **`app/(public)/page.tsx` (FILE LỚN)** **Các phần cần cập nhật:** - Phần hero (tiêu đề, phụ đề) - Form tìm kiếm (placeholder) - Các badge loại bất động sản - Các khoảng giá - Tùy chọn thành phố - Tiêu đề các phần - Nhãn thống kê - Các nút CTA ```typescript export default function LandingPage() { const t = useTranslations(); const PROPERTY_TYPES_LABELS = PROPERTY_TYPES.map(pt => ({ value: pt.value, label: t(`property.${pt.value.toLowerCase()}`), })); // Update all hardcoded strings: // Hero title: t('landing.hero_title') // Hero subtitle: t('landing.hero_subtitle') // Search placeholder: t('landing.search_placeholder') // etc. } ``` 2. **`app/(auth)/login/page.tsx`** ```typescript // Update all form labels to use t() const phoneLabel = t('auth.phone'); const passwordLabel = t('auth.password'); const loginButton = t('auth.sign_in'); // OAuth error messages - move to translations const OAUTH_ERROR_MESSAGES = { oauth_failed: t('auth.oauth_failed'), access_denied: t('auth.access_denied'), // ... etc }; ``` 3. **`app/(auth)/register/page.tsx`** Tương tự như trang login. 4. **`app/(dashboard)/dashboard/page.tsx`** và tất cả các trang dashboard khác Cập nhật tiêu đề các phần, trạng thái rỗng, nhãn các nút. ##### Các trang Search & Listing 1. **`app/(public)/search/page.tsx`** Cập nhật tiêu đề kết quả tìm kiếm, trạng thái rỗng, nhãn bộ lọc. 2. **`app/(public)/listings/[id]/page.tsx`** Cập nhật nhãn chi tiết bất động sản. 3. **`app/(dashboard)/listings/page.tsx`** Cập nhật tiêu đề bảng, nhãn trạng thái, nhãn hành động. 4. **`app/(dashboard)/listings/new/page.tsx`** Sử dụng component listing-form-steps (xem bên dưới). --- #### Các file Component ##### Các component quan trọng (Làm trước) 1. **`components/search/filter-bar.tsx` (ƯU TIÊN CAO)** ```typescript // Current: Hardcoded arrays const CITIES = ['Hồ Chí Minh', 'Hà Nội', 'Đà Nẵng', ...]; const PRICE_RANGES = [ { label: 'Dưới 1 tỷ', ... }, { label: '1 - 3 tỷ', ... }, ]; // After i18n: const CITIES = t('locations.cities').split(','); const PRICE_RANGES = [ { label: t('search.price_under_1b'), ... }, { label: t('search.price_1_3b'), ... }, ]; ``` 2. **`components/listings/listing-form-steps.tsx` (ƯU TIÊN CAO - FILE LỚN)** Form đa bước này có nhiều nhãn cần dịch: - Bước 1: Loại giao dịch, loại bất động sản, tiêu đề, mô tả - Bước 2: Các trường địa chỉ, vị trí - Bước 3: Diện tích, phòng, phòng tắm, hướng, năm xây dựng, v.v. - Bước 4: Giá Tất cả nhãn các trường nên sử dụng pattern `t('form.field_name')`. 3. **`components/auth/oauth-buttons.tsx`** ```typescript // Update button text ``` ##### Các component ưu tiên trung bình 1. **`components/search/property-card.tsx`** ```typescript // Update PROPERTY_TYPE_LABELS to use translations const t = useTranslations(); const PROPERTY_TYPE_LABELS = { APARTMENT: t('property.apartment'), HOUSE: t('property.house'), // ... etc }; // Update aria-labels to use translations aria-label={t('property.card_label', { title: listing.property.title, type: propertyTypeLabel, price: formatPrice(listing.priceVND) })} ``` 2. **`components/listings/listing-status-badge.tsx`** ```typescript // Update status labels const LISTING_STATUSES = { DRAFT: { label: t('status.draft'), variant: 'secondary' }, ACTIVE: { label: t('status.active'), variant: 'success' }, // ... etc }; ``` 3. **`components/valuation/valuation-form.tsx`** Cập nhật nhãn form và các nút. 4. **`components/listings/image-upload.tsx`** Cập nhật văn bản nút và thông báo lỗi. 5. **Tất cả các file `components/ui/*.tsx` có văn bản** - Button: bất kỳ văn bản mặc định nào - Dialog: aria-label nút Đóng - Input: thuộc tính placeholder nếu được hardcode - Label: bất kỳ văn bản mặc định nào - Khác: tương tự --- ### GIAI ĐOẠN 3: VALIDATION & THÔNG BÁO LỖI #### 1. `lib/validations/auth.ts` ```typescript // Current: const loginSchema = z.object({ phone: z.string().min(1, 'Vui lòng nhập số điện thoại'), password: z.string().min(1, 'Vui lòng nhập mật khẩu'), }); // After i18n - move to message files and use in component: // In component: const t = useTranslations('validation'); const schema = z.object({ phone: z.string().min(1, t('required')), password: z.string().min(1, t('required')), }); ``` #### 2. `lib/validations/listings.ts` (FILE LỚN) Cập nhật tất cả thông báo lỗi validation Zod: - "Vui lòng chọn loại giao dịch" → `t('validation.transaction_required')` - "Tiêu đề tối thiểu 5 ký tự" → `t('validation.title_min_length')` - Tất cả các thông báo validation khác #### 3. `lib/validations/valuation.ts` Mẫu tương tự như listings. --- ### GIAI ĐOẠN 4: CẬP NHẬT TIỆN ÍCH #### 1. `lib/utils.ts` Không thay đổi (đã tối giản). #### 2. `lib/auth-store.ts` ```typescript // Check if any error messages are hardcoded // If so, move to i18n and pass locale context ``` #### 3. `lib/api-client.ts` Kiểm tra xem các thông báo lỗi từ API có cần bao bọc i18n hay không. #### 4. Tất cả các file `lib/*-api.ts` Cập nhật xử lý thông báo lỗi nếu cần. --- ### GIAI ĐOẠN 5: CẬP NHẬT KHẢ NĂNG TIẾP CẬN #### 1. `components/ui/dialog.tsx` (A11y QUAN TRỌNG) **Thêm quản lý focus:** ```typescript // Add focus trap // Save initial focus element // On mount: move focus to dialog // On close: restore focus to initial element // On Escape key: close dialog import { useEffect, useRef } from 'react'; function Dialog() { const initialFocusRef = useRef(null); useEffect(() => { // Set initial focus const firstButton = dialogRef.current?.querySelector('button'); firstButton?.focus(); // Trap focus const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Tab') { // Prevent focus from leaving dialog } if (e.key === 'Escape') { onClose?.(); } }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); // Restore focus (initialFocusRef.current as HTMLElement | null)?.focus(); }; }, []); } ``` #### 2. `components/ui/input.tsx` Thêm aria-describedby cho thông báo lỗi: ```typescript export function Input({ error, ...props }) { const errorId = `${props.id}-error`; return ( <> {error && } ); } ``` #### 3. `components/ui/button.tsx` Đảm bảo tất cả các nút có chỉ báo focus rõ ràng (có thể đã có trong CSS). Thêm aria-busy cho trạng thái loading nếu được sử dụng: ```typescript export function Button({ disabled, isLoading, ...props }) { return ( ); } ``` #### 4. Các component Form Cập nhật tất cả các form để sử dụng aria-describedby cho thông báo lỗi: - `app/(auth)/login/page.tsx` — Đã có role="alert" ✓ nhưng có thể dùng aria-describedby - `app/(auth)/register/page.tsx` — Tương tự - `components/listings/listing-form-steps.tsx` — Thêm aria-describedby - `components/search/filter-bar.tsx` — Đảm bảo nhãn dễ tiếp cận #### 5. Tất cả các nút chỉ có biểu tượng Tìm tất cả các nút chỉ có biểu tượng và thêm aria-label: ```typescript // Search in components for: ``` #### 6. Loading Spinners Thêm aria-busy và aria-label: ```typescript // In app/(public)/page.tsx and similar:
``` #### 7. `components/listings/image-gallery.tsx` Thêm điều hướng bằng bàn phím (phím mũi tên): ```typescript // Add keyboard event handler for arrow keys // Left/Right arrows to navigate images ``` --- ### GIAI ĐOẠN 6: CẬP NHẬT THIẾT LẬP TEST #### 1. `vitest.setup.ts` ```typescript // Mock next-intl for tests vi.mock('next-intl', () => ({ useTranslations: () => (key) => key, // Return key as-is for testing getTranslations: async () => (key) => key, })); // Or provide full mock messages const mockMessages = { common: { home: 'Home', search: 'Search' }, auth: { login: 'Login', register: 'Register' }, // ... etc }; vi.mock('next-intl', () => ({ useTranslations: (namespace) => (key) => mockMessages[namespace]?.[key] || key, })); ``` #### 2. `vitest.config.ts` Có thể cần thêm path alias hoặc thiết lập môi trường test. #### 3. Cập nhật tất cả các file test trong thư mục `__tests__/` - Thêm prop locale khi render component - Test cả tiếng Anh và tiếng Việt nếu có thể - Mock các bản dịch i18n --- ## 📊 Tóm tắt: Các file theo độ phức tạp cập nhật ### Đơn giản (5 phút mỗi file) - `app/robots.ts` - `app/sitemap.ts` - `components/ui/badge.tsx` - `components/ui/card.tsx` - `components/ui/tabs.tsx` ### Đơn giản (15-30 phút mỗi file) - Các file `app/(admin)/*.tsx` (3 file) - `app/(dashboard)/analytics/page.tsx` - `app/(dashboard)/profile/page.tsx` - `app/(dashboard)/subscription/page.tsx` - `app/(dashboard)/payments/page.tsx` - `components/ui/*.tsx` (8 file) - `components/auth/oauth-buttons.tsx` - `components/listings/listing-status-badge.tsx` ### Trung bình (30-60 phút mỗi file) - `app/(public)/layout.tsx` - `app/(auth)/login/page.tsx` - `app/(auth)/register/page.tsx` - `app/(dashboard)/layout.tsx` - `app/(dashboard)/dashboard/page.tsx` - `app/(public)/search/page.tsx` - `components/search/property-card.tsx` - `components/search/filter-bar.tsx` - `components/listings/image-upload.tsx` - `components/valuation/*.tsx` (3 file) ### Phức tạp (1-2 giờ mỗi file) - `app/(public)/page.tsx` (trang landing - nhiều phần) - `components/listings/listing-form-steps.tsx` (form đa bước) - `components/map/listing-map.tsx` (nếu có nhãn) - `components/charts/*.tsx` (3 file - nhãn biểu đồ) ### Hạ tầng quan trọng - `middleware.ts` (30-45 phút) - `app/layout.tsx` (30 phút) - `lib/validations/*.ts` (3 file - 45 phút) --- ## ✅ Checklist xác minh Trước khi xem xét i18n + A11y hoàn tất: ### Xác minh i18n - [ ] Cả route `/en/*` và `/vi/*` đều hoạt động - [ ] Tất cả văn bản từ file messages, không hardcode - [ ] Metadata thay đổi theo locale - [ ] Cookie/header hoạt động cho việc chọn locale - [ ] Thông báo validation sử dụng i18n - [ ] Tất cả các enum sử dụng bản dịch - [ ] Test mock i18n đúng cách ### Xác minh A11y - [ ] Focus trap hoạt động trong dialog - [ ] Chỉ báo focus hiển thị trên tất cả input - [ ] Lỗi form được liên kết với aria-describedby - [ ] Tất cả nút biểu tượng có aria-label - [ ] Tỷ lệ tương phản màu >= 4.5:1 cho văn bản (chuẩn AA) - [ ] Điều hướng bàn phím hoạt động ở mọi nơi - [ ] Test với screen reader (NVDA/JAWS) - [ ] Loading spinner có aria-busy - [ ] Tất cả các bảng có header phù hợp --- **Được tạo:** Ngày 9 tháng 4 năm 2026 **Độ tin cậy:** Cao **Tổng số file ước tính cần cập nhật:** 50-60 file