feat: Refactor blog components and add pagination

This commit is contained in:
Wini_Fy
2026-02-03 17:05:09 +07:00
parent bf652a64b6
commit 29cc0bf2cd
27 changed files with 2051 additions and 429 deletions

1
.gitignore vendored
View File

@@ -42,3 +42,4 @@ next-env.d.ts
#vs code
/.vscode
package-lock.json

398
api/blog.ts Normal file
View File

@@ -0,0 +1,398 @@
import {
BlogListResponse,
BlogDetailResponse,
BlogFeaturedResponse,
BlogRecentResponse,
CategoryListResponse,
CategoryDetailResponse,
TagListResponse,
TagDetailResponse,
BlogQueryParams,
} from '../types/blog';
/**
* Lấy API URL từ environment variable
* Hỗ trợ cả REACT_APP_API_URL và NEXT_PUBLIC_API_URL
*/
const getApiUrl = (): string => {
// Trong Next.js, client-side env vars cần prefix NEXT_PUBLIC_
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
if (!apiUrl) {
console.warn('NEXT_PUBLIC_API_URL is not set. Using default http://localhost:3001');
return 'http://localhost:3001';
}
return apiUrl;
};
/**
* Fetch blog list từ API
* @param params - Query parameters (page, limit, category, tag, search)
* @returns Promise<BlogListResponse>
* @throws Error nếu fetch thất bại
*/
export const fetchBlogList = async (
params?: BlogQueryParams
): Promise<BlogListResponse> => {
const apiUrl = getApiUrl();
const queryParams = new URLSearchParams();
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.limit) queryParams.append('limit', params.limit.toString());
if (params?.category) queryParams.append('category', params.category);
if (params?.tag) queryParams.append('tag', params.tag);
if (params?.search) queryParams.append('search', params.search);
const queryString = queryParams.toString();
const url = `${apiUrl}/api/blog${queryString ? `?${queryString}` : ''}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
// Next.js: cache và revalidate
next: { revalidate: 60 }, // Revalidate mỗi 60 giây
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: BlogListResponse = await response.json();
return data;
} catch (error) {
console.error('Error fetching blog list:', error);
throw new Error(
`Failed to fetch blog list: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};
/**
* Fetch blog detail by slug từ API
* @param slug - Blog post slug
* @returns Promise<BlogDetailResponse>
* @throws Error nếu fetch thất bại
*/
export const fetchBlogDetail = async (
slug: string
): Promise<BlogDetailResponse> => {
const apiUrl = getApiUrl();
const url = `${apiUrl}/api/blog/${slug}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
// No cache for blog detail so comments/replies show immediately after submit + refresh
cache: 'no-store',
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('Blog post not found');
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: BlogDetailResponse = await response.json();
return data;
} catch (error) {
console.error('Error fetching blog detail:', error);
throw new Error(
`Failed to fetch blog detail: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};
/**
* Fetch featured blogs từ API
* @param limit - Số lượng blog featured (default: 3)
* @returns Promise<BlogFeaturedResponse>
* @throws Error nếu fetch thất bại
*/
export const fetchFeaturedBlogs = async (
limit: number = 5
): Promise<BlogFeaturedResponse> => {
const apiUrl = getApiUrl();
const url = `${apiUrl}/api/blog/featured?limit=${limit}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
next: { revalidate: 60 },
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: BlogFeaturedResponse = await response.json();
return data;
} catch (error) {
console.error('Error fetching featured blogs:', error);
throw new Error(
`Failed to fetch featured blogs: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};
/**
* Fetch recent blogs từ API
* @param limit - Số lượng blog recent (default: 5)
* @returns Promise<BlogRecentResponse>
* @throws Error nếu fetch thất bại
*/
export const fetchRecentBlogs = async (
limit: number = 5
): Promise<BlogRecentResponse> => {
const apiUrl = getApiUrl();
const url = `${apiUrl}/api/blog/recent?limit=${limit}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
next: { revalidate: 60 },
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: BlogRecentResponse = await response.json();
return data;
} catch (error) {
console.error('Error fetching recent blogs:', error);
throw new Error(
`Failed to fetch recent blogs: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};
/**
* Fetch categories list từ API
* @returns Promise<CategoryListResponse>
* @throws Error nếu fetch thất bại
*/
export const fetchCategories = async (): Promise<CategoryListResponse> => {
const apiUrl = getApiUrl();
const url = `${apiUrl}/api/blog/categories`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
next: { revalidate: 300 }, // Categories ít thay đổi, cache lâu hơn
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: CategoryListResponse = await response.json();
return data;
} catch (error) {
console.error('Error fetching categories:', error);
throw new Error(
`Failed to fetch categories: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};
/**
* Fetch category detail by slug từ API
* @param slug - Category slug
* @returns Promise<CategoryDetailResponse>
* @throws Error nếu fetch thất bại
*/
export const fetchCategoryDetail = async (
slug: string
): Promise<CategoryDetailResponse> => {
const apiUrl = getApiUrl();
const url = `${apiUrl}/api/blog/categories/${slug}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
next: { revalidate: 300 },
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('Category not found');
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: CategoryDetailResponse = await response.json();
return data;
} catch (error) {
console.error('Error fetching category detail:', error);
throw new Error(
`Failed to fetch category detail: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};
/**
* Fetch tags list từ API
* @returns Promise<TagListResponse>
* @throws Error nếu fetch thất bại
*/
export const fetchTags = async (): Promise<TagListResponse> => {
const apiUrl = getApiUrl();
const url = `${apiUrl}/api/blog/tags`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
next: { revalidate: 300 },
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: TagListResponse = await response.json();
return data;
} catch (error) {
console.error('Error fetching tags:', error);
throw new Error(
`Failed to fetch tags: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};
/**
* Fetch popular tags từ API
* @param limit - Số lượng tags (default: 10)
* @returns Promise<TagListResponse>
* @throws Error nếu fetch thất bại
*/
export const fetchPopularTags = async (
limit: number = 10
): Promise<TagListResponse> => {
const apiUrl = getApiUrl();
const url = `${apiUrl}/api/blog/tags/popular?limit=${limit}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
next: { revalidate: 300 },
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: TagListResponse = await response.json();
return data;
} catch (error) {
console.error('Error fetching popular tags:', error);
throw new Error(
`Failed to fetch popular tags: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};
/**
* Fetch tag detail by slug từ API
* @param slug - Tag slug
* @returns Promise<TagDetailResponse>
* @throws Error nếu fetch thất bại
*/
export const fetchTagDetail = async (
slug: string
): Promise<TagDetailResponse> => {
const apiUrl = getApiUrl();
const url = `${apiUrl}/api/blog/tags/${slug}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
next: { revalidate: 300 },
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('Tag not found');
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: TagDetailResponse = await response.json();
return data;
} catch (error) {
console.error('Error fetching tag detail:', error);
throw new Error(
`Failed to fetch tag detail: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
};
/**
* Fetch blogs by category từ API
* @param categorySlug - Category slug
* @param params - Query parameters (page, limit)
* @returns Promise<BlogListResponse>
* @throws Error nếu fetch thất bại
*/
export const fetchBlogsByCategory = async (
categorySlug: string,
params?: Omit<BlogQueryParams, 'category'>
): Promise<BlogListResponse> => {
// Lấy category name từ slug
const categoryResponse = await fetchCategoryDetail(categorySlug);
const categoryName = categoryResponse.data.name;
return fetchBlogList({
...params,
category: categoryName,
});
};
/**
* Fetch blogs by tag từ API
* @param tagSlug - Tag slug
* @param params - Query parameters (page, limit)
* @returns Promise<BlogListResponse>
* @throws Error nếu fetch thất bại
*/
export const fetchBlogsByTag = async (
tagSlug: string,
params?: Omit<BlogQueryParams, 'tag'>
): Promise<BlogListResponse> => {
// Lấy tag name từ slug
const tagResponse = await fetchTagDetail(tagSlug);
const tagName = tagResponse.data.name;
return fetchBlogList({
...params,
tag: tagName,
});
};

4
api/index.ts Normal file
View File

@@ -0,0 +1,4 @@
/**
* Export all API functions
*/
export * from './blog';

View File

@@ -0,0 +1,123 @@
"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 [content, setContent] = useState(initialContent);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(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;
}
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(),
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("");
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 (
<div>
<h3 className="mb-3">
{parentId ? (replyToName ? `Reply to @${replyToName}` : "Reply") : "Leave A Comment"}
</h3>
{error && <p className="text-danger mb-3">{error}</p>}
{success && <p className="text-success mb-3">{success}</p>}
<form onSubmit={onSubmit} className="contact-form-items">
<div className="row g-4">
<div className="col-lg-6">
<div className="form-clt">
<span>Your Name</span>
<input
type="text"
name="authorName"
placeholder="Your name"
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
disabled={isPending}
/>
</div>
</div>
<div className="col-lg-12">
<div className="form-clt">
<textarea
name="content"
placeholder="Type your comment"
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={isPending}
></textarea>
</div>
</div>
<div className="col-lg-12 wow fadeInUp" data-wow-delay=".3s">
<button type="submit" className="theme-btn" disabled={isPending}>
{isPending ? "Sending..." : "Send Comment"}
<i className="fa-solid fa-arrow-right"></i>
</button>
</div>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,232 @@
"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 [replyTarget, setReplyTarget] = useState<{
// Root comment id to store as parentId (keeps 1-level threading on backend)
parentId: string;
// The author we are replying to (used for @mention UI)
replyToName: string;
// Which item in the UI is showing the reply form under it
anchorId: string;
} | null>(null);
const { parents, repliesByParent } = useMemo(() => {
const parentsLocal = (comments || []).filter((c) => !c.parentId);
const replies = (comments || []).filter((c) => !!c.parentId);
const map = new Map<string, BlogComment[]>();
for (const r of replies) {
const pid = r.parentId as string;
map.set(pid, [...(map.get(pid) || []), r]);
}
return { parents: parentsLocal, repliesByParent: map };
}, [comments]);
// Collect all author names to detect full @mentions (including multi-word names)
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
.sort((a, b) => b.length - a.length);
}, [comments]);
const renderContentWithMention = (text?: string) => {
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à".
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
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
return text;
}
const mention = `@${matchedName}`;
const rest = text.slice(mention.length); // includes leading space if present
return (
<>
<strong>{mention}</strong>
{rest}
</>
);
};
return (
<div className="comments-area">
<div className="comments-heading">
<h3>
{(comments || []).length} {(comments || []).length === 1 ? "Comment" : "Comments"}
</h3>
</div>
{parents.map((comment, index) => {
const replies = comment._id ? repliesByParent.get(comment._id) || [] : [];
const isReplyingHere = !!comment._id && replyTarget?.anchorId === comment._id;
return (
<div key={comment._id || index}>
<div
className={`news-single-comment ${index % 2 === 1 ? "style-2" : ""
} d-flex gap-4 pt-4 pb-0`}
>
<div className="image">
<img
src={
comment.authorAvatar ||
`/assets/img/inner-page/news-details/comment-${(index % 3) + 1}.png`
}
alt={comment.authorName}
/>
</div>
<div className="content">
<div className="head d-flex flex-wrap gap-2 align-items-center justify-content-between">
<div className="con">
<span>{formatLongDate(comment.createdAt)}</span>
<h4>{comment.authorName}</h4>
</div>
{comment._id && (
<button
type="button"
className="reply"
onClick={() =>
setReplyTarget({
parentId: comment._id!,
replyToName: comment.authorName,
anchorId: comment._id!,
})
}
>
Reply
</button>
)}
</div>
<p className="mt-30 mb-4">{renderContentWithMention(comment.content)}</p>
{isReplyingHere && comment._id && (
<div className="mb-4">
<div className="d-flex justify-content-end mb-2">
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setReplyTarget(null)}
>
Cancel
</button>
</div>
<CommentForm
slug={slug}
parentId={replyTarget?.parentId || comment._id}
replyToName={replyTarget?.replyToName || comment.authorName}
initialContent={`@${replyTarget?.replyToName || comment.authorName} `}
onSubmitted={() => setReplyTarget(null)}
/>
</div>
)}
{/* Replies */}
{replies.length > 0 && (
<div className="replies">
{replies.map((reply, replyIndex) => (
<div key={reply._id || replyIndex}>
<div
className="news-single-comment d-flex gap-4 pt-4 pb-0"
>
<div className="image">
<img
src={
reply.authorAvatar ||
`/assets/img/inner-page/news-details/comment-${(replyIndex % 3) + 1}.png`
}
alt={reply.authorName}
/>
</div>
<div className="content">
<div className="head d-flex flex-wrap gap-2 align-items-center justify-content-between">
<div className="con">
<span>{formatLongDate(reply.createdAt)}</span>
<h4>{reply.authorName}</h4>
</div>
{reply._id && comment._id && (
<button
type="button"
className="reply"
onClick={() =>
setReplyTarget({
// Keep backend parentId as the root comment id (comment._id)
parentId: comment._id!,
// Mention the author we clicked reply on (child comment author)
replyToName: reply.authorName,
// Show form under this child comment
anchorId: reply._id!,
})
}
>
Reply
</button>
)}
</div>
<p className="mt-30 mb-4">{renderContentWithMention(reply.content)}</p>
</div>
</div>
{/* Reply form under child comment */}
{reply._id && replyTarget?.anchorId === reply._id && (
<div className="mb-4 mt-3">
<div className="d-flex justify-content-end mb-2">
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setReplyTarget(null)}
>
Cancel
</button>
</div>
<CommentForm
slug={slug}
parentId={replyTarget.parentId}
replyToName={replyTarget.replyToName}
initialContent={`@${replyTarget.replyToName} `}
onSubmitted={() => setReplyTarget(null)}
/>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
);
})}
{/* New top-level comment */}
<div className="mt-5">
<CommentForm slug={slug} />
</div>
</div>
);
}

View File

@@ -1,224 +1,116 @@
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) {
// Get comments from post (already included in API response)
const postComments = post.comments || [];
// Get base URL for EditorJS images
const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
// Convert EditorJS content to HTML
const renderContent = () => {
const html = editorjsToHtml(post.content, baseUrl);
return { __html: html };
};
// Convert EditorJS contentAfterQuote to HTML
const renderContentAfterQuote = () => {
const html = editorjsToHtml(post.contentAfterQuote, baseUrl);
return { __html: html };
};
export default function NewsDetailsContent() {
return (
<div className="col-lg-8 col-12">
<div className="news-details-post">
<div className="news-details-image">
<img src="/assets/img/inner-page/news-details/details-1.jpg" alt="img" />
<img
src={getCmsImageUrl(post.featuredImage) || "/assets/img/inner-page/news-details/details-1.jpg"}
alt={post.title}
/>
</div>
<div className="details-content">
<ul className="news-list">
<li>
<i className="fa-solid fa-user"></i>
By Admin
<i className="fa-solid fa-user"></i> By {post.author}
</li>
<li>
<i className="fa-solid fa-calendar-days"></i>
11 March 2025
<i className="fa-solid fa-calendar-days"></i> {post.publishedAt}
</li>
<li>
<i className="fa-solid fa-comments"></i>
0 Comments
<i className="fa-solid fa-comments"></i> {postComments.length} Comments
</li>
</ul>
<h2>Work Visa vs. Student Visa Which is Right for You?</h2>
<p>
Choosing between a work visa and a student visa depends on your career and academic goals. A
student visa allows you to pursue higher education abroad, gain international exposure, and
sometimes work part-time while studying. On the other hand, a work visa is for professionals
seeking employment opportunities and long-term career growth in another country.
</p>
<p className="mt-4 mb-3">
Both options have unique benefits, eligibility requirements, and future pathways. Understanding
your personal ambitions, financial plans, and long-term vision will help you decide which visa
option best suits your journey.
</p>
<h3>Work Visa vs. Student Visa: Which is Right for You?</h3>
<p className="mt-2 mb-3">
Choosing between a student visa and a work visa is a major decision that shapes your future
abroad. Both visa types open unique opportunities, but the right choice depends on your goals,
priorities, and resources. A student visa is designed for individuals who wish to pursue higher
education in a foreign country.
</p>
<div className="row g-4">
<div className="col-lg-6">
<h2>{post.title}</h2>
<div dangerouslySetInnerHTML={renderContent()} />
{/* Gallery Images */}
{post.galleryImages && post.galleryImages.length > 0 && (
<div className="row g-4 mt-4">
{post.galleryImages.map((image, index) => (
<div key={index} className={post.galleryImages!.length === 1 ? "col-12" : "col-lg-6"}>
<div className="thumb">
<img src="/assets/img/inner-page/news-details/details-2.jpg" alt="img" />
<img src={getCmsImageUrl(image)} alt={`${post.title} - Image ${index + 1}`} />
</div>
</div>
<div className="col-lg-6">
<div className="thumb">
<img src="/assets/img/inner-page/news-details/details-3.jpg" alt="img" />
))}
</div>
)}
{/* Quote/Sidebar */}
{post.quote && (
<div className="sideber mt-4 mb-3">
<h5>{post.quote}</h5>
</div>
</div>
<div className="sideber">
<h5>
This blog really helped me understand the difference between student and work visas. The
explanations were clear and practical.
</h5>
</div>
<p className="mb-3">
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.
</p>
<p>
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.
</p>
)}
{/* Content After Quote */}
{post.contentAfterQuote && (
<div dangerouslySetInnerHTML={renderContentAfterQuote()} />
)}
{/* Tags and Social Share */}
<div className="row tag-share-wrap mt-4 mb-5">
<div className="col-lg-8 col-12">
<div className="tagcloud">
<span>Tags:</span>
<Link href="/blog/work-visa">WorkVisa</Link>
<Link href="/blog/family-visa">FamilyVisa</Link>
<Link href="/blog/student-visa">StudentVisa</Link>
{post.tags.map((tagName) => {
// Generate slug from tag name (Vietnamese-friendly)
const tagSlug = toSlug(tagName);
return (
<Link key={tagName} href={`/blog/tag/${tagSlug}`}>
{tagName}
</Link>
);
})}
</div>
</div>
<div className="col-lg-4 col-12 mt-3 mt-lg-0 text-lg-end">
<div className="social-share">
<a href="#">
<a href="#" aria-label="Share on Twitter">
<i className="fab fa-twitter"></i>
</a>
<a href="#">
<a href="#" aria-label="Share on YouTube">
<i className="fa-brands fa-youtube"></i>
</a>
<a href="#">
<a href="#" aria-label="Share on LinkedIn">
<i className="fab fa-linkedin-in"></i>
</a>
<a href="#">
<a href="#" aria-label="Share on Facebook">
<i className="fab fa-facebook-f"></i>
</a>
</div>
</div>
</div>
<div className="comments-area">
<div className="comments-heading">
<h3>02 Comments</h3>
</div>
<div className="news-single-comment d-flex gap-4 pt-4 pb-0">
<div className="image">
<img src="/assets/img/inner-page/news-details/comment-1.png" alt="image" />
</div>
<div className="content">
<div className="head d-flex flex-wrap gap-2 align-items-center justify-content-between">
<div className="con">
<span>February 10, 2024</span>
<h4>Frank Flores</h4>
</div>
<Link href="/blog/work-visa" className="reply">
Reply
</Link>
</div>
<p className="mt-30 mb-4">
Neque porro est qui dolorem ipsum quia quaed inventor veritatis et quasi architecto var sed
efficitur turpis gilla sed sit amet finibus eros. Lorem Ipsum is simply dummy
</p>
</div>
</div>
<div className="news-single-comment style-2 d-flex gap-4 pt-4 pb-0">
<div className="image">
<img src="/assets/img/inner-page/news-details/comment-2.png" alt="image" />
</div>
<div className="content">
<div className="head d-flex flex-wrap gap-2 align-items-center justify-content-between">
<div className="con">
<h4>Charlie Tushar</h4>
<span>February 10, 2024</span>
</div>
<Link href="/blog/work-visa" className="reply">
Reply
</Link>
</div>
<p className="mt-30 mb-4">
Neque porro est qui dolorem ipsum quia quaed inventor veritatis et quasi architecto var sed
efficitur turpis gilla sed sit amet finibus eros. Lorem Ipsum is simply dummy
</p>
</div>
</div>
<div className="news-single-comment d-flex gap-4 pt-4 pb-4">
<div className="image">
<img src="/assets/img/inner-page/news-details/comment-3.png" alt="image" />
</div>
<div className="content">
<div className="head d-flex flex-wrap gap-2 align-items-center justify-content-between">
<div className="con">
<span>February 10, 2024 </span>
<h4>Fatma Sariqul</h4>
</div>
<Link href="/blog/work-visa" className="reply">
Reply
</Link>
</div>
<p className="mt-30 mb-4">
Neque porro est qui dolorem ipsum quia quaed inventor veritatis et quasi architecto var sed
efficitur turpis gilla sed sit amet finibus eros. Lorem Ipsum is simply dummy
</p>
</div>
</div>
</div>
<h3 className="mb-3">Leave A Comment</h3>
<form
action="contact.php"
id="contact-form1"
method="POST"
className="contact-form-items"
>
<div className="row g-4">
<div className="col-lg-4">
<div className="form-clt">
<span>Your Name</span>
<input type="text" name="name" id="name331" placeholder="Your name" />
</div>
</div>
<div className="col-lg-4">
<div className="form-clt">
<span>Your Email</span>
<input type="text" name="name" id="email11" placeholder="Your email" />
</div>
</div>
<div className="col-lg-4">
<div className="form-clt">
<span>Your Phone</span>
<input type="text" name="name" id="name22" placeholder="Phone Number" />
</div>
</div>
<div className="col-lg-6">
<div className="form-clt">
<span>Your Address</span>
<input type="text" name="name" id="name24" placeholder="Address Now" />
</div>
</div>
<div className="col-lg-6">
<div className="form-clt">
<span>Your Date</span>
<input type="text" name="name" id="name25" placeholder="Date" />
</div>
</div>
<div className="col-lg-12">
<div className="form-clt">
<textarea
name="message"
id="message1"
placeholder="Type your message"
></textarea>
</div>
</div>
<div className="col-lg-12 wow fadeInUp" data-wow-delay=".3s">
<button type="submit" className="theme-btn">
Send Message
<i className="fa-solid fa-arrow-right"></i>
</button>
</div>
</div>
</form>
<CommentsSection slug={post.slug} comments={postComments} />
</div>
</div>
</div>

View File

@@ -1,13 +1,18 @@
import NewsDetailsContent from "./NewsDetailsContent";
import Sidebar from "@/app/blog/components/Sidebar";
import type { BlogPost } from "@/types/blog";
export default function NewsDetailsSection() {
interface NewsDetailsSectionProps {
post: BlogPost;
}
export default function NewsDetailsSection({ post }: NewsDetailsSectionProps) {
return (
<section className="news-standard-section section-padding fix">
<div className="container">
<div className="news-details-wrapper">
<div className="row g-4">
<NewsDetailsContent />
<NewsDetailsContent post={post} />
<Sidebar />
</div>
</div>

View File

@@ -1,17 +1,72 @@
import Link from "next/link";
import Breadcrumb from "@/app/components/Breadcrumb";
import NewsDetailsSection from "./components/NewsDetailsSection";
import { fetchBlogList, fetchBlogDetail } from "@/api/blog";
import Sidebar from "@/app/blog/components/Sidebar";
// 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: {
params: Promise<{
slug: string;
}> | {
slug: string;
};
}
export default function BlogDetailsPage(_props: BlogDetailsPageProps) {
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 (
<>
<Breadcrumb title="Blog Details" current="Blog Details" />
<NewsDetailsSection />
<section className="news-standard-section section-padding fix">
<div className="container">
<div className="news-standard-wrapper">
<div className="row g-4">
<div className="col-lg-8 col-12">
<div className="py-5">
<h2>Post not found</h2>
<p>The blog post you are looking for does not exist.</p>
<Link href="/blog" className="theme-btn mt-3">
Back to Blog <i className="fa-solid fa-arrow-right"></i>
</Link>
</div>
</div>
{/* Sidebar on the right */}
<Sidebar />
</div>
</div>
</div>
</section>
</>
);
}
const post = blogResponse.data;
return (
<>
<Breadcrumb title={post.title} current="Blog Details" />
<NewsDetailsSection post={post} />
</>
);
}

View File

@@ -1,93 +1,170 @@
{
"title": "Blog & Tin Tức",
"subtitle": "Cập nhật thông tin mới nhất về visa và du lịch",
"featured": {
"id": "visa-schengen-2024",
"title": "Hướng Dẫn Xin Visa Schengen 2024 - Cập Nhật Mới Nhất",
"excerpt": "Thủ tục xin visa Schengen đã có những thay đổi quan trọng trong năm 2024. Cùng tìm hiểu chi tiết...",
"image": "/images/schengen-visa.jpg",
"date": "2024-01-15",
"author": "Nguyễn Văn A",
"category": "Visa",
"readTime": "5 phút đọc"
},
"categories": [
{
"name": "Tất cả",
"slug": "all",
"count": 24
"name": "Visa & Immigration",
"slug": "visa-immigration",
"description": "Tin tức và hướng dẫn về visa, định cư."
},
{
"name": "Visa",
"slug": "visa",
"count": 12
"name": "Study Abroad",
"slug": "study-abroad",
"description": "Kinh nghiệm du học, trường học, học bổng."
},
{
"name": "Du lịch",
"slug": "travel",
"count": 8
"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": "Thủ tục",
"slug": "procedures",
"count": 4
"name": "StudentVisa",
"slug": "student-visa"
},
{
"name": "Canada",
"slug": "canada"
},
{
"name": "Scholarship",
"slug": "scholarship"
},
{
"name": "TravelSafety",
"slug": "travel-safety"
}
],
"posts": [
{
"id": "visa-my-2024",
"title": "Thay Đổi Mới Trong Thủ Tục Xin Visa Mỹ 2024",
"excerpt": "Lãnh sự quán Mỹ đã công bố những thay đổi quan trọng trong quy trình xin visa...",
"image": "/images/us-visa.jpg",
"date": "2024-01-10",
"author": "Trần Thị B",
"category": "Visa",
"readTime": "7 phút đọc",
"tags": ["Visa Mỹ", "Thủ tục", "2024"]
"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": "<p>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.</p>",
"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": "<p class=\"mb-3\">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.</p><p>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.</p>",
"commentsCount": 3
},
{
"id": "du-lich-nhat-ban",
"title": "Top 10 Địa Điểm Du Lịch Nhật Bản Không Thể Bỏ Qua",
"excerpt": "Khám phá những điểm đến tuyệt vời nhất tại đất nước mặt trời mọc...",
"image": "/images/japan-travel.jpg",
"date": "2024-01-08",
"author": "Lê Văn C",
"category": "Du lịch",
"readTime": "6 phút đọc",
"tags": ["Nhật Bản", "Du lịch", "Điểm đến"]
"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": "<p>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.</p><p class=\"mt-4 mb-3\">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ế.</p>",
"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": "<p class=\"mb-3\">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.</p><p>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.</p>",
"commentsCount": 2
},
{
"id": "visa-han-quoc-tips",
"title": "Bí Quyết Xin Visa Hàn Quốc Thành Công 100%",
"excerpt": "Những kinh nghiệm quý báu từ chuyên gia để tăng tỷ lệ thành công...",
"image": "/images/korea-visa.jpg",
"date": "2024-01-05",
"author": "Phạm Thị D",
"category": "Visa",
"readTime": "8 phút đọc",
"tags": ["Visa Hàn Quốc", "Kinh nghiệm", "Thành công"]
"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": "<p>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.</p><p class=\"mt-4 mb-3\">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ó.</p>",
"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": "<p class=\"mb-3\">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.</p><p>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.</p>",
"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"
},
{
"id": "du-lich-chau-au",
"title": "Lịch Trình Du Lịch Châu Âu 14 Ngày Hoàn Hảo",
"excerpt": "Khám phá 7 quốc gia châu Âu với lịch trình được thiết kế tối ưu...",
"image": "/images/europe-travel.jpg",
"date": "2024-01-03",
"author": "Hoàng Văn E",
"category": "Du lịch",
"readTime": "10 phút đọc",
"tags": ["Châu Âu", "Lịch trình", "14 ngày"]
"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"
},
{
"id": "thu-tuc-ho-so",
"title": "Checklist Hồ Sơ Xin Visa - Không Bỏ Sót Gì",
"excerpt": "Danh sách chi tiết các giấy tờ cần thiết cho từng loại visa...",
"image": "/images/documents.jpg",
"date": "2024-01-01",
"author": "Vũ Thị F",
"category": "Thủ tục",
"readTime": "4 phút đọc",
"tags": ["Hồ sơ", "Checklist", "Visa"]
"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
}
]
}

View File

@@ -0,0 +1,58 @@
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";
interface CategoryPageProps {
params: Promise<{
slug: string;
}> | {
slug: string;
};
}
export default async function CategoryPage({ params }: CategoryPageProps) {
// Handle both Promise and direct object
const resolvedParams = params instanceof Promise ? await params : params;
const slug = resolvedParams.slug;
// Fetch category detail and blogs
let categoryResponse, blogsResponse;
try {
[categoryResponse, blogsResponse] = await Promise.all([
fetchCategoryDetail(slug),
fetchBlogsByCategory(slug, { page: 1, limit: 10 }),
]);
} catch {
return (
<>
<Breadcrumb title="Category" current="Blog Category" />
<section className="news-standard-section section-padding fix">
<div className="container">
<div className="news-standard-wrapper">
<div className="row g-4">
<div className="col-lg-8 col-12">
<div className="py-5">
<h2>Category not found</h2>
<p>The category you are looking for does not exist.</p>
</div>
</div>
<Sidebar />
</div>
</div>
</div>
</section>
</>
);
}
const category = categoryResponse.data;
const blogs = blogsResponse.data.blogs;
return (
<>
<Breadcrumb title={category.name} current="Blog Category" />
<NewsSection blogs={blogs} categorySlug={slug} />
</>
);
}

View File

@@ -1,92 +1,62 @@
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
}
export default function NewsList() {
return (
<div className="col-lg-8 col-12">
{/* News Post 1 */}
<div className="news-standard-post">
{posts.map((post, index) => (
<div
key={post.slug}
className={`news-standard-post ${index === posts.length - 1 ? "mb-0" : ""}`}
>
<div className="news-image">
<img src="/assets/img/home-1/news/news-13.jpg" alt="img" />
<img
src={
post.featuredImage
? getCmsImageUrl(post.featuredImage)
: "/assets/img/inner-page/news-details/details-1.jpg"
}
alt={post.title}
/>
</div>
<div className="news-content">
<ul className="news-list">
<li>
<i className="fa-solid fa-user"></i> By Admin
<i className="fa-solid fa-user"></i> By {post.author}
</li>
<li>
<i className="fa-solid fa-calendar-days"></i> 11 March 2025
<i className="fa-solid fa-calendar-days"></i> {post.publishedAt}
</li>
<li>
<i className="fa-solid fa-comments"></i> 0 Comments
<i className="fa-solid fa-comments"></i> {post.commentsCount} Comments
</li>
</ul>
<h3>
<Link href="/news-details">How to Avoid Common Mistakes in Visa Applications</Link>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</h3>
<p>
A business consultant provides expert guidance, strategic planning, and problem-solving supporthelping startups avoid mistakes, grow faster, and operate more efficiently from day one.
</p>
<Link href="/blog/news-details" className="theme-btn">
VIEW MORE <i className="fa-solid fa-arrow-right"></i>
</Link>
</div>
</div>
{/* News Post 2 */}
<div className="news-standard-post">
<div className="news-image">
<img src="/assets/img/home-1/news/news-14.jpg" alt="img" />
</div>
<div className="news-content">
<ul className="news-list">
<li>
<i className="fa-solid fa-user"></i> By Admin
</li>
<li>
<i className="fa-solid fa-calendar-days"></i> 11 March 2025
</li>
<li>
<i className="fa-solid fa-comments"></i> 0 Comments
</li>
</ul>
<h3>
<Link href="/news-details">The Role of Immigration Consultants in Your Journey</Link>
</h3>
<p>
Immigration consultants play a vital role in guiding applicants, simplifying complex processes, offering expert advice, and ensuring successful outcomes for study, work, or permanent residency abroad.
</p>
<Link href="/blog/news-details" className="theme-btn">
VIEW MORE <i className="fa-solid fa-arrow-right"></i>
</Link>
</div>
</div>
{/* News Post 3 */}
<div className="news-standard-post mb-0">
<div className="news-image">
<img src="/assets/img/home-1/news/news-15.jpg" alt="img" />
</div>
<div className="news-content">
<ul className="news-list">
<li>
<i className="fa-solid fa-user"></i> By Admin
</li>
<li>
<i className="fa-solid fa-calendar-days"></i> 11 March 2025
</li>
<li>
<i className="fa-solid fa-comments"></i> 0 Comments
</li>
</ul>
<h3>
<Link href="/news-details">Latest Immigration Policy Updates You Should Know</Link>
</h3>
<p>
Stay informed with the latest immigration policy updates, ensuring you understand new rules, visa requirements, and opportunities that impact your study, work, or migration journey abroad.
</p>
<Link href="/blog/news-details" className="theme-btn">
<p>{post.excerpt}</p>
<Link href={`/blog/${post.slug}`} className="theme-btn">
VIEW MORE <i className="fa-solid fa-arrow-right"></i>
</Link>
</div>
</div>
))}
</div>
);
}

View File

@@ -1,15 +1,38 @@
import NewsList from "./NewsList";
import Sidebar from "./Sidebar";
import Pagination from "./Pagination";
import type { BlogPost, BlogPagination } from "@/types";
export default function NewsSection() {
interface NewsSectionProps {
blogs?: BlogPost[];
categorySlug?: string;
tagSlug?: string;
searchQuery?: string;
pagination?: BlogPagination;
}
export default function NewsSection({
blogs,
categorySlug,
tagSlug,
searchQuery,
pagination,
}: NewsSectionProps) {
return (
<section className="news-standard-section section-padding fix">
<div className="container">
<div className="news-standard-wrapper">
<div className="row g-4">
<NewsList />
<Sidebar />
<NewsList blogs={blogs} categorySlug={categorySlug} tagSlug={tagSlug} />
<Sidebar searchQuery={searchQuery} />
</div>
{pagination && pagination.total > 1 && (
<div className="row g-4 mt-4">
<div className="col-12">
<Pagination basePath="/blog" pagination={pagination} searchQuery={searchQuery} />
</div>
</div>
)}
</div>
</div>
</section>

View File

@@ -0,0 +1,79 @@
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 (
<nav aria-label="Blog pagination" className="mt-4">
<ul className="pagination justify-content-center">
{current > 1 && (
<li className="page-item">
<Link className="page-link" href={makeHref(current - 1)}>
Previous
</Link>
</li>
)}
{items.map((item, idx) =>
item === "..." ? (
<li key={`ellipsis-${idx}`} className="page-item disabled">
<span className="page-link">...</span>
</li>
) : (
<li key={item} className={`page-item ${item === current ? "active" : ""}`}>
{item === current ? (
<span className="page-link">{item}</span>
) : (
<Link className="page-link" href={makeHref(item as number)}>
{item}
</Link>
)}
</li>
),
)}
{current < total && (
<li className="page-item">
<Link className="page-link" href={makeHref(current + 1)}>
Next
</Link>
</li>
)}
</ul>
</nav>
);
}

View File

@@ -1,14 +1,35 @@
import Link from "next/link";
import { fetchCategories, fetchRecentBlogs, fetchPopularTags } from "@/api/blog";
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;
export default function Sidebar() {
return (
<div className="col-lg-4 col-12">
<div className="main-sideber">
{/* Search Widget */}
<div className="news-sideber-box">
<div className="search-widget">
<form action="#">
<input type="text" placeholder="Search Blog" />
<form action="/blog" method="GET">
<input
type="text"
name="search"
placeholder="Search Blog"
defaultValue={searchQuery || ""}
/>
<button type="submit">
<i className="fa-solid fa-magnifying-glass"></i>
</button>
@@ -22,11 +43,17 @@ export default function Sidebar() {
</div>
<div className="news-widget-categories">
<ul>
<li><Link href="/news-details"><i className="fa-solid fa-chevrons-right"></i> Permanent Residency (PR)</Link><span>(04)</span></li>
<li><Link href="/news-details"><i className="fa-solid fa-chevrons-right"></i> Immigration Policy Updates</Link><span>(09)</span></li>
<li><Link href="/news-details"><i className="fa-solid fa-chevrons-right"></i> Scholarships & Grants</Link><span>(00)</span></li>
<li><Link href="/news-details"><i className="fa-solid fa-chevrons-right"></i> Citizenship & Naturalization</Link><span>(04)</span></li>
<li><Link href="/news-details"><i className="fa-solid fa-chevrons-right"></i> Visa Interview Preparation</Link><span>(01)</span></li>
{categories.map((category) => {
const postCount = category.postCount || 0;
return (
<li key={category.slug}>
<Link href={`/blog/category/${category.slug}`}>
<i className="fa-solid fa-chevrons-right"></i> {category.name}
</Link>
<span>({String(postCount).padStart(2, "0")})</span>
</li>
);
})}
</ul>
</div>
</div>
@@ -36,45 +63,27 @@ export default function Sidebar() {
<h3>Recent Post</h3>
</div>
<div className="recent-post-area">
<div className="recent-items">
{recentPosts.map((post) => (
<div key={post.slug} className="recent-items">
<div className="recent-thumb">
<img src="/assets/img/inner-page/news-details/post-1.jpg" alt="img" />
<img
src={post.thumbnail || "/assets/img/inner-page/news-details/details-1.jpg"}
alt={post.title}
width={88}
height={80}
style={{ objectFit: 'cover' }}
/>
</div>
<div className="recent-content">
<h6>
<Link href="/news-details">Top Countries for Higher Education in 2025</Link>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</h6>
<ul>
<li>March 26, 2025</li>
</ul>
</div>
</div>
<div className="recent-items">
<div className="recent-thumb">
<img src="/assets/img/inner-page/news-details/post-2.jpg" alt="img" />
</div>
<div className="recent-content">
<h6>
<Link href="/news-details">The Benefits of Hiring a Visa Consultant</Link>
</h6>
<ul>
<li>March 26, 2025</li>
</ul>
</div>
</div>
<div className="recent-items">
<div className="recent-thumb">
<img src="/assets/img/inner-page/news-details/post-3.jpg" alt="img" />
</div>
<div className="recent-content">
<h6>
<Link href="/news-details">How to Prepare for Your Immigration Interview</Link>
</h6>
<ul>
<li>March 26, 2025</li>
<li>{post.publishedAt}</li>
</ul>
</div>
</div>
))}
</div>
</div>
{/* Tag Cloud */}
@@ -84,12 +93,11 @@ export default function Sidebar() {
</div>
<div className="news-widget-categories">
<div className="tagcloud">
<Link href="/news-details">WorkVisa</Link>
<Link href="/news-details">FamilyVisa</Link>
<Link href="/news-details">StudentVisa</Link>
<Link href="/news-details">VisaUpdates</Link>
<Link href="/news-details">TravelVisa</Link>
<Link href="/news-details">StudyAbroad</Link>
{tags.map((tag) => (
<Link key={tag.slug} href={`/blog/tag/${tag.slug}`}>
{tag.name}
</Link>
))}
</div>
</div>
</div>

View File

@@ -1,11 +1,33 @@
import Breadcrumb from "@/app/components/Breadcrumb";
import NewsSection from "./components/NewsSection";
import { fetchBlogList } from "@/api/blog";
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: 10,
...(searchQuery ? { search: searchQuery } : {}),
});
const { blogs, pagination } = blogResponse.data;
export default function NewsPage() {
return (
<>
<Breadcrumb title="Blog Stardard" current="Blog Stardard" />
<NewsSection />
<Breadcrumb
title={searchQuery ? `Search: ${searchQuery}` : "Blog Standard"}
current="Blog Standard"
/>
<NewsSection blogs={blogs} searchQuery={searchQuery} pagination={pagination} />
</>
);
}

View File

@@ -0,0 +1,58 @@
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/blog";
interface TagPageProps {
params: Promise<{
slug: string;
}> | {
slug: string;
};
}
export default async function TagPage({ params }: TagPageProps) {
// Handle both Promise and direct object
const resolvedParams = params instanceof Promise ? await params : params;
const slug = resolvedParams.slug;
// Fetch tag detail and blogs
let tagResponse, blogsResponse;
try {
[tagResponse, blogsResponse] = await Promise.all([
fetchTagDetail(slug),
fetchBlogsByTag(slug, { page: 1, limit: 10 }),
]);
} catch {
return (
<>
<Breadcrumb title="Tag" current="Blog Tag" />
<section className="news-standard-section section-padding fix">
<div className="container">
<div className="news-standard-wrapper">
<div className="row g-4">
<div className="col-lg-8 col-12">
<div className="py-5">
<h2>Tag not found</h2>
<p>The tag you are looking for does not exist.</p>
</div>
</div>
<Sidebar />
</div>
</div>
</div>
</section>
</>
);
}
const tag = tagResponse.data;
const blogs = blogsResponse.data.blogs;
return (
<>
<Breadcrumb title={tag.name} current="Blog Tag" />
<NewsSection blogs={blogs} tagSlug={slug} />
</>
);
}

View File

@@ -1,13 +1,12 @@
import type { Metadata } from "next";
import "./globals.css";
import Header from "./components/Header";
import Footer from "./components/Footer";
import Loader from "./components/Loader";
import BackToTop from "./components/BackToTop";
import MouseCursor from "./components/MouseCursor";
import Script from "next/script";
import Header from "./components/layout/Header/Header";
import Footer from "./components/layout/Footer/Footer";
export const metadata: Metadata = {
title: "Visaway Immigration & Visa Consulting HTML Template",
@@ -49,11 +48,8 @@ export default function RootLayout({
<BackToTop />
<MouseCursor />
<Header />
{children}
<Footer />
{/* Scripts */}
<Script src="/assets/js/jquery-3.7.1.min.js" strategy="beforeInteractive" />
<Script src="/assets/js/viewport.jquery.js" strategy="afterInteractive" />

View File

@@ -6242,6 +6242,10 @@ html.lenis, html.lenis body {
line-height: 133%;
font-weight: 500;
font-size: 18px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (max-width: 1399px) {
.main-sideber .news-sideber-box .recent-post-area .recent-items .recent-content h6 {

View File

@@ -565,6 +565,10 @@
line-height: 133%;
font-weight: 500;
font-size: 18px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
@include breakpoint (max-xxl) {
font-size: 15px;
@@ -640,7 +644,9 @@
.news-details-image {
img {
@include imgw;
width: 852px;
height: 400px;
object-fit: cover;
border-radius: 16px;
}
}
@@ -676,7 +682,9 @@
.thumb {
img {
@include imgw;
width: 410px;
height: 264px;
object-fit: cover;
border-radius: 16px;
}
}

136
types/blog.ts Normal file
View File

@@ -0,0 +1,136 @@
/**
* Blog Types
* Định nghĩa các interface cho Blog system
*/
export interface BlogCategory {
_id?: string;
name: string;
slug: string;
description?: string;
postCount?: number;
isActive?: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface BlogTag {
_id?: string;
name: string;
slug: string;
postCount?: number;
isActive?: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface BlogComment {
_id?: string;
postId?: string;
authorName: string;
authorAvatar?: string;
content: string;
createdAt: string;
status?: 'pending' | 'approved' | 'rejected';
parentId?: string;
parentAuthorName?: string | null;
}
export interface BlogPost {
_id?: string;
title: string;
slug: string;
excerpt: string;
content: string;
category: string[];
tags: string[];
author: string;
status: 'draft' | 'published';
publishedAt: string;
isFeatured: boolean;
featuredImage: string;
galleryImages?: string[];
quote?: string;
contentAfterQuote?: string;
commentsCount: number;
comments?: BlogComment[];
createdAt?: string;
updatedAt?: string;
}
export interface RecentPost {
_id?: string;
title: string;
slug: string;
thumbnail: string;
publishedAt: string;
originalPostId?: string;
createdAt?: string;
updatedAt?: string;
}
export interface BlogPagination {
current: number;
total: number;
limit: number;
totalItems: number;
}
export interface BlogListResponse {
success: boolean;
message: string;
data: {
blogs: BlogPost[];
pagination: BlogPagination;
};
}
export interface BlogDetailResponse {
success: boolean;
message: string;
data: BlogPost;
}
export interface BlogFeaturedResponse {
success: boolean;
message: string;
data: BlogPost[];
}
export interface BlogRecentResponse {
success: boolean;
message: string;
data: RecentPost[];
}
export interface CategoryListResponse {
success: boolean;
message: string;
data: BlogCategory[];
}
export interface CategoryDetailResponse {
success: boolean;
message: string;
data: BlogCategory;
}
export interface TagListResponse {
success: boolean;
message: string;
data: BlogTag[];
}
export interface TagDetailResponse {
success: boolean;
message: string;
data: BlogTag;
}
export interface BlogQueryParams {
page?: number;
limit?: number;
category?: string;
tag?: string;
search?: string;
}

4
types/index.ts Normal file
View File

@@ -0,0 +1,4 @@
/**
* Export all types
*/
export * from './blog';

21
utils/date.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* Format a date to `20 March 2025` style.
*
* Accepts:
* - ISO string: "2025-03-20T07:59:53.219Z"
* - "2025-03-20" or other Dateparsable strings
* - Date instance
*/
export function formatLongDate(input: string | Date): string {
if (!input) return "";
const date = input instanceof Date ? input : new Date(input);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
});
}

397
utils/editorjsToHtml.ts Normal file
View File

@@ -0,0 +1,397 @@
/**
* Convert EditorJS JSON data to HTML
* Supports: header, paragraph, list, image, quote, marker, embed, code, table, delimiter,
* checklist, link, inlineCode, warning, alert, raw, personality, and more
*/
interface EditorJSBlockData {
text?: string;
level?: number;
items?: string[] | Array<{ text?: string; checked?: boolean }>;
style?: 'ordered' | 'unordered';
file?: { url?: string };
url?: string;
caption?: string;
withBorder?: boolean;
withBackground?: boolean;
stretched?: boolean;
alignment?: 'left' | 'center' | 'right';
service?: string;
source?: string;
embed?: string;
width?: number;
height?: number;
code?: string;
language?: string;
content?: string[][];
link?: string;
meta?: { url?: string; title?: string; description?: string; image?: string };
title?: string;
message?: string;
html?: string;
htmlCode?: string;
name?: string;
description?: string;
photo?: string;
linkText?: string;
linkUrl?: string;
type?: string;
}
interface EditorJSBlock {
id?: string;
type: string;
data: EditorJSBlockData;
}
interface EditorJSData {
time?: number;
blocks: EditorJSBlock[];
version?: string;
}
/**
* Convert EditorJS block to HTML
*/
function blockToHtml(block: EditorJSBlock, baseUrl?: string): string {
const { type, data } = block;
switch (type) {
case 'header': {
const level = data.level || 2;
const text = escapeHtml(data.text || '');
return `<h${level}>${text}</h${level}>`;
}
case 'paragraph': {
let text = data.text || '';
// Convert markdown-style links to HTML
text = text.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2">$1</a>'
);
// Convert **bold** to <strong>
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Convert *italic* to <em>
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
// Convert `inline code` to <code>
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
// Convert line breaks
text = text.replace(/\n/g, '<br>');
return `<p>${text}</p>`;
}
case 'list': {
const items = data.items || [];
const style = data.style || 'unordered';
const tag = style === 'ordered' ? 'ol' : 'ul';
const itemsHtml = items
.map((item: { text?: string; checked?: boolean }) => {
const escapedItem = escapeHtml(item.text || '');
return `<li>${escapedItem}</li>`;
})
.join('');
return `<${tag}>${itemsHtml}</${tag}>`;
}
case 'image': {
const url = data.file?.url || data.url || '';
const caption = data.caption || '';
const withBorder = data.withBorder || false;
const withBackground = data.withBackground || false;
const stretched = data.stretched || false;
let imageUrl = url;
if (baseUrl && imageUrl && !imageUrl.startsWith('http')) {
if (imageUrl.startsWith('/')) {
imageUrl = `${baseUrl}${imageUrl}`;
} else {
imageUrl = `${baseUrl}/${imageUrl}`;
}
}
const classes: string[] = [];
if (withBorder) classes.push('with-border');
if (withBackground) classes.push('with-background');
if (stretched) classes.push('stretched');
const classAttr = classes.length > 0 ? ` class="${classes.join(' ')}"` : '';
const captionHtml = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : '';
return `<figure${classAttr}>
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(caption)}" />
${captionHtml}
</figure>`;
}
case 'quote': {
const text = escapeHtml(data.text || '');
const caption = escapeHtml(data.caption || '');
const alignment = data.alignment || 'left';
const captionHtml = caption ? `<cite>${caption}</cite>` : '';
return `<blockquote class="quote-${alignment}">
<p>${text}</p>
${captionHtml}
</blockquote>`;
}
case 'marker': {
const text = data.text || '';
// Marker typically highlights text, so we'll use <mark>
return `<mark>${escapeHtml(text)}</mark>`;
}
case 'embed': {
const service = data.service || '';
const embed = data.embed || '';
const width = data.width || 600;
const height = data.height || 400;
const caption = data.caption || '';
if (service === 'youtube' || service === 'vimeo') {
const captionHtml = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : '';
return `<figure class="embed-${service}">
<iframe src="${escapeHtml(embed)}" width="${width}" height="${height}" frameborder="0" allowfullscreen></iframe>
${captionHtml}
</figure>`;
}
// Fallback for other embed types
return `<div class="embed embed-${service}">
${embed}
${caption ? `<p class="embed-caption">${escapeHtml(caption)}</p>` : ''}
</div>`;
}
case 'code': {
const code = data.code || '';
const language = data.language || '';
const langAttr = language ? ` class="language-${language}"` : '';
return `<pre><code${langAttr}>${escapeHtml(code)}</code></pre>`;
}
case 'delimiter': {
return '<div class="delimiter">***</div>';
}
case 'table': {
const content = data.content || [];
if (content.length === 0) return '';
// First row is usually header
const headerRow = content[0] || [];
const headerCells = headerRow
.map((cell: string) => `<th>${escapeHtml(cell)}</th>`)
.join('');
// Rest are body rows
const bodyRows = content.slice(1)
.map((row: string[]) => {
const cells = row
.map((cell: string) => `<td>${escapeHtml(cell)}</td>`)
.join('');
return `<tr>${cells}</tr>`;
})
.join('');
return `<table>
${headerCells ? `<thead><tr>${headerCells}</tr></thead>` : ''}
<tbody>${bodyRows}</tbody>
</table>`;
}
case 'checklist': {
const items = data.items || [];
const itemsHtml = items
.map((item: { text?: string; checked?: boolean }) => {
const text = escapeHtml(item.text || '');
const checked = item.checked ? ' checked' : '';
return `<div class="checklist-item">
<input type="checkbox"${checked} disabled>
<label>${text}</label>
</div>`;
})
.join('');
return `<div class="checklist">${itemsHtml}</div>`;
}
case 'linkTool':
case 'link': {
const link = data.link || data.url || '';
const meta = data.meta || {};
const title = meta.title || data.title || link;
const description = meta.description || '';
const image = meta.image || '';
let imageHtml = '';
if (image) {
let imageUrl = image;
if (baseUrl && imageUrl && !imageUrl.startsWith('http')) {
imageUrl = imageUrl.startsWith('/') ? `${baseUrl}${imageUrl}` : `${baseUrl}/${imageUrl}`;
}
imageHtml = `<div class="link-image">
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(title)}" />
</div>`;
}
return `<div class="link-block">
<a href="${escapeHtml(link)}" target="_blank" rel="noopener noreferrer">
${imageHtml}
<div class="link-content">
<div class="link-title">${escapeHtml(title)}</div>
${description ? `<div class="link-description">${escapeHtml(description)}</div>` : ''}
<div class="link-url">${escapeHtml(link)}</div>
</div>
</a>
</div>`;
}
case 'inlineCode': {
const code = data.text || '';
return `<code class="inline-code">${escapeHtml(code)}</code>`;
}
case 'warning': {
const title = data.title || 'Warning';
const message = data.message || '';
return `<div class="warning-block">
<div class="warning-title">⚠️ ${escapeHtml(title)}</div>
<div class="warning-message">${escapeHtml(message)}</div>
</div>`;
}
case 'alert': {
const type = data.type || 'info';
const title = data.title || '';
const message = data.message || '';
return `<div class="alert alert-${type}">
${title ? `<div class="alert-title">${escapeHtml(title)}</div>` : ''}
<div class="alert-message">${escapeHtml(message)}</div>
</div>`;
}
case 'raw': {
const html = data.html || data.htmlCode || '';
// Return raw HTML (be careful with XSS)
return html;
}
case 'personality': {
const name = data.name || '';
const description = data.description || '';
const photo = data.photo || '';
const linkText = data.linkText || '';
const linkUrl = data.linkUrl || '';
let photoHtml = '';
if (photo) {
let photoUrl = photo;
if (baseUrl && photoUrl && !photoUrl.startsWith('http')) {
photoUrl = photoUrl.startsWith('/') ? `${baseUrl}${photoUrl}` : `${baseUrl}/${photoUrl}`;
}
photoHtml = `<div class="personality-photo">
<img src="${escapeHtml(photoUrl)}" alt="${escapeHtml(name)}" />
</div>`;
}
const linkHtml = linkUrl && linkText
? `<a href="${escapeHtml(linkUrl)}" class="personality-link">${escapeHtml(linkText)}</a>`
: '';
return `<div class="personality-block">
${photoHtml}
<div class="personality-content">
<div class="personality-name">${escapeHtml(name)}</div>
${description ? `<div class="personality-description">${escapeHtml(description)}</div>` : ''}
${linkHtml}
</div>
</div>`;
}
default:
// Unknown block type - return empty or log warning
console.warn(`Unknown EditorJS block type: ${type}`);
return '';
}
}
/**
* Escape HTML special characters
*/
function escapeHtml(text: string): string {
const map: { [key: string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
/**
* Convert EditorJS JSON data to HTML string
* @param editorData - EditorJS JSON data (can be string or object)
* @param baseUrl - Base URL for images (optional)
* @returns HTML string
*/
export function editorjsToHtml(
editorData: string | EditorJSData | null | undefined,
baseUrl?: string
): string {
if (!editorData) {
return '';
}
// If it's a string, try to parse it as JSON
let parsedData: EditorJSData;
if (typeof editorData === 'string') {
try {
parsedData = JSON.parse(editorData);
} catch {
// If parsing fails, assume it's already HTML
return editorData;
}
} else {
parsedData = editorData;
}
// If it doesn't have blocks, assume it's already HTML
if (!parsedData.blocks || !Array.isArray(parsedData.blocks)) {
if (typeof editorData === 'string') {
return editorData;
}
return '';
}
// Convert each block to HTML
const htmlBlocks = parsedData.blocks
.map((block) => blockToHtml(block, baseUrl))
.filter((html) => html.length > 0);
return htmlBlocks.join('\n');
}
/**
* Check if content is EditorJS format
*/
export function isEditorJSFormat(
content: string | EditorJSData | null | undefined
): boolean {
if (!content) return false;
try {
const parsed =
typeof content === 'string' ? JSON.parse(content) : content;
return (
parsed &&
typeof parsed === 'object' &&
Array.isArray(parsed.blocks) &&
parsed.blocks.length > 0
);
} catch {
return false;
}
}

28
utils/image.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* Build a safe image URL for assets coming from the CMS.
*
* Rules:
* - If already a full URL (http/https) → return as is
* - If starts with `/uploads/` → prepend API URL (NEXT_PUBLIC_API_URL or default localhost)
* - If starts with `/` → use as-is (served by Next/public)
* - Otherwise → treat as relative path under `/`
*/
export function getCmsImageUrl(imagePath: string | undefined): string {
if (!imagePath) return "";
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
return imagePath;
}
if (imagePath.startsWith("/uploads/")) {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
return `${apiUrl}${imagePath}`;
}
if (imagePath.startsWith("/")) {
return imagePath;
}
return `/${imagePath}`;
}

3
utils/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { editorjsToHtml, isEditorJSFormat } from "./editorjsToHtml";
export { formatLongDate } from "./date";
export { getCmsImageUrl } from "./image";

20
utils/slugify.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Simple slugify helper (Vietnamese-friendly) to match CMS behavior:
* - lowercased
* - diacritics removed (NFD)
* - "đ" -> "d"
* - strict: keep [a-z0-9 -] only, then collapse spaces/hyphens
*/
export function toSlug(input: string): string {
return (input || "")
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d")
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}