Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 18s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 2m15s
Deploy / Build API Image (push) Failing after 28s
Deploy / Build Web Image (push) Failing after 16s
Deploy / Build AI Services Image (push) Failing after 17s
E2E Tests / Playwright E2E (push) Failing after 31s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m46s
Security Scanning / Trivy Scan — Web Image (push) Failing after 1m7s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 53s
Security Scanning / Trivy Filesystem Scan (push) Failing after 35s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 0s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
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>
649 lines
18 KiB
Markdown
649 lines
18 KiB
Markdown
# 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<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:
|
|
```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
|
|
<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
|
|
|
|
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<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:
|
|
```typescript
|
|
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:
|
|
```typescript
|
|
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-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: <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:
|
|
```typescript
|
|
// 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):
|
|
```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
|