Commit 2ffb9901 authored by Văn Hoàng's avatar Văn Hoàng

fix: SEO metadata cho trang bai viet - generateMetadata server-side, fallback anh tu HTML content

parent 998b966b
"use client";
import { useEffect, useMemo } from "react";
import { notFound, useParams, useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Spinner } from "@/components/ui";
import ArticlePage from "./templates/ArticlePage";
import ArticleDetailPage from "./templates/ArticleDetailPage";
import CatalogPage from "./templates/CatalogPage";
import InformationPage from "./templates/InformationPage";
import {
fetchDynamicCategories,
fetchDynamicPostById,
fetchDynamicPostByExternalLink,
fetchDynamicSinglePagePost,
findDynamicCategoryByPath,
findFirstChildCategory,
findMenuCategoryForPost,
} from "./templates/data";
export default function DynamicPageClient() {
const params = useParams();
const slug = Array.isArray(params.slug) ? params.slug : [params.slug];
const path = slug.join("/");
const routePath = `/${path}`;
const router = useRouter();
const searchParams = useSearchParams();
const postId = searchParams.get("id")?.trim() ?? "";
const preferredCategoryId = searchParams.get("categoryId")?.trim() ?? "";
const categoryQuery = useQuery({
queryKey: ["dynamic-categories"],
queryFn: fetchDynamicCategories,
staleTime: 5 * 60 * 1000,
});
const matchedCategory = useMemo(
() => findDynamicCategoryByPath(categoryQuery.data ?? [], routePath),
[categoryQuery.data, routePath],
);
const detailQuery = useQuery({
queryKey: ["dynamic-post-detail", postId || routePath],
queryFn: () =>
postId
? fetchDynamicPostById(postId)
: fetchDynamicPostByExternalLink(routePath),
enabled:
(Boolean(postId) || Boolean(routePath)) &&
!categoryQuery.isLoading &&
(Boolean(postId) || !matchedCategory),
staleTime: 60 * 1000,
});
const resolvedCategory = useMemo(
() =>
(preferredCategoryId
? categoryQuery.data?.find((item) => item.id === preferredCategoryId) ?? null
: null) ??
matchedCategory ??
findMenuCategoryForPost(detailQuery.data ?? null, categoryQuery.data ?? []),
[preferredCategoryId, matchedCategory, detailQuery.data, categoryQuery.data],
);
const singlePageQuery = useQuery({
queryKey: ["dynamic-single-page-post", resolvedCategory?.id],
queryFn: () => fetchDynamicSinglePagePost(resolvedCategory!.id),
enabled: resolvedCategory?.type === "page",
staleTime: 60 * 1000,
});
useEffect(() => {
if (!matchedCategory || matchedCategory.type !== "category") return;
const firstChild = findFirstChildCategory(matchedCategory, categoryQuery.data ?? []);
if (slug.length === 1 && firstChild?.url) {
router.replace(firstChild.url);
}
}, [matchedCategory, categoryQuery.data, router, slug.length]);
const isLoading =
categoryQuery.isLoading ||
detailQuery.isLoading ||
(resolvedCategory?.type === "page" && singlePageQuery.isLoading);
if (isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<Spinner />
</div>
);
}
if (detailQuery.data) {
return (
<ArticleDetailPage
post={detailQuery.data}
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
}
if (resolvedCategory?.type === "page") {
if (!singlePageQuery.data) return notFound();
return (
<InformationPage
post={singlePageQuery.data}
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
}
if (resolvedCategory?.type === "news") {
if (
resolvedCategory.slug === "an-pham" ||
resolvedCategory.slug === "thu-vien-tai-lieu"
) {
return (
<CatalogPage
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
}
return (
<ArticlePage
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
}
if (resolvedCategory?.type === "category") {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<Spinner />
</div>
);
}
return notFound();
}
"use client";
import { useEffect, useMemo } from "react";
import { notFound, useParams, useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Spinner } from "@/components/ui";
import ArticlePage from "./templates/ArticlePage";
import ArticleDetailPage from "./templates/ArticleDetailPage";
import CatalogPage from "./templates/CatalogPage";
import InformationPage from "./templates/InformationPage";
import type { Metadata } from "next";
import links from "@links/index";
import {
fetchDynamicCategories,
fetchDynamicPostById,
fetchDynamicPostByExternalLink,
fetchDynamicSinglePagePost,
findDynamicCategoryByPath,
findFirstChildCategory,
findMenuCategoryForPost,
getDynamicPostSeoImage,
getDynamicPostExcerpt,
stripHtml,
} from "./templates/data";
export default function DynamicPage() {
const params = useParams();
const slug = Array.isArray(params.slug) ? params.slug : [params.slug];
const path = slug.join("/");
const routePath = `/${path}`;
const router = useRouter();
const searchParams = useSearchParams();
const postId = searchParams.get("id")?.trim() ?? "";
const preferredCategoryId = searchParams.get("categoryId")?.trim() ?? "";
const categoryQuery = useQuery({
queryKey: ["dynamic-categories"],
queryFn: fetchDynamicCategories,
staleTime: 5 * 60 * 1000,
});
const matchedCategory = useMemo(
() => findDynamicCategoryByPath(categoryQuery.data ?? [], routePath),
[categoryQuery.data, routePath],
);
const detailQuery = useQuery({
queryKey: ["dynamic-post-detail", postId || routePath],
queryFn: () =>
postId
? fetchDynamicPostById(postId)
: fetchDynamicPostByExternalLink(routePath),
enabled:
(Boolean(postId) || Boolean(routePath)) &&
!categoryQuery.isLoading &&
(Boolean(postId) || !matchedCategory),
staleTime: 60 * 1000,
});
const resolvedCategory = useMemo(
() =>
(preferredCategoryId
? categoryQuery.data?.find((item) => item.id === preferredCategoryId) ?? null
: null) ??
matchedCategory ??
findMenuCategoryForPost(detailQuery.data ?? null, categoryQuery.data ?? []),
[preferredCategoryId, matchedCategory, detailQuery.data, categoryQuery.data],
);
const singlePageQuery = useQuery({
queryKey: ["dynamic-single-page-post", resolvedCategory?.id],
queryFn: () => fetchDynamicSinglePagePost(resolvedCategory!.id),
enabled: resolvedCategory?.type === "page",
staleTime: 60 * 1000,
});
useEffect(() => {
if (!matchedCategory || matchedCategory.type !== "category") return;
const firstChild = findFirstChildCategory(matchedCategory, categoryQuery.data ?? []);
if (slug.length === 1 && firstChild?.url) {
router.replace(firstChild.url);
import DynamicPageClient from "./DynamicPageClient";
type GenerateMetadataArgs = {
params: Promise<{ slug: string[] }>;
searchParams: Promise<{ id?: string; categoryId?: string }>;
};
export async function generateMetadata({
params,
searchParams,
}: GenerateMetadataArgs): Promise<Metadata> {
const { slug } = await params;
const { id, categoryId } = await searchParams;
const path = `/${(slug ?? []).join("/")}`;
const postId = id?.trim() ?? "";
const categoryIdParam = categoryId?.trim() ?? "";
let post = null;
try {
post = postId
? await fetchDynamicPostById(postId)
: await fetchDynamicPostByExternalLink(path);
} catch {
post = null;
}
}, [matchedCategory, categoryQuery.data, router, slug.length]);
const isLoading =
categoryQuery.isLoading ||
detailQuery.isLoading ||
(resolvedCategory?.type === "page" && singlePageQuery.isLoading);
if (isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<Spinner />
</div>
);
if (!post || !post.title) {
return {
title: "Bài viết không tìm thấy",
robots: { index: false, follow: false },
};
}
if (detailQuery.data) {
return (
<ArticleDetailPage
post={detailQuery.data}
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
}
if (resolvedCategory?.type === "page") {
if (!singlePageQuery.data) return notFound();
return (
<InformationPage
post={singlePageQuery.data}
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
}
if (resolvedCategory?.type === "news") {
if (
resolvedCategory.slug === "an-pham" ||
resolvedCategory.slug === "thu-vien-tai-lieu"
) {
return (
<CatalogPage
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
}
return (
<ArticlePage
category={resolvedCategory}
allCategories={categoryQuery.data ?? []}
/>
);
}
if (resolvedCategory?.type === "category") {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<Spinner />
</div>
);
}
const title = post.title;
const description =
stripHtml(post.summary) ||
getDynamicPostExcerpt(post).slice(0, 160) ||
"Tin tức từ VCCI HCM";
const imageUrl = getDynamicPostSeoImage(post);
const articleUrl = `${links.siteURL.replace(/\/+$/, "")}${path}${
postId || categoryIdParam
? `?${new URLSearchParams({
...(postId && { id: postId }),
...(categoryIdParam && { categoryId: categoryIdParam }),
}).toString()}`
: ""
}`;
return {
title,
description,
alternates: { canonical: articleUrl },
openGraph: {
title,
description,
url: articleUrl,
siteName: "VCCI HCM",
images: [
{
url: imageUrl,
width: 1200,
height: 630,
alt: title,
},
],
locale: "vi_VN",
type: "article",
},
twitter: {
card: "summary_large_image",
title,
description,
images: [imageUrl],
},
};
}
return notFound();
export default function Page() {
return <DynamicPageClient />;
}
......@@ -522,6 +522,42 @@ export function resolveDynamicPostImage(thumbnail?: DynamicPostThumbnail) {
return resolveUploadUrl(value);
}
export function extractFirstImageFromHtml(html?: string | null): string {
if (!html) return "";
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["']/i;
const match = html.match(imgRegex);
return match?.[1]?.trim() ?? "";
}
export function getDynamicPostSeoImage(post: DynamicPostItem | null): string {
if (!post) return "/thumbnail.png";
if (post.thumbnail) {
const resolved = resolveDynamicPostImage(post.thumbnail);
if (resolved && resolved !== "/thumbnail.png") return resolved;
}
const sections = post.content_structure?.post_content ?? [];
for (const section of sections) {
for (const item of section.images) {
const url = item.image?.url;
if (url) return url;
}
}
for (const section of sections) {
const htmlImage = extractFirstImageFromHtml(section.content);
if (htmlImage) return resolveUploadUrl(htmlImage);
}
const htmlImage = extractFirstImageFromHtml(post.content) || extractFirstImageFromHtml(post.summary);
if (htmlImage) return resolveUploadUrl(htmlImage);
return "/thumbnail.png";
}
export function stripHtml(value?: string | null) {
if (!value) return "";
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment