Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
V
VCCI-News
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Văn Hoàng
VCCI-News
Commits
2ffb9901
Commit
2ffb9901
authored
Aug 28, 2026
by
Văn Hoàng
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: SEO metadata cho trang bai viet - generateMetadata server-side, fallback anh tu HTML content
parent
998b966b
Changes
3
Show whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
262 additions
and
136 deletions
+262
-136
DynamicPageClient.tsx
src/app/(main)/[...slug]/DynamicPageClient.tsx
+146
-0
page.tsx
src/app/(main)/[...slug]/page.tsx
+80
-136
data.ts
src/app/(main)/[...slug]/templates/data.ts
+36
-0
No files found.
src/app/(main)/[...slug]/DynamicPageClient.tsx
0 → 100644
View file @
2ffb9901
"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
();
}
src/app/(main)/[...slug]/page.tsx
View file @
2ffb9901
"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 />;
}
src/app/(main)/[...slug]/templates/data.ts
View file @
2ffb9901
...
...
@@ -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
[^
>
]
*
\s
src=
[
"'
]([^
"'
]
+
)[
"'
]
/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
""
;
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment