diff --git a/app/blog/[slug]/components/CommentForm.tsx b/app/blog/[slug]/components/CommentForm.tsx deleted file mode 100644 index 3a4b580..0000000 --- a/app/blog/[slug]/components/CommentForm.tsx +++ /dev/null @@ -1,199 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useState } from "react"; - -interface CommentFormProps { - slug: string; - parentId?: string | null; - replyToName?: string | null; - initialContent?: string; - onSubmitted?: () => void; -} - -export default function CommentForm({ - slug, - parentId = null, - replyToName = null, - initialContent = "", - onSubmitted, -}: CommentFormProps) { - 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); - - const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; - - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(null); - setSuccess(null); - - if (!authorName.trim() || !content.trim()) { - setError("Please enter your name and comment."); - 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`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - 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 } : {}), - }), - }); - - const data = await res.json(); - if (!res.ok || !data?.success) { - throw new Error(data?.message || "Failed to submit comment"); - } - - setAuthorName(""); - setAuthorEmail(""); - setAuthorPhone(""); - setAuthorAddress(""); - setAuthorDate(""); - setContent(""); - setSuccess("Comment submitted."); - - // Re-fetch server data (blog detail is no-store) to show new comment immediately - router.refresh(); - - onSubmitted?.(); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to submit comment"); - } finally { - setIsPending(false); - } - }; - - return ( -
-

- {parentId ? (replyToName ? `Reply to @${replyToName}` : "Reply") : "Leave A Comment"} -

- - {error &&

{error}

} - {success &&

{success}

} - -
-
-
-
- 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} - /> -
-
- -
-
- -
-
- -
- -
-
-
-
- ); -} - diff --git a/app/blog/[slug]/components/CommentsSection.tsx b/app/blog/[slug]/components/CommentsSection.tsx deleted file mode 100644 index b74fdc6..0000000 --- a/app/blog/[slug]/components/CommentsSection.tsx +++ /dev/null @@ -1,382 +0,0 @@ -"use client"; - -import type { BlogComment } from "@/types/blog"; -import { useMemo, useState } from "react"; -import CommentForm from "./CommentForm"; -import { formatLongDate } from "@/utils"; - -interface CommentsSectionProps { - slug: string; - comments: BlogComment[]; -} - -export default function CommentsSection({ slug, comments }: CommentsSectionProps) { - const [showAllComments, setShowAllComments] = useState(false); - const [expandedCommentKeys, setExpandedCommentKeys] = useState>( - () => new Set() - ); - const [replyTarget, setReplyTarget] = useState<{ - // ID của comment gốc để lưu làm parentId (giữ thread 1 cấp trên backend) - parentId: string; - // Tên tác giả mà chúng ta đang reply (dùng cho UI @mention) - replyToName: string; - // 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); - const map = new Map(); - for (const r of replies) { - const pid = r.parentId as string; - map.set(pid, [...(map.get(pid) || []), r]); - } - return { parents: parentsLocal, repliesByParent: map }; - }, [comments]); - // 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) - // 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]); - - const renderContentWithMention = (text?: string) => { - if (!text) return null; - if (!text.startsWith("@")) return text; - - // 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; - - // Đả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: 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); // bao gồm khoảng trắng đầu nếu có - - return ( - <> - {mention} - {rest} - - ); - }; - - 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 ( -
-
-

- {(comments || []).length} {(comments || []).length === 1 ? "Comment" : "Comments"} -

-
- - {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 ( -
-
-
-
- {getInitials(comment.authorName)} -
-
- -
-
-
- {formatLongDate(comment.createdAt)} -

{comment.authorName}

-
-
- -

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

- - {comment._id && ( - - )} - - {isReplyingHere && comment._id && ( -
-
- -
- setReplyTarget(null)} - /> -
- )} - - {/* Các reply */} - {replies.length > 0 && ( -
- {replies.map((reply: BlogComment, replyIndex: number) => ( -
-
-
-
- {getInitials(reply.authorName)} -
-
-
-
-
- {formatLongDate(reply.createdAt)} -

{reply.authorName}

-
-
-

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

- - {reply._id && comment._id && ( - - )} -
-
- - {/* Form reply bên dưới child comment */} - {reply._id && replyTarget && replyTarget.anchorId === reply._id && ( -
-
- -
- setReplyTarget(null)} - /> -
- )} -
- ))} -
- )} -
-
-
- ); - })} - - {/* Nút hiển thị thêm comments */} - {hasMoreComments && !showAllComments && ( -
- -
- )} - - {/* 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 deleted file mode 100644 index 11bf29d..0000000 --- a/app/blog/[slug]/components/NewsDetailsContent.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import Link from "next/link"; -import type { BlogPost } from "@/types/blog"; -import { editorjsToHtml, getCmsImageUrl } from "@/utils"; -import { toSlug } from "@/utils/slugify"; -import CommentsSection from "./CommentsSection"; - -interface NewsDetailsContentProps { - post: BlogPost; -} - -export default function NewsDetailsContent({ post }: NewsDetailsContentProps) { - // Lấy comments từ post (đã được bao gồm trong API response) - const postComments = post.comments || []; - - // Lấy base URL cho EditorJS images và URL tuyệt đối của bài viết - const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; - const postUrl = `${baseUrl}/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 }; - }; - - // Chuyển đổi EditorJS contentAfterQuote sang HTML - const renderContentAfterQuote = () => { - const html = editorjsToHtml(post.contentAfterQuote, baseUrl); - return { __html: html }; - }; - - return ( -
-
-
- {post.title} -
-
-
    -
  • - By {post.author} -
  • -
  • - {post.publishedAt} -
  • -
  • - {postComments.length} Comments -
  • -
