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/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/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/routes/admin.js b/routes/admin.js index bfaa9dd..fb7d1ff 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -157,6 +157,52 @@ router.get( 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( @@ -355,9 +401,9 @@ router.post( // Test Image Paths route router.get("/test-images", ensureAuthenticated, (req, res) => { - const fs = require("fs"); - const path = require("path"); - const campLocationData = require("../data/camp-location.json"); + const fs = require('fs'); + const path = require('path'); + const campLocationData = require('../data/camp-location.json'); // Collect all image paths const imagePaths = []; @@ -408,9 +454,9 @@ router.get("/test-images", ensureAuthenticated, (req, res) => { }); } - res.render("admin/test-images", { - layout: "layouts/admin", - title: "Test Image Paths", + res.render('admin/test-images', { + layout: 'layouts/admin', + title: 'Test Image Paths', images: imagePaths, user: req.session.user, }); diff --git a/routes/index.js b/routes/index.js index e0796c1..3322df9 100644 --- a/routes/index.js +++ b/routes/index.js @@ -56,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); 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_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 8b1dda4..eb71f17 100644 --- a/server.js +++ b/server.js @@ -42,8 +42,6 @@ app.use( }, express.static(path.join(__dirname, "assets")), ); -app.use("/img", express.static(path.join(process.cwd(), "public/img"))); -// Serve static files from public directory (uploads, etc.) app.use( "/uploads", (req, res, next) => { @@ -55,6 +53,11 @@ app.use( express.static(path.join(__dirname, "public", "uploads")), ); +// Serve other public files +app.use( + express.static(path.join(__dirname, "public")), +); + // Serve other public files app.use(express.static(path.join(__dirname, "public"))); // Session configuration 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 @@ +
+
+
+

+ <%= title %> +

+

Edit content displayed on Appointment page

+
+
+ + View Appointment Page + +
+
+ +
+
+
+ + + + + + +
+ + +
+
+ +
+
+
+
Hero Section
+
+
+ +
+ + +
+ Recommended size: 1920x1080px +
+
+
+ <% if (data.hero?.backgroundImage) { %> + <% let heroImgSrc=data.hero.backgroundImage; if (heroImgSrc && + !heroImgSrc.startsWith('http://') && + !heroImgSrc.startsWith('https://')) { + heroImgSrc=heroImgSrc.startsWith('/') ? heroImgSrc : '/' + + heroImgSrc; } %> + Background image preview + + <% } else { %> +
+ Image preview +
+ <% } %> +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+
+
+
Visa Options
+ +
+

These options will appear in the visa type selection + dropdown on the appointment form.

+
+ <% if (data.visaOptions && data.visaOptions.length> 0) { %> + <% data.visaOptions.forEach((option, index)=> { %> +
+ + + +
+ <% }); %> + <% } %> +
+
+
+
+ + +
+
+
+
Form Settings
+
+
+ + +
+
+ + +
+ + + +
+ +
+ +
+
Form Fields
+ +
+ +
+ <% if (data.form?.fields && data.form.fields.length> 0) { %> + <% data.form.fields.forEach((field, index)=> { %> +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ > +
+
+
+ + +
+
+ +
+
+ <% }); %> + <% } %> +
+
+
+
+ + +
+
+
+
+
Recent Submissions
+
+ + +
+ +
+ + +
+
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + <% if (locals.submissions && submissions.length> 0) { %> + <% submissions.forEach(submission=> { %> + + + + + + + + + + + <% }); %> + <% } else { %> + + + + <% } %> + +
DateNameContactAppt DateVisa TypesMessageStatusAction
+ <%= new + Date(submission.createdAt).toLocaleDateString() + %> +
+ + <%= new + Date(submission.createdAt).toLocaleTimeString([], + {hour: '2-digit' , minute:'2-digit'}) %> + +
+ <%= submission.name %> + +
+ + <%= submission.email %> + + <% if(submission.phone) { %> + + <%= submission.phone %> + + <% } %> +
+
+ <%= submission.appointmentDate || '-' %> + + <% if (submission.visaTypes && + submission.visaTypes.length> 0) { %> + <% submission.visaTypes.forEach(type=> { %> + + <%= type %> + + <% }); %> + <% } else { %> + - + <% } %> + + <% if (submission.message) { %> +
+ <%= submission.message %> +
+ <% } else { %> + - + <% } %> +
+ <% let statusClass='bg-secondary' ; + if(submission.status==='pending' ) + statusClass='bg-warning text-dark' ; + if(submission.status==='confirmed' ) + statusClass='bg-success' ; + if(submission.status==='completed' ) + statusClass='bg-info text-dark' ; + if(submission.status==='cancelled' ) + statusClass='bg-danger' ; %> + + <%= submission.status %> + + + +
No + submissions found
+
+
+ Showing last 50 submissions +
+
+
+
+
+
+
+ + +
+ + +
+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/views/admin/contact/index.ejs b/views/admin/contact/index.ejs index 2ce659e..ba96344 100644 --- a/views/admin/contact/index.ejs +++ b/views/admin/contact/index.ejs @@ -15,8 +15,7 @@
-
+ @@ -47,6 +46,11 @@ Form +
@@ -61,10 +65,12 @@
+ name="heroBackgroundImage" + value="<%= data.hero?.backgroundImage || '' %>">
@@ -73,23 +79,26 @@
<% if (data.hero?.backgroundImage) { %> - <% - let heroImgSrc = data.hero.backgroundImage; - if (heroImgSrc && !heroImgSrc.startsWith('http://') && !heroImgSrc.startsWith('https://')) { - heroImgSrc = heroImgSrc.startsWith('/') ? heroImgSrc : '/' + heroImgSrc; - } - %> - Background image preview - - <% } else { %> -
- Image preview -
- <% } %> + <% let heroImgSrc=data.hero.backgroundImage; if (heroImgSrc && + !heroImgSrc.startsWith('http://') && + !heroImgSrc.startsWith('https://')) { + heroImgSrc=heroImgSrc.startsWith('/') ? heroImgSrc : '/' + + heroImgSrc; } %> + Background image preview + + <% } else { %> +
+ Image preview +
+ <% } %>
@@ -101,12 +110,17 @@ - + - - - - + + + + @@ -117,126 +131,246 @@
Contact Cards
-
- <% if (data.contactCards && data.contactCards.length > 0) { %> - <% data.contactCards.forEach((card, index) => { %> - <% - const iconSource = card.iconSource || (card.iconType && card.iconType.startsWith('/uploads/') ? 'image' : 'fontawesome'); - const isImageIcon = iconSource === 'image'; - const faIconValue = !isImageIcon ? (card.iconType || '') : ''; - const imageIconValue = isImageIcon ? (card.iconType || '') : ''; - %> -
-
-
-
- - -
-
- - -
-
- -
- - data-index="<%= index %>" onchange="handleIconSourceChange(this)"> - - - - data-index="<%= index %>" onchange="handleIconSourceChange(this)"> - -
- - -
- - + + + + + + + - Choose a Font Awesome icon from the list -
- <% if (faIconValue) { %> - - <% } %> +
+
+ + +
+
+ +
+ + data-index="<%= index %>" + onchange="handleIconSourceChange(this)"> + + + + data-index="<%= index %>" + onchange="handleIconSourceChange(this)"> + +
+ + +
+ + + Choose a Font + Awesome icon from the list +
+ <% if (faIconValue) { %> + + <% } %> +
+
+ + +
+ +
+ + +
+ <% if (imageIconValue) { %> + Icon preview + <% } else { %> + + <% } %> + Upload + a custom icon image for this + contact card
- - -
- -
- - -
- <% if (imageIconValue) { %> - Icon preview - <% } else { %> - - <% } %> - Upload a custom icon image for this contact card +
+ + + Enter each content + item on a new line
-
- - - Enter each content item on a new line -
+
-
-
- <% }); %> - <% } %> + <% }); %> + <% } %>
@@ -251,44 +385,61 @@
+ value="<%= data.map?.markerTitle || '' %>" + placeholder="e.g., Our Office">
- Enter address - map will be automatically shown + value="<%= data.map?.location || '' %>" + placeholder="e.g., 123 Main St, City, Country"> + Enter address - map will be automatically + shown
-
- <% if (data.map?.location && data.map?.coordinates?.lat && data.map?.coordinates?.lng) { %> - <% - const lat = data.map.coordinates.lat; - const lng = data.map.coordinates.lng; - const zoom = data.map.zoom || 15; - const markerTitle = data.map.markerTitle || data.map.location; - // Calculate bbox for proper zoom - smaller bbox = more zoomed in - const zoomDelta = { - 10: 0.1, 11: 0.05, 12: 0.025, 13: 0.0125, 14: 0.006, - 15: 0.003, 16: 0.0015, 17: 0.00075, 18: 0.000375 - }; - const delta = zoomDelta[zoom] || 0.003; - const latDelta = delta; - const lngDelta = delta * 1.5; - %> -
- 📍 <%= markerTitle %> + Location: <%= + data.map?.markerTitle || data.map?.location + || 'Location' %>
- <% } else { %> - Enter location above to see map preview - <% } %> + <% } else if (data.map?.location && data.map?.coordinates?.lat + && data.map?.coordinates?.lng) { %> + <% var lat=data.map.coordinates.lat; var + lng=data.map.coordinates.lng; var zoom=data.map.zoom || + 15; var markerTitle=data.map.markerTitle || + data.map.location; var zoomDelta={ 10: 0.1, 11: 0.05, + 12: 0.025, 13: 0.0125, 14: 0.006, 15: 0.003, 16: 0.0015, + 17: 0.00075, 18: 0.000375 }; var delta=zoomDelta[zoom] + || 0.003; var latDelta=delta; var lngDelta=delta * 1.5; + %> + +
+ 📍 <%= markerTitle %> + +
+ <% } else { %> + Enter location above to see map preview + <% } %>
@@ -296,10 +447,14 @@ - - - - + + + + @@ -325,53 +480,87 @@ +
+ + +
+ + +
Form Fields
-
- <% if (data.form?.fields && data.form.fields.length > 0) { %> - <% data.form.fields.forEach((field, index) => { %> + <% if (data.form?.fields && data.form.fields.length> 0) { %> + <% data.form.fields.forEach((field, index)=> { %>
- +
- + + + + +
-
- > + >
-
+
- - Internal name for the programme + + Internal name for the + programme
+ + +
- <% }); %> - <% } %> + <% }); %> + <% } %> +
+
+ + + + +
+
+
+
+
Recent Submissions
+
+ + +
+ +
+ + +
+
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + <% if (locals.submissions && submissions.length> 0) { %> + <% submissions.forEach(submission=> { %> + + + + + + + + + + <% }); %> + <% } else { %> + + + + <% } %> + +
DateNameEmailPhoneMessageStatusAction
+ <%= new + Date(submission.createdAt).toLocaleDateString() + %> + <%= new + Date(submission.createdAt).toLocaleTimeString([], + {hour: '2-digit' , minute:'2-digit'}) %> + + <%= submission.name %> + + <%= submission.email %> + + <%= submission.phone || '-' %> + +
+ <%= submission.message %> +
+
+ <% 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
+
+
+ Showing last 50 submissions
@@ -404,15 +710,126 @@
+ + + + + - + \ No newline at end of file diff --git a/views/admin/dashboard.ejs b/views/admin/dashboard.ejs index 9fb5879..d6a9534 100644 --- a/views/admin/dashboard.ejs +++ b/views/admin/dashboard.ejs @@ -11,18 +11,9 @@
-
- +
+
Home
@@ -38,18 +29,9 @@
-
- +
+
Header & Menu
@@ -65,18 +47,9 @@
-
- +
+
Footer
@@ -92,18 +65,9 @@
-
- +
+
About Us
@@ -119,18 +83,9 @@
-
- +
+
Contact
@@ -146,18 +101,9 @@
-
- +
+
FAQ
@@ -173,18 +119,45 @@
-
- +
+ +
+
+
Appointment
+

