Hoàn tất đợt cuối của nhiệm vụ chuyển toàn bộ tài liệu sang tiếng Việt. Đã dịch 22 file `.md` còn sót (~9.7k dòng) — gồm RUNBOOK, audits, docs/architecture, docs/load-testing, libs READMEs và các quick references. Giữ nguyên code blocks, đường dẫn, identifier kỹ thuật, URL và biến môi trường. Co-Authored-By: Paperclip <noreply@paperclip.ing>
18 KiB
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:
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
NextIntlClientProvidertừ next-intl - Giữ nguyên các provider hiện có (ThemeProvider, QueryProvider, AuthProvider)
Các thay đổi chính:
import { getLocale, getTranslations } from 'next-intl/server';
export async function generateMetadata(): Promise<Metadata> {
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:
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:
{
"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
app/(public)/layout.tsx
// 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
}
app/(dashboard)/layout.tsx
// 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');
app/(auth)/layout.tsxCậ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
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
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.
}
app/(auth)/login/page.tsx
// 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
};
-
app/(auth)/register/page.tsxTương tự như trang login. -
app/(dashboard)/dashboard/page.tsxvà 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
-
app/(public)/search/page.tsxCập nhật tiêu đề kết quả tìm kiếm, trạng thái rỗng, nhãn bộ lọc. -
app/(public)/listings/[id]/page.tsxCập nhật nhãn chi tiết bất động sản. -
app/(dashboard)/listings/page.tsxCập nhật tiêu đề bảng, nhãn trạng thái, nhãn hành động. -
app/(dashboard)/listings/new/page.tsxSử 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)
components/search/filter-bar.tsx(ƯU TIÊN CAO)
// 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'), ... },
];
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').
components/auth/oauth-buttons.tsx
// Update button text
<Button>
{t('auth.google')} // Currently hardcoded "Google"
</Button>
<Button>
{t('auth.zalo')} // Currently hardcoded "Zalo"
</Button>
Các component ưu tiên trung bình
components/search/property-card.tsx
// 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)
})}
components/listings/listing-status-badge.tsx
// Update status labels
const LISTING_STATUSES = {
DRAFT: { label: t('status.draft'), variant: 'secondary' },
ACTIVE: { label: t('status.active'), variant: 'success' },
// ... etc
};
-
components/valuation/valuation-form.tsxCập nhật nhãn form và các nút. -
components/listings/image-upload.tsxCập nhật văn bản nút và thông báo lỗi. -
Tất cả các file
components/ui/*.tsxcó 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
// 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
// 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:
// 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<HTMLElement>(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:
export function Input({ error, ...props }) {
const errorId = `${props.id}-error`;
return (
<>
<input
{...props}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
/>
{error && <p id={errorId} role="alert">{error}</p>}
</>
);
}
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:
export function Button({ disabled, isLoading, ...props }) {
return (
<button
{...props}
disabled={disabled || isLoading}
aria-busy={isLoading}
>
{/* content */}
</button>
);
}
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-describedbyapp/(auth)/register/page.tsx— Tương tựcomponents/listings/listing-form-steps.tsx— Thêm aria-describedbycomponents/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:
// Search in components for: <Button> with only SVG children
// Add aria-label={t('...')}
// Examples:
<Button aria-label={t('common.close')}>
<X />
</Button>
<Button aria-label={t('common.toggle_dark_mode')}>
<Moon />
</Button>
6. Loading Spinners
Thêm aria-busy và aria-label:
// In app/(public)/page.tsx and similar:
<div aria-busy={loadingFeatured} aria-label={t('common.loading')}>
<div className="h-8 w-8 animate-spin rounded-full..." />
</div>
7. components/listings/image-gallery.tsx
Thêm điều hướng bằng bàn phím (phím mũi tên):
// 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
// 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.tsapp/sitemap.tscomponents/ui/badge.tsxcomponents/ui/card.tsxcomponents/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.tsxapp/(dashboard)/profile/page.tsxapp/(dashboard)/subscription/page.tsxapp/(dashboard)/payments/page.tsxcomponents/ui/*.tsx(8 file)components/auth/oauth-buttons.tsxcomponents/listings/listing-status-badge.tsx
Trung bình (30-60 phút mỗi file)
app/(public)/layout.tsxapp/(auth)/login/page.tsxapp/(auth)/register/page.tsxapp/(dashboard)/layout.tsxapp/(dashboard)/dashboard/page.tsxapp/(public)/search/page.tsxcomponents/search/property-card.tsxcomponents/search/filter-bar.tsxcomponents/listings/image-upload.tsxcomponents/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