diff --git a/controllers/visaController.js b/controllers/visaController.js new file mode 100644 index 0000000..77ef292 --- /dev/null +++ b/controllers/visaController.js @@ -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", + }); + } +}; diff --git a/data/visa.json b/data/visa.json new file mode 100644 index 0000000..f39e1d5 --- /dev/null +++ b/data/visa.json @@ -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" + ] + } + ] + } +} diff --git a/models/visa.js b/models/visa.js index e69de29..6edae4e 100644 --- a/models/visa.js +++ b/models/visa.js @@ -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); diff --git a/routes/admin.js b/routes/admin.js index b14f98c..9c2ecb8 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -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); diff --git a/routes/index.js b/routes/index.js index 7b58529..daffad6 100644 --- a/routes/index.js +++ b/routes/index.js @@ -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 diff --git a/scripts/2026_02_03_645124_visa.js b/scripts/2026_02_03_645124_visa.js new file mode 100644 index 0000000..d3c031e --- /dev/null +++ b/scripts/2026_02_03_645124_visa.js @@ -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(); diff --git a/server.js b/server.js index 4c46226..01be70a 100644 --- a/server.js +++ b/server.js @@ -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( diff --git a/views/admin/visa/index.ejs b/views/admin/visa/index.ejs new file mode 100644 index 0000000..488d279 --- /dev/null +++ b/views/admin/visa/index.ejs @@ -0,0 +1,1231 @@ + + +
+
+
+
+ + Visa Management +
+ +
+ + +
+ + + + + + + + + + + + <% if (data && data.hero && data.hero.summaryList) { %> + <% data.hero.summaryList.forEach(function(country) { %> + + + + + + + + <% }); %> + <% } %> + +
IDFlagCountry NameServicesActions
+ #<%= String(country.id).padStart(3, "0" ) %> + +
+ <%= country.name %> +
+
+ <%= country.name %> + +
+ <% country.services.forEach(function(service) { %> + + <%= service %> + + <% }); %> +
+
+
+ + + +
+
+
+ + + + + + +
+
+ \ No newline at end of file diff --git a/views/layouts/main.ejs b/views/layouts/main.ejs index 6111b28..59ce255 100644 --- a/views/layouts/main.ejs +++ b/views/layouts/main.ejs @@ -807,6 +807,54 @@ +
  • + Travel +
  • +
  • + Terms & Conditions +
  • + + + + + + + + + +
    <% if (locals.user) { %>