Merge pull request 'fea/hoang-04022026-Visa/VisaDetail' (#7) from fea/hoang-04022026-Visa/VisaDetail into main

Reviewed-on: UKSOURCE/cms.hailearning.edu.vn#7
This commit is contained in:
2026-02-04 02:36:10 +00:00
9 changed files with 2760 additions and 20 deletions

View File

@@ -0,0 +1,557 @@
// controllers/visaController.js
const addBaseUrlToImages = (data, baseUrl) => {
if (!data) return data;
// Nếu là mảng, duyệt từng phần tử
if (Array.isArray(data)) {
return data.map((item) => addBaseUrlToImages(item, baseUrl));
}
// Nếu là object, duyệt từng key
if (typeof data === "object") {
const newObj = {};
for (const [key, value] of Object.entries(data)) {
// Kiểm tra nếu key là các trường chứa ảnh và value là string
const imageKeys = ["icon", "mainImage", "bannerImage", "image"];
if (
imageKeys.includes(key) &&
typeof value === "string" &&
!value.startsWith("http")
) {
newObj[key] = `${baseUrl}/${value}`
.replace(/\/+/g, "/")
.replace(":/", "://");
}
// Xử lý riêng cho mảng gallery (mảng các chuỗi)
else if (key === "gallery" && Array.isArray(value)) {
newObj[key] = value.map((img) =>
img.startsWith("http")
? img
: `${baseUrl}/${img}`.replace(/\/+/g, "/").replace(":/", "://"),
);
}
// Nếu là object hoặc mảng con khác, đệ quy tiếp
else if (typeof value === "object" && value !== null) {
newObj[key] = addBaseUrlToImages(value, baseUrl);
} else {
newObj[key] = value;
}
}
return newObj;
}
return data;
};
const Visa = require("../models/visa");
// -------------------- Helper Functions --------------------
// Get visa data from MongoDB
const getVisaData = async () => {
const visa = await Visa.findOne().sort({ updatedAt: -1 }).lean();
return visa || {};
};
// Get default visa data structure (updated to match new JSON)
const getDefaultVisaData = () => ({
hero: {
title: "Visa Service",
summaryList: [],
},
});
// Helper function: Generate next country ID
const getNextCountryId = (countries) => {
if (!Array.isArray(countries) || countries.length === 0) return 1;
return Math.max(...countries.map((c) => c.id || 0)) + 1;
};
// -------------------- Admin Exports --------------------
// Display visa management page
exports.index = async (req, res) => {
try {
// Fetch Visa data
let data = await getVisaData();
// If no data exists, use default
if (!data || Object.keys(data).length === 0) {
data = getDefaultVisaData();
} else {
// Merge with defaults to ensure all fields exist
const defaultData = getDefaultVisaData();
// Ensure hero section exists with defaults
data.hero = data.hero || defaultData.hero;
data.hero.title = data.hero.title || "Visa Service";
data.hero.summaryList = data.hero.summaryList || [];
}
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
res.render("admin/visa/index", {
layout: "layouts/main",
title: "Visa Management",
data,
frontendUrl,
currentPath: req.path,
user: req.session.user,
});
// return res.json(data);
} catch (err) {
console.error("Visa index error:", err);
req.flash("error_msg", "Error loading visa data");
res.redirect("/admin/dashboard");
}
};
// Get single country for edit
exports.getCountry = async (req, res) => {
try {
const { slug } = req.params;
const visaData = await getVisaData();
if (!visaData.hero || !visaData.hero.summaryList) {
return res.status(404).json({ error: "Visa data not found" });
}
const country = visaData.hero.summaryList.find((c) => c.slug === slug);
if (!country) {
return res.status(404).json({ error: `Country "${slug}" not found` });
}
res.json(country);
} catch (err) {
console.error("Get country error:", err);
res.status(500).json({ error: "Error loading country" });
}
};
// Update visa data (hero title only)
exports.update = async (req, res) => {
try {
// Get current data
const currentData = await getVisaData();
// Create updated data object
const updatedData = {
...(currentData.toObject ? currentData.toObject() : currentData),
};
// Ensure hero structure exists
updatedData.hero = updatedData.hero || {
title: "Visa Service",
summaryList: [],
};
// Update hero title
if (req.body.heroTitle) {
updatedData.hero.title = req.body.heroTitle;
}
// Check if there are changes
const hasChanges =
JSON.stringify(updatedData) !== JSON.stringify(currentData);
if (!hasChanges) {
req.flash("info_msg", "No changes were made");
return req.session.save(() => res.redirect("/admin/visa"));
}
// Update or create document
try {
if (currentData._id) {
await Visa.findByIdAndUpdate(currentData._id, updatedData, {
new: true,
});
} else {
await Visa.create(updatedData);
}
req.flash("success_msg", "Visa data updated successfully");
return req.session.save(() => res.redirect("/admin/visa"));
} catch (dbError) {
console.error("Database error:", dbError);
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/visa"));
}
} catch (err) {
console.error("Update error:", err);
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
return req.session.save(() => res.redirect("/admin/visa"));
}
};
// Add new country
exports.addCountry = async (req, res) => {
try {
let visaData = await getVisaData();
// Initialize hero structure if not exist
if (!visaData.hero || !visaData.hero.summaryList) {
visaData = getDefaultVisaData();
}
// Validate required fields
if (!req.body.name || !req.body.slug) {
return res.status(400).json({ error: "Name and slug are required" });
}
// Check if slug already exists
const slugExists = visaData.hero.summaryList.some(
(c) => c.slug === req.body.slug,
);
if (slugExists) {
return res
.status(400)
.json({ error: `Slug "${req.body.slug}" already exists` });
}
// Parse services array
let services = [];
if (req.body.services) {
if (typeof req.body.services === "string") {
try {
services = JSON.parse(req.body.services);
} catch (e) {
services = [req.body.services];
}
} else if (Array.isArray(req.body.services)) {
services = req.body.services;
}
}
// Parse detailedView if provided (optional)
let detailedView = null;
if (req.body.detailedView) {
try {
detailedView =
typeof req.body.detailedView === "string"
? JSON.parse(req.body.detailedView)
: req.body.detailedView;
} catch (e) {
console.warn("Could not parse detailedView, creating without it");
}
}
// Create new country object
const newCountry = {
id: req.body.id || getNextCountryId(visaData.hero.summaryList),
name: req.body.name,
slug: req.body.slug,
icon: req.body.icon || "",
services: services,
...(detailedView && { detailedView }),
};
// Add new country to summaryList
visaData.hero.summaryList.push(newCountry);
// Update database
const updatedData = {
...(visaData.toObject ? visaData.toObject() : visaData),
};
let savedData;
if (visaData._id) {
savedData = await Visa.findByIdAndUpdate(visaData._id, updatedData, {
new: true,
});
} else {
savedData = await Visa.create(updatedData);
}
console.log(`✅ Country "${newCountry.name}" added successfully`);
res.json({
success: true,
message: `Country "${newCountry.name}" added successfully`,
country: newCountry,
});
} catch (err) {
console.error("Add country error:", err);
res.status(500).json({ error: err.message });
}
};
// Update single country
exports.updateCountry = async (req, res) => {
try {
const { slug } = req.params;
let visaData = await getVisaData();
if (!visaData.hero || !visaData.hero.summaryList) {
return res.status(400).json({ error: "Invalid visa data structure" });
}
const countryIndex = visaData.hero.summaryList.findIndex(
(c) => c.slug === slug,
);
if (countryIndex === -1) {
return res.status(404).json({ error: `Country "${slug}" not found` });
}
const currentCountry = visaData.hero.summaryList[countryIndex];
// Parse services array
let services = currentCountry.services || [];
if (req.body.services) {
if (typeof req.body.services === "string") {
try {
services = JSON.parse(req.body.services);
} catch (e) {
services = [req.body.services];
}
} else if (Array.isArray(req.body.services)) {
services = req.body.services;
}
}
// Parse detailedView if provided (optional)
let detailedView = currentCountry.detailedView;
if (req.body.detailedView) {
try {
detailedView =
typeof req.body.detailedView === "string"
? JSON.parse(req.body.detailedView)
: req.body.detailedView;
} catch (e) {
console.warn("Could not parse detailedView");
}
}
// Update country data
const updatedCountry = {
...currentCountry,
name: req.body.name || currentCountry.name,
slug: req.body.slug || currentCountry.slug, // Allow slug update
icon: req.body.icon || currentCountry.icon,
services: services,
...(detailedView && { detailedView }),
};
visaData.hero.summaryList[countryIndex] = updatedCountry;
// Update database
const updatedData = {
...(visaData.toObject ? visaData.toObject() : visaData),
};
if (visaData._id) {
await Visa.findByIdAndUpdate(visaData._id, updatedData, { new: true });
} else {
await Visa.create(updatedData);
}
console.log(`✅ Country "${updatedCountry.name}" updated successfully`);
res.json({
success: true,
message: `Country "${updatedCountry.name}" updated successfully`,
country: updatedCountry,
});
} catch (err) {
console.error("Update country error:", err);
res.status(500).json({ error: err.message });
}
};
// Delete country
exports.deleteCountry = async (req, res) => {
try {
const { slug } = req.params;
let visaData = await getVisaData();
if (!visaData.hero || !visaData.hero.summaryList) {
return res.status(400).json({ error: "Invalid visa data structure" });
}
const countryIndex = visaData.hero.summaryList.findIndex(
(c) => c.slug === slug,
);
if (countryIndex === -1) {
return res.status(404).json({ error: `Country "${slug}" not found` });
}
const deletedCountry = visaData.hero.summaryList[countryIndex];
visaData.hero.summaryList.splice(countryIndex, 1);
// Update database
const updatedData = {
...(visaData.toObject ? visaData.toObject() : visaData),
};
if (visaData._id) {
await Visa.findByIdAndUpdate(visaData._id, updatedData, { new: true });
} else {
await Visa.create(updatedData);
}
console.log(`✅ Country "${deletedCountry.name}" deleted successfully`);
res.json({
success: true,
message: `Country "${deletedCountry.name}" deleted successfully`,
});
} catch (err) {
console.error("Delete country error:", err);
res.status(500).json({ error: err.message });
}
};
// -------------------- Public API Exports --------------------
// API to get all visa data for frontend
exports.api = async (req, res) => {
try {
const visaData = await getVisaData();
if (!visaData) {
return res.status(404).json({
success: false,
error: "Visa data not found",
data: null,
});
}
const heroData = visaData?.hero;
// 2. Lấy riêng phần hero (Dùng biến mới, không gán đè vào const)
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(heroData, baseUrl);
return res.json({
success: true,
hero: processedData,
});
} catch (err) {
console.error("Visa API error:", err);
res.status(500).json({
success: false,
error: "Error loading visa data",
});
}
};
// API to get all countries (summaryList only)
exports.apiCountries = async (req, res) => {
try {
const visaData = await getVisaData();
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
return res.status(404).json({
success: false,
error: "Countries data not found",
data: null,
});
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
// 1. Lọc bỏ 'viewDetail' khỏi từng quốc gia trong danh sách
const filteredCountries = visaData.hero.summaryList.map((item) => {
// Dùng destructuring để tách viewDetail ra và gom phần còn lại vào countryInfo
const { detailedView, ...countryInfo } = item;
return countryInfo;
});
// 2. Gắn baseUrl vào ảnh cho danh sách đã lọc
const processedData = addBaseUrlToImages(filteredCountries, baseUrl);
return res.json({
success: true,
data: processedData, // Lúc này data chỉ chứa thông tin quốc gia, không có viewDetail
});
} catch (err) {
console.error("Countries API error:", err);
res.status(500).json({
success: false,
error: "Error loading countries data",
});
}
};
// API to get single country by slug
exports.apiCountry = async (req, res) => {
try {
const { slug } = req.params;
const visaData = await getVisaData();
if (!visaData || !visaData.hero || !visaData.hero.summaryList) {
return res.status(404).json({
success: false,
error: "Visa data not found",
data: null,
});
}
// 1. Tìm quốc gia khớp với slug
const country = visaData.hero.summaryList.find((c) => c.slug === slug);
// 2. Kiểm tra nếu không thấy quốc gia hoặc quốc gia đó không có viewDetail
if (!country || !country.viewDetail) {
return res.status(404).json({
success: false,
error: `Detailed information for country "${slug}" not found`,
data: null,
});
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
// 3. Chỉ lấy phần chi tiết (detailed view)
// Lưu ý: Chúng ta copy ra object mới để tránh tham chiếu dữ liệu gốc
const detailedData = JSON.parse(JSON.stringify(country.viewDetail));
// 4. Gắn baseUrl vào các ảnh nằm trong phần chi tiết này
const processedData = addBaseUrlToImages(detailedData, baseUrl);
return res.json({
success: true,
data: processedData, // Trả về nội dung của detailedView
});
} catch (err) {
console.error("Visa country API error:", err);
res.status(500).json({
success: false,
error: "Error loading country detailed data",
});
}
};
// API to get hero data (title + summaryList)
exports.apiHero = async (req, res) => {
try {
const visaData = await getVisaData();
// 1. Kiểm tra dữ liệu gốc
if (!visaData || !visaData.hero) {
return res.status(404).json({
success: false,
error: "Hero data not found",
data: null,
});
}
const { summaryList, ...heroData } = JSON.parse(
JSON.stringify(visaData.hero),
);
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(heroData, baseUrl);
return res.json({
success: true,
data: processedData,
});
} catch (err) {
console.error("Visa hero API error:", err);
res.status(500).json({
success: false,
error: "Error loading hero data",
});
}
};

300
data/visa.json Normal file
View File

@@ -0,0 +1,300 @@
{
"hero": {
"title": "Visa Service",
"summaryList": [
{
"id": 1,
"name": "France",
"slug": "france",
"icon": "/img/home-2/visa/03.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
],
"detailedView": {
"activeCountry": {
"id": 1,
"name": "United States of America ",
"title": "COUNTRY USA",
"mainImage": "/img/inner-page/country-details/details-1.jpg",
"description": "The United States is one of the most popular destinations for international students and immigrants, offering world-class universities, diverse cultural experiences, and countless career opportunities...",
"additionalInfo": "Our consultancy provides complete guidance for study visas, work permits, and permanent residency pathways tailored to your goals.",
"tagline": "Over the last 35 Years we made an impact that is strong & we have long way to go.",
"visaTypes": [
{
"category": "Tourist & Work",
"items": [
{
"title": "Tourist Visa",
"description": "Broad term that can refer to various aspects of interconnectedness"
},
{
"title": "Work Permit",
"description": "Broad term that can refer to various aspects of interconnectedness"
}
]
},
{
"category": "Student & Family",
"items": [
{
"title": "Student",
"description": "Broad term that can refer to various aspects of interconnectedness"
},
{
"title": "Tourist Visa",
"description": "Broad term that can refer to various aspects of interconnectedness"
}
]
}
],
"visaProcess": {
"title": "USA Visa Process",
"steps": [
{
"number": "01",
"title": "Consultation & Eligibility Check",
"description": "Our experts review your profile and visa requirements."
},
{
"number": "02",
"title": "Application Preparation",
"description": "We help with document collection, form filling, and statement drafting."
},
{
"number": "03",
"title": "Submission",
"description": "Visa application is submitted online with required fees."
},
{
"number": "04",
"title": "Interview Guidance",
"description": "Get training and mock sessions for embassy interview."
},
{
"number": "05",
"title": "Approval & Travel",
"description": "Once approved, we provide travel and pre-departure guidance."
}
]
},
"gallery": [
"/img/inner-page/country-details/details-2.jpg",
"/img/inner-page/country-details/details-3.png"
],
"visaCategories": {
"title": "Types of USA Visas",
"steps": [
[
"Student Visa (F1, M1, J1)",
"Work Visa (H1B, L1)",
"Tourist Visa (B1/B2)"
],
[
"Family/Spouse Visa (K1, IR1, F2A)",
"Green Card / Immigrant Visa"
]
]
},
"visaService": {
"title": "Our USA Visa Service Options",
"steps": [
{
"number": "01",
"title": "Consultation & Eligibility Check",
"description": "Our experts review your profile and visa requirements."
},
{
"number": "02",
"title": "Application Preparation",
"description": "We help with document collection, form filling, and statement drafting."
},
{
"number": "03",
"title": "Submission",
"description": "Visa application is submitted online with required fees."
},
{
"number": "04",
"title": "Interview Guidance",
"description": "Get training and mock sessions for embassy interview."
},
{
"number": "05",
"title": "Approval & Travel",
"description": "Once approved, we provide travel and pre-departure guidance."
}
]
}
},
"relatedCountries": [
{
"id": 1,
"name": "Canada",
"icon": "/img/inner-page/country-details/01.png"
},
{
"id": 2,
"name": "USA",
"icon": "/img/inner-page/country-details/02.png"
},
{
"id": 3,
"name": "USA",
"icon": "/img/inner-page/country-details/03.png"
},
{
"id": 4,
"name": "Saint Helena",
"icon": "/img/inner-page/country-details/05.png"
},
{
"id": 5,
"name": "Iran",
"icon": "/img/inner-page/country-details/06.png"
},
{
"id": 6,
"name": "Spain",
"icon": "/img/inner-page/country-details/07.png"
},
{
"id": 7,
"name": "Japan",
"icon": "/img/inner-page/country-details/08.png"
}
],
"contactInfo": {
"img": "/img/inner-page/country-details/bg.jpg",
"sectionTitle": "Visa & Immigration",
"helpText": "Need Help? Book Lab Visit",
"phone": {
"label": "Call Us",
"value": "+009 438 222 9540",
"link": "tel:+0094382229540"
},
"email": {
"label": "Mail Us",
"value": "infor@xridergamil.com",
"link": "mailto:infor@xridergamil.com"
},
"location": {
"label": "Location",
"address": "Toronto, Montreal, City 2026"
}
}
}
},
{
"id": 2,
"name": "UK",
"slug": "uk",
"icon": "/img/home-2/visa/11.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 3,
"name": "Canada",
"slug": "canada",
"icon": "/img/home-2/visa/02.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 4,
"name": "Germany",
"slug": "germany",
"icon": "/img/home-2/visa/12.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 5,
"name": "Spain",
"slug": "spain",
"icon": "/img/home-2/visa/13.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 6,
"name": "South Korea",
"slug": "south-korea",
"icon": "/img/home-2/visa/14.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 7,
"name": "Japan",
"slug": "japan",
"icon": "/img/home-2/visa/15.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 8,
"name": "Croatia",
"slug": "croatia",
"icon": "/img/home-2/visa/16.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 9,
"name": "England",
"slug": "england",
"icon": "/img/home-2/visa/17.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
},
{
"id": 10,
"name": "Indonesia",
"slug": "indonesia",
"icon": "/img/home-2/visa/18.png",
"services": [
"Student Visa & Admission",
"Work Visa H1B",
"Work permit for Canada",
"Student Visa for Canada"
]
}
]
}
}

View File

@@ -0,0 +1,233 @@
// models/visa.js
const mongoose = require("mongoose");
// ==================== SCHEMAS ====================
// VisaItem Schema
const VisaItemSchema = new mongoose.Schema(
{
title: { type: String, default: "" },
description: { type: String, default: "" },
},
{ _id: false },
);
// VisaTypeCategory Schema
const VisaTypeCategorySchema = new mongoose.Schema(
{
category: { type: String, default: "" },
items: [VisaItemSchema],
},
{ _id: false },
);
// VisaProcessStep Schema
const VisaProcessStepSchema = new mongoose.Schema(
{
number: { type: String, default: "" },
title: { type: String, default: "" },
description: { type: String, default: "" },
},
{ _id: false },
);
// VisaProcess Schema
const VisaProcessSchema = new mongoose.Schema(
{
title: { type: String, default: "" },
steps: [VisaProcessStepSchema],
},
{ _id: false },
);
// VisaCategory Schema
const VisaCategorySchema = new mongoose.Schema(
{
title: { type: String, default: "" },
steps: {
type: [[String]],
default: [],
},
},
{ _id: false },
);
// VisaService Schema
const VisaServiceSchema = new mongoose.Schema(
{
title: { type: String, default: "" },
steps: [VisaProcessStepSchema],
},
{ _id: false },
);
// RelatedCountry Schema
const RelatedCountrySchema = new mongoose.Schema(
{
id: { type: Number, default: 0 },
name: { type: String, default: "" },
icon: { type: String, default: "" },
},
{ _id: false },
);
// Phone Schema
const PhoneSchema = new mongoose.Schema(
{
label: { type: String, default: "" },
value: { type: String, default: "" },
link: { type: String, default: "" },
},
{ _id: false },
);
// Email Schema
const EmailSchema = new mongoose.Schema(
{
label: { type: String, default: "" },
value: { type: String, default: "" },
link: { type: String, default: "" },
},
{ _id: false },
);
// Location Schema
const LocationSchema = new mongoose.Schema(
{
label: { type: String, default: "" },
address: { type: String, default: "" },
},
{ _id: false },
);
// ContactInfo Schema
const ContactInfoSchema = new mongoose.Schema(
{
img: { type: String, default: "" },
sectionTitle: { type: String, default: "" },
helpText: { type: String, default: "" },
phone: {
type: PhoneSchema,
default: () => ({}),
},
email: {
type: EmailSchema,
default: () => ({}),
},
location: {
type: LocationSchema,
default: () => ({}),
},
},
{ _id: false },
);
// ActiveCountry Schema
const ActiveCountrySchema = new mongoose.Schema(
{
id: { type: Number, default: 0 },
name: { type: String, default: "" },
title: { type: String, default: "" },
mainImage: { type: String, default: "" },
description: { type: String, default: "" },
additionalInfo: { type: String, default: "" },
tagline: { type: String, default: "" },
visaTypes: [VisaTypeCategorySchema],
visaProcess: {
type: VisaProcessSchema,
default: null,
},
gallery: {
type: [String],
default: [],
},
visaCategories: {
type: VisaCategorySchema,
default: null,
},
visaService: {
type: VisaServiceSchema,
default: null,
},
},
{ _id: false },
);
// DetailedView Schema
const DetailedViewSchema = new mongoose.Schema(
{
activeCountry: {
type: ActiveCountrySchema,
default: null,
},
relatedCountries: {
type: [RelatedCountrySchema],
default: [],
},
contactInfo: {
type: ContactInfoSchema,
default: null,
},
},
{ _id: false },
);
// ==================== MAIN VISA COUNTRY SCHEMA ====================
// Main VisaCountry Schema (Individual country object)
const VisaCountrySchema = new mongoose.Schema(
{
id: { type: Number, required: true, index: true },
name: { type: String, required: true, index: true },
slug: { type: String, required: true, unique: true, index: true },
icon: { type: String, default: "" },
services: {
type: [String],
default: [],
},
detailedView: {
type: DetailedViewSchema,
default: null,
},
},
{ _id: false },
);
// ==================== HERO SCHEMA ====================
const HeroSchema = new mongoose.Schema(
{
title: { type: String, default: "Visa" },
summaryList: {
type: [VisaCountrySchema],
default: [],
},
},
{ _id: false },
);
// ==================== MAIN VISA SCHEMA ====================
const visaDataSchema = new mongoose.Schema(
{
hero: {
type: HeroSchema,
default: () => ({ title: "Visa", summaryList: [] }),
},
},
{
timestamps: true,
},
);
// ==================== INDEXES ====================
visaDataSchema.index({ "hero.summaryList.slug": 1 });
visaDataSchema.index({ "hero.summaryList.id": 1 });
visaDataSchema.index({ "hero.summaryList.name": 1 });
// ==================== MODEL ====================
module.exports = mongoose.models.Visa || mongoose.model("Visa", visaDataSchema);

View File

@@ -16,7 +16,7 @@ const settingController = require("../controllers/settingController");
const faqController = require("../controllers/faqController"); // Thêm import này
const termsController = require("../controllers/termsController");
const travelController = require("../controllers/travelController");
const visaController = require("../controllers/visaController");
const { upload, uploadVideo, convertToWebp } = require("../middleware/upload");
const safetyController = require("../controllers/safetyController");
const insuranceController = require("../controllers/insuranceController");
@@ -161,12 +161,12 @@ router.get(
router.get(
"/contact/submissions",
ensureAuthenticated,
contactController.getSubmissions
contactController.getSubmissions,
);
router.put(
"/contact/submissions/:id",
ensureAuthenticated,
contactController.updateSubmissionStatus
contactController.updateSubmissionStatus,
);
// Appointment management
@@ -174,34 +174,46 @@ const appointmentController = require("../controllers/appointmentController");
router.get(
"/appointments",
ensureAuthenticated,
appointmentController.getAppointments
appointmentController.getAppointments,
);
router.get(
"/appointments/:id",
ensureAuthenticated,
appointmentController.getAppointmentById
appointmentController.getAppointmentById,
);
router.put(
"/appointments/:id",
ensureAuthenticated,
appointmentController.updateAppointmentStatus
appointmentController.updateAppointmentStatus,
);
router.delete(
"/appointments/:id",
ensureAuthenticated,
appointmentController.deleteAppointment
appointmentController.deleteAppointment,
);
// Appointment CMS page management
router.get("/appointment", ensureAuthenticated, appointmentController.index);
router.post("/appointment/update", ensureAuthenticated, appointmentController.update);
router.get("/appointment/data", ensureAuthenticated, appointmentController.getAppointmentData);
router.post(
"/appointment/update",
ensureAuthenticated,
appointmentController.update,
);
router.get(
"/appointment/data",
ensureAuthenticated,
appointmentController.getAppointmentData,
);
// Pricing CMS page management
const pricingController = require("../controllers/pricingController");
router.get("/pricing", ensureAuthenticated, pricingController.index);
router.post("/pricing/update", ensureAuthenticated, pricingController.update);
router.get("/pricing/data", ensureAuthenticated, pricingController.getPricingData);
router.get(
"/pricing/data",
ensureAuthenticated,
pricingController.getPricingData,
);
// Activity CRUD routes
router.get("/activity", ensureAuthenticated, activityController.index);
@@ -373,6 +385,8 @@ router.post(
ensureAuthenticated,
insuranceController.update,
);
<<<<<<< HEAD
=======
// Service routes
router.get("/service", ensureAuthenticated, serviceController.index);
@@ -399,11 +413,12 @@ router.post(
serviceController.updateDetails,
);
>>>>>>> a255d09ef0a6eb0c487595aac19cefbf729d78a2
// Test Image Paths route
router.get("/test-images", ensureAuthenticated, (req, res) => {
const fs = require('fs');
const path = require('path');
const campLocationData = require('../data/camp-location.json');
const fs = require("fs");
const path = require("path");
const campLocationData = require("../data/camp-location.json");
// Collect all image paths
const imagePaths = [];
@@ -454,14 +469,36 @@ router.get("/test-images", ensureAuthenticated, (req, res) => {
});
}
res.render('admin/test-images', {
layout: 'layouts/admin',
title: 'Test Image Paths',
res.render("admin/test-images", {
layout: "layouts/admin",
title: "Test Image Paths",
images: imagePaths,
user: req.session.user,
});
});
// Display visa management page
router.get("/visa", visaController.index);
// Update hero title
router.post("/visa/update", ensureAuthenticated, visaController.update);
// Add new country
router.post("/visa/add", ensureAuthenticated, visaController.addCountry);
// Update single country
router.put(
"/visa/update/:slug",
ensureAuthenticated,
visaController.updateCountry,
);
// Delete country
router.delete(
"/delete/:slug",
ensureAuthenticated,
visaController.deleteCountry,
);
// Blog routes
// Blog Management Routes
router.get("/blog", ensureAuthenticated, blogController.index);

View File

@@ -8,7 +8,7 @@ const headerController = require("../controllers/headerController");
const footerController = require("../controllers/footerController");
const contactController = require("../controllers/contactController");
const faqController = require("../controllers/faqController");
const visaController = require("../controllers/visaController");
const safetyController = require("../controllers/safetyController");
const campLocationController = require("../controllers/campLocationController");
// Booking flow removed

View File

@@ -0,0 +1,336 @@
// scripts/migrateVisa.js
require("dotenv").config();
const fs = require("fs").promises;
const path = require("path");
const mongoose = require("mongoose");
const Visa = require("../models/visa");
// 1. Đọc file JSON
async function loadVisaData() {
const filePath = path.join(__dirname, "..", "data", "visa.json");
const raw = await fs.readFile(filePath, "utf8");
return JSON.parse(raw);
}
// 2. Hàm Transform: Đổ dữ liệu từ JSON vào đúng Schema
function transformVisa(sourceData) {
// JSON có structure hero.title và hero.summaryList
return {
hero: {
title: sourceData.hero?.title || "Visa",
summaryList: Array.isArray(sourceData.hero?.summaryList)
? sourceData.hero.summaryList.map((country) =>
transformCountry(country),
)
: [],
},
updatedAt: new Date(),
};
}
// Helper function: Transform individual country
function transformCountry(source) {
return {
id: source.id || 0,
name: source.name || "",
slug: source.slug || "",
icon: source.icon || "",
services: Array.isArray(source.services) ? source.services : [],
detailedView: source.detailedView
? transformDetailedView(source.detailedView)
: null,
};
}
// Helper function: Transform DetailedView
function transformDetailedView(source) {
return {
activeCountry: source.activeCountry
? transformActiveCountry(source.activeCountry)
: null,
relatedCountries: Array.isArray(source.relatedCountries)
? source.relatedCountries.map((country) => ({
id: country.id || 0,
name: country.name || "",
icon: country.icon || "",
}))
: [],
contactInfo: source.contactInfo
? transformContactInfo(source.contactInfo)
: null,
};
}
// Helper function: Transform ActiveCountry
function transformActiveCountry(source) {
return {
id: source.id || 0,
name: source.name || "",
title: source.title || "",
mainImage: source.mainImage || "",
description: source.description || "",
additionalInfo: source.additionalInfo || "",
tagline: source.tagline || "",
visaTypes: Array.isArray(source.visaTypes)
? source.visaTypes.map((type) => ({
category: type.category || "",
items: Array.isArray(type.items)
? type.items.map((item) => ({
title: item.title || "",
description: item.description || "",
}))
: [],
}))
: [],
visaProcess: source.visaProcess
? transformVisaProcess(source.visaProcess)
: null,
gallery: Array.isArray(source.gallery) ? source.gallery : [],
visaCategories: source.visaCategories
? transformVisaCategories(source.visaCategories)
: null,
visaService: source.visaService
? transformVisaService(source.visaService)
: null,
};
}
// Helper function: Transform VisaProcess
function transformVisaProcess(source) {
return {
title: source.title || "",
steps: Array.isArray(source.steps)
? source.steps.map((step) => ({
number: step.number || "",
title: step.title || "",
description: step.description || "",
}))
: [],
};
}
// Helper function: Transform VisaCategories
function transformVisaCategories(source) {
return {
title: source.title || "",
steps: Array.isArray(source.steps) ? source.steps : [],
};
}
// Helper function: Transform VisaService
function transformVisaService(source) {
return {
title: source.title || "",
steps: Array.isArray(source.steps)
? source.steps.map((step) => ({
number: step.number || "",
title: step.title || "",
description: step.description || "",
}))
: [],
};
}
// Helper function: Transform ContactInfo
function transformContactInfo(source) {
return {
img: source.img || "",
sectionTitle: source.sectionTitle || "Visa & Immigration",
helpText: source.helpText || "Need Help?",
phone: {
label: source.phone?.label || "Call Us",
value: source.phone?.value || "",
link: source.phone?.link || "",
},
email: {
label: source.email?.label || "Mail Us",
value: source.email?.value || "",
link: source.email?.link || "",
},
location: {
label: source.location?.label || "Location",
address: source.location?.address || "",
},
};
}
// 3. Validate data before migration
function validateVisaData(visaData) {
const errors = [];
if (!visaData.hero) {
errors.push("Missing hero section");
}
if (!visaData.hero?.title) {
console.warn("⚠️ Hero title is missing, using default 'Visa'");
}
if (!Array.isArray(visaData.hero?.summaryList)) {
errors.push("summaryList must be an array");
} else if (visaData.hero.summaryList.length === 0) {
errors.push("summaryList is empty");
} else {
// Validate each country
visaData.hero.summaryList.forEach((country, idx) => {
if (!country.name || !country.slug) {
errors.push(`Country at index ${idx}: missing name or slug`);
}
if (country.detailedView) {
if (!country.detailedView.activeCountry) {
console.warn(
`⚠️ Country "${country.name}" (${idx}): missing activeCountry details`,
);
}
if (!Array.isArray(country.detailedView.relatedCountries)) {
errors.push(
`Country "${country.name}" (${idx}): relatedCountries must be array`,
);
}
}
});
}
return errors;
}
// Helper function: Get data summary
function getDataSummary(visaData) {
const summary = {
heroTitle: visaData.hero?.title || "N/A",
totalCountries: visaData.hero?.summaryList?.length || 0,
withDetails: 0,
withoutDetails: 0,
byCountry: [],
};
if (visaData.hero?.summaryList) {
visaData.hero.summaryList.forEach((country) => {
const hasDetails = !!country.detailedView?.activeCountry;
const relatedCount = country.detailedView?.relatedCountries?.length || 0;
if (hasDetails) {
summary.withDetails++;
} else {
summary.withoutDetails++;
}
summary.byCountry.push({
name: country.name,
slug: country.slug,
hasDetails,
relatedCountries: relatedCount,
services: country.services?.length || 0,
});
});
}
return summary;
}
// 4. Chạy Migration
async function migrate() {
try {
// Kết nối DB
console.log("🔗 Connecting to MongoDB...");
await mongoose.connect(process.env.MONGODB_URI);
console.log("✅ Connected to MongoDB\n");
// A. Lấy dữ liệu thô
console.log("📖 Loading visa data from JSON...");
const rawData = await loadVisaData();
console.log("✅ JSON data loaded\n");
// B. Chuẩn hóa dữ liệu theo Schema
console.log("🔄 Transforming data structure...");
const visaData = transformVisa(rawData);
console.log("✅ Data transformation completed\n");
// C. Validate dữ liệu
console.log("✔️ Validating data structure...");
const errors = validateVisaData(visaData);
if (errors.length > 0) {
console.error("❌ Validation errors found:");
errors.forEach((err, idx) => console.error(` ${idx + 1}. ${err}`));
process.exit(1);
}
console.log("✅ Data validation passed\n");
// D. Get summary
const summary = getDataSummary(visaData);
console.log("📊 Migration Summary:");
console.log(` Hero Title: "${summary.heroTitle}"`);
console.log(` Total countries: ${summary.totalCountries}`);
console.log(` With details: ${summary.withDetails}`);
console.log(` Without details: ${summary.withoutDetails}`);
console.log(`\n Country Details:`);
summary.byCountry.forEach((country) => {
const detailBadge = country.hasDetails ? "✅" : "❌";
const detailText = country.hasDetails
? `(${country.relatedCountries} related)`
: "(basic only)";
console.log(
` ${detailBadge} ${country.name.padEnd(20)} (${country.slug.padEnd(
12,
)}) - ${country.services} services ${detailText}`,
);
});
console.log("");
// E. Lưu vào DB (Upsert: Có rồi thì update, chưa có thì tạo)
const existingDoc = await Visa.findOne().sort({ updatedAt: -1 });
if (existingDoc) {
console.log("📝 Updating existing Visa document...");
console.log(` Document ID: ${existingDoc._id}`);
const updated = await Visa.findByIdAndUpdate(
existingDoc._id,
{ $set: visaData },
{ new: true },
);
console.log("✅ Visa document updated successfully");
console.log(` Updated at: ${updated.updatedAt}`);
} else {
console.log("📝 Creating NEW Visa document...");
const newDoc = await Visa.create(visaData);
console.log("✅ Visa document created successfully");
console.log(` Document ID: ${newDoc._id}`);
console.log(` Created at: ${newDoc.createdAt}`);
}
console.log("\n✨ Visa migration completed successfully!");
} catch (error) {
console.error("\n❌ Migration failed:");
console.error(` Error: ${error.message}`);
if (error.name === "ValidationError") {
console.error("\n Validation Errors:");
Object.keys(error.errors).forEach((field) => {
console.error(` - ${field}: ${error.errors[field].message}`);
});
}
if (error.stack) {
console.error("\n📋 Stack trace:");
console.error(error.stack);
}
process.exit(1);
} finally {
await mongoose.connection.close();
console.log("\n🔌 MongoDB connection closed");
process.exit(0);
}
}
// Run migration
console.log("🚀 Starting Visa Migration...\n");
migrate();

View File

@@ -54,9 +54,7 @@ app.use(
);
// Serve other public files
app.use(
express.static(path.join(__dirname, "public")),
);
app.use(express.static(path.join(__dirname, "public")));
// Session configuration
app.use(

1231
views/admin/visa/index.ejs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -807,6 +807,54 @@
</li>
</ul>
<li>
<a
class="dropdown-item <%= currentPath === '/admin/travel' ? 'active' : '' %>"
href="/admin/travel"
>Travel</a
>
</li>
<li>
<a
class="dropdown-item <%= currentPath === '/admin/terms-conditions' ? 'active' : '' %>"
href="/admin/terms-conditions"
>Terms & Conditions</a
>
</li>
</ul>
</li>
<li class="nav-item">
<a
class="nav-link <%= currentPath === '/admin/contact' ? 'active' : '' %>"
href="/admin/contact"
>Contact Us</a
>
</li>
<li class="nav-item">
<a
class="nav-link <%= currentPath === '/admin/camp-location' ? 'active' : '' %>"
href="/admin/camp-location"
>Camp Location</a
>
</li>
<li class="nav-item">
<a
class="nav-link <%= currentPath === '/admin/activity' ? 'active' : '' %>"
href="/admin/activity"
>Activity & Booking</a
>
</li>
<li class="nav-item">
<a
class="nav-link <%= currentPath === '/admin/visa' ? 'active' : '' %>"
href="/admin/visa"
>Visa</a
>
</li>
</ul>
</ul>
<div class="d-flex align-items-center">
<% if (locals.user) { %>