diff --git a/controllers/appointmentController.js b/controllers/appointmentController.js new file mode 100644 index 0000000..076d246 --- /dev/null +++ b/controllers/appointmentController.js @@ -0,0 +1,377 @@ +const AppointmentSubmission = require("../models/appointmentSubmission"); +const Appointment = require("../models/appointment"); + +// ==================== CMS ADMIN FUNCTIONS ==================== + +// Render admin page for appointment management +exports.index = async (req, res) => { + try { + let appointment = await Appointment.findOne({ name: "default" }); + + // If no data in DB, try to load from JSON file + if (!appointment) { + const fs = require("fs"); + const path = require("path"); + const jsonPath = path.join(__dirname, "../data/appointment.json"); + + if (fs.existsSync(jsonPath)) { + const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8")); + appointment = await Appointment.migrateFromJson(jsonData); + } else { + // Create default appointment + appointment = await Appointment.create({ + name: "default", + hero: { + title: "Make Appointment", + backgroundImage: "", + subtitle: "", + heading: "", + description: "", + }, + visaOptions: [], + form: { + heading: "Request Appointment", + fields: [], + submitButton: { + text: "Request Appointment", + icon: "fa-solid fa-arrow-right", + buttonClass: "theme-btn", + }, + }, + }); + } + } + + const { startDate, endDate } = req.query; + const query = {}; + + if (startDate || endDate) { + query.createdAt = {}; + if (startDate) { + query.createdAt.$gte = new Date(startDate); + } + if (endDate) { + // Set end date to end of day + const end = new Date(endDate); + end.setHours(23, 59, 59, 999); + query.createdAt.$lte = end; + } + } + + const submissions = await AppointmentSubmission.find(query).sort({ createdAt: -1 }).limit(50); + + res.render("admin/appointment/index", { + layout: "layouts/main", + title: "Appointment Management", + data: appointment, + submissions, + startDate, + endDate, + user: req.session.user, + frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000", + }); + } catch (err) { + console.error("Error loading appointment admin page:", err); + req.flash("error", "Error loading appointment data"); + res.redirect("/admin/dashboard"); + } +}; + +// Update appointment data +exports.update = async (req, res) => { + try { + const { hero, visaOptions, form } = req.body; + + // Parse JSON strings if needed + const heroData = typeof hero === "string" ? JSON.parse(hero) : hero; + const visaOptionsData = typeof visaOptions === "string" ? JSON.parse(visaOptions) : visaOptions; + const formData = typeof form === "string" ? JSON.parse(form) : form; + + let appointment = await Appointment.findOne({ name: "default" }); + + if (appointment) { + appointment.hero = heroData; + appointment.visaOptions = visaOptionsData; + appointment.form = formData; + await appointment.save(); + } else { + appointment = await Appointment.create({ + name: "default", + hero: heroData, + visaOptions: visaOptionsData, + form: formData, + }); + } + + req.flash("success", "Appointment data updated successfully"); + res.redirect("/admin/appointment"); + } catch (err) { + console.error("Error updating appointment:", err); + req.flash("error", "Error updating appointment data"); + res.redirect("/admin/appointment"); + } +}; + +// API to get appointment data +exports.getAppointmentData = async (req, res) => { + try { + let appointment = await Appointment.findOne({ name: "default" }); + + if (!appointment) { + const fs = require("fs"); + const path = require("path"); + const jsonPath = path.join(__dirname, "../data/appointment.json"); + + if (fs.existsSync(jsonPath)) { + const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8")); + appointment = await Appointment.migrateFromJson(jsonData); + } + } + + res.json({ + success: true, + data: appointment, + }); + } catch (err) { + console.error("Error getting appointment data:", err); + res.status(500).json({ + success: false, + error: "Error loading appointment data", + }); + } +}; + +// Public API to get appointment page data (for frontend) +exports.api = async (req, res) => { + try { + let appointment = await Appointment.findOne({ name: "default" }); + + if (!appointment) { + const fs = require("fs"); + const path = require("path"); + const jsonPath = path.join(__dirname, "../data/appointment.json"); + + if (fs.existsSync(jsonPath)) { + const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8")); + appointment = await Appointment.migrateFromJson(jsonData); + } + } + + if (!appointment) { + return res.status(404).json({ + success: false, + error: "Appointment data not found", + }); + } + + res.json({ + success: true, + data: { + hero: appointment.hero, + visaOptions: appointment.visaOptions, + form: appointment.form, + }, + }); + } catch (err) { + console.error("Error getting appointment API data:", err); + res.status(500).json({ + success: false, + error: "Error loading appointment data", + }); + } +}; + +// ==================== APPOINTMENT SUBMISSIONS API ==================== + +// API để submit appointment form (từ frontend) +exports.submitAppointment = async (req, res) => { + try { + const { name, email, phone, address, appointmentDate, message, visaTypes } = req.body; + + // Validation + if (!name || !email) { + return res.status(400).json({ + success: false, + error: "Name and email are required", + }); + } + + // Create new submission + const submission = new AppointmentSubmission({ + name: name.trim(), + email: email.trim().toLowerCase(), + phone: phone?.trim() || "", + address: address?.trim() || "", + appointmentDate: appointmentDate?.trim() || "", + message: message?.trim() || "", + visaTypes: Array.isArray(visaTypes) ? visaTypes : [], + ipAddress: req.ip || req.connection?.remoteAddress || "", + userAgent: req.get("User-Agent") || "", + }); + + await submission.save(); + + res.status(201).json({ + success: true, + message: "Thank you! Your appointment request has been submitted. We will contact you soon.", + data: { + id: submission._id, + name: submission.name, + email: submission.email, + appointmentDate: submission.appointmentDate, + }, + }); + } catch (err) { + console.error("Error submitting appointment:", err); + + // Handle validation errors + if (err.name === "ValidationError") { + const errors = Object.values(err.errors).map((e) => e.message); + return res.status(400).json({ + success: false, + error: errors.join(", "), + }); + } + + res.status(500).json({ + success: false, + error: "Error submitting appointment. Please try again later.", + }); + } +}; + +// API để lấy danh sách appointments (cho admin) +exports.getAppointments = async (req, res) => { + try { + const { status, page = 1, limit = 20 } = req.query; + + const query = {}; + if (status && ["pending", "confirmed", "completed", "cancelled"].includes(status)) { + query.status = status; + } + + const skip = (parseInt(page) - 1) * parseInt(limit); + + const [appointments, total] = await Promise.all([ + AppointmentSubmission.find(query) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(parseInt(limit)), + AppointmentSubmission.countDocuments(query), + ]); + + res.json({ + success: true, + data: appointments, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total, + totalPages: Math.ceil(total / parseInt(limit)), + }, + }); + } catch (err) { + console.error("Error getting appointments:", err); + res.status(500).json({ + success: false, + error: "Error loading appointments", + }); + } +}; + +// API để cập nhật status của appointment +exports.updateAppointmentStatus = async (req, res) => { + try { + const { id } = req.params; + const { status, notes } = req.body; + + const validStatuses = ["pending", "confirmed", "completed", "cancelled"]; + if (!validStatuses.includes(status)) { + return res.status(400).json({ + success: false, + error: "Invalid status", + }); + } + + const updateData = { status }; + if (notes !== undefined) updateData.notes = notes; + if (status === "confirmed") updateData.confirmedAt = new Date(); + if (status === "completed") updateData.completedAt = new Date(); + + const appointment = await AppointmentSubmission.findByIdAndUpdate( + id, + updateData, + { new: true } + ); + + if (!appointment) { + return res.status(404).json({ + success: false, + error: "Appointment not found", + }); + } + + res.json({ + success: true, + data: appointment, + }); + } catch (err) { + console.error("Error updating appointment:", err); + res.status(500).json({ + success: false, + error: "Error updating appointment", + }); + } +}; + +// API để lấy chi tiết một appointment +exports.getAppointmentById = async (req, res) => { + try { + const { id } = req.params; + const appointment = await AppointmentSubmission.findById(id); + + if (!appointment) { + return res.status(404).json({ + success: false, + error: "Appointment not found", + }); + } + + res.json({ + success: true, + data: appointment, + }); + } catch (err) { + console.error("Error getting appointment:", err); + res.status(500).json({ + success: false, + error: "Error loading appointment", + }); + } +}; + +// API để xóa appointment +exports.deleteAppointment = async (req, res) => { + try { + const { id } = req.params; + const appointment = await AppointmentSubmission.findByIdAndDelete(id); + + if (!appointment) { + return res.status(404).json({ + success: false, + error: "Appointment not found", + }); + } + + res.json({ + success: true, + message: "Appointment deleted successfully", + }); + } catch (err) { + console.error("Error deleting appointment:", err); + res.status(500).json({ + success: false, + error: "Error deleting appointment", + }); + } +}; diff --git a/controllers/contactController.js b/controllers/contactController.js index 8b49ed1..55890f4 100644 --- a/controllers/contactController.js +++ b/controllers/contactController.js @@ -1,5 +1,6 @@ const { addBaseUrlToImages } = require("../utils/imageHelper"); const Contact = require("../models/contact"); +const ContactSubmission = require("../models/contactSubmission"); // Get contact data from MongoDB const getContactData = async () => { @@ -60,6 +61,7 @@ exports.index = async (req, res) => { zoom: 15, location: "", markerTitle: "", + embedUrl: "", tileLayer: { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: "", @@ -70,16 +72,38 @@ exports.index = async (req, res) => { form: { sectionLabel: "", heading: "", + description: "", fields: [], - submitButton: { text: "Send Message" }, + submitButton: { text: "Send Message", icon: "fa-solid fa-arrow-right", buttonClass: "theme-btn style-2" }, }, }; - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + + const { startDate, endDate } = req.query; + const query = {}; + + if (startDate || endDate) { + query.createdAt = {}; + if (startDate) { + query.createdAt.$gte = new Date(startDate); + } + if (endDate) { + // Set end date to end of day + const end = new Date(endDate); + end.setHours(23, 59, 59, 999); + query.createdAt.$lte = end; + } + } + + const submissions = await ContactSubmission.find(query).sort({ createdAt: -1 }).limit(50); + const frontendUrl = process.env.FRONTEND_URL; res.render("admin/contact/index", { title: "Contact Management", layout: "layouts/main", data, + submissions, + startDate, + endDate, frontendUrl, currentPath: req.path, user: req.session.user, @@ -140,6 +164,7 @@ exports.update = async (req, res) => { zoom: 15, location: "", markerTitle: "", + embedUrl: "", tileLayer: { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: "", @@ -150,8 +175,9 @@ exports.update = async (req, res) => { form: formData || { sectionLabel: "", heading: "", + description: "", fields: [], - submitButton: { text: "Send Message" }, + submitButton: { text: "Send Message", icon: "fa-solid fa-arrow-right", buttonClass: "theme-btn style-2" }, }, }); } else { @@ -179,3 +205,141 @@ exports.update = async (req, res) => { res.redirect("/admin/contact"); } }; + +// API để submit contact form (từ frontend) +exports.submitForm = async (req, res) => { + try { + const { name, email, phone, address, date, message } = req.body; + + // Validation + if (!name || !email) { + return res.status(400).json({ + success: false, + error: "Name and email are required", + }); + } + + // Create new submission + const submission = new ContactSubmission({ + name: name.trim(), + email: email.trim().toLowerCase(), + phone: phone?.trim() || "", + address: address?.trim() || "", + date: date?.trim() || "", + message: message?.trim() || "", + ipAddress: req.ip || req.connection?.remoteAddress || "", + userAgent: req.get("User-Agent") || "", + }); + + await submission.save(); + + res.status(201).json({ + success: true, + message: "Thank you for contacting us! We will get back to you soon.", + data: { + id: submission._id, + name: submission.name, + email: submission.email, + }, + }); + } catch (err) { + console.error("Error submitting contact form:", err); + + // Handle validation errors + if (err.name === "ValidationError") { + const errors = Object.values(err.errors).map((e) => e.message); + return res.status(400).json({ + success: false, + error: errors.join(", "), + }); + } + + res.status(500).json({ + success: false, + error: "Error submitting form. Please try again later.", + }); + } +}; + +// API để lấy danh sách submissions (cho admin) +exports.getSubmissions = async (req, res) => { + try { + const { status, page = 1, limit = 20 } = req.query; + + const query = {}; + if (status && ["pending", "read", "replied", "archived"].includes(status)) { + query.status = status; + } + + const skip = (parseInt(page) - 1) * parseInt(limit); + + const [submissions, total] = await Promise.all([ + ContactSubmission.find(query) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(parseInt(limit)), + ContactSubmission.countDocuments(query), + ]); + + res.json({ + success: true, + data: submissions, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total, + totalPages: Math.ceil(total / parseInt(limit)), + }, + }); + } catch (err) { + console.error("Error getting submissions:", err); + res.status(500).json({ + success: false, + error: "Error loading submissions", + }); + } +}; + +// API để cập nhật status của submission +exports.updateSubmissionStatus = async (req, res) => { + try { + const { id } = req.params; + const { status, notes } = req.body; + + const validStatuses = ["pending", "read", "replied", "archived"]; + if (!validStatuses.includes(status)) { + return res.status(400).json({ + success: false, + error: "Invalid status", + }); + } + + const updateData = { status }; + if (notes !== undefined) updateData.notes = notes; + if (status === "replied") updateData.repliedAt = new Date(); + + const submission = await ContactSubmission.findByIdAndUpdate( + id, + updateData, + { new: true } + ); + + if (!submission) { + return res.status(404).json({ + success: false, + error: "Submission not found", + }); + } + + res.json({ + success: true, + data: submission, + }); + } catch (err) { + console.error("Error updating submission:", err); + res.status(500).json({ + success: false, + error: "Error updating submission", + }); + } +}; diff --git a/controllers/pricingController.js b/controllers/pricingController.js new file mode 100644 index 0000000..73fb543 --- /dev/null +++ b/controllers/pricingController.js @@ -0,0 +1,193 @@ +const Pricing = require("../models/pricing"); + +// ==================== CMS ADMIN FUNCTIONS ==================== + +// Render admin page for pricing management +exports.index = async (req, res) => { + try { + let pricing = await Pricing.findOne({ name: "default" }); + + // If no data in DB, try to load from JSON file + if (!pricing) { + const fs = require("fs"); + const path = require("path"); + const jsonPath = path.join(__dirname, "../data/pricing.json"); + + if (fs.existsSync(jsonPath)) { + const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8")); + pricing = await Pricing.migrateFromJson(jsonData); + } else { + // Create default pricing + pricing = await Pricing.create({ + name: "default", + hero: { + title: "Pricing Plan", + backgroundImage: "/assets/img/inner-page/breadcrumb.jpg", + shapeImage: "/assets/img/inner-page/shape.png", + breadcrumb: [ + { text: "Home", link: "/" }, + { text: "Pricing Plan", link: "" }, + ], + }, + pricingSection: { + subtitle: "pricing plan", + heading: "Flexible Plans to Suit Every Traveler", + description: "Choose the plan that fits your visa needs and enjoy expert guidance every step of the way.", + }, + plans: { + monthly: [], + yearly: [], + }, + testimonials: { + subtitle: "What Our Clients Say", + heading: "Immigration Success Stories", + buttonText: "View All Review", + buttonLink: "/contact", + buttonIcon: "fa-solid fa-arrow-right", + image: "", + items: [], + }, + }); + } + } + + res.render("admin/pricing/index", { + layout: "layouts/main", + title: "Pricing Management", + data: pricing, + user: req.session.user, + frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000", + }); + } catch (err) { + console.error("Error loading pricing admin page:", err); + req.flash("error", "Error loading pricing data"); + res.redirect("/admin/dashboard"); + } +}; + +// Update pricing data +exports.update = async (req, res) => { + try { + const { hero, pricingSection, plans, testimonials } = req.body; + + // Parse JSON strings if needed + const heroData = typeof hero === "string" ? JSON.parse(hero) : hero; + const pricingSectionData = typeof pricingSection === "string" ? JSON.parse(pricingSection) : pricingSection; + const plansData = typeof plans === "string" ? JSON.parse(plans) : plans; + const testimonialsData = typeof testimonials === "string" ? JSON.parse(testimonials) : testimonials; + + let pricing = await Pricing.findOne({ name: "default" }); + + if (pricing) { + pricing.hero = heroData; + pricing.pricingSection = pricingSectionData; + pricing.plans = plansData; + pricing.testimonials = testimonialsData; + await pricing.save(); + } else { + pricing = await Pricing.create({ + name: "default", + hero: heroData, + pricingSection: pricingSectionData, + plans: plansData, + testimonials: testimonialsData, + }); + } + + req.flash("success", "Pricing data updated successfully"); + res.redirect("/admin/pricing"); + } catch (err) { + console.error("Error updating pricing:", err); + req.flash("error", "Error updating pricing data"); + res.redirect("/admin/pricing"); + } +}; + +// API to get pricing data (admin) +exports.getPricingData = async (req, res) => { + try { + let pricing = await Pricing.findOne({ name: "default" }); + + if (!pricing) { + const fs = require("fs"); + const path = require("path"); + const jsonPath = path.join(__dirname, "../data/pricing.json"); + + if (fs.existsSync(jsonPath)) { + const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8")); + pricing = await Pricing.migrateFromJson(jsonData); + } + } + + res.json({ + success: true, + data: pricing, + }); + } catch (err) { + console.error("Error getting pricing data:", err); + res.status(500).json({ + success: false, + error: "Error loading pricing data", + }); + } +}; + +// Public API to get pricing page data (for frontend) +exports.api = async (req, res) => { + try { + let pricing = await Pricing.findOne({ name: "default" }); + + if (!pricing) { + const fs = require("fs"); + const path = require("path"); + const jsonPath = path.join(__dirname, "../data/pricing.json"); + + if (fs.existsSync(jsonPath)) { + const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8")); + pricing = await Pricing.migrateFromJson(jsonData); + } + } + + if (!pricing) { + return res.status(404).json({ + success: false, + error: "Pricing data not found", + }); + } + + const backendUrl = process.env.BACKEND_URL || "http://localhost:3001"; + + const getFullUrl = (path) => { + if (!path || path.startsWith("http")) return path; + return `${backendUrl}${path.startsWith("/") ? "" : "/"}${path}`; + }; + + // Convert to plain object to modify properties safely + const pricingData = pricing.toObject ? pricing.toObject() : pricing; + + if (pricingData.hero) { + pricingData.hero.backgroundImage = getFullUrl(pricingData.hero.backgroundImage); + pricingData.hero.shapeImage = getFullUrl(pricingData.hero.shapeImage); + } + + if (pricingData.testimonials) { + pricingData.testimonials.image = getFullUrl(pricingData.testimonials.image); + } + + res.json({ + success: true, + data: { + hero: pricingData.hero, + pricingSection: pricingData.pricingSection, + plans: pricingData.plans, + testimonials: pricingData.testimonials, + }, + }); + } catch (err) { + console.error("Error getting pricing API data:", err); + res.status(500).json({ + success: false, + error: "Error loading pricing data", + }); + } +}; diff --git a/controllers/serviceController.js b/controllers/serviceController.js new file mode 100644 index 0000000..33b36ac --- /dev/null +++ b/controllers/serviceController.js @@ -0,0 +1,360 @@ +const { getServiceData } = require("../services/service.service"); +const Service = require("../models/service"); +const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper"); +const slugify = require("slugify"); + +// Admin page - Service list +exports.index = async (req, res) => { + try { + const data = await getServiceData(); + console.log(data.services.items.image); + res.render("admin/service/index", { + title: "Service Management", + data, + layout: "layouts/main", + getFullImageUrl, // Truyền helper function vào view + }); + } catch (err) { + console.error(err); + req.flash("error_msg", "Error loading service data"); + res.redirect("/admin/dashboard"); + } +}; + +// Admin page - Service edit +exports.edit = async (req, res) => { + try { + const { slug } = req.params; + const data = await getServiceData(); + + const service = data.services?.items?.find((item) => item.slug === slug); + if (!service) { + req.flash("error_msg", "Service not found"); + return res.redirect("/admin/service"); + } + + res.render("admin/service/edit", { + title: `Edit Service - ${service.name}`, + service, + layout: "layouts/main", + getFullImageUrl, + }); + } catch (err) { + console.error(err); + req.flash("error_msg", "Error loading service for editing"); + res.redirect("/admin/service"); + } +}; + +// Update single service +exports.updateService = async (req, res) => { + try { + const { slug } = req.params; + const currentData = await getServiceData(); + + const serviceIndex = currentData.services?.items?.findIndex( + (item) => item.slug === slug, + ); + if (serviceIndex === -1) { + req.flash("error_msg", "Service not found"); + return res.redirect("/admin/service"); + } + + // Update service data + const updatedData = { ...currentData.toObject?.() }; + updatedData.services.items[serviceIndex] = { + ...updatedData.services.items[serviceIndex], + name: req.body.name, + slug: req.body.slug, + description: req.body.description, + image: req.body.image, + layout: req.body.layout, + }; + + if (currentData._id) { + await Service.findByIdAndUpdate(currentData._id, updatedData); + } else { + await Service.create(updatedData); + } + + req.flash("success_msg", "Service updated successfully"); + res.redirect("/admin/service"); + } catch (err) { + console.error(err); + req.flash("error_msg", err.message); + res.redirect("/admin/service"); + } +}; + +// Admin page - Service details +exports.details = async (req, res) => { + try { + const { slug } = req.params; + const data = await getServiceData(); + + const service = data.services?.items?.find((item) => item.slug === slug); + if (!service) { + req.flash("error_msg", "Service not found"); + return res.redirect("/admin/service"); + } + + res.render("admin/service/details", { + title: `Service Details - ${service.name}`, + service, + layout: "layouts/main", + getFullImageUrl, // Truyền helper function vào view + }); + } catch (err) { + console.error(err); + req.flash("error_msg", "Error loading service details"); + res.redirect("/admin/service"); + } +}; + +// Update service list +exports.update = async (req, res) => { + try { + const currentData = await getServiceData(); + const sections = [ + "pageTitle", + "services", + "destinations", + "visas", + "reviews", + ]; + + let updatedData = { ...currentData.toObject?.() }; + let hasChanges = false; + + sections.forEach((section) => { + if (!req.body[section]) return; + + const newData = JSON.parse(req.body[section]); + if (JSON.stringify(newData) !== JSON.stringify(currentData[section])) { + updatedData[section] = newData; + hasChanges = true; + } + }); + + if (!hasChanges) { + req.flash("info_msg", "No changes were made"); + return res.redirect("/admin/service"); + } + + if (currentData._id) { + await Service.findByIdAndUpdate(currentData._id, updatedData); + } else { + await Service.create(updatedData); + } + + req.flash("success_msg", "Service updated successfully"); + res.redirect("/admin/service"); + } catch (err) { + console.error(err); + req.flash("error_msg", err.message); + res.redirect("/admin/service"); + } +}; + +// Update service details +exports.updateDetails = async (req, res) => { + try { + const { slug } = req.params; + const currentData = await getServiceData(); + + const serviceIndex = currentData.services?.items?.findIndex( + (item) => item.slug === slug, + ); + if (serviceIndex === -1) { + req.flash("error_msg", "Service not found"); + return res.redirect("/admin/service"); + } + + // Parse features and FAQ from JSON strings + const features = req.body.features ? JSON.parse(req.body.features) : []; + const faq = req.body.faq ? JSON.parse(req.body.faq) : []; + + // Update service details + const updatedData = { ...currentData.toObject?.() }; + updatedData.services.items[serviceIndex].details = { + title: req.body.title, + description: req.body.description, + mainImage: req.body.mainImage, + overviewTitle: req.body.overviewTitle, + overviewDescription: req.body.overviewDescription, + additionalDescription: req.body.additionalDescription, + keyFeaturesTitle: req.body.keyFeaturesTitle, + keyFeaturesImage: req.body.keyFeaturesImage, + features: features, + faqTitle: req.body.faqTitle, + faqImage: req.body.faqImage, + faq: faq, + }; + + if (currentData._id) { + await Service.findByIdAndUpdate(currentData._id, updatedData); + } else { + await Service.create(updatedData); + } + + req.flash("success_msg", "Service details updated successfully"); + res.redirect(`/admin/service/${slug}/details`); + } catch (err) { + console.error(err); + req.flash("error_msg", err.message); + res.redirect("/admin/service"); + } +}; + +// API endpoint +exports.api = async (req, res) => { + try { + const serviceData = await getServiceData(); + const baseUrl = + process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`; + + const processedData = addBaseUrlToImages(serviceData, baseUrl); + res.json(processedData); + } catch (err) { + res.status(500).json({ error: "Error loading service data" }); + } +}; + +/** + * Get service details by slug - API endpoint + */ +exports.getServiceBySlug = async (req, res) => { + try { + const { slug } = req.params; + + const serviceDoc = await Service.findOne().lean(); + + if (!serviceDoc) { + return res.status(404).json({ + success: false, + message: "Service data not found", + }); + } + + // Find service by slug + const service = serviceDoc.services?.items?.find( + (item) => item.slug === slug, + ); + + if (!service) { + return res.status(404).json({ + success: false, + message: `Service with slug '${slug}' not found`, + }); + } + + const baseUrl = + process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`; + + // Return service details in the expected format + const responseData = { + pageTitle: serviceDoc.pageTitle, + breadcrumb: { + ...serviceDoc.breadcrumb, + title: "Service Details", + items: [ + { label: "Home", href: "/" }, + { label: "Services", href: "/services" }, + { label: service.name, href: `/services/${slug}` }, + ], + }, + serviceDetails: { + content: service.details, + keyFeatures: { + title: service.details.keyFeaturesTitle || "Key Features", + sideImage: service.details.keyFeaturesImage || "img/default.jpg", + items: service.details.features || [], + }, + faq: { + title: service.details.faqTitle || "Frequently Asked Questions", + sideImage: service.details.faqImage || "img/default.jpg", + items: service.details.faq || [], + }, + }, + }; + + const processedData = addBaseUrlToImages(responseData, baseUrl); + res.json(processedData); + } catch (error) { + console.error("Error fetching service by slug:", error); + res.status(500).json({ + success: false, + message: "Internal server error", + error: error.message, + }); + } +}; + +/** + * Generate slug from text - API endpoint + */ +exports.generateSlug = async (req, res) => { + try { + const { text } = req.body; + + if (!text || typeof text !== "string") { + return res.status(400).json({ + success: false, + message: "Text is required", + }); + } + + // Generate slug using slugify library with Vietnamese support + const slug = slugify(text, { + lower: true, + strict: true, + locale: "vi", + }); + + res.json({ + success: true, + slug: slug, + }); + } catch (error) { + console.error("Error generating slug:", error); + res.status(500).json({ + success: false, + message: "Internal server error", + error: error.message, + }); + } +}; + +/** + * Get all service slugs - API endpoint + */ +exports.getServiceSlugs = async (req, res) => { + try { + const serviceDoc = await Service.findOne().lean(); + + if (!serviceDoc?.services?.items) { + return res.json({ + success: true, + slugs: [], + }); + } + + const slugs = serviceDoc.services.items.map((item) => ({ + slug: item.slug, + name: item.name, + id: item.id, + })); + + res.json({ + success: true, + slugs, + }); + } catch (error) { + console.error("Error fetching service slugs:", error); + res.status(500).json({ + success: false, + message: "Internal server error", + error: error.message, + }); + } +}; diff --git a/controllers/uploadController.js b/controllers/uploadController.js index 3fd8590..ef6f4c8 100644 --- a/controllers/uploadController.js +++ b/controllers/uploadController.js @@ -13,7 +13,7 @@ const uploadController = { // Lấy loại ảnh từ query params const imageType = req.query.imageType || 'general'; - + // Tạo đường dẫn tương đối để lưu vào database const relativePath = `/uploads/${imageType}/${req.file.filename}`; const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; @@ -41,7 +41,7 @@ const uploadController = { if (camp) { // Đồng bộ camps.image và camps.camp-detail.hero.bgImage - 2 trường này luôn giống nhau camp.image = relativePath; - + // Đảm bảo camp-detail.hero tồn tại và sync bgImage if (!camp['camp-detail']) camp['camp-detail'] = {}; if (!camp['camp-detail'].hero) camp['camp-detail'].hero = {}; @@ -73,59 +73,59 @@ const uploadController = { updateImagePath: async (req, res) => { try { const { jsonFile, jsonPath, newImagePath } = req.body; - + if (!jsonFile || !jsonPath || !newImagePath) { - return res.status(400).json({ - success: false, - message: 'Missing required information (jsonFile, jsonPath, newImagePath)' + return res.status(400).json({ + success: false, + message: 'Missing required information (jsonFile, jsonPath, newImagePath)' }); } // Đọc file JSON const jsonFilePath = path.join(__dirname, '../data', jsonFile); const jsonData = jsonHelper.readJsonFile(jsonFilePath); - + // Cập nhật đường dẫn ảnh theo jsonPath // jsonPath có định dạng như "banner.image" hoặc "partners[0].logo" const pathParts = jsonPath.split('.'); let current = jsonData; - + // Duyệt qua các phần của path trừ phần cuối for (let i = 0; i < pathParts.length - 1; i++) { const part = pathParts[i]; - + // Kiểm tra nếu là mảng (ví dụ: partners[0]) if (part.includes('[') && part.includes(']')) { const arrName = part.substring(0, part.indexOf('[')); const index = parseInt(part.substring(part.indexOf('[') + 1, part.indexOf(']'))); - + if (!current[arrName] || !Array.isArray(current[arrName])) { - return res.status(400).json({ - success: false, - message: `Array ${arrName} not found in data` + return res.status(400).json({ + success: false, + message: `Array ${arrName} not found in data` }); } - + current = current[arrName][index]; } else { if (!current[part]) { - return res.status(400).json({ - success: false, - message: `Property ${part} not found in data` + return res.status(400).json({ + success: false, + message: `Property ${part} not found in data` }); } - + current = current[part]; } } - + // Cập nhật giá trị const lastPart = pathParts[pathParts.length - 1]; current[lastPart] = newImagePath; - + // Lưu lại file JSON jsonHelper.writeJsonFile(jsonFilePath, jsonData); - + return res.status(200).json({ success: true, message: 'Image path updated successfully', @@ -141,22 +141,22 @@ const uploadController = { deleteImage: async (req, res) => { try { const { imagePath } = req.body; - + if (!imagePath) { return res.status(400).json({ success: false, message: 'Missing image path to delete' }); } - + // Chuyển đổi đường dẫn tương đối thành đường dẫn tuyệt đối const fullPath = path.join(__dirname, '../public', imagePath); - + // Kiểm tra xem file có tồn tại không if (!fs.existsSync(fullPath)) { return res.status(404).json({ success: false, message: 'Image file not found' }); } - + // Xóa file fs.unlinkSync(fullPath); - + return res.status(200).json({ success: true, message: 'Image deleted successfully', diff --git a/data/appointment.json b/data/appointment.json new file mode 100644 index 0000000..a8f2cf2 --- /dev/null +++ b/data/appointment.json @@ -0,0 +1,77 @@ +{ + "hero": { + "title": "Make Appointment", + "backgroundImage": "/assets/img/inner-page/breadcrumb.jpg", + "subtitle": "About Our Consultancy", + "heading": "Want to meet us for your need?", + "description": "24/7 customer support is always ready to answer all your questions" + }, + "visaOptions": [ + "Canada Immigration", + "Tourist Visa", + "Medical Visa", + "Coaching", + "Student Visa", + "Spouse Visa", + "Job Opportunity", + "Exam" + ], + "form": { + "heading": "Request Appointment", + "fields": [ + { + "name": "name", + "label": "Your Name", + "type": "text", + "placeholder": "Your name", + "required": true, + "colClass": "col-lg-4" + }, + { + "name": "email", + "label": "Your Email", + "type": "email", + "placeholder": "Your email", + "required": true, + "colClass": "col-lg-4" + }, + { + "name": "phone", + "label": "Your Phone", + "type": "tel", + "placeholder": "Phone Number", + "required": false, + "colClass": "col-lg-4" + }, + { + "name": "address", + "label": "Your Address", + "type": "text", + "placeholder": "Your address", + "required": false, + "colClass": "col-lg-6" + }, + { + "name": "appointmentDate", + "label": "Appointment Date", + "type": "date", + "placeholder": "", + "required": false, + "colClass": "col-lg-6" + }, + { + "name": "message", + "label": "Your Message", + "type": "textarea", + "placeholder": "Type your message", + "required": false, + "colClass": "col-lg-12" + } + ], + "submitButton": { + "text": "Request Appointment", + "icon": "fa-solid fa-arrow-right", + "buttonClass": "theme-btn" + } + } +} \ No newline at end of file diff --git a/data/contact-data.json b/data/contact-data.json deleted file mode 100644 index ae404df..0000000 --- a/data/contact-data.json +++ /dev/null @@ -1,95 +0,0 @@ -{ - "hero": { - "title": "Contact Us", - "backgroundImage": "/uploads/banner/b10.jpg", - "overlayColor": "rgba(0, 0, 0, 0)", - "sectionClass": "uk-section-secondary uk-section-overlap uk-preserve-color uk-light", - "titleClass": "uk-heading-large uk-text-center !text-[5vw]", - "enableScrollspy": true, - "backgroundPosition": "top-center" - }, - "contactCards": [ - { - "type": "phone", - "title": "Phone Number", - "content": ["+123456789"], - "iconType": "fas fa-phone", - "iconSource": "fontawesome" - }, - { - "type": "email", - "title": "Email Address", - "content": ["office@ggcamp.org"], - "iconType": "fas fa-envelope", - "iconSource": "fontawesome" - }, - { - "type": "location", - "title": "Our Location", - "content": ["Poblacion, Madridejos 22, Cebu City, Philippines"], - "iconType": "fas fa-map-marker-alt", - "iconSource": "fontawesome" - }, - { - "type": "hours", - "title": "Working hours", - "content": ["Monday to Saturday: 07pm - 05am", "Sunday: Closed"], - "iconType": "fas fa-clock", - "iconSource": "fontawesome" - } - ], - "map": { - "coordinates": { - "lat": 10.3157, - "lng": 123.8854 - }, - "zoom": 15, - "location": "Poblacion, Madridejos 22, Cebu City, Philippines", - "markerTitle": "Our Office", - "tileLayer": { - "url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - "attribution": "© OpenStreetMap contributors", - "maxZoom": 18, - "minZoom": 0 - } - }, - "form": { - "sectionLabel": "Contact Us", - "heading": "Let's plan your dream adventure - contact our team today", - "fields": [ - { - "name": "firstName", - "type": "text", - "placeholder": "First name", - "required": true - }, - { - "name": "lastName", - "type": "text", - "placeholder": "Last name", - "required": true - }, - { - "name": "phone", - "type": "tel", - "placeholder": "Phone Number", - "required": true - }, - { - "name": "email", - "type": "email", - "placeholder": "Email Address", - "required": true - }, - { - "name": "message", - "type": "textarea", - "placeholder": "Send Message", - "required": true - } - ], - "submitButton": { - "text": "Send Message" - } - } -} diff --git a/data/contact.json b/data/contact.json new file mode 100644 index 0000000..474c9e1 --- /dev/null +++ b/data/contact.json @@ -0,0 +1,119 @@ +{ + "hero": { + "title": "CONTACT US", + "backgroundImage": "/assets/img/inner-page/breadcrumb.jpg", + "overlayColor": "rgba(0, 0, 0, 0)", + "sectionClass": "breadcrumb-wrapper fix bg-cover", + "titleClass": "breadcrumb-title", + "enableScrollspy": false, + "backgroundPosition": "center" + }, + "contactCards": [ + { + "type": "location", + "title": "Location", + "content": [ + "43 Sardinella, 3nd Land Walk,", + "Orchard view, London, UK" + ], + "iconType": "fa-solid fa-location-dot", + "iconSource": "fontawesome" + }, + { + "type": "email", + "title": "Email Address", + "content": [ + "supportinfo@gmail.com", + "arluxhotelinfo.com" + ], + "iconType": "fa-solid fa-envelope", + "iconSource": "fontawesome" + }, + { + "type": "phone", + "title": "Phone Number", + "content": [ + "+880 123 427 00", + "+000 938 809 12" + ], + "iconType": "fa-solid fa-phone", + "iconSource": "fontawesome" + } + ], + "map": { + "coordinates": { + "lat": -37.81450084255415, + "lng": 144.9618311901502 + }, + "zoom": 15, + "location": "Envato, Melbourne, Australia", + "markerTitle": "Our Office", + "embedUrl": "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d6678.7619084840835!2d144.9618311901502!3d-37.81450084255415!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x6ad642b4758afc1d%3A0x3119cc820fdfc62e!2sEnvato!5e0!3m2!1sen!2sbd!4v1641984054261!5m2!1sen!2sbd", + "tileLayer": { + "url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + "attribution": "", + "maxZoom": 18, + "minZoom": 0 + } + }, + "form": { + "sectionLabel": "", + "heading": "Send Us Message", + "description": "Have questions about visas or immigration? Send us a message today and our expert team will respond quickly.", + "fields": [ + { + "name": "name", + "label": "Your Name", + "type": "text", + "placeholder": "Your name", + "required": true, + "colClass": "col-lg-4" + }, + { + "name": "email", + "label": "Your Email", + "type": "email", + "placeholder": "Your email", + "required": true, + "colClass": "col-lg-4" + }, + { + "name": "phone", + "label": "Your Phone", + "type": "tel", + "placeholder": "Phone Number", + "required": true, + "colClass": "col-lg-4" + }, + { + "name": "address", + "label": "Your Address", + "type": "text", + "placeholder": "Address Now", + "required": false, + "colClass": "col-lg-6" + }, + { + "name": "date", + "label": "Your Date", + "type": "date", + "placeholder": "Date", + "required": false, + "colClass": "col-lg-6" + }, + { + "name": "message", + "label": "Your Message", + "type": "textarea", + "placeholder": "Type your message", + "required": false, + "colClass": "col-lg-12" + } + ], + "submitButton": { + "text": "SEND MESSAGE", + "icon": "fa-solid fa-arrow-right", + "buttonClass": "theme-btn style-2" + } + } +} \ No newline at end of file diff --git a/data/pricing.json b/data/pricing.json new file mode 100644 index 0000000..db531a8 --- /dev/null +++ b/data/pricing.json @@ -0,0 +1,118 @@ +{ + "hero": { + "title": "Pricing Plan", + "backgroundImage": "/assets/img/inner-page/breadcrumb.jpg", + "shapeImage": "/assets/img/inner-page/shape.png", + "breadcrumb": [ + { + "text": "Home", + "link": "/" + }, + { + "text": "Pricing Plan", + "link": "" + } + ] + }, + "pricingSection": { + "subtitle": "pricing plan", + "heading": "Flexible Plans to Suit Every Traveler", + "description": "Choose the plan that fits your visa needs and enjoy expert guidance every step of the way." + }, + "plans": { + "monthly": [ + { + "name": "Basic Plan", + "price": "32", + "period": "mo", + "currency": "$", + "buttonText": "Get Started Today", + "buttonLink": "/pricing", + "buttonIcon": "fa-solid fa-arrow-right", + "style": "default", + "features": [ + "Everything in Basic Plan", + "Visa Interview Preparation", + "Priority Processing Support", + "Phone & Email Assistance", + "Step-by-Step Application Support" + ] + }, + { + "name": "Premium Plan", + "price": "32", + "period": "mo", + "currency": "$", + "buttonText": "Get Started Today", + "buttonLink": "/pricing", + "buttonIcon": "fa-solid fa-arrow-right", + "style": "style-2", + "features": [ + "Everything in Basic Plan", + "Visa Interview Preparation", + "Priority Processing Support", + "Phone & Email Assistance", + "Step-by-Step Application Support" + ] + } + ], + "yearly": [ + { + "name": "Basic Plan", + "price": "32", + "period": "mo", + "currency": "$", + "buttonText": "Get Started Today", + "buttonLink": "/pricing", + "buttonIcon": "fa-solid fa-arrow-right", + "style": "default", + "features": [ + "Everything in Basic Plan", + "Visa Interview Preparation", + "Priority Processing Support", + "Phone & Email Assistance", + "Step-by-Step Application Support" + ] + }, + { + "name": "Premium Plan", + "price": "32", + "period": "mo", + "currency": "$", + "buttonText": "Get Started Today", + "buttonLink": "/pricing", + "buttonIcon": "fa-solid fa-arrow-right", + "style": "style-2", + "features": [ + "Everything in Basic Plan", + "Visa Interview Preparation", + "Priority Processing Support", + "Phone & Email Assistance", + "Step-by-Step Application Support" + ] + } + ] + }, + "testimonials": { + "subtitle": "What Our Clients Say", + "heading": "Immigration Success Stories", + "buttonText": "View All Review", + "buttonLink": "/contact", + "buttonIcon": "fa-solid fa-arrow-right", + "image": "/assets/img/home-3/test-thumb.jpg", + "items": [ + { + "name": "Mohammed Ali", + "role": "Family Visa", + "rating": 5, + "content": "The team provided exceptional guidance throughout my immigration process. Their expertise, personalized support, and attention to detail ensured a smooth, stress-free experience and successful visa approval." + }, + { + "name": "Mohammed Ali", + "role": "Family Visa", + "rating": 5, + "content": "The team provided exceptional guidance throughout my immigration process. Their expertise, personalized support, and attention to detail ensured a smooth, stress-free experience and successful visa approval." + } + ] + } +} \ No newline at end of file diff --git a/data/service.json b/data/service.json new file mode 100644 index 0000000..0b524c7 --- /dev/null +++ b/data/service.json @@ -0,0 +1,363 @@ +{ + "pageTitle": "Visaway – Immigration & Visa Consulting HTML Template", + + "services": { + "title": { + "subTitle": "What We Offer", + "mainTitle": "Our Immigration Services" + }, + "items": [ + { + "slug": "immigration-appeal", + "name": "Immigration Appeal & Legal Support", + "description": "Our experts provide professional guidance for immigration appeals and legal matters, helping clients overcome visa rejections with personalized strategies and strong case representation.", + "image": "img/home-3/service/01.jpg", + "layout": "left", + "details": { + "title": "Immigration Appeal & Legal Support", + "description": "Our experts provide professional guidance for immigration appeals and legal matters, helping clients overcome visa rejections with personalized strategies and strong case representation. We analyze your case thoroughly and develop custom strategies to maximize your chances of success.", + "mainImage": "img/inner-page/service-details/details-1.jpg", + "overviewTitle": "Service Overview", + "overviewDescription": "Our Immigration Appeal & Legal Support service is designed to help clients navigate complex immigration challenges. We provide expert legal guidance, case analysis, and strategic representation to maximize your chances of success. With our expert consultants, personalized approach, and global network, we ensure a smooth transition for every client.", + "additionalDescription": "From start to finish, we are committed to turning your immigration challenges into success stories through professional legal representation and strategic planning.", + "keyFeaturesTitle": "Key Features", + "keyFeaturesImage": "img/inner-page/service-details/details-2.jpg", + "features": [ + { + "title": "Personalized Guidance", + "description": "Tailored support for each client's specific legal situation and requirements." + }, + { + "title": "Expert Legal Team", + "description": "Experienced immigration lawyers with proven track records in appeals." + }, + { + "title": "Case Analysis & Strategy", + "description": "Thorough case review and development of winning appeal strategies." + }, + { + "title": "Document Preparation", + "description": "Professional preparation of all legal documents and supporting evidence." + }, + { + "title": "Court Representation", + "description": "Expert representation in immigration courts and tribunals." + }, + { + "title": "Success Monitoring", + "description": "Regular updates and monitoring throughout the appeal process." + } + ], + "faqTitle": "Frequently Asked Question", + "faqImage": "img/inner-page/service-details/details-3.jpg", + "faq": [ + { + "id": "faq-appeal-1", + "question": "01. What are the chances of a successful appeal?", + "answer": "Success rates vary by case type and circumstances, but our experienced legal team significantly improves your chances through thorough case analysis and strategic representation tailored to your specific situation.", + "isExpanded": false + }, + { + "id": "faq-appeal-2", + "question": "02. How long does the appeal process take?", + "answer": "Appeal timelines vary by jurisdiction and case complexity, typically ranging from 6-18 months. We keep you informed throughout the process and work to expedite where possible.", + "isExpanded": false + }, + { + "id": "faq-appeal-3", + "question": "03. What documents do I need for an appeal?", + "answer": "Required documents vary by case but typically include the original decision, supporting evidence, and legal submissions. We provide a comprehensive checklist and assist with document preparation.", + "isExpanded": false + }, + { + "id": "faq-appeal-4", + "question": "04. Do you handle all types of immigration appeals?", + "answer": "Yes, we handle various types of immigration appeals including visa refusals, deportation orders, and residency rejections. Our team has expertise across all immigration categories.", + "isExpanded": false + } + ] + } + }, + { + "slug": "scholarship-guidance", + "name": "Scholarship & Study Grant Guidance", + "description": "We help students unlock opportunities to study abroad with the right financial support. Our expert advisors guide you in finding scholarships, grants, and funding options that match your academic background, chosen destination, and career goals.", + "image": "img/home-3/service/02.jpg", + "layout": "right", + "details": { + "title": "Scholarship & Study Grant Guidance", + "description": "We help students unlock opportunities to study abroad with the right financial support. Our expert advisors guide you in finding scholarships, grants, and funding options that match your academic background, chosen destination, and career goals. From preparing strong applications to meeting eligibility criteria, we ensure you maximize your chances of securing financial aid.", + "mainImage": "img/inner-page/service-details/details-1.jpg", + "overviewTitle": "Service Overview", + "overviewDescription": "Our Education Visa Consultancy is dedicated to guiding students in achieving their study abroad dreams. We provide complete support including university selection, application assistance, scholarship guidance, visa documentation, and interview preparation. With our expert consultants, personalized approach, and global network, we ensure a smooth transition for every student.", + "additionalDescription": "From start to finish, we are committed to turning your education journey into a successful international experience.", + "keyFeaturesTitle": "Key Features", + "keyFeaturesImage": "img/inner-page/service-details/details-2.jpg", + "features": [ + { + "title": "Personalized Guidance", + "description": "Tailored support for each student's goals and requirements." + }, + { + "title": "Target Audience & Persona Development", + "description": "Experienced team with global education and visa knowledge." + }, + { + "title": "Scholarship & Grant Assistance", + "description": "Helping students secure financial aid opportunities." + }, + { + "title": "Visa Application Support", + "description": "Step-by-step guidance for smooth visa processing." + }, + { + "title": "Interview Preparation", + "description": "Coaching for successful student visa interviews." + }, + { + "title": "Documentation Assistance", + "description": "Accurate and complete paperwork for faster approvals." + } + ], + "faqTitle": "Frequently Asked Question", + "faqImage": "img/inner-page/service-details/details-3.jpg", + "faq": [ + { + "id": "faq-scholarship-1", + "question": "01. Do you assist with university selection?", + "answer": "Absolutely! We identify suitable scholarships, guide application processes, and maximize your chances of receiving financial aid.", + "isExpanded": false + }, + { + "id": "faq-scholarship-2", + "question": "02. Can you help with scholarship applications?", + "answer": "Absolutely! We identify suitable scholarships, guide application processes, and maximize your chances of receiving financial aid.", + "isExpanded": true + }, + { + "id": "faq-scholarship-3", + "question": "03. How long does the visa process take?", + "answer": "Absolutely! We identify suitable scholarships, guide application processes, and maximize your chances of receiving financial aid.", + "isExpanded": false + }, + { + "id": "faq-scholarship-4", + "question": "04. Is post-arrival support available?", + "answer": "Absolutely! We identify suitable scholarships, guide application processes, and maximize your chances of receiving financial aid.", + "isExpanded": false + } + ] + } + }, + { + "slug": "permanent-residency", + "name": "Permanent Residency (PR) Services", + "description": "Our PR services guide clients through every step of the residency process, including documentation, eligibility assessment, and application support, ensuring a smooth and successful approval.", + "image": "img/home-3/service/03.jpg", + "layout": "left", + "details": { + "title": "Permanent Residency (PR) Services", + "description": "Our PR services guide clients through every step of the residency process, including documentation, eligibility assessment, and application support, ensuring a smooth and successful approval.", + "mainImage": "img/inner-page/service-details/details-1.jpg", + "overviewTitle": "Service Overview", + "overviewDescription": "Our Permanent Residency services provide comprehensive support for individuals seeking to establish permanent residence in their chosen country. We handle all aspects of the PR application process with expertise and care.", + "additionalDescription": "Our experienced team ensures that your PR application is handled professionally and efficiently, maximizing your chances of approval.", + "keyFeaturesTitle": "Key Features", + "keyFeaturesImage": "img/inner-page/service-details/details-2.jpg", + "features": [ + { + "title": "Eligibility Assessment", + "description": "Comprehensive evaluation of your PR eligibility and options." + }, + { + "title": "Points Calculation", + "description": "Accurate calculation and optimization of your points score." + }, + { + "title": "Document Verification", + "description": "Thorough verification and preparation of all required documents." + }, + { + "title": "Application Tracking", + "description": "Regular updates and tracking of your PR application status." + }, + { + "title": "Interview Preparation", + "description": "Coaching and preparation for PR interviews if required." + }, + { + "title": "Post-Approval Support", + "description": "Guidance on next steps after PR approval and settlement." + } + ], + "faqTitle": "Frequently Asked Question", + "faqImage": "img/inner-page/service-details/details-3.jpg", + "faq": [ + { + "id": "faq-pr-1", + "question": "01. How long does the PR process take?", + "answer": "Processing times vary by country and program, typically ranging from 12-24 months. We provide realistic timelines based on current processing standards.", + "isExpanded": false + }, + { + "id": "faq-pr-2", + "question": "02. What documents are required for PR application?", + "answer": "Document requirements vary by country but typically include educational credentials, work experience, language test results, and medical examinations. We provide a complete checklist.", + "isExpanded": true + }, + { + "id": "faq-pr-3", + "question": "03. Can I include my family in the PR application?", + "answer": "Yes, most PR programs allow you to include your spouse and dependent children. We help you understand family inclusion requirements and processes.", + "isExpanded": false + }, + { + "id": "faq-pr-4", + "question": "04. What happens if my PR application is rejected?", + "answer": "If rejected, we analyze the reasons and explore options including appeals, reapplication, or alternative immigration pathways to achieve your goals.", + "isExpanded": false + } + ] + } + }, + { + "slug": "citizenship-naturalization", + "name": "Citizenship & Naturalization Guidance", + "description": "We provide expert guidance for citizenship and naturalization processes, assisting clients with documentation, eligibility, and legal procedures to achieve a smooth and successful application.", + "image": "img/home-3/service/04.jpg", + "layout": "right", + "details": { + "title": "Citizenship & Naturalization Guidance", + "description": "We provide expert guidance for citizenship and naturalization processes, assisting clients with documentation, eligibility, and legal procedures to achieve a smooth and successful application.", + "mainImage": "img/inner-page/service-details/details-1.jpg", + "overviewTitle": "Service Overview", + "overviewDescription": "Our Citizenship & Naturalization service helps individuals navigate the complex process of becoming a citizen. We provide step-by-step guidance, documentation support, and legal expertise throughout the entire process.", + "additionalDescription": "With our comprehensive approach, we make the path to citizenship clear, manageable, and successful for every client.", + "keyFeaturesTitle": "Key Features", + "keyFeaturesImage": "img/inner-page/service-details/details-2.jpg", + "features": [ + { + "title": "Citizenship Test Preparation", + "description": "Comprehensive preparation for citizenship knowledge tests." + }, + { + "title": "Language Requirements", + "description": "Guidance on meeting language proficiency requirements." + }, + { + "title": "Residency Verification", + "description": "Assistance with proving residency and physical presence requirements." + }, + { + "title": "Application Processing", + "description": "Complete support throughout the citizenship application process." + }, + { + "title": "Interview Coaching", + "description": "Preparation and coaching for citizenship interviews." + }, + { + "title": "Ceremony Preparation", + "description": "Support and guidance for the citizenship ceremony process." + } + ], + "faqTitle": "Frequently Asked Question", + "faqImage": "img/inner-page/service-details/details-3.jpg", + "faq": [ + { + "id": "faq-citizenship-1", + "question": "What are the basic requirements for citizenship?", + "answer": "Requirements typically include permanent residency, physical presence, language proficiency, and knowledge of the country's history and government. Specific requirements vary by country.", + "isExpanded": false + }, + { + "id": "faq-citizenship-2", + "question": "How do I prepare for the citizenship test?", + "answer": "We provide comprehensive study materials, practice tests, and coaching sessions to help you prepare for both the knowledge test and language requirements.", + "isExpanded": false + }, + { + "id": "faq-citizenship-3", + "question": "How long does the citizenship process take?", + "answer": "Processing times vary by country but typically range from 12-24 months from application to ceremony. We help you understand specific timelines for your situation.", + "isExpanded": false + }, + { + "id": "faq-citizenship-4", + "question": "Can I maintain dual citizenship?", + "answer": "Dual citizenship policies vary by country. We help you understand the implications and requirements for maintaining multiple citizenships if applicable.", + "isExpanded": false + } + ] + } + } + ] + }, + + "destinations": { + "backgroundImage": "img/home-3/choose-us/bg.png", + "title": { + "subTitle": "Countries we offer", + "mainTitle": "Choose Your Immigration Destination" + } + }, + + "visas": { + "items": [ + { + "id": "family-visa", + "number": "01", + "name": "Family Visa", + "description": "Our Family Visa services help reunite loved ones by providing expert guidance.", + "buttonText": "service _ 02", + "buttonLink": "service-details.html" + }, + { + "id": "student-visa", + "number": "02", + "name": "Student Visa", + "description": "We provide expert guidance for student visa applications.", + "buttonText": "service _ 02", + "buttonLink": "service-details.html" + }, + { + "id": "work-visa", + "number": "03", + "name": "Work Visa", + "description": "Collaboratively disintermediate one to one functionalities and long term.", + "buttonText": "service _ 02", + "buttonLink": "service-details.html" + } + ] + }, + + "reviews": { + "title": { + "subTitle": "What Our Clients Say", + "mainTitle": "Immigration Success Stories" + }, + "thumb": "img/home-3/test-thumb.jpg", + "items": [ + { + "id": "client-review-1", + "rating": 5, + "content": "The team provided exceptional guidance throughout my immigration process.", + "author": { + "name": "Mohammed Ali,", + "type": "Family Visa" + }, + "icon": "fa-solid fa-quote-right" + }, + { + "id": "client-review-2", + "rating": 5, + "content": "Their expertise and personalized support ensured a smooth visa approval.", + "author": { + "name": "Sarah Johnson,", + "type": "Student Visa" + }, + "icon": "fa-solid fa-quote-right" + } + ] + } +} diff --git a/middleware/upload.js b/middleware/upload.js index e285ff8..7808000 100644 --- a/middleware/upload.js +++ b/middleware/upload.js @@ -9,18 +9,18 @@ const storage = multer.diskStorage({ try { // Lấy loại ảnh từ request fields const imageType = req.query.imageType || 'general'; - + // Tạo đường dẫn đến thư mục lưu trữ const uploadPath = path.join(__dirname, '../public/uploads', imageType); - + console.log('Creating upload directory:', uploadPath); - + // Kiểm tra và tạo thư mục nếu chưa tồn tại if (!fs.existsSync(uploadPath)) { fs.mkdirSync(uploadPath, { recursive: true }); console.log('Directory created successfully'); } - + cb(null, uploadPath); } catch (error) { console.error('Error creating directory:', error); @@ -31,11 +31,11 @@ const storage = multer.diskStorage({ try { const imageType = req.query.imageType || 'general'; const uploadPath = path.join(__dirname, '../public/uploads', imageType); - + // Lấy tên file gốc (sanitize để tránh ký tự đặc biệt) const originalName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); const fullPath = path.join(uploadPath, originalName); - + // Kiểm tra nếu file đã tồn tại if (fs.existsSync(fullPath)) { console.log('File already exists, reusing:', originalName); @@ -63,7 +63,7 @@ const fileFilter = (req, file, cb) => { const allowedTypes = /jpeg|jpg|png|gif|webp|svg/; const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); const mimetype = allowedTypes.test(file.mimetype); - + if (extname && mimetype) { return cb(null, true); } else { @@ -83,12 +83,12 @@ const videoStorage = multer.diskStorage({ destination: function (req, file, cb) { // Tạo đường dẫn đến thư mục lưu trữ video const uploadPath = path.join(__dirname, '../public/uploads/videos'); - + // Kiểm tra và tạo thư mục nếu chưa tồn tại if (!fs.existsSync(uploadPath)) { fs.mkdirSync(uploadPath, { recursive: true }); } - + cb(null, uploadPath); }, filename: function (req, file, cb) { @@ -103,7 +103,7 @@ const videoStorage = multer.diskStorage({ const videoFileFilter = (req, file, cb) => { const allowedTypes = /mp4|webm/; const mimetype = allowedTypes.test(file.mimetype); - + if (mimetype) { return cb(null, true); } else { @@ -124,7 +124,7 @@ async function convertToWebp(req, res, next) { if (!req.file) return next(); console.log('🔄 Converting image to webp format...'); console.log('Original file:', req.file.path); - + try { const filePath = req.file.path; const webpPath = filePath.replace(path.extname(filePath), '.webp'); @@ -139,7 +139,7 @@ async function convertToWebp(req, res, next) { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } - + // Cập nhật thông tin file req.file.filename = path.basename(webpPath); req.file.path = webpPath; diff --git a/models/appointment.js b/models/appointment.js new file mode 100644 index 0000000..4416f0a --- /dev/null +++ b/models/appointment.js @@ -0,0 +1,206 @@ +const mongoose = require("mongoose"); + +// Clear cache +if (mongoose.models.Appointment) { + delete mongoose.models.Appointment; +} +if (mongoose.connection.models.Appointment) { + delete mongoose.connection.models.Appointment; +} + +// Schema cho hero section +const heroSchema = new mongoose.Schema( + { + title: { + type: String, + trim: true, + default: "Make Appointment", + }, + backgroundImage: { + type: String, + trim: true, + default: "", + }, + subtitle: { + type: String, + trim: true, + default: "", + }, + heading: { + type: String, + trim: true, + default: "", + }, + description: { + type: String, + trim: true, + default: "", + }, + }, + { _id: false } +); + +// Schema cho form field +const formFieldSchema = new mongoose.Schema( + { + name: { + type: String, + required: true, + trim: true, + }, + label: { + type: String, + trim: true, + default: "", + }, + type: { + type: String, + required: true, + trim: true, + enum: ["text", "email", "tel", "textarea", "date", "select"], + }, + placeholder: { + type: String, + trim: true, + default: "", + }, + required: { + type: Boolean, + default: false, + }, + colClass: { + type: String, + trim: true, + default: "col-lg-12", + }, + }, + { _id: false } +); + +// Schema cho submit button +const submitButtonSchema = new mongoose.Schema( + { + text: { + type: String, + required: true, + trim: true, + default: "Request Appointment", + }, + icon: { + type: String, + trim: true, + default: "fa-solid fa-arrow-right", + }, + buttonClass: { + type: String, + trim: true, + default: "theme-btn", + }, + }, + { _id: false } +); + +// Schema cho form +const formSchema = new mongoose.Schema( + { + heading: { + type: String, + trim: true, + default: "Request Appointment", + }, + fields: { + type: [formFieldSchema], + default: [], + }, + submitButton: { + type: submitButtonSchema, + default: () => ({}), + }, + }, + { _id: false } +); + +// Main Appointment Schema +const appointmentSchema = new mongoose.Schema( + { + name: { + type: String, + default: "default", + unique: true, + }, + hero: { + type: heroSchema, + default: () => ({}), + }, + visaOptions: { + type: [String], + default: [], + }, + form: { + type: formSchema, + default: () => ({}), + }, + }, + { + timestamps: true, + } +); + +// Migration method to import data from JSON +appointmentSchema.statics.migrateFromJson = async function (jsonData) { + try { + // Check if default appointment exists + const existingAppointment = await this.findOne({ name: "default" }); + + // Process data from JSON + const processedData = { + hero: { + title: jsonData.hero?.title || "Make Appointment", + backgroundImage: jsonData.hero?.backgroundImage || "", + subtitle: jsonData.hero?.subtitle || "", + heading: jsonData.hero?.heading || "", + description: jsonData.hero?.description || "", + }, + visaOptions: Array.isArray(jsonData.visaOptions) ? jsonData.visaOptions : [], + form: { + heading: jsonData.form?.heading || "Request Appointment", + fields: (jsonData.form?.fields || []).map((field) => ({ + name: field.name || "", + label: field.label || "", + type: field.type || "text", + placeholder: field.placeholder || "", + required: field.required || false, + colClass: field.colClass || "col-lg-12", + })), + submitButton: { + text: jsonData.form?.submitButton?.text || "Request Appointment", + icon: jsonData.form?.submitButton?.icon || "fa-solid fa-arrow-right", + buttonClass: jsonData.form?.submitButton?.buttonClass || "theme-btn", + }, + }, + }; + + if (existingAppointment) { + // Update existing appointment + existingAppointment.hero = processedData.hero; + existingAppointment.visaOptions = processedData.visaOptions; + existingAppointment.form = processedData.form; + await existingAppointment.save(); + console.log("Appointment data updated successfully"); + return existingAppointment; + } else { + // Create new appointment + const newAppointment = await this.create({ + name: "default", + ...processedData, + }); + console.log("Appointment data imported successfully"); + return newAppointment; + } + } catch (error) { + console.error("Error migrating appointment data:", error); + throw error; + } +}; + +module.exports = mongoose.model("Appointment", appointmentSchema); diff --git a/models/appointmentSubmission.js b/models/appointmentSubmission.js new file mode 100644 index 0000000..6987f90 --- /dev/null +++ b/models/appointmentSubmission.js @@ -0,0 +1,83 @@ +const mongoose = require("mongoose"); + +/** + * Schema for Appointment Submissions + * Stores appointment requests from users + */ +const appointmentSubmissionSchema = new mongoose.Schema( + { + name: { + type: String, + required: [true, "Name is required"], + trim: true, + maxlength: [100, "Name cannot exceed 100 characters"], + }, + email: { + type: String, + required: [true, "Email is required"], + trim: true, + lowercase: true, + match: [/^\S+@\S+\.\S+$/, "Please enter a valid email"], + }, + phone: { + type: String, + trim: true, + default: "", + }, + address: { + type: String, + trim: true, + default: "", + }, + appointmentDate: { + type: String, + trim: true, + default: "", + }, + message: { + type: String, + trim: true, + default: "", + }, + visaTypes: { + type: [String], + default: [], + }, + status: { + type: String, + enum: ["pending", "confirmed", "completed", "cancelled"], + default: "pending", + }, + notes: { + type: String, + trim: true, + default: "", + }, + confirmedAt: { + type: Date, + default: null, + }, + completedAt: { + type: Date, + default: null, + }, + ipAddress: { + type: String, + default: "", + }, + userAgent: { + type: String, + default: "", + }, + }, + { + timestamps: true, + } +); + +// Index for faster queries +appointmentSubmissionSchema.index({ status: 1, createdAt: -1 }); +appointmentSubmissionSchema.index({ email: 1 }); +appointmentSubmissionSchema.index({ appointmentDate: 1 }); + +module.exports = mongoose.model("AppointmentSubmission", appointmentSubmissionSchema); diff --git a/models/contact.js b/models/contact.js index 65776f9..26df236 100644 --- a/models/contact.js +++ b/models/contact.js @@ -145,6 +145,11 @@ const mapSchema = new mongoose.Schema( trim: true, default: "", }, + embedUrl: { + type: String, + trim: true, + default: "", + }, tileLayer: { type: tileLayerSchema, required: true, @@ -161,11 +166,16 @@ const formFieldSchema = new mongoose.Schema( required: true, trim: true, }, + label: { + type: String, + trim: true, + default: "", + }, type: { type: String, required: true, trim: true, - enum: ["text", "email", "tel", "textarea", "programme"], + enum: ["text", "email", "tel", "textarea", "programme", "date"], }, placeholder: { type: String, @@ -176,6 +186,11 @@ const formFieldSchema = new mongoose.Schema( type: Boolean, default: false, }, + colClass: { + type: String, + trim: true, + default: "col-lg-12", + }, programmeName: { type: String, trim: true, @@ -193,6 +208,16 @@ const submitButtonSchema = new mongoose.Schema( required: true, trim: true, }, + icon: { + type: String, + trim: true, + default: "fa-solid fa-arrow-right", + }, + buttonClass: { + type: String, + trim: true, + default: "theme-btn style-2", + }, }, { _id: false } ); @@ -210,6 +235,11 @@ const formSchema = new mongoose.Schema( trim: true, default: "", }, + description: { + type: String, + trim: true, + default: "", + }, fields: { type: [formFieldSchema], default: [], @@ -335,6 +365,7 @@ contactSchema.statics.migrateFromJson = async function (jsonData) { zoom: jsonData.map?.zoom || 15, location: jsonData.map?.location || "", markerTitle: jsonData.map?.markerTitle || "", + embedUrl: jsonData.map?.embedUrl || "", tileLayer: { url: jsonData.map?.tileLayer?.url || @@ -347,15 +378,20 @@ contactSchema.statics.migrateFromJson = async function (jsonData) { form: { sectionLabel: jsonData.form?.sectionLabel || "", heading: jsonData.form?.heading || "", + description: jsonData.form?.description || "", fields: (jsonData.form?.fields || []).map((field) => ({ name: field.name || "", + label: field.label || "", type: field.type || "text", placeholder: field.placeholder || "", required: field.required || false, + colClass: field.colClass || "col-lg-12", programmeName: field.programmeName || "", })), submitButton: { text: jsonData.form?.submitButton?.text || "Send Message", + icon: jsonData.form?.submitButton?.icon || "fa-solid fa-arrow-right", + buttonClass: jsonData.form?.submitButton?.buttonClass || "theme-btn style-2", }, }, }; diff --git a/models/contactSubmission.js b/models/contactSubmission.js new file mode 100644 index 0000000..ce7f5b9 --- /dev/null +++ b/models/contactSubmission.js @@ -0,0 +1,74 @@ +const mongoose = require("mongoose"); + +/** + * Schema for Contact Form Submissions + * Stores user inquiries from the contact form + */ +const contactSubmissionSchema = new mongoose.Schema( + { + name: { + type: String, + required: [true, "Name is required"], + trim: true, + maxlength: [100, "Name cannot exceed 100 characters"], + }, + email: { + type: String, + required: [true, "Email is required"], + trim: true, + lowercase: true, + match: [/^\S+@\S+\.\S+$/, "Please enter a valid email"], + }, + phone: { + type: String, + trim: true, + default: "", + }, + address: { + type: String, + trim: true, + default: "", + }, + date: { + type: String, + trim: true, + default: "", + }, + message: { + type: String, + trim: true, + default: "", + }, + status: { + type: String, + enum: ["pending", "read", "replied", "archived"], + default: "pending", + }, + notes: { + type: String, + trim: true, + default: "", + }, + repliedAt: { + type: Date, + default: null, + }, + ipAddress: { + type: String, + default: "", + }, + userAgent: { + type: String, + default: "", + }, + }, + { + timestamps: true, + } +); + +// Index for faster queries +contactSubmissionSchema.index({ status: 1, createdAt: -1 }); +contactSubmissionSchema.index({ email: 1 }); + +module.exports = mongoose.model("ContactSubmission", contactSubmissionSchema); diff --git a/models/pricing.js b/models/pricing.js new file mode 100644 index 0000000..4f9e931 --- /dev/null +++ b/models/pricing.js @@ -0,0 +1,328 @@ +const mongoose = require("mongoose"); + +// Clear cache +if (mongoose.models.Pricing) { + delete mongoose.models.Pricing; +} +if (mongoose.connection.models.Pricing) { + delete mongoose.connection.models.Pricing; +} + +// Schema for breadcrumb item +const breadcrumbItemSchema = new mongoose.Schema( + { + text: { + type: String, + trim: true, + default: "", + }, + link: { + type: String, + trim: true, + default: "", + }, + }, + { _id: false } +); + +// Schema for hero section +const heroSchema = new mongoose.Schema( + { + title: { + type: String, + trim: true, + default: "Pricing Plan", + }, + backgroundImage: { + type: String, + trim: true, + default: "/assets/img/inner-page/breadcrumb.jpg", + }, + shapeImage: { + type: String, + trim: true, + default: "/assets/img/inner-page/shape.png", + }, + breadcrumb: { + type: [breadcrumbItemSchema], + default: [], + }, + }, + { _id: false } +); + +// Schema for pricing section header +const pricingSectionSchema = new mongoose.Schema( + { + subtitle: { + type: String, + trim: true, + default: "pricing plan", + }, + heading: { + type: String, + trim: true, + default: "Flexible Plans to Suit Every Traveler", + }, + description: { + type: String, + trim: true, + default: "", + }, + }, + { _id: false } +); + +// Schema for individual plan +const planSchema = new mongoose.Schema( + { + name: { + type: String, + trim: true, + required: true, + }, + price: { + type: String, + trim: true, + default: "0", + }, + period: { + type: String, + trim: true, + default: "mo", + }, + currency: { + type: String, + trim: true, + default: "$", + }, + buttonText: { + type: String, + trim: true, + default: "Get Started Today", + }, + buttonLink: { + type: String, + trim: true, + default: "/pricing", + }, + buttonIcon: { + type: String, + trim: true, + default: "fa-solid fa-arrow-right", + }, + style: { + type: String, + trim: true, + enum: ["default", "style-2"], + default: "default", + }, + features: { + type: [String], + default: [], + }, + }, + { _id: false } +); + +// Schema for plans container +const plansSchema = new mongoose.Schema( + { + monthly: { + type: [planSchema], + default: [], + }, + yearly: { + type: [planSchema], + default: [], + }, + }, + { _id: false } +); + +// Schema for testimonial item +const testimonialItemSchema = new mongoose.Schema( + { + name: { + type: String, + trim: true, + default: "", + }, + role: { + type: String, + trim: true, + default: "", + }, + rating: { + type: Number, + min: 1, + max: 5, + default: 5, + }, + content: { + type: String, + trim: true, + default: "", + }, + }, + { _id: false } +); + +// Schema for testimonials section +const testimonialsSchema = new mongoose.Schema( + { + subtitle: { + type: String, + trim: true, + default: "What Our Clients Say", + }, + heading: { + type: String, + trim: true, + default: "Immigration Success Stories", + }, + buttonText: { + type: String, + trim: true, + default: "View All Review", + }, + buttonLink: { + type: String, + trim: true, + default: "/contact", + }, + buttonIcon: { + type: String, + trim: true, + default: "fa-solid fa-arrow-right", + }, + image: { + type: String, + trim: true, + default: "", + }, + items: { + type: [testimonialItemSchema], + default: [], + }, + }, + { _id: false } +); + +// Main Pricing Schema +const pricingSchema = new mongoose.Schema( + { + name: { + type: String, + default: "default", + unique: true, + }, + hero: { + type: heroSchema, + default: () => ({}), + }, + pricingSection: { + type: pricingSectionSchema, + default: () => ({}), + }, + plans: { + type: plansSchema, + default: () => ({}), + }, + testimonials: { + type: testimonialsSchema, + default: () => ({}), + }, + }, + { + timestamps: true, + } +); + +// Migration method to import data from JSON +pricingSchema.statics.migrateFromJson = async function (jsonData) { + try { + // Check if default pricing exists + const existingPricing = await this.findOne({ name: "default" }); + + // Process data from JSON + const processedData = { + hero: { + title: jsonData.hero?.title || "Pricing Plan", + backgroundImage: jsonData.hero?.backgroundImage || "/assets/img/inner-page/breadcrumb.jpg", + shapeImage: jsonData.hero?.shapeImage || "/assets/img/inner-page/shape.png", + breadcrumb: (jsonData.hero?.breadcrumb || []).map((item) => ({ + text: item.text || "", + link: item.link || "", + })), + }, + pricingSection: { + subtitle: jsonData.pricingSection?.subtitle || "pricing plan", + heading: jsonData.pricingSection?.heading || "Flexible Plans to Suit Every Traveler", + description: jsonData.pricingSection?.description || "", + }, + plans: { + monthly: (jsonData.plans?.monthly || []).map((plan) => ({ + name: plan.name || "", + price: plan.price || "0", + period: plan.period || "mo", + currency: plan.currency || "$", + buttonText: plan.buttonText || "Get Started Today", + buttonLink: plan.buttonLink || "/pricing", + buttonIcon: plan.buttonIcon || "fa-solid fa-arrow-right", + style: plan.style || "default", + features: plan.features || [], + })), + yearly: (jsonData.plans?.yearly || []).map((plan) => ({ + name: plan.name || "", + price: plan.price || "0", + period: plan.period || "mo", + currency: plan.currency || "$", + buttonText: plan.buttonText || "Get Started Today", + buttonLink: plan.buttonLink || "/pricing", + buttonIcon: plan.buttonIcon || "fa-solid fa-arrow-right", + style: plan.style || "default", + features: plan.features || [], + })), + }, + testimonials: { + subtitle: jsonData.testimonials?.subtitle || "What Our Clients Say", + heading: jsonData.testimonials?.heading || "Immigration Success Stories", + buttonText: jsonData.testimonials?.buttonText || "View All Review", + buttonLink: jsonData.testimonials?.buttonLink || "/contact", + buttonIcon: jsonData.testimonials?.buttonIcon || "fa-solid fa-arrow-right", + image: jsonData.testimonials?.image || "", + items: (jsonData.testimonials?.items || []).map((item) => ({ + name: item.name || "", + role: item.role || "", + rating: item.rating || 5, + content: item.content || "", + })), + }, + }; + + if (existingPricing) { + // Update existing pricing + existingPricing.hero = processedData.hero; + existingPricing.pricingSection = processedData.pricingSection; + existingPricing.plans = processedData.plans; + existingPricing.testimonials = processedData.testimonials; + await existingPricing.save(); + console.log("Pricing data updated successfully"); + return existingPricing; + } else { + // Create new pricing + const newPricing = await this.create({ + name: "default", + ...processedData, + }); + console.log("Pricing data imported successfully"); + return newPricing; + } + } catch (error) { + console.error("Error migrating pricing data:", error); + throw error; + } +}; + +module.exports = mongoose.model("Pricing", pricingSchema); diff --git a/models/service.js b/models/service.js new file mode 100644 index 0000000..a1d0f2e --- /dev/null +++ b/models/service.js @@ -0,0 +1,118 @@ +const mongoose = require("mongoose"); + +// Define sub-schemas first +const authorSchema = new mongoose.Schema( + { + name: String, + type: String, + }, + { _id: false }, +); + +const clientReviewSchema = new mongoose.Schema( + { + id: String, + rating: Number, + content: String, + author: authorSchema, + icon: String, + }, + { _id: false }, +); + +const featureSchema = new mongoose.Schema( + { + title: String, + description: String, + }, + { _id: false }, +); + +const faqSchema = new mongoose.Schema( + { + id: String, + question: String, + answer: String, + isExpanded: { type: Boolean, default: false }, + }, + { _id: false }, +); + +const serviceDetailsSchema = new mongoose.Schema( + { + title: String, + description: String, + mainImage: String, + overviewTitle: String, + overviewDescription: String, + additionalDescription: String, + keyFeaturesTitle: String, + keyFeaturesImage: String, + features: [featureSchema], + faqTitle: String, + faqImage: String, + faq: [faqSchema], + }, + { _id: false }, +); + +// Main service page schema +const serviceSchema = new mongoose.Schema( + { + pageTitle: String, + + // Main services section + services: { + title: { + subTitle: String, + mainTitle: String, + }, + items: [ + { + slug: String, + name: String, + description: String, + image: String, + layout: String, + details: serviceDetailsSchema, + }, + ], + }, + + // Destination countries section + destinations: { + backgroundImage: String, + title: { + subTitle: String, + mainTitle: String, + }, + }, + + // Visa types section + visas: { + items: [ + { + id: String, + number: String, + name: String, + description: String, + buttonText: String, + buttonLink: String, + }, + ], + }, + + // Client reviews section + reviews: { + title: { + subTitle: String, + mainTitle: String, + }, + thumb: String, + items: [clientReviewSchema], + }, + }, + { timestamps: true }, +); + +module.exports = mongoose.model("Service", serviceSchema); diff --git a/public/img/default.jpg b/public/img/default.jpg new file mode 100644 index 0000000..b0e2512 Binary files /dev/null and b/public/img/default.jpg differ diff --git a/public/uploads/service/03.jpg b/public/uploads/service/03.jpg new file mode 100644 index 0000000..1b47583 Binary files /dev/null and b/public/uploads/service/03.jpg differ diff --git a/public/uploads/service/404.png b/public/uploads/service/404.png new file mode 100644 index 0000000..f7c61d3 Binary files /dev/null and b/public/uploads/service/404.png differ diff --git a/public/uploads/service/Dell_Inspiron15.webp b/public/uploads/service/Dell_Inspiron15.webp new file mode 100644 index 0000000..00b169d Binary files /dev/null and b/public/uploads/service/Dell_Inspiron15.webp differ diff --git a/public/uploads/service/intro.jpg b/public/uploads/service/intro.jpg new file mode 100644 index 0000000..2540cd4 Binary files /dev/null and b/public/uploads/service/intro.jpg differ diff --git a/public/uploads/service/iphone15.webp b/public/uploads/service/iphone15.webp new file mode 100644 index 0000000..d158840 Binary files /dev/null and b/public/uploads/service/iphone15.webp differ diff --git a/public/uploads/service/smart_samsung_55inch.jpg b/public/uploads/service/smart_samsung_55inch.jpg new file mode 100644 index 0000000..07ede78 Binary files /dev/null and b/public/uploads/service/smart_samsung_55inch.jpg differ diff --git a/routes/admin.js b/routes/admin.js index 7fe3e75..b14f98c 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -23,6 +23,7 @@ const insuranceController = require("../controllers/insuranceController"); const activityController = require("../controllers/activityController"); const bookingSubmissionController = require("../controllers/bookingSubmissionController"); +const serviceController = require("../controllers/serviceController"); // Blog controllers const blogController = require("../controllers/blogController"); @@ -51,28 +52,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 @@ -82,7 +83,7 @@ router.get("/form", ensureAuthenticated, formController.index); router.post( "/form/update", ensureAuthenticated, - formController.updateDefaultForm + formController.updateDefaultForm, ); // Upload routes @@ -98,23 +99,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 @@ -123,22 +124,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); @@ -153,89 +154,135 @@ router.post("/contact/update", ensureAuthenticated, contactController.update); router.get( "/contact/data", ensureAuthenticated, - contactController.getContactData + contactController.getContactData, ); +// Contact submissions management +router.get( + "/contact/submissions", + ensureAuthenticated, + contactController.getSubmissions +); +router.put( + "/contact/submissions/:id", + ensureAuthenticated, + contactController.updateSubmissionStatus +); + +// Appointment management +const appointmentController = require("../controllers/appointmentController"); +router.get( + "/appointments", + ensureAuthenticated, + appointmentController.getAppointments +); +router.get( + "/appointments/:id", + ensureAuthenticated, + appointmentController.getAppointmentById +); +router.put( + "/appointments/:id", + ensureAuthenticated, + appointmentController.updateAppointmentStatus +); +router.delete( + "/appointments/:id", + ensureAuthenticated, + 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); + +// 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); + // Activity CRUD routes 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 @@ -244,7 +291,7 @@ router.delete( router.get( "/activity/:id/preview", ensureAuthenticated, - activityController.preview + activityController.preview, ); // FAQ routes - Thêm vào đây @@ -255,8 +302,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); @@ -272,80 +327,138 @@ 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, +); + +// Service routes +router.get("/service", ensureAuthenticated, serviceController.index); +router.post("/service/update", ensureAuthenticated, serviceController.update); +router.post( + "/service/generate-slug", + ensureAuthenticated, + serviceController.generateSlug, +); +router.get("/service/:slug/edit", ensureAuthenticated, serviceController.edit); +router.post( + "/service/:slug/edit", + ensureAuthenticated, + serviceController.updateService, +); +router.get( + "/service/:slug/details", + ensureAuthenticated, + serviceController.details, +); +router.post( + "/service/:slug/details/update", + ensureAuthenticated, + serviceController.updateDetails, +); + // 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'); - + // 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', images: imagePaths, - user: req.session.user + user: req.session.user, }); }); diff --git a/routes/index.js b/routes/index.js index 92fcd8d..7b58529 100644 --- a/routes/index.js +++ b/routes/index.js @@ -13,12 +13,13 @@ 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"); const bookingSubmissionController = require("../controllers/bookingSubmissionController"); +const serviceController = require("../controllers/serviceController"); // Blog controllers const blogController = require("../controllers/blogController"); const blogCategoryController = require("../controllers/blogCategoryController"); @@ -55,6 +56,18 @@ router.get("/api/footer", footerController.api); // Contact API route router.get("/api/contact", contactController.api); +// Contact form submission (public) +router.post("/api/contact/submit", contactController.submitForm); + +// Appointment API +const appointmentController = require("../controllers/appointmentController"); +router.get("/api/appointment", appointmentController.api); +router.post("/api/appointment/submit", appointmentController.submitAppointment); + +// Pricing API +const pricingController = require("../controllers/pricingController"); +router.get("/api/pricing", pricingController.api); + router.get("/api/faq", faqController.api); // Safety API route router.get("/api/safety", safetyController.api); @@ -66,8 +79,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); @@ -76,14 +88,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(), @@ -100,30 +112,42 @@ 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")); }); // Blog API Routes @@ -153,4 +177,17 @@ router.get("/api/blog/:slug", blogController.apiShow); // // API route cho blog detail // router.get('/api/blog-detail', blogDetailController.api); +/* CMS - Hailearning + */ +// service +router.get("/service", serviceController.index); +router.post("/service", serviceController.update); +router.get("/api/service", serviceController.api); + +// Service details by slug +router.get("/api/service/:slug", serviceController.getServiceBySlug); + +// Service slugs list +router.get("/api/service-slugs", serviceController.getServiceSlugs); + module.exports = router; diff --git a/scripts/2025_12_02_114127_contact.js b/scripts/2025_12_02_114127_contact.js new file mode 100644 index 0000000..c24e849 --- /dev/null +++ b/scripts/2025_12_02_114127_contact.js @@ -0,0 +1,38 @@ +require("dotenv").config(); +const fs = require("fs").promises; +const path = require("path"); +const connectDB = require("../config/database"); +const Contact = require("../models/contact"); +const mongoose = require("mongoose"); + +/** + * Migration: contact + * Migrate contact data from contact-data.json + */ +async function migrate() { + try { + await connectDB(); + + // Read contact-data.json file + const contactJsonPath = path.join(__dirname, "../data/contact.json"); + const contactData = JSON.parse(await fs.readFile(contactJsonPath, "utf8")); + + // Migrate data using the model's static method + await Contact.migrateFromJson(contactData); + + console.log("Contact migration completed successfully"); + + await mongoose.disconnect(); + process.exit(0); + } catch (error) { + console.error("Migration error:", error); + process.exit(1); + } +} + +// Chạy migration nếu được gọi trực tiếp +if (require.main === module) { + migrate(); +} + +module.exports = { migrate }; diff --git a/scripts/2026_02_02_131615_service.js b/scripts/2026_02_02_131615_service.js new file mode 100644 index 0000000..af24f29 --- /dev/null +++ b/scripts/2026_02_02_131615_service.js @@ -0,0 +1,186 @@ +require("dotenv").config(); +const fs = require("fs").promises; +const path = require("path"); +const mongoose = require("mongoose"); + +const connectDB = require("../config/database"); +const Service = require("../models/service"); + +/** + * Transform service.json data to match Service schema + */ +function transformServiceData(sourceData) { + return { + pageTitle: sourceData.pageTitle || "", + + // Breadcrumb navigation section + breadcrumb: { + title: sourceData?.breadcrumb?.title || "", + backgroundImage: sourceData?.breadcrumb?.backgroundImage || "", + shape: sourceData?.breadcrumb?.shape || "", + items: Array.isArray(sourceData?.breadcrumb?.items) + ? sourceData.breadcrumb.items.map((item) => ({ + label: item.label || "", + href: item.href || "", + })) + : [], + }, + + // Main services section + services: { + title: { + subTitle: sourceData?.services?.title?.subTitle || "", + mainTitle: sourceData?.services?.title?.mainTitle || "", + }, + items: Array.isArray(sourceData?.services?.items) + ? sourceData.services.items.map((service) => ({ + slug: service.slug || "", + name: service.name || "", + description: service.description || "", + image: service.image || "", + layout: service.layout || "", + details: { + title: service.details?.title || "", + description: service.details?.description || "", + mainImage: service.details?.mainImage || "", + overviewTitle: service.details?.overviewTitle || "", + overviewDescription: service.details?.overviewDescription || "", + additionalDescription: + service.details?.additionalDescription || "", + keyFeaturesTitle: service.details?.keyFeaturesTitle || "", + keyFeaturesImage: service.details?.keyFeaturesImage || "", + features: Array.isArray(service.details?.features) + ? service.details.features.map((feature) => ({ + icon: feature.icon || "", + title: feature.title || "", + description: feature.description || "", + })) + : [], + faqTitle: service.details?.faqTitle || "", + faqImage: service.details?.faqImage || "", + faq: Array.isArray(service.details?.faq) + ? service.details.faq.map((faqItem) => ({ + id: faqItem.id || "", + question: faqItem.question || "", + answer: faqItem.answer || "", + isExpanded: faqItem.isExpanded || false, + })) + : [], + }, + })) + : [], + }, + + // Destination countries section + destinations: { + backgroundImage: sourceData?.destinations?.backgroundImage || "", + title: { + subTitle: sourceData?.destinations?.title?.subTitle || "", + mainTitle: sourceData?.destinations?.title?.mainTitle || "", + }, + items: Array.isArray(sourceData?.destinations?.items) + ? sourceData.destinations.items.map((country) => ({ + id: country.id || "", + name: country.name || "", + description: country.description || "", + image: country.image || "", + icon: country.icon || "", + link: country.link || "", + })) + : [], + }, + + // Visa types section + visas: { + items: Array.isArray(sourceData?.visas?.items) + ? sourceData.visas.items.map((visa) => ({ + id: visa.id || "", + number: visa.number || "", + name: visa.name || "", + description: visa.description || "", + buttonText: visa.buttonText || "", + buttonLink: visa.buttonLink || "", + })) + : [], + }, + + // Client reviews section + reviews: { + title: { + subTitle: sourceData?.reviews?.title?.subTitle || "", + mainTitle: sourceData?.reviews?.title?.mainTitle || "", + }, + viewAllButton: { + text: sourceData?.reviews?.viewAllButton?.text || "", + icon: sourceData?.reviews?.viewAllButton?.icon || "", + link: sourceData?.reviews?.viewAllButton?.link || "", + }, + thumb: sourceData?.reviews?.thumb || "", + items: Array.isArray(sourceData?.reviews?.items) + ? sourceData.reviews.items.map((review) => ({ + id: review.id || "", + rating: review.rating || 5, + content: review.content || "", + author: { + name: review.author?.name || "", + type: review.author?.type || "", + }, + icon: review.icon || "", + })) + : [], + navigation: { + prevButton: sourceData?.reviews?.navigation?.prevButton || "", + nextButton: sourceData?.reviews?.navigation?.nextButton || "", + prevIcon: sourceData?.reviews?.navigation?.prevIcon || "", + nextIcon: sourceData?.reviews?.navigation?.nextIcon || "", + }, + }, + + updatedAt: new Date(), + }; +} + +/** + * Migration function for service page data + */ +async function migrateServiceData() { + try { + await connectDB(); + console.log("🚀 Starting service page migration..."); + + // Clear existing service documents + await Service.deleteMany({}); + console.log("🗑️ Cleared existing service documents"); + + // Read service.json file + const serviceJsonPath = path.join(__dirname, "..", "data", "service.json"); + const rawJsonData = await fs.readFile(serviceJsonPath, "utf8"); + const sourceServiceData = JSON.parse(rawJsonData); + + // Transform data to match schema + const transformedServiceData = transformServiceData(sourceServiceData); + + // Create new service document + const newService = new Service(transformedServiceData); + const savedService = await newService.save(); + + console.log("✅ Service page migration completed successfully!"); + console.log(`📄 Service document ID: ${savedService._id}`); + + await mongoose.disconnect(); + process.exit(0); + } catch (error) { + console.error("❌ Service migration error:", error); + process.exit(1); + } +} + +// Run migration if called directly +if (require.main === module) { + migrateServiceData(); +} + +module.exports = { + migrate: migrateServiceData, + transformServiceData, +}; diff --git a/scripts/2026_02_03_appointment.js b/scripts/2026_02_03_appointment.js new file mode 100644 index 0000000..edf5315 --- /dev/null +++ b/scripts/2026_02_03_appointment.js @@ -0,0 +1,71 @@ +/** + * Migration script for Appointment data + * Imports data from appointment.json to MongoDB + * + * Run: node scripts/2026_02_03_appointment.js + */ + +require("dotenv").config(); +const mongoose = require("mongoose"); +const fs = require("fs"); +const path = require("path"); + +// Connect to MongoDB +const connectDB = async () => { + try { + await mongoose.connect(process.env.MONGODB_URI); + console.log("MongoDB connected successfully"); + } catch (error) { + console.error("MongoDB connection error:", error); + process.exit(1); + } +}; + +const runMigration = async () => { + try { + await connectDB(); + + // Load Appointment model + const Appointment = require("../models/appointment"); + + // Load JSON data + const jsonPath = path.join(__dirname, "../data/appointment.json"); + + if (!fs.existsSync(jsonPath)) { + console.log("appointment.json not found, creating default data..."); + const defaultData = { + hero: { + title: "Make Appointment", + backgroundImage: "", + subtitle: "", + heading: "", + description: "", + }, + visaOptions: [], + form: { + heading: "Request Appointment", + fields: [], + submitButton: { + text: "Request Appointment", + icon: "fa-solid fa-arrow-right", + buttonClass: "theme-btn", + }, + }, + }; + await Appointment.migrateFromJson(defaultData); + } else { + const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8")); + console.log("Loaded appointment.json data"); + await Appointment.migrateFromJson(jsonData); + } + + console.log("✅ Appointment migration completed successfully!"); + } catch (error) { + console.error("❌ Migration failed:", error); + } finally { + await mongoose.connection.close(); + console.log("MongoDB connection closed"); + } +}; + +runMigration(); diff --git a/server.js b/server.js index b574c47..4c46226 100644 --- a/server.js +++ b/server.js @@ -42,8 +42,6 @@ app.use( }, express.static(path.join(__dirname, "assets")), ); - -// Serve static files from public directory (uploads, etc.) app.use( "/uploads", (req, res, next) => { diff --git a/services/service.service.js b/services/service.service.js new file mode 100644 index 0000000..64483a6 --- /dev/null +++ b/services/service.service.js @@ -0,0 +1,43 @@ +const Service = require("../models/service"); + +const getServiceData = async () => { + const service = await Service.findOne().sort({ updatedAt: -1 }); + console.log("check layout", service.services.items.layout); + + if (!service) { + return { + pageTitle: "", + services: { + title: { + subTitle: "", + mainTitle: "", + }, + items: [], + }, + destinations: { + backgroundImage: "", + title: { + subTitle: "", + mainTitle: "", + }, + }, + visas: { + items: [], + }, + reviews: { + title: { + subTitle: "", + mainTitle: "", + }, + thumb: "", + items: [], + }, + }; + } + + return service; +}; + +module.exports = { + getServiceData, +}; diff --git a/utils/imageHelper.js b/utils/imageHelper.js index b772b1d..bce7697 100644 --- a/utils/imageHelper.js +++ b/utils/imageHelper.js @@ -1,37 +1,68 @@ /** * Thêm BACKEND_URL vào đường dẫn hình ảnh - * @param {Object} data - Dữ liệu cần xử lý + * @param {Object} data - Dữ liệu cần xử lý * @returns {Object} - Dữ liệu đã được xử lý với đường dẫn hình ảnh đầy đủ */ function addBaseUrlToImages(data, baseUrl) { // baseUrl can be passed explicitly (e.g., from req), otherwise fall back to env - const BACKEND_URL = baseUrl || process.env.BACKEND_URL || ''; - + const BACKEND_URL = baseUrl || process.env.BACKEND_URL || ""; + // Tạo bản sao sâu để tránh thay đổi dữ liệu gốc const processedData = JSON.parse(JSON.stringify(data)); - + // Hàm đệ quy để xử lý tất cả các URL hình ảnh trong đối tượng const processObject = (obj) => { - if (!obj || typeof obj !== 'object') return; - - Object.keys(obj).forEach(key => { + if (!obj || typeof obj !== "object") return; + + Object.keys(obj).forEach((key) => { // Kiểm tra nếu thuộc tính chứa đường dẫn hình ảnh bắt đầu bằng /uploads/ - if (typeof obj[key] === 'string' && obj[key].startsWith('/uploads/')) { + if (typeof obj[key] === "string" && obj[key].startsWith("/uploads/")) { // Thêm BACKEND_URL nếu đường dẫn chưa có http - if (!obj[key].startsWith('http')) { + if (!obj[key].startsWith("http")) { obj[key] = `${BACKEND_URL}${obj[key]}`; } - } else if (typeof obj[key] === 'object') { + } else if (typeof obj[key] === "object") { // Đệ quy xử lý các đối tượng và mảng lồng nhau processObject(obj[key]); } }); }; - + processObject(processedData); return processedData; } +/** + * Tạo full URL cho ảnh từ đường dẫn tương đối - dùng cho EJS templates + * @param {string} imagePath - Đường dẫn ảnh + * @param {string} backendUrl - Backend URL (optional, sẽ lấy từ env nếu không có) + * @returns {string} - Full URL của ảnh + */ +function getFullImageUrl(imagePath, backendUrl = null) { + if (!imagePath) return ""; + + // Nếu đã là full URL thì return luôn + if (imagePath.startsWith("http")) { + return imagePath; + } + + // Lấy backend URL + const baseUrl = ( + backendUrl || + process.env.BACKEND_URL || + "http://localhost:3001" + ).replace(/\/$/, ""); + + // Xử lý đường dẫn + let imgSrc = imagePath; + if (!imgSrc.startsWith("/")) { + imgSrc = "/" + imgSrc; + } + + return baseUrl + imgSrc; +} + module.exports = { - addBaseUrlToImages -}; \ No newline at end of file + addBaseUrlToImages, + getFullImageUrl, +}; diff --git a/views/admin/appointment/index.ejs b/views/admin/appointment/index.ejs new file mode 100644 index 0000000..707fba1 --- /dev/null +++ b/views/admin/appointment/index.ejs @@ -0,0 +1,791 @@ +
Edit content displayed on Appointment page
+| Date | +Name | +Phone | +Message | +Status | +Action | +|
|---|---|---|---|---|---|---|
| + <%= new + Date(submission.createdAt).toLocaleDateString() + %> + <%= new + Date(submission.createdAt).toLocaleTimeString([], + {hour: '2-digit' , minute:'2-digit'}) %> + | ++ <%= submission.name %> + | ++ <%= submission.email %> + | ++ <%= submission.phone || '-' %> + | ++ + | ++ <% let statusClass='bg-secondary' ; + if(submission.status==='pending' ) + statusClass='bg-warning text-dark' ; + if(submission.status==='read' ) + statusClass='bg-info text-dark' ; + if(submission.status==='replied' ) + statusClass='bg-success' ; + if(submission.status==='archived' ) + statusClass='bg-secondary' ; %> + + <%= submission.status %> + + | +
+ |
+
| No + submissions found | +||||||
Manage pricing page
+Manage terms
+/api/header/api/home/api/about/api/about-us/api/faq/api/terms/api/travel/api/safety/api/camp-location/api/menu-tree/api/contact/api/camp-locationClick the Edit button to make changes to your data.
++ Click the Edit button to make changes to your data. +
Edit content displayed on Pricing page
+Edit detailed content for <%= service.name %> service
+Update service information and settings
+Manage services and their detailed content
+