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 type { Metadata } from "next";
import links from "@links/index";
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 { import {
fetchDynamicCategories,
fetchDynamicPostById, fetchDynamicPostById,
fetchDynamicPostByExternalLink, fetchDynamicPostByExternalLink,
fetchDynamicSinglePagePost, getDynamicPostSeoImage,
findDynamicCategoryByPath, getDynamicPostExcerpt,
findFirstChildCategory, stripHtml,
findMenuCategoryForPost,
} from "./templates/data"; } from "./templates/data";
import DynamicPageClient from "./DynamicPageClient";
export default function DynamicPage() {
const params = useParams(); type GenerateMetadataArgs = {
const slug = Array.isArray(params.slug) ? params.slug : [params.slug]; params: Promise<{ slug: string[] }>;
const path = slug.join("/"); searchParams: Promise<{ id?: string; categoryId?: string }>;
const routePath = `/${path}`; };
const router = useRouter();
const searchParams = useSearchParams(); export async function generateMetadata({
const postId = searchParams.get("id")?.trim() ?? ""; params,
const preferredCategoryId = searchParams.get("categoryId")?.trim() ?? ""; searchParams,
}: GenerateMetadataArgs): Promise<Metadata> {
const categoryQuery = useQuery({ const { slug } = await params;
queryKey: ["dynamic-categories"], const { id, categoryId } = await searchParams;
queryFn: fetchDynamicCategories,
staleTime: 5 * 60 * 1000, const path = `/${(slug ?? []).join("/")}`;
}); const postId = id?.trim() ?? "";
const categoryIdParam = categoryId?.trim() ?? "";
const matchedCategory = useMemo(
() => findDynamicCategoryByPath(categoryQuery.data ?? [], routePath), let post = null;
[categoryQuery.data, routePath], try {
); post = postId
? await fetchDynamicPostById(postId)
const detailQuery = useQuery({ : await fetchDynamicPostByExternalLink(path);
queryKey: ["dynamic-post-detail", postId || routePath], } catch {
queryFn: () => post = null;
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 = if (!post || !post.title) {
categoryQuery.isLoading || return {
detailQuery.isLoading || title: "Bài viết không tìm thấy",
(resolvedCategory?.type === "page" && singlePageQuery.isLoading); robots: { index: false, follow: false },
};
if (isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<Spinner />
</div>
);
} }
if (detailQuery.data) { const title = post.title;
return ( const description =
<ArticleDetailPage stripHtml(post.summary) ||
post={detailQuery.data} getDynamicPostExcerpt(post).slice(0, 160) ||
category={resolvedCategory} "Tin tức từ VCCI HCM";
allCategories={categoryQuery.data ?? []} const imageUrl = getDynamicPostSeoImage(post);
/> const articleUrl = `${links.siteURL.replace(/\/+$/, "")}${path}${
); postId || categoryIdParam
} ? `?${new URLSearchParams({
...(postId && { id: postId }),
if (resolvedCategory?.type === "page") { ...(categoryIdParam && { categoryId: categoryIdParam }),
if (!singlePageQuery.data) return notFound(); }).toString()}`
: ""
return ( }`;
<InformationPage
post={singlePageQuery.data} return {
category={resolvedCategory} title,
allCategories={categoryQuery.data ?? []} description,
/> alternates: { canonical: articleUrl },
); openGraph: {
} title,
description,
if (resolvedCategory?.type === "news") { url: articleUrl,
if ( siteName: "VCCI HCM",
resolvedCategory.slug === "an-pham" || images: [
resolvedCategory.slug === "thu-vien-tai-lieu" {
) { url: imageUrl,
return ( width: 1200,
<CatalogPage height: 630,
category={resolvedCategory} alt: title,
allCategories={categoryQuery.data ?? []} },
/> ],
); locale: "vi_VN",
} type: "article",
},
return ( twitter: {
<ArticlePage card: "summary_large_image",
category={resolvedCategory} title,
allCategories={categoryQuery.data ?? []} description,
/> images: [imageUrl],
); },
} };
}
if (resolvedCategory?.type === "category") {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<Spinner />
</div>
);
}
return notFound(); export default function Page() {
return <DynamicPageClient />;
} }
...@@ -522,6 +522,42 @@ export function resolveDynamicPostImage(thumbnail?: DynamicPostThumbnail) { ...@@ -522,6 +522,42 @@ export function resolveDynamicPostImage(thumbnail?: DynamicPostThumbnail) {
return resolveUploadUrl(value); 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) { export function stripHtml(value?: string | null) {
if (!value) return ""; 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