diff --git a/apps/web/app/[locale]/(public)/du-an/[slug]/page.tsx b/apps/web/app/[locale]/(public)/du-an/[slug]/page.tsx index 1a8c892..2830aea 100644 --- a/apps/web/app/[locale]/(public)/du-an/[slug]/page.tsx +++ b/apps/web/app/[locale]/(public)/du-an/[slug]/page.tsx @@ -2,12 +2,15 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { DuAnDetailClient } from '@/components/du-an/du-an-detail-client'; import { fetchProjectBySlug } from '@/lib/du-an-server'; +import { isResidentialProjectsEnabledServer } from '@/lib/hooks/use-residential-projects-flag'; interface PageProps { params: Promise<{ slug: string; locale: string }>; } export async function generateMetadata({ params }: PageProps): Promise { + if (!isResidentialProjectsEnabledServer()) return { title: 'Không tìm thấy dự án' }; + const { slug } = await params; const project = await fetchProjectBySlug(slug); if (!project) return { title: 'Không tìm thấy dự án' }; @@ -27,6 +30,10 @@ export async function generateMetadata({ params }: PageProps): Promise } export default async function DuAnDetailPage({ params }: PageProps) { + if (!isResidentialProjectsEnabledServer()) { + notFound(); + } + const { slug } = await params; const project = await fetchProjectBySlug(slug); diff --git a/apps/web/app/[locale]/(public)/du-an/__tests__/du-an.spec.tsx b/apps/web/app/[locale]/(public)/du-an/__tests__/du-an.spec.tsx new file mode 100644 index 0000000..5692c09 --- /dev/null +++ b/apps/web/app/[locale]/(public)/du-an/__tests__/du-an.spec.tsx @@ -0,0 +1,180 @@ +/* eslint-disable import-x/order */ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock next-intl +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'vi', +})); + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ + children, + href, + ...props + }: { + children: React.ReactNode; + href: string; + [key: string]: unknown; + }) => ( + + {children} + + ), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock('next/image', () => ({ + default: ({ src, alt, ...props }: { src: string; alt: string; [key: string]: unknown }) => ( + {alt})} /> + ), +})); + +vi.mock('next/dynamic', () => ({ + default: () => { + const Stub = () =>
; + Stub.displayName = 'DynamicStub'; + return Stub; + }, +})); + +const { notFoundMock } = vi.hoisted(() => ({ + notFoundMock: vi.fn(() => { + throw new Error('NEXT_NOT_FOUND'); + }), +})); + +vi.mock('next/navigation', () => ({ + notFound: notFoundMock, +})); + +vi.mock('@/lib/hooks/use-residential-projects-flag', () => ({ + useResidentialProjectsFlag: vi.fn(() => true), + isResidentialProjectsEnabledServer: vi.fn(() => true), +})); + +// Mock TanStack Query +const mockSearchData = { + data: [ + { + id: 'proj-1', + slug: 'vinhomes-grand-park', + name: 'Vinhomes Grand Park', + status: 'SELLING' as const, + developer: { id: 'dev-1', name: 'Vingroup', logoUrl: null, totalProjects: 10 }, + city: 'Hồ Chí Minh', + district: 'Quận 9', + address: '1 Nguyễn Xiển', + latitude: 10.84, + longitude: 106.84, + thumbnailUrl: '/img/project1.jpg', + totalArea: 271000, + totalUnits: 10000, + propertyTypes: ['APARTMENT' as const, 'VILLA' as const], + minPrice: '2000000000', + maxPrice: '5000000000', + completionDate: '2024-12-01', + createdAt: '2023-01-15', + }, + ], + total: 1, + page: 1, + limit: 12, + totalPages: 1, +}; + +vi.mock('@/lib/hooks/use-du-an', () => ({ + useProjectsSearch: vi.fn(() => ({ + data: mockSearchData, + isLoading: false, + isError: false, + })), + useProjectDetail: vi.fn(() => ({ + data: null, + isLoading: false, + })), + useProjectLinkedListings: vi.fn(() => ({ + data: null, + isLoading: false, + })), +})); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: vi.fn(), + QueryClient: vi.fn(), + QueryClientProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +import DuAnPage from '../page'; + +describe('DuAnPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the page header', () => { + render(); + expect(screen.getByText('Dự án bất động sản')).toBeDefined(); + }); + + it('renders project cards from search data', () => { + render(); + expect(screen.getByText('Vinhomes Grand Park')).toBeDefined(); + expect(screen.getByText('Quận 9, Hồ Chí Minh')).toBeDefined(); + }); + + it('renders view mode toggle buttons', () => { + render(); + expect(screen.getByLabelText('Xem dạng lưới')).toBeDefined(); + expect(screen.getByLabelText('Xem dạng danh sách')).toBeDefined(); + expect(screen.getByLabelText('Xem trên bản đồ')).toBeDefined(); + }); + + it('shows loading skeleton when isLoading', async () => { + const { useProjectsSearch } = await import('@/lib/hooks/use-du-an'); + vi.mocked(useProjectsSearch).mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + } as ReturnType); + + const { container } = render(); + const skeletons = container.querySelectorAll('.animate-pulse'); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('shows empty state when no results', async () => { + const { useProjectsSearch } = await import('@/lib/hooks/use-du-an'); + vi.mocked(useProjectsSearch).mockReturnValue({ + data: { data: [], total: 0, page: 1, limit: 12, totalPages: 0 }, + isLoading: false, + isError: false, + } as unknown as ReturnType); + + render(); + expect(screen.getByText('Không tìm thấy dự án')).toBeDefined(); + }); + + it('shows total results count', async () => { + const { useProjectsSearch } = await import('@/lib/hooks/use-du-an'); + vi.mocked(useProjectsSearch).mockReturnValue({ + data: mockSearchData, + isLoading: false, + isError: false, + } as ReturnType); + + render(); + expect(screen.getByText('1 dự án được tìm thấy')).toBeDefined(); + }); + + it('calls notFound when residential_projects flag is disabled', async () => { + const { useResidentialProjectsFlag } = await import( + '@/lib/hooks/use-residential-projects-flag' + ); + vi.mocked(useResidentialProjectsFlag).mockReturnValue(false); + + expect(() => render()).toThrow('NEXT_NOT_FOUND'); + expect(notFoundMock).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/[locale]/(public)/du-an/page.tsx b/apps/web/app/[locale]/(public)/du-an/page.tsx index 36d3773..8442928 100644 --- a/apps/web/app/[locale]/(public)/du-an/page.tsx +++ b/apps/web/app/[locale]/(public)/du-an/page.tsx @@ -3,6 +3,7 @@ import { Building2, LayoutGrid, List, Map, MapPin } from 'lucide-react'; import dynamic from 'next/dynamic'; import Image from 'next/image'; +import { notFound } from 'next/navigation'; import * as React from 'react'; import { ProjectCard } from '@/components/du-an/project-card'; import { ProjectFilterBar } from '@/components/du-an/project-filter-bar'; @@ -19,6 +20,7 @@ import { type SearchProjectsParams, } from '@/lib/du-an-api'; import { useProjectsSearch } from '@/lib/hooks/use-du-an'; +import { useResidentialProjectsFlag } from '@/lib/hooks/use-residential-projects-flag'; import { cn } from '@/lib/utils'; const ProjectMap = dynamic( @@ -31,6 +33,7 @@ const PAGE_SIZE = 12; type ViewMode = 'grid' | 'list' | 'map'; export default function DuAnPage() { + const flagEnabled = useResidentialProjectsFlag(); const [filters, setFilters] = React.useState({ page: 1, limit: PAGE_SIZE, @@ -39,6 +42,10 @@ export default function DuAnPage() { const { data, isLoading, isError } = useProjectsSearch(filters); + if (!flagEnabled) { + notFound(); + } + const handleFilterChange = (newFilters: SearchProjectsParams) => { setFilters({ ...newFilters, limit: PAGE_SIZE }); window.scrollTo({ top: 0, behavior: 'smooth' }); diff --git a/apps/web/lib/hooks/use-residential-projects-flag.ts b/apps/web/lib/hooks/use-residential-projects-flag.ts new file mode 100644 index 0000000..c2a6123 --- /dev/null +++ b/apps/web/lib/hooks/use-residential-projects-flag.ts @@ -0,0 +1,59 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +const LOCAL_STORAGE_KEY = 'goodgo:residential_projects'; +const QUERY_PARAM = 'residential_projects'; + +function readEnvDefault(): boolean { + const raw = process.env['NEXT_PUBLIC_FEATURE_RESIDENTIAL_PROJECTS']; + if (!raw) return false; + return raw === '1' || raw.toLowerCase() === 'true'; +} + +function readOverride(): boolean | null { + if (typeof window === 'undefined') return null; + + const params = new URLSearchParams(window.location.search); + const qp = params.get(QUERY_PARAM); + if (qp === '1' || qp === 'true') { + try { + window.localStorage.setItem(LOCAL_STORAGE_KEY, '1'); + } catch { + // localStorage may be blocked — ignore + } + return true; + } + if (qp === '0' || qp === 'false') { + try { + window.localStorage.setItem(LOCAL_STORAGE_KEY, '0'); + } catch { + // localStorage may be blocked — ignore + } + return false; + } + + try { + const stored = window.localStorage.getItem(LOCAL_STORAGE_KEY); + if (stored === '1') return true; + if (stored === '0') return false; + } catch { + // ignore + } + return null; +} + +export function useResidentialProjectsFlag(): boolean { + const [enabled, setEnabled] = useState(readEnvDefault()); + + useEffect(() => { + const override = readOverride(); + setEnabled(override ?? readEnvDefault()); + }, []); + + return enabled; +} + +export function isResidentialProjectsEnabledServer(): boolean { + return readEnvDefault(); +}