Manage appointment page

+
+
+ + Edit + +
+
+ +
+
+
+
+ +
+
+
Pricing
+

Manage pricing page

+
+
+ + Edit + +
+
+ +
+
+
+
+
Terms & Conditions
@@ -203,18 +176,9 @@
-
- +
+
Travel
@@ -230,18 +194,9 @@
-
- +
+
Safety
@@ -257,18 +212,9 @@
-
- +
+
Camp Location
@@ -287,18 +233,9 @@
-
- +
+
Activities
@@ -362,18 +299,9 @@
-
- +
+
Menu Header API
@@ -400,18 +328,9 @@
-
- +
+
Home API
@@ -438,18 +357,9 @@
-
- +
+
About API
@@ -476,18 +386,9 @@
-
- +
+
About Us API
@@ -514,18 +415,9 @@
-
- +
+
FAQ API
@@ -552,18 +444,9 @@
-
- +
+
Terms & Conditions API
@@ -590,18 +473,9 @@
-
- +
+
Travel API
@@ -628,18 +502,9 @@
-
- +
+
Safety API
@@ -666,18 +531,9 @@
-
- +
+
Camp Location API
@@ -705,18 +561,9 @@
-
- +
+
Menu Tree API
@@ -743,18 +590,9 @@
-
- +
+
Contact API
@@ -781,56 +619,9 @@
-
- -
- Service API -
- - /api/service - - GET - - API to get service data - - - View - - - - - -
-
- +
+
Camp Location API
@@ -875,18 +666,9 @@
-
- +
+
Version
@@ -899,19 +681,13 @@
-
- +
+
Logged in as
-
+
<%= user.username %>
@@ -919,14 +695,8 @@
-