From 693821a9aadc1f53a2415b7745a919a8b60fbf60 Mon Sep 17 00:00:00 2001 From: nguyenvanbao Date: Wed, 4 Feb 2026 11:52:19 +0700 Subject: [PATCH 1/4] fix: refactor services page styling and improve component logic --- app/services/page.tsx | 10 +++----- app/services/services.css | 53 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 app/services/services.css diff --git a/app/services/page.tsx b/app/services/page.tsx index 1d1a476..944ebed 100644 --- a/app/services/page.tsx +++ b/app/services/page.tsx @@ -2,11 +2,7 @@ import { Metadata } from "next"; import { fetchServicePageData } from "../../api/servicesApi"; import { imageUrl } from "../utils/image"; import Breadcrumb from "../components/Breadcrumb"; - -export const metadata: Metadata = { - title: "Services - Visaway Immigration & Visa Consulting", - description: "Immigration & Visa Consulting Services", -}; +import "./services.css"; export default async function ServicesPage() { const data = await fetchServicePageData(); @@ -77,7 +73,7 @@ export default async function ServicesPage() {
{services.items.map((service: any) => (
{service.layout === "right" && ( @@ -203,7 +199,7 @@ export default async function ServicesPage() { {reviews.title.mainTitle}
- + View All Review diff --git a/app/services/services.css b/app/services/services.css new file mode 100644 index 0000000..c386373 --- /dev/null +++ b/app/services/services.css @@ -0,0 +1,53 @@ +/* Custom CSS for Service Image Sizing */ +.service-main-item-3 .service-left .service-image { + flex-shrink: 0; + width: 352px; + height: 216px; + overflow: hidden; + border-radius: 16px; +} + +.service-main-item-3 .service-left .service-image img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + border-radius: 16px; +} +/* +.service-main-item-3 .service-left .content { + flex: 1; + min-width: 0; +} + +.service-main-item-3 .service-left { + gap: 30px; + align-items: flex-start; +} */ + +/* @media (max-width: 1399px) { + .service-main-item-3 .service-left .service-image { + width: 250px; + height: 180px; + } +} + +@media (max-width: 767px) { + .service-main-item-3 .service-left .service-image { + width: 100%; + height: 200px; + max-width: 300px; + } + + .service-main-item-3 .service-left { + flex-direction: column; + align-items: flex-start; + text-align: left; + } + + .service-main-item-3.style-2 .service-left { + flex-direction: column-reverse; + align-items: flex-start; + text-align: left; + } +} */ From f4529d1e90ddf6292ab2cb5ca7e3732b70802ea3 Mon Sep 17 00:00:00 2001 From: nguyenvanbao Date: Wed, 4 Feb 2026 14:42:54 +0700 Subject: [PATCH 2/4] feat: add country API integration and image fallback component --- api/servicesApi.ts | 118 +++++++++++++++++++++++++++ app/components/ImageWithFallback.tsx | 40 +++++++++ app/services/page.tsx | 81 +++++++----------- 3 files changed, 188 insertions(+), 51 deletions(-) create mode 100644 app/components/ImageWithFallback.tsx diff --git a/api/servicesApi.ts b/api/servicesApi.ts index 1fcf7dc..9bae705 100644 --- a/api/servicesApi.ts +++ b/api/servicesApi.ts @@ -100,6 +100,20 @@ export interface ServicePageData { reviews: ReviewSection; } +export interface Country { + id: number; + name: string; + slug: string; + mainImage: string; + icon: string; + services: string[]; +} + +export interface CountryApiResponse { + success: boolean; + data: Country[]; +} + /* ======================= Utils ======================= */ @@ -230,6 +244,43 @@ export const fetchServiceBySlug = async (slug: string): Promise => { } }; +export const fetchCountries = async (): Promise => { + try { + const apiUrl = getApiUrl(); + const endpoint = `${apiUrl}/api/visa/country`; + + console.log("Fetching countries from endpoint:", endpoint); + + const response = await fetch(endpoint, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + cache: "no-store", + }); + + console.log("Countries API response status:", response.status); + + if (!response.ok) { + console.error("Countries API failed, using fallback data"); + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = (await response.json()) as CountryApiResponse; + + if (!result.success) { + throw new Error("Countries API returned success=false"); + } + + console.log("Countries data received successfully"); + return result.data; + } catch (error) { + console.error("Error fetching countries:", error); + console.log("Using fallback countries data"); + return getFallbackCountries(); + } +}; + /* ======================= Fallback Data ======================= */ @@ -560,3 +611,70 @@ export const getFallbackServicePageData = (): ServicePageData => ({ ], }, }); + +export const getFallbackCountries = (): Country[] => [ + { + id: 1, + name: "Canada", + slug: "canada", + mainImage: "_images/default.jpg", + icon: "img/home-3/choose-us/icon-1.png", + services: ["Immigration Appeal", "Permanent Residency", "Study Visa"], + }, + { + id: 2, + name: "Australia", + slug: "australia", + mainImage: "_images/default.jpg", + icon: "img/home-3/choose-us/icon-2.png", + services: ["Work Visa", "Permanent Residency", "Student Visa"], + }, + { + id: 3, + name: "United Kingdom", + slug: "united-kingdom", + mainImage: "_images/default.jpg", + icon: "img/home-3/choose-us/icon-3.png", + services: ["Study Visa", "Work Visa", "Family Visa"], + }, + { + id: 4, + name: "United States", + slug: "united-states", + mainImage: "_images/default.jpg", + icon: "img/home-3/choose-us/icon-1.png", + services: ["Immigration Appeal", "Work Visa", "Student Visa"], + }, + { + id: 5, + name: "Germany", + slug: "germany", + mainImage: "_images/default.jpg", + icon: "img/home-3/choose-us/icon-2.png", + services: ["Study Visa", "Work Visa", "Permanent Residency"], + }, + { + id: 6, + name: "France", + slug: "france", + mainImage: "_images/default.jpg", + icon: "img/home-3/choose-us/icon-3.png", + services: ["Student Visa", "Work Visa", "Family Visa"], + }, + { + id: 7, + name: "New Zealand", + slug: "new-zealand", + mainImage: "_images/default.jpg", + icon: "img/home-3/choose-us/icon-1.png", + services: ["Work Visa", "Permanent Residency", "Study Visa"], + }, + { + id: 8, + name: "Japan", + slug: "japan", + mainImage: "_images/default.jpg", + icon: "img/home-3/choose-us/icon-2.png", + services: ["Work Visa", "Student Visa", "Business Visa"], + }, +]; diff --git a/app/components/ImageWithFallback.tsx b/app/components/ImageWithFallback.tsx new file mode 100644 index 0000000..0c3cd81 --- /dev/null +++ b/app/components/ImageWithFallback.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useState } from "react"; +import { imageUrl } from "../utils/image"; + +interface ImageWithFallbackProps { + src: string; + alt: string; + fallbackSrc?: string; + className?: string; + style?: React.CSSProperties; +} + +export default function ImageWithFallback({ + src, + alt, + fallbackSrc = "_images/default.jpg", + className, + style, +}: ImageWithFallbackProps) { + const [imgSrc, setImgSrc] = useState(imageUrl(src)); + const [hasError, setHasError] = useState(false); + + const handleError = () => { + if (!hasError) { + setHasError(true); + setImgSrc(imageUrl(fallbackSrc)); + } + }; + + return ( + {alt} + ); +} diff --git a/app/services/page.tsx b/app/services/page.tsx index 944ebed..5147e09 100644 --- a/app/services/page.tsx +++ b/app/services/page.tsx @@ -1,57 +1,18 @@ -import { Metadata } from "next"; -import { fetchServicePageData } from "../../api/servicesApi"; +import { fetchServicePageData, fetchCountries } from "../../api/servicesApi"; import { imageUrl } from "../utils/image"; import Breadcrumb from "../components/Breadcrumb"; +import ImageWithFallback from "../components/ImageWithFallback"; import "./services.css"; export default async function ServicesPage() { const data = await fetchServicePageData(); + const allCountries = await fetchCountries(); const { services, destinations, visas, reviews } = data; - const country = [ - { - id: "canada", - name: "Canada", - description: - "Canada provides quality education, rich culture and global opportunities", - image: "img/home-3/choose-us/01.jpg", - icon: "img/home-3/choose-us/icon-1.png", - link: "country-details.html", - }, - { - id: "south-korea", - name: "South Korea", - description: - "South Korea offers advanced technology and cultural experiences", - image: "img/home-3/choose-us/02.jpg", - icon: "img/home-3/choose-us/icon-2.png", - link: "country-details.html", - }, - { - id: "france", - name: "France", - description: - "France offers rich cultural heritage and educational excellence", - image: "img/home-3/choose-us/03.jpg", - icon: "img/home-3/choose-us/icon-3.png", - link: "country-details.html", - }, - { - id: "uk", - name: "UK", - description: "UK provides world-class education and career opportunities", - image: "img/home-3/choose-us/04.jpg", - icon: "img/home-3/choose-us/icon-2.png", - link: "country-details.html", - }, - { - id: "germany", - name: "Germany", - description: "Germany offers excellent education and strong economy", - image: "img/home-3/choose-us/05.jpg", - icon: "img/home-3/choose-us/icon-3.png", - link: "country-details.html", - }, - ]; + + // Pagination logic - show only first 5 countries + const COUNTRIES_PER_PAGE = 5; + const displayedCountries = allCountries.slice(0, COUNTRIES_PER_PAGE); + const hasMoreCountries = allCountries.length > COUNTRIES_PER_PAGE; return ( <> @@ -140,10 +101,14 @@ export default async function ServicesPage() {
- {country.map((country: any) => ( + {displayedCountries.map((country: any) => (
- img +
@@ -151,14 +116,28 @@ export default async function ServicesPage() { img
- {country.name} + {country.name}
-

{country.description}

+

+ {country.services && country.services.length > 0 + ? `Services: ${country.services.slice(0, 2).join(", ")}${country.services.length > 2 ? "..." : ""}` + : "Immigration services available"} +

))} + + {/* Show "View More" button if there are more countries */} + {hasMoreCountries && ( +
+ + View All Countries ({allCountries.length}) + + +
+ )} From 9a71d39ebf376428dcc613e593cbdfd3f5de90b0 Mon Sep 17 00:00:00 2001 From: Wini_Fy Date: Wed, 4 Feb 2026 15:33:02 +0700 Subject: [PATCH 3/4] feat: Implement blog API service and refactor components for improved data fetching --- api/{blog.ts => blogsApi.ts} | 37 ++- api/index.ts | 2 +- app/blog/[slug]/components/CommentForm.tsx | 78 ++++- .../[slug]/components/CommentsSection.tsx | 294 +++++++++++++----- .../[slug]/components/NewsDetailsContent.tsx | 65 ++-- app/blog/[slug]/page.tsx | 58 +++- app/blog/category/[slug]/page.tsx | 33 +- app/blog/components/NewsSection.tsx | 8 +- app/blog/components/Pagination.tsx | 10 +- app/blog/components/Sidebar.tsx | 8 +- app/blog/page.tsx | 4 +- app/blog/tag/[slug]/page.tsx | 28 +- public/assets/css/main.css | 284 ++++++++++++++++- public/assets/scss/_news.scss | 4 +- utils/editorjsToHtml.ts | 22 +- utils/image.ts | 4 +- 16 files changed, 790 insertions(+), 149 deletions(-) rename api/{blog.ts => blogsApi.ts} (92%) diff --git a/api/blog.ts b/api/blogsApi.ts similarity index 92% rename from api/blog.ts rename to api/blogsApi.ts index dc2ef72..0f8b43a 100644 --- a/api/blog.ts +++ b/api/blogsApi.ts @@ -53,8 +53,10 @@ export const fetchBlogList = async ( headers: { 'Content-Type': 'application/json', }, - // Next.js: cache và revalidate - next: { revalidate: 60 }, // Revalidate mỗi 60 giây + // Next.js: cache và revalidate (disabled) + // next: { revalidate: 60 }, // Revalidate mỗi 60 giây + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -89,7 +91,8 @@ export const fetchBlogDetail = async ( headers: { 'Content-Type': 'application/json', }, - // No cache for blog detail so comments/replies show immediately after submit + refresh + // No cache for blog detail (disabled caching) + // no-cache cache: 'no-store', }); @@ -128,7 +131,9 @@ export const fetchFeaturedBlogs = async ( headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 60 }, + // next: { revalidate: 60 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -163,7 +168,9 @@ export const fetchRecentBlogs = async ( headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 60 }, + // next: { revalidate: 60 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -195,7 +202,9 @@ export const fetchCategories = async (): Promise => { headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 300 }, // Categories ít thay đổi, cache lâu hơn + // next: { revalidate: 300 }, // Categories ít thay đổi, cache lâu hơn + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -230,7 +239,9 @@ export const fetchCategoryDetail = async ( headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 300 }, + // next: { revalidate: 300 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -265,7 +276,9 @@ export const fetchTags = async (): Promise => { headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 300 }, + // next: { revalidate: 300 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -300,7 +313,9 @@ export const fetchPopularTags = async ( headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 300 }, + // next: { revalidate: 300 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -335,7 +350,9 @@ export const fetchTagDetail = async ( headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 300 }, + // next: { revalidate: 300 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { diff --git a/api/index.ts b/api/index.ts index 36d466b..e576090 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,4 +1,4 @@ /** * Export all API functions */ -export * from './blog'; +export * from './blogsApi'; diff --git a/app/blog/[slug]/components/CommentForm.tsx b/app/blog/[slug]/components/CommentForm.tsx index c1a95ec..3a4b580 100644 --- a/app/blog/[slug]/components/CommentForm.tsx +++ b/app/blog/[slug]/components/CommentForm.tsx @@ -21,6 +21,10 @@ export default function CommentForm({ const router = useRouter(); const [isPending, setIsPending] = useState(false); const [authorName, setAuthorName] = useState(""); + const [authorEmail, setAuthorEmail] = useState(""); + const [authorPhone, setAuthorPhone] = useState(""); + const [authorAddress, setAuthorAddress] = useState(""); + const [authorDate, setAuthorDate] = useState(""); const [content, setContent] = useState(initialContent); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); @@ -37,6 +41,12 @@ export default function CommentForm({ return; } + // Basic email validation if provided + if (authorEmail.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail.trim())) { + setError("Please enter a valid email address."); + return; + } + try { setIsPending(true); const res = await fetch(`${apiUrl}/api/blog/${slug}/comments`, { @@ -47,6 +57,10 @@ export default function CommentForm({ }, body: JSON.stringify({ authorName: authorName.trim(), + ...(authorEmail.trim() ? { authorEmail: authorEmail.trim() } : {}), + ...(authorPhone.trim() ? { authorPhone: authorPhone.trim() } : {}), + ...(authorAddress.trim() ? { authorAddress: authorAddress.trim() } : {}), + ...(authorDate.trim() ? { authorDate: authorDate.trim() } : {}), content: content.trim(), ...(parentId ? { parentId } : {}), }), @@ -58,6 +72,10 @@ export default function CommentForm({ } setAuthorName(""); + setAuthorEmail(""); + setAuthorPhone(""); + setAuthorAddress(""); + setAuthorDate(""); setContent(""); setSuccess("Comment submitted."); @@ -83,7 +101,7 @@ export default function CommentForm({
-
+
Your Name setAuthorName(e.target.value)} disabled={isPending} + required + /> +
+
+ +
+
+ Your Email + setAuthorEmail(e.target.value)} + disabled={isPending} + /> +
+
+ +
+
+ Your Phone + setAuthorPhone(e.target.value)} + disabled={isPending} + /> +
+
+ +
+
+ Your Address + setAuthorAddress(e.target.value)} + disabled={isPending} + /> +
+
+ +
+
+ Your Date + setAuthorDate(e.target.value)} + disabled={isPending} />
@@ -105,6 +180,7 @@ export default function CommentForm({ value={content} onChange={(e) => setContent(e.target.value)} disabled={isPending} + required >
diff --git a/app/blog/[slug]/components/CommentsSection.tsx b/app/blog/[slug]/components/CommentsSection.tsx index 54a2237..b74fdc6 100644 --- a/app/blog/[slug]/components/CommentsSection.tsx +++ b/app/blog/[slug]/components/CommentsSection.tsx @@ -11,15 +11,41 @@ interface CommentsSectionProps { } export default function CommentsSection({ slug, comments }: CommentsSectionProps) { + const [showAllComments, setShowAllComments] = useState(false); + const [expandedCommentKeys, setExpandedCommentKeys] = useState>( + () => new Set() + ); const [replyTarget, setReplyTarget] = useState<{ - // Root comment id to store as parentId (keeps 1-level threading on backend) + // ID của comment gốc để lưu làm parentId (giữ thread 1 cấp trên backend) parentId: string; - // The author we are replying to (used for @mention UI) + // Tên tác giả mà chúng ta đang reply (dùng cho UI @mention) replyToName: string; - // Which item in the UI is showing the reply form under it + // ID của item trong UI đang hiển thị form reply bên dưới anchorId: string; } | null>(null); + // Helper function để lấy chữ cái đầu của tên + const getInitials = (name?: string): string => { + if (!name) return "?"; + const trimmed = name.trim(); + if (trimmed.length === 0) return "?"; + return trimmed.charAt(0).toUpperCase(); + }; + + const makeCommentKey = (prefix: "p" | "r", id: string | undefined, index: number) => + id ? `${prefix}:${id}` : `${prefix}:idx:${index}`; + + const isExpanded = (key: string) => expandedCommentKeys.has(key); + + const toggleExpanded = (key: string) => { + setExpandedCommentKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + const { parents, repliesByParent } = useMemo(() => { const parentsLocal = (comments || []).filter((c) => !c.parentId); const replies = (comments || []).filter((c) => !!c.parentId); @@ -30,12 +56,12 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps } return { parents: parentsLocal, repliesByParent: map }; }, [comments]); - // Collect all author names to detect full @mentions (including multi-word names) + // Thu thập tất cả tên tác giả để phát hiện @mention đầy đủ (bao gồm tên nhiều từ) const authorNames = useMemo(() => { return (comments || []) .map((c) => c.authorName) .filter((n): n is string => !!n) - // Sort by length desc so we match the longest possible name first + // Sắp xếp theo độ dài giảm dần để khớp tên dài nhất trước .sort((a, b) => b.length - a.length); }, [comments]); @@ -43,24 +69,24 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps if (!text) return null; if (!text.startsWith("@")) return text; - // Try to match an @mention that corresponds to a known author name. - // This supports multi-word names like "@Bạn Cũng Thấy Thế Hà". + // Thử khớp @mention với tên tác giả đã biết + // Hỗ trợ tên nhiều từ như "@Bạn Cũng Thấy Thế Hà" const matchedName = authorNames.find((name) => { const candidate = `@${name}`; if (!text.startsWith(candidate)) return false; - // Ensure the match ends at string end or is followed by a space + // Đảm bảo khớp kết thúc ở cuối chuỗi hoặc theo sau bởi khoảng trắng const nextChar = text.charAt(candidate.length); return candidate.length === text.length || nextChar === " "; }); if (!matchedName) { - // Fallback: just return the original text if we can't confidently match a name + // Fallback: trả về text gốc nếu không thể khớp tên một cách chắc chắn return text; } const mention = `@${matchedName}`; - const rest = text.slice(mention.length); // includes leading space if present + const rest = text.slice(mention.length); // bao gồm khoảng trắng đầu nếu có return ( <> @@ -70,6 +96,92 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps ); }; + const splitWords = (s: string) => + s + .trim() + .split(/\s+/) + .filter(Boolean); + + const renderTruncatedContent = (text: string | undefined, key: string) => { + if (!text) return null; + + const maxWords = 35; + const words = splitWords(text); + const needsTruncate = words.length > maxWords; + const expanded = isExpanded(key); + + const displayText = + !needsTruncate || expanded ? text : `${words.slice(0, maxWords).join(" ")}...`; + + return ( + <> + {renderContentWithMention(displayText)} + {needsTruncate && ( + <> + {" "} + + + )} + + ); + }; + + // Tính toán các parent comment cần hiển thị (bao gồm children) để hiển thị tối đa 5 comments tổng cộng + const getDisplayedParents = () => { + if (showAllComments) return { parents: parents, repliesMap: new Map() }; + + let totalCount = 0; + const result: typeof parents = []; + const limitedRepliesMap = new Map(); + + for (const parent of parents) { + const allReplies = parent._id ? repliesByParent.get(parent._id) || [] : []; + const remaining = 5 - totalCount; + + if (remaining <= 0) break; + + // Tính toán số lượng replies có thể hiển thị + const repliesToShow = Math.min(allReplies.length, remaining - 1); // -1 cho chính parent comment + + if (repliesToShow >= 0) { + result.push(parent); + if (parent._id && repliesToShow > 0) { + limitedRepliesMap.set(parent._id, allReplies.slice(0, repliesToShow)); + } + totalCount += 1 + repliesToShow; + } else { + // Không thể hiển thị cả parent, dừng lại + break; + } + } + + return { parents: result, repliesMap: limitedRepliesMap }; + }; + + const { parents: displayedParents, repliesMap: displayedRepliesMap } = getDisplayedParents(); + + // Tính số lượng comments hiển thị ban đầu (khi thu gọn, tối đa 5) + const initialDisplayCount = displayedParents.reduce((sum, parent) => { + // Khi thu gọn, sử dụng replies giới hạn từ displayedRepliesMap + const replies = parent._id ? (displayedRepliesMap.get(parent._id) || []) : []; + return sum + 1 + replies.length; + }, 0); + + const hasMoreComments = (comments || []).length > initialDisplayCount; + return (
@@ -78,51 +190,57 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps
- {parents.map((comment, index) => { - const replies = comment._id ? repliesByParent.get(comment._id) || [] : []; + {displayedParents.map((comment, index) => { + // Khi showAllComments là false, chỉ sử dụng replies giới hạn từ displayedRepliesMap + // Khi showAllComments là true, sử dụng tất cả replies từ repliesByParent + const replies = comment._id + ? (showAllComments + ? (repliesByParent.get(comment._id) || []) + : (displayedRepliesMap.get(comment._id) || [])) + : []; const isReplyingHere = !!comment._id && replyTarget?.anchorId === comment._id; return (
- {comment.authorName} +
+ {getInitials(comment.authorName)} +
-
+
{formatLongDate(comment.createdAt)}

{comment.authorName}

- - {comment._id && ( - - )}
-

{renderContentWithMention(comment.content)}

+

+ {renderTruncatedContent( + comment.content, + makeCommentKey("p", comment._id, index) + )} +

+ + {comment._id && ( + + )} {isReplyingHere && comment._id && (
@@ -145,54 +263,56 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps
)} - {/* Replies */} + {/* Các reply */} {replies.length > 0 && (
- {replies.map((reply, replyIndex) => ( + {replies.map((reply: BlogComment, replyIndex: number) => (
- {reply.authorName} +
+ {getInitials(reply.authorName)} +
-
+
{formatLongDate(reply.createdAt)}

{reply.authorName}

- {reply._id && comment._id && ( - - )}
-

{renderContentWithMention(reply.content)}

+

+ {renderTruncatedContent( + reply.content, + makeCommentKey("r", reply._id, replyIndex) + )} +

+ + {reply._id && comment._id && ( + + )}
- {/* Reply form under child comment */} - {reply._id && replyTarget?.anchorId === reply._id && ( + {/* Form reply bên dưới child comment */} + {reply._id && replyTarget && replyTarget.anchorId === reply._id && (
+
+ )} + + {/* Nút thu gọn */} + {hasMoreComments && showAllComments && ( +
+ +
+ )} + + {/* Form comment mới (top-level) */}
diff --git a/app/blog/[slug]/components/NewsDetailsContent.tsx b/app/blog/[slug]/components/NewsDetailsContent.tsx index 904ea36..dc915a8 100644 --- a/app/blog/[slug]/components/NewsDetailsContent.tsx +++ b/app/blog/[slug]/components/NewsDetailsContent.tsx @@ -9,19 +9,25 @@ interface NewsDetailsContentProps { } export default function NewsDetailsContent({ post }: NewsDetailsContentProps) { - // Get comments from post (already included in API response) + // Lấy comments từ post (đã được bao gồm trong API response) const postComments = post.comments || []; - // Get base URL for EditorJS images + // Lấy base URL cho EditorJS images const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; - // Convert EditorJS content to HTML + // URL tuyệt đối của bài viết để share lên mạng xã hội + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; + const postUrl = `${siteUrl}/blog/${post.slug}`; + const encodedPostUrl = encodeURIComponent(postUrl); + const encodedTitle = encodeURIComponent(post.title); + + // Chuyển đổi EditorJS content sang HTML const renderContent = () => { const html = editorjsToHtml(post.content, baseUrl); return { __html: html }; }; - // Convert EditorJS contentAfterQuote to HTML + // Chuyển đổi EditorJS contentAfterQuote sang HTML const renderContentAfterQuote = () => { const html = editorjsToHtml(post.contentAfterQuote, baseUrl); return { __html: html }; @@ -49,13 +55,13 @@ export default function NewsDetailsContent({ post }: NewsDetailsContentProps) {

{post.title}

-
+
- {/* Gallery Images */} + {/* Hình ảnh gallery */} {post.galleryImages && post.galleryImages.length > 0 && ( -
+
{post.galleryImages.map((image, index) => ( -
+
{`${post.title}
@@ -71,18 +77,21 @@ export default function NewsDetailsContent({ post }: NewsDetailsContentProps) {
)} - {/* Content After Quote */} + {/* Nội dung sau Quote */} {post.contentAfterQuote && ( -
+
)} - {/* Tags and Social Share */} + {/* Tags và Social Share */}
Tags: {post.tags.map((tagName) => { - // Generate slug from tag name (Vietnamese-friendly) + // Tạo slug từ tên tag (hỗ trợ tiếng Việt) const tagSlug = toSlug(tagName); return ( @@ -94,17 +103,37 @@ export default function NewsDetailsContent({ post }: NewsDetailsContentProps) {
diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index 937a767..4216b3f 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -1,8 +1,10 @@ import Link from "next/link"; +import type { Metadata } from "next"; import Breadcrumb from "@/app/components/Breadcrumb"; import NewsDetailsSection from "./components/NewsDetailsSection"; -import { fetchBlogList, fetchBlogDetail } from "@/api/blog"; +import { fetchBlogList, fetchBlogDetail } from "@/api/blogsApi"; import Sidebar from "@/app/blog/components/Sidebar"; +import { getCmsImageUrl } from "@/utils"; // Generate static params for all blog posts export async function generateStaticParams() { @@ -25,6 +27,60 @@ interface BlogDetailsPageProps { }; } +// SEO metadata cho từng bài blog (Open Graph / thumbnail khi share) +export async function generateMetadata({ + params, +}: { + params: + | Promise<{ + slug: string; + }> + | { + slug: string; + }; +}): Promise { + const resolvedParams = params instanceof Promise ? await params : params; + const slug = resolvedParams.slug; + + try { + const blogResponse = await fetchBlogDetail(slug); + const post = blogResponse.data; + + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; + const url = `${siteUrl}/blog/${post.slug}`; + const imageUrl = post.featuredImage + ? getCmsImageUrl(post.featuredImage) + : `${siteUrl}/assets/img/inner-page/news-details/details-1.jpg`; + + return { + title: post.title, + description: post.excerpt, + openGraph: { + title: post.title, + description: post.excerpt, + url, + type: "article", + images: [ + { + url: imageUrl, + alt: post.title, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: post.title, + description: post.excerpt, + images: [imageUrl], + }, + }; + } catch { + return { + title: "Blog Details", + }; + } +} + export default async function BlogDetailsPage({ params }: BlogDetailsPageProps) { // Handle both Promise and direct object const resolvedParams = params instanceof Promise ? await params : params; diff --git a/app/blog/category/[slug]/page.tsx b/app/blog/category/[slug]/page.tsx index e95fdad..4eebb6e 100644 --- a/app/blog/category/[slug]/page.tsx +++ b/app/blog/category/[slug]/page.tsx @@ -1,27 +1,39 @@ import Breadcrumb from "@/app/components/Breadcrumb"; import NewsSection from "@/app/blog/components/NewsSection"; import Sidebar from "@/app/blog/components/Sidebar"; -import { fetchBlogsByCategory, fetchCategoryDetail } from "@/api/blog"; +import { fetchBlogsByCategory, fetchCategoryDetail } from "@/api/blogsApi"; interface CategoryPageProps { - params: Promise<{ + params: + | Promise<{ slug: string; - }> | { + }> + | { slug: string; - }; + }; + searchParams?: Promise<{ search?: string; page?: string }> | { search?: string; page?: string }; } -export default async function CategoryPage({ params }: CategoryPageProps) { +export default async function CategoryPage({ params, searchParams }: CategoryPageProps) { // Handle both Promise and direct object const resolvedParams = params instanceof Promise ? await params : params; const slug = resolvedParams.slug; + const resolvedSearchParams = + searchParams instanceof Promise ? await searchParams : searchParams; + const searchQuery = resolvedSearchParams?.search?.toString() || ""; + const pageParam = resolvedSearchParams?.page?.toString() || "1"; + const currentPage = Number.parseInt(pageParam, 10) || 1; // Fetch category detail and blogs let categoryResponse, blogsResponse; try { [categoryResponse, blogsResponse] = await Promise.all([ fetchCategoryDetail(slug), - fetchBlogsByCategory(slug, { page: 1, limit: 10 }), + fetchBlogsByCategory(slug, { + page: currentPage, + limit: 3, + ...(searchQuery ? { search: searchQuery } : {}), + }), ]); } catch { return ( @@ -47,12 +59,17 @@ export default async function CategoryPage({ params }: CategoryPageProps) { } const category = categoryResponse.data; - const blogs = blogsResponse.data.blogs; + const { blogs, pagination } = blogsResponse.data; return ( <> - + ); } diff --git a/app/blog/components/NewsSection.tsx b/app/blog/components/NewsSection.tsx index 64a8ddf..34c3138 100644 --- a/app/blog/components/NewsSection.tsx +++ b/app/blog/components/NewsSection.tsx @@ -18,6 +18,12 @@ export default function NewsSection({ searchQuery, pagination, }: NewsSectionProps) { + const basePath = categorySlug + ? `/blog/category/${categorySlug}` + : tagSlug + ? `/blog/tag/${tagSlug}` + : "/blog"; + return (
@@ -29,7 +35,7 @@ export default function NewsSection({ {pagination && pagination.total > 1 && (
- +
)} diff --git a/app/blog/components/Pagination.tsx b/app/blog/components/Pagination.tsx index 78c3dbc..c1a815e 100644 --- a/app/blog/components/Pagination.tsx +++ b/app/blog/components/Pagination.tsx @@ -38,11 +38,11 @@ export default function Pagination({ basePath, pagination, searchQuery }: Pagina return (