Merge branch 'main' of https://gits.techvanguard.vn/UKSOURCE/cms.hailearning.edu.vn into fea/thanh-02022026-news

This commit is contained in:
Wini_Fy
2026-02-04 09:22:24 +07:00
41 changed files with 7863 additions and 570 deletions

View 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",
});
}
};

View File

@@ -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",
});
}
};

View 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",
});
}
};

View File

@@ -0,0 +1,360 @@
const { getServiceData } = require("../services/service.service");
const Service = require("../models/service");
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
const slugify = require("slugify");
// Admin page - Service list
exports.index = async (req, res) => {
try {
const data = await getServiceData();
console.log(data.services.items.image);
res.render("admin/service/index", {
title: "Service Management",
data,
layout: "layouts/main",
getFullImageUrl, // Truyền helper function vào view
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading service data");
res.redirect("/admin/dashboard");
}
};
// Admin page - Service edit
exports.edit = async (req, res) => {
try {
const { slug } = req.params;
const data = await getServiceData();
const service = data.services?.items?.find((item) => item.slug === slug);
if (!service) {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
res.render("admin/service/edit", {
title: `Edit Service - ${service.name}`,
service,
layout: "layouts/main",
getFullImageUrl,
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading service for editing");
res.redirect("/admin/service");
}
};
// Update single service
exports.updateService = async (req, res) => {
try {
const { slug } = req.params;
const currentData = await getServiceData();
const serviceIndex = currentData.services?.items?.findIndex(
(item) => item.slug === slug,
);
if (serviceIndex === -1) {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
// Update service data
const updatedData = { ...currentData.toObject?.() };
updatedData.services.items[serviceIndex] = {
...updatedData.services.items[serviceIndex],
name: req.body.name,
slug: req.body.slug,
description: req.body.description,
image: req.body.image,
layout: req.body.layout,
};
if (currentData._id) {
await Service.findByIdAndUpdate(currentData._id, updatedData);
} else {
await Service.create(updatedData);
}
req.flash("success_msg", "Service updated successfully");
res.redirect("/admin/service");
} catch (err) {
console.error(err);
req.flash("error_msg", err.message);
res.redirect("/admin/service");
}
};
// Admin page - Service details
exports.details = async (req, res) => {
try {
const { slug } = req.params;
const data = await getServiceData();
const service = data.services?.items?.find((item) => item.slug === slug);
if (!service) {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
res.render("admin/service/details", {
title: `Service Details - ${service.name}`,
service,
layout: "layouts/main",
getFullImageUrl, // Truyền helper function vào view
});
} catch (err) {
console.error(err);
req.flash("error_msg", "Error loading service details");
res.redirect("/admin/service");
}
};
// Update service list
exports.update = async (req, res) => {
try {
const currentData = await getServiceData();
const sections = [
"pageTitle",
"services",
"destinations",
"visas",
"reviews",
];
let updatedData = { ...currentData.toObject?.() };
let hasChanges = false;
sections.forEach((section) => {
if (!req.body[section]) return;
const newData = JSON.parse(req.body[section]);
if (JSON.stringify(newData) !== JSON.stringify(currentData[section])) {
updatedData[section] = newData;
hasChanges = true;
}
});
if (!hasChanges) {
req.flash("info_msg", "No changes were made");
return res.redirect("/admin/service");
}
if (currentData._id) {
await Service.findByIdAndUpdate(currentData._id, updatedData);
} else {
await Service.create(updatedData);
}
req.flash("success_msg", "Service updated successfully");
res.redirect("/admin/service");
} catch (err) {
console.error(err);
req.flash("error_msg", err.message);
res.redirect("/admin/service");
}
};
// Update service details
exports.updateDetails = async (req, res) => {
try {
const { slug } = req.params;
const currentData = await getServiceData();
const serviceIndex = currentData.services?.items?.findIndex(
(item) => item.slug === slug,
);
if (serviceIndex === -1) {
req.flash("error_msg", "Service not found");
return res.redirect("/admin/service");
}
// Parse features and FAQ from JSON strings
const features = req.body.features ? JSON.parse(req.body.features) : [];
const faq = req.body.faq ? JSON.parse(req.body.faq) : [];
// Update service details
const updatedData = { ...currentData.toObject?.() };
updatedData.services.items[serviceIndex].details = {
title: req.body.title,
description: req.body.description,
mainImage: req.body.mainImage,
overviewTitle: req.body.overviewTitle,
overviewDescription: req.body.overviewDescription,
additionalDescription: req.body.additionalDescription,
keyFeaturesTitle: req.body.keyFeaturesTitle,
keyFeaturesImage: req.body.keyFeaturesImage,
features: features,
faqTitle: req.body.faqTitle,
faqImage: req.body.faqImage,
faq: faq,
};
if (currentData._id) {
await Service.findByIdAndUpdate(currentData._id, updatedData);
} else {
await Service.create(updatedData);
}
req.flash("success_msg", "Service details updated successfully");
res.redirect(`/admin/service/${slug}/details`);
} catch (err) {
console.error(err);
req.flash("error_msg", err.message);
res.redirect("/admin/service");
}
};
// API endpoint
exports.api = async (req, res) => {
try {
const serviceData = await getServiceData();
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
const processedData = addBaseUrlToImages(serviceData, baseUrl);
res.json(processedData);
} catch (err) {
res.status(500).json({ error: "Error loading service data" });
}
};
/**
* Get service details by slug - API endpoint
*/
exports.getServiceBySlug = async (req, res) => {
try {
const { slug } = req.params;
const serviceDoc = await Service.findOne().lean();
if (!serviceDoc) {
return res.status(404).json({
success: false,
message: "Service data not found",
});
}
// Find service by slug
const service = serviceDoc.services?.items?.find(
(item) => item.slug === slug,
);
if (!service) {
return res.status(404).json({
success: false,
message: `Service with slug '${slug}' not found`,
});
}
const baseUrl =
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
// Return service details in the expected format
const responseData = {
pageTitle: serviceDoc.pageTitle,
breadcrumb: {
...serviceDoc.breadcrumb,
title: "Service Details",
items: [
{ label: "Home", href: "/" },
{ label: "Services", href: "/services" },
{ label: service.name, href: `/services/${slug}` },
],
},
serviceDetails: {
content: service.details,
keyFeatures: {
title: service.details.keyFeaturesTitle || "Key Features",
sideImage: service.details.keyFeaturesImage || "img/default.jpg",
items: service.details.features || [],
},
faq: {
title: service.details.faqTitle || "Frequently Asked Questions",
sideImage: service.details.faqImage || "img/default.jpg",
items: service.details.faq || [],
},
},
};
const processedData = addBaseUrlToImages(responseData, baseUrl);
res.json(processedData);
} catch (error) {
console.error("Error fetching service by slug:", error);
res.status(500).json({
success: false,
message: "Internal server error",
error: error.message,
});
}
};
/**
* Generate slug from text - API endpoint
*/
exports.generateSlug = async (req, res) => {
try {
const { text } = req.body;
if (!text || typeof text !== "string") {
return res.status(400).json({
success: false,
message: "Text is required",
});
}
// Generate slug using slugify library with Vietnamese support
const slug = slugify(text, {
lower: true,
strict: true,
locale: "vi",
});
res.json({
success: true,
slug: slug,
});
} catch (error) {
console.error("Error generating slug:", error);
res.status(500).json({
success: false,
message: "Internal server error",
error: error.message,
});
}
};
/**
* Get all service slugs - API endpoint
*/
exports.getServiceSlugs = async (req, res) => {
try {
const serviceDoc = await Service.findOne().lean();
if (!serviceDoc?.services?.items) {
return res.json({
success: true,
slugs: [],
});
}
const slugs = serviceDoc.services.items.map((item) => ({
slug: item.slug,
name: item.name,
id: item.id,
}));
res.json({
success: true,
slugs,
});
} catch (error) {
console.error("Error fetching service slugs:", error);
res.status(500).json({
success: false,
message: "Internal server error",
error: error.message,
});
}
};

View File

@@ -13,7 +13,7 @@ const uploadController = {
// Lấy loại ảnh từ query params // Lấy loại ảnh từ query params
const imageType = req.query.imageType || 'general'; const imageType = req.query.imageType || 'general';
// Tạo đường dẫn tương đối để lưu vào database // Tạo đường dẫn tương đối để lưu vào database
const relativePath = `/uploads/${imageType}/${req.file.filename}`; const relativePath = `/uploads/${imageType}/${req.file.filename}`;
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`; const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
@@ -41,7 +41,7 @@ const uploadController = {
if (camp) { if (camp) {
// Đồng bộ camps.image và camps.camp-detail.hero.bgImage - 2 trường này luôn giống nhau // Đồng bộ camps.image và camps.camp-detail.hero.bgImage - 2 trường này luôn giống nhau
camp.image = relativePath; camp.image = relativePath;
// Đảm bảo camp-detail.hero tồn tại và sync bgImage // Đảm bảo camp-detail.hero tồn tại và sync bgImage
if (!camp['camp-detail']) camp['camp-detail'] = {}; if (!camp['camp-detail']) camp['camp-detail'] = {};
if (!camp['camp-detail'].hero) camp['camp-detail'].hero = {}; if (!camp['camp-detail'].hero) camp['camp-detail'].hero = {};
@@ -73,59 +73,59 @@ const uploadController = {
updateImagePath: async (req, res) => { updateImagePath: async (req, res) => {
try { try {
const { jsonFile, jsonPath, newImagePath } = req.body; const { jsonFile, jsonPath, newImagePath } = req.body;
if (!jsonFile || !jsonPath || !newImagePath) { if (!jsonFile || !jsonPath || !newImagePath) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: 'Missing required information (jsonFile, jsonPath, newImagePath)' message: 'Missing required information (jsonFile, jsonPath, newImagePath)'
}); });
} }
// Đọc file JSON // Đọc file JSON
const jsonFilePath = path.join(__dirname, '../data', jsonFile); const jsonFilePath = path.join(__dirname, '../data', jsonFile);
const jsonData = jsonHelper.readJsonFile(jsonFilePath); const jsonData = jsonHelper.readJsonFile(jsonFilePath);
// Cập nhật đường dẫn ảnh theo jsonPath // Cập nhật đường dẫn ảnh theo jsonPath
// jsonPath có định dạng như "banner.image" hoặc "partners[0].logo" // jsonPath có định dạng như "banner.image" hoặc "partners[0].logo"
const pathParts = jsonPath.split('.'); const pathParts = jsonPath.split('.');
let current = jsonData; let current = jsonData;
// Duyệt qua các phần của path trừ phần cuối // Duyệt qua các phần của path trừ phần cuối
for (let i = 0; i < pathParts.length - 1; i++) { for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i]; const part = pathParts[i];
// Kiểm tra nếu là mảng (ví dụ: partners[0]) // Kiểm tra nếu là mảng (ví dụ: partners[0])
if (part.includes('[') && part.includes(']')) { if (part.includes('[') && part.includes(']')) {
const arrName = part.substring(0, part.indexOf('[')); const arrName = part.substring(0, part.indexOf('['));
const index = parseInt(part.substring(part.indexOf('[') + 1, part.indexOf(']'))); const index = parseInt(part.substring(part.indexOf('[') + 1, part.indexOf(']')));
if (!current[arrName] || !Array.isArray(current[arrName])) { if (!current[arrName] || !Array.isArray(current[arrName])) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: `Array ${arrName} not found in data` message: `Array ${arrName} not found in data`
}); });
} }
current = current[arrName][index]; current = current[arrName][index];
} else { } else {
if (!current[part]) { if (!current[part]) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: `Property ${part} not found in data` message: `Property ${part} not found in data`
}); });
} }
current = current[part]; current = current[part];
} }
} }
// Cập nhật giá trị // Cập nhật giá trị
const lastPart = pathParts[pathParts.length - 1]; const lastPart = pathParts[pathParts.length - 1];
current[lastPart] = newImagePath; current[lastPart] = newImagePath;
// Lưu lại file JSON // Lưu lại file JSON
jsonHelper.writeJsonFile(jsonFilePath, jsonData); jsonHelper.writeJsonFile(jsonFilePath, jsonData);
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
message: 'Image path updated successfully', message: 'Image path updated successfully',
@@ -141,22 +141,22 @@ const uploadController = {
deleteImage: async (req, res) => { deleteImage: async (req, res) => {
try { try {
const { imagePath } = req.body; const { imagePath } = req.body;
if (!imagePath) { if (!imagePath) {
return res.status(400).json({ success: false, message: 'Missing image path to delete' }); return res.status(400).json({ success: false, message: 'Missing image path to delete' });
} }
// Chuyển đổi đường dẫn tương đối thành đường dẫn tuyệt đối // Chuyển đổi đường dẫn tương đối thành đường dẫn tuyệt đối
const fullPath = path.join(__dirname, '../public', imagePath); const fullPath = path.join(__dirname, '../public', imagePath);
// Kiểm tra xem file có tồn tại không // Kiểm tra xem file có tồn tại không
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
return res.status(404).json({ success: false, message: 'Image file not found' }); return res.status(404).json({ success: false, message: 'Image file not found' });
} }
// Xóa file // Xóa file
fs.unlinkSync(fullPath); fs.unlinkSync(fullPath);
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
message: 'Image deleted successfully', message: 'Image deleted successfully',

77
data/appointment.json Normal file
View 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"
}
}
}

View File

@@ -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": "&copy; <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
View 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
View 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."
}
]
}
}

363
data/service.json Normal file
View File

@@ -0,0 +1,363 @@
{
"pageTitle": "Visaway Immigration & Visa Consulting HTML Template",
"services": {
"title": {
"subTitle": "What We Offer",
"mainTitle": "Our Immigration Services"
},
"items": [
{
"slug": "immigration-appeal",
"name": "Immigration Appeal & Legal Support",
"description": "Our experts provide professional guidance for immigration appeals and legal matters, helping clients overcome visa rejections with personalized strategies and strong case representation.",
"image": "img/home-3/service/01.jpg",
"layout": "left",
"details": {
"title": "Immigration Appeal & Legal Support",
"description": "Our experts provide professional guidance for immigration appeals and legal matters, helping clients overcome visa rejections with personalized strategies and strong case representation. We analyze your case thoroughly and develop custom strategies to maximize your chances of success.",
"mainImage": "img/inner-page/service-details/details-1.jpg",
"overviewTitle": "Service Overview",
"overviewDescription": "Our Immigration Appeal & Legal Support service is designed to help clients navigate complex immigration challenges. We provide expert legal guidance, case analysis, and strategic representation to maximize your chances of success. With our expert consultants, personalized approach, and global network, we ensure a smooth transition for every client.",
"additionalDescription": "From start to finish, we are committed to turning your immigration challenges into success stories through professional legal representation and strategic planning.",
"keyFeaturesTitle": "Key Features",
"keyFeaturesImage": "img/inner-page/service-details/details-2.jpg",
"features": [
{
"title": "Personalized Guidance",
"description": "Tailored support for each client's specific legal situation and requirements."
},
{
"title": "Expert Legal Team",
"description": "Experienced immigration lawyers with proven track records in appeals."
},
{
"title": "Case Analysis & Strategy",
"description": "Thorough case review and development of winning appeal strategies."
},
{
"title": "Document Preparation",
"description": "Professional preparation of all legal documents and supporting evidence."
},
{
"title": "Court Representation",
"description": "Expert representation in immigration courts and tribunals."
},
{
"title": "Success Monitoring",
"description": "Regular updates and monitoring throughout the appeal process."
}
],
"faqTitle": "Frequently Asked Question",
"faqImage": "img/inner-page/service-details/details-3.jpg",
"faq": [
{
"id": "faq-appeal-1",
"question": "01. What are the chances of a successful appeal?",
"answer": "Success rates vary by case type and circumstances, but our experienced legal team significantly improves your chances through thorough case analysis and strategic representation tailored to your specific situation.",
"isExpanded": false
},
{
"id": "faq-appeal-2",
"question": "02. How long does the appeal process take?",
"answer": "Appeal timelines vary by jurisdiction and case complexity, typically ranging from 6-18 months. We keep you informed throughout the process and work to expedite where possible.",
"isExpanded": false
},
{
"id": "faq-appeal-3",
"question": "03. What documents do I need for an appeal?",
"answer": "Required documents vary by case but typically include the original decision, supporting evidence, and legal submissions. We provide a comprehensive checklist and assist with document preparation.",
"isExpanded": false
},
{
"id": "faq-appeal-4",
"question": "04. Do you handle all types of immigration appeals?",
"answer": "Yes, we handle various types of immigration appeals including visa refusals, deportation orders, and residency rejections. Our team has expertise across all immigration categories.",
"isExpanded": false
}
]
}
},
{
"slug": "scholarship-guidance",
"name": "Scholarship & Study Grant Guidance",
"description": "We help students unlock opportunities to study abroad with the right financial support. Our expert advisors guide you in finding scholarships, grants, and funding options that match your academic background, chosen destination, and career goals.",
"image": "img/home-3/service/02.jpg",
"layout": "right",
"details": {
"title": "Scholarship & Study Grant Guidance",
"description": "We help students unlock opportunities to study abroad with the right financial support. Our expert advisors guide you in finding scholarships, grants, and funding options that match your academic background, chosen destination, and career goals. From preparing strong applications to meeting eligibility criteria, we ensure you maximize your chances of securing financial aid.",
"mainImage": "img/inner-page/service-details/details-1.jpg",
"overviewTitle": "Service Overview",
"overviewDescription": "Our Education Visa Consultancy is dedicated to guiding students in achieving their study abroad dreams. We provide complete support including university selection, application assistance, scholarship guidance, visa documentation, and interview preparation. With our expert consultants, personalized approach, and global network, we ensure a smooth transition for every student.",
"additionalDescription": "From start to finish, we are committed to turning your education journey into a successful international experience.",
"keyFeaturesTitle": "Key Features",
"keyFeaturesImage": "img/inner-page/service-details/details-2.jpg",
"features": [
{
"title": "Personalized Guidance",
"description": "Tailored support for each student's goals and requirements."
},
{
"title": "Target Audience & Persona Development",
"description": "Experienced team with global education and visa knowledge."
},
{
"title": "Scholarship & Grant Assistance",
"description": "Helping students secure financial aid opportunities."
},
{
"title": "Visa Application Support",
"description": "Step-by-step guidance for smooth visa processing."
},
{
"title": "Interview Preparation",
"description": "Coaching for successful student visa interviews."
},
{
"title": "Documentation Assistance",
"description": "Accurate and complete paperwork for faster approvals."
}
],
"faqTitle": "Frequently Asked Question",
"faqImage": "img/inner-page/service-details/details-3.jpg",
"faq": [
{
"id": "faq-scholarship-1",
"question": "01. Do you assist with university selection?",
"answer": "Absolutely! We identify suitable scholarships, guide application processes, and maximize your chances of receiving financial aid.",
"isExpanded": false
},
{
"id": "faq-scholarship-2",
"question": "02. Can you help with scholarship applications?",
"answer": "Absolutely! We identify suitable scholarships, guide application processes, and maximize your chances of receiving financial aid.",
"isExpanded": true
},
{
"id": "faq-scholarship-3",
"question": "03. How long does the visa process take?",
"answer": "Absolutely! We identify suitable scholarships, guide application processes, and maximize your chances of receiving financial aid.",
"isExpanded": false
},
{
"id": "faq-scholarship-4",
"question": "04. Is post-arrival support available?",
"answer": "Absolutely! We identify suitable scholarships, guide application processes, and maximize your chances of receiving financial aid.",
"isExpanded": false
}
]
}
},
{
"slug": "permanent-residency",
"name": "Permanent Residency (PR) Services",
"description": "Our PR services guide clients through every step of the residency process, including documentation, eligibility assessment, and application support, ensuring a smooth and successful approval.",
"image": "img/home-3/service/03.jpg",
"layout": "left",
"details": {
"title": "Permanent Residency (PR) Services",
"description": "Our PR services guide clients through every step of the residency process, including documentation, eligibility assessment, and application support, ensuring a smooth and successful approval.",
"mainImage": "img/inner-page/service-details/details-1.jpg",
"overviewTitle": "Service Overview",
"overviewDescription": "Our Permanent Residency services provide comprehensive support for individuals seeking to establish permanent residence in their chosen country. We handle all aspects of the PR application process with expertise and care.",
"additionalDescription": "Our experienced team ensures that your PR application is handled professionally and efficiently, maximizing your chances of approval.",
"keyFeaturesTitle": "Key Features",
"keyFeaturesImage": "img/inner-page/service-details/details-2.jpg",
"features": [
{
"title": "Eligibility Assessment",
"description": "Comprehensive evaluation of your PR eligibility and options."
},
{
"title": "Points Calculation",
"description": "Accurate calculation and optimization of your points score."
},
{
"title": "Document Verification",
"description": "Thorough verification and preparation of all required documents."
},
{
"title": "Application Tracking",
"description": "Regular updates and tracking of your PR application status."
},
{
"title": "Interview Preparation",
"description": "Coaching and preparation for PR interviews if required."
},
{
"title": "Post-Approval Support",
"description": "Guidance on next steps after PR approval and settlement."
}
],
"faqTitle": "Frequently Asked Question",
"faqImage": "img/inner-page/service-details/details-3.jpg",
"faq": [
{
"id": "faq-pr-1",
"question": "01. How long does the PR process take?",
"answer": "Processing times vary by country and program, typically ranging from 12-24 months. We provide realistic timelines based on current processing standards.",
"isExpanded": false
},
{
"id": "faq-pr-2",
"question": "02. What documents are required for PR application?",
"answer": "Document requirements vary by country but typically include educational credentials, work experience, language test results, and medical examinations. We provide a complete checklist.",
"isExpanded": true
},
{
"id": "faq-pr-3",
"question": "03. Can I include my family in the PR application?",
"answer": "Yes, most PR programs allow you to include your spouse and dependent children. We help you understand family inclusion requirements and processes.",
"isExpanded": false
},
{
"id": "faq-pr-4",
"question": "04. What happens if my PR application is rejected?",
"answer": "If rejected, we analyze the reasons and explore options including appeals, reapplication, or alternative immigration pathways to achieve your goals.",
"isExpanded": false
}
]
}
},
{
"slug": "citizenship-naturalization",
"name": "Citizenship & Naturalization Guidance",
"description": "We provide expert guidance for citizenship and naturalization processes, assisting clients with documentation, eligibility, and legal procedures to achieve a smooth and successful application.",
"image": "img/home-3/service/04.jpg",
"layout": "right",
"details": {
"title": "Citizenship & Naturalization Guidance",
"description": "We provide expert guidance for citizenship and naturalization processes, assisting clients with documentation, eligibility, and legal procedures to achieve a smooth and successful application.",
"mainImage": "img/inner-page/service-details/details-1.jpg",
"overviewTitle": "Service Overview",
"overviewDescription": "Our Citizenship & Naturalization service helps individuals navigate the complex process of becoming a citizen. We provide step-by-step guidance, documentation support, and legal expertise throughout the entire process.",
"additionalDescription": "With our comprehensive approach, we make the path to citizenship clear, manageable, and successful for every client.",
"keyFeaturesTitle": "Key Features",
"keyFeaturesImage": "img/inner-page/service-details/details-2.jpg",
"features": [
{
"title": "Citizenship Test Preparation",
"description": "Comprehensive preparation for citizenship knowledge tests."
},
{
"title": "Language Requirements",
"description": "Guidance on meeting language proficiency requirements."
},
{
"title": "Residency Verification",
"description": "Assistance with proving residency and physical presence requirements."
},
{
"title": "Application Processing",
"description": "Complete support throughout the citizenship application process."
},
{
"title": "Interview Coaching",
"description": "Preparation and coaching for citizenship interviews."
},
{
"title": "Ceremony Preparation",
"description": "Support and guidance for the citizenship ceremony process."
}
],
"faqTitle": "Frequently Asked Question",
"faqImage": "img/inner-page/service-details/details-3.jpg",
"faq": [
{
"id": "faq-citizenship-1",
"question": "What are the basic requirements for citizenship?",
"answer": "Requirements typically include permanent residency, physical presence, language proficiency, and knowledge of the country's history and government. Specific requirements vary by country.",
"isExpanded": false
},
{
"id": "faq-citizenship-2",
"question": "How do I prepare for the citizenship test?",
"answer": "We provide comprehensive study materials, practice tests, and coaching sessions to help you prepare for both the knowledge test and language requirements.",
"isExpanded": false
},
{
"id": "faq-citizenship-3",
"question": "How long does the citizenship process take?",
"answer": "Processing times vary by country but typically range from 12-24 months from application to ceremony. We help you understand specific timelines for your situation.",
"isExpanded": false
},
{
"id": "faq-citizenship-4",
"question": "Can I maintain dual citizenship?",
"answer": "Dual citizenship policies vary by country. We help you understand the implications and requirements for maintaining multiple citizenships if applicable.",
"isExpanded": false
}
]
}
}
]
},
"destinations": {
"backgroundImage": "img/home-3/choose-us/bg.png",
"title": {
"subTitle": "Countries we offer",
"mainTitle": "Choose Your Immigration Destination"
}
},
"visas": {
"items": [
{
"id": "family-visa",
"number": "01",
"name": "Family Visa",
"description": "Our Family Visa services help reunite loved ones by providing expert guidance.",
"buttonText": "service _ 02",
"buttonLink": "service-details.html"
},
{
"id": "student-visa",
"number": "02",
"name": "Student Visa",
"description": "We provide expert guidance for student visa applications.",
"buttonText": "service _ 02",
"buttonLink": "service-details.html"
},
{
"id": "work-visa",
"number": "03",
"name": "Work Visa",
"description": "Collaboratively disintermediate one to one functionalities and long term.",
"buttonText": "service _ 02",
"buttonLink": "service-details.html"
}
]
},
"reviews": {
"title": {
"subTitle": "What Our Clients Say",
"mainTitle": "Immigration Success Stories"
},
"thumb": "img/home-3/test-thumb.jpg",
"items": [
{
"id": "client-review-1",
"rating": 5,
"content": "The team provided exceptional guidance throughout my immigration process.",
"author": {
"name": "Mohammed Ali,",
"type": "Family Visa"
},
"icon": "fa-solid fa-quote-right"
},
{
"id": "client-review-2",
"rating": 5,
"content": "Their expertise and personalized support ensured a smooth visa approval.",
"author": {
"name": "Sarah Johnson,",
"type": "Student Visa"
},
"icon": "fa-solid fa-quote-right"
}
]
}
}

View File

@@ -9,18 +9,18 @@ const storage = multer.diskStorage({
try { try {
// Lấy loại ảnh từ request fields // Lấy loại ảnh từ request fields
const imageType = req.query.imageType || 'general'; const imageType = req.query.imageType || 'general';
// Tạo đường dẫn đến thư mục lưu trữ // Tạo đường dẫn đến thư mục lưu trữ
const uploadPath = path.join(__dirname, '../public/uploads', imageType); const uploadPath = path.join(__dirname, '../public/uploads', imageType);
console.log('Creating upload directory:', uploadPath); console.log('Creating upload directory:', uploadPath);
// Kiểm tra và tạo thư mục nếu chưa tồn tại // Kiểm tra và tạo thư mục nếu chưa tồn tại
if (!fs.existsSync(uploadPath)) { if (!fs.existsSync(uploadPath)) {
fs.mkdirSync(uploadPath, { recursive: true }); fs.mkdirSync(uploadPath, { recursive: true });
console.log('Directory created successfully'); console.log('Directory created successfully');
} }
cb(null, uploadPath); cb(null, uploadPath);
} catch (error) { } catch (error) {
console.error('Error creating directory:', error); console.error('Error creating directory:', error);
@@ -31,11 +31,11 @@ const storage = multer.diskStorage({
try { try {
const imageType = req.query.imageType || 'general'; const imageType = req.query.imageType || 'general';
const uploadPath = path.join(__dirname, '../public/uploads', imageType); const uploadPath = path.join(__dirname, '../public/uploads', imageType);
// Lấy tên file gốc (sanitize để tránh ký tự đặc biệt) // Lấy tên file gốc (sanitize để tránh ký tự đặc biệt)
const originalName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); const originalName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
const fullPath = path.join(uploadPath, originalName); const fullPath = path.join(uploadPath, originalName);
// Kiểm tra nếu file đã tồn tại // Kiểm tra nếu file đã tồn tại
if (fs.existsSync(fullPath)) { if (fs.existsSync(fullPath)) {
console.log('File already exists, reusing:', originalName); console.log('File already exists, reusing:', originalName);
@@ -63,7 +63,7 @@ const fileFilter = (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp|svg/; const allowedTypes = /jpeg|jpg|png|gif|webp|svg/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype); const mimetype = allowedTypes.test(file.mimetype);
if (extname && mimetype) { if (extname && mimetype) {
return cb(null, true); return cb(null, true);
} else { } else {
@@ -83,12 +83,12 @@ const videoStorage = multer.diskStorage({
destination: function (req, file, cb) { destination: function (req, file, cb) {
// Tạo đường dẫn đến thư mục lưu trữ video // Tạo đường dẫn đến thư mục lưu trữ video
const uploadPath = path.join(__dirname, '../public/uploads/videos'); const uploadPath = path.join(__dirname, '../public/uploads/videos');
// Kiểm tra và tạo thư mục nếu chưa tồn tại // Kiểm tra và tạo thư mục nếu chưa tồn tại
if (!fs.existsSync(uploadPath)) { if (!fs.existsSync(uploadPath)) {
fs.mkdirSync(uploadPath, { recursive: true }); fs.mkdirSync(uploadPath, { recursive: true });
} }
cb(null, uploadPath); cb(null, uploadPath);
}, },
filename: function (req, file, cb) { filename: function (req, file, cb) {
@@ -103,7 +103,7 @@ const videoStorage = multer.diskStorage({
const videoFileFilter = (req, file, cb) => { const videoFileFilter = (req, file, cb) => {
const allowedTypes = /mp4|webm/; const allowedTypes = /mp4|webm/;
const mimetype = allowedTypes.test(file.mimetype); const mimetype = allowedTypes.test(file.mimetype);
if (mimetype) { if (mimetype) {
return cb(null, true); return cb(null, true);
} else { } else {
@@ -124,7 +124,7 @@ async function convertToWebp(req, res, next) {
if (!req.file) return next(); if (!req.file) return next();
console.log('🔄 Converting image to webp format...'); console.log('🔄 Converting image to webp format...');
console.log('Original file:', req.file.path); console.log('Original file:', req.file.path);
try { try {
const filePath = req.file.path; const filePath = req.file.path;
const webpPath = filePath.replace(path.extname(filePath), '.webp'); const webpPath = filePath.replace(path.extname(filePath), '.webp');
@@ -139,7 +139,7 @@ async function convertToWebp(req, res, next) {
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
} }
// Cập nhật thông tin file // Cập nhật thông tin file
req.file.filename = path.basename(webpPath); req.file.filename = path.basename(webpPath);
req.file.path = webpPath; req.file.path = webpPath;

206
models/appointment.js Normal file
View 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);

View 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);

View File

@@ -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",
}, },
}, },
}; };

View 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
View 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);

118
models/service.js Normal file
View File

@@ -0,0 +1,118 @@
const mongoose = require("mongoose");
// Define sub-schemas first
const authorSchema = new mongoose.Schema(
{
name: String,
type: String,
},
{ _id: false },
);
const clientReviewSchema = new mongoose.Schema(
{
id: String,
rating: Number,
content: String,
author: authorSchema,
icon: String,
},
{ _id: false },
);
const featureSchema = new mongoose.Schema(
{
title: String,
description: String,
},
{ _id: false },
);
const faqSchema = new mongoose.Schema(
{
id: String,
question: String,
answer: String,
isExpanded: { type: Boolean, default: false },
},
{ _id: false },
);
const serviceDetailsSchema = new mongoose.Schema(
{
title: String,
description: String,
mainImage: String,
overviewTitle: String,
overviewDescription: String,
additionalDescription: String,
keyFeaturesTitle: String,
keyFeaturesImage: String,
features: [featureSchema],
faqTitle: String,
faqImage: String,
faq: [faqSchema],
},
{ _id: false },
);
// Main service page schema
const serviceSchema = new mongoose.Schema(
{
pageTitle: String,
// Main services section
services: {
title: {
subTitle: String,
mainTitle: String,
},
items: [
{
slug: String,
name: String,
description: String,
image: String,
layout: String,
details: serviceDetailsSchema,
},
],
},
// Destination countries section
destinations: {
backgroundImage: String,
title: {
subTitle: String,
mainTitle: String,
},
},
// Visa types section
visas: {
items: [
{
id: String,
number: String,
name: String,
description: String,
buttonText: String,
buttonLink: String,
},
],
},
// Client reviews section
reviews: {
title: {
subTitle: String,
mainTitle: String,
},
thumb: String,
items: [clientReviewSchema],
},
},
{ timestamps: true },
);
module.exports = mongoose.model("Service", serviceSchema);

BIN
public/img/default.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@@ -23,6 +23,7 @@ const insuranceController = require("../controllers/insuranceController");
const activityController = require("../controllers/activityController"); const activityController = require("../controllers/activityController");
const bookingSubmissionController = require("../controllers/bookingSubmissionController"); const bookingSubmissionController = require("../controllers/bookingSubmissionController");
const serviceController = require("../controllers/serviceController");
// Blog controllers // Blog controllers
const blogController = require("../controllers/blogController"); const blogController = require("../controllers/blogController");
@@ -51,28 +52,28 @@ router.get("/about-us", ensureAuthenticated, aboutUsController.index);
router.get( router.get(
"/about-us/create", "/about-us/create",
ensureAuthenticated, ensureAuthenticated,
aboutUsController.createForm aboutUsController.createForm,
); );
router.post("/about-us/create", ensureAuthenticated, aboutUsController.create); router.post("/about-us/create", ensureAuthenticated, aboutUsController.create);
router.get( router.get(
"/about-us/:id/edit", "/about-us/:id/edit",
ensureAuthenticated, ensureAuthenticated,
aboutUsController.editForm aboutUsController.editForm,
); );
router.post( router.post(
"/about-us/:id/update", "/about-us/:id/update",
ensureAuthenticated, ensureAuthenticated,
aboutUsController.update aboutUsController.update,
); );
router.post( router.post(
"/about-us/:id/delete", "/about-us/:id/delete",
ensureAuthenticated, ensureAuthenticated,
aboutUsController.delete aboutUsController.delete,
); );
router.get( router.get(
"/about-us/:id/preview", "/about-us/:id/preview",
ensureAuthenticated, ensureAuthenticated,
aboutUsController.preview aboutUsController.preview,
); );
// Booking admin CRUD removed // Booking admin CRUD removed
@@ -82,7 +83,7 @@ router.get("/form", ensureAuthenticated, formController.index);
router.post( router.post(
"/form/update", "/form/update",
ensureAuthenticated, ensureAuthenticated,
formController.updateDefaultForm formController.updateDefaultForm,
); );
// Upload routes // Upload routes
@@ -98,23 +99,23 @@ router.post(
ensureAuthenticated, ensureAuthenticated,
upload.single("image"), upload.single("image"),
// convertToWebp, // Disabled to keep original image format (JPG/PNG) // convertToWebp, // Disabled to keep original image format (JPG/PNG)
uploadController.uploadImage uploadController.uploadImage,
); );
router.post( router.post(
"/upload/video", "/upload/video",
ensureAuthenticated, ensureAuthenticated,
uploadVideo.single("video"), uploadVideo.single("video"),
uploadController.uploadVideo uploadController.uploadVideo,
); );
router.post( router.post(
"/upload/update-path", "/upload/update-path",
ensureAuthenticated, ensureAuthenticated,
uploadController.updateImagePath uploadController.updateImagePath,
); );
router.post( router.post(
"/upload/delete", "/upload/delete",
ensureAuthenticated, ensureAuthenticated,
uploadController.deleteImage uploadController.deleteImage,
); );
// Header routes // Header routes
@@ -123,22 +124,22 @@ router.post("/header/update", ensureAuthenticated, headerController.update);
router.post( router.post(
"/header/update-menu", "/header/update-menu",
ensureAuthenticated, ensureAuthenticated,
headerController.updateMenu headerController.updateMenu,
); );
router.get( router.get(
"/header/menu-tree", "/header/menu-tree",
ensureAuthenticated, ensureAuthenticated,
headerController.getMenuTree headerController.getMenuTree,
); );
router.get( router.get(
"/header/programmes/:menuId", "/header/programmes/:menuId",
ensureAuthenticated, ensureAuthenticated,
headerController.getProgrammesByMenuId headerController.getProgrammesByMenuId,
); );
router.get( router.get(
"/header/menu-item/:menuId", "/header/menu-item/:menuId",
ensureAuthenticated, ensureAuthenticated,
headerController.getMenuItem headerController.getMenuItem,
); );
router.get("/header/data", ensureAuthenticated, headerController.getHeaderData); router.get("/header/data", ensureAuthenticated, headerController.getHeaderData);
@@ -153,89 +154,135 @@ router.post("/contact/update", ensureAuthenticated, contactController.update);
router.get( router.get(
"/contact/data", "/contact/data",
ensureAuthenticated, ensureAuthenticated,
contactController.getContactData contactController.getContactData,
); );
// Contact submissions management
router.get(
"/contact/submissions",
ensureAuthenticated,
contactController.getSubmissions
);
router.put(
"/contact/submissions/:id",
ensureAuthenticated,
contactController.updateSubmissionStatus
);
// Appointment management
const appointmentController = require("../controllers/appointmentController");
router.get(
"/appointments",
ensureAuthenticated,
appointmentController.getAppointments
);
router.get(
"/appointments/:id",
ensureAuthenticated,
appointmentController.getAppointmentById
);
router.put(
"/appointments/:id",
ensureAuthenticated,
appointmentController.updateAppointmentStatus
);
router.delete(
"/appointments/:id",
ensureAuthenticated,
appointmentController.deleteAppointment
);
// Appointment CMS page management
router.get("/appointment", ensureAuthenticated, appointmentController.index);
router.post("/appointment/update", ensureAuthenticated, appointmentController.update);
router.get("/appointment/data", ensureAuthenticated, appointmentController.getAppointmentData);
// Pricing CMS page management
const pricingController = require("../controllers/pricingController");
router.get("/pricing", ensureAuthenticated, pricingController.index);
router.post("/pricing/update", ensureAuthenticated, pricingController.update);
router.get("/pricing/data", ensureAuthenticated, pricingController.getPricingData);
// Activity CRUD routes // Activity CRUD routes
router.get("/activity", ensureAuthenticated, activityController.index); router.get("/activity", ensureAuthenticated, activityController.index);
router.get( router.get(
"/activity/create", "/activity/create",
ensureAuthenticated, ensureAuthenticated,
activityController.createForm activityController.createForm,
); );
router.post("/activity/create", ensureAuthenticated, activityController.create); router.post("/activity/create", ensureAuthenticated, activityController.create);
// Update filters (place before any parameterized /activity/:id routes to avoid route collision) // Update filters (place before any parameterized /activity/:id routes to avoid route collision)
router.post( router.post(
"/activity/filters/update", "/activity/filters/update",
ensureAuthenticated, ensureAuthenticated,
activityController.updateFilters activityController.updateFilters,
); );
// Update hero (global hero section for activities) // Update hero (global hero section for activities)
router.post( router.post(
"/activity/hero/update", "/activity/hero/update",
ensureAuthenticated, ensureAuthenticated,
activityController.updateHero activityController.updateHero,
); );
router.get( router.get(
"/activity/:id/edit", "/activity/:id/edit",
ensureAuthenticated, ensureAuthenticated,
activityController.editForm activityController.editForm,
); );
router.post( router.post(
"/activity/:id/update", "/activity/:id/update",
ensureAuthenticated, ensureAuthenticated,
activityController.update activityController.update,
); );
router.post( router.post(
"/activity/:id/delete", "/activity/:id/delete",
ensureAuthenticated, ensureAuthenticated,
activityController.delete activityController.delete,
); );
router.post( router.post(
"/activity/:id/toggle-status", "/activity/:id/toggle-status",
ensureAuthenticated, ensureAuthenticated,
activityController.toggleStatus activityController.toggleStatus,
); );
// Update display order // Update display order
router.post( router.post(
"/activity/update-order", "/activity/update-order",
ensureAuthenticated, ensureAuthenticated,
activityController.updateOrder activityController.updateOrder,
); );
// Booking submissions routes // Booking submissions routes
router.get( router.get(
"/activity/:id/bookings/count", "/activity/:id/bookings/count",
ensureAuthenticated, ensureAuthenticated,
activityController.getBookingCount activityController.getBookingCount,
); );
router.get( router.get(
"/activity/:id/bookings", "/activity/:id/bookings",
ensureAuthenticated, ensureAuthenticated,
activityController.getBookingSubmissions activityController.getBookingSubmissions,
); );
router.get( router.get(
"/activity/:id/bookings/export", "/activity/:id/bookings/export",
ensureAuthenticated, ensureAuthenticated,
activityController.exportBookingData activityController.exportBookingData,
); );
// Export all bookings (across all activities) // Export all bookings (across all activities)
router.get( router.get(
"/bookings/export-all", "/bookings/export-all",
ensureAuthenticated, ensureAuthenticated,
activityController.exportAllBookingsData activityController.exportAllBookingsData,
); );
// Update booking submission // Update booking submission
router.put( router.put(
"/bookings/:bookingId", "/bookings/:bookingId",
ensureAuthenticated, ensureAuthenticated,
bookingSubmissionController.updateBookingSubmission bookingSubmissionController.updateBookingSubmission,
); );
// Delete booking submission // Delete booking submission
router.delete( router.delete(
"/bookings/:bookingId", "/bookings/:bookingId",
ensureAuthenticated, ensureAuthenticated,
bookingSubmissionController.deleteBookingSubmission bookingSubmissionController.deleteBookingSubmission,
); );
// Update filters // Update filters
@@ -244,7 +291,7 @@ router.delete(
router.get( router.get(
"/activity/:id/preview", "/activity/:id/preview",
ensureAuthenticated, ensureAuthenticated,
activityController.preview activityController.preview,
); );
// FAQ routes - Thêm vào đây // FAQ routes - Thêm vào đây
@@ -255,8 +302,16 @@ router.get("/faq/api", faqController.api);
// API routes cho quản lý FAQ items (AJAX calls) // API routes cho quản lý FAQ items (AJAX calls)
router.post("/faq/api/add-faq", ensureAuthenticated, faqController.addFAQ); router.post("/faq/api/add-faq", ensureAuthenticated, faqController.addFAQ);
router.put("/faq/api/update-faq-item/:sectionId/:faqId", ensureAuthenticated, faqController.updateFAQItem); router.put(
router.delete("/faq/api/delete-faq-item/:sectionId/:faqId", ensureAuthenticated, faqController.deleteFAQItem); "/faq/api/update-faq-item/:sectionId/:faqId",
ensureAuthenticated,
faqController.updateFAQItem,
);
router.delete(
"/faq/api/delete-faq-item/:sectionId/:faqId",
ensureAuthenticated,
faqController.deleteFAQItem,
);
router.get("/terms-conditions", ensureAuthenticated, termsController.index); router.get("/terms-conditions", ensureAuthenticated, termsController.index);
router.post("/terms/update", ensureAuthenticated, termsController.update); router.post("/terms/update", ensureAuthenticated, termsController.update);
router.get("/terms/data", ensureAuthenticated, termsController.getTermsData); router.get("/terms/data", ensureAuthenticated, termsController.getTermsData);
@@ -272,80 +327,138 @@ router.get("/travel/api", travelController.api);
router.get("/travel/seed", ensureAuthenticated, travelController.seed); router.get("/travel/seed", ensureAuthenticated, travelController.seed);
// API routes cho quản lý FAQ sections (AJAX calls) // API routes cho quản lý FAQ sections (AJAX calls)
router.post("/faq/api/add-section", ensureAuthenticated, faqController.addFAQSection); router.post(
router.put("/faq/api/update-section/:sectionId", ensureAuthenticated, faqController.updateFAQSection); "/faq/api/add-section",
router.delete("/faq/api/delete-section/:sectionId", ensureAuthenticated, faqController.deleteFAQSection); ensureAuthenticated,
router.post("/faq/api/reorder-sections", ensureAuthenticated, faqController.reorderFAQSection); faqController.addFAQSection,
);
router.put(
"/faq/api/update-section/:sectionId",
ensureAuthenticated,
faqController.updateFAQSection,
);
router.delete(
"/faq/api/delete-section/:sectionId",
ensureAuthenticated,
faqController.deleteFAQSection,
);
router.post(
"/faq/api/reorder-sections",
ensureAuthenticated,
faqController.reorderFAQSection,
);
// API routes cho sidebar navigation (AJAX calls) // API routes cho sidebar navigation (AJAX calls)
router.put("/faq/api/update-sidebar", ensureAuthenticated, faqController.updateSidebarNav); router.put(
"/faq/api/update-sidebar",
ensureAuthenticated,
faqController.updateSidebarNav,
);
// Safety routes // Safety routes
router.get("/safety", ensureAuthenticated, safetyController.index); router.get("/safety", ensureAuthenticated, safetyController.index);
router.post("/safety/update", ensureAuthenticated, safetyController.update); router.post("/safety/update", ensureAuthenticated, safetyController.update);
// Camp Location routes // Camp Location routes
router.get("/camp-location", ensureAuthenticated, campLocationController.index); router.get("/camp-location", ensureAuthenticated, campLocationController.index);
router.post("/camp-location/update", ensureAuthenticated, campLocationController.update); router.post(
"/camp-location/update",
ensureAuthenticated,
campLocationController.update,
);
//Insurance routes //Insurance routes
router.get("/insurance", ensureAuthenticated, insuranceController.index); router.get("/insurance", ensureAuthenticated, insuranceController.index);
router.post("/insurance/update", ensureAuthenticated, insuranceController.update); router.post(
"/insurance/update",
ensureAuthenticated,
insuranceController.update,
);
// Service routes
router.get("/service", ensureAuthenticated, serviceController.index);
router.post("/service/update", ensureAuthenticated, serviceController.update);
router.post(
"/service/generate-slug",
ensureAuthenticated,
serviceController.generateSlug,
);
router.get("/service/:slug/edit", ensureAuthenticated, serviceController.edit);
router.post(
"/service/:slug/edit",
ensureAuthenticated,
serviceController.updateService,
);
router.get(
"/service/:slug/details",
ensureAuthenticated,
serviceController.details,
);
router.post(
"/service/:slug/details/update",
ensureAuthenticated,
serviceController.updateDetails,
);
// Test Image Paths route // Test Image Paths route
router.get("/test-images", ensureAuthenticated, (req, res) => { router.get("/test-images", ensureAuthenticated, (req, res) => {
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const campLocationData = require('../data/camp-location.json'); const campLocationData = require('../data/camp-location.json');
// Collect all image paths // Collect all image paths
const imagePaths = []; const imagePaths = [];
// Camps images // Camps images
if (campLocationData.camps) { if (campLocationData.camps) {
campLocationData.camps.forEach(camp => { campLocationData.camps.forEach((camp) => {
if (camp.image) { if (camp.image) {
imagePaths.push({ imagePaths.push({
type: 'Camp', type: "Camp",
name: camp.title, name: camp.title,
path: camp.image, path: camp.image,
exists: fs.existsSync(path.join(__dirname, '../public', camp.image)) exists: fs.existsSync(path.join(__dirname, "../public", camp.image)),
}); });
} }
}); });
} }
// Locations images // Locations images
if (campLocationData.locations) { if (campLocationData.locations) {
campLocationData.locations.forEach(location => { campLocationData.locations.forEach((location) => {
if (location.imageSrc) { if (location.imageSrc) {
imagePaths.push({ imagePaths.push({
type: 'Location', type: "Location",
name: location.title, name: location.title,
path: location.imageSrc, path: location.imageSrc,
exists: fs.existsSync(path.join(__dirname, '../public', location.imageSrc)) exists: fs.existsSync(
path.join(__dirname, "../public", location.imageSrc),
),
}); });
} }
// Program images // Program images
if (location.programOptions) { if (location.programOptions) {
location.programOptions.forEach(program => { location.programOptions.forEach((program) => {
if (program.imageSrc) { if (program.imageSrc) {
imagePaths.push({ imagePaths.push({
type: 'Program', type: "Program",
name: program.title, name: program.title,
path: program.imageSrc, path: program.imageSrc,
exists: fs.existsSync(path.join(__dirname, '../public', program.imageSrc)) exists: fs.existsSync(
path.join(__dirname, "../public", program.imageSrc),
),
}); });
} }
}); });
} }
}); });
} }
res.render('admin/test-images', { res.render('admin/test-images', {
layout: 'layouts/admin', layout: 'layouts/admin',
title: 'Test Image Paths', title: 'Test Image Paths',
images: imagePaths, images: imagePaths,
user: req.session.user user: req.session.user,
}); });
}); });

View File

@@ -13,12 +13,13 @@ 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");
const bookingSubmissionController = require("../controllers/bookingSubmissionController"); const bookingSubmissionController = require("../controllers/bookingSubmissionController");
const serviceController = require("../controllers/serviceController");
// Blog controllers // Blog controllers
const blogController = require("../controllers/blogController"); const blogController = require("../controllers/blogController");
const blogCategoryController = require("../controllers/blogCategoryController"); const blogCategoryController = require("../controllers/blogCategoryController");
@@ -55,6 +56,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);
@@ -66,8 +79,7 @@ router.get("/api/activities/:id", activityController.apiDetail);
router.get("/api/camp-location", campLocationController.api); router.get("/api/camp-location", campLocationController.api);
// Booking routes removed // Booking routes removed
// Insurance APi route // Insurance APi route
router.get("/api/insurance", insuranceController.api) router.get("/api/insurance", insuranceController.api);
router.get("/api/terms", termsController.api); router.get("/api/terms", termsController.api);
@@ -76,14 +88,14 @@ router.get("/travel", async (req, res) => {
try { try {
const Travel = require("../models/travel"); const Travel = require("../models/travel");
const travel = await Travel.findOne(); const travel = await Travel.findOne();
if (!travel) { if (!travel) {
return res.status(404).render("errors/404", { return res.status(404).render("errors/404", {
title: "Page Not Found", title: "Page Not Found",
message: "Travel information not found", message: "Travel information not found",
}); });
} }
res.render("page/travel", { res.render("page/travel", {
title: travel.page.title, title: travel.page.title,
data: travel.toObject(), data: travel.toObject(),
@@ -100,30 +112,42 @@ router.get("/api/travel", travelController.api);
// Booking submission APIs (public endpoints) // Booking submission APIs (public endpoints)
router.post("/api/booking/submit", bookingSubmissionController.submitBooking); router.post("/api/booking/submit", bookingSubmissionController.submitBooking);
router.get("/api/activity/:activityId/sessions", bookingSubmissionController.getAvailableSessions); router.get(
router.get("/api/activity/:activityId/session/:sessionId/availability", bookingSubmissionController.getSessionAvailability); "/api/activity/:activityId/sessions",
bookingSubmissionController.getAvailableSessions,
);
router.get(
"/api/activity/:activityId/session/:sessionId/availability",
bookingSubmissionController.getSessionAvailability,
);
// New API for creating bookings directly into camp sessions (by program) // New API for creating bookings directly into camp sessions (by program)
router.post( router.post(
"/api/camps/:program/sessions/:sessionId/bookings", "/api/camps/:program/sessions/:sessionId/bookings",
activityController.createSessionBookingByProgram activityController.createSessionBookingByProgram,
); );
router.get( router.get(
"/api/camps/:program/sessions/:sessionId/bookings", "/api/camps/:program/sessions/:sessionId/bookings",
activityController.getSessionBookingsByProgram activityController.getSessionBookingsByProgram,
); );
// Keep admin-style update/delete by activityId (protected) if needed // Keep admin-style update/delete by activityId (protected) if needed
router.put("/api/camps/:activityId/sessions/:sessionId/bookings/:bookingId", activityController.updateSessionBooking); router.put(
router.delete("/api/camps/:activityId/sessions/:sessionId/bookings/:bookingId", activityController.deleteSessionBooking); "/api/camps/:activityId/sessions/:sessionId/bookings/:bookingId",
activityController.updateSessionBooking,
);
router.delete(
"/api/camps/:activityId/sessions/:sessionId/bookings/:bookingId",
activityController.deleteSessionBooking,
);
// Demo booking form // Demo booking form
router.get("/demo/booking-form", (req, res) => { router.get("/demo/booking-form", (req, res) => {
res.sendFile(path.join(__dirname, '../views/demo/booking-form.html')); res.sendFile(path.join(__dirname, "../views/demo/booking-form.html"));
}); });
// Demo session booking API // Demo session booking API
router.get("/demo/session-booking-api", (req, res) => { router.get("/demo/session-booking-api", (req, res) => {
res.sendFile(path.join(__dirname, '../views/demo/session-booking-api.html')); res.sendFile(path.join(__dirname, "../views/demo/session-booking-api.html"));
}); });
// Blog API Routes // Blog API Routes
@@ -153,4 +177,17 @@ router.get("/api/blog/:slug", blogController.apiShow);
// // API route cho blog detail // // API route cho blog detail
// router.get('/api/blog-detail', blogDetailController.api); // router.get('/api/blog-detail', blogDetailController.api);
/* CMS - Hailearning
*/
// service
router.get("/service", serviceController.index);
router.post("/service", serviceController.update);
router.get("/api/service", serviceController.api);
// Service details by slug
router.get("/api/service/:slug", serviceController.getServiceBySlug);
// Service slugs list
router.get("/api/service-slugs", serviceController.getServiceSlugs);
module.exports = router; module.exports = router;

View File

@@ -0,0 +1,38 @@
require("dotenv").config();
const fs = require("fs").promises;
const path = require("path");
const connectDB = require("../config/database");
const Contact = require("../models/contact");
const mongoose = require("mongoose");
/**
* Migration: contact
* Migrate contact data from contact-data.json
*/
async function migrate() {
try {
await connectDB();
// Read contact-data.json file
const contactJsonPath = path.join(__dirname, "../data/contact.json");
const contactData = JSON.parse(await fs.readFile(contactJsonPath, "utf8"));
// Migrate data using the model's static method
await Contact.migrateFromJson(contactData);
console.log("Contact migration completed successfully");
await mongoose.disconnect();
process.exit(0);
} catch (error) {
console.error("Migration error:", error);
process.exit(1);
}
}
// Chạy migration nếu được gọi trực tiếp
if (require.main === module) {
migrate();
}
module.exports = { migrate };

View File

@@ -0,0 +1,186 @@
require("dotenv").config();
const fs = require("fs").promises;
const path = require("path");
const mongoose = require("mongoose");
const connectDB = require("../config/database");
const Service = require("../models/service");
/**
* Transform service.json data to match Service schema
*/
function transformServiceData(sourceData) {
return {
pageTitle: sourceData.pageTitle || "",
// Breadcrumb navigation section
breadcrumb: {
title: sourceData?.breadcrumb?.title || "",
backgroundImage: sourceData?.breadcrumb?.backgroundImage || "",
shape: sourceData?.breadcrumb?.shape || "",
items: Array.isArray(sourceData?.breadcrumb?.items)
? sourceData.breadcrumb.items.map((item) => ({
label: item.label || "",
href: item.href || "",
}))
: [],
},
// Main services section
services: {
title: {
subTitle: sourceData?.services?.title?.subTitle || "",
mainTitle: sourceData?.services?.title?.mainTitle || "",
},
items: Array.isArray(sourceData?.services?.items)
? sourceData.services.items.map((service) => ({
slug: service.slug || "",
name: service.name || "",
description: service.description || "",
image: service.image || "",
layout: service.layout || "",
details: {
title: service.details?.title || "",
description: service.details?.description || "",
mainImage: service.details?.mainImage || "",
overviewTitle: service.details?.overviewTitle || "",
overviewDescription: service.details?.overviewDescription || "",
additionalDescription:
service.details?.additionalDescription || "",
keyFeaturesTitle: service.details?.keyFeaturesTitle || "",
keyFeaturesImage: service.details?.keyFeaturesImage || "",
features: Array.isArray(service.details?.features)
? service.details.features.map((feature) => ({
icon: feature.icon || "",
title: feature.title || "",
description: feature.description || "",
}))
: [],
faqTitle: service.details?.faqTitle || "",
faqImage: service.details?.faqImage || "",
faq: Array.isArray(service.details?.faq)
? service.details.faq.map((faqItem) => ({
id: faqItem.id || "",
question: faqItem.question || "",
answer: faqItem.answer || "",
isExpanded: faqItem.isExpanded || false,
}))
: [],
},
}))
: [],
},
// Destination countries section
destinations: {
backgroundImage: sourceData?.destinations?.backgroundImage || "",
title: {
subTitle: sourceData?.destinations?.title?.subTitle || "",
mainTitle: sourceData?.destinations?.title?.mainTitle || "",
},
items: Array.isArray(sourceData?.destinations?.items)
? sourceData.destinations.items.map((country) => ({
id: country.id || "",
name: country.name || "",
description: country.description || "",
image: country.image || "",
icon: country.icon || "",
link: country.link || "",
}))
: [],
},
// Visa types section
visas: {
items: Array.isArray(sourceData?.visas?.items)
? sourceData.visas.items.map((visa) => ({
id: visa.id || "",
number: visa.number || "",
name: visa.name || "",
description: visa.description || "",
buttonText: visa.buttonText || "",
buttonLink: visa.buttonLink || "",
}))
: [],
},
// Client reviews section
reviews: {
title: {
subTitle: sourceData?.reviews?.title?.subTitle || "",
mainTitle: sourceData?.reviews?.title?.mainTitle || "",
},
viewAllButton: {
text: sourceData?.reviews?.viewAllButton?.text || "",
icon: sourceData?.reviews?.viewAllButton?.icon || "",
link: sourceData?.reviews?.viewAllButton?.link || "",
},
thumb: sourceData?.reviews?.thumb || "",
items: Array.isArray(sourceData?.reviews?.items)
? sourceData.reviews.items.map((review) => ({
id: review.id || "",
rating: review.rating || 5,
content: review.content || "",
author: {
name: review.author?.name || "",
type: review.author?.type || "",
},
icon: review.icon || "",
}))
: [],
navigation: {
prevButton: sourceData?.reviews?.navigation?.prevButton || "",
nextButton: sourceData?.reviews?.navigation?.nextButton || "",
prevIcon: sourceData?.reviews?.navigation?.prevIcon || "",
nextIcon: sourceData?.reviews?.navigation?.nextIcon || "",
},
},
updatedAt: new Date(),
};
}
/**
* Migration function for service page data
*/
async function migrateServiceData() {
try {
await connectDB();
console.log("🚀 Starting service page migration...");
// Clear existing service documents
await Service.deleteMany({});
console.log("🗑️ Cleared existing service documents");
// Read service.json file
const serviceJsonPath = path.join(__dirname, "..", "data", "service.json");
const rawJsonData = await fs.readFile(serviceJsonPath, "utf8");
const sourceServiceData = JSON.parse(rawJsonData);
// Transform data to match schema
const transformedServiceData = transformServiceData(sourceServiceData);
// Create new service document
const newService = new Service(transformedServiceData);
const savedService = await newService.save();
console.log("✅ Service page migration completed successfully!");
console.log(`📄 Service document ID: ${savedService._id}`);
await mongoose.disconnect();
process.exit(0);
} catch (error) {
console.error("❌ Service migration error:", error);
process.exit(1);
}
}
// Run migration if called directly
if (require.main === module) {
migrateServiceData();
}
module.exports = {
migrate: migrateServiceData,
transformServiceData,
};

View 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();

View File

@@ -42,8 +42,6 @@ app.use(
}, },
express.static(path.join(__dirname, "assets")), express.static(path.join(__dirname, "assets")),
); );
// Serve static files from public directory (uploads, etc.)
app.use( app.use(
"/uploads", "/uploads",
(req, res, next) => { (req, res, next) => {

View File

@@ -0,0 +1,43 @@
const Service = require("../models/service");
const getServiceData = async () => {
const service = await Service.findOne().sort({ updatedAt: -1 });
console.log("check layout", service.services.items.layout);
if (!service) {
return {
pageTitle: "",
services: {
title: {
subTitle: "",
mainTitle: "",
},
items: [],
},
destinations: {
backgroundImage: "",
title: {
subTitle: "",
mainTitle: "",
},
},
visas: {
items: [],
},
reviews: {
title: {
subTitle: "",
mainTitle: "",
},
thumb: "",
items: [],
},
};
}
return service;
};
module.exports = {
getServiceData,
};

View File

@@ -1,37 +1,68 @@
/** /**
* Thêm BACKEND_URL vào đường dẫn hình ảnh * Thêm BACKEND_URL vào đường dẫn hình ảnh
* @param {Object} data - Dữ liệu cần xử lý * @param {Object} data - Dữ liệu cần xử lý
* @returns {Object} - Dữ liệu đã được xử lý với đường dẫn hình ảnh đầy đủ * @returns {Object} - Dữ liệu đã được xử lý với đường dẫn hình ảnh đầy đủ
*/ */
function addBaseUrlToImages(data, baseUrl) { function addBaseUrlToImages(data, baseUrl) {
// baseUrl can be passed explicitly (e.g., from req), otherwise fall back to env // baseUrl can be passed explicitly (e.g., from req), otherwise fall back to env
const BACKEND_URL = baseUrl || process.env.BACKEND_URL || ''; const BACKEND_URL = baseUrl || process.env.BACKEND_URL || "";
// Tạo bản sao sâu để tránh thay đổi dữ liệu gốc // Tạo bản sao sâu để tránh thay đổi dữ liệu gốc
const processedData = JSON.parse(JSON.stringify(data)); const processedData = JSON.parse(JSON.stringify(data));
// Hàm đệ quy để xử lý tất cả các URL hình ảnh trong đối tượng // Hàm đệ quy để xử lý tất cả các URL hình ảnh trong đối tượng
const processObject = (obj) => { const processObject = (obj) => {
if (!obj || typeof obj !== 'object') return; if (!obj || typeof obj !== "object") return;
Object.keys(obj).forEach(key => { Object.keys(obj).forEach((key) => {
// Kiểm tra nếu thuộc tính chứa đường dẫn hình ảnh bắt đầu bằng /uploads/ // Kiểm tra nếu thuộc tính chứa đường dẫn hình ảnh bắt đầu bằng /uploads/
if (typeof obj[key] === 'string' && obj[key].startsWith('/uploads/')) { if (typeof obj[key] === "string" && obj[key].startsWith("/uploads/")) {
// Thêm BACKEND_URL nếu đường dẫn chưa có http // Thêm BACKEND_URL nếu đường dẫn chưa có http
if (!obj[key].startsWith('http')) { if (!obj[key].startsWith("http")) {
obj[key] = `${BACKEND_URL}${obj[key]}`; obj[key] = `${BACKEND_URL}${obj[key]}`;
} }
} else if (typeof obj[key] === 'object') { } else if (typeof obj[key] === "object") {
// Đệ quy xử lý các đối tượng và mảng lồng nhau // Đệ quy xử lý các đối tượng và mảng lồng nhau
processObject(obj[key]); processObject(obj[key]);
} }
}); });
}; };
processObject(processedData); processObject(processedData);
return processedData; return processedData;
} }
/**
* Tạo full URL cho ảnh từ đường dẫn tương đối - dùng cho EJS templates
* @param {string} imagePath - Đường dẫn ảnh
* @param {string} backendUrl - Backend URL (optional, sẽ lấy từ env nếu không có)
* @returns {string} - Full URL của ảnh
*/
function getFullImageUrl(imagePath, backendUrl = null) {
if (!imagePath) return "";
// Nếu đã là full URL thì return luôn
if (imagePath.startsWith("http")) {
return imagePath;
}
// Lấy backend URL
const baseUrl = (
backendUrl ||
process.env.BACKEND_URL ||
"http://localhost:3001"
).replace(/\/$/, "");
// Xử lý đường dẫn
let imgSrc = imagePath;
if (!imgSrc.startsWith("/")) {
imgSrc = "/" + imgSrc;
}
return baseUrl + imgSrc;
}
module.exports = { module.exports = {
addBaseUrlToImages addBaseUrlToImages,
}; getFullImageUrl,
};

View 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

View File

@@ -4,14 +4,14 @@
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0" style="color: var(--primary-color);">Quick Management</h5> <h5 class="mb-0" style="color: var(--primary-color)">Quick Management</h5>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="row g-0"> <div class="row g-0">
<div class="col-md-4 border-end"> <div class="col-md-4 border-end">
<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">
<div class="rounded-circle d-flex align-items-center justify-content-center me-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);"> style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-home fa-lg" style="color: var(--primary-color);"></i> <i class="fas fa-home fa-lg" style="color: var(--primary-color);"></i>
</div> </div>
@@ -29,7 +29,7 @@
<div class="col-md-4 border-end"> <div class="col-md-4 border-end">
<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">
<div class="rounded-circle d-flex align-items-center justify-content-center me-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);"> style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-bars fa-lg" style="color: var(--primary-color);"></i> <i class="fas fa-bars fa-lg" style="color: var(--primary-color);"></i>
</div> </div>
@@ -47,7 +47,7 @@
<div class="col-md-4"> <div class="col-md-4">
<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">
<div class="rounded-circle d-flex align-items-center justify-content-center me-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);"> style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-window-minimize fa-lg" style="color: var(--primary-color);"></i> <i class="fas fa-window-minimize fa-lg" style="color: var(--primary-color);"></i>
</div> </div>
@@ -62,12 +62,10 @@
</div> </div>
</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">
<div class="rounded-circle d-flex align-items-center justify-content-center me-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);"> style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-users fa-lg" style="color: var(--primary-color);"></i> <i class="fas fa-users fa-lg" style="color: var(--primary-color);"></i>
</div> </div>
@@ -85,7 +83,7 @@
<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">
<div class="rounded-circle d-flex align-items-center justify-content-center me-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);"> style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-envelope fa-lg" style="color: var(--primary-color);"></i> <i class="fas fa-envelope fa-lg" style="color: var(--primary-color);"></i>
</div> </div>
@@ -103,7 +101,7 @@
<div class="col-md-4 border-top"> <div class="col-md-4 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">
<div class="rounded-circle d-flex align-items-center justify-content-center me-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);"> style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-question-circle fa-lg" style="color: var(--primary-color);"></i> <i class="fas fa-question-circle fa-lg" style="color: var(--primary-color);"></i>
</div> </div>
@@ -121,16 +119,16 @@
<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">
<div class="rounded-circle d-flex align-items-center justify-content-center me-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);"> style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-file-contract fa-lg" style="color: var(--primary-color);"></i> <i class="fas fa-calendar-check fa-lg" style="color: var(--primary-color);"></i>
</div> </div>
<div> <div>
<h5 class="mb-0">Terms & Conditions</h5> <h5 class="mb-0">Appointment</h5>
<p class="text-muted mb-0 small">Manage terms</p> <p class="text-muted mb-0 small">Manage appointment page</p>
</div> </div>
</div> </div>
<a href="/admin/terms-conditions" class="btn btn-sm btn-primary w-100 mt-2"> <a href="/admin/appointment" class="btn btn-sm btn-primary w-100 mt-2">
<i class="fas fa-edit me-2"></i>Edit <i class="fas fa-edit me-2"></i>Edit
</a> </a>
</div> </div>
@@ -139,7 +137,46 @@
<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">
<div class="rounded-circle d-flex align-items-center justify-content-center me-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="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-file-contract fa-lg" style="color: var(--primary-color);"></i>
</div>
<div>
<h5 class="mb-0">Terms & Conditions</h5>
<p class="text-muted mb-0 small">Manage terms</p>
</div>
</div>
<a
href="/admin/terms-conditions"
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);"> style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-plane fa-lg" style="color: var(--primary-color);"></i> <i class="fas fa-plane fa-lg" style="color: var(--primary-color);"></i>
</div> </div>
@@ -157,7 +194,7 @@
<div class="col-md-4 border-top"> <div class="col-md-4 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">
<div class="rounded-circle d-flex align-items-center justify-content-center me-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);"> style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-shield-alt fa-lg" style="color: var(--primary-color);"></i> <i class="fas fa-shield-alt fa-lg" style="color: var(--primary-color);"></i>
</div> </div>
@@ -175,7 +212,7 @@
<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">
<div class="rounded-circle d-flex align-items-center justify-content-center me-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);"> style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-campground fa-lg" style="color: var(--primary-color);"></i> <i class="fas fa-campground fa-lg" style="color: var(--primary-color);"></i>
</div> </div>
@@ -184,7 +221,10 @@
<p class="text-muted mb-0 small">Manage camp location</p> <p class="text-muted mb-0 small">Manage camp location</p>
</div> </div>
</div> </div>
<a href="/admin/camp-location" class="btn btn-sm btn-primary w-100 mt-2"> <a
href="/admin/camp-location"
class="btn btn-sm btn-primary w-100 mt-2"
>
<i class="fas fa-edit me-2"></i>Edit <i class="fas fa-edit me-2"></i>Edit
</a> </a>
</div> </div>
@@ -193,7 +233,7 @@
<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">
<div class="rounded-circle d-flex align-items-center justify-content-center me-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);"> style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-running fa-lg" style="color: var(--primary-color);"></i> <i class="fas fa-running fa-lg" style="color: var(--primary-color);"></i>
</div> </div>
@@ -207,7 +247,32 @@
</a> </a>
</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-running fa-lg"
style="color: var(--primary-color)"
></i>
</div>
<div>
<h5 class="mb-0">Services</h5>
<p class="text-muted mb-0 small">Manage services</p>
</div>
</div>
<a href="/admin/service" class="btn btn-sm btn-primary w-100 mt-2">
<i class="fas fa-edit me-2"></i>Edit
</a>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -234,7 +299,7 @@
<tr> <tr>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2" <div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);"> style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-bars" style="color: var(--primary-color);"></i> <i class="fas fa-bars" style="color: var(--primary-color);"></i>
</div> </div>
@@ -242,10 +307,20 @@
</div> </div>
</td> </td>
<td><code>/api/header</code></td> <td><code>/api/header</code></td>
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td> <td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get menu header data</td> <td>API to get menu header data</td>
<td> <td>
<a href="/api/header" class="btn btn-sm btn-outline-primary" target="_blank"> <a
href="/api/header"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
@@ -253,7 +328,7 @@
<tr> <tr>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2" <div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);"> style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-home" style="color: var(--primary-color);"></i> <i class="fas fa-home" style="color: var(--primary-color);"></i>
</div> </div>
@@ -261,10 +336,20 @@
</div> </div>
</td> </td>
<td><code>/api/home</code></td> <td><code>/api/home</code></td>
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td> <td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get homepage data</td> <td>API to get homepage data</td>
<td> <td>
<a href="/api/home" class="btn btn-sm btn-outline-primary" target="_blank"> <a
href="/api/home"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
@@ -272,7 +357,7 @@
<tr> <tr>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2" <div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);"> style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-info-circle" style="color: var(--primary-color);"></i> <i class="fas fa-info-circle" style="color: var(--primary-color);"></i>
</div> </div>
@@ -280,10 +365,20 @@
</div> </div>
</td> </td>
<td><code>/api/about</code></td> <td><code>/api/about</code></td>
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td> <td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get about page data</td> <td>API to get about page data</td>
<td> <td>
<a href="/api/about" class="btn btn-sm btn-outline-primary" target="_blank"> <a
href="/api/about"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
@@ -291,7 +386,7 @@
<tr> <tr>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2" <div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);"> style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-users" style="color: var(--primary-color);"></i> <i class="fas fa-users" style="color: var(--primary-color);"></i>
</div> </div>
@@ -299,10 +394,20 @@
</div> </div>
</td> </td>
<td><code>/api/about-us</code></td> <td><code>/api/about-us</code></td>
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td> <td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get about us data</td> <td>API to get about us data</td>
<td> <td>
<a href="/api/about-us" class="btn btn-sm btn-outline-primary" target="_blank"> <a
href="/api/about-us"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
@@ -310,7 +415,7 @@
<tr> <tr>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2" <div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);"> style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-question-circle" style="color: var(--primary-color);"></i> <i class="fas fa-question-circle" style="color: var(--primary-color);"></i>
</div> </div>
@@ -318,10 +423,20 @@
</div> </div>
</td> </td>
<td><code>/api/faq</code></td> <td><code>/api/faq</code></td>
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td> <td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get FAQ data</td> <td>API to get FAQ data</td>
<td> <td>
<a href="/api/faq" class="btn btn-sm btn-outline-primary" target="_blank"> <a
href="/api/faq"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
@@ -329,7 +444,7 @@
<tr> <tr>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2" <div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);"> style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-file-contract" style="color: var(--primary-color);"></i> <i class="fas fa-file-contract" style="color: var(--primary-color);"></i>
</div> </div>
@@ -337,10 +452,20 @@
</div> </div>
</td> </td>
<td><code>/api/terms</code></td> <td><code>/api/terms</code></td>
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td> <td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get terms & conditions data</td> <td>API to get terms & conditions data</td>
<td> <td>
<a href="/api/terms" class="btn btn-sm btn-outline-primary" target="_blank"> <a
href="/api/terms"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
@@ -348,7 +473,7 @@
<tr> <tr>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2" <div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);"> style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-plane" style="color: var(--primary-color);"></i> <i class="fas fa-plane" style="color: var(--primary-color);"></i>
</div> </div>
@@ -356,10 +481,20 @@
</div> </div>
</td> </td>
<td><code>/api/travel</code></td> <td><code>/api/travel</code></td>
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td> <td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get travel data</td> <td>API to get travel data</td>
<td> <td>
<a href="/api/travel" class="btn btn-sm btn-outline-primary" target="_blank"> <a
href="/api/travel"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
@@ -367,7 +502,7 @@
<tr> <tr>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2" <div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);"> style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-shield-alt" style="color: var(--primary-color);"></i> <i class="fas fa-shield-alt" style="color: var(--primary-color);"></i>
</div> </div>
@@ -375,10 +510,20 @@
</div> </div>
</td> </td>
<td><code>/api/safety</code></td> <td><code>/api/safety</code></td>
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td> <td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get safety data</td> <td>API to get safety data</td>
<td> <td>
<a href="/api/safety" class="btn btn-sm btn-outline-primary" target="_blank"> <a
href="/api/safety"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
@@ -386,7 +531,7 @@
<tr> <tr>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2" <div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);"> style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-campground" style="color: var(--primary-color);"></i> <i class="fas fa-campground" style="color: var(--primary-color);"></i>
</div> </div>
@@ -394,19 +539,29 @@
</div> </div>
</td> </td>
<td><code>/api/camp-location</code></td> <td><code>/api/camp-location</code></td>
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td> <td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get camp location data</td> <td>API to get camp location data</td>
<td> <td>
<a href="/api/camp-location" class="btn btn-sm btn-outline-primary" target="_blank"> <a
href="/api/camp-location"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2" <div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);"> style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-sitemap" style="color: var(--primary-color);"></i> <i class="fas fa-sitemap" style="color: var(--primary-color);"></i>
</div> </div>
@@ -414,10 +569,20 @@
</div> </div>
</td> </td>
<td><code>/api/menu-tree</code></td> <td><code>/api/menu-tree</code></td>
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td> <td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get menu tree data</td> <td>API to get menu tree data</td>
<td> <td>
<a href="/api/menu-tree" class="btn btn-sm btn-outline-primary" target="_blank"> <a
href="/api/menu-tree"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
@@ -425,7 +590,7 @@
<tr> <tr>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2" <div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);"> style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-envelope" style="color: var(--primary-color);"></i> <i class="fas fa-envelope" style="color: var(--primary-color);"></i>
</div> </div>
@@ -433,10 +598,20 @@
</div> </div>
</td> </td>
<td><code>/api/contact</code></td> <td><code>/api/contact</code></td>
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td> <td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get contact data</td> <td>API to get contact data</td>
<td> <td>
<a href="/api/contact" class="btn btn-sm btn-outline-primary" target="_blank"> <a
href="/api/contact"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
@@ -444,7 +619,7 @@
<tr> <tr>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2" <div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);"> style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-campground" style="color: var(--primary-color);"></i> <i class="fas fa-campground" style="color: var(--primary-color);"></i>
</div> </div>
@@ -452,10 +627,20 @@
</div> </div>
</td> </td>
<td><code>/api/camp-location</code></td> <td><code>/api/camp-location</code></td>
<td><span class="badge" style="background-color: var(--primary-color);">GET</span></td> <td>
<span
class="badge"
style="background-color: var(--primary-color)"
>GET</span
>
</td>
<td>API to get camp location data</td> <td>API to get camp location data</td>
<td> <td>
<a href="/api/camp-location" class="btn btn-sm btn-outline-primary" target="_blank"> <a
href="/api/camp-location"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
<i class="fas fa-external-link-alt me-1"></i>View <i class="fas fa-external-link-alt me-1"></i>View
</a> </a>
</td> </td>
@@ -468,46 +653,62 @@
<!-- System Info --> <!-- System Info -->
<div class="card"> <div class="card">
<div class="card-header" style="background: linear-gradient(135deg, #b8b76a, #9a994a); color: white;"> <div
class="card-header"
style="
background: linear-gradient(135deg, #b8b76a, #9a994a);
color: white;
"
>
<h5 class="mb-0">System Information</h5> <h5 class="mb-0">System Information</h5>
</div> </div>
<div class="card-body" style="background-color: #f8faf8;"> <div class="card-body" style="background-color: #f8faf8">
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="d-flex align-items-center mb-3"> <div class="d-flex align-items-center mb-3">
<div class="rounded-circle d-flex align-items-center justify-content-center me-3" <div class="rounded-circle d-flex align-items-center justify-content-center me-3"
style="width: 40px; height: 40px; background-color: rgba(184, 183, 106, 0.1);"> style="width: 40px; height: 40px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-info-circle" style="color: var(--primary-color);"></i> <i class="fas fa-info-circle" style="color: var(--primary-color);"></i>
</div> </div>
<div> <div>
<div class="text-muted small">Version</div> <div class="text-muted small">Version</div>
<div class="fw-bold" style="color: var(--primary-color);">CMS-SIMS v1.0.0</div> <div class="fw-bold" style="color: var(--primary-color)">
CMS-SIMS v1.0.0
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="d-flex align-items-center mb-3"> <div class="d-flex align-items-center mb-3">
<div class="rounded-circle d-flex align-items-center justify-content-center me-3" <div class="rounded-circle d-flex align-items-center justify-content-center me-3"
style="width: 40px; height: 40px; background-color: rgba(184, 183, 106, 0.1);"> style="width: 40px; height: 40px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-user" style="color: var(--primary-color);"></i> <i class="fas fa-user" style="color: var(--primary-color);"></i>
</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>
</div> </div>
<div> <div>
<h6 class="mb-1" style="color: var(--primary-color);">Quick Tip</h6> <h6 class="mb-1" style="color: var(--primary-color)">Quick Tip</h6>
<p class="mb-0 text-muted">Click the Edit button to make changes to your data.</p> <p class="mb-0 text-muted">
Click the Edit button to make changes to your data.
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -520,36 +721,36 @@
color: var(--primary-color); color: var(--primary-color);
font-weight: 600; font-weight: 600;
} }
.btn-primary { .btn-primary {
background-color: var(--primary-color); background-color: var(--primary-color);
border-color: var(--primary-color); border-color: var(--primary-color);
color: white; color: white;
} }
.btn-primary:hover { .btn-primary:hover {
background-color: var(--primary-dark); background-color: var(--primary-dark);
border-color: var(--primary-dark); border-color: var(--primary-dark);
color: white; color: white;
} }
.btn-outline-primary { .btn-outline-primary {
color: var(--primary-color); color: var(--primary-color);
border-color: var(--primary-color); border-color: var(--primary-color);
} }
.btn-outline-primary:hover { .btn-outline-primary:hover {
background-color: var(--primary-color); background-color: var(--primary-color);
border-color: var(--primary-color); border-color: var(--primary-color);
color: white; color: white;
} }
.card-header h5 { .card-header h5 {
color: white; color: white;
} }
.badge { .badge {
background-color: var(--primary-color) !important; background-color: var(--primary-color) !important;
color: white; color: white;
} }
</style> </style>

View 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>

View File

@@ -0,0 +1,723 @@
<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);">Service Details: <%= service.name %></h1>
<p class="text-muted mb-0">Edit detailed content for <%= service.name %> service</p>
</div>
<div>
<a href="/admin/service" class="btn btn-outline-primary">
<i class="fas fa-arrow-left me-2"></i>Back to Services
</a>
</div>
</div>
<div class="row">
<div class="col-12">
<form action="/admin/service/<%= service.slug %>/details/update" method="POST" class="content-with-fixed-buttons" id="serviceDetailsForm">
<!-- Hidden inputs for JSON data -->
<input type="hidden" name="details" id="detailsJson">
<input type="hidden" name="features" id="featuresJson">
<input type="hidden" name="faq" id="faqJson">
<!-- 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="#basic-info" role="tab">
<i class="fas fa-info-circle me-2"></i>Basic Information
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#key-features" role="tab">
<i class="fas fa-star me-2"></i>Key Features
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#faq-section" role="tab">
<i class="fas fa-question-circle me-2"></i>FAQ Section
</a>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content">
<!-- Basic Information Tab -->
<div class="tab-pane fade show active" id="basic-info" role="tabpanel">
<div class="card border shadow-sm">
<div class="card-body">
<div class="row">
<div class="col-md-5">
<label class="form-label fw-medium">Main Image</label>
<div class="input-group mb-2">
<input type="text" class="form-control" id="mainImage" name="mainImage"
value="<%= service.details?.mainImage || '' %>">
<button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="mainImage" data-image-type="service">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
<small class="form-text text-muted">Recommended size: 800x600px</small>
</div>
<div class="col-md-7">
<div id="mainImagePreview">
<% if (service.details?.mainImage) { %>
<img src="<%= getFullImageUrl(service.details.mainImage) %>" class="img-thumbnail" style="max-height: 250px; width: auto; max-width: 100%; object-fit: contain;" alt="Main image preview">
<% } %>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-12">
<label class="form-label fw-medium">Title</label>
<input type="text" class="form-control" id="detailsTitle" name="title"
value="<%= service.details?.title || service.name %>">
</div>
</div>
<div class="row mt-3">
<div class="col-md-12">
<label class="form-label fw-medium">Description</label>
<textarea class="form-control" id="detailsDescription" name="description" rows="3"><%= service.details?.description || service.description %></textarea>
</div>
</div>
<!-- Overview Section -->
<div class="row mt-4">
<div class="col-12">
<div class="card border">
<div class="card-header bg-light">
<h6 class="mb-0">Overview Section</h6>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-12">
<label class="form-label">Overview Title</label>
<input type="text" class="form-control" id="overviewTitle" name="overviewTitle"
value="<%= service.details?.overviewTitle || 'Service Overview' %>">
</div>
<div class="col-md-12">
<label class="form-label">Overview Description</label>
<textarea class="form-control" id="overviewDescription" name="overviewDescription" rows="4"><%= service.details?.overviewDescription || '' %></textarea>
</div>
<div class="col-md-12">
<label class="form-label">Additional Description</label>
<textarea class="form-control" id="additionalDescription" name="additionalDescription" rows="3"><%= service.details?.additionalDescription || '' %></textarea>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Key Features Tab -->
<div class="tab-pane fade" id="key-features" role="tabpanel">
<div class="card border shadow-sm">
<div class="card-body">
<!-- Features Header -->
<div class="row mb-4">
<div class="col-md-6">
<label class="form-label fw-medium">Features Title</label>
<input type="text" class="form-control" id="keyFeaturesTitle" name="keyFeaturesTitle"
value="<%= service.details?.keyFeaturesTitle || 'Key Features' %>">
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Features Image</label>
<div class="input-group">
<input type="text" class="form-control" id="keyFeaturesImage" name="keyFeaturesImage"
value="<%= service.details?.keyFeaturesImage || '' %>">
<button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="keyFeaturesImage" data-image-type="service">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
</div>
</div>
<!-- Key Features Image Preview -->
<div class="row mt-3">
<div class="col-12">
<div id="keyFeaturesImagePreview">
<% if (service.details?.keyFeaturesImage) { %>
<img src="<%= getFullImageUrl(service.details.keyFeaturesImage) %>" class="img-thumbnail" style="max-height: 180px; width: auto; max-width: 100%; object-fit: contain;" alt="Key Features image preview">
<% } %>
</div>
</div>
</div>
<!-- Features List -->
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="fw-medium mb-0">Features</h6>
<button type="button" class="btn btn-primary btn-sm" onclick="addFeature()">
<i class="fas fa-plus"></i> Add Feature
</button>
</div>
<div id="featuresContainer">
<% if (service.details?.features && service.details.features.length > 0) { %>
<% service.details.features.forEach((feature, index) => { %>
<div class="card mb-3 feature-item">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0 text-decoration-underline">Feature <%= index + 1 %></h6>
<button type="button" class="btn btn-danger btn-sm" onclick="removeFeature(this)">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">Title</label>
<input type="text" class="form-control feature-title"
value="<%= feature.title || '' %>" required>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-md-12">
<label class="form-label">Description</label>
<textarea class="form-control feature-description" rows="2" required><%= feature.description || '' %></textarea>
</div>
</div>
</div>
</div>
<% }) %>
<% } %>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- FAQ Section Tab -->
<div class="tab-pane fade" id="faq-section" role="tabpanel">
<div class="card border shadow-sm">
<div class="card-body">
<!-- FAQ Header -->
<div class="row mb-4">
<div class="col-md-6">
<label class="form-label fw-medium">FAQ Title</label>
<input type="text" class="form-control" id="faqTitle" name="faqTitle"
value="<%= service.details?.faqTitle || 'Frequently Asked Questions' %>">
</div>
<div class="col-md-6">
<label class="form-label fw-medium">FAQ Image</label>
<div class="input-group">
<input type="text" class="form-control" id="faqImage" name="faqImage"
value="<%= service.details?.faqImage || '' %>">
<button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="faqImage" data-image-type="service">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
</div>
</div>
<!-- FAQ Image Preview -->
<div class="row mt-3">
<div class="col-12">
<div id="faqImagePreview">
<% if (service.details?.faqImage) { %>
<img src="<%= getFullImageUrl(service.details.faqImage) %>" class="img-thumbnail" style="max-height: 180px; width: auto; max-width: 100%; object-fit: contain;" alt="FAQ image preview">
<% } %>
</div>
</div>
</div>
<!-- FAQ List -->
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="fw-medium mb-0">FAQ Items</h6>
<button type="button" class="btn btn-primary btn-sm" onclick="addFAQ()">
<i class="fas fa-plus"></i> Add FAQ
</button>
</div>
<div id="faqContainer">
<% if (service.details?.faq && service.details.faq.length > 0) { %>
<% service.details.faq.forEach((faq, index) => { %>
<div class="card mb-3 faq-item">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0 text-decoration-underline">FAQ <%= index + 1 %></h6>
<button type="button" class="btn btn-danger btn-sm" onclick="removeFAQ(this)">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">ID</label>
<input type="text" class="form-control faq-id"
value="<%= faq.id || 'faq-' + (index + 1) %>" required>
</div>
<div class="col-md-6">
<label class="form-label">Expanded by Default</label>
<select class="form-control faq-expanded">
<option value="false" <%= !faq.isExpanded ? 'selected' : '' %>>No</option>
<option value="true" <%= faq.isExpanded ? 'selected' : '' %>>Yes</option>
</select>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-md-12">
<label class="form-label">Question</label>
<input type="text" class="form-control faq-question"
value="<%= faq.question || '' %>" required>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-md-12">
<label class="form-label">Answer</label>
<textarea class="form-control faq-answer" rows="3" required><%= faq.answer || '' %></textarea>
</div>
</div>
</div>
</div>
<% }) %>
<% } %>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bottom Buttons -->
<div class="bottom-buttons">
<button type="reset" class="btn btn-secondary">
<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>
<!-- Scripts -->
<script>
let originalFormData = null;
let featureIndex = <%= service.details?.features?.length || 0 %>;
let faqIndex = <%= service.details?.faq?.length || 0 %>;
document.addEventListener('DOMContentLoaded', function() {
// Initialize form data
originalFormData = <%- JSON.stringify(service) %>;
// Set initial JSON values
updateAllJsonInputs(originalFormData);
// Initialize form handlers
initializeFormHandlers();
});
function initializeFormHandlers() {
// Form submission
const form = document.getElementById('serviceDetailsForm');
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);
showError('Failed to process form data. Please try again.');
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
}
});
// Initialize 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);
});
});
// Initialize image input change listeners for manual URL input
const imageInputs = ['mainImage', 'keyFeaturesImage', 'faqImage'];
imageInputs.forEach(inputId => {
const input = document.getElementById(inputId);
if (input) {
input.addEventListener('input', function() {
updateImagePreviewAfterUpload(inputId, this.value);
});
}
});
}
function addFeature() {
const container = document.getElementById('featuresContainer');
const featureHtml = `
<div class="card mb-3 feature-item">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0 text-decoration-underline">Feature ${featureIndex + 1}</h6>
<button type="button" class="btn btn-danger btn-sm" onclick="removeFeature(this)">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">Title</label>
<input type="text" class="form-control feature-title" required>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-md-12">
<label class="form-label">Description</label>
<textarea class="form-control feature-description" rows="2" required></textarea>
</div>
</div>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', featureHtml);
featureIndex++;
}
function removeFeature(button) {
const featureItem = button.closest('.feature-item');
if (featureItem) {
featureItem.remove();
}
}
function addFAQ() {
const container = document.getElementById('faqContainer');
const faqHtml = `
<div class="card mb-3 faq-item">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0 text-decoration-underline">FAQ ${faqIndex + 1}</h6>
<button type="button" class="btn btn-danger btn-sm" onclick="removeFAQ(this)">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">ID</label>
<input type="text" class="form-control faq-id"
value="faq-${faqIndex + 1}" required>
</div>
<div class="col-md-6">
<label class="form-label">Expanded by Default</label>
<select class="form-control faq-expanded">
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-md-12">
<label class="form-label">Question</label>
<input type="text" class="form-control faq-question" required>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-md-12">
<label class="form-label">Answer</label>
<textarea class="form-control faq-answer" rows="3" required></textarea>
</div>
</div>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', faqHtml);
faqIndex++;
}
function removeFAQ(button) {
const faqItem = button.closest('.faq-item');
if (faqItem) {
faqItem.remove();
}
}
function updateAllJsonInputs(data) {
// Collect basic details data
const details = {
title: data.details?.title || data.name,
description: data.details?.description || data.description,
mainImage: data.details?.mainImage || '',
overviewTitle: data.details?.overviewTitle || 'Service Overview',
overviewDescription: data.details?.overviewDescription || '',
additionalDescription: data.details?.additionalDescription || '',
keyFeaturesTitle: data.details?.keyFeaturesTitle || 'Key Features',
keyFeaturesImage: data.details?.keyFeaturesImage || '',
faqTitle: data.details?.faqTitle || 'Frequently Asked Questions',
faqImage: data.details?.faqImage || ''
};
document.getElementById('detailsJson').value = JSON.stringify(details);
document.getElementById('featuresJson').value = JSON.stringify(data.details?.features || []);
document.getElementById('faqJson').value = JSON.stringify(data.details?.faq || []);
}
function updateJsonData() {
// Collect basic details data
const details = {
title: document.getElementById('detailsTitle').value,
description: document.getElementById('detailsDescription').value,
mainImage: document.getElementById('mainImage').value,
overviewTitle: document.getElementById('overviewTitle').value,
overviewDescription: document.getElementById('overviewDescription').value,
additionalDescription: document.getElementById('additionalDescription').value,
keyFeaturesTitle: document.getElementById('keyFeaturesTitle').value,
keyFeaturesImage: document.getElementById('keyFeaturesImage').value,
faqTitle: document.getElementById('faqTitle').value,
faqImage: document.getElementById('faqImage').value
};
// Collect features data
const features = [];
document.querySelectorAll('.feature-item').forEach(item => {
features.push({
title: item.querySelector('.feature-title').value,
description: item.querySelector('.feature-description').value
});
});
// Collect FAQ data
const faq = [];
document.querySelectorAll('.faq-item').forEach(item => {
faq.push({
id: item.querySelector('.faq-id').value,
question: item.querySelector('.faq-question').value,
answer: item.querySelector('.faq-answer').value,
isExpanded: item.querySelector('.faq-expanded').value === 'true'
});
});
document.getElementById('detailsJson').value = JSON.stringify(details);
document.getElementById('featuresJson').value = JSON.stringify(features);
document.getElementById('faqJson').value = JSON.stringify(faq);
}
// Helper function để tạo full URL cho ảnh - tương tự như server-side helper
function getFullImageUrlJS(imagePath) {
if (!imagePath) return '';
// Nếu đã là full URL thì return luôn
if (imagePath.startsWith('http')) {
return imagePath;
}
// Lấy backend URL
const backendUrl = '<%= (process.env.BACKEND_URL || "http://localhost:3001").replace(/\/$/, "") %>';
// Xử lý đường dẫn
let imgSrc = imagePath;
if (!imgSrc.startsWith('/')) {
imgSrc = '/uploads/' + imgSrc;
}
return backendUrl + imgSrc;
}
// Function để cập nhật image preview sau khi upload
function updateImagePreviewAfterUpload(targetInput, imagePath) {
const fullImageUrl = getFullImageUrlJS(imagePath);
switch(targetInput) {
case 'mainImage':
const mainPreview = document.getElementById('mainImagePreview');
if (mainPreview) {
mainPreview.innerHTML = `<img src="${fullImageUrl}" class="img-thumbnail" style="max-height: 250px; width: auto; max-width: 100%; object-fit: contain;" alt="Main image preview">`;
}
break;
case 'keyFeaturesImage':
const featuresPreview = document.getElementById('keyFeaturesImagePreview');
if (featuresPreview) {
featuresPreview.innerHTML = `<img src="${fullImageUrl}" class="img-thumbnail" style="max-height: 180px; width: auto; max-width: 100%; object-fit: contain;" alt="Key Features image preview">`;
}
break;
case 'faqImage':
const faqPreview = document.getElementById('faqImagePreview');
if (faqPreview) {
faqPreview.innerHTML = `<img src="${fullImageUrl}" class="img-thumbnail" style="max-height: 180px; width: auto; max-width: 100%; object-fit: contain;" alt="FAQ image preview">`;
}
break;
}
}
async function openImageUploader(targetInput, imageType) {
return new Promise((resolve, reject) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
fileInput.onchange = async function (e) {
const file = e.target.files[0];
if (!file) return reject(new Error('No file selected'));
try {
const formData = new FormData();
formData.append('image', file);
const uploadBtn = document.querySelector(`[data-target-input="${targetInput}"]`);
const originalBtnHtml = uploadBtn ? uploadBtn.innerHTML : null;
if (uploadBtn) {
uploadBtn.disabled = true;
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Uploading...';
}
const response = await fetch(`/admin/upload/image?imageType=${encodeURIComponent(imageType)}`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Upload failed');
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Upload failed');
}
const input = document.getElementById(targetInput) || document.querySelector(`[name="${targetInput}"]`);
if (!input) throw new Error('Target input not found');
input.value = result.path;
// Update image preview based on target input
updateImagePreviewAfterUpload(targetInput, result.path);
if (uploadBtn) {
uploadBtn.disabled = false;
uploadBtn.innerHTML = originalBtnHtml;
}
resolve(result);
} catch (err) {
if (uploadBtn) {
uploadBtn.disabled = false;
uploadBtn.innerHTML = originalBtnHtml;
}
console.error('Upload error:', err);
showError('Upload failed: ' + (err.message || 'Unknown error'));
reject(err);
} finally {
fileInput.remove();
}
};
fileInput.click();
});
}
function showSuccess(message) {
// Create and show success alert
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
setTimeout(() => {
if (alert.parentNode) {
alert.parentNode.removeChild(alert);
}
}, 5000);
}
function showError(message) {
// Create and show error alert
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
setTimeout(() => {
if (alert.parentNode) {
alert.parentNode.removeChild(alert);
}
}, 5000);
}
</script>
<style>
.bottom-buttons {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
padding: 15px 0;
border-top: 1px solid #dee2e6;
}
.content-with-fixed-buttons {
/* Remove bottom padding since buttons are no longer fixed */
}
.btn-group .btn {
margin-right: 2px;
}
.btn-group .btn:last-child {
margin-right: 0;
}
.card-header h6 {
color: var(--primary-dark);
}
.text-decoration-underline {
text-decoration: underline;
color: var(--primary-dark);
}
/* Image Preview Styles */
#mainImagePreview, #keyFeaturesImagePreview, #faqImagePreview {
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f8f9fa;
border: 2px dashed #dee2e6;
border-radius: 8px;
padding: 10px;
transition: all 0.3s ease;
}
#mainImagePreview:empty::before,
#keyFeaturesImagePreview:empty::before,
#faqImagePreview:empty::before {
content: "No image selected";
color: #6c757d;
font-style: italic;
}
#mainImagePreview img,
#keyFeaturesImagePreview img,
#faqImagePreview img {
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
</style>

View File

@@ -0,0 +1,440 @@
<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);">Edit Service: <%= service.name %></h1>
<p class="text-muted mb-0">Update service information and settings</p>
</div>
<div>
<a href="/admin/service" class="btn btn-outline-primary">
<i class="fas fa-arrow-left me-2"></i>Back to Services
</a>
</div>
</div>
<div class="row">
<div class="col-12">
<form action="/admin/service/<%= service.slug %>/edit" method="POST" id="editServiceForm">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom">
<h5 class="mb-0" style="color: var(--primary-dark);">
<i class="fas fa-edit me-2"></i>Service Information
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-12">
<label class="form-label fw-medium">Service Name</label>
<input type="text" class="form-control" id="serviceName" name="name"
value="<%= service.name %>" required>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-md-6">
<label class="form-label fw-medium">
Slug
<small class="text-muted">(generated from name)</small>
<span id="slugAutoIndicator" class="badge bg-info ms-1" style="font-size: 0.7em;">EXISTING</span>
</label>
<div class="input-group">
<input type="text" class="form-control" id="serviceSlug" name="slug"
value="<%= service.slug %>" readonly>
<button type="button" class="btn btn-primary" id="generateSlugBtn" title="Generate slug from name">
<i class="fas fa-magic me-1"></i>Generate
</button>
</div>
<small class="form-text text-muted">URL-friendly version of the service name.</small>
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Layout</label>
<select class="form-control" id="serviceLayout" name="layout">
<option value="left" <%= service.layout === 'left' ? 'selected' : '' %>>Left</option>
<option value="right" <%= service.layout === 'right' ? 'selected' : '' %>>Right</option>
</select>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-md-8">
<label class="form-label fw-medium">Image</label>
<div class="input-group">
<input type="text" class="form-control" id="serviceImage" name="image"
value="<%= service.image || '' %>">
<button type="button" class="btn btn-outline-primary btn-upload-image"
data-target-input="serviceImage" data-image-type="service">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
</div>
<div class="col-md-4">
<div id="serviceImagePreview">
<% if (service.image) { %>
<img src="<%= getFullImageUrl(service.image) %>" class="img-thumbnail"
style="height: 80px; width: 100%; object-fit: cover;" alt="Preview">
<% } %>
</div>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-12">
<label class="form-label fw-medium">Description</label>
<textarea class="form-control" id="serviceDescription" name="description"
rows="3" required><%= service.description %></textarea>
</div>
</div>
</div>
</div>
<!-- Bottom Buttons -->
<div class="bottom-buttons">
<a href="/admin/service" class="btn btn-secondary">
<i class="fas fa-times me-2"></i>Cancel
</a>
<button type="submit" class="btn btn-primary" id="submitBtn">
<i class="fas fa-save me-2"></i>Update Service
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Scripts -->
<script>
let servicesData = []; // Will be populated for duplicate checking
document.addEventListener('DOMContentLoaded', function() {
// Load existing services data for duplicate checking
loadServicesData();
// Initialize form handlers
initializeFormHandlers();
});
async function loadServicesData() {
try {
const response = await fetch('/api/service');
const data = await response.json();
servicesData = data.services?.items || [];
} catch (error) {
console.error('Error loading services data:', error);
servicesData = [];
}
}
function initializeFormHandlers() {
// Form submission
const form = document.getElementById('editServiceForm');
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>Updating...';
try {
// Check for duplicate slug before submitting
const slug = document.getElementById('serviceSlug').value.trim();
const currentSlug = '<%= service.slug %>';
if (slug !== currentSlug && isSlugDuplicate(slug, -1)) {
showError('Service with this slug already exists. Please generate a new slug.');
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Update Service';
return;
}
this.submit();
} catch (error) {
console.error('Error updating service:', error);
showError('Failed to update service. Please try again.');
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Update Service';
}
});
// Initialize 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);
});
});
// Image preview for service image
const serviceImageInput = document.getElementById('serviceImage');
if (serviceImageInput) {
serviceImageInput.addEventListener('input', function() {
updateImagePreview('serviceImagePreview', this.value);
});
}
// Generate slug from service name
const serviceNameInput = document.getElementById('serviceName');
const serviceSlugInput = document.getElementById('serviceSlug');
const slugAutoIndicator = document.getElementById('slugAutoIndicator');
const generateSlugBtn = document.getElementById('generateSlugBtn');
if (serviceNameInput && serviceSlugInput && generateSlugBtn) {
// Generate slug button
generateSlugBtn.addEventListener('click', async function() {
const serviceName = serviceNameInput.value.trim();
if (serviceName) {
// Show loading state
const originalBtnHtml = this.innerHTML;
this.disabled = true;
this.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Generating...';
try {
const slug = await generateSlugFromText(serviceName);
const currentSlug = '<%= service.slug %>';
// Check for duplicate slug (excluding current service)
if (slug !== currentSlug && isSlugDuplicate(slug, -1)) {
const uniqueSlug = generateUniqueSlug(slug);
serviceSlugInput.value = uniqueSlug;
showWarning(`Slug "${slug}" already exists. Generated unique slug: "${uniqueSlug}"`);
} else {
serviceSlugInput.value = slug;
showSuccess('Slug generated successfully!');
}
if (slugAutoIndicator) {
slugAutoIndicator.textContent = 'GENERATED';
slugAutoIndicator.className = 'badge bg-success ms-1';
slugAutoIndicator.style.fontSize = '0.7em';
}
} catch (error) {
console.error('Error generating slug:', error);
showError('Failed to generate slug. Please try again.');
} finally {
// Restore button state
this.disabled = false;
this.innerHTML = originalBtnHtml;
}
} else {
showError('Please enter a service name first.');
}
});
}
}
// Generate slug using backend API
async function generateSlugFromText(text) {
try {
const response = await fetch('/admin/service/generate-slug', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: text })
});
const result = await response.json();
if (result.success) {
return result.slug;
} else {
throw new Error(result.message || 'Failed to generate slug');
}
} catch (error) {
console.error('Error generating slug:', error);
// Fallback to simple slug generation if API fails
return text
.toString()
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w\-]+/g, '')
.replace(/\-\-+/g, '-')
.replace(/^-+/, '')
.replace(/-+$/, '');
}
}
// Check if slug already exists
function isSlugDuplicate(slug, excludeIndex = -1) {
return servicesData.some((service, index) => {
return service && service.slug === slug && index !== excludeIndex;
});
}
// Generate unique slug by appending number
function generateUniqueSlug(baseSlug) {
let counter = 1;
let uniqueSlug = baseSlug;
while (isSlugDuplicate(uniqueSlug, -1)) {
uniqueSlug = baseSlug + '-' + counter;
counter++;
}
return uniqueSlug;
}
function updateImagePreview(previewId, imagePath) {
const preview = document.getElementById(previewId);
if (imagePath) {
const fullImageUrl = getFullImageUrlJS(imagePath);
preview.innerHTML = `<img src="${fullImageUrl}" class="img-thumbnail" style="height: 80px; width: 100%; object-fit: cover;" alt="Preview">`;
} else {
preview.innerHTML = '';
}
}
// Helper function để tạo full URL cho ảnh
function getFullImageUrlJS(imagePath) {
if (!imagePath) return '';
if (imagePath.startsWith('http')) {
return imagePath;
}
const backendUrl = '<%= (process.env.BACKEND_URL || "http://localhost:3001").replace(/\/$/, "") %>';
let imgSrc = imagePath;
if (!imgSrc.startsWith('/')) {
imgSrc = '/uploads/' + imgSrc;
}
return backendUrl + imgSrc;
}
async function openImageUploader(targetInput, imageType) {
return new Promise((resolve, reject) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
fileInput.onchange = async function (e) {
const file = e.target.files[0];
if (!file) return reject(new Error('No file selected'));
try {
const formData = new FormData();
formData.append('image', file);
const uploadBtn = document.querySelector(`[data-target-input="${targetInput}"]`);
const originalBtnHtml = uploadBtn ? uploadBtn.innerHTML : null;
if (uploadBtn) {
uploadBtn.disabled = true;
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Uploading...';
}
const response = await fetch(`/admin/upload/image?imageType=service`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Upload failed');
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Upload failed');
}
const input = document.getElementById(targetInput);
if (!input) throw new Error('Target input not found');
input.value = result.path;
// Update preview
if (targetInput === 'serviceImage') {
updateImagePreview('serviceImagePreview', result.path);
}
if (uploadBtn) {
uploadBtn.disabled = false;
uploadBtn.innerHTML = originalBtnHtml;
}
resolve(result);
} catch (err) {
if (uploadBtn) {
uploadBtn.disabled = false;
uploadBtn.innerHTML = originalBtnHtml;
}
console.error('Upload error:', err);
showError('Upload failed: ' + (err.message || 'Unknown error'));
reject(err);
} finally {
fileInput.remove();
}
};
fileInput.click();
});
}
function showSuccess(message) {
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
setTimeout(() => {
if (alert.parentNode) {
alert.parentNode.removeChild(alert);
}
}, 5000);
}
function showWarning(message) {
const alert = document.createElement('div');
alert.className = 'alert alert-warning alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
setTimeout(() => {
if (alert.parentNode) {
alert.parentNode.removeChild(alert);
}
}, 5000);
}
function showError(message) {
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
setTimeout(() => {
if (alert.parentNode) {
alert.parentNode.removeChild(alert);
}
}, 5000);
}
</script>
<style>
.bottom-buttons {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
padding: 15px 0;
border-top: 1px solid #dee2e6;
}
.card-header h5 {
color: var(--primary-dark);
}
</style>

View File

@@ -0,0 +1,782 @@
<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">Manage services and their detailed content</p>
</div>
<div>
<a href="<%= frontendUrl %>/admin/service/" class="btn btn-outline-primary" target="_blank">
<i class="fas fa-external-link-alt me-2"></i>View Services Page
</a>
</div>
</div>
<div class="row">
<div class="col-12">
<form action="/admin/service/update" method="POST" class="content-with-fixed-buttons" id="serviceForm">
<!-- Hidden inputs for JSON data -->
<input type="hidden" name="services" id="servicesJson">
<!-- 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="#services-list" role="tab">
<i class="fas fa-list me-2"></i>Services List
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#section-settings" role="tab">
<i class="fas fa-cog me-2"></i>Section Settings
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#add-service" role="tab">
<i class="fas fa-plus me-2"></i>Add Service
</a>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content">
<!-- Services List Tab -->
<div class="tab-pane fade show active" id="services-list" role="tabpanel">
<div class="card border shadow-sm">
<div class="card-body">
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="bg-primary bg-opacity-10 rounded-circle p-3">
<i class="fas fa-cogs text-primary"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="text-muted mb-0">Total Services</h6>
<h3 class="mb-0" id="totalCount">
<%= (data.services && data.services.items) ? data.services.items.length : 0 %>
</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="bg-success bg-opacity-10 rounded-circle p-3">
<i class="fas fa-align-left text-success"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="text-muted mb-0">Left Layout</h6>
<h3 class="mb-0" id="leftCount">
<%= (data.services && data.services.items) ? data.services.items.filter(s => s && s.layout === 'left').length : 0 %>
</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="bg-info bg-opacity-10 rounded-circle p-3">
<i class="fas fa-align-right text-info"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="text-muted mb-0">Right Layout</h6>
<h3 class="mb-0" id="rightCount">
<%= (data.services && data.services.items) ? data.services.items.filter(s => s && s.layout === 'right').length : 0 %>
</h3>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Services Table -->
<div class="table-responsive">
<table class="table table-hover mb-0" id="servicesTable">
<thead class="table-light">
<tr>
<th style="width: 40px;">#</th>
<th style="width: 80px;">Image</th>
<th>Name</th>
<th>Slug</th>
<th style="width: 100px;">Layout</th>
<th style="width: 200px;">Actions</th>
</tr>
</thead>
<tbody id="servicesTableBody">
<% if (data.services && data.services.items) { %>
<% data.services.items.forEach((service, index) => { %>
<% if (service) { %>
<tr data-index="<%= index %>">
<td class="text-muted"><%= index + 1 %></td>
<td>
<% if (service.image) { %>
<img src="<%= getFullImageUrl(service.image) %>" class="rounded" style="width: 60px; height: 45px; object-fit: cover;" alt="<%= service.name %>" >
<% } else { %>
<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 60px; height: 45px;">
<i class="fas fa-image text-muted"></i>
</div>
<% } %>
</td>
<td>
<strong><%= service.name %></strong>
<% if (service.description) { %>
<br><small class="text-muted"><%= service.description.substring(0, 50) %>...</small>
<% } %>
</td>
<td>
<span class="badge bg-light text-dark"><%= service.slug %></span>
</td>
<td>
<span class="badge bg-<%= service.layout === 'left' ? 'primary' : 'success' %>">
<i class="fas fa-align-<%= service.layout %>"></i> <%= service.layout %>
</span>
</td>
<td>
<div class="btn-group" role="group">
<a href="/admin/service/<%= service.slug %>/edit" class="btn btn-sm btn-outline-primary" title="Edit Service">
<i class="fas fa-edit"></i>
</a>
<a href="/admin/service/<%= service.slug %>/details" class="btn btn-sm btn-outline-warning" title="Edit Details">
<i class="fas fa-cog"></i>
</a>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteService(<%= index %>)" title="Delete Service">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
<% } %>
<% }) %>
<% } %>
</tbody>
</table>
</div>
<% if (!data.services || !data.services.items || data.services.items.length === 0) { %>
<div class="text-center py-5">
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No services found</h5>
<p class="text-muted">Click "Add Service" tab to create your first service.</p>
</div>
<% } %>
</div>
</div>
</div>
<!-- Section Settings Tab -->
<div class="tab-pane fade" id="section-settings" role="tabpanel">
<div class="card border shadow-sm">
<div class="card-body">
<h6 class="fw-medium mb-3">Services Section Title</h6>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-medium">Sub Title</label>
<input type="text" class="form-control" id="servicesSubTitle"
value="<%= data.services?.title?.subTitle || '' %>"
placeholder="Enter subtitle for services section">
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Main Title</label>
<input type="text" class="form-control" id="servicesMainTitle"
value="<%= data.services?.title?.mainTitle || '' %>"
placeholder="Enter main title for services section">
</div>
</div>
</div>
</div>
</div>
<!-- Add Service Tab -->
<div class="tab-pane fade" id="add-service" role="tabpanel">
<div class="card border shadow-sm">
<div class="card-body">
<h6 class="fw-medium mb-3">Add New Service</h6>
<div class="row g-3">
<div class="col-md-12">
<label class="form-label fw-medium">Service Name</label>
<input type="text" class="form-control" id="newServiceName" placeholder="Enter service name">
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-md-6">
<label class="form-label fw-medium">
Slug
<small class="text-muted">(generated from name)</small>
<span id="slugAutoIndicator" class="badge bg-success ms-1" style="font-size: 0.7em;">AUTO</span>
</label>
<div class="input-group">
<input type="text" class="form-control" id="newServiceSlug" placeholder="Click generate to create slug" readonly>
<button type="button" class="btn btn-primary" id="generateSlugBtn" title="Generate slug from name">
<i class="fas fa-magic me-1"></i>Generate
</button>
</div>
<small class="form-text text-muted">URL-friendly version of the service name. Generated automatically from the service name.</small>
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Layout</label>
<select class="form-control" id="newServiceLayout">
<option value="left">Left</option>
<option value="right">Right</option>
</select>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-md-8">
<label class="form-label fw-medium">Image</label>
<div class="input-group">
<input type="text" class="form-control" id="newServiceImage" placeholder="Enter image path">
<button type="button" class="btn btn-outline-primary btn-upload-image" data-target-input="newServiceImage" data-image-type="service">
<i class="fas fa-upload me-1"></i>Upload
</button>
</div>
</div>
<div class="col-md-4">
<div id="newServiceImagePreview"></div>
</div>
</div>
<div class="row g-3 mt-2">
<div class="col-12">
<label class="form-label fw-medium">Description</label>
<textarea class="form-control" id="newServiceDescription" rows="3" placeholder="Enter service description"></textarea>
</div>
</div>
<div class="mt-3">
<button type="button" class="btn btn-primary" onclick="addService()">
<i class="fas fa-plus me-2"></i>Add Service
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bottom Buttons -->
<div class="bottom-buttons">
<button type="reset" class="btn btn-secondary">
<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>
<!-- Scripts -->
<script>
let originalFormData = null;
let servicesData = <%- JSON.stringify(data.services?.items || []) %>;
document.addEventListener('DOMContentLoaded', function() {
// Initialize form data
originalFormData = <%- JSON.stringify(data) %>;
// Set initial JSON values
updateAllJsonInputs(originalFormData);
// Initialize form handlers
initializeFormHandlers();
});
function initializeFormHandlers() {
// Form submission
const form = document.getElementById('serviceForm');
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);
showError('Failed to process form data. Please try again.');
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
}
});
// Initialize 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);
});
});
// Image preview for new service
const newServiceImageInput = document.getElementById('newServiceImage');
if (newServiceImageInput) {
newServiceImageInput.addEventListener('input', function() {
updateImagePreview('newServiceImagePreview', this.value);
});
}
// Generate slug from service name
const newServiceNameInput = document.getElementById('newServiceName');
const newServiceSlugInput = document.getElementById('newServiceSlug');
const slugAutoIndicator = document.getElementById('slugAutoIndicator');
const generateSlugBtn = document.getElementById('generateSlugBtn');
if (newServiceNameInput && newServiceSlugInput && generateSlugBtn) {
// Generate slug button
generateSlugBtn.addEventListener('click', async function() {
const serviceName = newServiceNameInput.value.trim();
if (serviceName) {
// Show loading state
const originalBtnHtml = this.innerHTML;
this.disabled = true;
this.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Generating...';
try {
const slug = await generateSlugFromText(serviceName);
// Check if we're in edit mode
const addBtn = document.querySelector('#add-service .btn-primary');
const isEditMode = addBtn && addBtn.textContent.includes('Update');
// Get current service index if editing
let currentIndex = -1;
if (isEditMode) {
const onclickAttr = addBtn.getAttribute('onclick');
const match = onclickAttr.match(/updateService\((\d+)\)/);
if (match) {
currentIndex = parseInt(match[1]);
}
}
// Check for duplicate slug
if (isSlugDuplicate(slug, currentIndex)) {
const uniqueSlug = generateUniqueSlug(slug);
newServiceSlugInput.value = uniqueSlug;
showWarning(`Slug "${slug}" already exists. Generated unique slug: "${uniqueSlug}"`);
} else {
newServiceSlugInput.value = slug;
showSuccess('Slug generated successfully!');
}
if (slugAutoIndicator) {
slugAutoIndicator.textContent = 'GENERATED';
slugAutoIndicator.className = 'badge bg-success ms-1';
slugAutoIndicator.style.fontSize = '0.7em';
}
} catch (error) {
console.error('Error generating slug:', error);
showError('Failed to generate slug. Please try again.');
} finally {
// Restore button state
this.disabled = false;
this.innerHTML = originalBtnHtml;
}
} else {
showError('Please enter a service name first.');
}
});
}
}
// Helper function để tạo full URL cho ảnh - tương tự như server-side helper
function getFullImageUrlJS(imagePath) {
if (!imagePath) return '';
// Nếu đã là full URL thì return luôn
if (imagePath.startsWith('http')) {
return imagePath;
}
// Lấy backend URL
const backendUrl = '<%= (process.env.BACKEND_URL || "http://localhost:3001").replace(/\/$/, "") %>';
// Xử lý đường dẫn
let imgSrc = imagePath;
if (!imgSrc.startsWith('/')) {
imgSrc = '/uploads/' + imgSrc;
}
return backendUrl + imgSrc;
}
// Generate slug using backend API
async function generateSlugFromText(text) {
try {
const response = await fetch('/admin/service/generate-slug', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: text })
});
const result = await response.json();
if (result.success) {
return result.slug;
} else {
throw new Error(result.message || 'Failed to generate slug');
}
} catch (error) {
console.error('Error generating slug:', error);
// Fallback to simple slug generation if API fails
return text
.toString()
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w\-]+/g, '')
.replace(/\-\-+/g, '-')
.replace(/^-+/, '')
.replace(/-+$/, '');
}
}
// Check if slug already exists (excluding current service when editing)
function isSlugDuplicate(slug, excludeIndex = -1) {
return servicesData.some((service, index) => {
return service && service.slug === slug && index !== excludeIndex;
});
}
// Generate unique slug by appending number
function generateUniqueSlug(baseSlug) {
let counter = 1;
let uniqueSlug = baseSlug;
while (isSlugDuplicate(uniqueSlug, -1)) {
uniqueSlug = baseSlug + '-' + counter;
counter++;
}
return uniqueSlug;
}
function updateImagePreview(previewId, imagePath) {
const preview = document.getElementById(previewId);
if (imagePath) {
const fullImageUrl = getFullImageUrlJS(imagePath);
preview.innerHTML = `<img src="${fullImageUrl}" class="img-thumbnail" style="height: 80px; width: 100%; object-fit: cover;" alt="Preview">`;
} else {
preview.innerHTML = '';
}
}
function addService() {
const name = document.getElementById('newServiceName').value.trim();
const slug = document.getElementById('newServiceSlug').value.trim();
const description = document.getElementById('newServiceDescription').value.trim();
const image = document.getElementById('newServiceImage').value.trim();
const layout = document.getElementById('newServiceLayout').value;
if (!name || !slug || !description) {
showError('Please fill in all required fields and generate a slug.');
return;
}
// Check if service with same slug already exists
if (isSlugDuplicate(slug, -1)) {
showError('Service with this slug already exists. Please generate a new slug.');
return;
}
const newService = {
slug: slug,
name: name,
description: description,
image: image,
layout: layout,
details: {
title: name,
description: description,
mainImage: "img/inner-page/service-details/details-1.jpg",
overviewTitle: "Service Overview",
overviewDescription: "Service overview description...",
additionalDescription: "Additional description...",
keyFeaturesTitle: "Key Features",
keyFeaturesImage: "img/inner-page/service-details/details-2.jpg",
features: [],
faqTitle: "Frequently Asked Question",
faqImage: "img/inner-page/service-details/details-3.jpg",
faq: []
}
};
servicesData.push(newService);
updateServicesTable();
updateStatistics();
// Clear form
clearAddServiceForm();
showSuccess('Service added successfully!');
}
function deleteService(index) {
if (confirm('Are you sure you want to delete this service? This action cannot be undone.')) {
servicesData.splice(index, 1);
updateServicesTable();
updateStatistics();
showSuccess('Service deleted successfully!');
}
}
function clearAddServiceForm() {
document.getElementById('newServiceName').value = '';
document.getElementById('newServiceSlug').value = '';
document.getElementById('newServiceDescription').value = '';
document.getElementById('newServiceImage').value = '';
document.getElementById('newServiceLayout').value = 'left';
document.getElementById('newServiceImagePreview').innerHTML = '';
// Reset slug indicator
const slugAutoIndicator = document.getElementById('slugAutoIndicator');
if (slugAutoIndicator) {
slugAutoIndicator.textContent = 'AUTO';
slugAutoIndicator.className = 'badge bg-success ms-1';
slugAutoIndicator.style.fontSize = '0.7em';
}
}
function updateServicesTable() {
const tbody = document.getElementById('servicesTableBody');
tbody.innerHTML = '';
servicesData.forEach((service, index) => {
if (!service) return; // Skip undefined services
let imageHtml = '';
if (service.image) {
const fullImageUrl = getFullImageUrlJS(service.image);
imageHtml = `<img src="${fullImageUrl}" class="rounded" style="width: 60px; height: 45px; object-fit: cover;" alt="${service.name || 'Service'}" onerror="this.onerror=null; this.src='/images/placeholder.png'; this.classList.add('bg-light');">`;
} else {
imageHtml = `<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 60px; height: 45px;"><i class="fas fa-image text-muted"></i></div>`;
}
const row = `
<tr data-index="${index}">
<td class="text-muted">${index + 1}</td>
<td>${imageHtml}</td>
<td>
<strong>${service.name || 'Unnamed Service'}</strong>
${service.description ? `<br><small class="text-muted">${service.description.substring(0, 50)}...</small>` : ''}
</td>
<td><span class="badge bg-light text-dark">${service.slug || 'no-slug'}</span></td>
<td><span class="badge bg-${(service.layout === 'left') ? 'primary' : 'success'}"><i class="fas fa-align-${service.layout || 'left'}"></i> ${service.layout || 'left'}</span></td>
<td>
<div class="btn-group" role="group">
<a href="/admin/service/${service.slug || 'unknown'}/edit" class="btn btn-sm btn-outline-primary" title="Edit Service">
<i class="fas fa-edit"></i>
</a>
<a href="/admin/service/${service.slug || 'unknown'}/details" class="btn btn-sm btn-outline-warning" title="Edit Details">
<i class="fas fa-cog"></i>
</a>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteService(${index})" title="Delete Service">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
`;
tbody.innerHTML += row;
});
}
function updateStatistics() {
const validServices = servicesData.filter(s => s && s.layout);
document.getElementById('totalCount').textContent = servicesData.length;
document.getElementById('leftCount').textContent = validServices.filter(s => s.layout === 'left').length;
document.getElementById('rightCount').textContent = validServices.filter(s => s.layout === 'right').length;
}
function updateAllJsonInputs(data) {
const servicesSection = {
title: data.services?.title || { subTitle: '', mainTitle: '' },
items: data.services?.items || []
};
document.getElementById('servicesJson').value = JSON.stringify(servicesSection);
}
function updateJsonData() {
const servicesSection = {
title: {
subTitle: document.getElementById('servicesSubTitle').value,
mainTitle: document.getElementById('servicesMainTitle').value,
},
items: servicesData
};
// Gửi toàn bộ services section (bao gồm cả title và items)
document.getElementById('servicesJson').value = JSON.stringify(servicesSection);
}
async function openImageUploader(targetInput, imageType) {
return new Promise((resolve, reject) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
fileInput.onchange = async function (e) {
const file = e.target.files[0];
if (!file) return reject(new Error('No file selected'));
try {
const formData = new FormData();
formData.append('image', file);
const uploadBtn = document.querySelector(`[data-target-input="${targetInput}"]`);
const originalBtnHtml = uploadBtn ? uploadBtn.innerHTML : null;
if (uploadBtn) {
uploadBtn.disabled = true;
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Uploading...';
}
// Sử dụng imageType=service để upload vào folder service
const response = await fetch(`/admin/upload/image?imageType=service`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Upload failed');
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Upload failed');
}
const input = document.getElementById(targetInput) || document.querySelector(`[name="${targetInput}"]`);
if (!input) throw new Error('Target input not found');
input.value = result.path;
// Update preview if it's the new service image
if (targetInput === 'newServiceImage') {
updateImagePreview('newServiceImagePreview', result.path);
}
if (uploadBtn) {
uploadBtn.disabled = false;
uploadBtn.innerHTML = originalBtnHtml;
}
resolve(result);
} catch (err) {
if (uploadBtn) {
uploadBtn.disabled = false;
uploadBtn.innerHTML = originalBtnHtml;
}
console.error('Upload error:', err);
showError('Upload failed: ' + (err.message || 'Unknown error'));
reject(err);
} finally {
fileInput.remove();
}
};
fileInput.click();
});
}
function showSuccess(message) {
// Create and show success alert
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
setTimeout(() => {
if (alert.parentNode) {
alert.parentNode.removeChild(alert);
}
}, 5000);
}
function showWarning(message) {
// Create and show warning alert
const alert = document.createElement('div');
alert.className = 'alert alert-warning alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
setTimeout(() => {
if (alert.parentNode) {
alert.parentNode.removeChild(alert);
}
}, 5000);
}
function showError(message) {
// Create and show error alert
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
setTimeout(() => {
if (alert.parentNode) {
alert.parentNode.removeChild(alert);
}
}, 5000);
}
</script>
<style>
.bottom-buttons {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
padding: 15px 0;
border-top: 1px solid #dee2e6;
}
.content-with-fixed-buttons {
/* Remove bottom padding since buttons are no longer fixed */
}
.btn-group .btn {
margin-right: 2px;
}
.btn-group .btn:last-child {
margin-right: 0;
}
</style>

View File

@@ -69,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>
@@ -82,8 +85,6 @@
<a class="nav-link" href="/admin/camp-location">Camp Location</a> <a class="nav-link" href="/admin/camp-location">Camp Location</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/admin/blog">Blog</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/admin/form">Form</a> <a class="nav-link" href="/admin/form">Form</a>
</li> </li>

View File

@@ -759,6 +759,14 @@
<a class="nav-link <%= currentPath === '/admin/contact' ? 'active' : '' %>" href="/admin/contact">Contact <a class="nav-link <%= currentPath === '/admin/contact' ? 'active' : '' %>" href="/admin/contact">Contact
Us</a> Us</a>
</li> </li>
<li class="nav-item">
<a class="nav-link <%= currentPath === '/admin/appointment' ? 'active' : '' %>"
href="/admin/appointment">Appointment</a>
</li>
<li class="nav-item">
<a class="nav-link <%= currentPath === '/admin/pricing' ? 'active' : '' %>"
href="/admin/pricing">Pricing</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link <%= currentPath === '/admin/camp-location' ? 'active' : '' %>" <a class="nav-link <%= currentPath === '/admin/camp-location' ? 'active' : '' %>"
href="/admin/camp-location">Camp Location</a> href="/admin/camp-location">Camp Location</a>
@@ -768,10 +776,35 @@
<a class="nav-link <%= currentPath === '/admin/activity' ? 'active' : '' %>" href="/admin/activity">Activity <a class="nav-link <%= currentPath === '/admin/activity' ? 'active' : '' %>" href="/admin/activity">Activity
& Booking</a> & Booking</a>
</li> </li>
<li class="nav-item"> </ul>
<a class="nav-link <%= currentPath === '/admin/blog' || currentPath.startsWith('/admin/blog') ? 'active' : '' %>" <li>
href="/admin/blog">Blog</a> <a class="dropdown-item <%= currentPath === '/admin/travel' ? 'active' : '' %>"
</li> href="/admin/travel">Travel</a>
</li>
<li>
<a class="dropdown-item <%= currentPath === '/admin/terms-conditions' ? 'active' : '' %>"
href="/admin/terms-conditions">Terms & Conditions</a>
</li>
<li>
<a class="dropdown-item <%= currentPath === '/admin/service' ? 'active' : '' %>"
href="/admin/service">Service</a>
</li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link <%= currentPath === '/admin/contact' ? 'active' : '' %>" href="/admin/contact">Contact
Us</a>
</li>
<li class="nav-item">
<a class="nav-link <%= currentPath === '/admin/camp-location' ? 'active' : '' %>"
href="/admin/camp-location">Camp Location</a>
</li>
<li class="nav-item">
<a class="nav-link <%= currentPath === '/admin/activity' ? 'active' : '' %>" href="/admin/activity">Activity &
Booking</a>
</li>
</ul> </ul>
</ul> </ul>