forked from UKSOURCE/cms.hailearning.edu.vn
feat: Implement core admin panel functionalities including appointment, contact, and pricing management with associated models, controllers, views, and routes.
This commit is contained in:
377
controllers/appointmentController.js
Normal file
377
controllers/appointmentController.js
Normal file
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||||
const Contact = require("../models/contact");
|
const Contact = require("../models/contact");
|
||||||
|
const ContactSubmission = require("../models/contactSubmission");
|
||||||
|
|
||||||
// Get contact data from MongoDB
|
// Get contact data from MongoDB
|
||||||
const getContactData = async () => {
|
const getContactData = async () => {
|
||||||
@@ -60,6 +61,7 @@ exports.index = async (req, res) => {
|
|||||||
zoom: 15,
|
zoom: 15,
|
||||||
location: "",
|
location: "",
|
||||||
markerTitle: "",
|
markerTitle: "",
|
||||||
|
embedUrl: "",
|
||||||
tileLayer: {
|
tileLayer: {
|
||||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
attribution: "",
|
attribution: "",
|
||||||
@@ -70,16 +72,38 @@ exports.index = async (req, res) => {
|
|||||||
form: {
|
form: {
|
||||||
sectionLabel: "",
|
sectionLabel: "",
|
||||||
heading: "",
|
heading: "",
|
||||||
|
description: "",
|
||||||
fields: [],
|
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", {
|
res.render("admin/contact/index", {
|
||||||
title: "Contact Management",
|
title: "Contact Management",
|
||||||
layout: "layouts/main",
|
layout: "layouts/main",
|
||||||
data,
|
data,
|
||||||
|
submissions,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
frontendUrl,
|
frontendUrl,
|
||||||
currentPath: req.path,
|
currentPath: req.path,
|
||||||
user: req.session.user,
|
user: req.session.user,
|
||||||
@@ -140,6 +164,7 @@ exports.update = async (req, res) => {
|
|||||||
zoom: 15,
|
zoom: 15,
|
||||||
location: "",
|
location: "",
|
||||||
markerTitle: "",
|
markerTitle: "",
|
||||||
|
embedUrl: "",
|
||||||
tileLayer: {
|
tileLayer: {
|
||||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
attribution: "",
|
attribution: "",
|
||||||
@@ -150,8 +175,9 @@ exports.update = async (req, res) => {
|
|||||||
form: formData || {
|
form: formData || {
|
||||||
sectionLabel: "",
|
sectionLabel: "",
|
||||||
heading: "",
|
heading: "",
|
||||||
|
description: "",
|
||||||
fields: [],
|
fields: [],
|
||||||
submitButton: { text: "Send Message" },
|
submitButton: { text: "Send Message", icon: "fa-solid fa-arrow-right", buttonClass: "theme-btn style-2" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -179,3 +205,141 @@ exports.update = async (req, res) => {
|
|||||||
res.redirect("/admin/contact");
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
193
controllers/pricingController.js
Normal file
193
controllers/pricingController.js
Normal file
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
77
data/appointment.json
Normal file
77
data/appointment.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": "© <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
119
data/contact.json
Normal file
119
data/contact.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
data/pricing.json
Normal file
118
data/pricing.json
Normal file
@@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
206
models/appointment.js
Normal file
206
models/appointment.js
Normal file
@@ -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);
|
||||||
83
models/appointmentSubmission.js
Normal file
83
models/appointmentSubmission.js
Normal file
@@ -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);
|
||||||
@@ -145,6 +145,11 @@ const mapSchema = new mongoose.Schema(
|
|||||||
trim: true,
|
trim: true,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
|
embedUrl: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
tileLayer: {
|
tileLayer: {
|
||||||
type: tileLayerSchema,
|
type: tileLayerSchema,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -161,11 +166,16 @@ const formFieldSchema = new mongoose.Schema(
|
|||||||
required: true,
|
required: true,
|
||||||
trim: true,
|
trim: true,
|
||||||
},
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
type: {
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
trim: true,
|
trim: true,
|
||||||
enum: ["text", "email", "tel", "textarea", "programme"],
|
enum: ["text", "email", "tel", "textarea", "programme", "date"],
|
||||||
},
|
},
|
||||||
placeholder: {
|
placeholder: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -176,6 +186,11 @@ const formFieldSchema = new mongoose.Schema(
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
colClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "col-lg-12",
|
||||||
|
},
|
||||||
programmeName: {
|
programmeName: {
|
||||||
type: String,
|
type: String,
|
||||||
trim: true,
|
trim: true,
|
||||||
@@ -193,6 +208,16 @@ const submitButtonSchema = new mongoose.Schema(
|
|||||||
required: true,
|
required: true,
|
||||||
trim: 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 }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
@@ -210,6 +235,11 @@ const formSchema = new mongoose.Schema(
|
|||||||
trim: true,
|
trim: true,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
fields: {
|
fields: {
|
||||||
type: [formFieldSchema],
|
type: [formFieldSchema],
|
||||||
default: [],
|
default: [],
|
||||||
@@ -335,6 +365,7 @@ contactSchema.statics.migrateFromJson = async function (jsonData) {
|
|||||||
zoom: jsonData.map?.zoom || 15,
|
zoom: jsonData.map?.zoom || 15,
|
||||||
location: jsonData.map?.location || "",
|
location: jsonData.map?.location || "",
|
||||||
markerTitle: jsonData.map?.markerTitle || "",
|
markerTitle: jsonData.map?.markerTitle || "",
|
||||||
|
embedUrl: jsonData.map?.embedUrl || "",
|
||||||
tileLayer: {
|
tileLayer: {
|
||||||
url:
|
url:
|
||||||
jsonData.map?.tileLayer?.url ||
|
jsonData.map?.tileLayer?.url ||
|
||||||
@@ -347,15 +378,20 @@ contactSchema.statics.migrateFromJson = async function (jsonData) {
|
|||||||
form: {
|
form: {
|
||||||
sectionLabel: jsonData.form?.sectionLabel || "",
|
sectionLabel: jsonData.form?.sectionLabel || "",
|
||||||
heading: jsonData.form?.heading || "",
|
heading: jsonData.form?.heading || "",
|
||||||
|
description: jsonData.form?.description || "",
|
||||||
fields: (jsonData.form?.fields || []).map((field) => ({
|
fields: (jsonData.form?.fields || []).map((field) => ({
|
||||||
name: field.name || "",
|
name: field.name || "",
|
||||||
|
label: field.label || "",
|
||||||
type: field.type || "text",
|
type: field.type || "text",
|
||||||
placeholder: field.placeholder || "",
|
placeholder: field.placeholder || "",
|
||||||
required: field.required || false,
|
required: field.required || false,
|
||||||
|
colClass: field.colClass || "col-lg-12",
|
||||||
programmeName: field.programmeName || "",
|
programmeName: field.programmeName || "",
|
||||||
})),
|
})),
|
||||||
submitButton: {
|
submitButton: {
|
||||||
text: jsonData.form?.submitButton?.text || "Send Message",
|
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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
74
models/contactSubmission.js
Normal file
74
models/contactSubmission.js
Normal file
@@ -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);
|
||||||
328
models/pricing.js
Normal file
328
models/pricing.js
Normal file
@@ -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);
|
||||||
@@ -151,6 +151,52 @@ router.get(
|
|||||||
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
|
// Activity CRUD routes
|
||||||
router.get("/activity", ensureAuthenticated, activityController.index);
|
router.get("/activity", ensureAuthenticated, activityController.index);
|
||||||
router.get(
|
router.get(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const safetyController = require("../controllers/safetyController");
|
|||||||
const campLocationController = require("../controllers/campLocationController");
|
const campLocationController = require("../controllers/campLocationController");
|
||||||
// Booking flow removed
|
// Booking flow removed
|
||||||
|
|
||||||
const insuranceController= require("../controllers/insuranceController");
|
const insuranceController = require("../controllers/insuranceController");
|
||||||
const termsController = require("../controllers/termsController"); // <-- IMPORT ĐÃ CÓ
|
const termsController = require("../controllers/termsController"); // <-- IMPORT ĐÃ CÓ
|
||||||
const activityController = require("../controllers/activityController");
|
const activityController = require("../controllers/activityController");
|
||||||
const travelController = require("../controllers/travelController");
|
const travelController = require("../controllers/travelController");
|
||||||
@@ -50,6 +50,18 @@ router.get("/api/footer", footerController.api);
|
|||||||
// Contact API route
|
// Contact API route
|
||||||
router.get("/api/contact", contactController.api);
|
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);
|
router.get("/api/faq", faqController.api);
|
||||||
// Safety API route
|
// Safety API route
|
||||||
router.get("/api/safety", safetyController.api);
|
router.get("/api/safety", safetyController.api);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ async function migrate() {
|
|||||||
await connectDB();
|
await connectDB();
|
||||||
|
|
||||||
// Read contact-data.json file
|
// Read contact-data.json file
|
||||||
const contactJsonPath = path.join(__dirname, "../data/contact-data.json");
|
const contactJsonPath = path.join(__dirname, "../data/contact.json");
|
||||||
const contactData = JSON.parse(await fs.readFile(contactJsonPath, "utf8"));
|
const contactData = JSON.parse(await fs.readFile(contactJsonPath, "utf8"));
|
||||||
|
|
||||||
// Migrate data using the model's static method
|
// Migrate data using the model's static method
|
||||||
|
|||||||
71
scripts/2026_02_03_appointment.js
Normal file
71
scripts/2026_02_03_appointment.js
Normal file
@@ -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();
|
||||||
15
server.js
15
server.js
@@ -42,6 +42,21 @@ app.use(
|
|||||||
},
|
},
|
||||||
express.static(path.join(__dirname, "assets")),
|
express.static(path.join(__dirname, "assets")),
|
||||||
);
|
);
|
||||||
|
app.use(
|
||||||
|
"/uploads",
|
||||||
|
(req, res, next) => {
|
||||||
|
// Cho phép mọi domain truy cập tài nguyên tĩnh
|
||||||
|
res.header("Access-Control-Allow-Origin", "*");
|
||||||
|
res.header("Access-Control-Allow-Methods", "GET");
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
express.static(path.join(__dirname, "public", "uploads")),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Serve other public files
|
||||||
|
app.use(
|
||||||
|
express.static(path.join(__dirname, "public")),
|
||||||
|
);
|
||||||
|
|
||||||
// Session configuration
|
// Session configuration
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
791
views/admin/appointment/index.ejs
Normal file
791
views/admin/appointment/index.ejs
Normal file
@@ -0,0 +1,791 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0" style="color: var(--primary-dark);">
|
||||||
|
<%= title %>
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted mb-0">Edit content displayed on Appointment page</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="<%= frontendUrl %>/make-appointment/" class="btn btn-outline-primary" target="_blank">
|
||||||
|
<i class="fas fa-external-link-alt me-2"></i>View Appointment Page
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<form method="POST" class="content-with-fixed-buttons" id="appointmentForm"
|
||||||
|
action="/admin/appointment/update">
|
||||||
|
<!-- Hidden inputs for JSON data -->
|
||||||
|
<input type="hidden" name="hero" id="heroJson">
|
||||||
|
<input type="hidden" name="visaOptions" id="visaOptionsJson">
|
||||||
|
<input type="hidden" name="form" id="formJson">
|
||||||
|
|
||||||
|
<!-- Navigation Tabs -->
|
||||||
|
<div class="card shadow-sm border-0 mb-4">
|
||||||
|
<div class="card-header bg-white border-bottom">
|
||||||
|
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" data-bs-toggle="tab" href="#hero" role="tab">
|
||||||
|
<i class="fas fa-home me-2"></i>Hero
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#visaOptions" role="tab">
|
||||||
|
<i class="fas fa-passport me-2"></i>Visa Options
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#form" role="tab">
|
||||||
|
<i class="fas fa-envelope me-2"></i>Form
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#submissions" role="tab">
|
||||||
|
<i class="fas fa-list me-2"></i>Submissions
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content">
|
||||||
|
<!-- Hero Tab -->
|
||||||
|
<div class="tab-pane fade show active" id="hero" role="tabpanel">
|
||||||
|
<div class="card border shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-medium mb-3">Hero Section</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label fw-medium">Background Image</label>
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<input type="text" class="form-control" id="heroBackgroundImage"
|
||||||
|
name="heroBackgroundImage"
|
||||||
|
value="<%= data.hero?.backgroundImage || '' %>">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-primary btn-upload-image"
|
||||||
|
data-target-input="heroBackgroundImage"
|
||||||
|
data-image-type="appointment">
|
||||||
|
<i class="fas fa-upload me-1"></i>Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Recommended size: 1920x1080px</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div id="heroImagePreview" style="height: 200px;">
|
||||||
|
<% if (data.hero?.backgroundImage) { %>
|
||||||
|
<% let heroImgSrc=data.hero.backgroundImage; if (heroImgSrc &&
|
||||||
|
!heroImgSrc.startsWith('http://') &&
|
||||||
|
!heroImgSrc.startsWith('https://')) {
|
||||||
|
heroImgSrc=heroImgSrc.startsWith('/') ? heroImgSrc : '/' +
|
||||||
|
heroImgSrc; } %>
|
||||||
|
<img src="<%= heroImgSrc %>" class="img-thumbnail"
|
||||||
|
id="heroPreviewImg"
|
||||||
|
style="height: 200px; width: 100%; object-fit: cover;"
|
||||||
|
alt="Background image preview"
|
||||||
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||||
|
<div class="border rounded p-5 text-center text-muted"
|
||||||
|
style="height: 200px; display: none; align-items: center; justify-content: center;">
|
||||||
|
Image preview
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="border rounded p-5 text-center text-muted"
|
||||||
|
style="height: 200px; display: flex; align-items: center; justify-content: center;">
|
||||||
|
Image preview
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mt-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">Title</label>
|
||||||
|
<input type="text" class="form-control" id="heroTitle" name="heroTitle"
|
||||||
|
value="<%= data.hero?.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">Subtitle</label>
|
||||||
|
<input type="text" class="form-control" id="heroSubtitle"
|
||||||
|
name="heroSubtitle" value="<%= data.hero?.subtitle || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium">Heading</label>
|
||||||
|
<input type="text" class="form-control" id="heroHeading"
|
||||||
|
name="heroHeading" value="<%= data.hero?.heading || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium">Description</label>
|
||||||
|
<textarea class="form-control" id="heroDescription"
|
||||||
|
name="heroDescription"
|
||||||
|
rows="2"><%= data.hero?.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visa Options Tab -->
|
||||||
|
<div class="tab-pane fade" id="visaOptions" role="tabpanel">
|
||||||
|
<div class="card border shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="fw-medium mb-0">Visa Options</h6>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm"
|
||||||
|
onclick="addVisaOption()">
|
||||||
|
<i class="fas fa-plus"></i> Add Option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small">These options will appear in the visa type selection
|
||||||
|
dropdown on the appointment form.</p>
|
||||||
|
<div id="visaOptionsContainer">
|
||||||
|
<% if (data.visaOptions && data.visaOptions.length> 0) { %>
|
||||||
|
<% data.visaOptions.forEach((option, index)=> { %>
|
||||||
|
<div class="input-group mb-2 visa-option-item">
|
||||||
|
<span class="input-group-text"><i
|
||||||
|
class="fas fa-passport"></i></span>
|
||||||
|
<input type="text" class="form-control visa-option-input"
|
||||||
|
value="<%= option %>" placeholder="Enter visa option">
|
||||||
|
<button type="button" class="btn btn-outline-danger"
|
||||||
|
onclick="removeVisaOption(this)">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Tab -->
|
||||||
|
<div class="tab-pane fade" id="form" role="tabpanel">
|
||||||
|
<div class="card border shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-medium mb-3">Form Settings</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Form Heading</label>
|
||||||
|
<input type="text" class="form-control" id="formHeading"
|
||||||
|
value="<%= data.form?.heading || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Submit Button Text</label>
|
||||||
|
<input type="text" class="form-control" id="formSubmitButtonText"
|
||||||
|
value="<%= data.form?.submitButton?.text || 'Request Appointment' %>">
|
||||||
|
</div>
|
||||||
|
<!-- Hidden fields for submitButton icon and buttonClass -->
|
||||||
|
<input type="hidden" id="formSubmitButtonIcon"
|
||||||
|
value="<%= data.form?.submitButton?.icon || 'fa-solid fa-arrow-right' %>">
|
||||||
|
<input type="hidden" id="formSubmitButtonClass"
|
||||||
|
value="<%= data.form?.submitButton?.buttonClass || 'theme-btn' %>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="fw-medium mb-0">Form Fields</h6>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm"
|
||||||
|
onclick="addFormField()">
|
||||||
|
<i class="fas fa-plus"></i> Add Field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="formFieldsContainer">
|
||||||
|
<% if (data.form?.fields && data.form.fields.length> 0) { %>
|
||||||
|
<% data.form.fields.forEach((field, index)=> { %>
|
||||||
|
<div class="card mb-3 form-field-item">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Field Name</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control field-name-input"
|
||||||
|
value="<%= field.name || '' %>"
|
||||||
|
placeholder="e.g., name">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Label</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control field-label-input"
|
||||||
|
value="<%= field.label || '' %>"
|
||||||
|
placeholder="e.g., Your Name">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<select class="form-select field-type-select">
|
||||||
|
<option value="text" <%=field.type==='text'
|
||||||
|
? 'selected' : '' %>>Text</option>
|
||||||
|
<option value="email" <%=field.type==='email'
|
||||||
|
? 'selected' : '' %>>Email</option>
|
||||||
|
<option value="tel" <%=field.type==='tel'
|
||||||
|
? 'selected' : '' %>>Phone</option>
|
||||||
|
<option value="textarea"
|
||||||
|
<%=field.type==='textarea' ? 'selected' : ''
|
||||||
|
%>>Textarea</option>
|
||||||
|
<option value="date" <%=field.type==='date'
|
||||||
|
? 'selected' : '' %>>Date</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Col Class</label>
|
||||||
|
<select class="form-select field-col-select">
|
||||||
|
<option value="col-lg-4"
|
||||||
|
<%=field.colClass==='col-lg-4' ? 'selected'
|
||||||
|
: '' %>>1/3 Width</option>
|
||||||
|
<option value="col-lg-6"
|
||||||
|
<%=field.colClass==='col-lg-6' ? 'selected'
|
||||||
|
: '' %>>1/2 Width</option>
|
||||||
|
<option value="col-lg-12"
|
||||||
|
<%=field.colClass==='col-lg-12' ? 'selected'
|
||||||
|
: '' %>>Full Width</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Required</label>
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input
|
||||||
|
class="form-check-input field-required-check"
|
||||||
|
type="checkbox" <%=field.required
|
||||||
|
? 'checked' : '' %>>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Placeholder</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control field-placeholder-input"
|
||||||
|
value="<%= field.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-danger btn-sm mt-3"
|
||||||
|
onclick="removeFormField(this)">
|
||||||
|
<i class="fas fa-trash me-2"></i>Remove Field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submissions Tab -->
|
||||||
|
<div class="tab-pane fade" id="submissions" role="tabpanel">
|
||||||
|
<div class="card border shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="fw-medium mb-0">Recent Submissions</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date Filter -->
|
||||||
|
<div class="row g-2 mb-4 align-items-end" id="filterContainer">
|
||||||
|
<input type="hidden" id="filterTab" value="submissions">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small text-muted">Start Date</label>
|
||||||
|
<input type="date" class="form-control form-control-sm"
|
||||||
|
id="filterStartDate" value="<%= locals.startDate || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small text-muted">End Date</label>
|
||||||
|
<input type="date" class="form-control form-control-sm"
|
||||||
|
id="filterEndDate" value="<%= locals.endDate || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary w-100"
|
||||||
|
onclick="applyDateFilter()">
|
||||||
|
<i class="fas fa-filter me-1"></i> Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<a href="/admin/appointment?tab=submissions"
|
||||||
|
class="btn btn-sm btn-outline-secondary w-100">
|
||||||
|
<i class="fas fa-times me-1"></i> Clear
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Contact</th>
|
||||||
|
<th>Appt Date</th>
|
||||||
|
<th>Visa Types</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% if (locals.submissions && submissions.length> 0) { %>
|
||||||
|
<% submissions.forEach(submission=> { %>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<%= new
|
||||||
|
Date(submission.createdAt).toLocaleDateString()
|
||||||
|
%>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">
|
||||||
|
<%= new
|
||||||
|
Date(submission.createdAt).toLocaleTimeString([],
|
||||||
|
{hour: '2-digit' , minute:'2-digit'}) %>
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<%= submission.name %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<a href="mailto:<%= submission.email %>"
|
||||||
|
class="text-decoration-none"><i
|
||||||
|
class="fas fa-envelope me-1"></i>
|
||||||
|
<%= submission.email %>
|
||||||
|
</a>
|
||||||
|
<% if(submission.phone) { %>
|
||||||
|
<span class="text-muted small"><i
|
||||||
|
class="fas fa-phone me-1"></i>
|
||||||
|
<%= submission.phone %>
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<%= submission.appointmentDate || '-' %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if (submission.visaTypes &&
|
||||||
|
submission.visaTypes.length> 0) { %>
|
||||||
|
<% submission.visaTypes.forEach(type=> { %>
|
||||||
|
<span
|
||||||
|
class="badge bg-light text-dark border me-1">
|
||||||
|
<%= type %>
|
||||||
|
</span>
|
||||||
|
<% }); %>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if (submission.message) { %>
|
||||||
|
<div title="<%= submission.message %>"
|
||||||
|
style="max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||||
|
<%= submission.message %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% 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' ; %>
|
||||||
|
<span
|
||||||
|
class="badge <%= statusClass %> rounded-pill">
|
||||||
|
<%= submission.status %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
onclick="openStatusModal('<%= submission._id %>', '<%= submission.status %>')"
|
||||||
|
title="Update Status">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
<% } else { %>
|
||||||
|
<tr>
|
||||||
|
<td colspan="8"
|
||||||
|
class="text-center py-4 text-muted">No
|
||||||
|
submissions found</td>
|
||||||
|
</tr>
|
||||||
|
<% } %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-end">
|
||||||
|
<small class="text-muted">Showing last 50 submissions</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fixed Bottom Buttons -->
|
||||||
|
<div class="fixed-bottom-buttons">
|
||||||
|
<button type="reset" class="btn btn-secondary" onclick="resetForm()">
|
||||||
|
<i class="fas fa-undo me-2"></i>Reset
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||||
|
<i class="fas fa-save me-2"></i>Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Update Modal -->
|
||||||
|
<div class="modal fade" id="statusModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Update Status</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="statusForm">
|
||||||
|
<input type="hidden" id="statusSubmissionId">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="statusSelect" class="form-label">Status</label>
|
||||||
|
<select class="form-select" id="statusSelect">
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="confirmed">Confirmed</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveStatus()">Save changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="application/json" id="appointmentDataJson"><%- JSON.stringify(data) %></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let originalFormData = null;
|
||||||
|
let statusModal = null;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
try {
|
||||||
|
var jsonScript = document.getElementById('appointmentDataJson');
|
||||||
|
originalFormData = JSON.parse(jsonScript.textContent);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing originalFormData:', e);
|
||||||
|
originalFormData = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for tab parameter in URL
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const tab = urlParams.get('tab');
|
||||||
|
if (tab) {
|
||||||
|
const triggerEl = document.querySelector(`a[href="#${tab}"]`);
|
||||||
|
if (triggerEl) {
|
||||||
|
const tabInstance = new bootstrap.Tab(triggerEl);
|
||||||
|
tabInstance.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move modal to body to prevent backdrop issues
|
||||||
|
const statusModalEl = document.getElementById('statusModal');
|
||||||
|
if (statusModalEl) {
|
||||||
|
document.body.appendChild(statusModalEl);
|
||||||
|
}
|
||||||
|
statusModal = new bootstrap.Modal(statusModalEl);
|
||||||
|
|
||||||
|
updateAllJsonInputs();
|
||||||
|
initializeFormHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyDateFilter() {
|
||||||
|
const startDate = document.getElementById('filterStartDate').value;
|
||||||
|
const endDate = document.getElementById('filterEndDate').value;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('tab', 'submissions');
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
url.searchParams.set('startDate', startDate);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('startDate');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
url.searchParams.set('endDate', endDate);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('endDate');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStatusModal(id, currentStatus) {
|
||||||
|
document.getElementById('statusSubmissionId').value = id;
|
||||||
|
document.getElementById('statusSelect').value = currentStatus;
|
||||||
|
statusModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStatus() {
|
||||||
|
const id = document.getElementById('statusSubmissionId').value;
|
||||||
|
const status = document.getElementById('statusSelect').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/admin/appointments/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Determine CSS class for the notification or badge
|
||||||
|
// Since this is generic, we'll reload or update UI manually if complex.
|
||||||
|
// Reload is safest to show updated table state (including sorting/filtering if any)
|
||||||
|
// But let's try to be smooth:
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to update status: ' + (result.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating status:', error);
|
||||||
|
alert('Error updating status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeFormHandlers() {
|
||||||
|
const form = document.getElementById('appointmentForm');
|
||||||
|
form.addEventListener('submit', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateJsonData();
|
||||||
|
this.submit();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating data:', error);
|
||||||
|
alert('Failed to process form data. Please try again.');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Image upload buttons
|
||||||
|
document.querySelectorAll('.btn-upload-image').forEach(button => {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
const targetInput = this.dataset.targetInput;
|
||||||
|
const imageType = this.dataset.imageType;
|
||||||
|
openImageUploader(targetInput, imageType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update preview when background image changes
|
||||||
|
document.getElementById('heroBackgroundImage').addEventListener('input', function () {
|
||||||
|
updateHeroImagePreview(this.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeroImagePreview(imagePath) {
|
||||||
|
const previewContainer = document.getElementById('heroImagePreview');
|
||||||
|
if (imagePath) {
|
||||||
|
let imgSrc = imagePath;
|
||||||
|
if (!imgSrc.startsWith('http://') && !imgSrc.startsWith('https://')) {
|
||||||
|
imgSrc = imgSrc.startsWith('/') ? imgSrc : '/' + imgSrc;
|
||||||
|
}
|
||||||
|
previewContainer.innerHTML = `
|
||||||
|
<img src="${imgSrc}" class="img-thumbnail" id="heroPreviewImg"
|
||||||
|
style="height: 200px; width: 100%; object-fit: cover;"
|
||||||
|
alt="Background image preview"
|
||||||
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||||
|
<div class="border rounded p-5 text-center text-muted"
|
||||||
|
style="height: 200px; display: none; align-items: center; justify-content: center;">
|
||||||
|
Image preview
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
previewContainer.innerHTML = `
|
||||||
|
<div class="border rounded p-5 text-center text-muted"
|
||||||
|
style="height: 200px; display: flex; align-items: center; justify-content: center;">
|
||||||
|
Image preview
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAllJsonInputs() {
|
||||||
|
updateJsonData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateJsonData() {
|
||||||
|
// Hero data
|
||||||
|
const heroData = {
|
||||||
|
title: document.getElementById('heroTitle').value || '',
|
||||||
|
backgroundImage: document.getElementById('heroBackgroundImage').value || '',
|
||||||
|
subtitle: document.getElementById('heroSubtitle').value || '',
|
||||||
|
heading: document.getElementById('heroHeading').value || '',
|
||||||
|
description: document.getElementById('heroDescription').value || '',
|
||||||
|
};
|
||||||
|
document.getElementById('heroJson').value = JSON.stringify(heroData);
|
||||||
|
|
||||||
|
// Visa options
|
||||||
|
const visaOptions = [];
|
||||||
|
document.querySelectorAll('.visa-option-input').forEach(input => {
|
||||||
|
if (input.value.trim()) {
|
||||||
|
visaOptions.push(input.value.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('visaOptionsJson').value = JSON.stringify(visaOptions);
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
const fields = [];
|
||||||
|
document.querySelectorAll('.form-field-item').forEach(item => {
|
||||||
|
fields.push({
|
||||||
|
name: item.querySelector('.field-name-input').value || '',
|
||||||
|
label: item.querySelector('.field-label-input').value || '',
|
||||||
|
type: item.querySelector('.field-type-select').value || 'text',
|
||||||
|
placeholder: item.querySelector('.field-placeholder-input').value || '',
|
||||||
|
required: item.querySelector('.field-required-check').checked,
|
||||||
|
colClass: item.querySelector('.field-col-select').value || 'col-lg-12',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
heading: document.getElementById('formHeading').value || '',
|
||||||
|
fields: fields,
|
||||||
|
submitButton: {
|
||||||
|
text: document.getElementById('formSubmitButtonText').value || 'Request Appointment',
|
||||||
|
icon: document.getElementById('formSubmitButtonIcon').value || 'fa-solid fa-arrow-right',
|
||||||
|
buttonClass: document.getElementById('formSubmitButtonClass').value || 'theme-btn',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
document.getElementById('formJson').value = JSON.stringify(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addVisaOption() {
|
||||||
|
const container = document.getElementById('visaOptionsContainer');
|
||||||
|
const html = `
|
||||||
|
<div class="input-group mb-2 visa-option-item">
|
||||||
|
<span class="input-group-text"><i class="fas fa-passport"></i></span>
|
||||||
|
<input type="text" class="form-control visa-option-input" value="" placeholder="Enter visa option">
|
||||||
|
<button type="button" class="btn btn-outline-danger" onclick="removeVisaOption(this)">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.insertAdjacentHTML('beforeend', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeVisaOption(button) {
|
||||||
|
button.closest('.visa-option-item').remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFormField() {
|
||||||
|
const container = document.getElementById('formFieldsContainer');
|
||||||
|
const html = `
|
||||||
|
<div class="card mb-3 form-field-item">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Field Name</label>
|
||||||
|
<input type="text" class="form-control field-name-input" value="" placeholder="e.g., name">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Label</label>
|
||||||
|
<input type="text" class="form-control field-label-input" value="" placeholder="e.g., Your Name">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<select class="form-select field-type-select">
|
||||||
|
<option value="text" selected>Text</option>
|
||||||
|
<option value="email">Email</option>
|
||||||
|
<option value="tel">Phone</option>
|
||||||
|
<option value="textarea">Textarea</option>
|
||||||
|
<option value="date">Date</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Col Class</label>
|
||||||
|
<select class="form-select field-col-select">
|
||||||
|
<option value="col-lg-4">1/3 Width</option>
|
||||||
|
<option value="col-lg-6">1/2 Width</option>
|
||||||
|
<option value="col-lg-12" selected>Full Width</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Required</label>
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input field-required-check" type="checkbox">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Placeholder</label>
|
||||||
|
<input type="text" class="form-control field-placeholder-input" value="">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm mt-3" onclick="removeFormField(this)">
|
||||||
|
<i class="fas fa-trash me-2"></i>Remove Field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.insertAdjacentHTML('beforeend', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFormField(button) {
|
||||||
|
button.closest('.form-field-item').remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
if (confirm('Are you sure you want to reset all changes?')) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image uploader function (reuse from shared)
|
||||||
|
function openImageUploader(targetInput, imageType) {
|
||||||
|
// Open upload modal or trigger file input
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/*';
|
||||||
|
input.onchange = async function (e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/upload/image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success && result.imagePath) {
|
||||||
|
document.getElementById(targetInput).value = result.imagePath;
|
||||||
|
if (targetInput === 'heroBackgroundImage') {
|
||||||
|
updateHeroImagePreview(result.imagePath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert('Upload failed: ' + (result.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
alert('Upload failed. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -118,6 +118,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 border-end border-top">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<div class="rounded-circle d-flex align-items-center justify-content-center me-3"
|
||||||
|
style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
|
||||||
|
<i class="fas fa-calendar-check fa-lg" style="color: var(--primary-color);"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0">Appointment</h5>
|
||||||
|
<p class="text-muted mb-0 small">Manage appointment page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/appointment" class="btn btn-sm btn-primary w-100 mt-2">
|
||||||
|
<i class="fas fa-edit me-2"></i>Edit
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 border-end border-top">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<div class="rounded-circle d-flex align-items-center justify-content-center me-3"
|
||||||
|
style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
|
||||||
|
<i class="fas fa-tags fa-lg" style="color: var(--primary-color);"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0">Pricing</h5>
|
||||||
|
<p class="text-muted mb-0 small">Manage pricing page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/pricing" class="btn btn-sm btn-primary w-100 mt-2">
|
||||||
|
<i class="fas fa-edit me-2"></i>Edit
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4 border-end border-top">
|
<div class="col-md-4 border-end border-top">
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<div class="d-flex align-items-center mb-3">
|
<div class="d-flex align-items-center mb-3">
|
||||||
@@ -494,13 +530,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small">Logged in as</div>
|
<div class="text-muted small">Logged in as</div>
|
||||||
<div class="fw-bold" style="color: var(--primary-color);"><%= user.username %></div>
|
<div class="fw-bold" style="color: var(--primary-color);">
|
||||||
|
<%= user.username %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert mt-3 mb-0" style="background-color: rgba(184, 183, 106, 0.05); border-left: 4px solid var(--primary-color);" role="alert">
|
<div class="alert mt-3 mb-0"
|
||||||
|
style="background-color: rgba(184, 183, 106, 0.05); border-left: 4px solid var(--primary-color);" role="alert">
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<div class="me-3">
|
<div class="me-3">
|
||||||
<i class="fas fa-lightbulb fa-lg" style="color: var(--primary-color);"></i>
|
<i class="fas fa-lightbulb fa-lg" style="color: var(--primary-color);"></i>
|
||||||
|
|||||||
742
views/admin/pricing/index.ejs
Normal file
742
views/admin/pricing/index.ejs
Normal file
@@ -0,0 +1,742 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0" style="color: var(--primary-dark);">
|
||||||
|
<%= title %>
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted mb-0">Edit content displayed on Pricing page</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="<%= frontendUrl %>/pricing/" class="btn btn-outline-primary" target="_blank">
|
||||||
|
<i class="fas fa-external-link-alt me-2"></i>View Pricing Page
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<form method="POST" class="content-with-fixed-buttons" id="pricingForm" action="/admin/pricing/update">
|
||||||
|
<!-- Hidden inputs for JSON data -->
|
||||||
|
<input type="hidden" name="hero" id="heroJson">
|
||||||
|
<input type="hidden" name="pricingSection" id="pricingSectionJson">
|
||||||
|
<input type="hidden" name="plans" id="plansJson">
|
||||||
|
<input type="hidden" name="testimonials" id="testimonialsJson">
|
||||||
|
|
||||||
|
<!-- Navigation Tabs -->
|
||||||
|
<div class="card shadow-sm border-0 mb-4">
|
||||||
|
<div class="card-header bg-white border-bottom">
|
||||||
|
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" data-bs-toggle="tab" href="#hero" role="tab">
|
||||||
|
<i class="fas fa-home me-2"></i>Hero
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#pricingSection" role="tab">
|
||||||
|
<i class="fas fa-tags me-2"></i>Pricing Section
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#plans" role="tab">
|
||||||
|
<i class="fas fa-dollar-sign me-2"></i>Plans
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#testimonials" role="tab">
|
||||||
|
<i class="fas fa-quote-right me-2"></i>Testimonials
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content">
|
||||||
|
<!-- Hero Tab -->
|
||||||
|
<div class="tab-pane fade show active" id="hero" role="tabpanel">
|
||||||
|
<div class="card border shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-medium mb-3">Hero Section</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label fw-medium">Background Image</label>
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<input type="text" class="form-control" id="heroBackgroundImage"
|
||||||
|
name="heroBackgroundImage"
|
||||||
|
value="<%= data.hero?.backgroundImage || '' %>">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-primary btn-upload-image"
|
||||||
|
data-target-input="heroBackgroundImage"
|
||||||
|
data-image-type="pricing">
|
||||||
|
<i class="fas fa-upload me-1"></i>Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Recommended size: 1920x1080px</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div id="heroImagePreview" style="height: 200px;">
|
||||||
|
<% if (data.hero?.backgroundImage) { %>
|
||||||
|
<% let heroImgSrc=data.hero.backgroundImage; if (heroImgSrc &&
|
||||||
|
!heroImgSrc.startsWith('http://') &&
|
||||||
|
!heroImgSrc.startsWith('https://')) {
|
||||||
|
heroImgSrc=heroImgSrc.startsWith('/') ? heroImgSrc : '/' +
|
||||||
|
heroImgSrc; } %>
|
||||||
|
<img src="<%= heroImgSrc %>" class="img-thumbnail"
|
||||||
|
id="heroPreviewImg"
|
||||||
|
style="height: 200px; width: 100%; object-fit: cover;"
|
||||||
|
alt="Background image preview"
|
||||||
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||||
|
<div class="border rounded p-5 text-center text-muted"
|
||||||
|
style="height: 200px; display: none; align-items: center; justify-content: center;">
|
||||||
|
Image preview
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="border rounded p-5 text-center text-muted"
|
||||||
|
style="height: 200px; display: flex; align-items: center; justify-content: center;">
|
||||||
|
Image preview
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mt-2">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium">Title</label>
|
||||||
|
<input type="text" class="form-control" id="heroTitle" name="heroTitle"
|
||||||
|
value="<%= data.hero?.title || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pricing Section Tab -->
|
||||||
|
<div class="tab-pane fade" id="pricingSection" role="tabpanel">
|
||||||
|
<div class="card border shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-medium mb-3">Pricing Section Header</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">Subtitle</label>
|
||||||
|
<input type="text" class="form-control" id="pricingSectionSubtitle"
|
||||||
|
value="<%= data.pricingSection?.subtitle || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">Heading</label>
|
||||||
|
<input type="text" class="form-control" id="pricingSectionHeading"
|
||||||
|
value="<%= data.pricingSection?.heading || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium">Description</label>
|
||||||
|
<textarea class="form-control" id="pricingSectionDescription"
|
||||||
|
rows="3"><%= data.pricingSection?.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plans Tab -->
|
||||||
|
<div class="tab-pane fade" id="plans" role="tabpanel">
|
||||||
|
<!-- Monthly Plans -->
|
||||||
|
<div class="card border shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="fw-medium mb-0">Monthly Plans</h6>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm"
|
||||||
|
onclick="addPlan('monthly')">
|
||||||
|
<i class="fas fa-plus"></i> Add Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="monthlyPlansContainer">
|
||||||
|
<% if (data.plans?.monthly && data.plans.monthly.length> 0) { %>
|
||||||
|
<% data.plans.monthly.forEach((plan, index)=> { %>
|
||||||
|
<div class="card mb-3 plan-item" data-type="monthly">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Plan Name</label>
|
||||||
|
<input type="text" class="form-control plan-name"
|
||||||
|
value="<%= plan.name || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Price</label>
|
||||||
|
<input type="text" class="form-control plan-price"
|
||||||
|
value="<%= plan.price || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Currency</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control plan-currency"
|
||||||
|
value="<%= plan.currency || '$' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Period</label>
|
||||||
|
<input type="text" class="form-control plan-period"
|
||||||
|
value="<%= plan.period || 'mo' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Style</label>
|
||||||
|
<select class="form-select plan-style">
|
||||||
|
<option value="default"
|
||||||
|
<%=plan.style==='default' ? 'selected' : ''
|
||||||
|
%>>Default</option>
|
||||||
|
<option value="style-2"
|
||||||
|
<%=plan.style==='style-2' ? 'selected' : ''
|
||||||
|
%>>Style 2 (Featured)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Text</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control plan-button-text"
|
||||||
|
value="<%= plan.buttonText || 'Get Started Today' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Link</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control plan-button-link"
|
||||||
|
value="<%= plan.buttonLink || '/pricing' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Icon</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control plan-button-icon"
|
||||||
|
value="<%= plan.buttonIcon || 'fa-solid fa-arrow-right' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label">Features (one per
|
||||||
|
line)</label>
|
||||||
|
<textarea class="form-control plan-features"
|
||||||
|
rows="4"><%= (plan.features || []).join('\n') %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-danger btn-sm mt-3"
|
||||||
|
onclick="removePlan(this)">
|
||||||
|
<i class="fas fa-trash me-2"></i>Remove Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Yearly Plans -->
|
||||||
|
<div class="card border shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="fw-medium mb-0">Yearly Plans</h6>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm"
|
||||||
|
onclick="addPlan('yearly')">
|
||||||
|
<i class="fas fa-plus"></i> Add Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="yearlyPlansContainer">
|
||||||
|
<% if (data.plans?.yearly && data.plans.yearly.length> 0) { %>
|
||||||
|
<% data.plans.yearly.forEach((plan, index)=> { %>
|
||||||
|
<div class="card mb-3 plan-item" data-type="yearly">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Plan Name</label>
|
||||||
|
<input type="text" class="form-control plan-name"
|
||||||
|
value="<%= plan.name || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Price</label>
|
||||||
|
<input type="text" class="form-control plan-price"
|
||||||
|
value="<%= plan.price || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Currency</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control plan-currency"
|
||||||
|
value="<%= plan.currency || '$' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Period</label>
|
||||||
|
<input type="text" class="form-control plan-period"
|
||||||
|
value="<%= plan.period || 'mo' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Style</label>
|
||||||
|
<select class="form-select plan-style">
|
||||||
|
<option value="default"
|
||||||
|
<%=plan.style==='default' ? 'selected' : ''
|
||||||
|
%>>Default</option>
|
||||||
|
<option value="style-2"
|
||||||
|
<%=plan.style==='style-2' ? 'selected' : ''
|
||||||
|
%>>Style 2 (Featured)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Text</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control plan-button-text"
|
||||||
|
value="<%= plan.buttonText || 'Get Started Today' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Link</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control plan-button-link"
|
||||||
|
value="<%= plan.buttonLink || '/pricing' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Icon</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control plan-button-icon"
|
||||||
|
value="<%= plan.buttonIcon || 'fa-solid fa-arrow-right' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label">Features (one per
|
||||||
|
line)</label>
|
||||||
|
<textarea class="form-control plan-features"
|
||||||
|
rows="4"><%= (plan.features || []).join('\n') %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-danger btn-sm mt-3"
|
||||||
|
onclick="removePlan(this)">
|
||||||
|
<i class="fas fa-trash me-2"></i>Remove Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Testimonials Tab -->
|
||||||
|
<div class="tab-pane fade" id="testimonials" role="tabpanel">
|
||||||
|
<div class="card border shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-medium mb-3">Testimonials Section Header</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">Subtitle</label>
|
||||||
|
<input type="text" class="form-control" id="testimonialsSubtitle"
|
||||||
|
value="<%= data.testimonials?.subtitle || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">Heading</label>
|
||||||
|
<input type="text" class="form-control" id="testimonialsHeading"
|
||||||
|
value="<%= data.testimonials?.heading || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium">Button Text</label>
|
||||||
|
<input type="text" class="form-control" id="testimonialsButtonText"
|
||||||
|
value="<%= data.testimonials?.buttonText || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium">Button Link</label>
|
||||||
|
<input type="text" class="form-control" id="testimonialsButtonLink"
|
||||||
|
value="<%= data.testimonials?.buttonLink || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium">Section Image</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="testimonialsImage"
|
||||||
|
value="<%= data.testimonials?.image || '' %>">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-primary btn-upload-image"
|
||||||
|
data-target-input="testimonialsImage" data-image-type="pricing">
|
||||||
|
<i class="fas fa-upload"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="fw-medium mb-0">Testimonial Items</h6>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm"
|
||||||
|
onclick="addTestimonial()">
|
||||||
|
<i class="fas fa-plus"></i> Add Testimonial
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="testimonialsContainer">
|
||||||
|
<% if (data.testimonials?.items && data.testimonials.items.length> 0) { %>
|
||||||
|
<% data.testimonials.items.forEach((item, index)=> { %>
|
||||||
|
<div class="card mb-3 testimonial-item">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control testimonial-name"
|
||||||
|
value="<%= item.name || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Role/Type</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control testimonial-role"
|
||||||
|
value="<%= item.role || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Rating</label>
|
||||||
|
<select class="form-select testimonial-rating">
|
||||||
|
<option value="1" <%=item.rating===1
|
||||||
|
? 'selected' : '' %>>1 Star</option>
|
||||||
|
<option value="2" <%=item.rating===2
|
||||||
|
? 'selected' : '' %>>2 Stars</option>
|
||||||
|
<option value="3" <%=item.rating===3
|
||||||
|
? 'selected' : '' %>>3 Stars</option>
|
||||||
|
<option value="4" <%=item.rating===4
|
||||||
|
? 'selected' : '' %>>4 Stars</option>
|
||||||
|
<option value="5" <%=item.rating===5
|
||||||
|
? 'selected' : '' %>>5 Stars</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label">Content</label>
|
||||||
|
<textarea class="form-control testimonial-content"
|
||||||
|
rows="3"><%= item.content || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-danger btn-sm mt-3"
|
||||||
|
onclick="removeTestimonial(this)">
|
||||||
|
<i class="fas fa-trash me-2"></i>Remove Testimonial
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fixed Bottom Buttons -->
|
||||||
|
<div class="fixed-bottom-buttons">
|
||||||
|
<button type="reset" class="btn btn-secondary" onclick="resetForm()">
|
||||||
|
<i class="fas fa-undo me-2"></i>Reset
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||||
|
<i class="fas fa-save me-2"></i>Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="application/json" id="pricingDataJson"><%- JSON.stringify(data) %></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let originalFormData = null;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
try {
|
||||||
|
var jsonScript = document.getElementById('pricingDataJson');
|
||||||
|
originalFormData = JSON.parse(jsonScript.textContent);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing originalFormData:', e);
|
||||||
|
originalFormData = {};
|
||||||
|
}
|
||||||
|
updateAllJsonInputs();
|
||||||
|
initializeFormHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function initializeFormHandlers() {
|
||||||
|
const form = document.getElementById('pricingForm');
|
||||||
|
form.addEventListener('submit', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateJsonData();
|
||||||
|
this.submit();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating data:', error);
|
||||||
|
alert('Failed to process form data. Please try again.');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Image upload buttons
|
||||||
|
document.querySelectorAll('.btn-upload-image').forEach(button => {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
const targetInput = this.dataset.targetInput;
|
||||||
|
const imageType = this.dataset.imageType;
|
||||||
|
openImageUploader(targetInput, imageType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update preview when background image changes
|
||||||
|
document.getElementById('heroBackgroundImage').addEventListener('input', function () {
|
||||||
|
updateHeroImagePreview(this.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeroImagePreview(imagePath) {
|
||||||
|
const previewContainer = document.getElementById('heroImagePreview');
|
||||||
|
if (imagePath) {
|
||||||
|
let imgSrc = imagePath;
|
||||||
|
if (!imgSrc.startsWith('http://') && !imgSrc.startsWith('https://')) {
|
||||||
|
imgSrc = imgSrc.startsWith('/') ? imgSrc : '/' + imgSrc;
|
||||||
|
}
|
||||||
|
previewContainer.innerHTML = `
|
||||||
|
<img src="${imgSrc}" class="img-thumbnail" id="heroPreviewImg"
|
||||||
|
style="height: 200px; width: 100%; object-fit: cover;"
|
||||||
|
alt="Background image preview"
|
||||||
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||||
|
<div class="border rounded p-5 text-center text-muted"
|
||||||
|
style="height: 200px; display: none; align-items: center; justify-content: center;">
|
||||||
|
Image preview
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
previewContainer.innerHTML = `
|
||||||
|
<div class="border rounded p-5 text-center text-muted"
|
||||||
|
style="height: 200px; display: flex; align-items: center; justify-content: center;">
|
||||||
|
Image preview
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAllJsonInputs() {
|
||||||
|
updateJsonData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateJsonData() {
|
||||||
|
// Hero data
|
||||||
|
const heroData = {
|
||||||
|
title: document.getElementById('heroTitle').value || '',
|
||||||
|
backgroundImage: document.getElementById('heroBackgroundImage').value || '',
|
||||||
|
shapeImage: originalFormData?.hero?.shapeImage || '/assets/img/inner-page/shape.png',
|
||||||
|
breadcrumb: originalFormData?.hero?.breadcrumb || [],
|
||||||
|
};
|
||||||
|
document.getElementById('heroJson').value = JSON.stringify(heroData);
|
||||||
|
|
||||||
|
// Pricing Section data
|
||||||
|
const pricingSectionData = {
|
||||||
|
subtitle: document.getElementById('pricingSectionSubtitle').value || '',
|
||||||
|
heading: document.getElementById('pricingSectionHeading').value || '',
|
||||||
|
description: document.getElementById('pricingSectionDescription').value || '',
|
||||||
|
};
|
||||||
|
document.getElementById('pricingSectionJson').value = JSON.stringify(pricingSectionData);
|
||||||
|
|
||||||
|
// Plans data
|
||||||
|
const monthlyPlans = [];
|
||||||
|
document.querySelectorAll('#monthlyPlansContainer .plan-item').forEach(item => {
|
||||||
|
const featuresText = item.querySelector('.plan-features').value || '';
|
||||||
|
monthlyPlans.push({
|
||||||
|
name: item.querySelector('.plan-name').value || '',
|
||||||
|
price: item.querySelector('.plan-price').value || '0',
|
||||||
|
currency: item.querySelector('.plan-currency').value || '$',
|
||||||
|
period: item.querySelector('.plan-period').value || 'mo',
|
||||||
|
style: item.querySelector('.plan-style').value || 'default',
|
||||||
|
buttonText: item.querySelector('.plan-button-text').value || 'Get Started Today',
|
||||||
|
buttonLink: item.querySelector('.plan-button-link').value || '/pricing',
|
||||||
|
buttonIcon: item.querySelector('.plan-button-icon').value || 'fa-solid fa-arrow-right',
|
||||||
|
features: featuresText.split('\n').filter(f => f.trim()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const yearlyPlans = [];
|
||||||
|
document.querySelectorAll('#yearlyPlansContainer .plan-item').forEach(item => {
|
||||||
|
const featuresText = item.querySelector('.plan-features').value || '';
|
||||||
|
yearlyPlans.push({
|
||||||
|
name: item.querySelector('.plan-name').value || '',
|
||||||
|
price: item.querySelector('.plan-price').value || '0',
|
||||||
|
currency: item.querySelector('.plan-currency').value || '$',
|
||||||
|
period: item.querySelector('.plan-period').value || 'mo',
|
||||||
|
style: item.querySelector('.plan-style').value || 'default',
|
||||||
|
buttonText: item.querySelector('.plan-button-text').value || 'Get Started Today',
|
||||||
|
buttonLink: item.querySelector('.plan-button-link').value || '/pricing',
|
||||||
|
buttonIcon: item.querySelector('.plan-button-icon').value || 'fa-solid fa-arrow-right',
|
||||||
|
features: featuresText.split('\n').filter(f => f.trim()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('plansJson').value = JSON.stringify({
|
||||||
|
monthly: monthlyPlans,
|
||||||
|
yearly: yearlyPlans,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Testimonials data
|
||||||
|
const testimonialItems = [];
|
||||||
|
document.querySelectorAll('#testimonialsContainer .testimonial-item').forEach(item => {
|
||||||
|
testimonialItems.push({
|
||||||
|
name: item.querySelector('.testimonial-name').value || '',
|
||||||
|
role: item.querySelector('.testimonial-role').value || '',
|
||||||
|
rating: parseInt(item.querySelector('.testimonial-rating').value) || 5,
|
||||||
|
content: item.querySelector('.testimonial-content').value || '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const testimonialsData = {
|
||||||
|
subtitle: document.getElementById('testimonialsSubtitle').value || '',
|
||||||
|
heading: document.getElementById('testimonialsHeading').value || '',
|
||||||
|
buttonText: document.getElementById('testimonialsButtonText').value || '',
|
||||||
|
buttonLink: document.getElementById('testimonialsButtonLink').value || '',
|
||||||
|
buttonIcon: originalFormData?.testimonials?.buttonIcon || 'fa-solid fa-arrow-right',
|
||||||
|
image: document.getElementById('testimonialsImage').value || '',
|
||||||
|
items: testimonialItems,
|
||||||
|
};
|
||||||
|
document.getElementById('testimonialsJson').value = JSON.stringify(testimonialsData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPlan(type) {
|
||||||
|
const container = document.getElementById(type + 'PlansContainer');
|
||||||
|
const html = `
|
||||||
|
<div class="card mb-3 plan-item" data-type="${type}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Plan Name</label>
|
||||||
|
<input type="text" class="form-control plan-name" value="">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Price</label>
|
||||||
|
<input type="text" class="form-control plan-price" value="">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Currency</label>
|
||||||
|
<input type="text" class="form-control plan-currency" value="$">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Period</label>
|
||||||
|
<input type="text" class="form-control plan-period" value="mo">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Style</label>
|
||||||
|
<select class="form-select plan-style">
|
||||||
|
<option value="default" selected>Default</option>
|
||||||
|
<option value="style-2">Style 2 (Featured)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Text</label>
|
||||||
|
<input type="text" class="form-control plan-button-text" value="Get Started Today">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Link</label>
|
||||||
|
<input type="text" class="form-control plan-button-link" value="/pricing">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Icon</label>
|
||||||
|
<input type="text" class="form-control plan-button-icon" value="fa-solid fa-arrow-right">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label">Features (one per line)</label>
|
||||||
|
<textarea class="form-control plan-features" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm mt-3" onclick="removePlan(this)">
|
||||||
|
<i class="fas fa-trash me-2"></i>Remove Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.insertAdjacentHTML('beforeend', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePlan(button) {
|
||||||
|
if (confirm('Are you sure you want to remove this plan?')) {
|
||||||
|
button.closest('.plan-item').remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTestimonial() {
|
||||||
|
const container = document.getElementById('testimonialsContainer');
|
||||||
|
const html = `
|
||||||
|
<div class="card mb-3 testimonial-item">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-control testimonial-name" value="">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Role/Type</label>
|
||||||
|
<input type="text" class="form-control testimonial-role" value="">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Rating</label>
|
||||||
|
<select class="form-select testimonial-rating">
|
||||||
|
<option value="1">1 Star</option>
|
||||||
|
<option value="2">2 Stars</option>
|
||||||
|
<option value="3">3 Stars</option>
|
||||||
|
<option value="4">4 Stars</option>
|
||||||
|
<option value="5" selected>5 Stars</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label">Content</label>
|
||||||
|
<textarea class="form-control testimonial-content" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm mt-3" onclick="removeTestimonial(this)">
|
||||||
|
<i class="fas fa-trash me-2"></i>Remove Testimonial
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.insertAdjacentHTML('beforeend', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTestimonial(button) {
|
||||||
|
if (confirm('Are you sure you want to remove this testimonial?')) {
|
||||||
|
button.closest('.testimonial-item').remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
if (confirm('Are you sure you want to reset all changes?')) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image uploader function
|
||||||
|
function openImageUploader(targetInput, imageType) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/*';
|
||||||
|
input.onchange = async function (e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send imageType via query string as controller expects req.query.imageType
|
||||||
|
const uploadUrl = '/admin/upload/image?imageType=' + encodeURIComponent(imageType || 'general');
|
||||||
|
const response = await fetch(uploadUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success && result.path) {
|
||||||
|
document.getElementById(targetInput).value = result.path;
|
||||||
|
if (targetInput === 'heroBackgroundImage') {
|
||||||
|
updateHeroImagePreview(result.path);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert('Upload failed: ' + (result.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
alert('Upload failed. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
|
<head>
|
||||||
<link rel="icon" type="image/png" href="/uploads/layout/favicon.png" />
|
<link rel="icon" type="image/png" href="/uploads/layout/favicon.png" />
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@@ -8,15 +9,9 @@
|
|||||||
<%= typeof title !=='undefined' ? title + ' | ' : '' %>CMS-GGCamp
|
<%= typeof title !=='undefined' ? title + ' | ' : '' %>CMS-GGCamp
|
||||||
</title>
|
</title>
|
||||||
<!-- Bootstrap CSS -->
|
<!-- Bootstrap CSS -->
|
||||||
<link
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<!-- Font Awesome -->
|
<!-- Font Awesome -->
|
||||||
<link
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||||
rel="stylesheet"
|
|
||||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
|
||||||
/>
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--primary-color: #b8b76a;
|
--primary-color: #b8b76a;
|
||||||
@@ -45,9 +40,9 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<%- style %>
|
<%- style %>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<nav class="col-md-2 d-none d-md-block bg-light sidebar">
|
<nav class="col-md-2 d-none d-md-block bg-light sidebar">
|
||||||
@@ -74,6 +69,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/admin/contact">Contact</a>
|
<a class="nav-link" href="/admin/contact">Contact</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/admin/appointment">Appointment</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/admin/faq">FAQ</a>
|
<a class="nav-link" href="/admin/faq">FAQ</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -100,12 +98,10 @@
|
|||||||
|
|
||||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
||||||
<!-- Flash Messages Data (Hidden) -->
|
<!-- Flash Messages Data (Hidden) -->
|
||||||
<% if(typeof success_msg !=='undefined' || typeof error_msg
|
<% if(typeof success_msg !=='undefined' || typeof error_msg !=='undefined' || typeof error !=='undefined' ) { %>
|
||||||
!=='undefined' || typeof error !=='undefined' ) { %>
|
|
||||||
<div id="flash-messages-data" style="display: none">
|
<div id="flash-messages-data" style="display: none">
|
||||||
<%- JSON.stringify({ success_msg: typeof success_msg !=='undefined'
|
<%- JSON.stringify({ success_msg: typeof success_msg !=='undefined' && success_msg ? success_msg : null,
|
||||||
&& success_msg ? success_msg : null, error_msg: typeof error_msg
|
error_msg: typeof error_msg !=='undefined' && error_msg ? error_msg : null, error: typeof error
|
||||||
!=='undefined' && error_msg ? error_msg : null, error: typeof error
|
|
||||||
!=='undefined' && error ? error : null }) %>
|
!=='undefined' && error ? error : null }) %>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
@@ -118,5 +114,6 @@
|
|||||||
<!-- Bootstrap JS -->
|
<!-- Bootstrap JS -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<%- script %>
|
<%- script %>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -729,72 +729,52 @@
|
|||||||
|
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a class="dropdown-item <%= currentPath === '/admin/about-us' ? 'active' : '' %>"
|
||||||
class="dropdown-item <%= currentPath === '/admin/about-us' ? 'active' : '' %>"
|
href="/admin/about-us">About us</a>
|
||||||
href="/admin/about-us"
|
|
||||||
>About us</a
|
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a class="dropdown-item <%= currentPath === '/admin/safety' ? 'active' : '' %>"
|
||||||
class="dropdown-item <%= currentPath === '/admin/safety' ? 'active' : '' %>"
|
href="/admin/safety">Safety</a>
|
||||||
href="/admin/safety"
|
|
||||||
>Safety</a
|
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a class="dropdown-item <%= currentPath === '/admin/FAQ' ? 'active' : '' %>" href="/admin/faq">FAQ</a>
|
||||||
class="dropdown-item <%= currentPath === '/admin/FAQ' ? 'active' : '' %>"
|
|
||||||
href="/admin/faq"
|
|
||||||
>FAQ</a
|
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a class="dropdown-item <%= currentPath === '/admin/insurance' ? 'active' : '' %>"
|
||||||
class="dropdown-item <%= currentPath === '/admin/insurance' ? 'active' : '' %>"
|
href="/admin/insurance">Insurance</a>
|
||||||
href="/admin/insurance"
|
|
||||||
>Insurance</a
|
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a class="dropdown-item <%= currentPath === '/admin/travel' ? 'active' : '' %>"
|
||||||
class="dropdown-item <%= currentPath === '/admin/travel' ? 'active' : '' %>"
|
href="/admin/travel">Travel</a>
|
||||||
href="/admin/travel"
|
|
||||||
>Travel</a
|
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a class="dropdown-item <%= currentPath === '/admin/terms-conditions' ? 'active' : '' %>"
|
||||||
class="dropdown-item <%= currentPath === '/admin/terms-conditions' ? 'active' : '' %>"
|
href="/admin/terms-conditions">Terms & Conditions</a>
|
||||||
href="/admin/terms-conditions"
|
|
||||||
>Terms & Conditions</a
|
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a
|
<a class="nav-link <%= currentPath === '/admin/contact' ? 'active' : '' %>" href="/admin/contact">Contact
|
||||||
class="nav-link <%= currentPath === '/admin/contact' ? 'active' : '' %>"
|
Us</a>
|
||||||
href="/admin/contact"
|
|
||||||
>Contact Us</a
|
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a
|
<a class="nav-link <%= currentPath === '/admin/appointment' ? 'active' : '' %>"
|
||||||
class="nav-link <%= currentPath === '/admin/camp-location' ? 'active' : '' %>"
|
href="/admin/appointment">Appointment</a>
|
||||||
href="/admin/camp-location"
|
</li>
|
||||||
>Camp Location</a
|
<li class="nav-item">
|
||||||
>
|
<a class="nav-link <%= currentPath === '/admin/pricing' ? 'active' : '' %>"
|
||||||
|
href="/admin/pricing">Pricing</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link <%= currentPath === '/admin/camp-location' ? 'active' : '' %>"
|
||||||
|
href="/admin/camp-location">Camp Location</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a
|
<a class="nav-link <%= currentPath === '/admin/activity' ? 'active' : '' %>" href="/admin/activity">Activity
|
||||||
class="nav-link <%= currentPath === '/admin/activity' ? 'active' : '' %>"
|
& Booking</a>
|
||||||
href="/admin/activity"
|
|
||||||
>Activity & Booking</a
|
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user