From 325cd4c421b33ad3ca885c8e95277ac54da91de7 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 04:06:14 +0700 Subject: [PATCH] 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 --- .../app/(dashboard)/listings/[id]/layout.tsx | 10 +++ apps/web/app/(public)/search/layout.tsx | 16 +++++ apps/web/app/error.tsx | 62 +++++++++++++++++ apps/web/app/layout.tsx | 67 ++++++++++++++++++- apps/web/app/loading.tsx | 38 +++++++++++ apps/web/app/not-found.tsx | 31 +++++++++ apps/web/app/robots.ts | 16 +++++ apps/web/app/sitemap.ts | 32 +++++++++ 8 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/(dashboard)/listings/[id]/layout.tsx create mode 100644 apps/web/app/(public)/search/layout.tsx create mode 100644 apps/web/app/error.tsx create mode 100644 apps/web/app/loading.tsx create mode 100644 apps/web/app/not-found.tsx create mode 100644 apps/web/app/robots.ts create mode 100644 apps/web/app/sitemap.ts diff --git a/apps/web/app/(dashboard)/listings/[id]/layout.tsx b/apps/web/app/(dashboard)/listings/[id]/layout.tsx new file mode 100644 index 0000000..619e616 --- /dev/null +++ b/apps/web/app/(dashboard)/listings/[id]/layout.tsx @@ -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; +} diff --git a/apps/web/app/(public)/search/layout.tsx b/apps/web/app/(public)/search/layout.tsx new file mode 100644 index 0000000..41de070 --- /dev/null +++ b/apps/web/app/(public)/search/layout.tsx @@ -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; +} diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx new file mode 100644 index 0000000..82c322d --- /dev/null +++ b/apps/web/app/error.tsx @@ -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 ( +
+
+
+ + + +
+

+ Đã xảy ra lỗi +

+

+ Rất tiếc, đã có lỗi xảy ra. Vui lòng thử lại. +

+ {error.digest && ( +

+ Mã lỗi: {error.digest} +

+ )} +
+ + + Về trang chủ + +
+
+
+ ); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 855fae1..44ce3b0 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -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 }) { diff --git a/apps/web/app/loading.tsx b/apps/web/app/loading.tsx new file mode 100644 index 0000000..db7c7a5 --- /dev/null +++ b/apps/web/app/loading.tsx @@ -0,0 +1,38 @@ +export default function RootLoading() { + return ( +
+ {/* Header skeleton */} +
+
+
+
+
+
+
+
+
+ + {/* Content skeleton */} +
+
+
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx new file mode 100644 index 0000000..f717858 --- /dev/null +++ b/apps/web/app/not-found.tsx @@ -0,0 +1,31 @@ +import Link from 'next/link'; + +export default function NotFound() { + return ( +
+
+
404
+

+ Không tìm thấy trang +

+

+ Trang bạn đang tìm không tồn tại hoặc đã được di chuyển. +

+
+ + Về trang chủ + + + Tìm kiếm + +
+
+
+ ); +} diff --git a/apps/web/app/robots.ts b/apps/web/app/robots.ts new file mode 100644 index 0000000..6e798b2 --- /dev/null +++ b/apps/web/app/robots.ts @@ -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`, + }; +} diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts new file mode 100644 index 0000000..f325785 --- /dev/null +++ b/apps/web/app/sitemap.ts @@ -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, + }, + ]; +}