-

{post.title}

-
- - {/* Hình ảnh gallery */} - {post.galleryImages && post.galleryImages.length > 0 && ( -
- {post.galleryImages.map((image, index) => ( -
-
- {`${post.title} -
-
- ))} -
- )} - - {/* Quote/Sidebar */} - {post.quote && ( -
-
{post.quote}
-
- )} - - {/* Nội dung sau Quote */} - {post.contentAfterQuote && ( -
- )} - - {/* Tags và Social Share */} -
-
-
- Tags: - {post.tags.map((tagName) => { - // Tạo slug từ tên tag (hỗ trợ tiếng Việt) - const tagSlug = toSlug(tagName); - return ( - - {tagName} - - ); - })} -
-
- -
- - -
-
-
- ); -} diff --git a/app/blog/[slug]/components/NewsDetailsSection.tsx b/app/blog/[slug]/components/NewsDetailsSection.tsx deleted file mode 100644 index de4d86d..0000000 --- a/app/blog/[slug]/components/NewsDetailsSection.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import NewsDetailsContent from "./NewsDetailsContent"; -import Sidebar from "@/app/blog/components/Sidebar"; -import type { BlogPost } from "@/types/blog"; - -interface NewsDetailsSectionProps { - post: BlogPost; -} - -export default function NewsDetailsSection({ post }: NewsDetailsSectionProps) { - return ( -
-
-
-
- - -
-
-
-
- ); -} diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx deleted file mode 100644 index 336f3ec..0000000 --- a/app/blog/[slug]/page.tsx +++ /dev/null @@ -1,133 +0,0 @@ -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/blogsApi"; -import Sidebar from "@/app/blog/components/Sidebar"; -import { getCmsImageUrl } from "@/utils"; - -// Force dynamic rendering - không cache -export const dynamic = 'force-dynamic'; - -// Generate static params for all blog posts -export async function generateStaticParams() { - try { - const blogResponse = await fetchBlogList({ page: 1, limit: 100 }); - return blogResponse.data.blogs.map((post) => ({ - slug: post.slug, - })); - } catch (error) { - console.error("Error generating static params:", error); - return []; - } -} - -interface BlogDetailsPageProps { - params: - | Promise<{ - slug: string; - }> - | { - slug: string; - }; -} - -// 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 apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; - const url = `${apiUrl}/blog/${post.slug}`; - const imageUrl = post.featuredImage - ? getCmsImageUrl(post.featuredImage) - : `${apiUrl}/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; - const slug = resolvedParams.slug; - - // Fetch blog detail from API - let blogResponse; - try { - blogResponse = await fetchBlogDetail(slug); - } catch { - return ( - <> - -
-
-
-
-
-
-

Post not found

-

The blog post you are looking for does not exist.

- - Back to Blog - -
-
- {/* Sidebar on the right */} - -
-
-
-
- - ); - } - - const post = blogResponse.data; - - return ( - <> - - - - ); -} diff --git a/app/blog/blog.json b/app/blog/blog.json deleted file mode 100644 index 4a6b1d2..0000000 --- a/app/blog/blog.json +++ /dev/null @@ -1,170 +0,0 @@ -{ - "categories": [ - { - "name": "Visa & Immigration", - "slug": "visa-immigration", - "description": "Tin tức và hướng dẫn về visa, định cư." - }, - { - "name": "Study Abroad", - "slug": "study-abroad", - "description": "Kinh nghiệm du học, trường học, học bổng." - }, - { - "name": "Travel Tips", - "slug": "travel-tips", - "description": "Mẹo du lịch, chuẩn bị hành lý, bảo hiểm." - } - ], - "tags": [ - { - "name": "WorkVisa", - "slug": "work-visa" - }, - { - "name": "StudentVisa", - "slug": "student-visa" - }, - { - "name": "Canada", - "slug": "canada" - }, - { - "name": "Scholarship", - "slug": "scholarship" - }, - { - "name": "TravelSafety", - "slug": "travel-safety" - } - ], - "posts": [ - { - "title": "Ultimate Guide To Getting A Work Visa In Canada", - "slug": "ultimate-guide-work-visa-canada", - "excerpt": "Tổng hợp đầy đủ các bước xin work visa tại Canada cho người mới bắt đầu, từ điều kiện, hồ sơ đến thời gian xử lý.", - "content": "

Trong bài viết này, chúng ta sẽ đi qua từng bước cụ thể để xin work visa Canada, từ việc chuẩn bị hồ sơ, chọn chương trình phù hợp đến cách theo dõi tiến độ xử lý hồ sơ. Bạn cũng sẽ tìm thấy một số mẹo thực tế để tránh những sai lầm phổ biến.

", - "category": ["Visa & Immigration", "Canada"], - "tags": ["WorkVisa", "Canada"], - "author": "Admin", - "status": "published", - "publishedAt": "11 March 2025", - "isFeatured": true, - "featuredImage": "/assets/img/inner-page/news-details/details-1.jpg", - "galleryImages": [ - "/assets/img/inner-page/news-details/details-2.jpg", - "/assets/img/inner-page/news-details/details-3.jpg" - ], - "quote": "This blog really helped me understand the difference between student and work visas. The explanations were clear and practical.", - "contentAfterQuote": "

It provides access to world-class universities, cultural exposure, and global networking opportunities. With a student visa, you may also get part-time work rights, which can help support your expenses and give you valuable international work experience. However, the primary focus remains on academics and personal growth. On the other hand, a work visa is perfect for those who want to establish themselves in a career overseas.

It provides immediate access to job markets, stable income, and often a pathway to permanent residency. Work visas are suitable for skilled professionals who are ready to contribute to the global workforce and achieve long-term career goals. Ultimately, the choice comes down to your personal aspirations. If education and exploration are your priorities, a student visa is ideal. If career advancement and stability are your goals, a work visa is the right fit.

", - "commentsCount": 3 - }, - { - "title": "Top 5 Scholarship Programs For International Students", - "slug": "top-5-scholarship-programs-international-students", - "excerpt": "Danh sách 5 chương trình học bổng nổi bật dành cho sinh viên quốc tế với mức hỗ trợ hấp dẫn.", - "content": "

Nếu bạn đang tìm kiếm học bổng để giảm chi phí du học, đây là 5 chương trình bạn không nên bỏ qua. Mỗi chương trình đều có tiêu chí xét tuyển, mức hỗ trợ và thời hạn đăng ký khác nhau.

Học bổng là một trong những cách tốt nhất để giảm gánh nặng tài chính khi du học. Các chương trình học bổng không chỉ hỗ trợ về mặt tài chính mà còn mở ra nhiều cơ hội phát triển nghề nghiệp và mở rộng mạng lưới quan hệ quốc tế.

", - "category": ["Study Abroad"], - "tags": ["StudentVisa", "Scholarship"], - "author": "Admin", - "status": "published", - "publishedAt": "20 March 2025", - "isFeatured": false, - "featuredImage": "/assets/img/inner-page/news-details/details-1.jpg", - "galleryImages": [ - "/assets/img/inner-page/news-details/details-2.jpg", - "/assets/img/inner-page/news-details/details-3.jpg" - ], - "quote": "These scholarship programs opened doors I never thought possible. The application process was straightforward, and the support I received was incredible.", - "contentAfterQuote": "

Applying for scholarships requires careful planning and preparation. Start by researching each program's requirements, deadlines, and eligibility criteria. Make sure to prepare all necessary documents well in advance, including transcripts, recommendation letters, and personal statements. Each scholarship has its own unique focus, so tailor your application to highlight how you align with their values and goals.

Remember that scholarship applications are competitive, so it's important to stand out. Showcase your academic achievements, extracurricular activities, and community involvement. Be authentic in your personal statement and demonstrate how the scholarship will help you achieve your educational and career aspirations. With dedication and proper preparation, you can increase your chances of securing financial support for your studies abroad.

", - "commentsCount": 2 - }, - { - "title": "10 Travel Safety Tips You Should Know Before Flying", - "slug": "10-travel-safety-tips-before-flying", - "excerpt": "Những lưu ý quan trọng để đảm bảo an toàn cho chuyến bay và hành trình của bạn.", - "content": "

An toàn luôn là ưu tiên hàng đầu khi đi du lịch. Dưới đây là 10 tips giúp bạn yên tâm hơn trên mọi chuyến đi, từ việc chuẩn bị giấy tờ, bảo hiểm đến cách bảo vệ tài sản cá nhân.

Du lịch là một trải nghiệm tuyệt vời, nhưng điều quan trọng là phải chuẩn bị kỹ lưỡng để đảm bảo an toàn. Những tips này được đúc kết từ kinh nghiệm thực tế của nhiều du khách và sẽ giúp bạn tránh được những rủi ro không đáng có.

", - "category": ["Travel Tips"], - "tags": ["TravelSafety"], - "author": "Admin", - "status": "published", - "publishedAt": "05 April 2025", - "isFeatured": false, - "featuredImage": "/assets/img/inner-page/news-details/details-1.jpg", - "galleryImages": [ - "/assets/img/inner-page/news-details/details-2.jpg", - "/assets/img/inner-page/news-details/details-3.jpg" - ], - "quote": "These safety tips saved me from potential problems during my trip. I especially appreciated the advice about travel insurance and document preparation.", - "contentAfterQuote": "

Before you travel, make sure to research your destination thoroughly. Understand local customs, laws, and potential safety concerns. Keep copies of important documents like your passport, visa, and travel insurance in multiple places - both physical and digital. Inform family or friends about your itinerary and check in regularly during your trip.

When packing, prioritize essential items and keep valuables secure. Use luggage locks and consider travel insurance for expensive items. Stay aware of your surroundings, especially in crowded areas, and trust your instincts if something feels off. By following these safety tips, you can focus on enjoying your journey while staying protected throughout your travels.

", - "commentsCount": 1 - } - ], - "recentPosts": [ - { - "title": "Ultimate Guide To Getting A Work Visa In Canada", - "slug": "ultimate-guide-work-visa-canada", - "thumbnail": "/assets/img/inner-page/news-details/post-1.jpg", - "publishedAt": "11 March 2025" - }, - { - "title": "Top 5 Scholarship Programs For International Students", - "slug": "top-5-scholarship-programs-international-students", - "thumbnail": "/assets/img/inner-page/news-details/post-2.jpg", - "publishedAt": "20 March 2025" - }, - { - "title": "10 Travel Safety Tips You Should Know Before Flying", - "slug": "10-travel-safety-tips-before-flying", - "thumbnail": "/assets/img/inner-page/news-details/post-3.jpg", - "publishedAt": "05 April 2025" - } - ], - "comments": [ - { - "postSlug": "ultimate-guide-work-visa-canada", - "authorName": "Frank Flores", - "authorAvatar": "/assets/img/inner-page/news-details/comment-1.png", - "content": "Bài viết rất hữu ích, cảm ơn bạn đã chia sẻ!", - "createdAt": "February 10, 2024", - "status": "approved", - "parentAuthorName": null - }, - { - "postSlug": "ultimate-guide-work-visa-canada", - "authorName": "Courtney Henry", - "authorAvatar": "/assets/img/inner-page/news-details/comment-2.png", - "content": "Mình đã làm theo hướng dẫn và hồ sơ được duyệt nhanh hơn hẳn.", - "createdAt": "February 12, 2024", - "status": "approved", - "parentAuthorName": "Frank Flores" - }, - { - "postSlug": "top-5-scholarship-programs-international-students", - "authorName": "Sarah Johnson", - "authorAvatar": "/assets/img/inner-page/news-details/comment-1.png", - "content": "Cảm ơn bạn đã chia sẻ thông tin về các chương trình học bổng này. Mình đã apply và đang chờ kết quả!", - "createdAt": "March 15, 2025", - "status": "approved", - "parentAuthorName": null - }, - { - "postSlug": "top-5-scholarship-programs-international-students", - "authorName": "Michael Chen", - "authorAvatar": "/assets/img/inner-page/news-details/comment-2.png", - "content": "Bài viết rất chi tiết và hữu ích. Mình đã tìm thấy một chương trình phù hợp với mình.", - "createdAt": "March 18, 2025", - "status": "approved", - "parentAuthorName": null - }, - { - "postSlug": "10-travel-safety-tips-before-flying", - "authorName": "Jenny Wilson", - "authorAvatar": "/assets/img/inner-page/news-details/comment-3.png", - "content": "Những tip này rất thực tế, đặc biệt là phần chuẩn bị bảo hiểm!", - "createdAt": "March 02, 2024", - "status": "approved", - "parentAuthorName": null - } - ] -} diff --git a/app/blog/category/[slug]/page.tsx b/app/blog/category/[slug]/page.tsx deleted file mode 100644 index b2532c0..0000000 --- a/app/blog/category/[slug]/page.tsx +++ /dev/null @@ -1,69 +0,0 @@ -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/blogsApi"; - -interface CategoryPageProps { - params: - | Promise<{ - slug: string; - }> - | { - slug: string; - }; - searchParams?: Promise<{ search?: string; page?: string }> | { search?: string; page?: string }; -} - -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: currentPage, - limit: 3, - ...(searchQuery ? { search: searchQuery } : {}), - }), - ]); - } catch { - return ( - <> - -
-
-
-
-
-
-

Category not found

-

The category you are looking for does not exist.

-
-
- -
-
-
-
- - ); - } - - const category = categoryResponse.data; - const { blogs, pagination } = blogsResponse.data; - - return ( - <> - - - - ); -} diff --git a/app/blog/components/NewsList.tsx b/app/blog/components/NewsList.tsx deleted file mode 100644 index bf717a1..0000000 --- a/app/blog/components/NewsList.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import Link from "next/link"; -import type { BlogPost } from "@/types/blog"; -import { getCmsImageUrl } from "@/utils"; - -interface NewsListProps { - blogs?: BlogPost[]; - categorySlug?: string; - tagSlug?: string; -} - -export default function NewsList({ blogs = [], categorySlug, tagSlug }: NewsListProps) { - // Use blogs from props (already filtered by API) - const posts = blogs; - - // Additional client-side filtering if needed (though API should handle this) - if (categorySlug || tagSlug) { - // If filters are provided but blogs are not pre-filtered, filter here - // This is a fallback - ideally API should handle filtering - } - - return ( -
- {posts.map((post, index) => ( -
-
- {post.title} -
-
-
    -
  • - By {post.author} -
  • -
  • - {post.publishedAt} -
  • -
  • - {post.commentsCount} Comments -
  • -
-

- {post.title} -

-

{post.excerpt}

- - VIEW MORE - -
-
- ))} -
- ); -} \ No newline at end of file diff --git a/app/blog/components/NewsSection.tsx b/app/blog/components/NewsSection.tsx deleted file mode 100644 index 34c3138..0000000 --- a/app/blog/components/NewsSection.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import NewsList from "./NewsList"; -import Sidebar from "./Sidebar"; -import Pagination from "./Pagination"; -import type { BlogPost, BlogPagination } from "@/types"; - -interface NewsSectionProps { - blogs?: BlogPost[]; - categorySlug?: string; - tagSlug?: string; - searchQuery?: string; - pagination?: BlogPagination; -} - -export default function NewsSection({ - blogs, - categorySlug, - tagSlug, - searchQuery, - pagination, -}: NewsSectionProps) { - const basePath = categorySlug - ? `/blog/category/${categorySlug}` - : tagSlug - ? `/blog/tag/${tagSlug}` - : "/blog"; - - return ( -
-
-
-
- - -
- {pagination && pagination.total > 1 && ( -
-
- -
-
- )} -
-
-
- ); -} \ No newline at end of file diff --git a/app/blog/components/Pagination.tsx b/app/blog/components/Pagination.tsx deleted file mode 100644 index c1a815e..0000000 --- a/app/blog/components/Pagination.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import Link from "next/link"; -import type { BlogPagination } from "@/types"; - -interface PaginationProps { - basePath: string; - pagination: BlogPagination; - searchQuery?: string; -} - -export default function Pagination({ basePath, pagination, searchQuery }: PaginationProps) { - const { current, total } = pagination; - - if (total <= 1) return null; - - const makeHref = (page: number) => { - const params = new URLSearchParams(); - if (page > 1) params.set("page", page.toString()); - if (searchQuery) params.set("search", searchQuery); - const qs = params.toString(); - return qs ? `${basePath}?${qs}` : basePath; - }; - - const pages: number[] = []; - for (let i = 1; i <= total; i++) { - if (i === 1 || i === total || (i >= current - 2 && i <= current + 2)) { - pages.push(i); - } - } - - const items: (number | "...")[] = []; - for (let i = 0; i < pages.length; i++) { - const page = pages[i]; - items.push(page); - if (i < pages.length - 1 && pages[i + 1] !== page + 1) { - items.push("..."); - } - } - - return ( - - ); -} - diff --git a/app/blog/components/Sidebar.tsx b/app/blog/components/Sidebar.tsx deleted file mode 100644 index 3bb3be6..0000000 --- a/app/blog/components/Sidebar.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import Link from "next/link"; -import { fetchCategories, fetchRecentBlogs, fetchPopularTags } from "@/api/blogsApi"; -import { getCmsImageUrl } from "@/utils"; - -interface SidebarProps { - searchQuery?: string; -} - -export default async function Sidebar({ searchQuery }: SidebarProps) { - // Fetch data from API - const [categoriesResponse, recentBlogsResponse, tagsResponse] = await Promise.all([ - fetchCategories(), - fetchRecentBlogs(5), - fetchPopularTags(10), - ]); - - const categories = categoriesResponse.data; - const recentPosts = recentBlogsResponse.data; - const tags = tagsResponse.data; - - return ( -
-
- {/* Search Widget */} -
-
-
- - -
-
-
- {/* Categories */} -
-
-

Categories

-
-
-
    - {categories.map((category) => { - const postCount = category.postCount || 0; - return ( -
  • - - {category.name} - - ({String(postCount).padStart(2, "0")}) -
  • - ); - })} -
-
-
- {/* Recent Post */} -
-
-

Recent Post

-
-
- {recentPosts.map((post) => ( -
-
- {post.title} -
-
-
- {post.title} -
-
    -
  • {post.publishedAt}
  • -
-
-
- ))} -
-
- {/* Tag Cloud */} -
-
-

Tag Cloud

-
-
-
- {tags.map((tag) => ( - - {tag.name} - - ))} -
-
-
-
-
- ); -} \ No newline at end of file diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 1c9e5af..e69de29 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -1,29 +0,0 @@ -import Breadcrumb from "@/app/components/Breadcrumb"; -import NewsSection from "./components/NewsSection"; -import { fetchBlogList } from "@/api/blogsApi"; - -interface NewsPageProps { - searchParams?: Promise<{ search?: string; page?: string }> | { search?: string; page?: string }; -} - -export default async function NewsPage({ searchParams }: NewsPageProps) { - 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 blog list from API - const blogResponse = await fetchBlogList({ - page: currentPage, - limit: 3, - ...(searchQuery ? { search: searchQuery } : {}), - }); - const { blogs, pagination } = blogResponse.data; - - return ( - <> - - - - ); -} diff --git a/app/blog/tag/[slug]/page.tsx b/app/blog/tag/[slug]/page.tsx deleted file mode 100644 index 5090bcc..0000000 --- a/app/blog/tag/[slug]/page.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import Breadcrumb from "@/app/components/Breadcrumb"; -import NewsSection from "@/app/blog/components/NewsSection"; -import Sidebar from "@/app/blog/components/Sidebar"; -import { fetchBlogsByTag, fetchTagDetail } from "@/api/blogsApi"; - -interface TagPageProps { - params: - | Promise<{ - slug: string; - }> - | { - slug: string; - }; - searchParams?: Promise<{ search?: string; page?: string }> | { search?: string; page?: string }; -} - -export default async function TagPage({ params, searchParams }: TagPageProps) { - // 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 tag detail and blogs - let tagResponse, blogsResponse; - try { - [tagResponse, blogsResponse] = await Promise.all([ - fetchTagDetail(slug), - fetchBlogsByTag(slug, { - page: currentPage, - limit: 3, - ...(searchQuery ? { search: searchQuery } : {}), - }), - ]); - } catch { - return ( - <> - -
-
-
-
-
-
-

Tag not found

-

The tag you are looking for does not exist.

-
-
- -
-
-
-
- - ); - } - - const tag = tagResponse.data; - const { blogs, pagination } = blogsResponse.data; - - return ( - <> - - - - ); -} diff --git a/app/components/partnership/CollaborateCTA.tsx b/app/components/partnership/CollaborateCTA.tsx new file mode 100644 index 0000000..42e0117 --- /dev/null +++ b/app/components/partnership/CollaborateCTA.tsx @@ -0,0 +1,16 @@ +import CollaborateInfo from "./CollaborateInfo"; +import PartnershipForm from "./PartnershipForm"; + +export default function CollaborateCTA() { + return ( +
+
+
+
+ + +
+
+
+ ); +} diff --git a/app/components/partnership/CollaborateInfo.tsx b/app/components/partnership/CollaborateInfo.tsx new file mode 100644 index 0000000..c5c3836 --- /dev/null +++ b/app/components/partnership/CollaborateInfo.tsx @@ -0,0 +1,35 @@ +const checkItems = [ + "Joint Research Initiatives", + "Student & Faculty Exchange", + "Industry Integration & Internships", +]; + +export default function CollaborateInfo() { + return ( +
+ {/* Badge */} +
+ + Join Our Network +
+ + {/* Heading — dùng div tránh main.css override h2 */} +
+ Collaborate
With Us +
+ +

+ We are constantly seeking to expand our network with institutions and organizations that share our commitment to rigorous research and liberal arts education. +

+ +
    + {checkItems.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+ ); +} diff --git a/app/components/partnership/MainIntroBlock.tsx b/app/components/partnership/MainIntroBlock.tsx new file mode 100644 index 0000000..61734d1 --- /dev/null +++ b/app/components/partnership/MainIntroBlock.tsx @@ -0,0 +1,39 @@ +export default function MainIntroBlock() { + return ( +
+
+
+ + {/* Badge */} +
+ + Global Network +
+ + {/* Heading — dùng div tránh main.css override h1 */} +
+ Global
Partnerships +
+ + {/* Description */} +
+

+ [We at Université Libérale de Paris believe that research and liberal arts education thrive through global collaboration.] +

+

+ [By bridging international institutions and industry leaders, we create opportunities for our students and faculty to engage in transformative academic exchanges.] +

+
+ + {/* Button */} + + +
+
+ ); +} diff --git a/app/components/partnership/NetworkHighlightBlock.tsx b/app/components/partnership/NetworkHighlightBlock.tsx new file mode 100644 index 0000000..1d27eb2 --- /dev/null +++ b/app/components/partnership/NetworkHighlightBlock.tsx @@ -0,0 +1,26 @@ +export default function NetworkHighlightBlock() { + return ( +
+
+
+ + {/* Icon + Brand */} +
+ + ULP Network +
+ + {/* Heading — dùng div tránh main.css override h2 */} +
+
+ Connect Across
Continents +
+

+ 150+ active academic agreements across 45 countries. +

+
+ +
+
+ ); +} diff --git a/app/components/partnership/PartnershipForm.tsx b/app/components/partnership/PartnershipForm.tsx new file mode 100644 index 0000000..7719f67 --- /dev/null +++ b/app/components/partnership/PartnershipForm.tsx @@ -0,0 +1,49 @@ +export default function PartnershipForm() { + return ( +
+ {/* Title — dùng div tránh main.css override h3 */} +
Partnership Inquiry
+

+ Fill out this brief form and our Global Relations office will contact you. +

+ +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +