diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b913a3c --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +PORT=3000 +NEXT_PUBLIC_API_URL=https://www.hailearning.edu.vn/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2ad4ad4..0dc5d70 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,7 @@ yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) -.env* +.env # vercel .vercel diff --git a/api/aboutApi.ts b/api/aboutApi.ts new file mode 100644 index 0000000..d893c5e --- /dev/null +++ b/api/aboutApi.ts @@ -0,0 +1,35 @@ +/** + * About API Functions + * Fetch about us data from external API + */ + +const getApiUrl = (): string => { + return process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; +}; + +import { AboutData } from "../app/about/types"; + +export const aboutApi = { + // Get about us data + getAbout: async (): Promise => { + try { + const apiUrl = getApiUrl(); + const response = await fetch(`${apiUrl}/api/about`, { + // Không cache - luôn fetch dữ liệu mới nhất + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + } + }); + if (!response.ok) { + console.error(`HTTP error! status: ${response.status}`); + return null; + } + return await response.json(); + } catch (error) { + console.error("Error fetching about data:", error); + return null; + } + }, +}; + diff --git a/api/blogsApi.ts b/api/blogsApi.ts index 0f8b43a..54df225 100644 --- a/api/blogsApi.ts +++ b/api/blogsApi.ts @@ -53,9 +53,7 @@ export const fetchBlogList = async ( headers: { 'Content-Type': 'application/json', }, - // Next.js: cache và revalidate (disabled) - // next: { revalidate: 60 }, // Revalidate mỗi 60 giây - // no-cache + // Không cache - luôn fetch dữ liệu mới nhất cache: 'no-store', }); @@ -91,8 +89,7 @@ export const fetchBlogDetail = async ( headers: { 'Content-Type': 'application/json', }, - // No cache for blog detail (disabled caching) - // no-cache + // Không cache - luôn fetch dữ liệu mới nhất cache: 'no-store', }); @@ -131,8 +128,7 @@ export const fetchFeaturedBlogs = async ( headers: { 'Content-Type': 'application/json', }, - // next: { revalidate: 60 }, - // no-cache + // Không cache - luôn fetch dữ liệu mới nhất cache: 'no-store', }); @@ -168,8 +164,7 @@ export const fetchRecentBlogs = async ( headers: { 'Content-Type': 'application/json', }, - // next: { revalidate: 60 }, - // no-cache + // Không cache - luôn fetch dữ liệu mới nhất cache: 'no-store', }); @@ -202,8 +197,7 @@ export const fetchCategories = async (): Promise => { headers: { 'Content-Type': 'application/json', }, - // next: { revalidate: 300 }, // Categories ít thay đổi, cache lâu hơn - // no-cache + // Không cache - luôn fetch dữ liệu mới nhất cache: 'no-store', }); @@ -239,8 +233,7 @@ export const fetchCategoryDetail = async ( headers: { 'Content-Type': 'application/json', }, - // next: { revalidate: 300 }, - // no-cache + // Không cache - luôn fetch dữ liệu mới nhất cache: 'no-store', }); @@ -276,8 +269,7 @@ export const fetchTags = async (): Promise => { headers: { 'Content-Type': 'application/json', }, - // next: { revalidate: 300 }, - // no-cache + // Không cache - luôn fetch dữ liệu mới nhất cache: 'no-store', }); @@ -313,8 +305,7 @@ export const fetchPopularTags = async ( headers: { 'Content-Type': 'application/json', }, - // next: { revalidate: 300 }, - // no-cache + // Không cache - luôn fetch dữ liệu mới nhất cache: 'no-store', }); @@ -350,8 +341,7 @@ export const fetchTagDetail = async ( headers: { 'Content-Type': 'application/json', }, - // next: { revalidate: 300 }, - // no-cache + // Không cache - luôn fetch dữ liệu mới nhất cache: 'no-store', }); diff --git a/api/servicesApi.ts b/api/servicesApi.ts index 9bae705..5c1d230 100644 --- a/api/servicesApi.ts +++ b/api/servicesApi.ts @@ -138,6 +138,8 @@ export const fetchServicePageData = async (): Promise => { headers: { "Content-Type": "application/json", }, + // Không cache - luôn fetch dữ liệu mới nhất + cache: "no-store", }); console.log("Services API response status:", response.status); @@ -179,6 +181,8 @@ export const fetchServiceBySlug = async (slug: string): Promise => { headers: { "Content-Type": "application/json", }, + // Không cache - luôn fetch dữ liệu mới nhất + cache: "no-store", }); console.log("Response status:", response.status); diff --git a/api/visa.ts b/api/visa.ts index 0a6f9e4..357cecd 100644 --- a/api/visa.ts +++ b/api/visa.ts @@ -10,6 +10,7 @@ interface VisaItem { } interface VisaTypeCategory { + title?: string; items: VisaItem[]; } @@ -433,7 +434,7 @@ function getFallbackVisaData(): VisaData { tagline: "35+ years of excellence in UK visa consulting.", visaTypes: [ { - category: "Student & Work", + title: "Student & Work", items: [ { title: "Student Visa", @@ -448,7 +449,7 @@ function getFallbackVisaData(): VisaData { ], }, { - category: "Family & Other", + title: "Family & Other", items: [ { title: "Skilled Worker Visa", @@ -601,7 +602,7 @@ function getFallbackVisaData(): VisaData { tagline: "Your pathway to a better life in Canada starts here.", visaTypes: [ { - category: "Study & Work", + title: "Study & Work", items: [ { title: "Study Permit", @@ -614,7 +615,7 @@ function getFallbackVisaData(): VisaData { ], }, { - category: "Immigration & Family", + title: "Immigration & Family", items: [ { title: "Express Entry", diff --git a/app/about/about.json b/app/about/about.json deleted file mode 100644 index fbfdab5..0000000 --- a/app/about/about.json +++ /dev/null @@ -1,121 +0,0 @@ -{ - "hero": { - "title": "About Us", - "subtitle": "Global Education Simplified", - "breadcrumb": ["Home", "About Us"], - "backgroundImage": "/assets/img/inner-page/breadcrumb.jpg" - }, - "intro": { - "subheading": "Company Intro", - "heading": "Building Pathways to Your Immigration Success", - "description": "We provide expert guidance, personalized solutions, and transparent processes to help you achieve your immigration goals. Our dedicated team ensures a smooth journey, building pathways to your international success.", - "image": "/assets/img/inner-page/intro.jpg" - }, - "mission": { - "subheading": "About Our Consultancy", - "heading": "Turning Study Abroad Dreams Into Reality", - "description": "We guide students with expert visa consulting, ensuring a smooth process from application to approval, turning study abroad aspirations into life-changing opportunities for a brighter future.", - "images": { - "main": "/assets/img/home-1/about/about-1.jpg", - "secondary": "/assets/img/home-1/about/about-02.jpg", - "bgShape": "/assets/img/home-1/about/Vector.png", - "planeShape": "/assets/img/home-1/about/plane.png", - "topShape": "/assets/img/home-1/about/shape.png", - "globeShape": "/assets/img/home-1/about/globe.png" - }, - "items": [ - { - "icon": "/assets/img/home-1/icon/01.svg", - "label": "Global Reach", - "description": "Expanding Opportunities Worldwide" - }, - { - "icon": "/assets/img/home-1/icon/01.svg", - "label": "Global Reach", - "description": "Expanding Opportunities Worldwide" - } - ], - "features": [ - "Fastest Visa form processing with skilled immigration agents", - "Partnership with International Educational Institutions" - ], - "ctaButton": { - "label": "Get Started", - "href": "/about" - } - }, - "features": { - "backgroundImage": "/assets/img/home-2/feature/bg-shape.png", - "subheading": "Your Travel Made Easy", - "heading": "Smooth Visa Journey Guaranteed", - "description": "We provide expert guidance for every visa application, ensuring smooth processing, personalized support, and reliable assistance", - "image": "/assets/img/home-2/feature/02.png", - "items": [ - { - "icon": "/assets/img/home-2/icon/01.png", - "title": "Expert Consultants", - "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors." - }, - { - "icon": "/assets/img/home-2/icon/01.png", - "title": "Personalized Support", - "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors." - }, - { - "icon": "/assets/img/home-2/icon/01.png", - "title": "Transparent Process", - "description": "Skilled and knowledgeable visa advisors. Skilled and knowledgeable visa advisors." - } - ], - "ctaButton": { - "label": "Get Started Today", - "href": "/contact" - } - }, - "news": { - "subheading": "Visa Tips & Guides", - "heading": "Latest Insights & Updates", - "ctaButton": { - "label": "view all articles", - "href": "/blog" - }, - "items": [ - { - "title": "Step-by-Step Guide to Applying for a Student Visa", - "category": "Student Visa", - "date": "20 August ,2025", - "comments": 8, - "author": { - "name": "Sohel", - "avatar": "/assets/img/home-1/news/client.png" - }, - "link": "/blog/step-by-step-guide-student-visa", - "thumbnail": "/assets/img/home-1/news/news-1.jpg" - }, - { - "title": "Tips to Prepare Financial Documents for Visa Approval", - "category": "IELTS / TOEFL", - "date": "20 August ,2025", - "comments": 8, - "author": { - "name": "Sohel", - "avatar": "/assets/img/home-1/news/client.png" - }, - "link": "/blog/financial-documents-visa-approval", - "thumbnail": "/assets/img/home-1/news/news-2.jpg" - }, - { - "title": "Post-Arrival Guide What Every Student Should Know", - "category": "Study Abroad", - "date": "20 August ,2025", - "comments": 8, - "author": { - "name": "Sohel", - "avatar": "/assets/img/home-1/news/client.png" - }, - "link": "/blog/post-arrival-guide-students", - "thumbnail": "/assets/img/home-1/news/news-3.jpg" - } - ] - } -} diff --git a/app/about/page.tsx b/app/about/page.tsx index 0f7f075..e8958c5 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -1,14 +1,24 @@ import { AboutHero, AboutIntro, AboutMission, AboutFeatures, AboutNews } from "../components/about"; -import aboutData from "./about.json"; +import { aboutApi } from "../../api/aboutApi"; + +// Force dynamic rendering - không cache +export const dynamic = 'force-dynamic'; + +export default async function AboutPage() { + const data = await aboutApi.getAbout(); + + if (!data) { + return null; + } -export default function AboutPage() { return ( <> - - - - - + + + + + ); } + diff --git a/app/about/types.ts b/app/about/types.ts index 8d4cc1a..2f863dd 100644 --- a/app/about/types.ts +++ b/app/about/types.ts @@ -1,7 +1,6 @@ export interface AboutData { hero: { title: string; - subtitle: string; breadcrumb: string[]; backgroundImage: string; }; diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index 10ea765..336f3ec 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -6,6 +6,9 @@ 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 { diff --git a/app/components/Loader.tsx b/app/components/Loader.tsx index bcb074e..a597820 100644 --- a/app/components/Loader.tsx +++ b/app/components/Loader.tsx @@ -3,47 +3,72 @@ import { useEffect, useState } from "react"; export default function Loader() { - const [show, setShow] = useState(true); + const [show, setShow] = useState(true); - useEffect(() => { - const timer = setTimeout(() => setShow(false), 700); - return () => clearTimeout(timer); - }, []); + useEffect(() => { + const timer = setTimeout(() => setShow(false), 1100); + return () => clearTimeout(timer); + }, []); - if (!show) return null; + if (!show) return null; - return ( -
-
-
-
- V - I - S - A - W - A - Y -
-

