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>
|
||||
|
||||
Reference in New Issue
Block a user