From 6f82b2c35c412238ffed69efe549a6a82f577d5e Mon Sep 17 00:00:00 2001 From: r2xrzh9q2z-lab Date: Wed, 4 Feb 2026 09:17:57 +0700 Subject: [PATCH] UI Visa-VisaDetail --- controllers/visaController.js | 557 +++++++++++++ data/visa.json | 300 +++++++ models/visa.js | 233 ++++++ routes/admin.js | 184 +++-- routes/index.js | 49 +- scripts/2026_02_03_645124_visa.js | 336 ++++++++ server.js | 11 +- views/admin/visa/index.ejs | 1231 +++++++++++++++++++++++++++++ views/layouts/main.ejs | 7 + 9 files changed, 2823 insertions(+), 85 deletions(-) create mode 100644 controllers/visaController.js create mode 100644 data/visa.json create mode 100644 scripts/2026_02_03_645124_visa.js create mode 100644 views/admin/visa/index.ejs 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 d41e56d..a22528e 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"); @@ -46,28 +46,28 @@ router.get("/about-us", ensureAuthenticated, aboutUsController.index); router.get( "/about-us/create", ensureAuthenticated, - aboutUsController.createForm + aboutUsController.createForm, ); router.post("/about-us/create", ensureAuthenticated, aboutUsController.create); router.get( "/about-us/:id/edit", ensureAuthenticated, - aboutUsController.editForm + aboutUsController.editForm, ); router.post( "/about-us/:id/update", ensureAuthenticated, - aboutUsController.update + aboutUsController.update, ); router.post( "/about-us/:id/delete", ensureAuthenticated, - aboutUsController.delete + aboutUsController.delete, ); router.get( "/about-us/:id/preview", ensureAuthenticated, - aboutUsController.preview + aboutUsController.preview, ); // Booking admin CRUD removed @@ -77,7 +77,7 @@ router.get("/form", ensureAuthenticated, formController.index); router.post( "/form/update", ensureAuthenticated, - formController.updateDefaultForm + formController.updateDefaultForm, ); // Upload routes @@ -93,23 +93,23 @@ router.post( ensureAuthenticated, upload.single("image"), // convertToWebp, // Disabled to keep original image format (JPG/PNG) - uploadController.uploadImage + uploadController.uploadImage, ); router.post( "/upload/video", ensureAuthenticated, uploadVideo.single("video"), - uploadController.uploadVideo + uploadController.uploadVideo, ); router.post( "/upload/update-path", ensureAuthenticated, - uploadController.updateImagePath + uploadController.updateImagePath, ); router.post( "/upload/delete", ensureAuthenticated, - uploadController.deleteImage + uploadController.deleteImage, ); // Header routes @@ -118,22 +118,22 @@ router.post("/header/update", ensureAuthenticated, headerController.update); router.post( "/header/update-menu", ensureAuthenticated, - headerController.updateMenu + headerController.updateMenu, ); router.get( "/header/menu-tree", ensureAuthenticated, - headerController.getMenuTree + headerController.getMenuTree, ); router.get( "/header/programmes/:menuId", ensureAuthenticated, - headerController.getProgrammesByMenuId + headerController.getProgrammesByMenuId, ); router.get( "/header/menu-item/:menuId", ensureAuthenticated, - headerController.getMenuItem + headerController.getMenuItem, ); router.get("/header/data", ensureAuthenticated, headerController.getHeaderData); @@ -148,7 +148,7 @@ router.post("/contact/update", ensureAuthenticated, contactController.update); router.get( "/contact/data", ensureAuthenticated, - contactController.getContactData + contactController.getContactData, ); // Activity CRUD routes @@ -156,81 +156,81 @@ router.get("/activity", ensureAuthenticated, activityController.index); router.get( "/activity/create", ensureAuthenticated, - activityController.createForm + activityController.createForm, ); router.post("/activity/create", ensureAuthenticated, activityController.create); // Update filters (place before any parameterized /activity/:id routes to avoid route collision) router.post( "/activity/filters/update", ensureAuthenticated, - activityController.updateFilters + activityController.updateFilters, ); // Update hero (global hero section for activities) router.post( "/activity/hero/update", ensureAuthenticated, - activityController.updateHero + activityController.updateHero, ); router.get( "/activity/:id/edit", ensureAuthenticated, - activityController.editForm + activityController.editForm, ); router.post( "/activity/:id/update", ensureAuthenticated, - activityController.update + activityController.update, ); router.post( "/activity/:id/delete", ensureAuthenticated, - activityController.delete + activityController.delete, ); router.post( "/activity/:id/toggle-status", ensureAuthenticated, - activityController.toggleStatus + activityController.toggleStatus, ); // Update display order router.post( "/activity/update-order", ensureAuthenticated, - activityController.updateOrder + activityController.updateOrder, ); // Booking submissions routes router.get( "/activity/:id/bookings/count", ensureAuthenticated, - activityController.getBookingCount + activityController.getBookingCount, ); router.get( "/activity/:id/bookings", ensureAuthenticated, - activityController.getBookingSubmissions + activityController.getBookingSubmissions, ); router.get( "/activity/:id/bookings/export", ensureAuthenticated, - activityController.exportBookingData + activityController.exportBookingData, ); // Export all bookings (across all activities) router.get( "/bookings/export-all", ensureAuthenticated, - activityController.exportAllBookingsData + activityController.exportAllBookingsData, ); // Update booking submission router.put( "/bookings/:bookingId", ensureAuthenticated, - bookingSubmissionController.updateBookingSubmission + bookingSubmissionController.updateBookingSubmission, ); // Delete booking submission router.delete( "/bookings/:bookingId", ensureAuthenticated, - bookingSubmissionController.deleteBookingSubmission + bookingSubmissionController.deleteBookingSubmission, ); // Update filters @@ -239,7 +239,7 @@ router.delete( router.get( "/activity/:id/preview", ensureAuthenticated, - activityController.preview + activityController.preview, ); // FAQ routes - Thêm vào đây @@ -250,8 +250,16 @@ router.get("/faq/api", faqController.api); // API routes cho quản lý FAQ items (AJAX calls) router.post("/faq/api/add-faq", ensureAuthenticated, faqController.addFAQ); -router.put("/faq/api/update-faq-item/:sectionId/:faqId", ensureAuthenticated, faqController.updateFAQItem); -router.delete("/faq/api/delete-faq-item/:sectionId/:faqId", ensureAuthenticated, faqController.deleteFAQItem); +router.put( + "/faq/api/update-faq-item/:sectionId/:faqId", + ensureAuthenticated, + faqController.updateFAQItem, +); +router.delete( + "/faq/api/delete-faq-item/:sectionId/:faqId", + ensureAuthenticated, + faqController.deleteFAQItem, +); router.get("/terms-conditions", ensureAuthenticated, termsController.index); router.post("/terms/update", ensureAuthenticated, termsController.update); router.get("/terms/data", ensureAuthenticated, termsController.getTermsData); @@ -267,81 +275,135 @@ router.get("/travel/api", travelController.api); router.get("/travel/seed", ensureAuthenticated, travelController.seed); // API routes cho quản lý FAQ sections (AJAX calls) -router.post("/faq/api/add-section", ensureAuthenticated, faqController.addFAQSection); -router.put("/faq/api/update-section/:sectionId", ensureAuthenticated, faqController.updateFAQSection); -router.delete("/faq/api/delete-section/:sectionId", ensureAuthenticated, faqController.deleteFAQSection); -router.post("/faq/api/reorder-sections", ensureAuthenticated, faqController.reorderFAQSection); +router.post( + "/faq/api/add-section", + ensureAuthenticated, + faqController.addFAQSection, +); +router.put( + "/faq/api/update-section/:sectionId", + ensureAuthenticated, + faqController.updateFAQSection, +); +router.delete( + "/faq/api/delete-section/:sectionId", + ensureAuthenticated, + faqController.deleteFAQSection, +); +router.post( + "/faq/api/reorder-sections", + ensureAuthenticated, + faqController.reorderFAQSection, +); // API routes cho sidebar navigation (AJAX calls) -router.put("/faq/api/update-sidebar", ensureAuthenticated, faqController.updateSidebarNav); +router.put( + "/faq/api/update-sidebar", + ensureAuthenticated, + faqController.updateSidebarNav, +); // Safety routes router.get("/safety", ensureAuthenticated, safetyController.index); router.post("/safety/update", ensureAuthenticated, safetyController.update); // Camp Location routes router.get("/camp-location", ensureAuthenticated, campLocationController.index); -router.post("/camp-location/update", ensureAuthenticated, campLocationController.update); +router.post( + "/camp-location/update", + ensureAuthenticated, + campLocationController.update, +); //Insurance routes router.get("/insurance", ensureAuthenticated, insuranceController.index); -router.post("/insurance/update", ensureAuthenticated, insuranceController.update); +router.post( + "/insurance/update", + ensureAuthenticated, + insuranceController.update, +); // 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 = []; - + // Camps images if (campLocationData.camps) { - campLocationData.camps.forEach(camp => { + campLocationData.camps.forEach((camp) => { if (camp.image) { imagePaths.push({ - type: 'Camp', + type: "Camp", name: camp.title, path: camp.image, - exists: fs.existsSync(path.join(__dirname, '../public', camp.image)) + exists: fs.existsSync(path.join(__dirname, "../public", camp.image)), }); } }); } - + // Locations images if (campLocationData.locations) { - campLocationData.locations.forEach(location => { + campLocationData.locations.forEach((location) => { if (location.imageSrc) { imagePaths.push({ - type: 'Location', + type: "Location", name: location.title, path: location.imageSrc, - exists: fs.existsSync(path.join(__dirname, '../public', location.imageSrc)) + exists: fs.existsSync( + path.join(__dirname, "../public", location.imageSrc), + ), }); } - + // Program images if (location.programOptions) { - location.programOptions.forEach(program => { + location.programOptions.forEach((program) => { if (program.imageSrc) { imagePaths.push({ - type: 'Program', + type: "Program", name: program.title, path: program.imageSrc, - exists: fs.existsSync(path.join(__dirname, '../public', program.imageSrc)) + exists: fs.existsSync( + path.join(__dirname, "../public", program.imageSrc), + ), }); } }); } }); } - - 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 + 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, +); module.exports = router; diff --git a/routes/index.js b/routes/index.js index beecd84..16ee43d 100644 --- a/routes/index.js +++ b/routes/index.js @@ -8,12 +8,12 @@ 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 -const insuranceController= require("../controllers/insuranceController"); +const insuranceController = require("../controllers/insuranceController"); const termsController = require("../controllers/termsController"); // <-- IMPORT ĐÃ CÓ const activityController = require("../controllers/activityController"); const travelController = require("../controllers/travelController"); @@ -61,8 +61,7 @@ router.get("/api/activities/:id", activityController.apiDetail); router.get("/api/camp-location", campLocationController.api); // Booking routes removed // Insurance APi route -router.get("/api/insurance", insuranceController.api) - +router.get("/api/insurance", insuranceController.api); router.get("/api/terms", termsController.api); @@ -71,14 +70,14 @@ router.get("/travel", async (req, res) => { try { const Travel = require("../models/travel"); const travel = await Travel.findOne(); - + if (!travel) { return res.status(404).render("errors/404", { title: "Page Not Found", message: "Travel information not found", }); } - + res.render("page/travel", { title: travel.page.title, data: travel.toObject(), @@ -95,33 +94,55 @@ router.get("/api/travel", travelController.api); // Booking submission APIs (public endpoints) router.post("/api/booking/submit", bookingSubmissionController.submitBooking); -router.get("/api/activity/:activityId/sessions", bookingSubmissionController.getAvailableSessions); -router.get("/api/activity/:activityId/session/:sessionId/availability", bookingSubmissionController.getSessionAvailability); +router.get( + "/api/activity/:activityId/sessions", + bookingSubmissionController.getAvailableSessions, +); +router.get( + "/api/activity/:activityId/session/:sessionId/availability", + bookingSubmissionController.getSessionAvailability, +); // New API for creating bookings directly into camp sessions (by program) router.post( "/api/camps/:program/sessions/:sessionId/bookings", - activityController.createSessionBookingByProgram + activityController.createSessionBookingByProgram, ); router.get( "/api/camps/:program/sessions/:sessionId/bookings", - activityController.getSessionBookingsByProgram + activityController.getSessionBookingsByProgram, ); // Keep admin-style update/delete by activityId (protected) if needed -router.put("/api/camps/:activityId/sessions/:sessionId/bookings/:bookingId", activityController.updateSessionBooking); -router.delete("/api/camps/:activityId/sessions/:sessionId/bookings/:bookingId", activityController.deleteSessionBooking); +router.put( + "/api/camps/:activityId/sessions/:sessionId/bookings/:bookingId", + activityController.updateSessionBooking, +); +router.delete( + "/api/camps/:activityId/sessions/:sessionId/bookings/:bookingId", + activityController.deleteSessionBooking, +); // Demo booking form router.get("/demo/booking-form", (req, res) => { - res.sendFile(path.join(__dirname, '../views/demo/booking-form.html')); + res.sendFile(path.join(__dirname, "../views/demo/booking-form.html")); }); // Demo session booking API router.get("/demo/session-booking-api", (req, res) => { - res.sendFile(path.join(__dirname, '../views/demo/session-booking-api.html')); + res.sendFile(path.join(__dirname, "../views/demo/session-booking-api.html")); }); // // API route cho blog detail // router.get('/api/blog-detail', blogDetailController.api); +// ==================== PUBLIC API ROUTES ==================== + +// 1. Đưa các route cụ thể (chi tiết nhất) lên đầu tiên +// 2. Route lấy TOÀN BỘ dữ liệu (phải nằm trên route :slug) +router.get("/api/visa", visaController.api); +router.get("/api/visa/hero", visaController.apiHero); +router.get("/api/visa/countries", visaController.apiCountries); + +// 3. Route lấy chi tiết theo slug (luôn để dưới cùng của nhóm này) +router.get("/api/visa/:slug", visaController.apiCountry); module.exports = router; 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 24d4a0c..cbd07b9 100644 --- a/server.js +++ b/server.js @@ -32,16 +32,7 @@ app.set("layout extractStyles", true); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); -app.use( - "/assets", - (req, res, next) => { - // Cho phép mọi domain truy cập tài nguyên tĩnh - res.header("Access-Control-Allow-Origin", "*"); - res.header("Access-Control-Allow-Methods", "GET"); - next(); - }, - express.static(path.join(__dirname, "assets")), -); +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 3ec9a29..a715c0f 100644 --- a/views/layouts/main.ejs +++ b/views/layouts/main.ejs @@ -796,6 +796,13 @@ >Activity & Booking +