forked from UKSOURCE/hailearning.edu.vn
Compare commits
3 Commits
main
...
fea/kiet-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10103806bb | ||
|
|
4bfad8481b | ||
|
|
ad68b7d8c4 |
53
app/accreditation/accreditation.css
Normal file
53
app/accreditation/accreditation.css
Normal file
@@ -0,0 +1,53 @@
|
||||
/* ============================================
|
||||
Accreditation Page — Scoped Styles
|
||||
Scope: .accreditation-page
|
||||
============================================ */
|
||||
|
||||
/* Typography — dùng div thay h1/h2/h3 */
|
||||
.accreditation-page .acc-heading {
|
||||
font-size: clamp(2.5rem, 5vw, 3.75rem);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.accreditation-page .acc-section-title {
|
||||
font-size: 1.875rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.accreditation-page .acc-card-title {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ---------- Color tokens ---------- */
|
||||
.accreditation-page .text-brand-blue { color: #1b254b; }
|
||||
.accreditation-page .bg-brand-blue { background-color: #1b254b; }
|
||||
.accreditation-page .bg-brand-light { background-color: #f8fbff; }
|
||||
.accreditation-page .bg-brand-accent { background-color: #3b82f6; }
|
||||
.accreditation-page .text-brand-accent { color: #3b82f6; }
|
||||
.accreditation-page .text-ui-text { color: #111827; }
|
||||
.accreditation-page .text-ui-muted { color: #6b7280; }
|
||||
.accreditation-page .bg-ui-bg { background-color: #f9fafb; }
|
||||
.accreditation-page .border-ui-border { border-color: #e5e7eb; }
|
||||
|
||||
/* ---------- Shadow tokens ---------- */
|
||||
.accreditation-page .shadow-soft {
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.06), 0 1px 2px rgb(0 0 0 / 0.04);
|
||||
}
|
||||
.accreditation-page .shadow-hover,
|
||||
.accreditation-page .hover\:shadow-hover:hover {
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* ---------- Border radius fix ---------- */
|
||||
.accreditation-page .rounded-\[24px\] { border-radius: 24px !important; }
|
||||
.accreditation-page .rounded-lg { border-radius: 8px !important; }
|
||||
.accreditation-page .rounded-md { border-radius: 6px !important; }
|
||||
|
||||
/* Mesh background gradient */
|
||||
.accreditation-page .mesh-bg {
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #e8f0fe 40%, #ffffff 100%);
|
||||
}
|
||||
|
||||
/* rounded-xl fix */
|
||||
.accreditation-page .rounded-xl { border-radius: 12px !important; }
|
||||
14
app/accreditation/page.tsx
Normal file
14
app/accreditation/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import "./accreditation.css";
|
||||
import AccreditationHero from "../components/accreditation/AccreditationHero";
|
||||
import AccreditationGrid from "../components/accreditation/AccreditationGrid";
|
||||
import QualityStandards from "../components/accreditation/QualityStandards";
|
||||
|
||||
export default function AccreditationPage() {
|
||||
return (
|
||||
<main className="accreditation-page w-full">
|
||||
<AccreditationHero />
|
||||
<AccreditationGrid />
|
||||
<QualityStandards />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
interface CommentFormProps {
|
||||
slug: string;
|
||||
parentId?: string | null;
|
||||
replyToName?: string | null;
|
||||
initialContent?: string;
|
||||
onSubmitted?: () => void;
|
||||
}
|
||||
|
||||
export default function CommentForm({
|
||||
slug,
|
||||
parentId = null,
|
||||
replyToName = null,
|
||||
initialContent = "",
|
||||
onSubmitted,
|
||||
}: CommentFormProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [authorName, setAuthorName] = useState("");
|
||||
const [authorEmail, setAuthorEmail] = useState("");
|
||||
const [authorPhone, setAuthorPhone] = useState("");
|
||||
const [authorAddress, setAuthorAddress] = useState("");
|
||||
const [authorDate, setAuthorDate] = useState("");
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [error, setError] = useState<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;
|
||||
}
|
||||
|
||||
// Basic email validation if provided
|
||||
if (authorEmail.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail.trim())) {
|
||||
setError("Please enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsPending(true);
|
||||
const res = await fetch(`${apiUrl}/api/blog/${slug}/comments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
authorName: authorName.trim(),
|
||||
...(authorEmail.trim() ? { authorEmail: authorEmail.trim() } : {}),
|
||||
...(authorPhone.trim() ? { authorPhone: authorPhone.trim() } : {}),
|
||||
...(authorAddress.trim() ? { authorAddress: authorAddress.trim() } : {}),
|
||||
...(authorDate.trim() ? { authorDate: authorDate.trim() } : {}),
|
||||
content: content.trim(),
|
||||
...(parentId ? { parentId } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data?.success) {
|
||||
throw new Error(data?.message || "Failed to submit comment");
|
||||
}
|
||||
|
||||
setAuthorName("");
|
||||
setAuthorEmail("");
|
||||
setAuthorPhone("");
|
||||
setAuthorAddress("");
|
||||
setAuthorDate("");
|
||||
setContent("");
|
||||
setSuccess("Comment submitted.");
|
||||
|
||||
// Re-fetch server data (blog detail is no-store) to show new comment immediately
|
||||
router.refresh();
|
||||
|
||||
onSubmitted?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to submit comment");
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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-4">
|
||||
<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}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-4">
|
||||
<div className="form-clt">
|
||||
<span>Your Email</span>
|
||||
<input
|
||||
type="email"
|
||||
name="authorEmail"
|
||||
placeholder="Your email"
|
||||
value={authorEmail}
|
||||
onChange={(e) => setAuthorEmail(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-4">
|
||||
<div className="form-clt">
|
||||
<span>Your Phone</span>
|
||||
<input
|
||||
type="text"
|
||||
name="authorPhone"
|
||||
placeholder="Phone Number"
|
||||
value={authorPhone}
|
||||
onChange={(e) => setAuthorPhone(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-6">
|
||||
<div className="form-clt">
|
||||
<span>Your Address</span>
|
||||
<input
|
||||
type="text"
|
||||
name="authorAddress"
|
||||
placeholder="Address Now"
|
||||
value={authorAddress}
|
||||
onChange={(e) => setAuthorAddress(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-6">
|
||||
<div className="form-clt">
|
||||
<span>Your Date</span>
|
||||
<input
|
||||
type="text"
|
||||
name="authorDate"
|
||||
placeholder="Date"
|
||||
value={authorDate}
|
||||
onChange={(e) => setAuthorDate(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}
|
||||
required
|
||||
></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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { BlogComment } from "@/types/blog";
|
||||
import { useMemo, useState } from "react";
|
||||
import CommentForm from "./CommentForm";
|
||||
import { formatLongDate } from "@/utils";
|
||||
|
||||
interface CommentsSectionProps {
|
||||
slug: string;
|
||||
comments: BlogComment[];
|
||||
}
|
||||
|
||||
export default function CommentsSection({ slug, comments }: CommentsSectionProps) {
|
||||
const [showAllComments, setShowAllComments] = useState(false);
|
||||
const [expandedCommentKeys, setExpandedCommentKeys] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
const [replyTarget, setReplyTarget] = useState<{
|
||||
// ID của comment gốc để lưu làm parentId (giữ thread 1 cấp trên backend)
|
||||
parentId: string;
|
||||
// Tên tác giả mà chúng ta đang reply (dùng cho UI @mention)
|
||||
replyToName: string;
|
||||
// ID của item trong UI đang hiển thị form reply bên dưới
|
||||
anchorId: string;
|
||||
} | null>(null);
|
||||
|
||||
// Helper function để lấy chữ cái đầu của tên
|
||||
const getInitials = (name?: string): string => {
|
||||
if (!name) return "?";
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length === 0) return "?";
|
||||
return trimmed.charAt(0).toUpperCase();
|
||||
};
|
||||
|
||||
const makeCommentKey = (prefix: "p" | "r", id: string | undefined, index: number) =>
|
||||
id ? `${prefix}:${id}` : `${prefix}:idx:${index}`;
|
||||
|
||||
const isExpanded = (key: string) => expandedCommentKeys.has(key);
|
||||
|
||||
const toggleExpanded = (key: string) => {
|
||||
setExpandedCommentKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const { parents, repliesByParent } = useMemo(() => {
|
||||
const parentsLocal = (comments || []).filter((c) => !c.parentId);
|
||||
const replies = (comments || []).filter((c) => !!c.parentId);
|
||||
const map = new Map<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]);
|
||||
// Thu thập tất cả tên tác giả để phát hiện @mention đầy đủ (bao gồm tên nhiều từ)
|
||||
const authorNames = useMemo(() => {
|
||||
return (comments || [])
|
||||
.map((c) => c.authorName)
|
||||
.filter((n): n is string => !!n)
|
||||
// Sắp xếp theo độ dài giảm dần để khớp tên dài nhất trước
|
||||
.sort((a, b) => b.length - a.length);
|
||||
}, [comments]);
|
||||
|
||||
const renderContentWithMention = (text?: string) => {
|
||||
if (!text) return null;
|
||||
if (!text.startsWith("@")) return text;
|
||||
|
||||
// Thử khớp @mention với tên tác giả đã biết
|
||||
// Hỗ trợ tên nhiều từ như "@Bạn Cũng Thấy Thế Hà"
|
||||
const matchedName = authorNames.find((name) => {
|
||||
const candidate = `@${name}`;
|
||||
if (!text.startsWith(candidate)) return false;
|
||||
|
||||
// Đảm bảo khớp kết thúc ở cuối chuỗi hoặc theo sau bởi khoảng trắng
|
||||
const nextChar = text.charAt(candidate.length);
|
||||
return candidate.length === text.length || nextChar === " ";
|
||||
});
|
||||
|
||||
if (!matchedName) {
|
||||
// Fallback: trả về text gốc nếu không thể khớp tên một cách chắc chắn
|
||||
return text;
|
||||
}
|
||||
|
||||
const mention = `@${matchedName}`;
|
||||
const rest = text.slice(mention.length); // bao gồm khoảng trắng đầu nếu có
|
||||
|
||||
return (
|
||||
<>
|
||||
<strong>{mention}</strong>
|
||||
{rest}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const splitWords = (s: string) =>
|
||||
s
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
|
||||
const renderTruncatedContent = (text: string | undefined, key: string) => {
|
||||
if (!text) return null;
|
||||
|
||||
const maxWords = 35;
|
||||
const words = splitWords(text);
|
||||
const needsTruncate = words.length > maxWords;
|
||||
const expanded = isExpanded(key);
|
||||
|
||||
const displayText =
|
||||
!needsTruncate || expanded ? text : `${words.slice(0, maxWords).join(" ")}...`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderContentWithMention(displayText)}
|
||||
{needsTruncate && (
|
||||
<>
|
||||
{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(key)}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
color: "var(--primary-color, #0d6efd)",
|
||||
cursor: "pointer",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{expanded ? "less" : "more"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// 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<string, BlogComment[]>();
|
||||
|
||||
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 (
|
||||
<div className="comments-area">
|
||||
<div className="comments-heading">
|
||||
<h3>
|
||||
{(comments || []).length} {(comments || []).length === 1 ? "Comment" : "Comments"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{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 (
|
||||
<div key={comment._id || index}>
|
||||
<div
|
||||
className="news-single-comment d-flex pt-4 pb-0 position-relative"
|
||||
>
|
||||
<div className="image">
|
||||
<div className="comment-avatar-box">
|
||||
{getInitials(comment.authorName)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<div className="head d-flex gap-2 gap-md-2 align-items-center">
|
||||
<div className="con">
|
||||
<span>{formatLongDate(comment.createdAt)}</span>
|
||||
<h4>{comment.authorName}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-30">
|
||||
{renderTruncatedContent(
|
||||
comment.content,
|
||||
makeCommentKey("p", comment._id, index)
|
||||
)}
|
||||
</p>
|
||||
|
||||
{comment._id && (
|
||||
<button
|
||||
type="button"
|
||||
className="reply reply-absolute"
|
||||
onClick={() =>
|
||||
setReplyTarget({
|
||||
parentId: comment._id!,
|
||||
replyToName: comment.authorName,
|
||||
anchorId: comment._id!,
|
||||
})
|
||||
}
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Các reply */}
|
||||
{replies.length > 0 && (
|
||||
<div className="replies">
|
||||
{replies.map((reply: BlogComment, replyIndex: number) => (
|
||||
<div key={reply._id || replyIndex}>
|
||||
<div
|
||||
className="news-single-comment d-flex pt-4 pb-0 position-relative"
|
||||
>
|
||||
<div className="image">
|
||||
<div className="comment-avatar-box">
|
||||
{getInitials(reply.authorName)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="content">
|
||||
<div className="head d-flex gap-2 gap-md-2 align-items-center">
|
||||
<div className="con">
|
||||
<span>{formatLongDate(reply.createdAt)}</span>
|
||||
<h4>{reply.authorName}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-30">
|
||||
{renderTruncatedContent(
|
||||
reply.content,
|
||||
makeCommentKey("r", reply._id, replyIndex)
|
||||
)}
|
||||
</p>
|
||||
|
||||
{reply._id && comment._id && (
|
||||
<button
|
||||
type="button"
|
||||
className="reply reply-absolute"
|
||||
onClick={() =>
|
||||
setReplyTarget({
|
||||
// Giữ parentId trên backend là ID của comment gốc (comment._id)
|
||||
parentId: comment._id!,
|
||||
// Mention tác giả mà chúng ta click reply (tác giả của child comment)
|
||||
replyToName: reply.authorName,
|
||||
// Hiển thị form bên dưới child comment này
|
||||
anchorId: reply._id!,
|
||||
})
|
||||
}
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form reply bên dưới child comment */}
|
||||
{reply._id && replyTarget && 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>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Nút hiển thị thêm comments */}
|
||||
{hasMoreComments && !showAllComments && (
|
||||
<div className="text-center mt-4 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
className="theme-btn"
|
||||
onClick={() => setShowAllComments(true)}
|
||||
>
|
||||
Show More Comments ({(comments || []).length - initialDisplayCount} more)
|
||||
<i className="fa-solid fa-arrow-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nút thu gọn */}
|
||||
{hasMoreComments && showAllComments && (
|
||||
<div className="text-center mt-4 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
className="theme-btn"
|
||||
onClick={() => {
|
||||
setShowAllComments(false);
|
||||
}}
|
||||
>
|
||||
Show Less
|
||||
<i className="fa-solid fa-arrow-up"></i>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form comment mới (top-level) */}
|
||||
<div className="mt-5">
|
||||
<CommentForm slug={slug} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import type { BlogPost } from "@/types/blog";
|
||||
import { editorjsToHtml, getCmsImageUrl } from "@/utils";
|
||||
import { toSlug } from "@/utils/slugify";
|
||||
import CommentsSection from "./CommentsSection";
|
||||
|
||||
interface NewsDetailsContentProps {
|
||||
post: BlogPost;
|
||||
}
|
||||
|
||||
export default function NewsDetailsContent({ post }: NewsDetailsContentProps) {
|
||||
// Lấy comments từ post (đã được bao gồm trong API response)
|
||||
const postComments = post.comments || [];
|
||||
|
||||
// Lấy base URL cho EditorJS images và URL tuyệt đối của bài viết
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
const postUrl = `${baseUrl}/blog/${post.slug}`;
|
||||
const encodedPostUrl = encodeURIComponent(postUrl);
|
||||
const encodedTitle = encodeURIComponent(post.title);
|
||||
|
||||
// Chuyển đổi EditorJS content sang HTML
|
||||
const renderContent = () => {
|
||||
const html = editorjsToHtml(post.content, baseUrl);
|
||||
return { __html: html };
|
||||
};
|
||||
|
||||
// Chuyển đổi EditorJS contentAfterQuote sang HTML
|
||||
const renderContentAfterQuote = () => {
|
||||
const html = editorjsToHtml(post.contentAfterQuote, baseUrl);
|
||||
return { __html: html };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="col-lg-8 col-12">
|
||||
<div className="news-details-post">
|
||||
<div className="news-details-image">
|
||||
<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 {post.author}
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-calendar-days"></i> {post.publishedAt}
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-comments"></i> {postComments.length} Comments
|
||||
</li>
|
||||
</ul>
|
||||
<h2>{post.title}</h2>
|
||||
<div className="editorjs-render" dangerouslySetInnerHTML={renderContent()} />
|
||||
|
||||
{/* Hình ảnh gallery */}
|
||||
{post.galleryImages && post.galleryImages.length > 0 && (
|
||||
<div className="row g-4 gallery-images-row">
|
||||
{post.galleryImages.map((image, index) => (
|
||||
<div key={index} className={post.galleryImages!.length === 1 ? "col-12" : "col-lg-6 gallery-item"}>
|
||||
<div className="thumb">
|
||||
<img src={getCmsImageUrl(image)} alt={`${post.title} - Image ${index + 1}`} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quote/Sidebar */}
|
||||
{post.quote && (
|
||||
<div className="sideber mt-4 mb-3">
|
||||
<h5>{post.quote}</h5>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nội dung sau Quote */}
|
||||
{post.contentAfterQuote && (
|
||||
<div
|
||||
className="editorjs-render"
|
||||
dangerouslySetInnerHTML={renderContentAfterQuote()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tags và 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>
|
||||
{post.tags.map((tagName) => {
|
||||
// Tạo slug từ tên tag (hỗ trợ tiếng Việt)
|
||||
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={`https://twitter.com/intent/tweet?url=${encodedPostUrl}&text=${encodedTitle}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Share on Twitter"
|
||||
>
|
||||
<i className="fab fa-twitter"></i>
|
||||
</a>
|
||||
<a
|
||||
href={`https://www.facebook.com/sharer/sharer.php?u=${encodedPostUrl}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Share on Facebook"
|
||||
>
|
||||
<i className="fab fa-facebook-f"></i>
|
||||
</a>
|
||||
<a
|
||||
href={`https://www.linkedin.com/sharing/share-offsite/?url=${encodedPostUrl}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Share on LinkedIn"
|
||||
>
|
||||
<i className="fab fa-linkedin-in"></i>
|
||||
</a>
|
||||
<a
|
||||
href={postUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Open blog post"
|
||||
>
|
||||
<i className="fa-solid fa-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CommentsSection slug={post.slug} comments={postComments} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import NewsDetailsContent from "./NewsDetailsContent";
|
||||
import Sidebar from "@/app/blog/components/Sidebar";
|
||||
import type { BlogPost } from "@/types/blog";
|
||||
|
||||
interface NewsDetailsSectionProps {
|
||||
post: BlogPost;
|
||||
}
|
||||
|
||||
export default function NewsDetailsSection({ post }: NewsDetailsSectionProps) {
|
||||
return (
|
||||
<section className="news-standard-section section-padding fix">
|
||||
<div className="container">
|
||||
<div className="news-details-wrapper">
|
||||
<div className="row g-4">
|
||||
<NewsDetailsContent post={post} />
|
||||
<Sidebar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import Breadcrumb from "@/app/components/Breadcrumb";
|
||||
import NewsDetailsSection from "./components/NewsDetailsSection";
|
||||
import { fetchBlogList, fetchBlogDetail } from "@/api/blogsApi";
|
||||
import Sidebar from "@/app/blog/components/Sidebar";
|
||||
import { getCmsImageUrl } from "@/utils";
|
||||
|
||||
// Force dynamic rendering - không cache
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Generate static params for all blog posts
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const blogResponse = await fetchBlogList({ page: 1, limit: 100 });
|
||||
return blogResponse.data.blogs.map((post) => ({
|
||||
slug: post.slug,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error generating static params:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
interface BlogDetailsPageProps {
|
||||
params:
|
||||
| Promise<{
|
||||
slug: string;
|
||||
}>
|
||||
| {
|
||||
slug: string;
|
||||
};
|
||||
}
|
||||
|
||||
// SEO metadata cho từng bài blog (Open Graph / thumbnail khi share)
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params:
|
||||
| Promise<{
|
||||
slug: string;
|
||||
}>
|
||||
| {
|
||||
slug: string;
|
||||
};
|
||||
}): Promise<Metadata> {
|
||||
const resolvedParams = params instanceof Promise ? await params : params;
|
||||
const slug = resolvedParams.slug;
|
||||
|
||||
try {
|
||||
const blogResponse = await fetchBlogDetail(slug);
|
||||
const post = blogResponse.data;
|
||||
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
const url = `${apiUrl}/blog/${post.slug}`;
|
||||
const imageUrl = post.featuredImage
|
||||
? getCmsImageUrl(post.featuredImage)
|
||||
: `${apiUrl}/assets/img/inner-page/news-details/details-1.jpg`;
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
url,
|
||||
type: "article",
|
||||
images: [
|
||||
{
|
||||
url: imageUrl,
|
||||
alt: post.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
images: [imageUrl],
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
title: "Blog Details",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BlogDetailsPage({ params }: BlogDetailsPageProps) {
|
||||
// Handle both Promise and direct object
|
||||
const resolvedParams = params instanceof Promise ? await params : params;
|
||||
const slug = resolvedParams.slug;
|
||||
|
||||
// Fetch blog detail from API
|
||||
let blogResponse;
|
||||
try {
|
||||
blogResponse = await fetchBlogDetail(slug);
|
||||
} catch {
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb title="Blog Details" current="Blog Details" />
|
||||
<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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
176
app/blog/blog-page.css
Normal file
176
app/blog/blog-page.css
Normal file
@@ -0,0 +1,176 @@
|
||||
/* ============================================
|
||||
Blog Page — Scoped Styles
|
||||
Scope: .blog-page
|
||||
============================================ */
|
||||
|
||||
/* Reset heading override từ main.css */
|
||||
.blog-page h1,
|
||||
.blog-page h2,
|
||||
.blog-page h3,
|
||||
.blog-page h4 {
|
||||
font-size: unset;
|
||||
font-weight: unset;
|
||||
line-height: unset;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Typography classes thay thế h1/h2/h3 */
|
||||
.blog-page .blog-heading {
|
||||
font-size: clamp(1.75rem, 4vw, 3rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.blog-page .blog-card-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.blog-page .blog-widget-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ---------- Color tokens ---------- */
|
||||
|
||||
.blog-page .bg-brand-blue { background-color: rgb(38, 60, 111); }
|
||||
.blog-page .text-brand-blue { color: rgb(38, 60, 111); }
|
||||
.blog-page .border-brand-blue { border-color: rgb(38, 60, 111); }
|
||||
|
||||
.blog-page .bg-brand-light { background-color: #f8fbff; }
|
||||
.blog-page .text-brand-light { color: #f8fbff; }
|
||||
|
||||
.blog-page .bg-brand-hover { background-color: #2d3a8c; }
|
||||
.blog-page .hover\:bg-brand-hover:hover { background-color: #2d3a8c; }
|
||||
|
||||
.blog-page .text-ui-text { color: #111827; }
|
||||
.blog-page .text-ui-muted { color: #6b7280; }
|
||||
.blog-page .bg-ui-bg { background-color: #f9fafb; }
|
||||
.blog-page .border-ui-border { border-color: #e5e7eb; }
|
||||
|
||||
/* hover:text-brand-blue */
|
||||
.blog-page .hover\:text-brand-blue:hover { color: #1b254b; }
|
||||
.blog-page .hover\:border-brand-blue:hover { border-color: #1b254b; }
|
||||
|
||||
/* group-hover */
|
||||
.blog-page .group:hover .group-hover\:text-brand-blue { color: #1b254b; }
|
||||
.blog-page .group:hover .group-hover\:border-brand-blue { border-color: #1b254b; }
|
||||
.blog-page .group:hover .group-hover\:gap-2 { gap: 0.5rem; }
|
||||
.blog-page .group:hover .group-hover\:scale-105 { transform: scale(1.05); }
|
||||
|
||||
/* shadow tokens */
|
||||
.blog-page .shadow-soft {
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.06), 0 1px 2px rgb(0 0 0 / 0.04);
|
||||
}
|
||||
.blog-page .shadow-hover {
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
.blog-page .hover\:shadow-hover:hover {
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* border-brand-blue/20, border-brand-blue/50 */
|
||||
.blog-page .border-brand-blue\/20 { border-color: rgb(27 37 75 / 0.2); }
|
||||
.blog-page .border-brand-blue\/50 { border-color: rgb(27 37 75 / 0.5); }
|
||||
.blog-page .hover\:border-brand-blue\/50:hover { border-color: rgb(27 37 75 / 0.5); }
|
||||
|
||||
/* text-brand-blue/40 */
|
||||
.blog-page .text-brand-blue\/40 { color: rgb(27 37 75 / 0.4); }
|
||||
|
||||
/* text-brand-light/80, /60 */
|
||||
.blog-page .text-brand-light\/80 { color: rgb(248 251 255 / 0.8); }
|
||||
.blog-page .text-brand-light\/60 { color: rgb(248 251 255 / 0.6); }
|
||||
|
||||
/* bg-white/10, /90 */
|
||||
.blog-page .bg-white\/10 { background-color: rgb(255 255 255 / 0.1); }
|
||||
.blog-page .bg-white\/90 { background-color: rgb(255 255 255 / 0.9); }
|
||||
|
||||
/* border-white/20 */
|
||||
.blog-page .border-white\/20 { border-color: rgb(255 255 255 / 0.2); }
|
||||
|
||||
/* placeholder */
|
||||
.blog-page .placeholder-white\/50::placeholder { color: rgb(255 255 255 / 0.5); }
|
||||
|
||||
/* Category filter buttons */
|
||||
.blog-page #category-filters button {
|
||||
padding-left: 1.25rem !important;
|
||||
padding-right: 1.25rem !important;
|
||||
padding-top: 0.4rem !important;
|
||||
padding-bottom: 0.4rem !important;
|
||||
border-radius: 9999px !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
/* Category filter hover — viền sáng lên */
|
||||
.blog-page #category-filters button:not(:first-child):hover {
|
||||
border-color: #1b254b !important;
|
||||
color: #1b254b !important;
|
||||
}
|
||||
|
||||
/* Newsletter input placeholder */
|
||||
.blog-page .newsletter-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Newsletter widget */
|
||||
.blog-page .folder-tab {
|
||||
background-color: #263c6f !important;
|
||||
clip-path: polygon(0 0, calc(100% - 40px) 0, 100% 40px, 100% 100%, 0 100%);
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.blog-page .folder-tab input {
|
||||
border-radius: 8px !important;
|
||||
padding: 0.35rem 0.75rem !important;
|
||||
font-size: 0.875rem !important;
|
||||
background-color: rgba(255,255,255,0.12) !important;
|
||||
border: 1px solid rgba(255,255,255,0.25) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.blog-page .folder-tab input::placeholder {
|
||||
color: rgba(255,255,255,0.5) !important;
|
||||
}
|
||||
|
||||
.blog-page .folder-tab button[type="submit"] {
|
||||
border-radius: 8px !important;
|
||||
padding: 0.35rem 0.75rem !important;
|
||||
font-size: 0.875rem !important;
|
||||
font-weight: 700 !important;
|
||||
background-color: white !important;
|
||||
color: #1b254b !important;
|
||||
}
|
||||
|
||||
/* Pagination buttons */
|
||||
.blog-page .border-ui-border[class*="w-10"] {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.blog-page .border-ui-border[class*="w-10"]:hover {
|
||||
border-color: rgb(38, 60, 111) !important;
|
||||
color: rgb(38, 60, 111) !important;
|
||||
}
|
||||
|
||||
.blog-page .bg-brand-blue[class*="w-10"] {
|
||||
border-radius: 8px !important;
|
||||
background-color: rgb(38, 60, 111) !important;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.blog-page .pg-btn {
|
||||
border-radius: 8px !important;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.blog-page .pg-btn:hover {
|
||||
border-color: rgb(38, 60, 111) !important;
|
||||
color: rgb(38, 60, 111) !important;
|
||||
}
|
||||
|
||||
.blog-page .pg-active {
|
||||
background-color: rgb(38, 60, 111) !important;
|
||||
border: none !important;
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"name": "Visa & Immigration",
|
||||
"slug": "visa-immigration",
|
||||
"description": "Tin tức và hướng dẫn về visa, định cư."
|
||||
},
|
||||
{
|
||||
"name": "Study Abroad",
|
||||
"slug": "study-abroad",
|
||||
"description": "Kinh nghiệm du học, trường học, học bổng."
|
||||
},
|
||||
{
|
||||
"name": "Travel Tips",
|
||||
"slug": "travel-tips",
|
||||
"description": "Mẹo du lịch, chuẩn bị hành lý, bảo hiểm."
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "WorkVisa",
|
||||
"slug": "work-visa"
|
||||
},
|
||||
{
|
||||
"name": "StudentVisa",
|
||||
"slug": "student-visa"
|
||||
},
|
||||
{
|
||||
"name": "Canada",
|
||||
"slug": "canada"
|
||||
},
|
||||
{
|
||||
"name": "Scholarship",
|
||||
"slug": "scholarship"
|
||||
},
|
||||
{
|
||||
"name": "TravelSafety",
|
||||
"slug": "travel-safety"
|
||||
}
|
||||
],
|
||||
"posts": [
|
||||
{
|
||||
"title": "Ultimate Guide To Getting A Work Visa In Canada",
|
||||
"slug": "ultimate-guide-work-visa-canada",
|
||||
"excerpt": "Tổng hợp đầy đủ các bước xin work visa tại Canada cho người mới bắt đầu, từ điều kiện, hồ sơ đến thời gian xử lý.",
|
||||
"content": "<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
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"title": "Top 5 Scholarship Programs For International Students",
|
||||
"slug": "top-5-scholarship-programs-international-students",
|
||||
"thumbnail": "/assets/img/inner-page/news-details/post-2.jpg",
|
||||
"publishedAt": "20 March 2025"
|
||||
},
|
||||
{
|
||||
"title": "10 Travel Safety Tips You Should Know Before Flying",
|
||||
"slug": "10-travel-safety-tips-before-flying",
|
||||
"thumbnail": "/assets/img/inner-page/news-details/post-3.jpg",
|
||||
"publishedAt": "05 April 2025"
|
||||
}
|
||||
],
|
||||
"comments": [
|
||||
{
|
||||
"postSlug": "ultimate-guide-work-visa-canada",
|
||||
"authorName": "Frank Flores",
|
||||
"authorAvatar": "/assets/img/inner-page/news-details/comment-1.png",
|
||||
"content": "Bài viết rất hữu ích, cảm ơn bạn đã chia sẻ!",
|
||||
"createdAt": "February 10, 2024",
|
||||
"status": "approved",
|
||||
"parentAuthorName": null
|
||||
},
|
||||
{
|
||||
"postSlug": "ultimate-guide-work-visa-canada",
|
||||
"authorName": "Courtney Henry",
|
||||
"authorAvatar": "/assets/img/inner-page/news-details/comment-2.png",
|
||||
"content": "Mình đã làm theo hướng dẫn và hồ sơ được duyệt nhanh hơn hẳn.",
|
||||
"createdAt": "February 12, 2024",
|
||||
"status": "approved",
|
||||
"parentAuthorName": "Frank Flores"
|
||||
},
|
||||
{
|
||||
"postSlug": "top-5-scholarship-programs-international-students",
|
||||
"authorName": "Sarah Johnson",
|
||||
"authorAvatar": "/assets/img/inner-page/news-details/comment-1.png",
|
||||
"content": "Cảm ơn bạn đã chia sẻ thông tin về các chương trình học bổng này. Mình đã apply và đang chờ kết quả!",
|
||||
"createdAt": "March 15, 2025",
|
||||
"status": "approved",
|
||||
"parentAuthorName": null
|
||||
},
|
||||
{
|
||||
"postSlug": "top-5-scholarship-programs-international-students",
|
||||
"authorName": "Michael Chen",
|
||||
"authorAvatar": "/assets/img/inner-page/news-details/comment-2.png",
|
||||
"content": "Bài viết rất chi tiết và hữu ích. Mình đã tìm thấy một chương trình phù hợp với mình.",
|
||||
"createdAt": "March 18, 2025",
|
||||
"status": "approved",
|
||||
"parentAuthorName": null
|
||||
},
|
||||
{
|
||||
"postSlug": "10-travel-safety-tips-before-flying",
|
||||
"authorName": "Jenny Wilson",
|
||||
"authorAvatar": "/assets/img/inner-page/news-details/comment-3.png",
|
||||
"content": "Những tip này rất thực tế, đặc biệt là phần chuẩn bị bảo hiểm!",
|
||||
"createdAt": "March 02, 2024",
|
||||
"status": "approved",
|
||||
"parentAuthorName": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import Breadcrumb from "@/app/components/Breadcrumb";
|
||||
import NewsSection from "@/app/blog/components/NewsSection";
|
||||
import Sidebar from "@/app/blog/components/Sidebar";
|
||||
import { fetchBlogsByCategory, fetchCategoryDetail } from "@/api/blogsApi";
|
||||
|
||||
interface CategoryPageProps {
|
||||
params:
|
||||
| Promise<{
|
||||
slug: string;
|
||||
}>
|
||||
| {
|
||||
slug: string;
|
||||
};
|
||||
searchParams?: Promise<{ search?: string; page?: string }> | { search?: string; page?: string };
|
||||
}
|
||||
|
||||
export default async function CategoryPage({ params, searchParams }: CategoryPageProps) {
|
||||
// Handle both Promise and direct object
|
||||
const resolvedParams = params instanceof Promise ? await params : params;
|
||||
const slug = resolvedParams.slug;
|
||||
const resolvedSearchParams = searchParams instanceof Promise ? await searchParams : searchParams;
|
||||
const searchQuery = resolvedSearchParams?.search?.toString() || "";
|
||||
const pageParam = resolvedSearchParams?.page?.toString() || "1";
|
||||
const currentPage = Number.parseInt(pageParam, 10) || 1;
|
||||
|
||||
// Fetch category detail and blogs
|
||||
let categoryResponse, blogsResponse;
|
||||
try {
|
||||
[categoryResponse, blogsResponse] = await Promise.all([
|
||||
fetchCategoryDetail(slug),
|
||||
fetchBlogsByCategory(slug, {
|
||||
page: currentPage,
|
||||
limit: 3,
|
||||
...(searchQuery ? { search: searchQuery } : {}),
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
return (
|
||||
<>
|
||||
<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, pagination } = blogsResponse.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb title={category.name} current="Blog Category" />
|
||||
<NewsSection blogs={blogs} categorySlug={slug} searchQuery={searchQuery} pagination={pagination} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import type { BlogPost } from "@/types/blog";
|
||||
import { getCmsImageUrl } from "@/utils";
|
||||
|
||||
interface NewsListProps {
|
||||
blogs?: BlogPost[];
|
||||
categorySlug?: string;
|
||||
tagSlug?: string;
|
||||
}
|
||||
|
||||
export default function NewsList({ blogs = [], categorySlug, tagSlug }: NewsListProps) {
|
||||
// Use blogs from props (already filtered by API)
|
||||
const posts = blogs;
|
||||
|
||||
// Additional client-side filtering if needed (though API should handle this)
|
||||
if (categorySlug || tagSlug) {
|
||||
// If filters are provided but blogs are not pre-filtered, filter here
|
||||
// This is a fallback - ideally API should handle filtering
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col-lg-8 col-12">
|
||||
{posts.map((post, index) => (
|
||||
<div
|
||||
key={post.slug}
|
||||
className={`news-standard-post ${index === posts.length - 1 ? "mb-0" : ""}`}
|
||||
>
|
||||
<div className="news-image">
|
||||
<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 {post.author}
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-calendar-days"></i> {post.publishedAt}
|
||||
</li>
|
||||
<li>
|
||||
<i className="fa-solid fa-comments"></i> {post.commentsCount} Comments
|
||||
</li>
|
||||
</ul>
|
||||
<h3>
|
||||
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
|
||||
</h3>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import NewsList from "./NewsList";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Pagination from "./Pagination";
|
||||
import type { BlogPost, BlogPagination } from "@/types";
|
||||
|
||||
interface NewsSectionProps {
|
||||
blogs?: BlogPost[];
|
||||
categorySlug?: string;
|
||||
tagSlug?: string;
|
||||
searchQuery?: string;
|
||||
pagination?: BlogPagination;
|
||||
}
|
||||
|
||||
export default function NewsSection({
|
||||
blogs,
|
||||
categorySlug,
|
||||
tagSlug,
|
||||
searchQuery,
|
||||
pagination,
|
||||
}: NewsSectionProps) {
|
||||
const basePath = categorySlug
|
||||
? `/blog/category/${categorySlug}`
|
||||
: tagSlug
|
||||
? `/blog/tag/${tagSlug}`
|
||||
: "/blog";
|
||||
|
||||
return (
|
||||
<section className="news-standard-section section-padding fix">
|
||||
<div className="container">
|
||||
<div className="news-standard-wrapper">
|
||||
<div className="row g-4">
|
||||
<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={basePath} pagination={pagination} searchQuery={searchQuery} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import type { BlogPagination } from "@/types";
|
||||
|
||||
interface PaginationProps {
|
||||
basePath: string;
|
||||
pagination: BlogPagination;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export default function Pagination({ basePath, pagination, searchQuery }: PaginationProps) {
|
||||
const { current, total } = pagination;
|
||||
|
||||
if (total <= 1) return null;
|
||||
|
||||
const makeHref = (page: number) => {
|
||||
const params = new URLSearchParams();
|
||||
if (page > 1) params.set("page", page.toString());
|
||||
if (searchQuery) params.set("search", searchQuery);
|
||||
const qs = params.toString();
|
||||
return qs ? `${basePath}?${qs}` : basePath;
|
||||
};
|
||||
|
||||
const pages: number[] = [];
|
||||
for (let i = 1; i <= total; i++) {
|
||||
if (i === 1 || i === total || (i >= current - 2 && i <= current + 2)) {
|
||||
pages.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const items: (number | "...")[] = [];
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const page = pages[i];
|
||||
items.push(page);
|
||||
if (i < pages.length - 1 && pages[i + 1] !== page + 1) {
|
||||
items.push("...");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label="Blog pagination" className="mt-4">
|
||||
<ul className="pagination justify-content-center hai-pagination">
|
||||
{current > 1 && (
|
||||
<li className="page-item">
|
||||
<Link className="page-link" href={makeHref(current - 1)} aria-label="Previous page">
|
||||
<i className="fa-solid fa-arrow-left" aria-hidden="true"></i>
|
||||
</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)} aria-label="Next page">
|
||||
<i className="fa-solid fa-arrow-right" aria-hidden="true"></i>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { fetchCategories, fetchRecentBlogs, fetchPopularTags } from "@/api/blogsApi";
|
||||
import { getCmsImageUrl } from "@/utils";
|
||||
|
||||
interface SidebarProps {
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export default async function Sidebar({ searchQuery }: SidebarProps) {
|
||||
// Fetch data from API
|
||||
const [categoriesResponse, recentBlogsResponse, tagsResponse] = await Promise.all([
|
||||
fetchCategories(),
|
||||
fetchRecentBlogs(5),
|
||||
fetchPopularTags(10),
|
||||
]);
|
||||
|
||||
const categories = categoriesResponse.data;
|
||||
const recentPosts = recentBlogsResponse.data;
|
||||
const tags = tagsResponse.data;
|
||||
|
||||
return (
|
||||
<div className="col-lg-4 col-12">
|
||||
<div className="main-sideber">
|
||||
{/* Search Widget */}
|
||||
<div className="news-sideber-box">
|
||||
<div className="search-widget">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/* Categories */}
|
||||
<div className="news-sideber-box">
|
||||
<div className="wid-title">
|
||||
<h3>Categories</h3>
|
||||
</div>
|
||||
<div className="news-widget-categories">
|
||||
<ul>
|
||||
{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>
|
||||
{/* Recent Post */}
|
||||
<div className="news-sideber-box">
|
||||
<div className="wid-title">
|
||||
<h3>Recent Post</h3>
|
||||
</div>
|
||||
<div className="recent-post-area">
|
||||
{recentPosts.map((post) => (
|
||||
<div key={post.slug} className="recent-items">
|
||||
<div className="recent-thumb">
|
||||
<img
|
||||
src={getCmsImageUrl(post.thumbnail) || "/assets/img/inner-page/news-details/details-1.jpg"}
|
||||
alt={post.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="recent-content">
|
||||
<h6>
|
||||
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
|
||||
</h6>
|
||||
<ul>
|
||||
<li>{post.publishedAt}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Tag Cloud */}
|
||||
<div className="news-sideber-box mb-0">
|
||||
<div className="wid-title">
|
||||
<h3>Tag Cloud</h3>
|
||||
</div>
|
||||
<div className="news-widget-categories">
|
||||
<div className="tagcloud">
|
||||
{tags.map((tag) => (
|
||||
<Link key={tag.slug} href={`/blog/tag/${tag.slug}`}>
|
||||
{tag.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,26 @@
|
||||
import Breadcrumb from "@/app/components/Breadcrumb";
|
||||
import NewsSection from "./components/NewsSection";
|
||||
import { fetchBlogList } from "@/api/blogsApi";
|
||||
|
||||
interface NewsPageProps {
|
||||
searchParams?: Promise<{ search?: string; page?: string }> | { search?: string; page?: string };
|
||||
}
|
||||
|
||||
export default async function NewsPage({ searchParams }: NewsPageProps) {
|
||||
const resolvedSearchParams = searchParams instanceof Promise ? await searchParams : searchParams;
|
||||
const searchQuery = resolvedSearchParams?.search?.toString() || "";
|
||||
const pageParam = resolvedSearchParams?.page?.toString() || "1";
|
||||
const currentPage = Number.parseInt(pageParam, 10) || 1;
|
||||
|
||||
// Fetch blog list from API
|
||||
const blogResponse = await fetchBlogList({
|
||||
page: currentPage,
|
||||
limit: 3,
|
||||
...(searchQuery ? { search: searchQuery } : {}),
|
||||
});
|
||||
const { blogs, pagination } = blogResponse.data;
|
||||
import "./blog-page.css";
|
||||
import FeaturedHero from "../components/blog/FeaturedHero";
|
||||
import CategoryFilters from "../components/blog/CategoryFilters";
|
||||
import NewsGrid from "../components/blog/NewsGrid";
|
||||
import BlogSidebar from "../components/blog/BlogSidebar";
|
||||
|
||||
export default function BlogPage() {
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb title={searchQuery ? `Search: ${searchQuery}` : "Blog Standard"} current="Blog Standard" />
|
||||
<NewsSection blogs={blogs} searchQuery={searchQuery} pagination={pagination} />
|
||||
</>
|
||||
<main className="blog-page w-full min-h-screen bg-ui-bg pb-20">
|
||||
<FeaturedHero />
|
||||
|
||||
<div className="max-w-[1440px] mx-auto px-6 lg:px-8">
|
||||
<div className="flex flex-col lg:flex-row gap-12">
|
||||
{/* Left: filters + news grid */}
|
||||
<div className="flex-1">
|
||||
<CategoryFilters />
|
||||
<NewsGrid />
|
||||
</div>
|
||||
|
||||
{/* Right: sidebar */}
|
||||
<BlogSidebar />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import Breadcrumb from "@/app/components/Breadcrumb";
|
||||
import NewsSection from "@/app/blog/components/NewsSection";
|
||||
import Sidebar from "@/app/blog/components/Sidebar";
|
||||
import { fetchBlogsByTag, fetchTagDetail } from "@/api/blogsApi";
|
||||
|
||||
interface TagPageProps {
|
||||
params:
|
||||
| Promise<{
|
||||
slug: string;
|
||||
}>
|
||||
| {
|
||||
slug: string;
|
||||
};
|
||||
searchParams?: Promise<{ search?: string; page?: string }> | { search?: string; page?: string };
|
||||
}
|
||||
|
||||
export default async function TagPage({ params, searchParams }: TagPageProps) {
|
||||
// Handle both Promise and direct object
|
||||
const resolvedParams = params instanceof Promise ? await params : params;
|
||||
const slug = resolvedParams.slug;
|
||||
const resolvedSearchParams = searchParams instanceof Promise ? await searchParams : searchParams;
|
||||
const searchQuery = resolvedSearchParams?.search?.toString() || "";
|
||||
const pageParam = resolvedSearchParams?.page?.toString() || "1";
|
||||
const currentPage = Number.parseInt(pageParam, 10) || 1;
|
||||
|
||||
// Fetch tag detail and blogs
|
||||
let tagResponse, blogsResponse;
|
||||
try {
|
||||
[tagResponse, blogsResponse] = await Promise.all([
|
||||
fetchTagDetail(slug),
|
||||
fetchBlogsByTag(slug, {
|
||||
page: currentPage,
|
||||
limit: 3,
|
||||
...(searchQuery ? { search: searchQuery } : {}),
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
return (
|
||||
<>
|
||||
<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, pagination } = blogsResponse.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb title={tag.name} current="Blog Tag" />
|
||||
<NewsSection blogs={blogs} tagSlug={slug} searchQuery={searchQuery} pagination={pagination} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
app/components/accreditation/AccreditationCard.tsx
Normal file
20
app/components/accreditation/AccreditationCard.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
type AccreditationCardProps = {
|
||||
logo: string;
|
||||
logoAlt: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export default function AccreditationCard({ logo, logoAlt, title, subtitle, description }: AccreditationCardProps) {
|
||||
return (
|
||||
<div className="bg-white p-8 rounded-[24px] shadow-soft border border-ui-border hover:shadow-hover transition-all duration-300">
|
||||
<div className="h-20 flex items-center mb-6">
|
||||
<img src={logo} alt={logoAlt} className="h-16 object-contain grayscale hover:grayscale-0 transition-all duration-300" />
|
||||
</div>
|
||||
<div className="acc-card-title font-bold text-ui-text mb-2">{title}</div>
|
||||
<p className="text-sm text-brand-accent font-medium mb-4">{subtitle}</p>
|
||||
<p className="text-ui-muted leading-relaxed text-sm">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
app/components/accreditation/AccreditationGrid.tsx
Normal file
43
app/components/accreditation/AccreditationGrid.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import AccreditationCard from "./AccreditationCard";
|
||||
|
||||
const accreditations = [
|
||||
{
|
||||
logo: "https://storage.googleapis.com/uxpilot-auth.appspot.com/5688d070b4-3c6c0b3c66f21c272558.png",
|
||||
logoAlt: "French Ministry of Higher Education Logo",
|
||||
title: "Ministère de l'Enseignement Supérieur",
|
||||
subtitle: "National Institutional Accreditation",
|
||||
description: "Fully recognized by the French government as a degree-granting institution, ensuring our diplomas meet national educational standards.",
|
||||
},
|
||||
{
|
||||
logo: "https://storage.googleapis.com/uxpilot-auth.appspot.com/e2bd5be26e-fb4f2f04e138848db7e4.png",
|
||||
logoAlt: "European Association for Quality Assurance Logo",
|
||||
title: "ENQA",
|
||||
subtitle: "European Quality Assurance",
|
||||
description: "Compliant with the Standards and Guidelines for Quality Assurance in the European Higher Education Area (ESG).",
|
||||
},
|
||||
{
|
||||
logo: "https://storage.googleapis.com/uxpilot-auth.appspot.com/b97d101d24-d2eeb52206ef7f99965a.png",
|
||||
logoAlt: "Global Liberal Arts Alliance Logo",
|
||||
title: "GLAA",
|
||||
subtitle: "International Programmatic Certification",
|
||||
description: "Certified member of the Global Liberal Arts Alliance, affirming our commitment to interdisciplinary education globally.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AccreditationGrid() {
|
||||
return (
|
||||
<section id="accreditation-grid" className="py-20 bg-ui-bg">
|
||||
<div className="max-w-[1440px] mx-auto px-6 lg:px-8">
|
||||
<div className="mb-12">
|
||||
<div className="acc-section-title font-bold text-ui-text mb-4">Recognized Issuing Bodies</div>
|
||||
<p className="text-ui-muted text-lg">Our institutional and programmatic accreditations ensure global recognition of our degrees.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{accreditations.map((item) => (
|
||||
<AccreditationCard key={item.title} {...item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
22
app/components/accreditation/AccreditationHero.tsx
Normal file
22
app/components/accreditation/AccreditationHero.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export default function AccreditationHero() {
|
||||
return (
|
||||
<section id="accreditation-hero" className="relative w-full py-20 lg:py-24 bg-white overflow-hidden mesh-bg border-b border-ui-border">
|
||||
<div className="max-w-[1440px] mx-auto px-6 lg:px-8">
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
<div className="inline-flex items-center gap-2 mb-6">
|
||||
<span className="w-8 h-[2px] bg-brand-accent"></span>
|
||||
<span className="text-sm font-semibold text-brand-blue uppercase tracking-wider">Global Standards </span>
|
||||
<span className="w-8 h-[2px] bg-brand-accent"></span>
|
||||
</div>
|
||||
<div className="acc-heading font-bold text-brand-blue leading-tight mb-6">
|
||||
Accreditations <br />
|
||||
<span className="text-ui-text">& Compliance</span>
|
||||
</div>
|
||||
<p className="text-lg text-ui-muted leading-relaxed mb-8">
|
||||
Our commitment to excellence is validated by leading national and international accrediting bodies. We uphold the highest standards of academic rigor, research ethics, and institutional quality.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
71
app/components/accreditation/QualityStandards.tsx
Normal file
71
app/components/accreditation/QualityStandards.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
const features = [
|
||||
{
|
||||
icon: "fa-solid fa-chart-line",
|
||||
title: "Continuous Evaluation",
|
||||
description: "Annual reviews of all academic programs by independent academic boards.",
|
||||
},
|
||||
{
|
||||
icon: "fa-solid fa-users",
|
||||
title: "Peer Review Integration",
|
||||
description: "Regular assessments conducted by visiting professors from partner institutions.",
|
||||
},
|
||||
{
|
||||
icon: "fa-solid fa-shield",
|
||||
title: "Ethical Compliance",
|
||||
description: "Strict adherence to international research ethics and data protection standards.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function QualityStandards() {
|
||||
return (
|
||||
<section id="quality-standards" className="py-20 bg-white border-t border-ui-border">
|
||||
<div className="max-w-[1440px] mx-auto px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
||||
|
||||
{/* Left: Text content */}
|
||||
<div className="space-y-8">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="w-8 h-[2px] bg-brand-accent"></span>
|
||||
<span className="text-sm font-semibold text-ui-muted uppercase tracking-wider">Quality Assurance</span>
|
||||
</div>
|
||||
<div className="acc-section-title font-bold text-ui-text">Our Framework for Excellence</div>
|
||||
<p className="text-lg text-ui-muted leading-relaxed">
|
||||
Our internal quality assurance mechanisms are designed to continuously evaluate and improve our academic offerings, research outputs, and student services.
|
||||
</p>
|
||||
|
||||
<div className="space-y-6 mt-8">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="flex gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-brand-light flex items-center justify-center text-brand-blue shrink-0">
|
||||
<i className={`${f.icon} text-xl`}></i>
|
||||
</div>
|
||||
<div>
|
||||
<div className="acc-card-title font-bold text-ui-text mb-1">{f.title}</div>
|
||||
<p className="text-ui-muted text-sm">{f.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Images */}
|
||||
<div className="relative">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<img
|
||||
className="w-full h-64 object-cover rounded-[24px] shadow-soft"
|
||||
src="https://storage.googleapis.com/uxpilot-auth.appspot.com/a45c3de13a-8173142c33595269388d.png"
|
||||
alt="Students studying in library"
|
||||
/>
|
||||
<img
|
||||
className="w-full h-64 object-cover rounded-[24px] shadow-soft mt-8"
|
||||
src="https://storage.googleapis.com/uxpilot-auth.appspot.com/cb66207ea0-16795a67db08ef0e6f8c.png"
|
||||
alt="Research facility"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
13
app/components/blog/BlogSidebar.tsx
Normal file
13
app/components/blog/BlogSidebar.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import NewsletterWidget from "./NewsletterWidget";
|
||||
import ResearcherSpotlight from "./ResearcherSpotlight";
|
||||
import UpcomingEvents from "./UpcomingEvents";
|
||||
|
||||
export default function BlogSidebar() {
|
||||
return (
|
||||
<aside className="w-full lg:w-[400px] shrink-0 space-y-8">
|
||||
<NewsletterWidget />
|
||||
<ResearcherSpotlight />
|
||||
<UpcomingEvents />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
21
app/components/blog/CategoryFilters.tsx
Normal file
21
app/components/blog/CategoryFilters.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
const categories = ["All News", "Campus", "Research", "Partnerships", "Events"];
|
||||
|
||||
export default function CategoryFilters() {
|
||||
return (
|
||||
<div id="category-filters" className="flex flex-wrap items-center gap-3 mb-10 pb-6 border-b border-ui-border">
|
||||
{categories.map((cat, i) => (
|
||||
<button
|
||||
key={cat}
|
||||
style={{ borderRadius: "9999px", fontSize: "0.8rem" }}
|
||||
className={
|
||||
i === 0
|
||||
? "px-6 py-2 bg-brand-blue text-white text-sm font-semibold shadow-sm transition-colors"
|
||||
: "px-6 py-2 bg-white border border-ui-border text-ui-text hover:border-brand-blue hover:text-brand-blue text-sm font-medium transition-colors"
|
||||
}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
app/components/blog/FeaturedHero.tsx
Normal file
44
app/components/blog/FeaturedHero.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
export default function FeaturedHero() {
|
||||
return (
|
||||
<section id="featured-hero" className="w-full bg-white border-b border-ui-border mb-12">
|
||||
<div className="max-w-[1440px] mx-auto">
|
||||
<div className="flex flex-col lg:flex-row min-h-[500px]">
|
||||
{/* Left: Content */}
|
||||
<div className="w-full lg:w-1/2 p-8 lg:p-16 flex flex-col justify-center">
|
||||
<span className="inline-block px-3 py-1 bg-brand-light text-brand-blue border border-brand-blue/20 text-xs font-semibold mb-4 w-fit" style={{ borderRadius: "6px" }}>
|
||||
Featured Research
|
||||
</span>
|
||||
<div className="blog-heading text-ui-text tracking-tight mb-6 leading-tight font-bold">
|
||||
Pioneering Sustainable Urban Development in the Heart of Paris
|
||||
</div>
|
||||
<p className="text-lg text-ui-muted mb-8 leading-relaxed">
|
||||
Our latest collaborative research initiative uncovers groundbreaking methods for integrating green infrastructure into historic urban environments, setting a new standard for European cities.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-sm text-ui-muted mb-8 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="https://storage.googleapis.com/uxpilot-auth.appspot.com/avatars/avatar-1.jpg" alt="Author" className="w-8 h-8 rounded-full border border-ui-border" />
|
||||
<span className="font-medium text-ui-text">Dr. Amélie Dubois</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>October 15, 2024</span>
|
||||
<span>•</span>
|
||||
<span>5 min read</span>
|
||||
</div>
|
||||
<a href="#" className="inline-flex items-center justify-center px-5 py-3 text-white font-semibold text-sm hover:bg-brand-hover transition-colors shadow-sm w-fit gap-2" style={{ borderRadius: "5px", backgroundColor: "rgb(38, 60, 111)" }}>
|
||||
Read Full Story <i className="fas fa-arrow-right text-xs"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Right: Image */}
|
||||
<div className="w-full lg:w-1/2 bg-gray-100 relative min-h-[300px] lg:min-h-full">
|
||||
<img
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
src="https://storage.googleapis.com/uxpilot-auth.appspot.com/75c7c284e8-18119bca345a35938b88.png"
|
||||
alt="Aerial view of Paris with sustainable green roofs and modern eco-friendly architecture blending with historic buildings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
48
app/components/blog/NewsCard.tsx
Normal file
48
app/components/blog/NewsCard.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
type NewsCardProps = {
|
||||
category: string;
|
||||
date: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
image?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export default function NewsCard({ category, date, title, excerpt, image, icon }: NewsCardProps) {
|
||||
return (
|
||||
<article className="bg-white rounded-xl border border-ui-border overflow-hidden shadow-soft hover:shadow-hover hover:border-brand-blue/50 transition-all group flex flex-col">
|
||||
{/* Thumbnail */}
|
||||
<div className="h-48 overflow-hidden relative">
|
||||
{image ? (
|
||||
<img
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
src={image}
|
||||
alt={title}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-brand-light flex items-center justify-center">
|
||||
<i className={`${icon} text-4xl text-brand-blue/40`}></i>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-4 left-4">
|
||||
<span className="px-3 py-1 bg-white/90 backdrop-blur-sm text-brand-blue rounded-md text-xs font-bold shadow-sm">
|
||||
{category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<div className="text-xs text-ui-muted mb-3 flex items-center gap-2">
|
||||
<i className="far fa-calendar"></i> {date}
|
||||
</div>
|
||||
<div className="blog-card-title text-ui-text group-hover:text-brand-blue transition-colors mb-3 leading-tight font-bold">
|
||||
{title}
|
||||
</div>
|
||||
<p className="text-sm text-ui-muted mb-6 flex-1 line-clamp-3">{excerpt}</p>
|
||||
<a href="#" className="text-sm font-bold text-brand-blue flex items-center gap-1 group-hover:gap-2 transition-all pt-4">
|
||||
Read more <i className="fas fa-arrow-right text-xs"></i>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
59
app/components/blog/NewsGrid.tsx
Normal file
59
app/components/blog/NewsGrid.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import NewsCard from "./NewsCard";
|
||||
|
||||
const news = [
|
||||
{
|
||||
category: "Campus",
|
||||
date: "Oct 12, 2024",
|
||||
title: "New Liberal Arts Library Wing Opens to Students",
|
||||
excerpt: "The state-of-the-art facility provides expanded collaborative spaces and access to over 50,000 new digital and print resources for our growing student body.",
|
||||
image: "https://storage.googleapis.com/uxpilot-auth.appspot.com/8eafd095d9-314bdad36b2119266084.png",
|
||||
},
|
||||
{
|
||||
category: "Partnerships",
|
||||
date: "Oct 10, 2024",
|
||||
title: "ULP Announces Strategic Alliance with TechGlobal Institute",
|
||||
excerpt: "A new partnership aimed at bridging the gap between liberal arts education and emerging technological paradigms in the 21st century.",
|
||||
icon: "fas fa-handshake",
|
||||
},
|
||||
{
|
||||
category: "Events",
|
||||
date: "Oct 05, 2024",
|
||||
title: "Annual Global Ethics Symposium Draws Record Attendance",
|
||||
excerpt: "Scholars from over 40 countries gathered at ULP this weekend to discuss the evolving landscape of international human rights and digital privacy.",
|
||||
image: "https://storage.googleapis.com/uxpilot-auth.appspot.com/fc832714d8-ee7527b4d94c300aae71.png",
|
||||
},
|
||||
{
|
||||
category: "Research",
|
||||
date: "Sep 28, 2024",
|
||||
title: "Department of Sociology Publishes Landmark Study on Urban Migration",
|
||||
excerpt: "A comprehensive 5-year study reveals shifting demographic patterns in post-industrial European cities, highlighting new socio-economic challenges.",
|
||||
icon: "fas fa-flask",
|
||||
},
|
||||
];
|
||||
|
||||
export default function NewsGrid() {
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12">
|
||||
{news.map((item) => (
|
||||
<NewsCard key={item.title} {...item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex justify-center items-center gap-2 border-t border-ui-border pt-8">
|
||||
<button className="pg-btn w-10 h-10 flex items-center justify-center border border-ui-border text-ui-muted transition-colors disabled:opacity-50" disabled>
|
||||
<i className="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<button className="pg-btn pg-active w-10 h-10 flex items-center justify-center text-white font-bold">1</button>
|
||||
<button className="pg-btn w-10 h-10 flex items-center justify-center border border-ui-border text-ui-text transition-colors font-medium">2</button>
|
||||
<button className="pg-btn w-10 h-10 flex items-center justify-center border border-ui-border text-ui-text transition-colors font-medium">3</button>
|
||||
<span className="text-ui-muted mx-2">...</span>
|
||||
<button className="pg-btn w-10 h-10 flex items-center justify-center border border-ui-border text-ui-text transition-colors font-medium">12</button>
|
||||
<button className="pg-btn w-10 h-10 flex items-center justify-center border border-ui-border text-ui-text transition-colors">
|
||||
<i className="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
app/components/blog/NewsletterWidget.tsx
Normal file
37
app/components/blog/NewsletterWidget.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
export default function NewsletterWidget() {
|
||||
return (
|
||||
<div className="bg-brand-blue rounded-xl p-8 text-white shadow-hover relative overflow-hidden folder-tab">
|
||||
<div className="relative z-10">
|
||||
<div className="blog-widget-title font-bold mb-3">Stay Informed</div>
|
||||
<p className="text-sm text-brand-light/80 mb-6 leading-relaxed ">
|
||||
Subscribe to our weekly newsletter for the latest research updates, campus news, and upcoming events.
|
||||
</p>
|
||||
<form className="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Enter your email address"
|
||||
className="w-full px-4 py-3 mt-4 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white focus:ring-1 focus:ring-white text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 bg-white text-brand-blue rounded-lg font-bold hover:bg-gray-50 transition-colors text-sm shadow-sm"
|
||||
>
|
||||
Subscribe Now
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-xs text-brand-light/60 mt-4 text-center">
|
||||
By subscribing, you agree to our{" "}
|
||||
<a href="#" style={{ color: "inherit", textDecoration: "underline" }}>Privacy Policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Decorative background element */}
|
||||
<div className="absolute -bottom-12 -right-12 text-white/5 text-9xl">
|
||||
<i className="far fa-envelope"></i>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
app/components/blog/ResearcherSpotlight.tsx
Normal file
47
app/components/blog/ResearcherSpotlight.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
const researchers = [
|
||||
{
|
||||
name: "Dr. Sarah Jenkins",
|
||||
field: "Cognitive Linguistics",
|
||||
bio: "Exploring the intersection of language processing and modern ethical frameworks in digital communication.",
|
||||
avatar: "https://storage.googleapis.com/uxpilot-auth.appspot.com/avatars/avatar-5.jpg",
|
||||
},
|
||||
{
|
||||
name: "Prof. Marcus Chen",
|
||||
field: "Economic History",
|
||||
bio: "Recent recipient of the European Heritage Grant for his work on interwar economic policies.",
|
||||
avatar: "https://storage.googleapis.com/uxpilot-auth.appspot.com/avatars/avatar-4.jpg",
|
||||
},
|
||||
{
|
||||
name: "Dr. Elena Rostova",
|
||||
field: "Political Science",
|
||||
bio: "Leading the new comparative study on post-war democratic institutions across Western Europe.",
|
||||
avatar: "https://storage.googleapis.com/uxpilot-auth.appspot.com/avatars/avatar-6.jpg",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ResearcherSpotlight() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-ui-border shadow-soft p-6">
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-ui-border">
|
||||
<div className="blog-widget-title font-bold text-ui-text">Researcher Spotlight</div>
|
||||
<a href="#" className="text-sm text-brand-blue font-medium hover:underline">View All</a>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{researchers.map((r) => (
|
||||
<div key={r.name} className="flex gap-4 items-start group cursor-pointer">
|
||||
<img
|
||||
src={r.avatar}
|
||||
alt={r.name}
|
||||
className="w-16 h-16 rounded-lg object-cover border border-ui-border group-hover:border-brand-blue transition-colors"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-bold text-ui-text text-sm group-hover:text-brand-blue transition-colors">{r.name}</div>
|
||||
<p className="text-xs text-brand-blue font-medium mb-1">{r.field}</p>
|
||||
<p className="text-xs text-ui-muted line-clamp-2">{r.bio}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
app/components/blog/UpcomingEvents.tsx
Normal file
40
app/components/blog/UpcomingEvents.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
const events = [
|
||||
{
|
||||
month: "Nov",
|
||||
day: "12",
|
||||
title: "Open Campus Day",
|
||||
time: "09:00 AM - 04:00 PM",
|
||||
},
|
||||
{
|
||||
month: "Nov",
|
||||
day: "18",
|
||||
title: "Guest Lecture: Future of AI in Arts",
|
||||
time: "06:00 PM - Main Hall",
|
||||
},
|
||||
];
|
||||
|
||||
export default function UpcomingEvents() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-ui-border shadow-soft p-6">
|
||||
<div className="blog-widget-title font-bold text-ui-text mb-6 pb-4 border-b border-ui-border">
|
||||
Upcoming Events
|
||||
</div>
|
||||
<ul className="space-y-4">
|
||||
{events.map((e) => (
|
||||
<li key={e.title} className="flex gap-4">
|
||||
<div className="flex flex-col items-center justify-center w-12 h-12 bg-brand-light rounded-lg border border-brand-blue/20 shrink-0">
|
||||
<span className="text-xs font-bold text-brand-blue uppercase">{e.month}</span>
|
||||
<span className="text-lg font-bold text-ui-text leading-none">{e.day}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-ui-text hover:text-brand-blue cursor-pointer transition-colors">{e.title}</div>
|
||||
<p className="text-xs text-ui-muted mt-1">
|
||||
<i className="far fa-clock mr-1"></i>{e.time}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
app/components/contactus/Accessibility.tsx
Normal file
21
app/components/contactus/Accessibility.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export default function Accessibility() {
|
||||
return (
|
||||
<div className="bg-white rounded-[24px] p-8 shadow-[0_8px_30px_rgb(0,0,0,0.04)] border border-gray-100">
|
||||
<div className="contact-card-title font-bold text-dark mb-4">Accessibility</div>
|
||||
<p className="text-sm text-gray-600 mb-3 mt-2">
|
||||
Our campus is designed to be accessible to everyone. If you require specific accommodations for your visit, please contact us in advance.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="px-4 py-2 bg-gray-50 border border-gray-200 rounded-full text-xs font-medium text-gray-600 flex items-center gap-2">
|
||||
<i className="fa-brands fa-accessible-icon text-primary"></i> Wheelchair Access
|
||||
</div>
|
||||
<div className="px-4 py-2 bg-gray-50 border border-gray-200 rounded-full text-xs font-medium text-gray-600 flex items-center gap-2">
|
||||
<i className="fa-solid fa-elevator text-primary"></i> Elevators
|
||||
</div>
|
||||
<div className="px-4 py-2 bg-gray-50 border border-gray-200 rounded-full text-xs font-medium text-gray-600 flex items-center gap-2">
|
||||
<i className="fa-solid fa-square-parking text-primary"></i> Reserved Parking
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
app/components/contactus/ContactHero.tsx
Normal file
17
app/components/contactus/ContactHero.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export default function ContactHero() {
|
||||
return (
|
||||
<section id="contact-hero" className="bg-white border-b border-gray-200 pt-16 pb-20 px-6 lg:px-12">
|
||||
<div className="max-w-[1440px] mx-auto text-center">
|
||||
<span className="inline-block bg-blue-50 text-xs font-bold px-4 py-2 uppercase tracking-wider rounded-full mb-3 border border-blue-100" style={{ color: "rgb(38, 60, 111)" }}>
|
||||
Get in Touch
|
||||
</span>
|
||||
<div className="contact-heading font-display font-bold text-dark mb-6 leading-tight max-w-3xl mx-auto">
|
||||
How can we help advance your research journey?
|
||||
</div>
|
||||
<p className="text-gray-600 text-lg mb-0 leading-relaxed max-w-2xl mx-auto">
|
||||
Connect with our specialized departments to find the support, guidance, and resources you need to succeed at Paris Research University.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
19
app/components/contactus/ContactSplit.tsx
Normal file
19
app/components/contactus/ContactSplit.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import InquiryForm from "./InquiryForm";
|
||||
import LocationMap from "./LocationMap";
|
||||
import OfficeHours from "./OfficeHours";
|
||||
import Accessibility from "./Accessibility";
|
||||
|
||||
export default function ContactSplit() {
|
||||
return (
|
||||
<section id="inquiry-form-section" className="py-16 px-6 lg:px-12 max-w-[1440px] mx-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-stretch">
|
||||
<InquiryForm />
|
||||
<div className="space-y-8">
|
||||
<LocationMap />
|
||||
<OfficeHours />
|
||||
<Accessibility />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
27
app/components/contactus/DepartmentCard.tsx
Normal file
27
app/components/contactus/DepartmentCard.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
type DepartmentCardProps = {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
};
|
||||
|
||||
export default function DepartmentCard({ icon, title, description, email, phone }: DepartmentCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-[16px] p-8 shadow-[0_8px_30px_rgb(0,0,0,0.04)] border border-gray-100 hover:shadow-[0_8px_30px_rgb(38,60,111,0.08)] transition-all duration-300 flex flex-col h-full group">
|
||||
<div className="w-14 h-14 bg-blue-50 rounded-[12px] flex items-center justify-center text-primary text-2xl mb-6 group-hover:bg-primary group-hover:text-white transition-colors">
|
||||
<i className={icon}></i>
|
||||
</div>
|
||||
<div className="contact-card-title font-display font-bold text-dark mb-3">{title}</div>
|
||||
<p className="text-sm text-gray-600 mb-6 flex-grow">{description}</p>
|
||||
<div className="pt-4 border-t border-gray-100 mt-auto">
|
||||
<a href={`mailto:${email}`} className="flex items-center gap-3 text-sm text-gray-700 hover:text-primary mb-3 transition-colors">
|
||||
<i className="far fa-envelope text-primary w-4"></i> {email}
|
||||
</a>
|
||||
<a href={`tel:${phone.replace(/\s/g, '')}`} className="flex items-center gap-3 text-sm text-gray-700 hover:text-primary transition-colors">
|
||||
<i className="fas fa-phone text-primary w-4"></i> {phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
app/components/contactus/DepartmentCardBlue.tsx
Normal file
22
app/components/contactus/DepartmentCardBlue.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export default function DepartmentCardBlue() {
|
||||
return (
|
||||
<div className="bg-primary text-white rounded-[16px] p-8 shadow-[0_8px_30px_rgb(38,60,111,0.15)] transition-all duration-300 flex flex-col h-full relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-full -mr-10 -mt-10 blur-xl"></div>
|
||||
<div className="w-14 h-14 bg-white/10 rounded-[12px] flex items-center justify-center text-white text-2xl mb-6 backdrop-blur-sm border border-white/20">
|
||||
<i className="fas fa-microscope"></i>
|
||||
</div>
|
||||
<div className="contact-card-title font-display font-bold mb-3">Research Office</div>
|
||||
<p className="text-sm text-white/80 mb-6 flex-grow relative z-10">
|
||||
Grant applications, ethics committee approvals, lab space allocation, and cross-disciplinary collaboration opportunities.
|
||||
</p>
|
||||
<div className="pt-4 border-t border-white/20 mt-auto relative z-10">
|
||||
<a href="mailto:research@parisresearch.edu" className="flex items-center gap-3 text-sm text-white/90 hover:text-white mb-3 transition-colors">
|
||||
<i className="far fa-envelope w-4 opacity-80"></i> research@parisresearch.edu
|
||||
</a>
|
||||
<a href="tel:+33123456790" className="flex items-center gap-3 text-sm text-white/90 hover:text-white transition-colors">
|
||||
<i className="fas fa-phone w-4 opacity-80"></i> +33 1 23 45 67 90
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
app/components/contactus/DepartmentCardEmpty.tsx
Normal file
16
app/components/contactus/DepartmentCardEmpty.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export default function DepartmentCardEmpty() {
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-[16px] p-8 border border-gray-200 border-dashed flex flex-col items-center justify-center text-center h-full">
|
||||
<div className="w-16 h-16 bg-white rounded-full flex items-center justify-center text-gray-400 text-2xl mb-4 shadow-sm">
|
||||
<i className="fas fa-circle-question"></i>
|
||||
</div>
|
||||
<div className="contact-card-title font-bold text-gray-700 mb-2">Not sure who to contact?</div>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Use our general inquiry form below and we'll route your message to the appropriate department.
|
||||
</p>
|
||||
<a href="#inquiry-form-section" className="text-primary font-medium text-sm hover:underline flex items-center gap-2">
|
||||
Go to Form <i className="fas fa-arrow-down"></i>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
app/components/contactus/DepartmentDirectory.tsx
Normal file
49
app/components/contactus/DepartmentDirectory.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import DepartmentCard from "./DepartmentCard";
|
||||
import DepartmentCardBlue from "./DepartmentCardBlue";
|
||||
import DepartmentCardEmpty from "./DepartmentCardEmpty";
|
||||
|
||||
const departments = [
|
||||
{
|
||||
icon: "fas fa-graduation-cap",
|
||||
title: "Admissions & Enrollment",
|
||||
description: "Questions regarding application processes, deadlines, program requirements, and international student visas.",
|
||||
email: "admissions@parisresearch.edu",
|
||||
phone: "+33 1 23 45 67 89",
|
||||
},
|
||||
{
|
||||
icon: "fas fa-handshake",
|
||||
title: "Industry Partnerships",
|
||||
description: "Corporate sponsorships, technology transfer, joint research ventures, and industry-funded scholarships.",
|
||||
email: "partners@parisresearch.edu",
|
||||
phone: "+33 1 23 45 67 91",
|
||||
},
|
||||
{
|
||||
icon: "fas fa-bullhorn",
|
||||
title: "Media & Press",
|
||||
description: "Press releases, expert commentary requests, media kits, and institutional branding guidelines.",
|
||||
email: "press@parisresearch.edu",
|
||||
phone: "+33 1 23 45 67 92",
|
||||
},
|
||||
{
|
||||
icon: "fas fa-book-open",
|
||||
title: "Repository Support",
|
||||
description: "Assistance with navigating the publication database, requesting access to restricted papers, and submitting new works.",
|
||||
email: "library@parisresearch.edu",
|
||||
phone: "+33 1 23 45 67 93",
|
||||
},
|
||||
];
|
||||
|
||||
export default function DepartmentDirectory() {
|
||||
return (
|
||||
<section id="department-directory" className="py-20 px-6 lg:px-12 max-w-[1440px] mx-auto -mt-10 relative z-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<DepartmentCard {...departments[0]} />
|
||||
<DepartmentCardBlue />
|
||||
<DepartmentCard {...departments[1]} />
|
||||
<DepartmentCard {...departments[2]} />
|
||||
<DepartmentCard {...departments[3]} />
|
||||
<DepartmentCardEmpty />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
71
app/components/contactus/InquiryForm.tsx
Normal file
71
app/components/contactus/InquiryForm.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
export default function InquiryForm() {
|
||||
const inputClass = "w-full bg-gray-50 border border-gray-200 text-gray-800 px-4 py-3 rounded-[12px] focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-sm placeholder:text-gray-400";
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-[24px] p-8 md:p-10 shadow-[0_8px_30px_rgb(0,0,0,0.04)] border border-gray-100 h-full flex flex-col">
|
||||
<div className="mb-8">
|
||||
<div className="contact-form-title font-display font-bold text-dark mb-1">Send a Message</div>
|
||||
<p className="text-gray-600 text-sm">Please fill out the form below and our team will get back to you within 24-48 business hours.</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-5 flex-1 flex flex-col">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 block">First Name</label>
|
||||
<input type="text" placeholder="Jane" className={inputClass} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 block">Last Name</label>
|
||||
<input type="text" placeholder="Doe" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 block">Email Address <span className="text-red-500">*</span></label>
|
||||
<input type="email" placeholder="jane.doe@example.com" required className={inputClass} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 block">Inquiry Topic <span className="text-red-500">*</span></label>
|
||||
<div className="relative">
|
||||
<select required className={inputClass + " appearance-none cursor-pointer"}>
|
||||
<option value="" disabled selected>Select a topic...</option>
|
||||
<option value="admissions">Admissions & Enrollment</option>
|
||||
<option value="research">Research Opportunities</option>
|
||||
<option value="partnerships">Corporate Partnerships</option>
|
||||
<option value="media">Media Inquiry</option>
|
||||
<option value="repository">Publication Repository Access</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center px-4 pointer-events-none text-gray-500">
|
||||
<i className="fas fa-chevron-down text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 block">Message <span className="text-red-500">*</span></label>
|
||||
<textarea rows={5} placeholder="How can we help you?" required className={inputClass + " resize-none"} />
|
||||
</div>
|
||||
|
||||
<div className="pt-2 pb-2 space-y-4">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" required className="custom-checkbox mt-1 shrink-0" />
|
||||
<span className="text-xs text-gray-600 leading-relaxed">
|
||||
I consent to having Paris Research University collect my details via this form to respond to my inquiry. I understand my data will be handled in accordance with the{" "}
|
||||
<a href="#" className="text-primary hover:underline">Privacy Policy</a>.
|
||||
</span>
|
||||
</label>
|
||||
<div className="bg-gray-50 p-4 rounded-[12px] border border-gray-200 flex items-center justify-between max-w-xs">
|
||||
<span className="text-sm text-gray-600 font-medium">I am human</span>
|
||||
<input type="checkbox" required className="custom-checkbox w-6 h-6 rounded-full border-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="w-full bg-primary text-white font-bold py-4 rounded-[12px] text-sm hover:bg-opacity-90 transition-colors shadow-md flex items-center justify-center gap-2">
|
||||
Send Message <i className="fas fa-paper-plane text-xs"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
app/components/contactus/LocationMap.tsx
Normal file
26
app/components/contactus/LocationMap.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
export default function LocationMap() {
|
||||
return (
|
||||
<div className="bg-white rounded-[24px] overflow-hidden shadow-[0_8px_30px_rgb(0,0,0,0.04)] border border-gray-100 h-[420px] relative">
|
||||
<div className="absolute inset-0 bg-gray-200 relative">
|
||||
<img
|
||||
className="w-full h-full object-cover"
|
||||
src="https://storage.googleapis.com/uxpilot-auth.appspot.com/3d6faeb2c9-8e1cfd08c74433d49f78.png"
|
||||
alt="Modern minimalist map of Paris showing a university campus location with a blue pin marker"
|
||||
/>
|
||||
{/* Floating Pin Card */}
|
||||
<div className="absolute bottom-6 left-6 right-6 bg-white/95 backdrop-blur-md p-4 rounded-[12px] shadow-lg border border-white/50 flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-blue-50 rounded-full flex items-center justify-center text-primary flex-shrink-0 mt-1">
|
||||
<i className="fas fa-location-dot"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div className="contact-pin-title font-bold text-dark mb-1">Main Campus</div>
|
||||
<p className="text-xs text-gray-600 leading-relaxed">
|
||||
15 Rue de l'École de Médecine<br />75006 Paris, France
|
||||
</p>
|
||||
<a href="#" className="text-primary text-xs font-medium mt-2 inline-block hover:underline">Get Directions</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
app/components/contactus/OfficeHours.tsx
Normal file
28
app/components/contactus/OfficeHours.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
const hours = [
|
||||
{ day: "Monday - Thursday", time: "08:30 - 18:00" },
|
||||
{ day: "Friday", time: "08:30 - 17:00" },
|
||||
{ day: "Saturday - Sunday", time: null },
|
||||
];
|
||||
|
||||
export default function OfficeHours() {
|
||||
return (
|
||||
<div className="bg-white rounded-[24px] p-10 shadow-[0_8px_30px_rgb(0,0,0,0.04)] border border-gray-100">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<i className="far fa-clock text-primary text-xl"></i>
|
||||
<div className="contact-card-title font-display font-bold text-dark" style={{ fontSize: "1.3rem" }}>Office Hours</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{hours.map(({ day, time }) => (
|
||||
<div key={day} className="flex justify-between items-center pb-3 border-b border-gray-100 text-sm">
|
||||
<span className="text-gray-600 font-medium">{day}</span>
|
||||
{time
|
||||
? <span className="text-dark font-bold">{time}</span>
|
||||
: <span className="text-gray-400 font-medium">Closed</span>
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
app/components/partnership/CollaborateCTA.tsx
Normal file
16
app/components/partnership/CollaborateCTA.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import CollaborateInfo from "./CollaborateInfo";
|
||||
import PartnershipForm from "./PartnershipForm";
|
||||
|
||||
export default function CollaborateCTA() {
|
||||
return (
|
||||
<section id="collaborate-cta" className="py-14 bg-brand-blue relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-[800px] h-[800px] bg-brand-hover rounded-full blur-3xl opacity-50 -translate-y-1/2 translate-x-1/3"></div>
|
||||
<div className="max-w-[1440px] mx-auto px-6 lg:px-8 relative z-10">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
||||
<CollaborateInfo />
|
||||
<PartnershipForm />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
35
app/components/partnership/CollaborateInfo.tsx
Normal file
35
app/components/partnership/CollaborateInfo.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
const checkItems = [
|
||||
"Joint Research Initiatives",
|
||||
"Student & Faculty Exchange",
|
||||
"Industry Integration & Internships",
|
||||
];
|
||||
|
||||
export default function CollaborateInfo() {
|
||||
return (
|
||||
<div>
|
||||
{/* Badge */}
|
||||
<div className="inline-flex items-center gap-2 mb-6">
|
||||
<span className="w-8 h-[2px] bg-brand-accent"></span>
|
||||
<span className="text-sm font-semibold text-brand-light uppercase tracking-wider">Join Our Network</span>
|
||||
</div>
|
||||
|
||||
{/* Heading — dùng div tránh main.css override h2 */}
|
||||
<div className="ps-heading text-white font-bold mb-6 leading-tight">
|
||||
Collaborate <br />With Us
|
||||
</div>
|
||||
|
||||
<p className="text-lg text-brand-light opacity-90 mb-8 leading-relaxed max-w-lg">
|
||||
We are constantly seeking to expand our network with institutions and organizations that share our commitment to rigorous research and liberal arts education.
|
||||
</p>
|
||||
|
||||
<ul className="space-y-4 text-white mb-10 pl-4 pt-5">
|
||||
{checkItems.map((item) => (
|
||||
<li key={item} className="flex items-center gap-3">
|
||||
<i className="fas fa-circle-check text-brand-accent"></i>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
app/components/partnership/MainIntroBlock.tsx
Normal file
39
app/components/partnership/MainIntroBlock.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
export default function MainIntroBlock() {
|
||||
return (
|
||||
<div className="col-span-12 lg:col-span-8 bento-item rounded-[24px] p-10 lg:p-16 flex flex-col justify-start pt-12 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-brand-light rounded-full blur-3xl opacity-50 -translate-y-1/2 translate-x-1/4"></div>
|
||||
<div className="relative z-10">
|
||||
|
||||
{/* Badge */}
|
||||
<div className="inline-flex items-center gap-2 mb-6">
|
||||
<span className="w-8 h-[2px] bg-brand-accent"></span>
|
||||
<span className="text-sm font-semibold text-brand-blue uppercase tracking-wider">Global Network</span>
|
||||
</div>
|
||||
|
||||
{/* Heading — dùng div tránh main.css override h1 */}
|
||||
<div className="ps-heading text-brand-blue tracking-tight mb-8">
|
||||
Global <br />Partnerships
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-4 max-w-xl mb-10">
|
||||
<p className="text-lg text-ui-muted leading-relaxed font-medium">
|
||||
[We at Université Libérale de Paris believe that research and liberal arts education thrive through global collaboration.]
|
||||
</p>
|
||||
<p className="text-lg text-ui-muted leading-relaxed">
|
||||
[By bridging international institutions and industry leaders, we create opportunities for our students and faculty to engage in transformative academic exchanges.]
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Button */}
|
||||
<button
|
||||
className="px-5 py-2 bg-white border border-ui-border text-brand-blue text-xs font-semibold transition-all duration-300 flex items-center gap-2 ps-explore-btn"
|
||||
style={{ borderRadius: "20px" }}
|
||||
>
|
||||
Explore Directory <i className="fas fa-arrow-right text-xs"></i>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
app/components/partnership/NetworkHighlightBlock.tsx
Normal file
26
app/components/partnership/NetworkHighlightBlock.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
export default function NetworkHighlightBlock() {
|
||||
return (
|
||||
<div className="col-span-12 md:col-span-6 lg:col-span-4 bento-item ps-highlight-card rounded-[24px] p-10 flex flex-col justify-between text-white relative overflow-hidden">
|
||||
<div className="absolute bottom-0 right-0 w-48 h-48 bg-brand-accent rounded-full blur-2xl opacity-40 translate-y-1/4 translate-x-1/4"></div>
|
||||
<div className="relative z-10">
|
||||
|
||||
{/* Icon + Brand */}
|
||||
<div className="flex items-center gap-3 mb-12">
|
||||
<i className="fas fa-globe text-2xl text-brand-light"></i>
|
||||
<span className="font-bold text-xl tracking-tight">ULP Network</span>
|
||||
</div>
|
||||
|
||||
{/* Heading — dùng div tránh main.css override h2 */}
|
||||
<div>
|
||||
<div className="ps-subheading text-white font-bold leading-tight mb-4">
|
||||
Connect Across<br />Continents
|
||||
</div>
|
||||
<p className="text-brand-light opacity-90">
|
||||
150+ active academic agreements across 45 countries.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
app/components/partnership/PartnershipForm.tsx
Normal file
49
app/components/partnership/PartnershipForm.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
export default function PartnershipForm() {
|
||||
return (
|
||||
<div className="bg-white rounded-[24px] p-8 md:p-10 shadow-2xl">
|
||||
{/* Title — dùng div tránh main.css override h3 */}
|
||||
<div className="ps-form-title text-ui-text font-bold mb-2">Partnership Inquiry</div>
|
||||
<p className="text-sm text-ui-muted mb-8">
|
||||
Fill out this brief form and our Global Relations office will contact you.
|
||||
</p>
|
||||
|
||||
<form className="space-y-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ui-text uppercase tracking-wider mb-2">Institution Name</label>
|
||||
<input type="text" className="w-full px-4 py-3 bg-ui-bg border border-ui-border rounded-xl text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-blue" placeholder="e.g. Oxford University" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ui-text uppercase tracking-wider mb-2">Contact Person</label>
|
||||
<input type="text" className="w-full px-4 py-3 bg-ui-bg border border-ui-border rounded-xl text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-blue" placeholder="Full Name" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ui-text uppercase tracking-wider mb-2">Email Address</label>
|
||||
<input type="email" className="w-full px-4 py-3 bg-ui-bg border border-ui-border rounded-xl text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-blue" placeholder="email@institution.edu" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ui-text uppercase tracking-wider mb-2">Partnership Type</label>
|
||||
<select className="w-full px-4 py-3 bg-ui-bg border border-ui-border rounded-xl text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-brand-blue appearance-none">
|
||||
<option>Academic Exchange</option>
|
||||
<option>Joint Research</option>
|
||||
<option>Industry Partnership</option>
|
||||
<option>Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-ui-text uppercase tracking-wider mb-2">Message</label>
|
||||
<textarea rows={3} className="w-full px-4 py-3 bg-ui-bg border border-ui-border rounded-xl text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-blue resize-none" placeholder="Briefly describe your proposed collaboration..." />
|
||||
</div>
|
||||
|
||||
<button type="button" className="w-full py-4 bg-brand-blue text-white font-bold rounded-xl hover:bg-brand-hover transition-colors shadow-md mt-4">
|
||||
Submit Inquiry
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
app/contactus/contact.css
Normal file
159
app/contactus/contact.css
Normal file
@@ -0,0 +1,159 @@
|
||||
/* ============================================
|
||||
Contact Page — Scoped Styles
|
||||
Scope: .contact-page
|
||||
============================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&display=swap');
|
||||
|
||||
/* Reset heading override từ main.css */
|
||||
.contact-page h1,
|
||||
.contact-page h2,
|
||||
.contact-page h3,
|
||||
.contact-page h4 {
|
||||
font-size: unset;
|
||||
font-weight: unset;
|
||||
line-height: unset;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.contact-page .contact-heading {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-size: clamp(2rem, 4vw, 3.5rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.contact-page .contact-card-title {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.contact-page .contact-form-title {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.contact-page .contact-pin-title {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Color token: --primary */
|
||||
.contact-page .text-primary,
|
||||
.contact-page .hover\:text-primary:hover { color: rgb(38, 60, 111); }
|
||||
|
||||
.contact-page .bg-primary { background-color: rgb(38, 60, 111) !important; }
|
||||
|
||||
.contact-page .border-primary { border-color: rgb(38, 60, 111); }
|
||||
|
||||
.contact-page .focus\:border-primary:focus { border-color: rgb(38, 60, 111); }
|
||||
.contact-page .focus\:ring-primary:focus { --tw-ring-color: rgb(38, 60, 111); }
|
||||
|
||||
/* group-hover bg-primary */
|
||||
.contact-page .group:hover .group-hover\:bg-primary { background-color: rgb(38, 60, 111) !important; }
|
||||
.contact-page .group:hover .group-hover\:text-white { color: #fff; }
|
||||
|
||||
/* Color token: --dark */
|
||||
.contact-page .text-dark { color: #111827; }
|
||||
|
||||
/* bg-white/5, /10 */
|
||||
.contact-page .bg-white\/5 { background-color: rgba(255,255,255,0.05); }
|
||||
.contact-page .bg-white\/10 { background-color: rgba(255,255,255,0.10); }
|
||||
|
||||
/* border-white/20, /50 */
|
||||
.contact-page .border-white\/20 { border-color: rgba(255,255,255,0.20); }
|
||||
.contact-page .border-white\/50 { border-color: rgba(255,255,255,0.50); }
|
||||
|
||||
/* text-white/80, /90 */
|
||||
.contact-page .text-white\/80 { color: rgba(255,255,255,0.80); }
|
||||
.contact-page .text-white\/90 { color: rgba(255,255,255,0.90); }
|
||||
|
||||
/* bg-white/95 */
|
||||
.contact-page .bg-white\/95 { background-color: rgba(255,255,255,0.95); }
|
||||
|
||||
/* border-t border-white/20 */
|
||||
.contact-page .border-white\/20 { border-color: rgba(255,255,255,0.20); }
|
||||
|
||||
/* rounded fixes — main.css có thể reset border-radius */
|
||||
.contact-page .rounded-\[16px\] { border-radius: 16px !important; }
|
||||
.contact-page .rounded-\[24px\] { border-radius: 24px !important; }
|
||||
.contact-page .rounded-\[12px\] { border-radius: 12px !important; }
|
||||
.contact-page .rounded-full { border-radius: 9999px !important; }
|
||||
.contact-page .rounded-\[12px\] { border-radius: 12px !important; }
|
||||
|
||||
/* button reset */
|
||||
.contact-page button,
|
||||
.contact-page input,
|
||||
.contact-page select,
|
||||
.contact-page textarea {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* Send Message button */
|
||||
.contact-page .bg-primary.rounded-\[12px\] {
|
||||
border-radius: 12px !important;
|
||||
background-color: rgb(38, 60, 111) !important;
|
||||
}
|
||||
|
||||
.contact-page .bg-primary.rounded-\[12px\]:hover {
|
||||
background-color: rgba(38, 60, 111, 0.9) !important;
|
||||
}
|
||||
|
||||
/* Icon color fix */
|
||||
.contact-page .text-primary i,
|
||||
.contact-page i.text-primary,
|
||||
.contact-page .text-primary svg,
|
||||
.contact-page svg.text-primary {
|
||||
color: rgb(38, 60, 111) !important;
|
||||
}
|
||||
|
||||
/* Icon box bg-blue-50 */
|
||||
.contact-page .bg-blue-50 {
|
||||
background-color: rgb(239, 246, 255) !important;
|
||||
}
|
||||
|
||||
/* blue-100 border */
|
||||
.contact-page .border-blue-100 {
|
||||
border-color: rgb(219, 234, 254) !important;
|
||||
}
|
||||
|
||||
/* info box bg-blue-50 text-primary */
|
||||
.contact-page .bg-blue-50.rounded-\[12px\] {
|
||||
background-color: rgb(239, 246, 255) !important;
|
||||
}
|
||||
|
||||
/* Icon box hover — đổi icon sang trắng khi hover card */
|
||||
.contact-page .group:hover .group-hover\:text-white i,
|
||||
.contact-page .group:hover .group-hover\:text-white svg {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Form inputs border-radius và placeholder */
|
||||
.contact-page input,
|
||||
.contact-page select,
|
||||
.contact-page textarea {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.contact-page input::placeholder,
|
||||
.contact-page textarea::placeholder {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
/* contact-form-title size */
|
||||
.contact-page .contact-form-title {
|
||||
font-size: 2rem !important;
|
||||
}
|
||||
|
||||
/* Consent checkbox alignment */
|
||||
.contact-page .custom-checkbox {
|
||||
margin-top: 2px !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
14
app/contactus/page.tsx
Normal file
14
app/contactus/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import "./contact.css";
|
||||
import ContactHero from "../components/contactus/ContactHero";
|
||||
import DepartmentDirectory from "../components/contactus/DepartmentDirectory";
|
||||
import ContactSplit from "../components/contactus/ContactSplit";
|
||||
|
||||
export default function ContactUsPage() {
|
||||
return (
|
||||
<main className="contact-page min-h-screen bg-gray-50 pb-20">
|
||||
<ContactHero />
|
||||
<DepartmentDirectory />
|
||||
<ContactSplit />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -13,3 +13,24 @@
|
||||
|
||||
/* Header Responsive Styles */
|
||||
@import "./components/layout/Header/header-responsive.css";
|
||||
|
||||
|
||||
|
||||
@theme {
|
||||
|
||||
--color-brand-blue: #1b254b;
|
||||
|
||||
--color-brand-light: #f8fbff;
|
||||
|
||||
--color-ui-muted: #6b7280;
|
||||
|
||||
--color-ui-border: #e5e7eb;
|
||||
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.bento-item {
|
||||
@apply border border-[--color-ui-border] bg-white;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import localHomeData from './home.json';
|
||||
import { getCmsImageUrl } from '@/utils/image';
|
||||
import { fetchHomeData } from '@/api';
|
||||
|
||||
|
||||
// Force dynamic rendering - không cache
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -46,7 +47,9 @@ export default async function Home() {
|
||||
<FAQSection data={data.faq} />
|
||||
<Achievements data={data.achievements} />
|
||||
<Partners data={data.partners} />
|
||||
<BlogPreview data={data.blogPreview} />
|
||||
|
||||
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
18
app/partnership/page.tsx
Normal file
18
app/partnership/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import "./partnership.css";
|
||||
import MainIntroBlock from "../components/partnership/MainIntroBlock";
|
||||
import NetworkHighlightBlock from "../components/partnership/NetworkHighlightBlock";
|
||||
import CollaborateCTA from "../components/partnership/CollaborateCTA";
|
||||
|
||||
export default function PartnershipPage() {
|
||||
return (
|
||||
<main className="partnership-page min-h-screen bg-gray-50">
|
||||
<div className="max-w-[1440px] mx-auto px-6 lg:px-8 py-20">
|
||||
<div className="grid-bento min-h-[600px]">
|
||||
<MainIntroBlock />
|
||||
<NetworkHighlightBlock />
|
||||
</div>
|
||||
</div>
|
||||
<CollaborateCTA />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
91
app/partnership/partnership.css
Normal file
91
app/partnership/partnership.css
Normal file
@@ -0,0 +1,91 @@
|
||||
/* ============================================
|
||||
Partnership Page — Scoped CSS
|
||||
============================================ */
|
||||
|
||||
/* ---------- Grid ---------- */
|
||||
.partnership-page .grid-bento {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* ---------- Bento item ---------- */
|
||||
.partnership-page .bento-item {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* Card xanh đậm override bento-item */
|
||||
.partnership-page .ps-highlight-card {
|
||||
background-color: #1a365d !important;
|
||||
border-color: #1a365d !important;
|
||||
}
|
||||
|
||||
/* ---------- Typography — thay thế h1/h2/h3 ---------- */
|
||||
.partnership-page .ps-heading,
|
||||
#collaborate-cta .ps-heading {
|
||||
font-size: clamp(2.5rem, 5vw, 4.5rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.partnership-page .ps-subheading,
|
||||
#collaborate-cta .ps-subheading {
|
||||
font-size: clamp(1.75rem, 3vw, 2.25rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.partnership-page .ps-form-title,
|
||||
#collaborate-cta .ps-form-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ---------- Color tokens ---------- */
|
||||
|
||||
.partnership-page .bg-brand-blue,
|
||||
#collaborate-cta .bg-brand-blue { background-color: #1b254b; }
|
||||
|
||||
.partnership-page .text-brand-blue,
|
||||
#collaborate-cta .text-brand-blue { color: #1b254b; }
|
||||
|
||||
.partnership-page .bg-brand-accent,
|
||||
#collaborate-cta .bg-brand-accent { background-color: #3b82f6; }
|
||||
|
||||
.partnership-page .text-brand-accent,
|
||||
#collaborate-cta .text-brand-accent { color: #3b82f6; }
|
||||
|
||||
.partnership-page .bg-brand-light,
|
||||
#collaborate-cta .bg-brand-light { background-color: #f8fbff; }
|
||||
|
||||
.partnership-page .text-brand-light,
|
||||
#collaborate-cta .text-brand-light { color: #f8fbff; }
|
||||
|
||||
.partnership-page .bg-brand-hover,
|
||||
#collaborate-cta .bg-brand-hover { background-color: #2d3a8c; }
|
||||
|
||||
.partnership-page .hover\:bg-brand-hover:hover,
|
||||
#collaborate-cta .hover\:bg-brand-hover:hover { background-color: #2d3a8c; }
|
||||
|
||||
.partnership-page .text-ui-muted,
|
||||
#collaborate-cta .text-ui-muted { color: #6b7280; }
|
||||
|
||||
.partnership-page .text-ui-text,
|
||||
#collaborate-cta .text-ui-text { color: #111827; }
|
||||
|
||||
.partnership-page .border-ui-border,
|
||||
#collaborate-cta .border-ui-border { border-color: #e5e7eb; }
|
||||
|
||||
.partnership-page .bg-ui-bg,
|
||||
#collaborate-cta .bg-ui-bg { background-color: #f9fafb; }
|
||||
|
||||
.partnership-page .hover\:shadow-md:hover {
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Explore Directory button */
|
||||
.partnership-page .ps-explore-btn:hover {
|
||||
box-shadow: 0 8px 20px rgb(0 0 0 / 0.15);
|
||||
}
|
||||
Reference in New Issue
Block a user