Loading

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ return ( +
+
+
+
+ + H + + + A + + + I + + + L + + + E + + + A + + + R + + + N + + + I + + + N + + + G +
- ); +

Loading

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); } - diff --git a/app/components/about/AboutHero.tsx b/app/components/about/AboutHero.tsx index b18f21a..9ab631c 100644 --- a/app/components/about/AboutHero.tsx +++ b/app/components/about/AboutHero.tsx @@ -14,20 +14,22 @@ const AboutHero = ({ data }: AboutHeroProps) => {

{data.title}

-
    - {data.breadcrumb.map((item, index) => ( -
  • - {index === data.breadcrumb.length - 1 ? ( - item - ) : ( - <> - {item} - - - )} -
  • - ))} -
+ {Array.isArray(data.breadcrumb) && ( +
    + {data.breadcrumb.map((item, index) => ( +
  • + {index === data.breadcrumb.length - 1 ? ( + item + ) : ( + <> + {item} + + + )} +
  • + ))} +
+ )}
diff --git a/app/components/home/Partners.tsx b/app/components/home/Partners.tsx index fc41f3b..5681d28 100644 --- a/app/components/home/Partners.tsx +++ b/app/components/home/Partners.tsx @@ -27,7 +27,7 @@ const Partners = ({ data }: PartnersProps) => { {(data.visaConsultancy?.items || []).map((partner, index) => (
-
+
{partner.name}

{partner.name}

@@ -48,7 +48,7 @@ const Partners = ({ data }: PartnersProps) => {
{(data.brands?.items || []).map((brand, index) => (
-
+
brand-logo
diff --git a/app/components/layout/Footer/Footer.tsx b/app/components/layout/Footer/Footer.tsx index e97ee25..4a1fae1 100644 --- a/app/components/layout/Footer/Footer.tsx +++ b/app/components/layout/Footer/Footer.tsx @@ -1,11 +1,32 @@ +"use client"; + +import { useEffect, useState } from 'react'; import FooterTop from './FooterTop'; import FooterBottom from './FooterBottom'; +import { footerApi, FooterData } from "../../../../api/footerApi"; +import footerData from "./footer.json"; const Footer = () => { + const [data, setData] = useState(footerData as FooterData); + + useEffect(() => { + const loadFooterData = async () => { + try { + const apiData = await footerApi.getFooter(); + setData(apiData); + } catch (error) { + console.error("Failed to load footer data from API, using static data:", error); + // Keep using static data as fallback + } + }; + + loadFooterData(); + }, []); + return ( <> - - + + ); }; diff --git a/app/components/layout/Footer/FooterBottom.tsx b/app/components/layout/Footer/FooterBottom.tsx index 7bcd406..1087960 100644 --- a/app/components/layout/Footer/FooterBottom.tsx +++ b/app/components/layout/Footer/FooterBottom.tsx @@ -1,29 +1,18 @@ "use client"; import Link from "next/link"; -import { useEffect, useState } from "react"; -import { footerApi, FooterData } from "../../../../api/footerApi"; +import { FooterData } from "../../../../api/footerApi"; import footerData from "./footer.json"; -const FooterBottom = () => { - const [data, setData] = useState(footerData as FooterData); +interface FooterBottomProps { + data: FooterData; +} - useEffect(() => { - const loadFooterData = async () => { - try { - const apiData = await footerApi.getFooter(); - setData(apiData); - } catch (error) { - console.error("Failed to load footer data from API, using static data:", error); - // Keep using static data as fallback - } - }; - - loadFooterData(); - }, []); +const FooterBottom = ({ data }: FooterBottomProps) => { + const effectiveData = data || footerData; // Ensure we always have a valid `bottom` object, even if API shape changes - const bottom = data?.bottom || footerData.bottom; + const bottom = effectiveData?.bottom || footerData.bottom; // If bottom is still missing, avoid rendering to prevent runtime errors if (!bottom) { diff --git a/app/components/layout/Footer/FooterTop.tsx b/app/components/layout/Footer/FooterTop.tsx index 6cc9d72..d04dd17 100644 --- a/app/components/layout/Footer/FooterTop.tsx +++ b/app/components/layout/Footer/FooterTop.tsx @@ -1,29 +1,19 @@ "use client"; import Link from "next/link"; -import { useEffect, useState } from "react"; -import { footerApi, FooterData } from "../../../../api/footerApi"; +import { FooterData } from "../../../../api/footerApi"; import footerData from "./footer.json"; -const FooterTop = () => { - const [data, setData] = useState(footerData as FooterData); +interface FooterTopProps { + data: FooterData; +} - useEffect(() => { - const loadFooterData = async () => { - try { - const apiData = await footerApi.getFooter(); - setData(apiData); - } catch (error) { - console.error("Failed to load footer data from API, using static data:", error); - // Keep using static data as fallback - } - }; - - loadFooterData(); - }, []); +const FooterTop = ({ data }: FooterTopProps) => { + // Use passed data, fallback to static json if needed + const effectiveData = data || footerData; // Ensure we always have a valid `top` object, even if API shape changes - const top = data?.top || footerData.top; + const top = effectiveData?.top || footerData.top; // If for some reason `top` is still missing, avoid rendering to prevent runtime errors if (!top) { diff --git a/app/components/layout/Header/Header.tsx b/app/components/layout/Header/Header.tsx index 7b724e1..7fb025d 100644 --- a/app/components/layout/Header/Header.tsx +++ b/app/components/layout/Header/Header.tsx @@ -1,74 +1,97 @@ -'use client'; +"use client"; -import { useEffect, useState, useCallback } from 'react'; -import HeaderTop from './HeaderTop'; -import HeaderBottom from './HeaderBottom'; -import Offcanvas from './Offcanvas'; -import { headerMenuService } from '@/services/header-menu.service'; -import { HeaderMenu as HeaderMenuType } from '@/types/header-menu'; +import { useEffect, useState, useCallback } from "react"; +import HeaderTop from "./HeaderTop"; +import HeaderBottom from "./HeaderBottom"; +import Offcanvas from "./Offcanvas"; +import MobileMenu from "./MobileMenu"; +import { headerMenuService } from "@/services/header-menu.service"; +import { HeaderMenu as HeaderMenuType } from "@/types/header-menu"; const Header = () => { const [isOffcanvasOpen, setIsOffcanvasOpen] = useState(false); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); const [menuItems, setMenuItems] = useState([]); + const [headerData, setHeaderData] = useState(null); const [isLoading, setIsLoading] = useState(true); const toggleOffcanvas = () => setIsOffcanvasOpen(!isOffcanvasOpen); + const toggleMobileMenu = () => setIsMobileMenuOpen(!isMobileMenuOpen); const toggleSearch = () => setIsSearchOpen(!isSearchOpen); // Helper to adapt 'children' from API to 'submenu' for the existing components - const adaptMenu = useCallback((item: HeaderMenuType): any => ({ - label: item.title, - href: item.url, - submenu: item.children && item.children.length > 0 - ? item.children.map((child: HeaderMenuType) => adaptMenu(child)) - : undefined - }), []); + const adaptMenu = useCallback( + (item: HeaderMenuType): any => ({ + label: item.title, + href: item.url, + submenu: + item.children && item.children.length > 0 + ? item.children.map((child: HeaderMenuType) => adaptMenu(child)) + : undefined, + }), + [], + ); useEffect(() => { - const fetchMenu = async () => { + const fetchData = async () => { try { setIsLoading(true); - const data = await headerMenuService.getHeaderMenu(); - const mappedData = data.map(item => adaptMenu(item)); - setMenuItems(mappedData); + + // Fetch Menu + const menuPromise = headerMenuService.getHeaderMenu(); + + // Fetch Header Data (Logo, Topbar) + const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000"; + const headerPromise = fetch(`${apiUrl}/api/header`).then(res => res.json()); + + const [menuData, headerResult] = await Promise.all([menuPromise, headerPromise]); + + // Process Menu + const mappedMenu = menuData.map((item) => adaptMenu(item)); + setMenuItems(mappedMenu); + + // Process Header Data + if (headerResult.success && headerResult.data) { + setHeaderData(headerResult.data); + } + } catch (error) { - console.error('Error fetching menu in Header:', error); + console.error("Error fetching header data:", error); } finally { setIsLoading(false); } }; - fetchMenu(); + fetchData(); }, [adaptMenu]); return ( <> - - + - - setIsOffcanvasOpen(false)} - menuItems={menuItems} + logo={headerData?.logo} /> + setIsOffcanvasOpen(false)} menuItems={menuItems} /> + + setIsMobileMenuOpen(false)} menuItems={menuItems} /> + {/* Search Popup */} -
-
setIsSearchOpen(false)} - >
+
+
setIsSearchOpen(false)}>
diff --git a/app/components/layout/Header/HeaderBottom.tsx b/app/components/layout/Header/HeaderBottom.tsx index f00867c..ca30c21 100644 --- a/app/components/layout/Header/HeaderBottom.tsx +++ b/app/components/layout/Header/HeaderBottom.tsx @@ -1,24 +1,38 @@ -'use client'; +"use client"; -import React, { useEffect, useState } from 'react'; -import Link from 'next/link'; -import HeaderMenu from './HeaderMenu'; -import { headerMenuService } from '@/services/header-menu.service'; -import { HeaderMenu as HeaderMenuType } from '@/types/header-menu'; +import React, { useEffect, useState } from "react"; +import Link from "next/link"; +import HeaderMenu from "./HeaderMenu"; +import { headerMenuService } from "@/services/header-menu.service"; +import { HeaderMenu as HeaderMenuType } from "@/types/header-menu"; interface HeaderBottomProps { onToggleOffcanvas: () => void; + onToggleMobileMenu: () => void; onToggleSearch: () => void; menuItems: any[]; isLoading: boolean; + logo: { light: string; dark: string; alt: string } | null; } -const HeaderBottom: React.FC = ({ - onToggleOffcanvas, +const HeaderBottom: React.FC = ({ + onToggleOffcanvas, + onToggleMobileMenu, onToggleSearch, menuItems, - isLoading + isLoading, + logo, }) => { + // Helper function to resolve logo URL + const getLogoUrl = (path: string | undefined) => { + if (!path) return "/assets/img/logo/black-logo.svg"; + if (path.startsWith("http")) return path; + const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; + return `${apiUrl}${path}`; + }; + + const logoSrc = getLogoUrl(logo?.light); + return (
@@ -27,7 +41,11 @@ const HeaderBottom: React.FC = ({
- logo-img + {logo?.alt
@@ -36,21 +54,44 @@ const HeaderBottom: React.FC = ({
- + + {/* Desktop Search Icon - hidden on mobile */} + + Apply now -
-
+
+ +
+
+ + {/* Desktop Hamburger - hidden on mobile */} +
+
diff --git a/app/components/layout/Header/HeaderTop.tsx b/app/components/layout/Header/HeaderTop.tsx index 87b85d9..14da639 100644 --- a/app/components/layout/Header/HeaderTop.tsx +++ b/app/components/layout/Header/HeaderTop.tsx @@ -18,39 +18,15 @@ interface HeaderData { name: string; value: string; }>; - }; + } | null; } -const HeaderTop = () => { - const [data, setData] = useState(headerData); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const fetchHeaderData = async () => { - try { - const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000"; - const response = await fetch(`${apiUrl}/api/header`); - - if (response.ok) { - const result = await response.json(); - if (result.success && result.data && result.data.top) { - setData({ - top: result.data.top, - }); - } - } - } catch (error) { - console.warn("Failed to fetch header data from API, using fallback:", error); - // Use fallback data (already set as initial state) - } finally { - setLoading(false); - } - }; - - fetchHeaderData(); - }, []); - - const { phone, email, location, socialLinks, languages } = data.top; +const HeaderTop: React.FC<{ data: HeaderData['top'] }> = ({ data }) => { + // Use passed data or fallback to local JSON if data is null (though parent should handle fetching) + // If data is null (initial load), we can use headerData fallback or render nothing/skeleton + + const displayData = data || headerData.top; + const { phone, email, location, socialLinks, languages } = displayData; return (
diff --git a/app/components/layout/Header/MobileMenu.tsx b/app/components/layout/Header/MobileMenu.tsx new file mode 100644 index 0000000..e9cc5c5 --- /dev/null +++ b/app/components/layout/Header/MobileMenu.tsx @@ -0,0 +1,112 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import Link from "next/link"; + +interface MenuItem { + label: string; + href: string; + submenu?: MenuItem[]; +} + +interface MobileMenuProps { + isOpen: boolean; + onClose: () => void; + menuItems: MenuItem[]; +} + +const MobileMenu: React.FC = ({ isOpen, onClose, menuItems }) => { + const [expandedItems, setExpandedItems] = useState<{ [key: string]: boolean }>({}); + + // Disable body scroll when menu is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + + return () => { + document.body.style.overflow = ""; + }; + }, [isOpen]); + + const toggleSubmenu = (path: string) => { + setExpandedItems((prev) => ({ + ...prev, + [path]: !prev[path], + })); + }; + + const handleLinkClick = () => { + onClose(); + }; + + // Recursive component for rendering menu items with nested submenus + const renderMenuItem = (item: MenuItem, index: number, parentPath: string = "", level: number = 0) => { + const currentPath = parentPath ? `${parentPath}-${index}` : `${index}`; + const hasSubmenu = item.submenu && item.submenu.length > 0; + const isExpanded = expandedItems[currentPath]; + + return ( +
  • +
    + {hasSubmenu ? ( + <> + {item.label} + + + ) : ( + + {item.label} + + )} +
    + + {/* Submenu with accordion animation - supports nested submenus */} + {hasSubmenu && ( +
      + {item.submenu!.map((subItem, subIndex) => + renderMenuItem(subItem, subIndex, currentPath, level + 1), + )} +
    + )} +
  • + ); + }; + + return ( + <> + {/* Mobile Menu Slide Panel */} +
    +
    +
    + + logo + +
    + +
    + + +
    + + {/* Overlay */} +
    + + ); +}; + +export default MobileMenu; diff --git a/app/components/layout/Header/header-responsive.css b/app/components/layout/Header/header-responsive.css new file mode 100644 index 0000000..d6717f2 --- /dev/null +++ b/app/components/layout/Header/header-responsive.css @@ -0,0 +1,271 @@ +/* ======================================== + HEADER RESPONSIVE FIXES + Additional responsive adjustments for header + ======================================== */ + +/* ======================================== + MOBILE & TABLET (max-width: 991px) + ======================================== */ + +@media (max-width: 991px) { + /* Ensure header is always visible and sticky */ + #header-sticky { + position: sticky !important; + top: 0; + z-index: 999; + background-color: var(--white); + } + + /* Add shadow when scrolled */ + #header-sticky.sticky { + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + } + + /* Header container adjustments */ + .header-1 .container-fluid { + padding-left: 20px; + padding-right: 20px; + } + + /* Header main layout */ + .header-1 .header-main { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0; + min-height: 60px; + } + + /* Logo section */ + .header-1 .header-left { + flex: 0 0 auto; + } + + .header-1 .header-left .logo { + margin: 0; + } + + .header-1 .header-left .logo img { + max-height: 40px; + height: 40px; + width: auto; + object-fit: contain; + } + + /* Right section with icons */ + .header-1 .header-right { + flex: 0 0 auto; + margin-top: 0 !important; + } + + .header-1 .header-right .header-call-item { + display: flex; + align-items: center; + gap: 12px; + } + + /* Search icon mobile styling */ + .header-1 .header-right .main-header__search { + width: 40px; + height: 40px; + border: 2px solid var(--theme); + border-radius: 50%; + background: transparent; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: all 0.3s ease; + } + + .header-1 .header-right .main-header__search i { + font-size: 16px; + color: var(--theme); + transition: color 0.3s ease; + } + + .header-1 .header-right .main-header__search:hover { + background-color: var(--theme); + } + + .header-1 .header-right .main-header__search:hover i { + color: var(--white); + } + + /* Hamburger icon mobile styling */ + .header-1 .header__hamburger { + margin: 0; + } + + .header-1 .header__hamburger .sidebar__toggle { + font-size: 24px; + color: var(--header); + padding: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.3s ease; + } + + .header-1 .header__hamburger .sidebar__toggle:hover { + color: var(--theme); + } + + /* Ensure icons don't stick to edges */ + .header-1 .header-right .header-call-item > * { + flex-shrink: 0; + } +} + +/* ======================================== + SMALL MOBILE (max-width: 480px) + ======================================== */ + +@media (max-width: 480px) { + .header-1 .container-fluid { + padding-left: 15px; + padding-right: 15px; + } + + .header-1 .header-main { + padding: 10px 0; + min-height: 55px; + } + + .header-1 .header-left .logo img { + max-height: 35px; + height: 35px; + } + + .header-1 .header-right .header-call-item { + gap: 10px; + } + + .header-1 .header-right .main-header__search { + width: 38px; + height: 38px; + } + + .header-1 .header-right .main-header__search i { + font-size: 15px; + } + + .header-1 .header__hamburger .sidebar__toggle { + font-size: 22px; + padding: 6px; + } +} + +/* ======================================== + DESKTOP (min-width: 992px) + ======================================== */ + +@media (min-width: 992px) { + /* Ensure desktop layout is not affected */ + .header-1 .header-main { + display: flex; + align-items: center; + justify-content: space-between; + } + + /* Desktop menu visible */ + .header-1 .mean__menu-wrapper { + display: block; + } + + /* Desktop button visible */ + /* .header-1 .header-right .theme-btn { + display: inline-flex; + } */ + + /* Ensure proper desktop search icon */ + .header-1 .header-right .main-header__search { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: var(--bg); + display: flex; + align-items: center; + justify-content: center; + } +} + +/* ======================================== + PREVENT LAYOUT SHIFT ON LOAD + ======================================== */ + +.header-1 { + transition: none; +} + +.header-1.sticky { + transition: box-shadow 0.3s ease; +} + +/* ======================================== + Z-INDEX MANAGEMENT + ======================================== */ + +.header-1 { + z-index: 999; +} + +.mobile-slide-menu { + z-index: 99999; +} + +.mobile-menu-overlay { + z-index: 99998; +} + +.offcanvas__info { + z-index: 9999999; +} + +.offcanvas__overlay { + z-index: 900; +} + +/* ======================================== + ACCESSIBILITY IMPROVEMENTS + ======================================== */ + +@media (max-width: 991px) { + /* Focus states for mobile */ + .mobile-search-icon:focus, + .mobile-hamburger .sidebar__toggle:focus { + outline: 2px solid var(--theme); + outline-offset: 2px; + } + + /* Touch target sizes */ + .mobile-search-icon, + .mobile-hamburger .sidebar__toggle { + min-width: 44px; + min-height: 44px; + } +} + +/* ======================================== + HIDE OFFCANVAS ON MOBILE (Prevent Menu Overlap) + ======================================== */ + +@media (max-width: 991px) { + /* Hide the old offcanvas menu on mobile - only use new mobile menu */ + .offcanvas__info, + .offcanvas__overlay { + display: none !important; + visibility: hidden !important; + pointer-events: none !important; + } +} + +@media (min-width: 992px) { + /* Hide mobile menu on desktop - only use offcanvas */ + .mobile-slide-menu, + .mobile-menu-overlay { + display: none !important; + visibility: hidden !important; + pointer-events: none !important; + } +} diff --git a/app/components/layout/Header/mobile-menu.css b/app/components/layout/Header/mobile-menu.css new file mode 100644 index 0000000..5a86f6c --- /dev/null +++ b/app/components/layout/Header/mobile-menu.css @@ -0,0 +1,495 @@ +/* ======================================== + MOBILE HEADER & MENU STYLES + Only applies to mobile & tablet (max-width: 991px) + Desktop layout remains unchanged + ======================================== */ + +/* ======================================== + 1. MOBILE HEADER ADJUSTMENTS + ======================================== */ + +@media (max-width: 991px) { + /* Make header sticky on mobile */ + .header-1 { + position: sticky; + top: 0; + z-index: 999; + background-color: var(--white); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + } + + /* Hide header top section on mobile */ + .header-top-section { + display: none; + } + + /* Mobile header adjustments */ + .header-1 .header-main { + padding: 15px 0; + } + + /* Logo adjustments for mobile */ + .header-1 .header-left .logo img { + max-height: 40px; + width: auto; + object-fit: contain; + } + + /* Hide desktop menu on mobile */ + .header-1 .mean__menu-wrapper { + display: none; + } + + /* Mobile header right section */ + .header-1 .header-right .header-call-item { + display: flex; + align-items: center; + gap: 15px; + } + + /* Hide "Apply now" button on mobile */ + /* .header-1 .header-right .theme-btn { + display: none; + } */ + + /* Mobile search icon styling */ + .mobile-search-icon { + display: flex !important; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 2px solid var(--theme); + border-radius: 50%; + background: transparent; + color: var(--theme); + font-size: 16px; + cursor: pointer; + transition: all 0.3s ease; + } + + .mobile-search-icon:hover { + background-color: var(--theme); + color: var(--white); + } + + /* Hide desktop search icon on mobile */ + .desktop-search-icon { + display: none !important; + } + + /* Mobile hamburger icon styling */ + .mobile-hamburger { + display: block !important; + } + + .mobile-hamburger .sidebar__toggle { + font-size: 24px; + color: var(--header); + padding: 5px; + } + + /* Hide desktop hamburger on mobile */ + .desktop-hamburger { + display: none !important; + } + + /* Ensure proper spacing */ + .header-1 .container-fluid { + padding: 0 20px; + } +} + +/* Desktop: Show desktop icons, hide mobile icons */ +@media (min-width: 992px) { + .mobile-search-icon, + .mobile-hamburger { + display: none !important; + } + + .desktop-search-icon, + .desktop-hamburger { + display: block !important; + } +} + +/* ======================================== + 2. MOBILE SLIDE MENU + ======================================== */ + +/* Mobile menu container */ +.mobile-slide-menu { + position: fixed; + top: 0; + right: 0; + width: 100%; + max-width: 320px; + height: 100vh; + background-color: var(--white); + z-index: 99999; + transform: translateX(100%); + transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); + overflow-y: auto; + box-shadow: -5px 0 20px rgba(0, 0, 0, 0.1); +} + +.mobile-slide-menu.mobile-menu-open { + transform: translateX(0); +} + +/* Mobile menu header */ +.mobile-menu-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); +} + +.mobile-menu-logo img { + max-height: 35px; + width: auto; +} + +/* Close button */ +.mobile-menu-close { + width: 45px; + height: 45px; + border-radius: 50%; + background-color: var(--theme); + border: none; + color: var(--white); + font-size: 18px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s ease; +} + +.mobile-menu-close:hover { + background-color: #d32f2f; + transform: rotate(90deg); +} + +/* ======================================== + 3. MOBILE MENU NAVIGATION + ======================================== */ + +.mobile-menu-nav { + padding: 10px 0; +} + +.mobile-menu-list { + list-style: none; + margin: 0; + padding: 0; +} + +.mobile-menu-item { + border-bottom: 1px solid rgba(0, 0, 0, 0.08); +} + +.mobile-menu-item-wrapper { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + position: relative; +} + +.mobile-menu-link { + flex: 1; + font-size: 16px; + font-weight: 600; + color: var(--header); + text-decoration: none; + text-transform: capitalize; + transition: color 0.3s ease; +} + +.mobile-menu-link:hover { + color: var(--theme); +} + +/* Plus/Minus toggle button */ +.mobile-menu-toggle { + width: 30px; + height: 30px; + border: none; + background: transparent; + color: var(--theme); + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s ease; + flex-shrink: 0; + margin-left: 10px; +} + +.mobile-menu-toggle:hover { + transform: scale(1.1); +} + +.mobile-menu-toggle i { + transition: transform 0.3s ease; +} + +/* ======================================== + 4. MOBILE SUBMENU (ACCORDION) + ======================================== */ + +.mobile-submenu { + list-style: none; + margin: 0; + padding: 0; + max-height: 0; + overflow: hidden; + opacity: 0; + transition: + max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.3s ease, + padding 0.3s ease; + background-color: rgba(0, 0, 0, 0.02); +} + +.mobile-submenu.mobile-submenu-open { + max-height: 1000px; + opacity: 1; + padding: 10px 0; +} + +.mobile-submenu-item { + padding: 0; +} + +.mobile-submenu-link { + display: block; + padding: 12px 20px 12px 40px; + font-size: 14px; + font-weight: 500; + color: var(--header); + text-decoration: none; + transition: all 0.3s ease; + position: relative; +} + +.mobile-submenu-link::before { + content: "→"; + position: absolute; + left: 25px; + opacity: 0; + transition: all 0.3s ease; +} + +.mobile-submenu-link:hover { + color: var(--theme); + padding-left: 45px; +} + +.mobile-submenu-link:hover::before { + opacity: 1; + left: 30px; +} + +/* ======================================== + 5. MOBILE MENU OVERLAY + ======================================== */ + +.mobile-menu-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100vh; + background-color: rgba(0, 0, 0, 0.5); + z-index: 99998; + opacity: 0; + visibility: hidden; + transition: + opacity 0.3s ease, + visibility 0.3s ease; +} + +.mobile-menu-overlay.mobile-overlay-open { + opacity: 1; + visibility: visible; +} + +/* ======================================== + 6. RESPONSIVE ADJUSTMENTS + ======================================== */ + +/* Tablet adjustments */ +@media (max-width: 991px) and (min-width: 768px) { + .mobile-slide-menu { + max-width: 380px; + } + + .mobile-menu-link { + font-size: 17px; + } +} + +/* Small mobile devices */ +@media (max-width: 480px) { + .mobile-slide-menu { + max-width: 280px; + } + + .header-1 .container-fluid { + padding: 0 15px; + } + + .mobile-menu-header { + padding: 15px; + } + + .mobile-menu-close { + width: 40px; + height: 40px; + font-size: 16px; + } +} + +/* ======================================== + 7. PREVENT BODY SCROLL WHEN MENU OPEN + ======================================== */ + +body.mobile-menu-active { + overflow: hidden; +} + +/* ======================================== + 8. SMOOTH ANIMATIONS + ======================================== */ + +@media (prefers-reduced-motion: reduce) { + .mobile-slide-menu, + .mobile-submenu, + .mobile-menu-overlay, + .mobile-menu-toggle i { + transition: none; + } +} + +/* ======================================== + 9. DESKTOP - ENSURE NO MOBILE STYLES + ======================================== */ + +@media (min-width: 992px) { + .mobile-slide-menu, + .mobile-menu-overlay { + display: none !important; + } +} + +/* ======================================== + NESTED SUBMENU SUPPORT (Multi-level) + ======================================== */ + +/* Increase max-height for nested submenus */ +.mobile-submenu.mobile-submenu-open { + max-height: 2000px !important; +} + +/* Level 1 submenu (first level) */ +.mobile-submenu-level-1 { + background-color: rgba(0, 0, 0, 0.02); +} + +.mobile-submenu-level-1 .mobile-menu-item-wrapper { + padding-left: 40px; + padding-right: 20px; +} + +.mobile-submenu-level-1 .mobile-menu-link { + font-size: 14px; + font-weight: 500; +} + +/* Level 2 submenu (nested inside level 1) */ +.mobile-submenu-level-2 { + background-color: rgba(0, 0, 0, 0.04); +} + +.mobile-submenu-level-2 .mobile-menu-item-wrapper { + padding-left: 60px; + padding-right: 20px; +} + +.mobile-submenu-level-2 .mobile-menu-link { + font-size: 13px; + font-weight: 400; +} + +/* Level 3 submenu (nested inside level 2) */ +.mobile-submenu-level-3 { + background-color: rgba(0, 0, 0, 0.06); +} + +.mobile-submenu-level-3 .mobile-menu-item-wrapper { + padding-left: 80px; + padding-right: 20px; +} + +.mobile-submenu-level-3 .mobile-menu-link { + font-size: 12px; + font-weight: 400; +} + +/* Adjust toggle button size for nested items */ +.mobile-submenu .mobile-menu-toggle { + width: 26px; + height: 26px; + font-size: 14px; +} + +/* Border for nested items */ +.mobile-menu-level-1 { + border-bottom: 1px solid rgba(0, 0, 0, 0.05); +} + +.mobile-menu-level-2, +.mobile-menu-level-3 { + border-bottom: none; +} + +/* Adjust padding for nested menu items */ +.mobile-menu-level-1 .mobile-menu-item-wrapper, +.mobile-menu-level-2 .mobile-menu-item-wrapper, +.mobile-menu-level-3 .mobile-menu-item-wrapper { + padding-top: 12px; + padding-bottom: 12px; +} + +/* ======================================== + PREVENT MENU OVERLAP - HIDE OLD OFFCANVAS ON MOBILE + ======================================== */ + +@media (max-width: 991px) { + /* Completely hide offcanvas menu on mobile */ + .offcanvas__info, + .offcanvas__overlay, + .fix-area { + display: none !important; + visibility: hidden !important; + opacity: 0 !important; + pointer-events: none !important; + z-index: -1 !important; + } +} + +@media (min-width: 992px) { + /* Completely hide mobile menu on desktop */ + .mobile-slide-menu, + .mobile-menu-overlay { + display: none !important; + visibility: hidden !important; + opacity: 0 !important; + pointer-events: none !important; + z-index: -1 !important; + } +} diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/app/globals.css b/app/globals.css index 24d681c..22cc726 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,8 +1,15 @@ @import "tailwindcss"; + +/* Mobile Menu Styles */ +@import "./components/layout/Header/mobile-menu.css"; + .collapse { - visibility: visible !important; + visibility: visible !important; } .collapse.show { - visibility: visible; + visibility: visible; } + +/* Header Responsive Styles */ +@import "./components/layout/Header/header-responsive.css"; diff --git a/app/layout.tsx b/app/layout.tsx index 5027846..547fd3f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -9,8 +9,8 @@ import MouseCursor from "./components/MouseCursor"; import Script from "next/script"; export const metadata: Metadata = { - title: "Visaway – Immigration & Visa Consulting HTML Template", - description: "Visaway – Immigration & Visa Consulting HTML Template", + title: "H.A.I Learning", + description: "H.A.I Learning", }; export default function RootLayout({ diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..d73b1db --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,55 @@ +import Link from "next/link"; + +export default function NotFound() { + return ( + <> +
    +
    + img +
    +
    +
    +

    Page Not Found

    + +
    +
    +
    + +
    +
    +
    +
    +
    +

    404 - Page Not Found

    +

    + The page you're looking for could not be found. It may have been moved or deleted. Please check the URL or return to the home page. +

    +
    + + Back to Home + + +
    +
    +
    +
    +
    +
    + + ); +} diff --git a/app/page.tsx b/app/page.tsx index 10c1914..a7caf9f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,6 +12,9 @@ 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'; + export default async function Home() { // Fetch home data (blog aggregation is now handled by the backend) const apiHomeData = await fetchHomeData(); diff --git a/app/services/page.tsx b/app/services/page.tsx index 5147e09..5962b99 100644 --- a/app/services/page.tsx +++ b/app/services/page.tsx @@ -4,6 +4,9 @@ import Breadcrumb from "../components/Breadcrumb"; import ImageWithFallback from "../components/ImageWithFallback"; import "./services.css"; +// Force dynamic rendering - không cache +export const dynamic = 'force-dynamic'; + export default async function ServicesPage() { const data = await fetchServicePageData(); const allCountries = await fetchCountries(); diff --git a/app/visa/[slug]/VisaDetail.tsx b/app/visa/[slug]/VisaDetail.tsx index bd85ea2..fe23f77 100644 --- a/app/visa/[slug]/VisaDetail.tsx +++ b/app/visa/[slug]/VisaDetail.tsx @@ -7,24 +7,33 @@ interface VisaDetailProps { } export default function VisaDetail({ country }: VisaDetailProps) { + // 1. Kiểm tra an toàn (Null Check) + // Vì detailedView có kiểu là 'DetailedView | undefined', ta phải chắc chắn nó tồn tại + if (!country || !country.detailedView) { + return ( +
    +

    Đang tải dữ liệu...

    +
    + ); + } + const { name: rootName, detailedView } = country; + + // 2. Bóc tách dữ liệu sau khi đã kiểm tra detailedView tồn tại const { activeCountry: countryData, relatedCountries, contactInfo, - } = country.detailedView; + } = detailedView; return ( <> - {/* Breadcrumb-Wrapper Section Start */} - {/* Country-details Section Start */}
    - {/* Main Content */}
    @@ -38,33 +47,19 @@ export default function VisaDetail({ country }: VisaDetailProps) { {/* Visa Types */}
    - {/* Render mảng đầu tiên (index 0) */} - {countryData.visaTypes?.[0] && ( -
    - {countryData.visaTypes[0].items.map( - (item: any, itemIdx: number) => ( -
    -
    {item.title}
    -

    {item.description}

    -
    - ), - )} + {countryData.visaTypes?.map((type: any, idx: number) => ( +
    + {type.items.map((item: any, itemIdx: number) => ( +
    +
    {item.title}
    +

    {item.description}

    +
    + ))}
    - )} - - {/* Render mảng thứ hai (index 1) */} - {countryData.visaTypes?.[1] && ( -
    - {countryData.visaTypes[1].items.map( - (item: any, itemIdx: number) => ( -
    -
    {item.title}
    -

    {item.description}

    -
    - ), - )} -
    - )} + ))}
    {/* Visa Process */} @@ -102,49 +97,6 @@ export default function VisaDetail({ country }: VisaDetailProps) { )}
    )} - - {/* Visa Categories */} - {countryData.visaCategories && ( - <> -

    - {countryData.visaCategories.title} -

    - - {countryData.visaCategories.steps.map( - (subGroup: string[], groupIdx: number) => ( -
      - {subGroup.map((category: string, idx: number) => ( -
    • - - {category} -
    • - ))} -
    - ), - )} - - )} - - {/* Service Options */} - {countryData.visaService && ( - <> -

    - {countryData.visaService.title} -

    -
      - {countryData.visaService.steps.map( - (process: any, idx: number) => ( -
    • - - {process.number}. {process.title} - - - {process.description} -
    • - ), - )} -
    - - )}
    @@ -152,7 +104,7 @@ export default function VisaDetail({ country }: VisaDetailProps) { {/* Sidebar */}
    - {relatedCountries.map((relCountry: any, idx: number) => ( + {relatedCountries?.map((relCountry: any, idx: number) => (
    @@ -178,8 +130,6 @@ export default function VisaDetail({ country }: VisaDetailProps) {

    {contactInfo.sectionTitle}

    {contactInfo.helpText}

    - - {/* Phone */}
    @@ -187,13 +137,15 @@ export default function VisaDetail({ country }: VisaDetailProps) {
    {contactInfo.phone.label}:
    - + {contactInfo.phone.value}
    - {/* Email */}
    diff --git a/app/visa/page.tsx b/app/visa/page.tsx index 02c4c6d..f5401a9 100644 --- a/app/visa/page.tsx +++ b/app/visa/page.tsx @@ -3,6 +3,9 @@ import Breadcrumb from "../components/Breadcrumb"; import { fetchVisaData, type VisaCountry } from "@/api/visa"; +// Force dynamic rendering - không cache +export const dynamic = 'force-dynamic'; + export default async function VisaListPage() { // Fetch all visa countries từ API let visaCountries: any[] = []; diff --git a/pages/[level]/[slug]/index.tsx b/pages/[level]/[slug]/index.tsx deleted file mode 100644 index f0cc077..0000000 --- a/pages/[level]/[slug]/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useRouter } from "next/router"; - -export default function LevelSlugPage() { - const router = useRouter(); - const { level, slug } = router.query; - - return ( -
    -

    Lesson page

    -

    Level: {level}

    -

    Slug: {slug}

    -
    - ); -} diff --git a/pages/[level]/index.tsx b/pages/[level]/index.tsx deleted file mode 100644 index e9d07b9..0000000 --- a/pages/[level]/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useRouter } from "next/router"; - -export default function LevelPage() { - const router = useRouter(); - const { level } = router.query; - - return ( -
    -

    Level page

    -

    Level: {level}

    -
    - ); -} diff --git a/pages/_app.tsx b/pages/_app.tsx deleted file mode 100644 index 86d8929..0000000 --- a/pages/_app.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { AppProps } from "next/app"; -import "../app/globals.css"; - -export default function MyApp({ Component, pageProps }: AppProps) { - return ; -} diff --git a/pages/_document.tsx b/pages/_document.tsx deleted file mode 100644 index b2fff8b..0000000 --- a/pages/_document.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Html, Head, Main, NextScript } from "next/document"; - -export default function Document() { - return ( - - - -
    - - - - ); -} diff --git a/public/assets/css/main.css b/public/assets/css/main.css index 732a531..cd1df00 100644 --- a/public/assets/css/main.css +++ b/public/assets/css/main.css @@ -45,8 +45,8 @@ Version: 1.0.0 --body: #fff; --black: #000; --white: #fff; - --theme: #e13833; - --theme-2: #0048b4; + --theme: #BF3432; + --theme-2: #0a2347; --header: #151a26; --text: #535761; --text-2: #0b4e3d; @@ -7264,43 +7264,67 @@ html.lenis body { .animation-preloader .txt-loading .letters-loading:nth-child(2):before { - animation-delay: 0.2s; + animation-delay: 0.1s; } .preloader .animation-preloader .txt-loading .letters-loading:nth-child(3):before { - animation-delay: 0.4s; + animation-delay: 0.2s; } .preloader .animation-preloader .txt-loading .letters-loading:nth-child(4):before { - animation-delay: 0.6s; + animation-delay: 0.3s; } .preloader .animation-preloader .txt-loading .letters-loading:nth-child(5):before { - animation-delay: 0.8s; + animation-delay: 0.4s; } .preloader .animation-preloader .txt-loading .letters-loading:nth-child(6):before { - animation-delay: 1s; + animation-delay: 0.5s; } .preloader .animation-preloader .txt-loading .letters-loading:nth-child(7):before { - animation-delay: 1.2s; + animation-delay: 0.6s; } .preloader .animation-preloader .txt-loading .letters-loading:nth-child(8):before { - animation-delay: 1.4s; + animation-delay: 0.7s; +} +.preloader + .animation-preloader + .txt-loading + .letters-loading:nth-child(8):before { + animation-delay: 0.8s; +} +.preloader + .animation-preloader + .txt-loading + .letters-loading:nth-child(9):before { + animation-delay: 0.9s; +} +.preloader + .animation-preloader + .txt-loading + .letters-loading:nth-child(10):before { + animation-delay: 1s; +} +.preloader + .animation-preloader + .txt-loading + .letters-loading:nth-child(11):before { + animation-delay: 1.1s; } .preloader .animation-preloader .txt-loading .letters-loading::before { animation: letters-loading 4s infinite; diff --git a/public/assets/img/favicon.png b/public/assets/img/favicon.png index 207b12b..820c256 100644 Binary files a/public/assets/img/favicon.png and b/public/assets/img/favicon.png differ diff --git a/public/assets/img/logo/Logo_HaiLearning.jpg b/public/assets/img/logo/Logo_HaiLearning.jpg new file mode 100644 index 0000000..7014168 Binary files /dev/null and b/public/assets/img/logo/Logo_HaiLearning.jpg differ