feat(web): add auth+search i18n translations and filter-bar accessibility
Add missing auth and search translation namespaces to vi.json and en.json
that are required by login/register pages and search filter-bar component.
Update filter-bar with useTranslations('search'), aria-labels, and
role="search" for WCAG 2.1 AA compliance.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -32,13 +33,15 @@ const CITIES = [
|
||||
'Bà Rịa - Vũng Tàu',
|
||||
];
|
||||
|
||||
const PRICE_RANGES = [
|
||||
{ label: 'Dưới 1 tỷ', min: '0', max: '1000000000' },
|
||||
{ label: '1 - 3 tỷ', min: '1000000000', max: '3000000000' },
|
||||
{ label: '3 - 5 tỷ', min: '3000000000', max: '5000000000' },
|
||||
{ label: '5 - 10 tỷ', min: '5000000000', max: '10000000000' },
|
||||
{ label: '10 - 20 tỷ', min: '10000000000', max: '20000000000' },
|
||||
{ label: 'Trên 20 tỷ', min: '20000000000', max: '' },
|
||||
const PRICE_RANGE_KEYS = ['under1b', '1to3b', '3to5b', '5to10b', '10to20b', 'over20b'] as const;
|
||||
|
||||
const PRICE_RANGE_VALUES = [
|
||||
{ min: '0', max: '1000000000' },
|
||||
{ min: '1000000000', max: '3000000000' },
|
||||
{ min: '3000000000', max: '5000000000' },
|
||||
{ min: '5000000000', max: '10000000000' },
|
||||
{ min: '10000000000', max: '20000000000' },
|
||||
{ min: '20000000000', max: '' },
|
||||
];
|
||||
|
||||
interface FilterBarProps {
|
||||
@@ -49,6 +52,8 @@ interface FilterBarProps {
|
||||
}
|
||||
|
||||
export function FilterBar({ filters, onChange, onSearch, layout = 'horizontal' }: FilterBarProps) {
|
||||
const t = useTranslations('search');
|
||||
|
||||
const update = (key: keyof SearchFilters, value: string) => {
|
||||
onChange({ ...filters, [key]: value });
|
||||
};
|
||||
@@ -58,32 +63,33 @@ export function FilterBar({ filters, onChange, onSearch, layout = 'horizontal' }
|
||||
onChange({ ...filters, minPrice: '', maxPrice: '' });
|
||||
return;
|
||||
}
|
||||
const range = PRICE_RANGES[Number(value)];
|
||||
const range = PRICE_RANGE_VALUES[Number(value)];
|
||||
if (range) {
|
||||
onChange({ ...filters, minPrice: range.min, maxPrice: range.max });
|
||||
}
|
||||
};
|
||||
|
||||
const currentPriceIdx = PRICE_RANGES.findIndex(
|
||||
const currentPriceIdx = PRICE_RANGE_VALUES.findIndex(
|
||||
(r) => r.min === filters.minPrice && r.max === filters.maxPrice,
|
||||
);
|
||||
|
||||
const isSidebar = layout === 'sidebar';
|
||||
|
||||
return (
|
||||
<div className={isSidebar ? 'space-y-4' : 'space-y-3'}>
|
||||
{isSidebar && <h3 className="font-semibold">Bộ lọc</h3>}
|
||||
<div className={isSidebar ? 'space-y-4' : 'space-y-3'} role="search" aria-label={t('filters')}>
|
||||
{isSidebar && <h3 className="font-semibold">{t('filters')}</h3>}
|
||||
|
||||
<div className={isSidebar ? 'space-y-3' : 'flex flex-wrap gap-3'}>
|
||||
<Select
|
||||
value={filters.transactionType}
|
||||
onChange={(e) => update('transactionType', e.target.value)}
|
||||
className={isSidebar ? 'w-full' : 'w-40'}
|
||||
aria-label={t('allTransactions')}
|
||||
>
|
||||
<option value="">Tất cả giao dịch</option>
|
||||
{TRANSACTION_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
<option value="">{t('allTransactions')}</option>
|
||||
{TRANSACTION_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -92,11 +98,12 @@ export function FilterBar({ filters, onChange, onSearch, layout = 'horizontal' }
|
||||
value={filters.propertyType}
|
||||
onChange={(e) => update('propertyType', e.target.value)}
|
||||
className={isSidebar ? 'w-full' : 'w-44'}
|
||||
aria-label={t('allPropertyTypes')}
|
||||
>
|
||||
<option value="">Tất cả loại BĐS</option>
|
||||
{PROPERTY_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
<option value="">{t('allPropertyTypes')}</option>
|
||||
{PROPERTY_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -105,8 +112,9 @@ export function FilterBar({ filters, onChange, onSearch, layout = 'horizontal' }
|
||||
value={filters.city}
|
||||
onChange={(e) => update('city', e.target.value)}
|
||||
className={isSidebar ? 'w-full' : 'w-44'}
|
||||
aria-label={t('allAreas')}
|
||||
>
|
||||
<option value="">Tất cả khu vực</option>
|
||||
<option value="">{t('allAreas')}</option>
|
||||
{CITIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
@@ -118,11 +126,12 @@ export function FilterBar({ filters, onChange, onSearch, layout = 'horizontal' }
|
||||
value={currentPriceIdx >= 0 ? String(currentPriceIdx) : ''}
|
||||
onChange={(e) => handlePriceRange(e.target.value)}
|
||||
className={isSidebar ? 'w-full' : 'w-40'}
|
||||
aria-label={t('allPrices')}
|
||||
>
|
||||
<option value="">Tất cả mức giá</option>
|
||||
{PRICE_RANGES.map((r, i) => (
|
||||
<option key={i} value={String(i)}>
|
||||
{r.label}
|
||||
<option value="">{t('allPrices')}</option>
|
||||
{PRICE_RANGE_KEYS.map((key, i) => (
|
||||
<option key={key} value={String(i)}>
|
||||
{t(`priceRanges.${key}`)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -130,21 +139,23 @@ export function FilterBar({ filters, onChange, onSearch, layout = 'horizontal' }
|
||||
{isSidebar && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-muted-foreground">Diện tích (m²)</label>
|
||||
<label className="mb-1 block text-sm text-muted-foreground">{t('areaLabel')}</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Từ"
|
||||
placeholder={t('areaFrom')}
|
||||
value={filters.minArea}
|
||||
onChange={(e) => update('minArea', e.target.value)}
|
||||
className="w-full"
|
||||
aria-label={`${t('areaLabel')} ${t('areaFrom')}`}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Đến"
|
||||
placeholder={t('areaTo')}
|
||||
value={filters.maxArea}
|
||||
onChange={(e) => update('maxArea', e.target.value)}
|
||||
className="w-full"
|
||||
aria-label={`${t('areaLabel')} ${t('areaTo')}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,20 +164,22 @@ export function FilterBar({ filters, onChange, onSearch, layout = 'horizontal' }
|
||||
value={filters.bedrooms}
|
||||
onChange={(e) => update('bedrooms', e.target.value)}
|
||||
className="w-full"
|
||||
aria-label={t('bedrooms')}
|
||||
>
|
||||
<option value="">Số phòng ngủ</option>
|
||||
<option value="">{t('bedrooms')}</option>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<option key={n} value={String(n)}>
|
||||
{n}+ PN
|
||||
{t('bedroomsCount', { count: n })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
placeholder="Quận/huyện"
|
||||
placeholder={t('district')}
|
||||
value={filters.district}
|
||||
onChange={(e) => update('district', e.target.value)}
|
||||
className="w-full"
|
||||
aria-label={t('district')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -176,11 +189,12 @@ export function FilterBar({ filters, onChange, onSearch, layout = 'horizontal' }
|
||||
value={filters.bedrooms}
|
||||
onChange={(e) => update('bedrooms', e.target.value)}
|
||||
className="w-36"
|
||||
aria-label={t('bedrooms')}
|
||||
>
|
||||
<option value="">Phòng ngủ</option>
|
||||
<option value="">{t('bedrooms')}</option>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<option key={n} value={String(n)}>
|
||||
{n}+ PN
|
||||
{t('bedroomsCount', { count: n })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -189,7 +203,7 @@ export function FilterBar({ filters, onChange, onSearch, layout = 'horizontal' }
|
||||
|
||||
{isSidebar && (
|
||||
<Button onClick={onSearch} className="w-full">
|
||||
Tìm kiếm
|
||||
{t('searchButton')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -106,5 +106,62 @@
|
||||
"label": "Language",
|
||||
"vi": "Tiếng Việt",
|
||||
"en": "English"
|
||||
},
|
||||
"auth": {
|
||||
"loginTitle": "Login",
|
||||
"loginDescription": "Enter your phone number and password to log in",
|
||||
"phone": "Phone number",
|
||||
"phonePlaceholder": "0912345678",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "Enter password",
|
||||
"showPassword": "Show",
|
||||
"hidePassword": "Hide",
|
||||
"loginButton": "Login",
|
||||
"orLoginWith": "Or login with",
|
||||
"noAccount": "Don't have an account?",
|
||||
"registerLink": "Register",
|
||||
"dismiss": "Dismiss",
|
||||
"registerTitle": "Register",
|
||||
"registerDescription": "Create a new account to start using GoodGo",
|
||||
"fullName": "Full name",
|
||||
"fullNamePlaceholder": "John Doe",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "email@example.com",
|
||||
"confirmPassword": "Confirm password",
|
||||
"confirmPasswordPlaceholder": "Re-enter password",
|
||||
"registerButton": "Register",
|
||||
"hasAccount": "Already have an account?",
|
||||
"loginLink": "Login",
|
||||
"orRegisterWith": "Or register with",
|
||||
"oauthErrors": {
|
||||
"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.",
|
||||
"temporarily_unavailable": "Service temporarily unavailable. Please try again later.",
|
||||
"default": "An error occurred during login. Please try again."
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"filters": "Filters",
|
||||
"allTransactions": "All transactions",
|
||||
"allPropertyTypes": "All property types",
|
||||
"allAreas": "All areas",
|
||||
"allPrices": "All prices",
|
||||
"bedrooms": "Bedrooms",
|
||||
"bedroomsCount": "{count}+ BR",
|
||||
"areaLabel": "Area (m²)",
|
||||
"areaFrom": "From",
|
||||
"areaTo": "To",
|
||||
"district": "District",
|
||||
"searchButton": "Search",
|
||||
"priceRanges": {
|
||||
"under1b": "Under 1 billion",
|
||||
"1to3b": "1 - 3 billion",
|
||||
"3to5b": "3 - 5 billion",
|
||||
"5to10b": "5 - 10 billion",
|
||||
"10to20b": "10 - 20 billion",
|
||||
"over20b": "Over 20 billion"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,5 +106,62 @@
|
||||
"label": "Ngôn ngữ",
|
||||
"vi": "Tiếng Việt",
|
||||
"en": "English"
|
||||
},
|
||||
"auth": {
|
||||
"loginTitle": "Đăng nhập",
|
||||
"loginDescription": "Nhập số điện thoại và mật khẩu để đăng nhập",
|
||||
"phone": "Số điện thoại",
|
||||
"phonePlaceholder": "0912345678",
|
||||
"password": "Mật khẩu",
|
||||
"passwordPlaceholder": "Nhập mật khẩu",
|
||||
"showPassword": "Hiện",
|
||||
"hidePassword": "Ẩn",
|
||||
"loginButton": "Đăng nhập",
|
||||
"orLoginWith": "Hoặc đăng nhập với",
|
||||
"noAccount": "Chưa có tài khoản?",
|
||||
"registerLink": "Đăng ký",
|
||||
"dismiss": "Bỏ qua",
|
||||
"registerTitle": "Đăng ký",
|
||||
"registerDescription": "Tạo tài khoản mới để bắt đầu sử dụng GoodGo",
|
||||
"fullName": "Họ và tên",
|
||||
"fullNamePlaceholder": "Nguyễn Văn A",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "email@example.com",
|
||||
"confirmPassword": "Xác nhận mật khẩu",
|
||||
"confirmPasswordPlaceholder": "Nhập lại mật khẩu",
|
||||
"registerButton": "Đăng ký",
|
||||
"hasAccount": "Đã có tài khoản?",
|
||||
"loginLink": "Đăng nhập",
|
||||
"orRegisterWith": "Hoặc đăng ký với",
|
||||
"oauthErrors": {
|
||||
"oauth_failed": "Đăng nhập bằng mạng xã hội thất bại. Vui lòng thử lại.",
|
||||
"access_denied": "Bạn đã từ chối quyền truy cập. Vui lòng thử lại.",
|
||||
"invalid_request": "Yêu cầu đăng nhập không hợp lệ. Vui lòng thử lại.",
|
||||
"server_error": "Lỗi máy chủ. Vui lòng thử lại sau.",
|
||||
"temporarily_unavailable": "Dịch vụ tạm thời không khả dụng. Vui lòng thử lại sau.",
|
||||
"default": "Đã xảy ra lỗi khi đăng nhập. Vui lòng thử lại."
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"filters": "Bộ lọc",
|
||||
"allTransactions": "Tất cả giao dịch",
|
||||
"allPropertyTypes": "Tất cả loại BĐS",
|
||||
"allAreas": "Tất cả khu vực",
|
||||
"allPrices": "Tất cả mức giá",
|
||||
"bedrooms": "Phòng ngủ",
|
||||
"bedroomsCount": "{count}+ PN",
|
||||
"areaLabel": "Diện tích (m²)",
|
||||
"areaFrom": "Từ",
|
||||
"areaTo": "Đến",
|
||||
"district": "Quận/huyện",
|
||||
"searchButton": "Tìm kiếm",
|
||||
"priceRanges": {
|
||||
"under1b": "Dưới 1 tỷ",
|
||||
"1to3b": "1 - 3 tỷ",
|
||||
"3to5b": "3 - 5 tỷ",
|
||||
"5to10b": "5 - 10 tỷ",
|
||||
"10to20b": "10 - 20 tỷ",
|
||||
"over20b": "Trên 20 tỷ"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user