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:
10
apps/web/app/(dashboard)/listings/[id]/layout.tsx
Normal file
10
apps/web/app/(dashboard)/listings/[id]/layout.tsx
Normal 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;
|
||||
}
|
||||
16
apps/web/app/(public)/search/layout.tsx
Normal file
16
apps/web/app/(public)/search/layout.tsx
Normal 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
62
apps/web/app/error.tsx
Normal 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, đã có lỗi xảy ra. Vui lòng thử lại.
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Mã 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>
|
||||
);
|
||||
}
|
||||
@@ -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
38
apps/web/app/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
apps/web/app/not-found.tsx
Normal file
31
apps/web/app/not-found.tsx
Normal 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
16
apps/web/app/robots.ts
Normal 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
32
apps/web/app/sitemap.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user