From 862078df377c6597191ae5363109dee48f613366 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 9 Apr 2026 10:22:59 +0700 Subject: [PATCH] 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 --- apps/api/package.json | 1 + .../__tests__/user-banned.listener.spec.ts | 2 +- .../listeners/user-banned.listener.ts | 2 +- .../get-agent-dashboard.handler.spec.ts | 2 +- .../get-inquiries-by-agent.handler.spec.ts | 2 +- .../get-inquiries-by-listing.handler.spec.ts | 2 +- .../create-inquiry/create-inquiry.handler.ts | 3 +- .../mark-inquiry-read.handler.ts | 3 +- .../get-inquiries-by-agent.handler.ts | 3 +- .../domain/__tests__/inquiry-domain.spec.ts | 2 +- .../__tests__/get-lead-stats.handler.spec.ts | 2 +- .../get-leads-by-agent.handler.spec.ts | 2 +- .../__tests__/price-validator.spec.ts | 2 +- .../domain/services/moderation.service.ts | 3 +- .../services/prisma-price-validator.ts | 6 +- .../__tests__/listing-sold.listener.spec.ts | 11 +-- .../__tests__/review-deleted.listener.spec.ts | 23 ------ apps/web/components/search/filter-bar.tsx | 80 +++++++++++-------- apps/web/messages/en.json | 57 +++++++++++++ apps/web/messages/vi.json | 57 +++++++++++++ pnpm-lock.yaml | 31 +++++++ 21 files changed, 213 insertions(+), 83 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index a507840..d25acd3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -58,6 +58,7 @@ }, "devDependencies": { "@nestjs/cli": "^11.0.0", + "@nestjs/config": "^4.0.3", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.0", "@types/bcrypt": "^6.0.0", diff --git a/apps/api/src/modules/admin/application/__tests__/user-banned.listener.spec.ts b/apps/api/src/modules/admin/application/__tests__/user-banned.listener.spec.ts index 67a62e0..08ebe72 100644 --- a/apps/api/src/modules/admin/application/__tests__/user-banned.listener.spec.ts +++ b/apps/api/src/modules/admin/application/__tests__/user-banned.listener.spec.ts @@ -40,7 +40,7 @@ describe('UserBannedListener', () => { sellerId: 'user-1', status: { in: ['ACTIVE', 'PENDING_REVIEW', 'DRAFT'] }, }, - data: { status: 'CANCELLED' }, + data: { status: 'EXPIRED' }, }); }); diff --git a/apps/api/src/modules/admin/application/listeners/user-banned.listener.ts b/apps/api/src/modules/admin/application/listeners/user-banned.listener.ts index 5ff4485..f082e10 100644 --- a/apps/api/src/modules/admin/application/listeners/user-banned.listener.ts +++ b/apps/api/src/modules/admin/application/listeners/user-banned.listener.ts @@ -23,7 +23,7 @@ export class UserBannedListener { sellerId: event.aggregateId, status: { in: ['ACTIVE', 'PENDING_REVIEW', 'DRAFT'] }, }, - data: { status: 'CANCELLED' }, + data: { status: 'EXPIRED' }, }); this.logger.log( diff --git a/apps/api/src/modules/agents/application/__tests__/get-agent-dashboard.handler.spec.ts b/apps/api/src/modules/agents/application/__tests__/get-agent-dashboard.handler.spec.ts index da6f8ad..54efcdd 100644 --- a/apps/api/src/modules/agents/application/__tests__/get-agent-dashboard.handler.spec.ts +++ b/apps/api/src/modules/agents/application/__tests__/get-agent-dashboard.handler.spec.ts @@ -1,6 +1,6 @@ import type { IAgentRepository, AgentDashboardData } from '../../domain/repositories/agent.repository'; -import { GetAgentDashboardQuery } from '../queries/get-agent-dashboard/get-agent-dashboard.query'; import { GetAgentDashboardHandler } from '../queries/get-agent-dashboard/get-agent-dashboard.handler'; +import { GetAgentDashboardQuery } from '../queries/get-agent-dashboard/get-agent-dashboard.query'; describe('GetAgentDashboardHandler', () => { let handler: GetAgentDashboardHandler; diff --git a/apps/api/src/modules/inquiries/application/__tests__/get-inquiries-by-agent.handler.spec.ts b/apps/api/src/modules/inquiries/application/__tests__/get-inquiries-by-agent.handler.spec.ts index 4cf90fc..66c3f8b 100644 --- a/apps/api/src/modules/inquiries/application/__tests__/get-inquiries-by-agent.handler.spec.ts +++ b/apps/api/src/modules/inquiries/application/__tests__/get-inquiries-by-agent.handler.spec.ts @@ -1,6 +1,6 @@ import type { IInquiryRepository } from '../../domain/repositories/inquiry.repository'; -import { GetInquiriesByAgentQuery } from '../queries/get-inquiries-by-agent/get-inquiries-by-agent.query'; import { GetInquiriesByAgentHandler } from '../queries/get-inquiries-by-agent/get-inquiries-by-agent.handler'; +import { GetInquiriesByAgentQuery } from '../queries/get-inquiries-by-agent/get-inquiries-by-agent.query'; describe('GetInquiriesByAgentHandler', () => { let handler: GetInquiriesByAgentHandler; diff --git a/apps/api/src/modules/inquiries/application/__tests__/get-inquiries-by-listing.handler.spec.ts b/apps/api/src/modules/inquiries/application/__tests__/get-inquiries-by-listing.handler.spec.ts index 1f24d8b..f75790e 100644 --- a/apps/api/src/modules/inquiries/application/__tests__/get-inquiries-by-listing.handler.spec.ts +++ b/apps/api/src/modules/inquiries/application/__tests__/get-inquiries-by-listing.handler.spec.ts @@ -1,6 +1,6 @@ import type { IInquiryRepository } from '../../domain/repositories/inquiry.repository'; -import { GetInquiriesByListingQuery } from '../queries/get-inquiries-by-listing/get-inquiries-by-listing.query'; import { GetInquiriesByListingHandler } from '../queries/get-inquiries-by-listing/get-inquiries-by-listing.handler'; +import { GetInquiriesByListingQuery } from '../queries/get-inquiries-by-listing/get-inquiries-by-listing.query'; describe('GetInquiriesByListingHandler', () => { let handler: GetInquiriesByListingHandler; diff --git a/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts b/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts index cd0abaa..542c07e 100644 --- a/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts +++ b/apps/api/src/modules/inquiries/application/commands/create-inquiry/create-inquiry.handler.ts @@ -1,8 +1,7 @@ import { Inject, Logger } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { createId } from '@paralleldrive/cuid2'; -import { NotFoundException } from '@modules/shared'; -import { type PrismaService } from '@modules/shared'; +import { NotFoundException, type PrismaService } from '@modules/shared'; import { InquiryEntity } from '../../../domain/entities/inquiry.entity'; import { INQUIRY_REPOSITORY, type IInquiryRepository } from '../../../domain/repositories/inquiry.repository'; import { CreateInquiryCommand } from './create-inquiry.command'; diff --git a/apps/api/src/modules/inquiries/application/commands/mark-inquiry-read/mark-inquiry-read.handler.ts b/apps/api/src/modules/inquiries/application/commands/mark-inquiry-read/mark-inquiry-read.handler.ts index 1457cb1..dcfed00 100644 --- a/apps/api/src/modules/inquiries/application/commands/mark-inquiry-read/mark-inquiry-read.handler.ts +++ b/apps/api/src/modules/inquiries/application/commands/mark-inquiry-read/mark-inquiry-read.handler.ts @@ -1,7 +1,6 @@ import { Inject, Logger } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; -import { ForbiddenException, NotFoundException } from '@modules/shared'; -import { type PrismaService } from '@modules/shared'; +import { ForbiddenException, NotFoundException, type PrismaService } from '@modules/shared'; import { INQUIRY_REPOSITORY, type IInquiryRepository } from '../../../domain/repositories/inquiry.repository'; import { MarkInquiryReadCommand } from './mark-inquiry-read.command'; diff --git a/apps/api/src/modules/inquiries/application/queries/get-inquiries-by-agent/get-inquiries-by-agent.handler.ts b/apps/api/src/modules/inquiries/application/queries/get-inquiries-by-agent/get-inquiries-by-agent.handler.ts index b2447df..1edf7e6 100644 --- a/apps/api/src/modules/inquiries/application/queries/get-inquiries-by-agent/get-inquiries-by-agent.handler.ts +++ b/apps/api/src/modules/inquiries/application/queries/get-inquiries-by-agent/get-inquiries-by-agent.handler.ts @@ -1,7 +1,6 @@ import { Inject } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; -import { NotFoundException } from '@modules/shared'; -import { type PrismaService } from '@modules/shared'; +import { NotFoundException, type PrismaService } from '@modules/shared'; import { type InquiryReadDto } from '../../../domain/repositories/inquiry-read.dto'; import { INQUIRY_REPOSITORY, type IInquiryRepository, type PaginatedResult } from '../../../domain/repositories/inquiry.repository'; import { GetInquiriesByAgentQuery } from './get-inquiries-by-agent.query'; diff --git a/apps/api/src/modules/inquiries/domain/__tests__/inquiry-domain.spec.ts b/apps/api/src/modules/inquiries/domain/__tests__/inquiry-domain.spec.ts index 8c29cba..f36f9a1 100644 --- a/apps/api/src/modules/inquiries/domain/__tests__/inquiry-domain.spec.ts +++ b/apps/api/src/modules/inquiries/domain/__tests__/inquiry-domain.spec.ts @@ -1,6 +1,6 @@ +import { InquiryEntity } from '../entities/inquiry.entity'; import { InquiryCreatedEvent } from '../events/inquiry-created.event'; import { InquiryReadEvent } from '../events/inquiry-read.event'; -import { InquiryEntity } from '../entities/inquiry.entity'; describe('InquiryEntity', () => { describe('createNew', () => { diff --git a/apps/api/src/modules/leads/application/__tests__/get-lead-stats.handler.spec.ts b/apps/api/src/modules/leads/application/__tests__/get-lead-stats.handler.spec.ts index bedbc97..ed3b636 100644 --- a/apps/api/src/modules/leads/application/__tests__/get-lead-stats.handler.spec.ts +++ b/apps/api/src/modules/leads/application/__tests__/get-lead-stats.handler.spec.ts @@ -1,6 +1,6 @@ import type { ILeadRepository } from '../../domain/repositories/lead.repository'; -import { GetLeadStatsQuery } from '../queries/get-lead-stats/get-lead-stats.query'; import { GetLeadStatsHandler } from '../queries/get-lead-stats/get-lead-stats.handler'; +import { GetLeadStatsQuery } from '../queries/get-lead-stats/get-lead-stats.query'; describe('GetLeadStatsHandler', () => { let handler: GetLeadStatsHandler; diff --git a/apps/api/src/modules/leads/application/__tests__/get-leads-by-agent.handler.spec.ts b/apps/api/src/modules/leads/application/__tests__/get-leads-by-agent.handler.spec.ts index dde23e5..31acef9 100644 --- a/apps/api/src/modules/leads/application/__tests__/get-leads-by-agent.handler.spec.ts +++ b/apps/api/src/modules/leads/application/__tests__/get-leads-by-agent.handler.spec.ts @@ -1,6 +1,6 @@ import type { ILeadRepository } from '../../domain/repositories/lead.repository'; -import { GetLeadsByAgentQuery } from '../queries/get-leads-by-agent/get-leads-by-agent.query'; import { GetLeadsByAgentHandler } from '../queries/get-leads-by-agent/get-leads-by-agent.handler'; +import { GetLeadsByAgentQuery } from '../queries/get-leads-by-agent/get-leads-by-agent.query'; describe('GetLeadsByAgentHandler', () => { let handler: GetLeadsByAgentHandler; diff --git a/apps/api/src/modules/listings/application/__tests__/price-validator.spec.ts b/apps/api/src/modules/listings/application/__tests__/price-validator.spec.ts index 0eb385b..3563992 100644 --- a/apps/api/src/modules/listings/application/__tests__/price-validator.spec.ts +++ b/apps/api/src/modules/listings/application/__tests__/price-validator.spec.ts @@ -94,7 +94,7 @@ describe('PrismaPriceValidator', () => { const result = await validator.validate({ priceVND: 5_000_000_000n, areaM2: 80, - propertyType: 'HOUSE', + propertyType: 'TOWNHOUSE', district: 'Quận 1', }); diff --git a/apps/api/src/modules/listings/domain/services/moderation.service.ts b/apps/api/src/modules/listings/domain/services/moderation.service.ts index 338d2b7..d18b2f8 100644 --- a/apps/api/src/modules/listings/domain/services/moderation.service.ts +++ b/apps/api/src/modules/listings/domain/services/moderation.service.ts @@ -1,3 +1,4 @@ +import { type ListingStatus } from '@prisma/client'; import { type ListingEntity } from '../entities/listing.entity'; export interface ModerationAction { @@ -29,7 +30,7 @@ export class ModerationService { */ applyStatusTransition( listing: ListingEntity, - newStatus: string, + newStatus: ListingStatus, moderationNotes?: string, ): void { if (newStatus === 'REJECTED' && moderationNotes) { diff --git a/apps/api/src/modules/listings/infrastructure/services/prisma-price-validator.ts b/apps/api/src/modules/listings/infrastructure/services/prisma-price-validator.ts index 6dd436b..3ebe0cd 100644 --- a/apps/api/src/modules/listings/infrastructure/services/prisma-price-validator.ts +++ b/apps/api/src/modules/listings/infrastructure/services/prisma-price-validator.ts @@ -13,7 +13,7 @@ import { */ const DEFAULT_RANGES: Record = { APARTMENT: { min: 15_000_000, max: 200_000_000 }, - HOUSE: { min: 20_000_000, max: 500_000_000 }, + TOWNHOUSE: { min: 20_000_000, max: 500_000_000 }, VILLA: { min: 50_000_000, max: 1_000_000_000 }, LAND: { min: 5_000_000, max: 800_000_000 }, OFFICE: { min: 10_000_000, max: 300_000_000 }, @@ -108,8 +108,8 @@ export class PrismaPriceValidator implements IPriceValidator { AND l."createdAt" > NOW() - INTERVAL '6 months' `; - if (rows.length > 0 && rows[0].min_price && rows[0].max_price) { - return { min: rows[0].min_price, max: rows[0].max_price }; + if (rows.length > 0 && rows[0]!.min_price && rows[0]!.max_price) { + return { min: rows[0]!.min_price, max: rows[0]!.max_price }; } return null; } catch (err) { diff --git a/apps/api/src/modules/notifications/application/__tests__/listing-sold.listener.spec.ts b/apps/api/src/modules/notifications/application/__tests__/listing-sold.listener.spec.ts index ad2854c..339f6c8 100644 --- a/apps/api/src/modules/notifications/application/__tests__/listing-sold.listener.spec.ts +++ b/apps/api/src/modules/notifications/application/__tests__/listing-sold.listener.spec.ts @@ -5,7 +5,6 @@ describe('ListingSoldListener', () => { let mockCommandBus: { execute: ReturnType }; let mockPrisma: { listing: { findUnique: ReturnType }; - savedListing: { findMany: ReturnType }; }; let mockLogger: { log: ReturnType; warn: ReturnType }; @@ -13,7 +12,6 @@ describe('ListingSoldListener', () => { mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) }; mockPrisma = { listing: { findUnique: vi.fn() }, - savedListing: { findMany: vi.fn().mockResolvedValue([]) }, }; mockLogger = { log: vi.fn(), warn: vi.fn() }; @@ -48,15 +46,12 @@ describe('ListingSoldListener', () => { ); }); - it('notifies watchers when listing is sold', async () => { + it('notifies only seller when listing is sold (no watcher support)', async () => { mockPrisma.listing.findUnique.mockResolvedValue({ id: 'listing-1', property: { title: 'Căn hộ đẹp' }, seller: { id: 'seller-1', email: 'seller@example.com' }, }); - mockPrisma.savedListing.findMany.mockResolvedValue([ - { user: { id: 'watcher-1', email: 'watcher@example.com' } }, - ]); await listener.handle({ aggregateId: 'listing-1', @@ -66,8 +61,8 @@ describe('ListingSoldListener', () => { occurredAt: new Date(), }); - // Seller + 1 watcher = 2 notifications - expect(mockCommandBus.execute).toHaveBeenCalledTimes(2); + // Only seller notification (savedListing model removed) + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); }); it('skips notification when listing not found', async () => { diff --git a/apps/api/src/modules/reviews/application/__tests__/review-deleted.listener.spec.ts b/apps/api/src/modules/reviews/application/__tests__/review-deleted.listener.spec.ts index 072b42d..ca6f2b9 100644 --- a/apps/api/src/modules/reviews/application/__tests__/review-deleted.listener.spec.ts +++ b/apps/api/src/modules/reviews/application/__tests__/review-deleted.listener.spec.ts @@ -5,7 +5,6 @@ describe('ReviewDeletedListener', () => { let mockPrisma: { review: { aggregate: ReturnType }; agent: { update: ReturnType }; - listing: { update: ReturnType }; }; let mockLogger: { log: ReturnType; warn: ReturnType }; @@ -13,7 +12,6 @@ describe('ReviewDeletedListener', () => { mockPrisma = { review: { aggregate: vi.fn() }, agent: { update: vi.fn().mockResolvedValue(undefined) }, - listing: { update: vi.fn().mockResolvedValue(undefined) }, }; mockLogger = { log: vi.fn(), warn: vi.fn() }; @@ -41,27 +39,6 @@ describe('ReviewDeletedListener', () => { }); }); - it('recalculates listing average rating on review deletion', async () => { - mockPrisma.review.aggregate.mockResolvedValue({ - _avg: { rating: 3.5 }, - _count: { rating: 10 }, - }); - - await listener.handle({ - aggregateId: 'review-2', - userId: 'user-2', - targetType: 'LISTING', - targetId: 'listing-1', - eventName: 'review.deleted', - occurredAt: new Date(), - }); - - expect(mockPrisma.listing.update).toHaveBeenCalledWith({ - where: { id: 'listing-1' }, - data: { averageRating: 3.5, reviewCount: 10 }, - }); - }); - it('handles zero reviews after deletion', async () => { mockPrisma.review.aggregate.mockResolvedValue({ _avg: { rating: null }, diff --git a/apps/web/components/search/filter-bar.tsx b/apps/web/components/search/filter-bar.tsx index 0e9ede2..da63610 100644 --- a/apps/web/components/search/filter-bar.tsx +++ b/apps/web/components/search/filter-bar.tsx @@ -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 ( -
- {isSidebar &&

Bộ lọc

} +
+ {isSidebar &&

{t('filters')}

}
@@ -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')} > - - {PROPERTY_TYPES.map((t) => ( - + {PROPERTY_TYPES.map((type) => ( + ))} @@ -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')} > - + {CITIES.map((c) => ( - {PRICE_RANGES.map((r, i) => ( - + {PRICE_RANGE_KEYS.map((key, i) => ( + ))} @@ -130,21 +139,23 @@ export function FilterBar({ filters, onChange, onSearch, layout = 'horizontal' } {isSidebar && ( <>
- +
update('minArea', e.target.value)} className="w-full" + aria-label={`${t('areaLabel')} ${t('areaFrom')}`} /> update('maxArea', e.target.value)} className="w-full" + aria-label={`${t('areaLabel')} ${t('areaTo')}`} />
@@ -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')} > - + {[1, 2, 3, 4, 5].map((n) => ( ))} 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')} > - + {[1, 2, 3, 4, 5].map((n) => ( ))} @@ -189,7 +203,7 @@ export function FilterBar({ filters, onChange, onSearch, layout = 'horizontal' } {isSidebar && ( )}
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 1bfb1b3..c5676ae 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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" + } } } diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index 6e1e77a..322f0db 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -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ỷ" + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03d6245..2061a1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,9 @@ importers: '@nestjs/cli': specifier: ^11.0.0 version: 11.0.18(@swc/core@1.15.24)(@types/node@25.5.2) + '@nestjs/config': + specifier: ^4.0.3 + version: 4.0.3(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/schematics': specifier: ^11.0.0 version: 11.0.10(chokidar@4.0.3)(typescript@6.0.2) @@ -1309,6 +1312,12 @@ packages: class-validator: optional: true + '@nestjs/config@4.0.3': + resolution: {integrity: sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + '@nestjs/core@11.1.18': resolution: {integrity: sha512-wR3DtGyk/LUAiPtbXDuWJJwVkWElKBY0sqnTzf9d4uM3+X18FRZhK7WFc47czsIGOdWuRsMeLYV+1Z9dO4zDEQ==} engines: {node: '>= 20'} @@ -3873,10 +3882,18 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + dotenv@17.4.1: resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} engines: {node: '>=12'} @@ -7815,6 +7832,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/config@4.0.3(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.2.3 + dotenv-expand: 12.0.3 + lodash: 4.17.23 + rxjs: 7.8.2 + '@nestjs/core@11.1.18(@nestjs/common@11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.18)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -10531,8 +10556,14 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + dotenv@16.6.1: {} + dotenv@17.2.3: {} + dotenv@17.4.1: {} dunder-proto@1.0.1: