diff --git a/api/blog.ts b/api/blogsApi.ts similarity index 92% rename from api/blog.ts rename to api/blogsApi.ts index dc2ef72..0f8b43a 100644 --- a/api/blog.ts +++ b/api/blogsApi.ts @@ -53,8 +53,10 @@ export const fetchBlogList = async ( headers: { 'Content-Type': 'application/json', }, - // Next.js: cache và revalidate - next: { revalidate: 60 }, // Revalidate mỗi 60 giây + // Next.js: cache và revalidate (disabled) + // next: { revalidate: 60 }, // Revalidate mỗi 60 giây + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -89,7 +91,8 @@ export const fetchBlogDetail = async ( headers: { 'Content-Type': 'application/json', }, - // No cache for blog detail so comments/replies show immediately after submit + refresh + // No cache for blog detail (disabled caching) + // no-cache cache: 'no-store', }); @@ -128,7 +131,9 @@ export const fetchFeaturedBlogs = async ( headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 60 }, + // next: { revalidate: 60 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -163,7 +168,9 @@ export const fetchRecentBlogs = async ( headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 60 }, + // next: { revalidate: 60 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -195,7 +202,9 @@ export const fetchCategories = async (): Promise => { headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 300 }, // Categories ít thay đổi, cache lâu hơn + // next: { revalidate: 300 }, // Categories ít thay đổi, cache lâu hơn + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -230,7 +239,9 @@ export const fetchCategoryDetail = async ( headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 300 }, + // next: { revalidate: 300 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -265,7 +276,9 @@ export const fetchTags = async (): Promise => { headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 300 }, + // next: { revalidate: 300 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -300,7 +313,9 @@ export const fetchPopularTags = async ( headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 300 }, + // next: { revalidate: 300 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { @@ -335,7 +350,9 @@ export const fetchTagDetail = async ( headers: { 'Content-Type': 'application/json', }, - next: { revalidate: 300 }, + // next: { revalidate: 300 }, + // no-cache + cache: 'no-store', }); if (!response.ok) { diff --git a/api/index.ts b/api/index.ts index 36d466b..e576090 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,4 +1,4 @@ /** * Export all API functions */ -export * from './blog'; +export * from './blogsApi'; diff --git a/api/servicesApi.ts b/api/servicesApi.ts index 1fcf7dc..9bae705 100644 --- a/api/servicesApi.ts +++ b/api/servicesApi.ts @@ -100,6 +100,20 @@ export interface ServicePageData { reviews: ReviewSection; } +export interface Country { + id: number; + name: string; + slug: string; + mainImage: string; + icon: string; + services: string[]; +} + +export interface CountryApiResponse { + success: boolean; + data: Country[]; +} + /* ======================= Utils ======================= */ @@ -230,6 +244,43 @@ export const fetchServiceBySlug = async (slug: string): Promise => { } }; +export const fetchCountries = async (): Promise => { + try { + const apiUrl = getApiUrl(); + const endpoint = `${apiUrl}/api/visa/country`; + + console.log("Fetching countries from endpoint:", endpoint); + + const response = await fetch(endpoint, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + cache: "no-store", + }); + + console.log("Countries API response status:", response.status); + + if (!response.ok) { + console.error("Countries API failed, using fallback data"); + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = (await response.json()) as CountryApiResponse; + + if (!result.success) { + throw new Error("Countries API returned success=false"); + } + + console.log("Countries data received successfully"); + return result.data; + } catch (error) { + console.error("Error fetching countries:", error); + console.log("Using fallback countries data"); + return getFallbackCountries(); + } +}; + /* ======================= Fallback Data ======================= */ @@ -560,3 +611,70 @@ export const getFallbackServicePageData = (): ServicePageData => ({ ], }, }); + +export const getFallbackCountries = (): Country[] => [ + { + id: 1, + name: "Canada", + slug: "canada", + mainImage: "_images/default.jpg", + icon: "img/home-3/choose-us/icon-1.png", + services: ["Immigration Appeal", "Permanent Residency", "Study Visa"], + }, + { + id: 2, + name: "Australia", + slug: "australia", + mainImage: "_images/default.jpg", + icon: "img/home-3/choose-us/icon-2.png", + services: ["Work Visa", "Permanent Residency", "Student Visa"], + }, + { + id: 3, + name: "United Kingdom", + slug: "united-kingdom", + mainImage: "_images/default.jpg", + icon: "img/home-3/choose-us/icon-3.png", + services: ["Study Visa", "Work Visa", "Family Visa"], + }, + { + id: 4, + name: "United States", + slug: "united-states", + mainImage: "_images/default.jpg", + icon: "img/home-3/choose-us/icon-1.png", + services: ["Immigration Appeal", "Work Visa", "Student Visa"], + }, + { + id: 5, + name: "Germany", + slug: "germany", + mainImage: "_images/default.jpg", + icon: "img/home-3/choose-us/icon-2.png", + services: ["Study Visa", "Work Visa", "Permanent Residency"], + }, + { + id: 6, + name: "France", + slug: "france", + mainImage: "_images/default.jpg", + icon: "img/home-3/choose-us/icon-3.png", + services: ["Student Visa", "Work Visa", "Family Visa"], + }, + { + id: 7, + name: "New Zealand", + slug: "new-zealand", + mainImage: "_images/default.jpg", + icon: "img/home-3/choose-us/icon-1.png", + services: ["Work Visa", "Permanent Residency", "Study Visa"], + }, + { + id: 8, + name: "Japan", + slug: "japan", + mainImage: "_images/default.jpg", + icon: "img/home-3/choose-us/icon-2.png", + services: ["Work Visa", "Student Visa", "Business Visa"], + }, +]; diff --git a/app/blog/[slug]/components/CommentForm.tsx b/app/blog/[slug]/components/CommentForm.tsx index c1a95ec..3a4b580 100644 --- a/app/blog/[slug]/components/CommentForm.tsx +++ b/app/blog/[slug]/components/CommentForm.tsx @@ -21,6 +21,10 @@ export default function CommentForm({ const router = useRouter(); const [isPending, setIsPending] = useState(false); const [authorName, setAuthorName] = useState(""); + const [authorEmail, setAuthorEmail] = useState(""); + const [authorPhone, setAuthorPhone] = useState(""); + const [authorAddress, setAuthorAddress] = useState(""); + const [authorDate, setAuthorDate] = useState(""); const [content, setContent] = useState(initialContent); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); @@ -37,6 +41,12 @@ export default function CommentForm({ return; } + // Basic email validation if provided + if (authorEmail.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail.trim())) { + setError("Please enter a valid email address."); + return; + } + try { setIsPending(true); const res = await fetch(`${apiUrl}/api/blog/${slug}/comments`, { @@ -47,6 +57,10 @@ export default function CommentForm({ }, body: JSON.stringify({ authorName: authorName.trim(), + ...(authorEmail.trim() ? { authorEmail: authorEmail.trim() } : {}), + ...(authorPhone.trim() ? { authorPhone: authorPhone.trim() } : {}), + ...(authorAddress.trim() ? { authorAddress: authorAddress.trim() } : {}), + ...(authorDate.trim() ? { authorDate: authorDate.trim() } : {}), content: content.trim(), ...(parentId ? { parentId } : {}), }), @@ -58,6 +72,10 @@ export default function CommentForm({ } setAuthorName(""); + setAuthorEmail(""); + setAuthorPhone(""); + setAuthorAddress(""); + setAuthorDate(""); setContent(""); setSuccess("Comment submitted."); @@ -83,7 +101,7 @@ export default function CommentForm({
-
+
Your Name setAuthorName(e.target.value)} disabled={isPending} + required + /> +
+
+ +
+
+ Your Email + setAuthorEmail(e.target.value)} + disabled={isPending} + /> +
+
+ +
+
+ Your Phone + setAuthorPhone(e.target.value)} + disabled={isPending} + /> +
+
+ +
+
+ Your Address + setAuthorAddress(e.target.value)} + disabled={isPending} + /> +
+
+ +
+
+ Your Date + setAuthorDate(e.target.value)} + disabled={isPending} />
@@ -105,6 +180,7 @@ export default function CommentForm({ value={content} onChange={(e) => setContent(e.target.value)} disabled={isPending} + required >
diff --git a/app/blog/[slug]/components/CommentsSection.tsx b/app/blog/[slug]/components/CommentsSection.tsx index 54a2237..b74fdc6 100644 --- a/app/blog/[slug]/components/CommentsSection.tsx +++ b/app/blog/[slug]/components/CommentsSection.tsx @@ -11,15 +11,41 @@ interface CommentsSectionProps { } export default function CommentsSection({ slug, comments }: CommentsSectionProps) { + const [showAllComments, setShowAllComments] = useState(false); + const [expandedCommentKeys, setExpandedCommentKeys] = useState>( + () => new Set() + ); const [replyTarget, setReplyTarget] = useState<{ - // Root comment id to store as parentId (keeps 1-level threading on backend) + // ID của comment gốc để lưu làm parentId (giữ thread 1 cấp trên backend) parentId: string; - // The author we are replying to (used for @mention UI) + // Tên tác giả mà chúng ta đang reply (dùng cho UI @mention) replyToName: string; - // Which item in the UI is showing the reply form under it + // ID của item trong UI đang hiển thị form reply bên dưới anchorId: string; } | null>(null); + // Helper function để lấy chữ cái đầu của tên + const getInitials = (name?: string): string => { + if (!name) return "?"; + const trimmed = name.trim(); + if (trimmed.length === 0) return "?"; + return trimmed.charAt(0).toUpperCase(); + }; + + const makeCommentKey = (prefix: "p" | "r", id: string | undefined, index: number) => + id ? `${prefix}:${id}` : `${prefix}:idx:${index}`; + + const isExpanded = (key: string) => expandedCommentKeys.has(key); + + const toggleExpanded = (key: string) => { + setExpandedCommentKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + const { parents, repliesByParent } = useMemo(() => { const parentsLocal = (comments || []).filter((c) => !c.parentId); const replies = (comments || []).filter((c) => !!c.parentId); @@ -30,12 +56,12 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps } return { parents: parentsLocal, repliesByParent: map }; }, [comments]); - // Collect all author names to detect full @mentions (including multi-word names) + // Thu thập tất cả tên tác giả để phát hiện @mention đầy đủ (bao gồm tên nhiều từ) const authorNames = useMemo(() => { return (comments || []) .map((c) => c.authorName) .filter((n): n is string => !!n) - // Sort by length desc so we match the longest possible name first + // Sắp xếp theo độ dài giảm dần để khớp tên dài nhất trước .sort((a, b) => b.length - a.length); }, [comments]); @@ -43,24 +69,24 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps if (!text) return null; if (!text.startsWith("@")) return text; - // Try to match an @mention that corresponds to a known author name. - // This supports multi-word names like "@Bạn Cũng Thấy Thế Hà". + // Thử khớp @mention với tên tác giả đã biết + // Hỗ trợ tên nhiều từ như "@Bạn Cũng Thấy Thế Hà" const matchedName = authorNames.find((name) => { const candidate = `@${name}`; if (!text.startsWith(candidate)) return false; - // Ensure the match ends at string end or is followed by a space + // Đảm bảo khớp kết thúc ở cuối chuỗi hoặc theo sau bởi khoảng trắng const nextChar = text.charAt(candidate.length); return candidate.length === text.length || nextChar === " "; }); if (!matchedName) { - // Fallback: just return the original text if we can't confidently match a name + // Fallback: trả về text gốc nếu không thể khớp tên một cách chắc chắn return text; } const mention = `@${matchedName}`; - const rest = text.slice(mention.length); // includes leading space if present + const rest = text.slice(mention.length); // bao gồm khoảng trắng đầu nếu có return ( <> @@ -70,6 +96,92 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps ); }; + const splitWords = (s: string) => + s + .trim() + .split(/\s+/) + .filter(Boolean); + + const renderTruncatedContent = (text: string | undefined, key: string) => { + if (!text) return null; + + const maxWords = 35; + const words = splitWords(text); + const needsTruncate = words.length > maxWords; + const expanded = isExpanded(key); + + const displayText = + !needsTruncate || expanded ? text : `${words.slice(0, maxWords).join(" ")}...`; + + return ( + <> + {renderContentWithMention(displayText)} + {needsTruncate && ( + <> + {" "} + + + )} + + ); + }; + + // Tính toán các parent comment cần hiển thị (bao gồm children) để hiển thị tối đa 5 comments tổng cộng + const getDisplayedParents = () => { + if (showAllComments) return { parents: parents, repliesMap: new Map() }; + + let totalCount = 0; + const result: typeof parents = []; + const limitedRepliesMap = new Map(); + + for (const parent of parents) { + const allReplies = parent._id ? repliesByParent.get(parent._id) || [] : []; + const remaining = 5 - totalCount; + + if (remaining <= 0) break; + + // Tính toán số lượng replies có thể hiển thị + const repliesToShow = Math.min(allReplies.length, remaining - 1); // -1 cho chính parent comment + + if (repliesToShow >= 0) { + result.push(parent); + if (parent._id && repliesToShow > 0) { + limitedRepliesMap.set(parent._id, allReplies.slice(0, repliesToShow)); + } + totalCount += 1 + repliesToShow; + } else { + // Không thể hiển thị cả parent, dừng lại + break; + } + } + + return { parents: result, repliesMap: limitedRepliesMap }; + }; + + const { parents: displayedParents, repliesMap: displayedRepliesMap } = getDisplayedParents(); + + // Tính số lượng comments hiển thị ban đầu (khi thu gọn, tối đa 5) + const initialDisplayCount = displayedParents.reduce((sum, parent) => { + // Khi thu gọn, sử dụng replies giới hạn từ displayedRepliesMap + const replies = parent._id ? (displayedRepliesMap.get(parent._id) || []) : []; + return sum + 1 + replies.length; + }, 0); + + const hasMoreComments = (comments || []).length > initialDisplayCount; + return (
@@ -78,51 +190,57 @@ export default function CommentsSection({ slug, comments }: CommentsSectionProps
- {parents.map((comment, index) => { - const replies = comment._id ? repliesByParent.get(comment._id) || [] : []; + {displayedParents.map((comment, index) => { + // Khi showAllComments là false, chỉ sử dụng replies giới hạn từ displayedRepliesMap + // Khi showAllComments là true, sử dụng tất cả replies từ repliesByParent + const replies = comment._id + ? (showAllComments + ? (repliesByParent.get(comment._id) || []) + : (displayedRepliesMap.get(comment._id) || [])) + : []; const isReplyingHere = !!comment._id && replyTarget?.anchorId === comment._id; return (
- {comment.authorName} +
+ {getInitials(comment.authorName)} +
-
+
{formatLongDate(comment.createdAt)}

{comment.authorName}

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

{renderContentWithMention(comment.content)}

+

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

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

{reply.authorName}

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

{renderContentWithMention(reply.content)}

+

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

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

{post.title}

-
+
- {/* Gallery Images */} + {/* Hình ảnh gallery */} {post.galleryImages && post.galleryImages.length > 0 && ( -
+
{post.galleryImages.map((image, index) => ( -
+
{`${post.title}
@@ -71,18 +77,21 @@ export default function NewsDetailsContent({ post }: NewsDetailsContentProps) {
)} - {/* Content After Quote */} + {/* Nội dung sau Quote */} {post.contentAfterQuote && ( -
+
)} - {/* Tags and Social Share */} + {/* Tags và Social Share */}
Tags: {post.tags.map((tagName) => { - // Generate slug from tag name (Vietnamese-friendly) + // Tạo slug từ tên tag (hỗ trợ tiếng Việt) const tagSlug = toSlug(tagName); return ( @@ -94,17 +103,37 @@ export default function NewsDetailsContent({ post }: NewsDetailsContentProps) {
diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index 937a767..4216b3f 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -1,8 +1,10 @@ import Link from "next/link"; +import type { Metadata } from "next"; import Breadcrumb from "@/app/components/Breadcrumb"; import NewsDetailsSection from "./components/NewsDetailsSection"; -import { fetchBlogList, fetchBlogDetail } from "@/api/blog"; +import { fetchBlogList, fetchBlogDetail } from "@/api/blogsApi"; import Sidebar from "@/app/blog/components/Sidebar"; +import { getCmsImageUrl } from "@/utils"; // Generate static params for all blog posts export async function generateStaticParams() { @@ -25,6 +27,60 @@ interface BlogDetailsPageProps { }; } +// SEO metadata cho từng bài blog (Open Graph / thumbnail khi share) +export async function generateMetadata({ + params, +}: { + params: + | Promise<{ + slug: string; + }> + | { + slug: string; + }; +}): Promise { + const resolvedParams = params instanceof Promise ? await params : params; + const slug = resolvedParams.slug; + + try { + const blogResponse = await fetchBlogDetail(slug); + const post = blogResponse.data; + + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; + const url = `${siteUrl}/blog/${post.slug}`; + const imageUrl = post.featuredImage + ? getCmsImageUrl(post.featuredImage) + : `${siteUrl}/assets/img/inner-page/news-details/details-1.jpg`; + + return { + title: post.title, + description: post.excerpt, + openGraph: { + title: post.title, + description: post.excerpt, + url, + type: "article", + images: [ + { + url: imageUrl, + alt: post.title, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: post.title, + description: post.excerpt, + images: [imageUrl], + }, + }; + } catch { + return { + title: "Blog Details", + }; + } +} + export default async function BlogDetailsPage({ params }: BlogDetailsPageProps) { // Handle both Promise and direct object const resolvedParams = params instanceof Promise ? await params : params; diff --git a/app/blog/category/[slug]/page.tsx b/app/blog/category/[slug]/page.tsx index e95fdad..4eebb6e 100644 --- a/app/blog/category/[slug]/page.tsx +++ b/app/blog/category/[slug]/page.tsx @@ -1,27 +1,39 @@ import Breadcrumb from "@/app/components/Breadcrumb"; import NewsSection from "@/app/blog/components/NewsSection"; import Sidebar from "@/app/blog/components/Sidebar"; -import { fetchBlogsByCategory, fetchCategoryDetail } from "@/api/blog"; +import { fetchBlogsByCategory, fetchCategoryDetail } from "@/api/blogsApi"; interface CategoryPageProps { - params: Promise<{ + params: + | Promise<{ slug: string; - }> | { + }> + | { slug: string; - }; + }; + searchParams?: Promise<{ search?: string; page?: string }> | { search?: string; page?: string }; } -export default async function CategoryPage({ params }: CategoryPageProps) { +export default async function CategoryPage({ params, searchParams }: CategoryPageProps) { // Handle both Promise and direct object const resolvedParams = params instanceof Promise ? await params : params; const slug = resolvedParams.slug; + const resolvedSearchParams = + searchParams instanceof Promise ? await searchParams : searchParams; + const searchQuery = resolvedSearchParams?.search?.toString() || ""; + const pageParam = resolvedSearchParams?.page?.toString() || "1"; + const currentPage = Number.parseInt(pageParam, 10) || 1; // Fetch category detail and blogs let categoryResponse, blogsResponse; try { [categoryResponse, blogsResponse] = await Promise.all([ fetchCategoryDetail(slug), - fetchBlogsByCategory(slug, { page: 1, limit: 10 }), + fetchBlogsByCategory(slug, { + page: currentPage, + limit: 3, + ...(searchQuery ? { search: searchQuery } : {}), + }), ]); } catch { return ( @@ -47,12 +59,17 @@ export default async function CategoryPage({ params }: CategoryPageProps) { } const category = categoryResponse.data; - const blogs = blogsResponse.data.blogs; + const { blogs, pagination } = blogsResponse.data; return ( <> - + ); } diff --git a/app/blog/components/NewsSection.tsx b/app/blog/components/NewsSection.tsx index 64a8ddf..34c3138 100644 --- a/app/blog/components/NewsSection.tsx +++ b/app/blog/components/NewsSection.tsx @@ -18,6 +18,12 @@ export default function NewsSection({ searchQuery, pagination, }: NewsSectionProps) { + const basePath = categorySlug + ? `/blog/category/${categorySlug}` + : tagSlug + ? `/blog/tag/${tagSlug}` + : "/blog"; + return (
@@ -29,7 +35,7 @@ export default function NewsSection({ {pagination && pagination.total > 1 && (
- +
)} diff --git a/app/blog/components/Pagination.tsx b/app/blog/components/Pagination.tsx index 78c3dbc..c1a815e 100644 --- a/app/blog/components/Pagination.tsx +++ b/app/blog/components/Pagination.tsx @@ -38,11 +38,11 @@ export default function Pagination({ basePath, pagination, searchQuery }: Pagina return (
@@ -203,7 +178,7 @@ export default async function ServicesPage() { {reviews.title.mainTitle}
- + View All Review diff --git a/app/services/services.css b/app/services/services.css new file mode 100644 index 0000000..c386373 --- /dev/null +++ b/app/services/services.css @@ -0,0 +1,53 @@ +/* Custom CSS for Service Image Sizing */ +.service-main-item-3 .service-left .service-image { + flex-shrink: 0; + width: 352px; + height: 216px; + overflow: hidden; + border-radius: 16px; +} + +.service-main-item-3 .service-left .service-image img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + border-radius: 16px; +} +/* +.service-main-item-3 .service-left .content { + flex: 1; + min-width: 0; +} + +.service-main-item-3 .service-left { + gap: 30px; + align-items: flex-start; +} */ + +/* @media (max-width: 1399px) { + .service-main-item-3 .service-left .service-image { + width: 250px; + height: 180px; + } +} + +@media (max-width: 767px) { + .service-main-item-3 .service-left .service-image { + width: 100%; + height: 200px; + max-width: 300px; + } + + .service-main-item-3 .service-left { + flex-direction: column; + align-items: flex-start; + text-align: left; + } + + .service-main-item-3.style-2 .service-left { + flex-direction: column-reverse; + align-items: flex-start; + text-align: left; + } +} */ diff --git a/public/assets/css/main.css b/public/assets/css/main.css index 4c1bea0..732a531 100644 --- a/public/assets/css/main.css +++ b/public/assets/css/main.css @@ -99,6 +99,54 @@ Version: 1.0.0 color: var(--header); } +/* -------------------------------------------- + Pagination (match theme-btn style: round, red/white) + ---------------------------------------------- */ +.hai-pagination { + gap: 10px; +} +.hai-pagination .page-item { + margin: 0; +} +.hai-pagination .page-link { + width: 48px; + height: 48px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50% !important; + border: 1px solid var(--border); + background: var(--white); + color: var(--header); + font-family: "Space Grotesk", sans-serif; + font-weight: 500; + line-height: 1; + transition: all 0.3s ease-in-out; + box-shadow: none; +} +.hai-pagination .page-link:focus { + box-shadow: none; +} +.hai-pagination .page-link:hover { + background-color: var(--theme); + border-color: var(--theme); + color: var(--white); +} +.hai-pagination .page-item.active .page-link, +.hai-pagination .page-item.active span.page-link { + background-color: var(--theme); + border-color: var(--theme); + color: var(--white); +} +.hai-pagination .page-item.disabled .page-link, +.hai-pagination .page-item.disabled span.page-link { + opacity: 0.6; + background: var(--white); + border-color: var(--border); + color: var(--header); +} + .link-btn { color: var(--theme-2); text-transform: capitalize; @@ -6548,6 +6596,10 @@ html.lenis body { .recent-thumb img { border-radius: 8px; + width: 88px; + min-width: 88px; + height: 80px; + object-fit: cover; } .main-sideber .news-sideber-box @@ -6633,10 +6685,28 @@ html.lenis body { } .news-details-wrapper .news-details-post .news-details-image img { - width: 100%; - height: 100%; + width: 852px; + height: 400px; + object-fit: cover; border-radius: 16px; } + +@media (max-width: 1399px) { + .news-details-wrapper .news-details-post .news-details-image img { + width: 100%; + height: 300px; + object-fit: cover; + } +} + +@media (max-width: 991px) { + .news-details-wrapper .news-details-post .news-details-image img { + width: 100%; + height: 200px; + object-fit: cover; + } +} + .news-details-wrapper .news-details-post .details-content { margin-top: 24px; } @@ -6669,6 +6739,32 @@ html.lenis body { height: 100%; border-radius: 16px; } +/* Gallery images responsive for mobile */ +@media (max-width: 991px) { + .news-details-wrapper .news-details-post .details-content .gallery-images-row { + justify-content: space-between; + margin-left: 0; + margin-right: 0; + margin-top: 1.5rem; + gap: 0.5rem; + } + .news-details-wrapper .news-details-post .details-content .gallery-images-row .gallery-item { + flex: 0 0 47.5%; + max-width: 47.5%; + padding-left: 0; + padding-right: 0; + margin: 0; + } + .news-details-wrapper .news-details-post .details-content .gallery-images-row .gallery-item .thumb { + margin: 0; + } + .news-details-wrapper .news-details-post .details-content .gallery-images-row .gallery-item .thumb img { + width: 100%; + height: auto; + max-height: 200px; + object-fit: cover; + } +} .news-details-wrapper .news-details-post .details-content .sideber { background-color: var(--theme-2); padding: 24px 30px; @@ -6813,9 +6909,19 @@ html.lenis body { .comments-area .news-single-comment { flex-wrap: wrap; - gap: 20px; + gap: 10px; } } +.news-details-wrapper + .news-details-post + .details-content + .comments-area + .news-single-comment + .image { + flex-shrink: 0; + width: 80px; + min-width: 80px; +} .news-details-wrapper .news-details-post .details-content @@ -6823,7 +6929,12 @@ html.lenis body { .news-single-comment .image img { + width: 80px; + height: 80px; + min-width: 80px; + min-height: 80px; border-radius: 12px; + object-fit: cover; } .news-details-wrapper .news-details-post @@ -6836,6 +6947,33 @@ html.lenis body { h4 { margin-bottom: 5px; } +.news-details-wrapper + .news-details-post + .details-content + .comments-area + .news-single-comment { + position: relative; +} +.news-details-wrapper + .news-details-post + .details-content + .comments-area + .news-single-comment + .content + .head { + flex-wrap: nowrap; +} +.news-details-wrapper + .news-details-post + .details-content + .comments-area + .news-single-comment + .content + .head + .con { + flex: 1; + min-width: 0; +} .news-details-wrapper .news-details-post .details-content @@ -6852,6 +6990,8 @@ html.lenis body { color: var(--white); background-color: var(--header); border-radius: 100px; + flex-shrink: 0; + margin-left: auto; } .news-details-wrapper .news-details-post @@ -6863,6 +7003,34 @@ html.lenis body { .reply:hover { background-color: var(--theme); } +.news-details-wrapper + .news-details-post + .details-content + .comments-area + .news-single-comment + .reply-absolute { + position: absolute; + top: 16px; + right: 0; + font-weight: 500; + font-size: 16px; + color: var(--white); + background-color: var(--header); + border-radius: 100px; + padding: 6px 14px; + text-transform: uppercase; + border: none; + cursor: pointer; + z-index: 10; +} +.news-details-wrapper + .news-details-post + .details-content + .comments-area + .news-single-comment + .reply-absolute:hover { + background-color: var(--theme); +} .news-details-wrapper .news-details-post .details-content @@ -6879,6 +7047,116 @@ html.lenis body { margin-left: 0; } } +/* Comments responsive for mobile */ +@media (max-width: 767px) { + .news-details-wrapper .news-details-post .details-content .comments-area { + margin-top: 30px; + padding-top: 20px; + margin-bottom: 30px; + } + .news-details-wrapper .news-details-post .details-content .comments-area .comments-heading { + margin-bottom: 20px; + } + .news-details-wrapper .news-details-post .details-content .comments-area .comments-heading h3 { + font-size: 20px; + } + .news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment { + gap: 12px !important; + padding-top: 16px !important; + padding-bottom: 16px !important; + } + .news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .image { + flex-shrink: 0; + width: 50px; + min-width: 50px; + max-width: 50px; + } + .news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .image img { + width: 50px; + height: 50px; + object-fit: cover; + } + .news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .image .comment-avatar-box { + width: 50px; + height: 50px; + font-size: 18px; + } + .news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .content { + flex: 1; + min-width: 0; + } + .news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .content .head { + gap: 8px !important; + margin-bottom: 8px; + flex-wrap: nowrap !important; + } + .news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .content .head .con { + flex: 1; + min-width: 0; + } + .news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .reply-absolute { + top: 12px; + right: 0; + font-size: 12px; + padding: 4px 10px; + } + .news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .content .head .con span { + font-size: 12px; + display: block; + margin-bottom: 4px; + } + .news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .content .head .con h4 { + font-size: 14px; + margin-bottom: 0; + } + .news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .content .head .reply { + font-size: 12px; + padding: 4px 10px; + white-space: nowrap; + } + .news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .content p { + font-size: 14px; + line-height: 1.5; + margin-top: 8px !important; + margin-bottom: 12px !important; + } + .news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .replies { + margin-top: 8px; + } + .news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .replies .news-single-comment { + padding-top: 12px !important; + padding-bottom: 12px !important; + } + .news-details-wrapper .news-details-post .details-content .comments-area .btn-outline-primary, + .news-details-wrapper .news-details-post .details-content .comments-area .btn-outline-secondary { + font-size: 14px; + padding: 10px 20px; + } +} + +/* Comment avatar (initial letter) */ +.news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .image .comment-avatar-box { + width: 60px; + height: 60px; + border-radius: 14px; + background: #6c757d; + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + font-size: 24px; + font-weight: 700; + user-select: none; +} + +@media (max-width: 767px) { + .news-details-wrapper .news-details-post .details-content .comments-area .news-single-comment .image .comment-avatar-box { + width: 50px; + height: 50px; + font-size: 18px; + } +} + .news-details-wrapper .news-details-post .details-content .form-clt { position: relative; } diff --git a/public/assets/scss/_news.scss b/public/assets/scss/_news.scss index 7d1ab26..bbfd6db 100644 --- a/public/assets/scss/_news.scss +++ b/public/assets/scss/_news.scss @@ -644,9 +644,7 @@ .news-details-image { img { - width: 852px; - height: 400px; - object-fit: cover; + @include imgw; border-radius: 16px; } } diff --git a/utils/editorjsToHtml.ts b/utils/editorjsToHtml.ts index 8f2e15b..7764b4e 100644 --- a/utils/editorjsToHtml.ts +++ b/utils/editorjsToHtml.ts @@ -60,7 +60,7 @@ function blockToHtml(block: EditorJSBlock, baseUrl?: string): string { case 'header': { const level = data.level || 2; const text = escapeHtml(data.text || ''); - return `${text}`; + return `${text}`; } case 'paragraph': { @@ -78,7 +78,7 @@ function blockToHtml(block: EditorJSBlock, baseUrl?: string): string { text = text.replace(/`([^`]+)`/g, '$1'); // Convert line breaks text = text.replace(/\n/g, '
'); - return `

${text}

`; + return `

${text}

`; } case 'list': { @@ -115,11 +115,15 @@ function blockToHtml(block: EditorJSBlock, baseUrl?: string): string { if (withBackground) classes.push('with-background'); if (stretched) classes.push('stretched'); - const classAttr = classes.length > 0 ? ` class="${classes.join(' ')}"` : ''; - const captionHtml = caption ? `
${escapeHtml(caption)}
` : ''; + const classAttr = + classes.length > 0 ? ` class="${classes.join(' ')} editorjs-image"` : ' class="editorjs-image"'; - return ` - ${escapeHtml(caption)} + const captionHtml = caption + ? `
${escapeHtml(caption)}
` + : ''; + + return ` + ${escapeHtml(caption)} ${captionHtml} `; } @@ -172,7 +176,7 @@ function blockToHtml(block: EditorJSBlock, baseUrl?: string): string { } case 'delimiter': { - return '
***
'; + return '
'; } case 'table': { @@ -223,7 +227,7 @@ function blockToHtml(block: EditorJSBlock, baseUrl?: string): string { const title = meta.title || data.title || link; const description = meta.description || ''; const image = meta.image || ''; - + let imageHtml = ''; if (image) { let imageUrl = image; @@ -295,7 +299,7 @@ function blockToHtml(block: EditorJSBlock, baseUrl?: string): string {
`; } - const linkHtml = linkUrl && linkText + const linkHtml = linkUrl && linkText ? `${escapeHtml(linkText)}` : ''; diff --git a/utils/image.ts b/utils/image.ts index a2d6456..cf7dcdc 100644 --- a/utils/image.ts +++ b/utils/image.ts @@ -3,7 +3,7 @@ * * 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 `/uploads/` or `/img/` → 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 `/` */ @@ -14,7 +14,7 @@ export function getCmsImageUrl(imagePath: string | undefined): string { return imagePath; } - if (imagePath.startsWith("/uploads/")) { + if (imagePath.startsWith("/uploads/") || imagePath.startsWith("/img/")) { const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; return `${apiUrl}${imagePath}`; }