Merge pull request 'fea/thanh-02022026-news' (#19) from fea/thanh-02022026-news into main

Reviewed-on: UKSOURCE/hailearning.edu.vn#19
This commit is contained in:
2026-02-05 09:02:06 +00:00
11 changed files with 188 additions and 114 deletions

43
api/homeApi.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Lấy API URL từ environment variable
*/
const getApiUrl = (): string => {
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
if (!apiUrl) {
console.warn('NEXT_PUBLIC_API_URL is not set. Using default http://localhost:3001');
return 'http://localhost:3001';
}
return apiUrl;
};
/**
* Fetch home page data từ API
* @returns Promise<any>
* @throws Error nếu fetch thất bại
*/
export const fetchHomeData = async (): Promise<any> => {
const apiUrl = getApiUrl();
const url = `${apiUrl}/api/home`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
cache: 'no-store',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Error fetching home data:', error);
return null; // Trả về null để fallback sang dữ liệu local
}
};

View File

@@ -6,9 +6,6 @@ import { fetchBlogList, fetchBlogDetail } from "@/api/blogsApi";
import Sidebar from "@/app/blog/components/Sidebar"; import Sidebar from "@/app/blog/components/Sidebar";
import { getCmsImageUrl } from "@/utils"; import { getCmsImageUrl } from "@/utils";
// Force dynamic rendering to avoid build-time API calls
export const dynamic = "force-dynamic";
// Generate static params for all blog posts // Generate static params for all blog posts
export async function generateStaticParams() { export async function generateStaticParams() {
try { try {

View File

@@ -3,9 +3,6 @@ import NewsSection from "@/app/blog/components/NewsSection";
import Sidebar from "@/app/blog/components/Sidebar"; import Sidebar from "@/app/blog/components/Sidebar";
import { fetchBlogsByCategory, fetchCategoryDetail } from "@/api/blogsApi"; import { fetchBlogsByCategory, fetchCategoryDetail } from "@/api/blogsApi";
// Force dynamic rendering to avoid build-time API calls
export const dynamic = "force-dynamic";
interface CategoryPageProps { interface CategoryPageProps {
params: params:
| Promise<{ | Promise<{

View File

@@ -2,9 +2,6 @@ import Breadcrumb from "@/app/components/Breadcrumb";
import NewsSection from "./components/NewsSection"; import NewsSection from "./components/NewsSection";
import { fetchBlogList } from "@/api/blogsApi"; import { fetchBlogList } from "@/api/blogsApi";
// Force dynamic rendering to avoid build-time API calls
export const dynamic = "force-dynamic";
interface NewsPageProps { interface NewsPageProps {
searchParams?: Promise<{ search?: string; page?: string }> | { search?: string; page?: string }; searchParams?: Promise<{ search?: string; page?: string }> | { search?: string; page?: string };
} }

View File

@@ -3,9 +3,6 @@ import NewsSection from "@/app/blog/components/NewsSection";
import Sidebar from "@/app/blog/components/Sidebar"; import Sidebar from "@/app/blog/components/Sidebar";
import { fetchBlogsByTag, fetchTagDetail } from "@/api/blogsApi"; import { fetchBlogsByTag, fetchTagDetail } from "@/api/blogsApi";
// Force dynamic rendering to avoid build-time API calls
export const dynamic = "force-dynamic";
interface TagPageProps { interface TagPageProps {
params: params:
| Promise<{ | Promise<{

View File

@@ -1,3 +1,4 @@
import { getCmsImageUrl } from '@/utils/image';
import Link from 'next/link'; import Link from 'next/link';
interface BlogPreviewProps { interface BlogPreviewProps {
@@ -46,40 +47,66 @@ const BlogPreview = ({ data }: BlogPreviewProps) => {
</Link> </Link>
</div> </div>
<div className="row"> <div className="row">
{data.items.map((item, index) => ( {data.items.map((item, index) => {
<div key={index} className="col-xl-4 col-lg-6 col-md-6 wow fadeInUp" data-wow-delay={`.${(index + 1) * 2 + 1}s`}> const thumbUrl = getCmsImageUrl(item.thumbnail);
<div className="news-card-item"> return (
<div className="news-image"> <div key={index} className="col-xl-4 col-lg-6 col-md-6 wow fadeInUp" data-wow-delay={`.${(index + 1) * 2 + 1}s`}>
<img src={item.thumbnail} alt="img" /> <div className="news-card-item">
<span>{item.category}</span> <div className="news-image">
<div className="news-layer-wrapper"> <img
<div className="news-layer-image" style={{ backgroundImage: `url('${item.thumbnail}')` }}></div> src={thumbUrl}
<div className="news-layer-image" style={{ backgroundImage: `url('${item.thumbnail}')` }}></div> alt="img"
<div className="news-layer-image" style={{ backgroundImage: `url('${item.thumbnail}')` }}></div> style={{
<div className="news-layer-image" style={{ backgroundImage: `url('${item.thumbnail}')` }}></div> width: '419px',
</div> height: '312px',
</div> objectFit: 'cover'
<div className="news-content"> }}
<div className="list"> />
<span>Comment ({item.comments.toString().padStart(2, '0')})</span> <span>{item.category}</span>
<span>_ {formatDate(item.date)}</span> <div className="news-layer-wrapper">
</div> <div className="news-layer-image" style={{ backgroundImage: `url('${thumbUrl}')`, width: '419px', height: '312px' }}></div>
<h3> <div className="news-layer-image" style={{ backgroundImage: `url('${thumbUrl}')`, width: '419px', height: '312px' }}></div>
<Link href={item.link}> <div className="news-layer-image" style={{ backgroundImage: `url('${thumbUrl}')`, width: '419px', height: '312px' }}></div>
{item.title} <div className="news-layer-image" style={{ backgroundImage: `url('${thumbUrl}')`, width: '419px', height: '312px' }}></div>
</Link> </div>
</h3> </div>
<div className="news-bottom"> <div className="news-content">
<div className="info-item"> <div className="list">
<img src={item.author.avatar} alt="img" /> <span>Comment ({item.comments.toString().padStart(2, '0')})</span>
<span>By {item.author.name}</span> <span>_ {formatDate(item.date)}</span>
</div>
<h3>
<Link href={item.link}>
{item.title}
</Link>
</h3>
<div className="news-bottom">
<div className="info-item">
<div
style={{
width: '32px',
height: '32px',
backgroundColor: '#d1d5db',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontWeight: 'bold',
color: '#374151',
marginRight: '10px'
}}
>
{item.author.name.charAt(0).toUpperCase()}
</div>
<span>By {item.author.name}</span>
</div>
<Link href={item.link} className="link-btn">View Articles<i className="fa-solid fa-arrow-right"></i></Link>
</div> </div>
<Link href={item.link} className="link-btn">View Articles<i className="fa-solid fa-arrow-right"></i></Link>
</div> </div>
</div> </div>
</div> </div>
</div> );
))} })}
</div> </div>
</div> </div>
</section> </section>

View File

@@ -1,3 +1,4 @@
import { getCmsImageUrl } from '@/utils/image';
import Link from 'next/link'; import Link from 'next/link';
interface HeroSectionProps { interface HeroSectionProps {
@@ -20,7 +21,7 @@ interface HeroSectionProps {
const HeroSection = ({ data }: HeroSectionProps) => { const HeroSection = ({ data }: HeroSectionProps) => {
return ( return (
<section className="hero-section hero-1 fix bg-cover" style={{ backgroundImage: `url('${data.backgroundImage}')` }}> <section className="hero-section hero-1 fix bg-cover" style={{ backgroundImage: `url('${getCmsImageUrl(data.backgroundImage)}')` }}>
<div className="left-shape"> <div className="left-shape">
<img src="/assets/img/home-1/hero/sape-2.png" alt="img" /> <img src="/assets/img/home-1/hero/sape-2.png" alt="img" />
</div> </div>

View File

@@ -1,11 +1,19 @@
import { getCmsImageUrl } from '@/utils/image';
interface PartnersProps { interface PartnersProps {
data: { data: {
heading: string; visaConsultancy: {
items: { items: {
name: string; name: string;
logo: string; icon: string;
year: string; year: string;
}[]; }[];
};
brands: {
items: {
logo: string;
}[];
};
}; };
} }
@@ -16,11 +24,11 @@ const Partners = ({ data }: PartnersProps) => {
<section className="visa-consultency-section section-padding fix"> <section className="visa-consultency-section section-padding fix">
<div className="container"> <div className="container">
<div className="row g-4"> <div className="row g-4">
{data.items.slice(0, 4).map((partner, index) => ( {(data.visaConsultancy?.items || []).map((partner, index) => (
<div key={index} className="col-xl-3 col-lg-4 col-md-6"> <div key={index} className="col-xl-3 col-lg-4 col-md-6">
<div className="visa-consultency-item"> <div className="visa-consultency-item">
<div className="image"> <div className="image">
<img src={partner.logo} alt="img" /> <img src={getCmsImageUrl(partner.icon)} alt={partner.name} />
</div> </div>
<h3>{partner.name}</h3> <h3>{partner.name}</h3>
<h6>{partner.year}</h6> <h6>{partner.year}</h6>
@@ -38,10 +46,10 @@ const Partners = ({ data }: PartnersProps) => {
<div className="brand-item"> <div className="brand-item">
<div className="swiper brand-slider"> <div className="swiper brand-slider">
<div className="swiper-wrapper"> <div className="swiper-wrapper">
{data.items.slice(4).map((partner, index) => ( {(data.brands?.items || []).map((brand, index) => (
<div key={index} className="swiper-slide"> <div key={index} className="swiper-slide">
<div className="brand-image text-center"> <div className="brand-image text-center">
<img src={partner.logo} alt={partner.name} /> <img src={getCmsImageUrl(brand.logo)} alt="brand-logo" />
</div> </div>
</div> </div>
))} ))}

View File

@@ -212,7 +212,7 @@
"subheading": "Did You Know", "subheading": "Did You Know",
"items": [ "items": [
{ {
"value": "1000", "value": "1",
"suffix": "k+", "suffix": "k+",
"label": "Students Guided", "label": "Students Guided",
"description": "Successfully assisted over a thousand students worldwide." "description": "Successfully assisted over a thousand students worldwide."
@@ -238,54 +238,39 @@
] ]
}, },
"partners": { "partners": {
"heading": "Our Trusted Partners", "visaConsultancy": {
"items": [ "items": [
{ {
"name": "Best Visa Consultancy", "name": "Best Visa Consultancy",
"logo": "/assets/img/home-1/feature/icon-1.png", "icon": "/assets/img/home-1/feature/icon-1.png",
"year": "2025" "year": "2025"
}, },
{ {
"name": "Visa Success Award", "name": "Visa Success Award",
"logo": "/assets/img/home-1/feature/icon-2.png", "icon": "/assets/img/home-1/feature/icon-2.png",
"year": "2025" "year": "2025"
}, },
{ {
"name": "Innovation Award", "name": "Innovation Award",
"logo": "/assets/img/home-1/feature/icon-3.png", "icon": "/assets/img/home-1/feature/icon-3.png",
"year": "2025" "year": "2025"
}, },
{ {
"name": "Global Education Partner", "name": "Global Education Partner",
"logo": "/assets/img/home-1/feature/icon-4.png", "icon": "/assets/img/home-1/feature/icon-4.png",
"year": "2025" "year": "2025"
}, }
{ ]
"name": "University Partner 1", },
"logo": "/assets/img/home-1/brand/01.png", "brands": {
"year": "2025" "items": [
}, { "logo": "/assets/img/home-1/brand/01.png" },
{ { "logo": "/assets/img/home-1/brand/02.png" },
"name": "University Partner 2", { "logo": "/assets/img/home-1/brand/03.png" },
"logo": "/assets/img/home-1/brand/02.png", { "logo": "/assets/img/home-1/brand/04.png" },
"year": "2025" { "logo": "/assets/img/home-1/brand/05.png" }
}, ]
{ }
"name": "University Partner 3",
"logo": "/assets/img/home-1/brand/03.png",
"year": "2025"
},
{
"name": "University Partner 4",
"logo": "/assets/img/home-1/brand/04.png",
"year": "2025"
},
{
"name": "University Partner 5",
"logo": "/assets/img/home-1/brand/05.png",
"year": "2025"
}
]
}, },
"blogPreview": { "blogPreview": {
"heading": "Latest Insights & Updates", "heading": "Latest Insights & Updates",

View File

@@ -8,21 +8,42 @@ import FAQSection from './components/home/FAQSection';
import Achievements from './components/home/Achievements'; import Achievements from './components/home/Achievements';
import Partners from './components/home/Partners'; import Partners from './components/home/Partners';
import BlogPreview from './components/home/BlogPreview'; import BlogPreview from './components/home/BlogPreview';
import homeData from './home.json'; import localHomeData from './home.json';
import { fetchHomeData } from '@/api';
import { getCmsImageUrl } from '@/utils/image';
export default async function Home() {
// Fetch home data (blog aggregation is now handled by the backend)
const apiHomeData = await fetchHomeData();
// Use API home data if available, otherwise fallback to local JSON
const data = JSON.parse(JSON.stringify(apiHomeData || localHomeData));
// Process Hero Image URL
if (data.hero?.backgroundImage) {
data.hero.backgroundImage = getCmsImageUrl(data.hero.backgroundImage);
}
// Process blog images from CMS (thumbnail normalization)
if (data.blogPreview?.items) {
data.blogPreview.items = data.blogPreview.items.map((item: any) => ({
...item,
thumbnail: getCmsImageUrl(item.thumbnail) || '/assets/img/home-1/news/01.jpg'
}));
}
export default function Home() {
return ( return (
<> <>
<HeroSection data={homeData.hero} /> <HeroSection data={data.hero} />
<WhyChooseUs data={homeData.whyChooseUs} /> <WhyChooseUs data={data.whyChooseUs} />
<VisaSolutions data={homeData.visaSolutions} /> <VisaSolutions data={data.visaSolutions} />
<VisaCountries data={homeData.visaCountries} /> <VisaCountries data={data.visaCountries} />
<Testimonials data={homeData.testimonials} /> <Testimonials data={data.testimonials} />
<VideoGallery data={homeData.videoGallery} /> <VideoGallery data={data.videoGallery} />
<FAQSection data={homeData.faq} /> <FAQSection data={data.faq} />
<Achievements data={homeData.achievements} /> <Achievements data={data.achievements} />
<Partners data={homeData.partners} /> <Partners data={data.partners} />
<BlogPreview data={homeData.blogPreview} /> <BlogPreview data={data.blogPreview} />
</> </>
); );
} }

View File

@@ -9,6 +9,7 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"axios": "^1.13.4",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"