feat(web): add error boundaries, 404 page, loading states, and SEO metadata

- Add branded not-found.tsx with navigation links
- Add global error.tsx boundary with retry and error digest display
- Add root loading.tsx skeleton for route transitions
- Expand root layout metadata: OpenGraph, Twitter cards, robots, viewport
- Add sitemap.ts and robots.ts for SEO
- Add search page and listing detail metadata via route layouts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 04:06:14 +07:00
parent 7e64e32d8f
commit 325cd4c421
8 changed files with 269 additions and 3 deletions

View File

@@ -0,0 +1,10 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Chi tiết tin đăng',
description: 'Xem chi tiết bất động sản trên GoodGo.',
};
export default function ListingDetailLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Tìm kiếm bất động sản',
description:
'Tìm kiếm mua bán, cho thuê bất động sản trên toàn quốc — căn hộ, nhà phố, biệt thự, đất nền với bộ lọc thông minh.',
openGraph: {
title: 'Tìm kiếm bất động sản | GoodGo',
description:
'Tìm kiếm mua bán, cho thuê bất động sản trên toàn quốc với GoodGo.',
},
};
export default function SearchLayout({ children }: { children: React.ReactNode }) {
return children;
}

62
apps/web/app/error.tsx Normal file
View File

@@ -0,0 +1,62 @@
'use client';
import { useEffect } from 'react';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('Unhandled error:', error);
}, [error]);
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4">
<div className="mx-auto max-w-md text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
<svg
className="h-8 w-8 text-destructive"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<h1 className="mt-4 text-2xl font-bold tracking-tight">
Đã xảy ra lỗi
</h1>
<p className="mt-2 text-muted-foreground">
Rất tiếc, đã lỗi xảy ra. Vui lòng thử lại.
</p>
{error.digest && (
<p className="mt-1 text-xs text-muted-foreground">
lỗi: {error.digest}
</p>
)}
<div className="mt-8 flex justify-center gap-3">
<button
onClick={reset}
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
>
Thử lại
</button>
<a
href="/"
className="inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-6 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
Về trang chủ
</a>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,71 @@
import type { Metadata } from 'next';
import type { Metadata, Viewport } from 'next';
import { AuthProvider } from '@/components/providers/auth-provider';
import './globals.css';
const siteUrl = process.env['NEXT_PUBLIC_SITE_URL'] || 'https://goodgo.vn';
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
themeColor: '#15803d',
};
export const metadata: Metadata = {
title: 'GoodGo Platform',
description: 'Vietnam Real Estate Platform',
metadataBase: new URL(siteUrl),
title: {
default: 'GoodGo \u2014 N\u1ec1n t\u1ea3ng B\u1ea5t \u0111\u1ed9ng s\u1ea3n Vi\u1ec7t Nam',
template: '%s | GoodGo',
},
description:
'GoodGo \u2014 n\u1ec1n t\u1ea3ng b\u1ea5t \u0111\u1ed9ng s\u1ea3n th\u00f4ng minh t\u1ea1i Vi\u1ec7t Nam. Mua b\u00e1n, cho thu\u00ea nh\u00e0 \u0111\u1ea5t d\u1ec5 d\u00e0ng v\u1edbi h\u01a1n 10,000+ tin \u0111\u0103ng tr\u00ean to\u00e0n qu\u1ed1c.',
keywords: [
'b\u1ea5t \u0111\u1ed9ng s\u1ea3n',
'mua b\u00e1n nh\u00e0 \u0111\u1ea5t',
'cho thu\u00ea nh\u00e0',
'goodgo',
'nh\u00e0 \u0111\u1ea5t vi\u1ec7t nam',
'chung c\u01b0',
'bi\u1ec7t th\u1ef1',
'nh\u00e0 ph\u1ed1',
'\u0111\u1ea5t n\u1ec1n',
],
authors: [{ name: 'GoodGo' }],
creator: 'GoodGo',
openGraph: {
type: 'website',
locale: 'vi_VN',
url: siteUrl,
siteName: 'GoodGo',
title: 'GoodGo \u2014 N\u1ec1n t\u1ea3ng B\u1ea5t \u0111\u1ed9ng s\u1ea3n Vi\u1ec7t Nam',
description:
'Mua b\u00e1n, cho thu\u00ea b\u1ea5t \u0111\u1ed9ng s\u1ea3n d\u1ec5 d\u00e0ng v\u1edbi GoodGo \u2014 n\u1ec1n t\u1ea3ng th\u00f4ng minh, uy t\u00edn h\u00e0ng \u0111\u1ea7u Vi\u1ec7t Nam.',
images: [
{
url: '/og-image.png',
width: 1200,
height: 630,
alt: 'GoodGo \u2014 N\u1ec1n t\u1ea3ng B\u1ea5t \u0111\u1ed9ng s\u1ea3n Vi\u1ec7t Nam',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'GoodGo \u2014 N\u1ec1n t\u1ea3ng B\u1ea5t \u0111\u1ed9ng s\u1ea3n Vi\u1ec7t Nam',
description:
'Mua b\u00e1n, cho thu\u00ea b\u1ea5t \u0111\u1ed9ng s\u1ea3n d\u1ec5 d\u00e0ng v\u1edbi GoodGo.',
images: ['/og-image.png'],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {

38
apps/web/app/loading.tsx Normal file
View File

@@ -0,0 +1,38 @@
export default function RootLoading() {
return (
<div className="flex min-h-screen flex-col bg-background">
{/* Header skeleton */}
<div className="border-b">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
<div className="h-8 w-32 animate-pulse rounded bg-muted" />
<div className="flex gap-3">
<div className="h-8 w-20 animate-pulse rounded bg-muted" />
<div className="h-8 w-20 animate-pulse rounded bg-muted" />
</div>
</div>
</div>
{/* Content skeleton */}
<div className="mx-auto w-full max-w-7xl flex-1 px-4 py-8">
<div className="h-8 w-64 animate-pulse rounded bg-muted" />
<div className="mt-2 h-4 w-96 animate-pulse rounded bg-muted" />
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-lg border bg-card shadow-sm">
<div className="aspect-[16/10] animate-pulse rounded-t-lg bg-muted" />
<div className="p-4">
<div className="h-5 w-3/4 animate-pulse rounded bg-muted" />
<div className="mt-2 h-4 w-1/2 animate-pulse rounded bg-muted" />
<div className="mt-3 flex gap-2">
<div className="h-6 w-16 animate-pulse rounded bg-muted" />
<div className="h-6 w-16 animate-pulse rounded bg-muted" />
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4">
<div className="mx-auto max-w-md text-center">
<div className="text-8xl font-bold text-primary/20">404</div>
<h1 className="mt-4 text-2xl font-bold tracking-tight">
Không tìm thấy trang
</h1>
<p className="mt-2 text-muted-foreground">
Trang bạn đang tìm không tồn tại hoặc đã đưc di chuyển.
</p>
<div className="mt-8 flex justify-center gap-3">
<Link
href="/"
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
>
Về trang chủ
</Link>
<Link
href="/search"
className="inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-6 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
Tìm kiếm
</Link>
</div>
</div>
</div>
);
}

16
apps/web/app/robots.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const siteUrl = process.env['NEXT_PUBLIC_SITE_URL'] || 'https://goodgo.vn';
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/dashboard/', '/admin/', '/auth/', '/api/'],
},
],
sitemap: `${siteUrl}/sitemap.xml`,
};
}

32
apps/web/app/sitemap.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap {
const siteUrl = process.env['NEXT_PUBLIC_SITE_URL'] || 'https://goodgo.vn';
return [
{
url: siteUrl,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url: `${siteUrl}/search`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.9,
},
{
url: `${siteUrl}/login`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.3,
},
{
url: `${siteUrl}/register`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.3,
},
];
}