forked from UKSOURCE/hailearning.edu.vn
fix: resolve merge conflict in app layout
This commit is contained in:
562
api/servicesApi.ts
Normal file
562
api/servicesApi.ts
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
/**
|
||||||
|
* Service Page API Functions
|
||||||
|
* Fetch data for Service page from external API
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* =======================
|
||||||
|
Types
|
||||||
|
======================= */
|
||||||
|
|
||||||
|
export interface ServiceItem {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
image: string;
|
||||||
|
layout: "left" | "right";
|
||||||
|
details: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
mainImage: string;
|
||||||
|
overviewTitle: string;
|
||||||
|
overviewDescription: string;
|
||||||
|
additionalDescription: string;
|
||||||
|
features: {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}[];
|
||||||
|
faq: {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
isExpanded: boolean;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceSection {
|
||||||
|
title: {
|
||||||
|
subTitle: string;
|
||||||
|
mainTitle: string;
|
||||||
|
};
|
||||||
|
items: ServiceItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CountryItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
image: string;
|
||||||
|
icon: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DestinationSection {
|
||||||
|
backgroundImage: string;
|
||||||
|
title: {
|
||||||
|
subTitle: string;
|
||||||
|
mainTitle: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisaItem {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
buttonText: string;
|
||||||
|
buttonLink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisaSection {
|
||||||
|
items: VisaItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientReviewItem {
|
||||||
|
id: string;
|
||||||
|
rating: number;
|
||||||
|
content: string;
|
||||||
|
author: {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewSection {
|
||||||
|
title: {
|
||||||
|
subTitle: string;
|
||||||
|
mainTitle: string;
|
||||||
|
};
|
||||||
|
thumb: string;
|
||||||
|
items: ClientReviewItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServicePageData {
|
||||||
|
pageTitle: string;
|
||||||
|
services: ServiceSection;
|
||||||
|
destinations: DestinationSection;
|
||||||
|
visas: VisaSection;
|
||||||
|
reviews: ReviewSection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =======================
|
||||||
|
Utils
|
||||||
|
======================= */
|
||||||
|
|
||||||
|
const getApiUrl = (): string => {
|
||||||
|
return process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||||
|
};
|
||||||
|
|
||||||
|
/* =======================
|
||||||
|
Fetch API
|
||||||
|
======================= */
|
||||||
|
|
||||||
|
export const fetchServicePageData = async (): Promise<ServicePageData> => {
|
||||||
|
try {
|
||||||
|
const apiUrl = getApiUrl();
|
||||||
|
const endpoint = `${apiUrl}/api/service`;
|
||||||
|
|
||||||
|
console.log("Fetching services from endpoint:", endpoint);
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Services API response status:", response.status);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Services API failed, using fallback data");
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as ServicePageData;
|
||||||
|
console.log("Services data received successfully");
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching service page data:", error);
|
||||||
|
console.log("Using fallback service data");
|
||||||
|
return getFallbackServicePageData();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchServiceBySlug = async (slug: string): Promise<any> => {
|
||||||
|
console.log("=== fetchServiceBySlug called ===");
|
||||||
|
console.log("Input slug:", slug);
|
||||||
|
console.log("Slug type:", typeof slug);
|
||||||
|
console.log("Slug length:", slug?.length);
|
||||||
|
|
||||||
|
if (!slug || slug === "undefined") {
|
||||||
|
console.error("Invalid slug provided:", slug);
|
||||||
|
throw new Error("Invalid slug parameter");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiUrl = getApiUrl();
|
||||||
|
const endpoint = `${apiUrl}/api/service/${slug}`;
|
||||||
|
|
||||||
|
console.log("Fetching service from endpoint:", endpoint);
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Response status:", response.status);
|
||||||
|
console.log("Response ok:", response.ok);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error("Error response body:", errorText);
|
||||||
|
|
||||||
|
// If it's a 404, try to get the service from fallback data
|
||||||
|
if (response.status === 404) {
|
||||||
|
console.log("Service not found in backend, checking fallback data");
|
||||||
|
const fallbackData = getFallbackServicePageData();
|
||||||
|
const service = fallbackData.services.items.find(
|
||||||
|
(item) => item.slug === slug,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (service) {
|
||||||
|
console.log(
|
||||||
|
"Found service in fallback data, creating response structure",
|
||||||
|
);
|
||||||
|
// Create a response structure that matches what the backend should return
|
||||||
|
return {
|
||||||
|
pageTitle: fallbackData.pageTitle,
|
||||||
|
breadcrumb: {
|
||||||
|
title: "Service Details",
|
||||||
|
items: [
|
||||||
|
{ label: "Home", href: "/" },
|
||||||
|
{ label: "Services", href: "/services" },
|
||||||
|
{ label: service.name, href: `/services/details/${slug}` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
serviceDetails: {
|
||||||
|
content: service.details,
|
||||||
|
keyFeatures: {
|
||||||
|
title: "Key Features",
|
||||||
|
sideImage:
|
||||||
|
"/assets/img/inner-page/service-details/details-2.jpg",
|
||||||
|
items: service.details.features || [],
|
||||||
|
},
|
||||||
|
faq: {
|
||||||
|
title: "Frequently Asked Questions",
|
||||||
|
sideImage:
|
||||||
|
"/assets/img/inner-page/service-details/details-3.jpg",
|
||||||
|
items: service.details.faq || [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`HTTP error! status: ${response.status}, body: ${errorText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log("Service data received:", data);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching service by slug:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* =======================
|
||||||
|
Fallback Data
|
||||||
|
======================= */
|
||||||
|
|
||||||
|
export const getFallbackServicePageData = (): ServicePageData => ({
|
||||||
|
pageTitle: "Visaway – Immigration & Visa Consulting HTML Template",
|
||||||
|
|
||||||
|
services: {
|
||||||
|
title: {
|
||||||
|
subTitle: "What We Offer",
|
||||||
|
mainTitle: "Our Immigration Services",
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
slug: "immigration-appeal",
|
||||||
|
name: "Immigration Appeal & Legal Support",
|
||||||
|
description:
|
||||||
|
"Our experts provide professional guidance for immigration appeals and legal matters, helping clients overcome visa rejections with personalized strategies and strong case representation.",
|
||||||
|
image: "/assets/img/home-3/service/01.jpg",
|
||||||
|
layout: "left",
|
||||||
|
details: {
|
||||||
|
title: "Immigration Appeal & Legal Support",
|
||||||
|
description:
|
||||||
|
"Our experts provide professional guidance for immigration appeals and legal matters, helping clients overcome visa rejections with personalized strategies and strong case representation.",
|
||||||
|
mainImage: "/assets/img/inner-page/service-details/details-1.jpg",
|
||||||
|
overviewTitle: "Service Overview",
|
||||||
|
overviewDescription:
|
||||||
|
"Our Immigration Appeal & Legal Support service is designed to help clients navigate complex immigration challenges. We provide expert legal guidance, case analysis, and strategic representation to maximize your chances of success.",
|
||||||
|
additionalDescription:
|
||||||
|
"With years of experience in immigration law, our team is committed to turning your immigration challenges into success stories.",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
icon: "fa-solid fa-chevrons-right",
|
||||||
|
title: "Legal Case Analysis",
|
||||||
|
description:
|
||||||
|
"Thorough review and analysis of your immigration case.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "fa-solid fa-chevrons-right",
|
||||||
|
title: "Appeal Strategy Development",
|
||||||
|
description: "Custom strategies to overcome visa rejections.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "fa-solid fa-chevrons-right",
|
||||||
|
title: "Document Preparation",
|
||||||
|
description:
|
||||||
|
"Professional preparation of all required legal documents.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "fa-solid fa-chevrons-right",
|
||||||
|
title: "Court Representation",
|
||||||
|
description: "Expert legal representation in immigration courts.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
faq: [
|
||||||
|
{
|
||||||
|
id: "faq-appeal-1",
|
||||||
|
question: "01. What are the chances of a successful appeal?",
|
||||||
|
answer:
|
||||||
|
"Success rates vary by case type, but our experienced legal team significantly improves your chances through thorough case analysis and strategic representation.",
|
||||||
|
isExpanded: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "faq-appeal-2",
|
||||||
|
question: "02. How long does the appeal process take?",
|
||||||
|
answer:
|
||||||
|
"Appeal timelines vary by jurisdiction and case complexity, typically ranging from 6-18 months. We keep you informed throughout the process.",
|
||||||
|
isExpanded: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "scholarship-guidance",
|
||||||
|
name: "Scholarship & Study Grant Guidance",
|
||||||
|
description:
|
||||||
|
"We help students identify suitable scholarships and study grants, assist with applications, and provide expert guidance to maximize chances of securing financial support abroad.",
|
||||||
|
image: "/assets/img/home-3/service/02.jpg",
|
||||||
|
layout: "right",
|
||||||
|
details: {
|
||||||
|
title: "Scholarship & Study Grant Guidance",
|
||||||
|
description:
|
||||||
|
"We help students unlock opportunities to study abroad with the right financial support. Our expert advisors guide you in finding scholarships, grants, and funding options that match your academic background, chosen destination, and career goals.",
|
||||||
|
mainImage: "/assets/img/inner-page/service-details/details-1.jpg",
|
||||||
|
overviewTitle: "Service Overview",
|
||||||
|
overviewDescription:
|
||||||
|
"Our Education Visa Consultancy is dedicated to guiding students in achieving their study abroad dreams. We provide complete support including university selection, application assistance, scholarship guidance, visa documentation, and interview preparation.",
|
||||||
|
additionalDescription:
|
||||||
|
"From start to finish, we are committed to turning your education journey into a successful international experience.",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
icon: "fa-solid fa-chevrons-right",
|
||||||
|
title: "Scholarship Research",
|
||||||
|
description:
|
||||||
|
"Comprehensive research to find suitable funding opportunities.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "fa-solid fa-chevrons-right",
|
||||||
|
title: "Application Assistance",
|
||||||
|
description:
|
||||||
|
"Expert help with scholarship and grant applications.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "fa-solid fa-chevrons-right",
|
||||||
|
title: "Essay Writing Support",
|
||||||
|
description:
|
||||||
|
"Professional guidance for compelling scholarship essays.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "fa-solid fa-chevrons-right",
|
||||||
|
title: "Interview Preparation",
|
||||||
|
description:
|
||||||
|
"Coaching for scholarship interviews and presentations.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
faq: [
|
||||||
|
{
|
||||||
|
id: "faq-scholarship-1",
|
||||||
|
question: "01. Do you help find scholarships for all countries?",
|
||||||
|
answer:
|
||||||
|
"Yes, we have extensive databases and partnerships worldwide to help you find scholarships in your preferred destination country.",
|
||||||
|
isExpanded: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "faq-scholarship-2",
|
||||||
|
question: "02. What are the eligibility requirements?",
|
||||||
|
answer:
|
||||||
|
"Eligibility varies by scholarship type and provider. We assess your profile and match you with suitable opportunities based on your academic background and goals.",
|
||||||
|
isExpanded: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "permanent-residency",
|
||||||
|
name: "Permanent Residency (PR) Services",
|
||||||
|
description:
|
||||||
|
"Our PR services guide clients through every step of the residency process, including documentation, eligibility assessment, and application support, ensuring a smooth and successful approval.",
|
||||||
|
image: "/assets/img/home-3/service/03.jpg",
|
||||||
|
layout: "left",
|
||||||
|
details: {
|
||||||
|
title: "Permanent Residency (PR) Services",
|
||||||
|
description:
|
||||||
|
"Our PR services guide clients through every step of the residency process, including documentation, eligibility assessment, and application support, ensuring a smooth and successful approval.",
|
||||||
|
mainImage: "/assets/img/inner-page/service-details/details-1.jpg",
|
||||||
|
overviewTitle: "Service Overview",
|
||||||
|
overviewDescription:
|
||||||
|
"Our Permanent Residency services provide comprehensive support for individuals seeking to establish permanent residence in their chosen country. We handle all aspects of the PR application process with expertise and care.",
|
||||||
|
additionalDescription:
|
||||||
|
"Our experienced team ensures that your PR application is handled professionally and efficiently, maximizing your chances of approval.",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
icon: "fa-solid fa-chevrons-right",
|
||||||
|
title: "Eligibility Assessment",
|
||||||
|
description:
|
||||||
|
"Comprehensive evaluation of your PR eligibility and options.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "fa-solid fa-chevrons-right",
|
||||||
|
title: "Points Calculation",
|
||||||
|
description:
|
||||||
|
"Accurate calculation and optimization of your points score.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "fa-solid fa-chevrons-right",
|
||||||
|
title: "Document Verification",
|
||||||
|
description:
|
||||||
|
"Thorough verification and preparation of all required documents.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "fa-solid fa-chevrons-right",
|
||||||
|
title: "Application Tracking",
|
||||||
|
description:
|
||||||
|
"Regular updates and tracking of your PR application status.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
faq: [
|
||||||
|
{
|
||||||
|
id: "faq-pr-1",
|
||||||
|
question: "01. How long does the PR process take?",
|
||||||
|
answer:
|
||||||
|
"Processing times vary by country and program, typically ranging from 12-24 months. We provide realistic timelines based on current processing standards.",
|
||||||
|
isExpanded: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "faq-pr-2",
|
||||||
|
question: "02. What documents are required for PR application?",
|
||||||
|
answer:
|
||||||
|
"Document requirements vary by country but typically include educational credentials, work experience, language test results, and medical examinations. We provide a complete checklist.",
|
||||||
|
isExpanded: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "citizenship-naturalization",
|
||||||
|
name: "Citizenship & Naturalization Guidance",
|
||||||
|
description:
|
||||||
|
"We provide expert guidance for citizenship and naturalization processes, assisting clients with documentation, eligibility, and legal procedures to achieve a smooth and successful application.",
|
||||||
|
image: "/assets/img/home-3/service/04.jpg",
|
||||||
|
layout: "right",
|
||||||
|
details: {
|
||||||
|
title: "Citizenship & Naturalization Guidance",
|
||||||
|
description:
|
||||||
|
"We provide expert guidance for citizenship and naturalization processes, assisting clients with documentation, eligibility, and legal procedures to achieve a smooth and successful application.",
|
||||||
|
mainImage: "/assets/img/inner-page/service-details/details-1.jpg",
|
||||||
|
overviewTitle: "Service Overview",
|
||||||
|
overviewDescription:
|
||||||
|
"Our Citizenship & Naturalization service helps individuals navigate the complex process of becoming a citizen. We provide step-by-step guidance, documentation support, and legal expertise throughout the entire process.",
|
||||||
|
additionalDescription:
|
||||||
|
"With our comprehensive approach, we make the path to citizenship clear, manageable, and successful for every client.",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
icon: "fa-solid fa-chevrons-right",
|
||||||
|
title: "Citizenship Test Preparation",
|
||||||
|
description:
|
||||||
|
"Comprehensive preparation for citizenship knowledge tests.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "fa-solid fa-chevrons-right",
|
||||||
|
title: "Language Requirements",
|
||||||
|
description:
|
||||||
|
"Guidance on meeting language proficiency requirements.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "fa-solid fa-chevrons-right",
|
||||||
|
title: "Residency Verification",
|
||||||
|
description:
|
||||||
|
"Assistance with proving residency and physical presence requirements.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "fa-solid fa-chevrons-right",
|
||||||
|
title: "Ceremony Preparation",
|
||||||
|
description:
|
||||||
|
"Support and guidance for the citizenship ceremony process.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
faq: [
|
||||||
|
{
|
||||||
|
id: "faq-citizenship-1",
|
||||||
|
question: "01. What are the basic requirements for citizenship?",
|
||||||
|
answer:
|
||||||
|
"Requirements typically include permanent residency, physical presence, language proficiency, and knowledge of the country's history and government. Specific requirements vary by country.",
|
||||||
|
isExpanded: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "faq-citizenship-2",
|
||||||
|
question: "02. How do I prepare for the citizenship test?",
|
||||||
|
answer:
|
||||||
|
"We provide comprehensive study materials, practice tests, and coaching sessions to help you prepare for both the knowledge test and language requirements.",
|
||||||
|
isExpanded: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
destinations: {
|
||||||
|
backgroundImage: "/assets/img/home-3/choose-us/bg.png",
|
||||||
|
title: {
|
||||||
|
subTitle: "Countries we offer",
|
||||||
|
mainTitle: "Choose Your Immigration Destination",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
visas: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "family-visa",
|
||||||
|
number: "01",
|
||||||
|
name: "Family Visa",
|
||||||
|
description:
|
||||||
|
"Our Family Visa services help reunite loved ones by providing expert guidance.",
|
||||||
|
buttonText: "service _ 02",
|
||||||
|
buttonLink: "/services/details/family-visa",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "student-visa",
|
||||||
|
number: "02",
|
||||||
|
name: "Student Visa",
|
||||||
|
description:
|
||||||
|
"We provide expert guidance for student visa applications.",
|
||||||
|
buttonText: "service _ 02",
|
||||||
|
buttonLink: "/services/details/student-visa",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "work-visa",
|
||||||
|
number: "03",
|
||||||
|
name: "Work Visa",
|
||||||
|
description:
|
||||||
|
"Collaboratively disintermediate one to one functionalities and long term.",
|
||||||
|
buttonText: "service _ 02",
|
||||||
|
buttonLink: "/services/details/work-visa",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
reviews: {
|
||||||
|
title: {
|
||||||
|
subTitle: "What Our Clients Say",
|
||||||
|
mainTitle: "Immigration Success Stories",
|
||||||
|
},
|
||||||
|
thumb: "/assets/img/home-3/test-thumb.jpg",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "client-review-1",
|
||||||
|
rating: 5,
|
||||||
|
content:
|
||||||
|
"The team provided exceptional guidance throughout my immigration process. Their expertise, personalized support, and attention to detail ensured a smooth, stress-free experience and successful visa approval.",
|
||||||
|
author: {
|
||||||
|
name: "Mohammed Ali,",
|
||||||
|
type: "Family Visa",
|
||||||
|
},
|
||||||
|
icon: "fa-solid fa-quote-right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "client-review-2",
|
||||||
|
rating: 5,
|
||||||
|
content:
|
||||||
|
"The team provided exceptional guidance throughout my immigration process. Their expertise, personalized support, and attention to detail ensured a smooth, stress-free experience and successful visa approval.",
|
||||||
|
author: {
|
||||||
|
name: "Mohammed Ali,",
|
||||||
|
type: "Family Visa",
|
||||||
|
},
|
||||||
|
icon: "fa-solid fa-quote-right",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,501 +1,437 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect, FormEvent } from "react";
|
||||||
import appointmentData from "./appointment.json";
|
import { AppointmentData } from "./types";
|
||||||
|
import Breadcrumb from "../components/Breadcrumb";
|
||||||
|
|
||||||
|
|
||||||
export default function AppointmentPage() {
|
export default function AppointmentPage() {
|
||||||
const [selectedType, setSelectedType] = useState("consultation");
|
const [appointmentData, setAppointmentData] = useState<AppointmentData | null>(null);
|
||||||
const [selectedConsultant, setSelectedConsultant] = useState("");
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [selectedDate, setSelectedDate] = useState("");
|
|
||||||
const [selectedTime, setSelectedTime] = useState("");
|
|
||||||
const [selectedOffice, setSelectedOffice] = useState("online");
|
|
||||||
const [openFaq, setOpenFaq] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
fullName: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
visaType: "",
|
address: "",
|
||||||
country: "",
|
appointmentDate: "",
|
||||||
travelDate: "",
|
message: "",
|
||||||
previousVisa: "",
|
visaTypes: [] as string[],
|
||||||
notes: "",
|
|
||||||
});
|
});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [submitStatus, setSubmitStatus] = useState<{
|
||||||
|
type: "success" | "error" | null;
|
||||||
|
message: string;
|
||||||
|
}>({ type: null, message: "" });
|
||||||
|
|
||||||
const handleInputChange = (
|
// Calendar state
|
||||||
e: React.ChangeEvent<
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
|
||||||
>,
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||||
|
|
||||||
|
// Calendar helper functions
|
||||||
|
const months = [
|
||||||
|
"January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December"
|
||||||
|
];
|
||||||
|
|
||||||
|
const getDaysInMonth = (year: number, month: number) => {
|
||||||
|
return new Date(year, month + 1, 0).getDate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFirstDayOfMonth = (year: number, month: number) => {
|
||||||
|
const day = new Date(year, month, 1).getDay();
|
||||||
|
// Convert Sunday (0) to 7 for Monday-first calendar
|
||||||
|
return day === 0 ? 7 : day;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateCalendarDays = () => {
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth();
|
||||||
|
const daysInMonth = getDaysInMonth(year, month);
|
||||||
|
const firstDay = getFirstDayOfMonth(year, month);
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
const days: React.ReactNode[] = [];
|
||||||
|
|
||||||
|
// Empty cells for days before the first day of the month
|
||||||
|
for (let i = 1; i < firstDay; i++) {
|
||||||
|
days.push(<div key={`empty-${i}`} className="date empty"></div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Days of the month
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const isToday =
|
||||||
|
day === today.getDate() &&
|
||||||
|
month === today.getMonth() &&
|
||||||
|
year === today.getFullYear();
|
||||||
|
|
||||||
|
days.push(
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className={`date ${isToday ? "today active" : ""}`}
|
||||||
|
onClick={() => handleDateClick(year, month, day)}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateClick = (year: number, month: number, day: number) => {
|
||||||
|
const selectedDate = new Date(year, month, day);
|
||||||
|
const formattedDate = selectedDate.toISOString().split("T")[0];
|
||||||
|
setFormData((prev) => ({ ...prev, appointmentDate: formattedDate }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevMonth = () => {
|
||||||
|
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextMonth = () => {
|
||||||
|
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch appointment data from CMS
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAppointmentData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/api/appointment`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setAppointmentData(result.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching appointment data:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAppointmentData();
|
||||||
|
}, [apiUrl]);
|
||||||
|
|
||||||
|
const handleChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||||
) => {
|
) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckboxChange = (visaType: string, checked: boolean) => {
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[name]: value,
|
visaTypes: checked
|
||||||
|
? [...prev.visaTypes, visaType]
|
||||||
|
: prev.visaTypes.filter((v) => v !== visaType),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
console.log("Appointment booked:", {
|
setIsSubmitting(true);
|
||||||
type: selectedType,
|
setSubmitStatus({ type: null, message: "" });
|
||||||
consultant: selectedConsultant,
|
|
||||||
date: selectedDate,
|
try {
|
||||||
time: selectedTime,
|
const response = await fetch(`${apiUrl}/api/appointment/submit`, {
|
||||||
office: selectedOffice,
|
method: "POST",
|
||||||
...formData,
|
headers: {
|
||||||
});
|
"Content-Type": "application/json",
|
||||||
alert(
|
},
|
||||||
"Đặt lịch thành công! Chúng tôi sẽ liên hệ xác nhận trong thời gian sớm nhất.",
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
setSubmitStatus({
|
||||||
|
type: "success",
|
||||||
|
message: data.message || "Thank you! Your appointment has been submitted.",
|
||||||
|
});
|
||||||
|
// Reset form
|
||||||
|
setFormData({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
address: "",
|
||||||
|
appointmentDate: "",
|
||||||
|
message: "",
|
||||||
|
visaTypes: [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSubmitStatus({
|
||||||
|
type: "error",
|
||||||
|
message: data.error || "Something went wrong. Please try again.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Submit error:", error);
|
||||||
|
setSubmitStatus({
|
||||||
|
type: "error",
|
||||||
|
message: "Network error. Please check your connection and try again.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get data with fallbacks
|
||||||
|
const hero = appointmentData?.hero || {
|
||||||
|
title: "Make Appointment",
|
||||||
|
backgroundImage: "/assets/img/inner-page/breadcrumb.jpg",
|
||||||
|
subtitle: "About Our Consultancy",
|
||||||
|
heading: "Want to meet us for your need?",
|
||||||
|
description: "24/7 customer support is always ready to answer all your questions",
|
||||||
|
};
|
||||||
|
|
||||||
|
const visaOptions = appointmentData?.visaOptions || [
|
||||||
|
"Canada Immigration",
|
||||||
|
"Tourist Visa",
|
||||||
|
"Medical Visa",
|
||||||
|
"Coaching",
|
||||||
|
"Student Visa",
|
||||||
|
"Spouse Visa",
|
||||||
|
"Job Opportunity",
|
||||||
|
"Exam",
|
||||||
|
];
|
||||||
|
|
||||||
|
const formSettings = appointmentData?.form || {
|
||||||
|
heading: "Request Appointment",
|
||||||
|
submitButton: {
|
||||||
|
text: "Request Appointment",
|
||||||
|
icon: "fa-solid fa-arrow-right",
|
||||||
|
buttonClass: "theme-btn",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get background image URL
|
||||||
|
const backgroundImage = hero.backgroundImage?.startsWith("http")
|
||||||
|
? hero.backgroundImage
|
||||||
|
: hero.backgroundImage?.startsWith("/")
|
||||||
|
? hero.backgroundImage
|
||||||
|
: `/assets/img/inner-page/breadcrumb.jpg`;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="loading-wrapper" style={{ minHeight: "50vh", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<div className="spinner-border text-primary" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
const toggleFaq = (index: number) => {
|
|
||||||
setOpenFaq(openFaq === index ? null : index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedAppointmentType = appointmentData.appointmentTypes.find(
|
|
||||||
(type) => type.id === selectedType,
|
|
||||||
);
|
|
||||||
const selectedConsultantData = appointmentData.consultants.find(
|
|
||||||
(c) => c.id === selectedConsultant,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<>
|
||||||
<div className="max-w-6xl mx-auto">
|
{/* Breadcrumb-Wrapper Section Start */}
|
||||||
{/* Header */}
|
<Breadcrumb title={hero.title} current={hero.title} />
|
||||||
<div className="text-center mb-12">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
|
||||||
{appointmentData.title}
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-600 mb-8">
|
|
||||||
{appointmentData.subtitle}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Benefits */}
|
{/* Appointment Section Start */}
|
||||||
<div className="grid md:grid-cols-4 gap-6 max-w-4xl mx-auto">
|
<section className="appointment-section section-padding fix">
|
||||||
{appointmentData.hero.benefits.map((benefit, index) => (
|
<div className="container">
|
||||||
<div key={index} className="bg-blue-50 p-4 rounded-lg">
|
<div className="appointment-wrapper">
|
||||||
<p className="text-blue-800 font-medium">{benefit}</p>
|
<div className="row g-4">
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="appointment-content">
|
||||||
|
<div className="section-title mb-0">
|
||||||
|
{hero.subtitle && (
|
||||||
|
<span className="sub-title-2">{hero.subtitle}</span>
|
||||||
|
)}
|
||||||
|
{hero.heading && (
|
||||||
|
<h2 className="split-text-right split-text-in-right">
|
||||||
|
{hero.heading}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h5>Have any questions?</h5>
|
||||||
|
{hero.description && <p>{hero.description}</p>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="col-lg-6">
|
||||||
</div>
|
<div className="calendar">
|
||||||
</div>
|
<div className="calendar-header">
|
||||||
|
<h2 id="month-year">{months[currentDate.getMonth()]} {currentDate.getFullYear()}</h2>
|
||||||
<div className="grid lg:grid-cols-3 gap-8">
|
<div>
|
||||||
{/* Booking Form */}
|
<button type="button" onClick={handlePrevMonth}><</button>
|
||||||
<div className="lg:col-span-2">
|
<button type="button" onClick={handleNextMonth}>></button>
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
|
||||||
Đặt Lịch Hẹn
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
{/* Appointment Type */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
||||||
Loại tư vấn <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
|
||||||
{appointmentData.appointmentTypes.map((type) => (
|
|
||||||
<div
|
|
||||||
key={type.id}
|
|
||||||
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
|
|
||||||
selectedType === type.id
|
|
||||||
? "border-blue-500 bg-blue-50"
|
|
||||||
: "border-gray-200 hover:border-gray-300"
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedType(type.id)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="text-2xl mr-3">{type.icon}</span>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold">{type.name}</h3>
|
|
||||||
{type.popular && (
|
|
||||||
<span className="bg-green-100 text-green-800 px-2 py-1 rounded text-xs">
|
|
||||||
Phổ biến
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="font-bold text-blue-600">
|
|
||||||
{type.price}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{type.duration}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-600 text-sm">
|
|
||||||
{type.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Consultant Selection */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
||||||
Chọn chuyên gia
|
|
||||||
</label>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div
|
|
||||||
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
|
|
||||||
selectedConsultant === ""
|
|
||||||
? "border-blue-500 bg-blue-50"
|
|
||||||
: "border-gray-200 hover:border-gray-300"
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedConsultant("")}
|
|
||||||
>
|
|
||||||
<div className="font-medium">
|
|
||||||
Để chúng tôi chọn chuyên gia phù hợp
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
Dựa trên loại visa và quốc gia bạn quan tâm
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{appointmentData.consultants.map((consultant) => (
|
|
||||||
<div
|
|
||||||
key={consultant.id}
|
|
||||||
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
|
|
||||||
selectedConsultant === consultant.id
|
|
||||||
? "border-blue-500 bg-blue-50"
|
|
||||||
: "border-gray-200 hover:border-gray-300"
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedConsultant(consultant.id)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="w-12 h-12 bg-gray-200 rounded-full flex items-center justify-center mr-4">
|
|
||||||
<span className="text-gray-600">👤</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-semibold">
|
|
||||||
{consultant.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
{consultant.title}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center text-sm text-gray-500">
|
|
||||||
<span className="mr-2">
|
|
||||||
⭐ {consultant.rating}
|
|
||||||
</span>
|
|
||||||
<span className="mr-2">
|
|
||||||
({consultant.reviews} đánh giá)
|
|
||||||
</span>
|
|
||||||
<span>{consultant.experience}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="days">
|
||||||
|
<div>Mon</div>
|
||||||
{/* Date & Time */}
|
<div>Tue</div>
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div>Wed</div>
|
||||||
<div>
|
<div>Thu</div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<div>Fri</div>
|
||||||
Ngày hẹn <span className="text-red-500">*</span>
|
<div>Sat</div>
|
||||||
</label>
|
<div>Sun</div>
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={selectedDate}
|
|
||||||
onChange={(e) => setSelectedDate(e.target.value)}
|
|
||||||
min={new Date().toISOString().split("T")[0]}
|
|
||||||
required
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="dates" id="dates">
|
||||||
<div>
|
{generateCalendarDays()}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Giờ hẹn <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedTime}
|
|
||||||
onChange={(e) => setSelectedTime(e.target.value)}
|
|
||||||
required
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
>
|
|
||||||
<option value="">Chọn giờ</option>
|
|
||||||
{appointmentData.timeSlots.map((slot) => (
|
|
||||||
<option
|
|
||||||
key={slot.time}
|
|
||||||
value={slot.time}
|
|
||||||
disabled={!slot.available}
|
|
||||||
>
|
|
||||||
{slot.label} {!slot.available && "(Đã đặt)"}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Office Selection */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
||||||
Hình thức tư vấn <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div
|
|
||||||
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
|
|
||||||
selectedOffice === "online"
|
|
||||||
? "border-blue-500 bg-blue-50"
|
|
||||||
: "border-gray-200 hover:border-gray-300"
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedOffice("online")}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="text-2xl mr-3">💻</span>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">Tư vấn online</div>
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
Video call qua Zoom/Google Meet
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{appointmentData.offices.map((office, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
|
|
||||||
selectedOffice === office.name
|
|
||||||
? "border-blue-500 bg-blue-50"
|
|
||||||
: "border-gray-200 hover:border-gray-300"
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedOffice(office.name)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="text-2xl mr-3">🏢</span>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">
|
|
||||||
Văn phòng {office.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
{office.address}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{office.hours}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Personal Information */}
|
|
||||||
<div className="border-t pt-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
|
||||||
Thông Tin Cá Nhân
|
|
||||||
</h3>
|
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
|
||||||
{appointmentData.formFields.map((field) => (
|
|
||||||
<div
|
|
||||||
key={field.name}
|
|
||||||
className={
|
|
||||||
field.type === "textarea" ? "md:col-span-2" : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
{field.label}
|
|
||||||
{field.required && (
|
|
||||||
<span className="text-red-500 ml-1">*</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{field.type === "textarea" ? (
|
|
||||||
<textarea
|
|
||||||
name={field.name}
|
|
||||||
value={
|
|
||||||
formData[field.name as keyof typeof formData]
|
|
||||||
}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
placeholder={field.placeholder}
|
|
||||||
required={field.required}
|
|
||||||
rows={4}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
) : field.type === "select" ? (
|
|
||||||
<select
|
|
||||||
name={field.name}
|
|
||||||
value={
|
|
||||||
formData[field.name as keyof typeof formData]
|
|
||||||
}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
required={field.required}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
Chọn {field.label.toLowerCase()}
|
|
||||||
</option>
|
|
||||||
{field.options?.map((option, index) => (
|
|
||||||
<option key={index} value={option}>
|
|
||||||
{option}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type={field.type}
|
|
||||||
name={field.name}
|
|
||||||
value={
|
|
||||||
formData[field.name as keyof typeof formData]
|
|
||||||
}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
placeholder={field.placeholder}
|
|
||||||
required={field.required}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full bg-blue-600 text-white py-3 px-6 rounded-lg hover:bg-blue-700 transition-colors font-medium text-lg"
|
|
||||||
>
|
|
||||||
Đặt Lịch Hẹn
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Booking Summary */}
|
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
|
||||||
Tóm Tắt Đặt Lịch
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{selectedAppointmentType && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Loại tư vấn:</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{selectedAppointmentType.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Thời gian:</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{selectedAppointmentType.duration}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Phí:</span>
|
|
||||||
<span className="font-medium text-blue-600">
|
|
||||||
{selectedAppointmentType.price}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedConsultantData && (
|
|
||||||
<div className="border-t pt-3">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Chuyên gia:</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{selectedConsultantData.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedDate && selectedTime && (
|
|
||||||
<div className="border-t pt-3">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Ngày giờ:</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{selectedDate} lúc{" "}
|
|
||||||
{
|
|
||||||
appointmentData.timeSlots.find(
|
|
||||||
(s) => s.time === selectedTime,
|
|
||||||
)?.label
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedOffice && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Hình thức:</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{selectedOffice === "online"
|
|
||||||
? "Online"
|
|
||||||
: `Văn phòng ${selectedOffice}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Info */}
|
|
||||||
<div className="bg-blue-50 rounded-lg p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
|
||||||
Cần Hỗ Trợ?
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="text-blue-600 mr-2">📞</span>
|
|
||||||
<span>1900 1234</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="text-blue-600 mr-2">✉️</span>
|
|
||||||
<span>info@visaservice.com</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="text-blue-600 mr-2">💬</span>
|
|
||||||
<span>Chat trực tuyến 24/7</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* FAQs */}
|
{/* Contact Section Start */}
|
||||||
<div className="mt-16">
|
<div className="contact-section section-padding fix pt-0">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-8 text-center">
|
<div className="container">
|
||||||
Câu Hỏi Thường Gặp
|
<div className="contact-from-wrapper">
|
||||||
</h2>
|
{/* Status Message */}
|
||||||
<div className="max-w-3xl mx-auto space-y-4">
|
{submitStatus.type && (
|
||||||
{appointmentData.faqs.map((faq, index) => (
|
|
||||||
<div
|
<div
|
||||||
key={index}
|
className={`alert ${submitStatus.type === "success"
|
||||||
className="bg-white rounded-lg shadow-md overflow-hidden"
|
? "alert-success"
|
||||||
|
: "alert-danger"
|
||||||
|
} text-center mb-4`}
|
||||||
>
|
>
|
||||||
<button
|
{submitStatus.message}
|
||||||
className="w-full p-6 text-left hover:bg-gray-50 transition-colors"
|
|
||||||
onClick={() => toggleFaq(index)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="font-semibold text-gray-900">
|
|
||||||
{faq.question}
|
|
||||||
</h3>
|
|
||||||
<span className="text-gray-400">
|
|
||||||
{openFaq === index ? "−" : "+"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{openFaq === index && (
|
|
||||||
<div className="px-6 pb-6">
|
|
||||||
<p className="text-gray-700">{faq.answer}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="contact-form-items">
|
||||||
|
<div className="row g-4">
|
||||||
|
<div className="col-xl-12">
|
||||||
|
<div className="row g-4">
|
||||||
|
<div className="col-lg-4">
|
||||||
|
<div className="form-clt">
|
||||||
|
<span>Your Name *</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Your name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-4">
|
||||||
|
<div className="form-clt">
|
||||||
|
<span>Your Email *</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Your email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-4">
|
||||||
|
<div className="form-clt">
|
||||||
|
<span>Your Phone</span>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
name="phone"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Phone Number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="form-clt">
|
||||||
|
<span>Your Address</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="address"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Your address"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="form-clt">
|
||||||
|
<span>Appointment Date</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="appointmentDate"
|
||||||
|
value={formData.appointmentDate}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-12">
|
||||||
|
<div className="form-clt">
|
||||||
|
<textarea
|
||||||
|
name="message"
|
||||||
|
value={formData.message}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Type your message"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visaOptions.length > 0 && (
|
||||||
|
<div className="cheak-list-item">
|
||||||
|
<div className="cheak-list">
|
||||||
|
{visaOptions.slice(0, Math.ceil(visaOptions.length / 2)).map((visa, index) => (
|
||||||
|
<div className="form-check" key={index}>
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id={`visa-${index}`}
|
||||||
|
checked={formData.visaTypes.includes(visa)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleCheckboxChange(visa, e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="form-check-label"
|
||||||
|
htmlFor={`visa-${index}`}
|
||||||
|
>
|
||||||
|
{visa}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="cheak-list mb-0">
|
||||||
|
{visaOptions.slice(Math.ceil(visaOptions.length / 2)).map((visa, index) => (
|
||||||
|
<div className="form-check" key={index + Math.ceil(visaOptions.length / 2)}>
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id={`visa-${index + Math.ceil(visaOptions.length / 2)}`}
|
||||||
|
checked={formData.visaTypes.includes(visa)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleCheckboxChange(visa, e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="form-check-label"
|
||||||
|
htmlFor={`visa-${index + Math.ceil(visaOptions.length / 2)}`}
|
||||||
|
>
|
||||||
|
{visa}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={formSettings.submitButton?.buttonClass || "theme-btn"}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "SUBMITTING..." : (formSettings.submitButton?.text || "Request Appointment")}
|
||||||
|
<i className={formSettings.submitButton?.icon || "fa-solid fa-arrow-right"}></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
30
app/appointment/types.ts
Normal file
30
app/appointment/types.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export interface AppointmentHero {
|
||||||
|
title: string;
|
||||||
|
backgroundImage: string;
|
||||||
|
subtitle: string;
|
||||||
|
heading: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppointmentForm {
|
||||||
|
heading: string;
|
||||||
|
fields: Array<{
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
placeholder: string;
|
||||||
|
required: boolean;
|
||||||
|
colClass: string;
|
||||||
|
}>;
|
||||||
|
submitButton: {
|
||||||
|
text: string;
|
||||||
|
icon: string;
|
||||||
|
buttonClass: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppointmentData {
|
||||||
|
hero: AppointmentHero;
|
||||||
|
visaOptions: string[];
|
||||||
|
form: AppointmentForm;
|
||||||
|
}
|
||||||
@@ -1,157 +1,237 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect, FormEvent } from "react";
|
||||||
import contactData from "./contact.json";
|
import { ContactData } from "./types";
|
||||||
|
import Breadcrumb from "../components/Breadcrumb";
|
||||||
|
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
|
const [contactData, setContactData] = useState<ContactData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
fullName: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
service: "",
|
address: "",
|
||||||
|
date: "",
|
||||||
message: "",
|
message: "",
|
||||||
});
|
});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [submitStatus, setSubmitStatus] = useState<{
|
||||||
|
type: "success" | "error" | null;
|
||||||
|
message: string;
|
||||||
|
}>({ type: null, message: "" });
|
||||||
|
|
||||||
const handleInputChange = (
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||||
e: React.ChangeEvent<
|
|
||||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
// Fetch contact data from CMS
|
||||||
>,
|
useEffect(() => {
|
||||||
|
const fetchContactData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/api/contact`, { cache: 'no-store' });
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setContactData(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching contact data:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchContactData();
|
||||||
|
}, [apiUrl]);
|
||||||
|
|
||||||
|
const handleChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||||
) => {
|
) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
...prev,
|
|
||||||
[name]: value,
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
console.log("Form submitted:", formData);
|
setIsSubmitting(true);
|
||||||
// Handle form submission here
|
setSubmitStatus({ type: null, message: "" });
|
||||||
alert(
|
|
||||||
"Cảm ơn bạn đã liên hệ! Chúng tôi sẽ phản hồi trong thời gian sớm nhất.",
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/api/contact/submit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
setSubmitStatus({
|
||||||
|
type: "success",
|
||||||
|
message: data.message || "Thank you! We will contact you soon.",
|
||||||
|
});
|
||||||
|
setFormData({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
address: "",
|
||||||
|
date: "",
|
||||||
|
message: "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSubmitStatus({
|
||||||
|
type: "error",
|
||||||
|
message: data.error || "Something went wrong. Please try again.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Submit error:", error);
|
||||||
|
setSubmitStatus({
|
||||||
|
type: "error",
|
||||||
|
message: "Network error. Please check your connection and try again.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="loading-wrapper" style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default values if API fails
|
||||||
|
const hero = contactData?.hero || { title: "Contact Us", backgroundImage: "/assets/img/inner-page/breadcrumb.jpg" };
|
||||||
|
const contactCards = contactData?.contactCards || [];
|
||||||
|
const map = contactData?.map || { embedUrl: "" };
|
||||||
|
const form = contactData?.form || {
|
||||||
|
heading: "Send Us Message",
|
||||||
|
description: "Have questions? Send us a message today.",
|
||||||
|
fields: [],
|
||||||
|
submitButton: { text: "SEND MESSAGE", icon: "fa-solid fa-arrow-right", buttonClass: "theme-btn style-2" }
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<>
|
||||||
<div className="max-w-6xl mx-auto">
|
{/* Breadcrumb-Wrapper Section Start */}
|
||||||
{/* Header */}
|
<Breadcrumb title={hero.title} current={hero.title} />
|
||||||
<div className="text-center mb-12">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
|
||||||
{contactData.title}
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-600">{contactData.subtitle}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-2 gap-12">
|
{/* Contact Icon Section Start */}
|
||||||
{/* Contact Information */}
|
<section className="contact-us-section-3 section-padding fix">
|
||||||
<div>
|
<div className="container">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
<div className="row g-4">
|
||||||
Thông Tin Liên Hệ
|
{contactCards.map((card, index) => (
|
||||||
</h2>
|
<div className="col-xl-4 col-lg-6 col-md-6" key={index}>
|
||||||
|
<div className="contact-icon-item">
|
||||||
{/* Contact Details */}
|
<div className="icon">
|
||||||
<div className="space-y-6 mb-8">
|
<i className={card.iconType}></i>
|
||||||
{Object.entries(contactData.contactInfo).map(([key, info]) => (
|
</div>
|
||||||
<div key={key} className="flex items-start">
|
<div className="content">
|
||||||
<span className="text-2xl mr-4">{info.icon}</span>
|
<p>{card.title}</p>
|
||||||
<div>
|
<h6>
|
||||||
<h3 className="font-semibold text-gray-900">
|
{card.content.map((line, i) => (
|
||||||
{info.label}
|
<span key={i}>
|
||||||
</h3>
|
{card.type === "email" ? (
|
||||||
<p className="text-gray-700 whitespace-pre-line">
|
<a href={`mailto:${line}`}>{line}</a>
|
||||||
{info.value}
|
) : card.type === "phone" ? (
|
||||||
</p>
|
<a href={`tel:${line.replace(/\s/g, "")}`}>{line}</a>
|
||||||
|
) : (
|
||||||
|
line
|
||||||
|
)}
|
||||||
|
{i < card.content.length - 1 && <br />}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</h6>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
|
||||||
{/* Offices */}
|
|
||||||
<h3 className="text-xl font-bold text-gray-900 mb-4">Văn Phòng</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{contactData.offices.map((office, index) => (
|
|
||||||
<div key={index} className="bg-gray-50 p-4 rounded-lg">
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-2">
|
|
||||||
{office.name}
|
|
||||||
</h4>
|
|
||||||
<p className="text-gray-700 text-sm mb-1">
|
|
||||||
📍 {office.address}
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 text-sm mb-1">
|
|
||||||
📞 {office.phone}
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-700 text-sm">✉️ {office.email}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Form */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
|
||||||
Gửi Tin Nhắn
|
|
||||||
</h2>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
{contactData.formFields.map((field) => (
|
|
||||||
<div key={field.name}>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
{field.label}
|
|
||||||
{field.required && (
|
|
||||||
<span className="text-red-500 ml-1">*</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{field.type === "textarea" ? (
|
|
||||||
<textarea
|
|
||||||
name={field.name}
|
|
||||||
value={formData[field.name as keyof typeof formData]}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
placeholder={field.placeholder}
|
|
||||||
required={field.required}
|
|
||||||
rows={4}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
) : field.type === "select" ? (
|
|
||||||
<select
|
|
||||||
name={field.name}
|
|
||||||
value={formData[field.name as keyof typeof formData]}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
required={field.required}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
>
|
|
||||||
<option value="">Chọn dịch vụ</option>
|
|
||||||
{field.options?.map((option, index) => (
|
|
||||||
<option key={index} value={option}>
|
|
||||||
{option}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type={field.type}
|
|
||||||
name={field.name}
|
|
||||||
value={formData[field.name as keyof typeof formData]}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
placeholder={field.placeholder}
|
|
||||||
required={field.required}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full bg-blue-600 text-white py-3 px-6 rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
|
||||||
>
|
|
||||||
Gửi Tin Nhắn
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
|
{/* Contact Section Start */}
|
||||||
|
<section className="contact-section-3 section-padding fix pt-0">
|
||||||
|
<div className="container">
|
||||||
|
<div className="contact-from-wrapper">
|
||||||
|
<h5 className="text-center">{form.heading}</h5>
|
||||||
|
<p className="text-center mt-3 mb-5">"{form.description}"</p>
|
||||||
|
|
||||||
|
{/* Status Message */}
|
||||||
|
{submitStatus.type && (
|
||||||
|
<div
|
||||||
|
className={`alert ${submitStatus.type === "success" ? "alert-success" : "alert-danger"
|
||||||
|
} text-center mb-4`}
|
||||||
|
>
|
||||||
|
{submitStatus.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="row g-4">
|
||||||
|
<div className="col-xl-12">
|
||||||
|
<form onSubmit={handleSubmit} className="contact-form-items">
|
||||||
|
<div className="row g-4">
|
||||||
|
{form.fields.map((field, index) => (
|
||||||
|
<div className={field.colClass || "col-lg-12"} key={index}>
|
||||||
|
<div className="form-clt">
|
||||||
|
<span>{field.label}{field.required && " *"}</span>
|
||||||
|
{field.type === "textarea" ? (
|
||||||
|
<textarea
|
||||||
|
name={field.name}
|
||||||
|
value={formData[field.name as keyof typeof formData] || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
></textarea>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={field.type}
|
||||||
|
name={field.name}
|
||||||
|
value={formData[field.name as keyof typeof formData] || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="col-lg-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={form.submitButton.buttonClass}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "SENDING..." : form.submitButton.text}
|
||||||
|
<i className={form.submitButton.icon}></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Map Section Start */}
|
||||||
|
{map.embedUrl && (
|
||||||
|
<div className="map-section section-padding pt-0">
|
||||||
|
<div className="map-items">
|
||||||
|
<div className="googpemap">
|
||||||
|
<iframe
|
||||||
|
src={map.embedUrl}
|
||||||
|
style={{ border: 0 }}
|
||||||
|
allowFullScreen
|
||||||
|
loading="lazy"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
44
app/contact/types.ts
Normal file
44
app/contact/types.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
export interface ContactCard {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
content: string[];
|
||||||
|
iconType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactHero {
|
||||||
|
title: string;
|
||||||
|
backgroundImage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactMap {
|
||||||
|
embedUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactFormField {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
placeholder: string;
|
||||||
|
required: boolean;
|
||||||
|
colClass: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactSubmitButton {
|
||||||
|
text: string;
|
||||||
|
icon: string;
|
||||||
|
buttonClass: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactForm {
|
||||||
|
heading: string;
|
||||||
|
description: string;
|
||||||
|
fields: ContactFormField[];
|
||||||
|
submitButton: ContactSubmitButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactData {
|
||||||
|
hero: ContactHero;
|
||||||
|
contactCards: ContactCard[];
|
||||||
|
map: ContactMap;
|
||||||
|
form: ContactForm;
|
||||||
|
}
|
||||||
@@ -1 +1,8 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
.collapse {
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse.show {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|||||||
111
app/layout.tsx
111
app/layout.tsx
@@ -3,73 +3,76 @@ import "./globals.css";
|
|||||||
|
|
||||||
import Header from "./components/layout/Header/Header";
|
import Header from "./components/layout/Header/Header";
|
||||||
import Footer from "./components/layout/Footer/Footer";
|
import Footer from "./components/layout/Footer/Footer";
|
||||||
|
|
||||||
import Loader from "./components/Loader";
|
import Loader from "./components/Loader";
|
||||||
import BackToTop from "./components/BackToTop";
|
import BackToTop from "./components/BackToTop";
|
||||||
import MouseCursor from "./components/MouseCursor";
|
import MouseCursor from "./components/MouseCursor";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Visaway – Immigration & Visa Consulting HTML Template",
|
title: "Visaway – Immigration & Visa Consulting HTML Template",
|
||||||
description: "Visaway – Immigration & Visa Consulting HTML Template",
|
description: "Visaway – Immigration & Visa Consulting HTML Template",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
{/* Favicon */}
|
{/* Favicon */}
|
||||||
<link rel="shortcut icon" href="/assets/img/favicon.png" />
|
<link rel="shortcut icon" href="/assets/img/favicon.png" />
|
||||||
|
|
||||||
{/* Bootstrap min.css */}
|
{/* Bootstrap min.css */}
|
||||||
<link rel="stylesheet" href="/assets/css/bootstrap.min.css" />
|
<link rel="stylesheet" href="/assets/css/bootstrap.min.css" />
|
||||||
{/* All Min Css */}
|
{/* All Min Css */}
|
||||||
<link rel="stylesheet" href="/assets/css/all.min.css" />
|
<link rel="stylesheet" href="/assets/css/all.min.css" />
|
||||||
{/* Animate.css */}
|
{/* Animate.css */}
|
||||||
<link rel="stylesheet" href="/assets/css/animate.css" />
|
<link rel="stylesheet" href="/assets/css/animate.css" />
|
||||||
{/* Magnific Popup.css */}
|
{/* Magnific Popup.css */}
|
||||||
<link rel="stylesheet" href="/assets/css/magnific-popup.css" />
|
<link rel="stylesheet" href="/assets/css/magnific-popup.css" />
|
||||||
{/* MeanMenu.css */}
|
{/* MeanMenu.css */}
|
||||||
<link rel="stylesheet" href="/assets/css/meanmenu.css" />
|
<link rel="stylesheet" href="/assets/css/meanmenu.css" />
|
||||||
{/* Odometer.css */}
|
{/* Odometer.css */}
|
||||||
<link rel="stylesheet" href="/assets/css/odometer.css" />
|
<link rel="stylesheet" href="/assets/css/odometer.css" />
|
||||||
{/* Swiper Bundle.css */}
|
{/* Swiper Bundle.css */}
|
||||||
<link rel="stylesheet" href="/assets/css/swiper-bundle.min.css" />
|
<link rel="stylesheet" href="/assets/css/swiper-bundle.min.css" />
|
||||||
{/* Nice Select.css */}
|
{/* Nice Select.css */}
|
||||||
<link rel="stylesheet" href="/assets/css/nice-select.css" />
|
<link rel="stylesheet" href="/assets/css/nice-select.css" />
|
||||||
{/* Main.css */}
|
{/* Main.css */}
|
||||||
<link rel="stylesheet" href="/assets/css/main.css" />
|
<link rel="stylesheet" href="/assets/css/main.css" />
|
||||||
</head>
|
</head>
|
||||||
<body className="smooth-scroll-yes">
|
|
||||||
<Loader />
|
|
||||||
<BackToTop />
|
|
||||||
<MouseCursor />
|
|
||||||
<Header />
|
|
||||||
|
|
||||||
{children}
|
<body className="smooth-scroll-yes">
|
||||||
|
<Loader />
|
||||||
|
<BackToTop />
|
||||||
|
<MouseCursor />
|
||||||
|
|
||||||
<Footer />
|
<Header />
|
||||||
|
|
||||||
{/* Scripts */}
|
{children}
|
||||||
<Script src="/assets/js/jquery-3.7.1.min.js" strategy="beforeInteractive" />
|
|
||||||
<Script src="/assets/js/viewport.jquery.js" strategy="afterInteractive" />
|
<Footer />
|
||||||
<Script src="/assets/js/bootstrap.bundle.min.js" strategy="afterInteractive" />
|
|
||||||
<Script src="/assets/js/jquery.nice-select.min.js" strategy="afterInteractive" />
|
{/* Scripts */}
|
||||||
<Script src="/assets/js/jquery.waypoints.js" strategy="afterInteractive" />
|
<Script src="/assets/js/jquery-3.7.1.min.js" strategy="beforeInteractive" />
|
||||||
<Script src="/assets/js/odometer.min.js" strategy="afterInteractive" />
|
<Script src="/assets/js/viewport.jquery.js" strategy="afterInteractive" />
|
||||||
<Script src="/assets/js/swiper-bundle.min.js" strategy="afterInteractive" />
|
<Script src="/assets/js/bootstrap.bundle.min.js" strategy="afterInteractive" />
|
||||||
<Script src="/assets/js/jquery.meanmenu.min.js" strategy="afterInteractive" />
|
<Script src="/assets/js/jquery.nice-select.min.js" strategy="afterInteractive" />
|
||||||
<Script src="/assets/js/jquery.magnific-popup.min.js" strategy="afterInteractive" />
|
<Script src="/assets/js/jquery.waypoints.js" strategy="afterInteractive" />
|
||||||
<Script src="/assets/js/wow.min.js" strategy="afterInteractive" />
|
<Script src="/assets/js/odometer.min.js" strategy="afterInteractive" />
|
||||||
<Script src="/assets/js/gsap.js" strategy="afterInteractive" />
|
<Script src="/assets/js/swiper-bundle.min.js" strategy="afterInteractive" />
|
||||||
<Script src="/assets/js/lenis.min.js" strategy="afterInteractive" />
|
<Script src="/assets/js/jquery.meanmenu.min.js" strategy="afterInteractive" />
|
||||||
<Script src="/assets/js/ScrollTrigger.min.js" strategy="afterInteractive" />
|
<Script src="/assets/js/jquery.magnific-popup.min.js" strategy="afterInteractive" />
|
||||||
<Script src="/assets/js/SplitText.min.js" strategy="afterInteractive" />
|
<Script src="/assets/js/wow.min.js" strategy="afterInteractive" />
|
||||||
<Script src="/assets/js/main.js" strategy="afterInteractive" />
|
<Script src="/assets/js/gsap.js" strategy="afterInteractive" />
|
||||||
</body>
|
<Script src="/assets/js/lenis.min.js" strategy="afterInteractive" />
|
||||||
</html>
|
<Script src="/assets/js/ScrollTrigger.min.js" strategy="afterInteractive" />
|
||||||
);
|
<Script src="/assets/js/SplitText.min.js" strategy="afterInteractive" />
|
||||||
|
<Script src="/assets/js/main.js" strategy="afterInteractive" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,188 +1,260 @@
|
|||||||
import pricingData from "./pricing.json";
|
import Breadcrumb from "../components/Breadcrumb";
|
||||||
|
import { PricingData, PricingAPIResponse, Plan, TestimonialItem } from "./types";
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||||
|
|
||||||
|
async function getPricingData(): Promise<PricingData | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/pricing`, {
|
||||||
|
next: { revalidate: 60 }, // Revalidate every 60 seconds
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to fetch pricing data");
|
||||||
|
}
|
||||||
|
const response: PricingAPIResponse = await res.json();
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching pricing data:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component to render a single plan
|
||||||
|
function PlanCard({ plan, index }: { plan: Plan; index: number }) {
|
||||||
|
const styleClass = plan.style === "style-2" ? " style-2" : "";
|
||||||
|
const buttonStyleClass = plan.style === "style-2" ? " style-2" : "";
|
||||||
|
|
||||||
export default function PricingPage() {
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className={`pricing-box-items${styleClass}`}>
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="pricing-header">
|
||||||
{/* Header */}
|
<h2>
|
||||||
<div className="text-center mb-12">
|
<sup>{plan.currency}</sup>
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
{plan.price}
|
||||||
{pricingData.title}
|
<sub>/{plan.period}</sub>
|
||||||
</h1>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 mb-4">{pricingData.subtitle}</p>
|
<span className="sub-texts">{plan.name}</span>
|
||||||
<p className="text-sm text-gray-500 bg-yellow-50 p-3 rounded-lg inline-block">
|
</div>
|
||||||
💡 {pricingData.note}
|
<a href={plan.buttonLink} className={`theme-btn${buttonStyleClass}`}>
|
||||||
</p>
|
{plan.buttonText}
|
||||||
</div>
|
<i className={plan.buttonIcon}></i>
|
||||||
|
</a>
|
||||||
|
<ul className="pricing-list">
|
||||||
|
{plan.features.map((feature, idx) => (
|
||||||
|
<li key={idx}>
|
||||||
|
<i className="fa-solid fa-chevrons-right"></i> {feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Pricing Packages */}
|
// Component to render star rating
|
||||||
<div className="grid md:grid-cols-3 gap-8 mb-16">
|
function StarRating({ rating }: { rating: number }) {
|
||||||
{pricingData.packages.map((pkg) => (
|
return (
|
||||||
<div
|
<div className="star">
|
||||||
key={pkg.id}
|
{[...Array(rating)].map((_, i) => (
|
||||||
className={`relative bg-white rounded-lg shadow-lg p-6 ${
|
<i key={i} className="fa-solid fa-star"></i>
|
||||||
pkg.popular ? "ring-2 ring-blue-500 transform scale-105" : ""
|
))}
|
||||||
}`}
|
</div>
|
||||||
>
|
);
|
||||||
{/* Popular Badge */}
|
}
|
||||||
{pkg.popular && (
|
|
||||||
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
|
|
||||||
<span className="bg-blue-500 text-white px-4 py-1 rounded-full text-sm font-medium">
|
|
||||||
Phổ biến nhất
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Package Header */}
|
// Component to render testimonial slide
|
||||||
<div className="text-center mb-6">
|
function TestimonialSlide({ item }: { item: TestimonialItem }) {
|
||||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
return (
|
||||||
{pkg.name}
|
<div className="swiper-slide">
|
||||||
</h3>
|
<div className="content">
|
||||||
<p className="text-gray-600 mb-4">{pkg.description}</p>
|
<StarRating rating={item.rating} />
|
||||||
<div className="text-3xl font-bold text-blue-600">
|
<h3>“{item.content}”</h3>
|
||||||
{parseInt(pkg.price).toLocaleString()} {pkg.currency}
|
<div className="info-item">
|
||||||
</div>
|
<div className="icon">
|
||||||
</div>
|
<i className="fa-solid fa-quote-right"></i>
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-3">Bao gồm:</h4>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{pkg.features.map((feature, index) => (
|
|
||||||
<li key={index} className="flex items-center text-gray-700">
|
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
|
||||||
{feature}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Limitations */}
|
|
||||||
{pkg.limitations.length > 0 && (
|
|
||||||
<div className="mb-6">
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-3">Lưu ý:</h4>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{pkg.limitations.map((limitation, index) => (
|
|
||||||
<li
|
|
||||||
key={index}
|
|
||||||
className="flex items-center text-gray-600 text-sm"
|
|
||||||
>
|
|
||||||
<span className="text-orange-500 mr-2">⚠</span>
|
|
||||||
{limitation}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* CTA Button */}
|
|
||||||
<button
|
|
||||||
className={`w-full py-3 px-6 rounded-lg font-medium transition-colors ${
|
|
||||||
pkg.popular
|
|
||||||
? "bg-blue-600 text-white hover:bg-blue-700"
|
|
||||||
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Chọn gói này
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Additional Services */}
|
|
||||||
<div className="mb-16">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-8 text-center">
|
|
||||||
Dịch Vụ Bổ Sung
|
|
||||||
</h2>
|
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{pricingData.additionalServices.map((service, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="bg-white rounded-lg shadow-md p-6 text-center"
|
|
||||||
>
|
|
||||||
<h3 className="font-semibold text-gray-900 mb-2">
|
|
||||||
{service.name}
|
|
||||||
</h3>
|
|
||||||
<div className="text-2xl font-bold text-blue-600 mb-2">
|
|
||||||
{parseInt(service.price).toLocaleString()} VNĐ
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 mb-3">
|
|
||||||
/{service.unit}
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-700 text-sm">{service.description}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="content">
|
||||||
|
<h5>{item.name},</h5>
|
||||||
{/* Country Pricing */}
|
<span>{item.role}</span>
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-8 text-center">
|
|
||||||
Phí Visa Theo Quốc Gia
|
|
||||||
</h2>
|
|
||||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Quốc gia
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Phí visa
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Thời gian xử lý
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Tỷ lệ thành công
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{pricingData.countries.map((country, index) => (
|
|
||||||
<tr key={index} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
|
||||||
{country.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{parseInt(country.visaFee).toLocaleString()} VNĐ
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{country.processingTime}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
||||||
{country.successRate}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact CTA */}
|
|
||||||
<div className="text-center mt-12 bg-blue-50 rounded-lg p-8">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
|
||||||
Cần Tư Vấn Chi Tiết?
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700 mb-6">
|
|
||||||
Liên hệ với chúng tôi để được tư vấn miễn phí và nhận báo giá phù
|
|
||||||
hợp nhất
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
|
||||||
<button className="bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 transition-colors">
|
|
||||||
Tư vấn miễn phí
|
|
||||||
</button>
|
|
||||||
<button className="bg-white text-blue-600 border border-blue-600 px-8 py-3 rounded-lg hover:bg-blue-50 transition-colors">
|
|
||||||
Gọi ngay: 1900 1234
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default async function PricingPage() {
|
||||||
|
const data = await getPricingData();
|
||||||
|
|
||||||
|
// Fallback values if data is not available
|
||||||
|
const pricingSection = data?.pricingSection || {
|
||||||
|
subtitle: "pricing plan",
|
||||||
|
heading: "Flexible Plans to Suit Every Traveler",
|
||||||
|
description: "Choose the plan that fits your visa needs and enjoy expert guidance every step of the way.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const plans = data?.plans || {
|
||||||
|
monthly: [],
|
||||||
|
yearly: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const testimonials = data?.testimonials || {
|
||||||
|
subtitle: "What Our Clients Say",
|
||||||
|
heading: "Immigration Success Stories",
|
||||||
|
buttonText: "View All Review",
|
||||||
|
buttonLink: "/contact",
|
||||||
|
buttonIcon: "fa-solid fa-arrow-right",
|
||||||
|
image: "/assets/img/home-3/test-thumb.jpg",
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const hero = data?.hero || {
|
||||||
|
title: "pricing plan",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Breadcrumb-Wrapper Section Start */}
|
||||||
|
<Breadcrumb title={hero.title} current={hero.title} />
|
||||||
|
|
||||||
|
{/* Pricing Section Start */}
|
||||||
|
<section className="pricing-section-2 section-padding fix section-bg-1">
|
||||||
|
<div className="container">
|
||||||
|
<div className="pricing-wrapper-2">
|
||||||
|
<div className="row g-4 align-items-center">
|
||||||
|
<div className="col-xl-6 col-lg-5">
|
||||||
|
<div className="pricing-content">
|
||||||
|
<div className="section-title mb-0">
|
||||||
|
<span className="sub-title-2 wow fadeInUp">
|
||||||
|
{pricingSection.subtitle}
|
||||||
|
</span>
|
||||||
|
<h2 className="split-text-right split-text-in-right">
|
||||||
|
{pricingSection.heading}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="pricing-text wow fadeInUp" data-wow-delay=".5s">
|
||||||
|
{pricingSection.description}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="d-flex mt-3 mt-md-0 wow fadeInUp"
|
||||||
|
data-wow-delay=".5s"
|
||||||
|
>
|
||||||
|
<div className="pricing-two__tab">
|
||||||
|
<nav>
|
||||||
|
<div className="nav nav-tabs" id="nav-tab" role="tablist">
|
||||||
|
<button
|
||||||
|
className="nav-link active"
|
||||||
|
id="pt-1-tab"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#pt-1"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="pt-1"
|
||||||
|
aria-selected="true"
|
||||||
|
>
|
||||||
|
Monthly
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="nav-link"
|
||||||
|
id="pt-2-tab"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#pt-2"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="pt-2"
|
||||||
|
aria-selected="false"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
Yearly
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-xl-6 col-lg-7">
|
||||||
|
<div className="pricing__tab-content">
|
||||||
|
<div className="tab-content" id="nav-tabContent">
|
||||||
|
{/* Monthly Plans Tab */}
|
||||||
|
<div
|
||||||
|
className="tab-pane fade active show"
|
||||||
|
id="pt-1"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="pt-1-tab"
|
||||||
|
>
|
||||||
|
<div className="pricing-right-items">
|
||||||
|
{plans.monthly.map((plan, index) => (
|
||||||
|
<PlanCard key={index} plan={plan} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Yearly Plans Tab */}
|
||||||
|
<div
|
||||||
|
className="tab-pane fade"
|
||||||
|
id="pt-2"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="pt-2-tab"
|
||||||
|
>
|
||||||
|
<div className="pricing-right-items">
|
||||||
|
{plans.yearly.map((plan, index) => (
|
||||||
|
<PlanCard key={index} plan={plan} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Testimonial Section Start */}
|
||||||
|
<section className="testimonial-section section-padding fix">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-title-area">
|
||||||
|
<div className="section-title mb-0">
|
||||||
|
<span className="sub-title-2 wow fadeInUp">
|
||||||
|
{testimonials.subtitle}
|
||||||
|
</span>
|
||||||
|
<h2 className="split-text-right split-text-in-right">
|
||||||
|
{testimonials.heading}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<a href={testimonials.buttonLink} className="theme-btn">
|
||||||
|
{testimonials.buttonText}
|
||||||
|
<i className={testimonials.buttonIcon}></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="testimonial-wrapper-3">
|
||||||
|
<div className="row g-4 align-items-center">
|
||||||
|
<div className="col-lg-4">
|
||||||
|
<div className="testimonial-thumb">
|
||||||
|
<img src={testimonials.image} alt="testimonial" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-8">
|
||||||
|
<div className="testimonial-content">
|
||||||
|
<div className="swiper testimonial-slider-3">
|
||||||
|
<div className="swiper-wrapper">
|
||||||
|
{testimonials.items.map((item, index) => (
|
||||||
|
<TestimonialSlide key={index} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="array-buttons-3">
|
||||||
|
<button className="array-prev">
|
||||||
|
<i className="fa-solid fa-arrow-left"></i>
|
||||||
|
</button>
|
||||||
|
<button className="array-next">
|
||||||
|
<i className="fa-solid fa-arrow-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
65
app/pricing/types.ts
Normal file
65
app/pricing/types.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Type definitions for Pricing page
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
text: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hero {
|
||||||
|
title: string;
|
||||||
|
backgroundImage: string;
|
||||||
|
shapeImage: string;
|
||||||
|
breadcrumb: BreadcrumbItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricingSection {
|
||||||
|
subtitle: string;
|
||||||
|
heading: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Plan {
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
period: string;
|
||||||
|
currency: string;
|
||||||
|
buttonText: string;
|
||||||
|
buttonLink: string;
|
||||||
|
buttonIcon: string;
|
||||||
|
style: "default" | "style-2";
|
||||||
|
features: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Plans {
|
||||||
|
monthly: Plan[];
|
||||||
|
yearly: Plan[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestimonialItem {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
rating: number;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Testimonials {
|
||||||
|
subtitle: string;
|
||||||
|
heading: string;
|
||||||
|
buttonText: string;
|
||||||
|
buttonLink: string;
|
||||||
|
buttonIcon: string;
|
||||||
|
image: string;
|
||||||
|
items: TestimonialItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricingData {
|
||||||
|
hero: Hero;
|
||||||
|
pricingSection: PricingSection;
|
||||||
|
plans: Plans;
|
||||||
|
testimonials: Testimonials;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricingAPIResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: PricingData;
|
||||||
|
}
|
||||||
56
app/services/details/[slug]/not-found.tsx
Normal file
56
app/services/details/[slug]/not-found.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section
|
||||||
|
className="breadcrumb-wrapper fix bg-cover"
|
||||||
|
style={{
|
||||||
|
backgroundImage: "url(/assets/img/inner-page/breadcrumb.jpg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="shape">
|
||||||
|
<img src="/assets/img/inner-page/shape.png" alt="img" />
|
||||||
|
</div>
|
||||||
|
<div className="container">
|
||||||
|
<div className="page-heading">
|
||||||
|
<h1 className="breadcrumb-title">Service Not Found</h1>
|
||||||
|
<ul className="breadcrumb-list">
|
||||||
|
<li>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i className="fa-solid fa-chevron-right"></i>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/services">Services</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i className="fa-solid fa-chevron-right"></i>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#">Not Found</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section-padding">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-lg-8 text-center">
|
||||||
|
<h2>Service Not Found</h2>
|
||||||
|
<p className="mb-4">
|
||||||
|
The service you're looking for could not be found. Please check
|
||||||
|
the URL or return to our services page.
|
||||||
|
</p>
|
||||||
|
<a href="/services" className="theme-btn">
|
||||||
|
Back to Services
|
||||||
|
<i className="fa-solid fa-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
app/services/details/[slug]/page.tsx
Normal file
173
app/services/details/[slug]/page.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import { fetchServiceBySlug } from "../../../../api/servicesApi";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { imageUrl } from "../../../utils/image";
|
||||||
|
import Breadcrumb from "../../../components/Breadcrumb";
|
||||||
|
|
||||||
|
interface ServiceDetailsPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
slug: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: ServiceDetailsPageProps): Promise<Metadata> {
|
||||||
|
try {
|
||||||
|
const { slug } = await params;
|
||||||
|
const data = await fetchServiceBySlug(slug);
|
||||||
|
if (!data || !data.serviceDetails) {
|
||||||
|
return {
|
||||||
|
title: "Service Not Found",
|
||||||
|
description: "The requested service could not be found.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${data.serviceDetails.content.title} - Visaway Immigration Services`,
|
||||||
|
description: data.serviceDetails.content.description,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
title: "Service Not Found",
|
||||||
|
description: "The requested service could not be found.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ServiceDetailsPage({
|
||||||
|
params,
|
||||||
|
}: ServiceDetailsPageProps) {
|
||||||
|
try {
|
||||||
|
const { slug } = await params;
|
||||||
|
console.log("Service details page - slug:", slug);
|
||||||
|
|
||||||
|
const data = await fetchServiceBySlug(slug);
|
||||||
|
|
||||||
|
if (!data || !data.serviceDetails) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { serviceDetails } = data;
|
||||||
|
const { content, keyFeatures, faq } = serviceDetails;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Breadcrumb Section */}
|
||||||
|
<Breadcrumb title="Services Details" current="Service Details" />
|
||||||
|
|
||||||
|
{/* Service-details Section Start */}
|
||||||
|
<section className="service-details-section section-padding fix">
|
||||||
|
<div className="container">
|
||||||
|
<div className="service-details-wrapper">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-xl-12">
|
||||||
|
<div className="service-details-post">
|
||||||
|
<h2>{content.title}</h2>
|
||||||
|
<p className="mt-2">{content.description}</p>
|
||||||
|
<div className="details-image">
|
||||||
|
<img src={imageUrl(content.mainImage)} alt="img" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text">{content.overviewTitle}</h3>
|
||||||
|
<p className="mt-3 mb-3">{content.overviewDescription}</p>
|
||||||
|
<p className="mb-4">{content.additionalDescription}</p>
|
||||||
|
|
||||||
|
<div className="row g-4">
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="service-left-content">
|
||||||
|
<h3>{keyFeatures.title}</h3>
|
||||||
|
<ul className="list-item">
|
||||||
|
{keyFeatures.items.map(
|
||||||
|
(feature: any, index: number) => (
|
||||||
|
<li key={index}>
|
||||||
|
<i className="fa-solid fa-chevrons-right"></i>
|
||||||
|
<span>{feature.title} -</span>
|
||||||
|
{feature.description}
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="thumb">
|
||||||
|
<img
|
||||||
|
src={imageUrl(keyFeatures.sideImage)}
|
||||||
|
alt="img"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mt-4 mt-xl-0 g-4">
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="thumb">
|
||||||
|
<img src={imageUrl(faq.sideImage)} alt="img" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="faq-items">
|
||||||
|
<h3 className="mb-3">{faq.title}</h3>
|
||||||
|
<div className="accordion" id="accordionExample">
|
||||||
|
{faq.items.map((faqItem: any, index: number) => {
|
||||||
|
const isExpanded = faqItem.isExpanded;
|
||||||
|
const questionNumber = String(index + 1).padStart(
|
||||||
|
2,
|
||||||
|
"0",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`faq-${index}`}
|
||||||
|
className="accordion-item wow fadeInUp"
|
||||||
|
data-wow-delay={`.${(index + 1) * 2}s`}
|
||||||
|
>
|
||||||
|
<h5
|
||||||
|
className="accordion-header"
|
||||||
|
id={`heading${index}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={`accordion-button ${!isExpanded ? "collapsed" : ""}`}
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target={`#collapse${index}`}
|
||||||
|
aria-expanded={
|
||||||
|
isExpanded ? "true" : "false"
|
||||||
|
}
|
||||||
|
aria-controls={`collapse${index}`}
|
||||||
|
>
|
||||||
|
{questionNumber}. {faqItem.question}
|
||||||
|
</button>
|
||||||
|
</h5>
|
||||||
|
<div
|
||||||
|
id={`collapse${index}`}
|
||||||
|
className={`accordion-collapse collapse ${isExpanded ? "show" : ""}`}
|
||||||
|
aria-labelledby={`heading${index}`}
|
||||||
|
data-bs-parent="#accordionExample"
|
||||||
|
>
|
||||||
|
<div className="accordion-body">
|
||||||
|
<p>{faqItem.answer}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading service details:", error);
|
||||||
|
const { slug } = await params;
|
||||||
|
console.log("Slug that failed:", slug);
|
||||||
|
console.log("Falling back to not found page");
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,73 +1,263 @@
|
|||||||
import servicesData from "./services.json";
|
import { Metadata } from "next";
|
||||||
|
import { fetchServicePageData } from "../../api/servicesApi";
|
||||||
|
import { imageUrl } from "../utils/image";
|
||||||
|
import Breadcrumb from "../components/Breadcrumb";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Services - Visaway Immigration & Visa Consulting",
|
||||||
|
description: "Immigration & Visa Consulting Services",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ServicesPage() {
|
||||||
|
const data = await fetchServicePageData();
|
||||||
|
const { services, destinations, visas, reviews } = data;
|
||||||
|
const country = [
|
||||||
|
{
|
||||||
|
id: "canada",
|
||||||
|
name: "Canada",
|
||||||
|
description:
|
||||||
|
"Canada provides quality education, rich culture and global opportunities",
|
||||||
|
image: "img/home-3/choose-us/01.jpg",
|
||||||
|
icon: "img/home-3/choose-us/icon-1.png",
|
||||||
|
link: "country-details.html",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "south-korea",
|
||||||
|
name: "South Korea",
|
||||||
|
description:
|
||||||
|
"South Korea offers advanced technology and cultural experiences",
|
||||||
|
image: "img/home-3/choose-us/02.jpg",
|
||||||
|
icon: "img/home-3/choose-us/icon-2.png",
|
||||||
|
link: "country-details.html",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "france",
|
||||||
|
name: "France",
|
||||||
|
description:
|
||||||
|
"France offers rich cultural heritage and educational excellence",
|
||||||
|
image: "img/home-3/choose-us/03.jpg",
|
||||||
|
icon: "img/home-3/choose-us/icon-3.png",
|
||||||
|
link: "country-details.html",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "uk",
|
||||||
|
name: "UK",
|
||||||
|
description: "UK provides world-class education and career opportunities",
|
||||||
|
image: "img/home-3/choose-us/04.jpg",
|
||||||
|
icon: "img/home-3/choose-us/icon-2.png",
|
||||||
|
link: "country-details.html",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "germany",
|
||||||
|
name: "Germany",
|
||||||
|
description: "Germany offers excellent education and strong economy",
|
||||||
|
image: "img/home-3/choose-us/05.jpg",
|
||||||
|
icon: "img/home-3/choose-us/icon-3.png",
|
||||||
|
link: "country-details.html",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function ServicesPage() {
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<>
|
||||||
<div className="max-w-6xl mx-auto">
|
{/* Breadcrumb Section */}
|
||||||
{/* Header */}
|
<Breadcrumb title="Services" current="Services" />
|
||||||
<div className="text-center mb-12">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
|
||||||
{servicesData.title}
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-600">{servicesData.subtitle}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Services Grid */}
|
{/* Service Section Start */}
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-2 gap-8">
|
<section className="service-section section-padding fix section-bg-1">
|
||||||
{servicesData.services.map((service) => (
|
<div className="container">
|
||||||
<div
|
<div className="section-title text-center">
|
||||||
key={service.id}
|
<span className="sub-title-2 wow fadeInUp">
|
||||||
className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition-shadow"
|
{services.title.subTitle}
|
||||||
>
|
</span>
|
||||||
{/* Service Header */}
|
<h2 className="split-text-right split-text-in-right">
|
||||||
<div className="flex items-center mb-4">
|
{services.title.mainTitle}
|
||||||
<span className="text-4xl mr-4">{service.icon}</span>
|
</h2>
|
||||||
<div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900">
|
<div className="row">
|
||||||
{service.name}
|
<div className="col-xl-12">
|
||||||
</h3>
|
{services.items.map((service: any) => (
|
||||||
<p className="text-blue-600 font-medium">{service.price}</p>
|
<div
|
||||||
|
key={service.id}
|
||||||
|
className={`service-main-item-3 ${service.layout === "right" ? "style-2" : ""} fade-up-anim`}
|
||||||
|
>
|
||||||
|
{service.layout === "right" && (
|
||||||
|
<div className="service-button">
|
||||||
|
<a
|
||||||
|
href={`/services/details/${service.slug}`}
|
||||||
|
className="theme-btn"
|
||||||
|
>
|
||||||
|
read more
|
||||||
|
<i className="fa-solid fa-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="service-left">
|
||||||
|
{service.layout === "left" && (
|
||||||
|
<div className="service-image">
|
||||||
|
<img src={imageUrl(service.image)} alt="img" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="content">
|
||||||
|
<h3>
|
||||||
|
<a href={`/services/details/${service.slug}`}>
|
||||||
|
{service.name}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<p>{service.description}</p>
|
||||||
|
</div>
|
||||||
|
{service.layout === "right" && (
|
||||||
|
<div className="service-image">
|
||||||
|
<img src={imageUrl(service.image)} alt="img" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{service.layout === "left" && (
|
||||||
|
<div className="service-button">
|
||||||
|
<a
|
||||||
|
href={`/services/details/${service.slug}`}
|
||||||
|
className="theme-btn"
|
||||||
|
>
|
||||||
|
read more
|
||||||
|
<i className="fa-solid fa-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Destination-Offer Section Start */}
|
||||||
|
<section className="destination-offer-section section-padding fix">
|
||||||
|
<div className="bg-image">
|
||||||
|
<img src={imageUrl(destinations.backgroundImage)} alt="img" />
|
||||||
|
</div>
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-title">
|
||||||
|
<span className="sub-title-2 theme wow fadeInUp">
|
||||||
|
{destinations.title.subTitle}
|
||||||
|
</span>
|
||||||
|
<h2 className="split-text-right split-text-in-right text-white">
|
||||||
|
{destinations.title.mainTitle}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="destination-offer-wrapper-3 fade-up-anim row g-4 g-xl-4 row-cols-xl-5 row-cols-lg-4 row-cols-md-2 row-cols-1">
|
||||||
|
{country.map((country: any) => (
|
||||||
|
<div key={country.id} className="col destination-offer-item">
|
||||||
|
<div className="choose-us-image">
|
||||||
|
<img src={imageUrl(country.image)} alt="img" />
|
||||||
|
</div>
|
||||||
|
<div className="choose-us-content">
|
||||||
|
<div className="icon-item">
|
||||||
|
<div className="icon">
|
||||||
|
<img src={imageUrl(country.icon)} alt="img" />
|
||||||
|
</div>
|
||||||
|
<h5>
|
||||||
|
<a href={country.link}>{country.name}</a>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<p>{country.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Service-Visa Section Start */}
|
||||||
<p className="text-gray-700 mb-6">{service.description}</p>
|
{/* <section className="service-visa-section fix">
|
||||||
|
<div className="container">
|
||||||
{/* Features */}
|
<div className="service-visa-wrapper">
|
||||||
<div className="mb-6">
|
{visas.items.map((visa: any, index: number) => (
|
||||||
<h4 className="font-semibold text-gray-900 mb-3">Tính năng:</h4>
|
<div
|
||||||
<ul className="space-y-2">
|
key={visa.id}
|
||||||
{service.features.map((feature, index) => (
|
className={`service-visa-items ${index > 0 ? "style-2" : ""}`}
|
||||||
<li key={index} className="flex items-center text-gray-700">
|
>
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<div className="top-item">
|
||||||
{feature}
|
<h4 className="number">{visa.number}</h4>
|
||||||
</li>
|
<h3>
|
||||||
))}
|
<a href={visa.buttonLink}>{visa.name}</a>
|
||||||
</ul>
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p>{visa.description}</p>
|
||||||
|
<a href={visa.buttonLink} className="service-button">
|
||||||
|
{visa.buttonText}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section> */}
|
||||||
|
|
||||||
{/* CTA Button */}
|
{/* Testimonial Section3 Start */}
|
||||||
<button className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors">
|
<section className="testimonial-section section-padding fix">
|
||||||
Tìm Hiểu Thêm
|
<div className="container">
|
||||||
</button>
|
<div className="section-title-area">
|
||||||
|
<div className="section-title mb-0">
|
||||||
|
<span className="sub-title-2 wow fadeInUp">
|
||||||
|
{reviews.title.subTitle}
|
||||||
|
</span>
|
||||||
|
<h2 className="split-text-right split-text-in-right">
|
||||||
|
{reviews.title.mainTitle}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<a href="#" className="theme-btn">
|
||||||
|
View All Review
|
||||||
|
<i className="fa-solid fa-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="testimonial-wrapper-3">
|
||||||
|
<div className="row g-4 align-items-center">
|
||||||
|
<div className="col-lg-4">
|
||||||
|
<div className="testimonial-thumb">
|
||||||
|
<img src={imageUrl(reviews.thumb)} alt="img" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-lg-8">
|
||||||
|
<div className="testimonial-content">
|
||||||
|
<div className="swiper testimonial-slider-3">
|
||||||
|
<div className="swiper-wrapper">
|
||||||
|
{reviews.items.map((testimonial: any) => (
|
||||||
|
<div key={testimonial.id} className="swiper-slide">
|
||||||
|
<div className="content">
|
||||||
|
<div className="star">
|
||||||
|
{[...Array(testimonial.rating)].map(
|
||||||
|
(_: any, i: number) => (
|
||||||
|
<i key={i} className="fa-solid fa-star"></i>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3>"{testimonial.content}"</h3>
|
||||||
|
<div className="info-item">
|
||||||
|
<div className="icon">
|
||||||
|
<i className={testimonial.icon}></i>
|
||||||
|
</div>
|
||||||
|
<div className="content">
|
||||||
|
<h5>{testimonial.author.name}</h5>
|
||||||
|
<span>{testimonial.author.type}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="array-buttons-3">
|
||||||
|
<button className="array-prev">
|
||||||
|
<i className="fa-solid fa-arrow-left"></i>
|
||||||
|
</button>
|
||||||
|
<button className="array-next">
|
||||||
|
<i className="fa-solid fa-arrow-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
{/* Contact CTA */}
|
</>
|
||||||
<div className="text-center mt-12 bg-gray-50 rounded-lg p-8">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
|
||||||
Cần Tư Vấn Thêm?
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-700 mb-6">
|
|
||||||
Liên hệ với chúng tôi để được tư vấn miễn phí và nhận báo giá chi
|
|
||||||
tiết
|
|
||||||
</p>
|
|
||||||
<button className="bg-green-600 text-white px-8 py-3 rounded-lg hover:bg-green-700 transition-colors">
|
|
||||||
Liên Hệ Ngay
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
13
app/utils/image.ts
Normal file
13
app/utils/image.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const imageUrl = (path?: string) => {
|
||||||
|
// Không có ảnh → ảnh mặc định
|
||||||
|
if (!path) return "/_images/default.jpg";
|
||||||
|
|
||||||
|
// Đã là full URL
|
||||||
|
if (path.startsWith("http")) return path;
|
||||||
|
|
||||||
|
const base = (
|
||||||
|
process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"
|
||||||
|
).replace(/\/$/, "");
|
||||||
|
|
||||||
|
return `${base}/${path.replace(/^\//, "")}`;
|
||||||
|
};
|
||||||
BIN
public/_images/default.jpg
Normal file
BIN
public/_images/default.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Reference in New Issue
Block a user