forked from UKSOURCE/cms.hailearning.edu.vn
Fix merge conflicts with main
This commit is contained in:
BIN
.env.example
BIN
.env.example
Binary file not shown.
377
controllers/appointmentController.js
Normal file
377
controllers/appointmentController.js
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
const AppointmentSubmission = require("../models/appointmentSubmission");
|
||||||
|
const Appointment = require("../models/appointment");
|
||||||
|
|
||||||
|
// ==================== CMS ADMIN FUNCTIONS ====================
|
||||||
|
|
||||||
|
// Render admin page for appointment management
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
let appointment = await Appointment.findOne({ name: "default" });
|
||||||
|
|
||||||
|
// If no data in DB, try to load from JSON file
|
||||||
|
if (!appointment) {
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const jsonPath = path.join(__dirname, "../data/appointment.json");
|
||||||
|
|
||||||
|
if (fs.existsSync(jsonPath)) {
|
||||||
|
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
||||||
|
appointment = await Appointment.migrateFromJson(jsonData);
|
||||||
|
} else {
|
||||||
|
// Create default appointment
|
||||||
|
appointment = await Appointment.create({
|
||||||
|
name: "default",
|
||||||
|
hero: {
|
||||||
|
title: "Make Appointment",
|
||||||
|
backgroundImage: "",
|
||||||
|
subtitle: "",
|
||||||
|
heading: "",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
visaOptions: [],
|
||||||
|
form: {
|
||||||
|
heading: "Request Appointment",
|
||||||
|
fields: [],
|
||||||
|
submitButton: {
|
||||||
|
text: "Request Appointment",
|
||||||
|
icon: "fa-solid fa-arrow-right",
|
||||||
|
buttonClass: "theme-btn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
const query = {};
|
||||||
|
|
||||||
|
if (startDate || endDate) {
|
||||||
|
query.createdAt = {};
|
||||||
|
if (startDate) {
|
||||||
|
query.createdAt.$gte = new Date(startDate);
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
// Set end date to end of day
|
||||||
|
const end = new Date(endDate);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
query.createdAt.$lte = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissions = await AppointmentSubmission.find(query).sort({ createdAt: -1 }).limit(50);
|
||||||
|
|
||||||
|
res.render("admin/appointment/index", {
|
||||||
|
layout: "layouts/main",
|
||||||
|
title: "Appointment Management",
|
||||||
|
data: appointment,
|
||||||
|
submissions,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
user: req.session.user,
|
||||||
|
frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading appointment admin page:", err);
|
||||||
|
req.flash("error", "Error loading appointment data");
|
||||||
|
res.redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update appointment data
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { hero, visaOptions, form } = req.body;
|
||||||
|
|
||||||
|
// Parse JSON strings if needed
|
||||||
|
const heroData = typeof hero === "string" ? JSON.parse(hero) : hero;
|
||||||
|
const visaOptionsData = typeof visaOptions === "string" ? JSON.parse(visaOptions) : visaOptions;
|
||||||
|
const formData = typeof form === "string" ? JSON.parse(form) : form;
|
||||||
|
|
||||||
|
let appointment = await Appointment.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (appointment) {
|
||||||
|
appointment.hero = heroData;
|
||||||
|
appointment.visaOptions = visaOptionsData;
|
||||||
|
appointment.form = formData;
|
||||||
|
await appointment.save();
|
||||||
|
} else {
|
||||||
|
appointment = await Appointment.create({
|
||||||
|
name: "default",
|
||||||
|
hero: heroData,
|
||||||
|
visaOptions: visaOptionsData,
|
||||||
|
form: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.flash("success", "Appointment data updated successfully");
|
||||||
|
res.redirect("/admin/appointment");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error updating appointment:", err);
|
||||||
|
req.flash("error", "Error updating appointment data");
|
||||||
|
res.redirect("/admin/appointment");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API to get appointment data
|
||||||
|
exports.getAppointmentData = async (req, res) => {
|
||||||
|
try {
|
||||||
|
let appointment = await Appointment.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (!appointment) {
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const jsonPath = path.join(__dirname, "../data/appointment.json");
|
||||||
|
|
||||||
|
if (fs.existsSync(jsonPath)) {
|
||||||
|
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
||||||
|
appointment = await Appointment.migrateFromJson(jsonData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: appointment,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error getting appointment data:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error loading appointment data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Public API to get appointment page data (for frontend)
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
let appointment = await Appointment.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (!appointment) {
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const jsonPath = path.join(__dirname, "../data/appointment.json");
|
||||||
|
|
||||||
|
if (fs.existsSync(jsonPath)) {
|
||||||
|
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
||||||
|
appointment = await Appointment.migrateFromJson(jsonData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!appointment) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Appointment data not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
hero: appointment.hero,
|
||||||
|
visaOptions: appointment.visaOptions,
|
||||||
|
form: appointment.form,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error getting appointment API data:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error loading appointment data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== APPOINTMENT SUBMISSIONS API ====================
|
||||||
|
|
||||||
|
// API để submit appointment form (từ frontend)
|
||||||
|
exports.submitAppointment = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, email, phone, address, appointmentDate, message, visaTypes } = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!name || !email) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Name and email are required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new submission
|
||||||
|
const submission = new AppointmentSubmission({
|
||||||
|
name: name.trim(),
|
||||||
|
email: email.trim().toLowerCase(),
|
||||||
|
phone: phone?.trim() || "",
|
||||||
|
address: address?.trim() || "",
|
||||||
|
appointmentDate: appointmentDate?.trim() || "",
|
||||||
|
message: message?.trim() || "",
|
||||||
|
visaTypes: Array.isArray(visaTypes) ? visaTypes : [],
|
||||||
|
ipAddress: req.ip || req.connection?.remoteAddress || "",
|
||||||
|
userAgent: req.get("User-Agent") || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await submission.save();
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "Thank you! Your appointment request has been submitted. We will contact you soon.",
|
||||||
|
data: {
|
||||||
|
id: submission._id,
|
||||||
|
name: submission.name,
|
||||||
|
email: submission.email,
|
||||||
|
appointmentDate: submission.appointmentDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error submitting appointment:", err);
|
||||||
|
|
||||||
|
// Handle validation errors
|
||||||
|
if (err.name === "ValidationError") {
|
||||||
|
const errors = Object.values(err.errors).map((e) => e.message);
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: errors.join(", "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error submitting appointment. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để lấy danh sách appointments (cho admin)
|
||||||
|
exports.getAppointments = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { status, page = 1, limit = 20 } = req.query;
|
||||||
|
|
||||||
|
const query = {};
|
||||||
|
if (status && ["pending", "confirmed", "completed", "cancelled"].includes(status)) {
|
||||||
|
query.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||||
|
|
||||||
|
const [appointments, total] = await Promise.all([
|
||||||
|
AppointmentSubmission.find(query)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(parseInt(limit)),
|
||||||
|
AppointmentSubmission.countDocuments(query),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: appointments,
|
||||||
|
pagination: {
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / parseInt(limit)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error getting appointments:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error loading appointments",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để cập nhật status của appointment
|
||||||
|
exports.updateAppointmentStatus = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { status, notes } = req.body;
|
||||||
|
|
||||||
|
const validStatuses = ["pending", "confirmed", "completed", "cancelled"];
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid status",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = { status };
|
||||||
|
if (notes !== undefined) updateData.notes = notes;
|
||||||
|
if (status === "confirmed") updateData.confirmedAt = new Date();
|
||||||
|
if (status === "completed") updateData.completedAt = new Date();
|
||||||
|
|
||||||
|
const appointment = await AppointmentSubmission.findByIdAndUpdate(
|
||||||
|
id,
|
||||||
|
updateData,
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!appointment) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Appointment not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: appointment,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error updating appointment:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error updating appointment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để lấy chi tiết một appointment
|
||||||
|
exports.getAppointmentById = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const appointment = await AppointmentSubmission.findById(id);
|
||||||
|
|
||||||
|
if (!appointment) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Appointment not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: appointment,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error getting appointment:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error loading appointment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để xóa appointment
|
||||||
|
exports.deleteAppointment = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const appointment = await AppointmentSubmission.findByIdAndDelete(id);
|
||||||
|
|
||||||
|
if (!appointment) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Appointment not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Appointment deleted successfully",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error deleting appointment:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error deleting appointment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
231
controllers/blogCategoryController.js
Normal file
231
controllers/blogCategoryController.js
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
const BlogCategory = require('../models/blogCategory');
|
||||||
|
|
||||||
|
// -------------------- Admin Controllers --------------------
|
||||||
|
|
||||||
|
// Display category management page
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const categories = await BlogCategory.find()
|
||||||
|
.sort({ name: 1 })
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
res.render('admin/blog/categories/index', {
|
||||||
|
layout: 'layouts/main',
|
||||||
|
title: 'Blog Categories',
|
||||||
|
categories,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Category index error:', err);
|
||||||
|
req.flash('error_msg', 'Error loading categories');
|
||||||
|
res.redirect('/admin/dashboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show create category form
|
||||||
|
exports.create = async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.render('admin/blog/categories/create', {
|
||||||
|
layout: 'layouts/main',
|
||||||
|
title: 'Create New Category',
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Category create form error:', err);
|
||||||
|
req.flash('error_msg', 'Error loading create form');
|
||||||
|
res.redirect('/admin/blog/categories');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store new category
|
||||||
|
exports.store = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
isActive
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Generate slug
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.trim('-');
|
||||||
|
|
||||||
|
// Check if slug exists
|
||||||
|
const existingCategory = await BlogCategory.findOne({ slug });
|
||||||
|
if (existingCategory) {
|
||||||
|
req.flash('error_msg', 'A category with this name already exists');
|
||||||
|
return res.redirect('/admin/blog/categories/create');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create category data
|
||||||
|
const categoryData = {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
isActive: isActive === 'on'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create category
|
||||||
|
const category = new BlogCategory(categoryData);
|
||||||
|
await category.save();
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Category created successfully');
|
||||||
|
res.redirect('/admin/blog/categories');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Category store error:', err);
|
||||||
|
req.flash('error_msg', 'Error creating category');
|
||||||
|
res.redirect('/admin/blog/categories/create');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show edit category form
|
||||||
|
exports.edit = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const category = await BlogCategory.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
req.flash('error_msg', 'Category not found');
|
||||||
|
return res.redirect('/admin/blog/categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render('admin/blog/categories/edit', {
|
||||||
|
layout: 'layouts/main',
|
||||||
|
title: 'Edit Category',
|
||||||
|
category,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Category edit form error:', err);
|
||||||
|
req.flash('error_msg', 'Error loading category');
|
||||||
|
res.redirect('/admin/blog/categories');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update category
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const category = await BlogCategory.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
req.flash('error_msg', 'Category not found');
|
||||||
|
return res.redirect('/admin/blog/categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
isActive
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Update category data
|
||||||
|
category.name = name;
|
||||||
|
category.description = description;
|
||||||
|
category.isActive = isActive === 'on';
|
||||||
|
|
||||||
|
// Generate new slug if name changed
|
||||||
|
const newSlug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.trim('-');
|
||||||
|
|
||||||
|
if (newSlug !== category.slug) {
|
||||||
|
const existingCategory = await BlogCategory.findOne({
|
||||||
|
slug: newSlug,
|
||||||
|
_id: { $ne: category._id }
|
||||||
|
});
|
||||||
|
if (existingCategory) {
|
||||||
|
req.flash('error_msg', 'A category with this name already exists');
|
||||||
|
return res.redirect(`/admin/blog/categories/${category._id}/edit`);
|
||||||
|
}
|
||||||
|
category.slug = newSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
await category.save();
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Category updated successfully');
|
||||||
|
res.redirect('/admin/blog/categories');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Category update error:', err);
|
||||||
|
req.flash('error_msg', 'Error updating category');
|
||||||
|
res.redirect(`/admin/blog/categories/${req.params.id}/edit`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete category
|
||||||
|
exports.destroy = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const category = await BlogCategory.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
req.flash('error_msg', 'Category not found');
|
||||||
|
return res.redirect('/admin/blog/categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if category has posts
|
||||||
|
const Blog = require('../models/blog');
|
||||||
|
const postCount = await Blog.countDocuments({ category: { $in: [category.name] } });
|
||||||
|
|
||||||
|
if (postCount > 0) {
|
||||||
|
req.flash('error_msg', 'Cannot delete category that has blog posts');
|
||||||
|
return res.redirect('/admin/blog/categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
await BlogCategory.findByIdAndDelete(req.params.id);
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Category deleted successfully');
|
||||||
|
res.redirect('/admin/blog/categories');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Category delete error:', err);
|
||||||
|
req.flash('error_msg', 'Error deleting category');
|
||||||
|
res.redirect('/admin/blog/categories');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------- Public API Controllers --------------------
|
||||||
|
|
||||||
|
// Get all active categories
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const categories = await BlogCategory.getActive();
|
||||||
|
|
||||||
|
// Update post counts
|
||||||
|
for (const category of categories) {
|
||||||
|
await category.updatePostCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(categories);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Categories API error:', err);
|
||||||
|
res.status(500).json({ error: 'Error loading categories' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get category by slug
|
||||||
|
exports.apiShow = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const category = await BlogCategory.findOne({
|
||||||
|
slug: req.params.slug,
|
||||||
|
isActive: true
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return res.status(404).json({ error: 'Category not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(category);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Category show API error:', err);
|
||||||
|
res.status(500).json({ error: 'Error loading category' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = exports;
|
||||||
554
controllers/blogController.js
Normal file
554
controllers/blogController.js
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
const Blog = require('../models/blog');
|
||||||
|
const BlogCategory = require('../models/blogCategory');
|
||||||
|
const BlogTag = require('../models/blogTag');
|
||||||
|
const BlogComment = require('../models/blogComment');
|
||||||
|
const RecentPost = require('../models/recentPost');
|
||||||
|
const { addBaseUrlToImages } = require('../utils/imageHelper');
|
||||||
|
|
||||||
|
// -------------------- Helper Functions --------------------
|
||||||
|
|
||||||
|
// Generate slug from title
|
||||||
|
const generateSlug = (title) => {
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.trim('-');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update category post counts
|
||||||
|
const updateCategoryPostCounts = async () => {
|
||||||
|
const categories = await BlogCategory.find();
|
||||||
|
for (const category of categories) {
|
||||||
|
await category.updatePostCount();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update tag post counts
|
||||||
|
const updateTagPostCounts = async () => {
|
||||||
|
const tags = await BlogTag.find();
|
||||||
|
for (const tag of tags) {
|
||||||
|
await tag.updatePostCount();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------- Admin Controllers --------------------
|
||||||
|
|
||||||
|
// Display blog management page
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = parseInt(req.query.page) || 1;
|
||||||
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Build filter
|
||||||
|
const filter = {};
|
||||||
|
if (req.query.status) {
|
||||||
|
filter.status = req.query.status;
|
||||||
|
}
|
||||||
|
if (req.query.category) {
|
||||||
|
filter.category = req.query.category;
|
||||||
|
}
|
||||||
|
if (req.query.search) {
|
||||||
|
filter.$or = [
|
||||||
|
{ title: { $regex: req.query.search, $options: 'i' } },
|
||||||
|
{ excerpt: { $regex: req.query.search, $options: 'i' } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get blogs with pagination
|
||||||
|
const blogs = await Blog.find(filter)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
const totalBlogs = await Blog.countDocuments(filter);
|
||||||
|
const totalPages = Math.ceil(totalBlogs / limit);
|
||||||
|
|
||||||
|
// Get categories for filter
|
||||||
|
const categories = await BlogCategory.getActive();
|
||||||
|
|
||||||
|
res.render('admin/blog/index', {
|
||||||
|
layout: 'layouts/main',
|
||||||
|
title: 'Blog Management',
|
||||||
|
blogs,
|
||||||
|
categories,
|
||||||
|
pagination: {
|
||||||
|
current: page,
|
||||||
|
total: totalPages,
|
||||||
|
limit,
|
||||||
|
totalItems: totalBlogs
|
||||||
|
},
|
||||||
|
query: req.query,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Blog index error:', err);
|
||||||
|
req.flash('error_msg', 'Error loading blogs');
|
||||||
|
res.redirect('/admin/dashboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show create blog form
|
||||||
|
exports.create = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const categories = await BlogCategory.getActive();
|
||||||
|
const tags = await BlogTag.getActive();
|
||||||
|
|
||||||
|
res.render('admin/blog/create', {
|
||||||
|
layout: 'layouts/main',
|
||||||
|
title: 'Create New Blog Post',
|
||||||
|
categories,
|
||||||
|
tags,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Blog create form error:', err);
|
||||||
|
req.flash('error_msg', 'Error loading create form');
|
||||||
|
res.redirect('/admin/blog');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store new blog
|
||||||
|
exports.store = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
excerpt,
|
||||||
|
content,
|
||||||
|
category,
|
||||||
|
tags,
|
||||||
|
status,
|
||||||
|
isFeatured,
|
||||||
|
author,
|
||||||
|
galleryImages
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Generate slug
|
||||||
|
const slug = generateSlug(title);
|
||||||
|
|
||||||
|
// Check if slug exists
|
||||||
|
const existingBlog = await Blog.findOne({ slug });
|
||||||
|
if (existingBlog) {
|
||||||
|
req.flash('error_msg', 'A blog post with this title already exists');
|
||||||
|
return res.redirect('/admin/blog/create');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create blog data
|
||||||
|
const blogData = {
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
excerpt,
|
||||||
|
content,
|
||||||
|
category: category ? (Array.isArray(category) ? category : [category]) : [], // Array categories
|
||||||
|
tags: tags ? (Array.isArray(tags) ? tags : [tags]) : [],
|
||||||
|
status: status || 'published',
|
||||||
|
isFeatured: isFeatured === 'on',
|
||||||
|
author: author || 'Admin',
|
||||||
|
galleryImages: galleryImages ? (Array.isArray(galleryImages) ? galleryImages : [galleryImages]) : []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle featured image
|
||||||
|
if (req.file) {
|
||||||
|
blogData.featuredImage = `/uploads/blog/${req.file.filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create blog
|
||||||
|
const blog = new Blog(blogData);
|
||||||
|
await blog.save();
|
||||||
|
|
||||||
|
// Update counts
|
||||||
|
await updateCategoryPostCounts();
|
||||||
|
await updateTagPostCounts();
|
||||||
|
await RecentPost.syncFromBlogs();
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Blog post created successfully');
|
||||||
|
res.redirect('/admin/blog');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Blog store error:', err);
|
||||||
|
req.flash('error_msg', 'Error creating blog post');
|
||||||
|
res.redirect('/admin/blog/create');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show edit blog form
|
||||||
|
exports.edit = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const blog = await Blog.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!blog) {
|
||||||
|
req.flash('error_msg', 'Blog post not found');
|
||||||
|
return res.redirect('/admin/blog');
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = await BlogCategory.getActive();
|
||||||
|
const tags = await BlogTag.getActive();
|
||||||
|
|
||||||
|
res.render('admin/blog/edit', {
|
||||||
|
layout: 'layouts/main',
|
||||||
|
title: 'Edit Blog Post',
|
||||||
|
blog,
|
||||||
|
categories,
|
||||||
|
tags,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Blog edit form error:', err);
|
||||||
|
req.flash('error_msg', 'Error loading blog post');
|
||||||
|
res.redirect('/admin/blog');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update blog
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const blog = await Blog.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!blog) {
|
||||||
|
req.flash('error_msg', 'Blog post not found');
|
||||||
|
return res.redirect('/admin/blog');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
excerpt,
|
||||||
|
content,
|
||||||
|
category,
|
||||||
|
tags,
|
||||||
|
status,
|
||||||
|
isFeatured,
|
||||||
|
author,
|
||||||
|
galleryImages
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Update blog data
|
||||||
|
blog.title = title;
|
||||||
|
blog.excerpt = excerpt;
|
||||||
|
blog.content = content;
|
||||||
|
blog.category = category ? (Array.isArray(category) ? category : [category]) : []; // Array categories
|
||||||
|
blog.tags = tags ? (Array.isArray(tags) ? tags : [tags]) : [];
|
||||||
|
blog.status = status || 'published';
|
||||||
|
blog.isFeatured = isFeatured === 'on';
|
||||||
|
blog.author = author || 'Admin';
|
||||||
|
blog.galleryImages = galleryImages ? (Array.isArray(galleryImages) ? galleryImages : [galleryImages]) : [];
|
||||||
|
|
||||||
|
// Handle featured image
|
||||||
|
if (req.file) {
|
||||||
|
blog.featuredImage = `/uploads/blog/${req.file.filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new slug if title changed
|
||||||
|
const newSlug = generateSlug(title);
|
||||||
|
if (newSlug !== blog.slug) {
|
||||||
|
const existingBlog = await Blog.findOne({ slug: newSlug, _id: { $ne: blog._id } });
|
||||||
|
if (existingBlog) {
|
||||||
|
req.flash('error_msg', 'A blog post with this title already exists');
|
||||||
|
return res.redirect(`/admin/blog/${blog._id}/edit`);
|
||||||
|
}
|
||||||
|
blog.slug = newSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
await blog.save();
|
||||||
|
|
||||||
|
// Update counts
|
||||||
|
await updateCategoryPostCounts();
|
||||||
|
await updateTagPostCounts();
|
||||||
|
await RecentPost.syncFromBlogs();
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Blog post updated successfully');
|
||||||
|
res.redirect('/admin/blog');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Blog update error:', err);
|
||||||
|
req.flash('error_msg', 'Error updating blog post');
|
||||||
|
res.redirect(`/admin/blog/${req.params.id}/edit`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete blog
|
||||||
|
exports.destroy = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const blog = await Blog.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!blog) {
|
||||||
|
req.flash('error_msg', 'Blog post not found');
|
||||||
|
return res.redirect('/admin/blog');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Blog.findByIdAndDelete(req.params.id);
|
||||||
|
|
||||||
|
// Update counts
|
||||||
|
await updateCategoryPostCounts();
|
||||||
|
await updateTagPostCounts();
|
||||||
|
await RecentPost.syncFromBlogs();
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Blog post deleted successfully');
|
||||||
|
res.redirect('/admin/blog');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Blog delete error:', err);
|
||||||
|
req.flash('error_msg', 'Error deleting blog post');
|
||||||
|
res.redirect('/admin/blog');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------- Public API Controllers --------------------
|
||||||
|
|
||||||
|
// Get all published blogs for frontend
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = parseInt(req.query.page) || 1;
|
||||||
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Build filter
|
||||||
|
const filter = { status: 'published' };
|
||||||
|
|
||||||
|
if (req.query.category) {
|
||||||
|
filter.category = { $in: [req.query.category] }; // Tìm trong array categories
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.tag) {
|
||||||
|
filter.tags = { $in: [req.query.tag] }; // Tìm trong array tags
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.search) {
|
||||||
|
filter.$or = [
|
||||||
|
{ title: { $regex: req.query.search, $options: 'i' } },
|
||||||
|
{ excerpt: { $regex: req.query.search, $options: 'i' } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get blogs
|
||||||
|
const blogs = await Blog.find(filter)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
const totalBlogs = await Blog.countDocuments(filter);
|
||||||
|
|
||||||
|
// Add base URL to images
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processedBlogs = blogs.map(blog => addBaseUrlToImages(blog, baseUrl));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Blogs fetched successfully',
|
||||||
|
data: {
|
||||||
|
blogs: processedBlogs,
|
||||||
|
pagination: {
|
||||||
|
current: page,
|
||||||
|
total: Math.ceil(totalBlogs / limit),
|
||||||
|
limit,
|
||||||
|
totalItems: totalBlogs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Blog API error:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error loading blogs',
|
||||||
|
error: err.message || 'Error loading blogs'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get single blog by slug
|
||||||
|
exports.apiShow = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const blog = await Blog.findOne({
|
||||||
|
slug: req.params.slug,
|
||||||
|
status: 'published'
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
if (!blog) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Blog post not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get comments for this post
|
||||||
|
const comments = await BlogComment.getApprovedByPost(blog._id);
|
||||||
|
|
||||||
|
// Add comments to blog
|
||||||
|
blog.comments = comments;
|
||||||
|
|
||||||
|
// Add base URL to images
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processedBlog = addBaseUrlToImages(blog, baseUrl);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Blog post fetched successfully',
|
||||||
|
data: processedBlog
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Blog show API error:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error loading blog post',
|
||||||
|
error: err.message || 'Error loading blog post'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get featured blogs
|
||||||
|
exports.apiFeatured = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit) || 3;
|
||||||
|
|
||||||
|
const blogs = await Blog.getFeatured()
|
||||||
|
.limit(limit)
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
// Add base URL to images
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processedBlogs = blogs.map(blog => addBaseUrlToImages(blog, baseUrl));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Featured blogs fetched successfully',
|
||||||
|
data: processedBlogs
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Featured blogs API error:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error loading featured blogs',
|
||||||
|
error: err.message || 'Error loading featured blogs'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get recent blogs
|
||||||
|
exports.apiRecent = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit) || 5;
|
||||||
|
|
||||||
|
// Try to get from RecentPost first
|
||||||
|
let recentPosts = await RecentPost.getRecent(limit);
|
||||||
|
|
||||||
|
// If no recent posts, sync from blogs
|
||||||
|
if (recentPosts.length === 0) {
|
||||||
|
await RecentPost.syncFromBlogs(limit);
|
||||||
|
recentPosts = await RecentPost.getRecent(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add base URL to images
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processedPosts = recentPosts.map(post => addBaseUrlToImages(post, baseUrl));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Recent blogs fetched successfully',
|
||||||
|
data: processedPosts
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Recent blogs API error:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error loading recent blogs',
|
||||||
|
error: err.message || 'Error loading recent blogs'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get categories of a specific blog post
|
||||||
|
exports.apiCategories = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
let query;
|
||||||
|
|
||||||
|
// Check if it's a valid ObjectId
|
||||||
|
if (mongoose.Types.ObjectId.isValid(req.params.id)) {
|
||||||
|
query = { _id: req.params.id };
|
||||||
|
} else {
|
||||||
|
query = { slug: req.params.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
query.status = 'published';
|
||||||
|
|
||||||
|
const blog = await Blog.findOne(query).lean();
|
||||||
|
|
||||||
|
if (!blog) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Blog post not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get category details
|
||||||
|
const BlogCategory = require('../models/blogCategory');
|
||||||
|
const categories = await BlogCategory.find({
|
||||||
|
name: { $in: blog.category },
|
||||||
|
isActive: true
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Blog categories fetched successfully',
|
||||||
|
data: categories
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Blog categories API error:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error loading blog categories',
|
||||||
|
error: err.message || 'Error loading blog categories'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get tags of a specific blog post
|
||||||
|
exports.apiTags = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
let query;
|
||||||
|
|
||||||
|
// Check if it's a valid ObjectId
|
||||||
|
if (mongoose.Types.ObjectId.isValid(req.params.id)) {
|
||||||
|
query = { _id: req.params.id };
|
||||||
|
} else {
|
||||||
|
query = { slug: req.params.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
query.status = 'published';
|
||||||
|
|
||||||
|
const blog = await Blog.findOne(query).lean();
|
||||||
|
|
||||||
|
if (!blog) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Blog post not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tag details
|
||||||
|
const BlogTag = require('../models/blogTag');
|
||||||
|
const tags = await BlogTag.find({
|
||||||
|
name: { $in: blog.tags },
|
||||||
|
isActive: true
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Blog tags fetched successfully',
|
||||||
|
data: tags
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Blog tags API error:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error loading blog tags',
|
||||||
|
error: err.message || 'Error loading blog tags'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = exports;
|
||||||
239
controllers/blogTagController.js
Normal file
239
controllers/blogTagController.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
const BlogTag = require('../models/blogTag');
|
||||||
|
|
||||||
|
// -------------------- Admin Controllers --------------------
|
||||||
|
|
||||||
|
// Display tag management page
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tags = await BlogTag.find()
|
||||||
|
.sort({ name: 1 })
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
res.render('admin/blog/tags/index', {
|
||||||
|
layout: 'layouts/main',
|
||||||
|
title: 'Blog Tags',
|
||||||
|
tags,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Tag index error:', err);
|
||||||
|
req.flash('error_msg', 'Error loading tags');
|
||||||
|
res.redirect('/admin/dashboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show create tag form
|
||||||
|
exports.create = async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.render('admin/blog/tags/create', {
|
||||||
|
layout: 'layouts/main',
|
||||||
|
title: 'Create New Tag',
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Tag create form error:', err);
|
||||||
|
req.flash('error_msg', 'Error loading create form');
|
||||||
|
res.redirect('/admin/blog/tags');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store new tag
|
||||||
|
exports.store = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
isActive
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Generate slug
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.trim('-');
|
||||||
|
|
||||||
|
// Check if slug exists
|
||||||
|
const existingTag = await BlogTag.findOne({ slug });
|
||||||
|
if (existingTag) {
|
||||||
|
req.flash('error_msg', 'A tag with this name already exists');
|
||||||
|
return res.redirect('/admin/blog/tags/create');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tag data
|
||||||
|
const tagData = {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
isActive: isActive === 'on'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create tag
|
||||||
|
const tag = new BlogTag(tagData);
|
||||||
|
await tag.save();
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Tag created successfully');
|
||||||
|
res.redirect('/admin/blog/tags');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Tag store error:', err);
|
||||||
|
req.flash('error_msg', 'Error creating tag');
|
||||||
|
res.redirect('/admin/blog/tags/create');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show edit tag form
|
||||||
|
exports.edit = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tag = await BlogTag.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!tag) {
|
||||||
|
req.flash('error_msg', 'Tag not found');
|
||||||
|
return res.redirect('/admin/blog/tags');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render('admin/blog/tags/edit', {
|
||||||
|
layout: 'layouts/main',
|
||||||
|
title: 'Edit Tag',
|
||||||
|
tag,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Tag edit form error:', err);
|
||||||
|
req.flash('error_msg', 'Error loading tag');
|
||||||
|
res.redirect('/admin/blog/tags');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update tag
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tag = await BlogTag.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!tag) {
|
||||||
|
req.flash('error_msg', 'Tag not found');
|
||||||
|
return res.redirect('/admin/blog/tags');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
isActive
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Update tag data
|
||||||
|
tag.name = name;
|
||||||
|
tag.isActive = isActive === 'on';
|
||||||
|
|
||||||
|
// Generate new slug if name changed
|
||||||
|
const newSlug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.trim('-');
|
||||||
|
|
||||||
|
if (newSlug !== tag.slug) {
|
||||||
|
const existingTag = await BlogTag.findOne({
|
||||||
|
slug: newSlug,
|
||||||
|
_id: { $ne: tag._id }
|
||||||
|
});
|
||||||
|
if (existingTag) {
|
||||||
|
req.flash('error_msg', 'A tag with this name already exists');
|
||||||
|
return res.redirect(`/admin/blog/tags/${tag._id}/edit`);
|
||||||
|
}
|
||||||
|
tag.slug = newSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tag.save();
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Tag updated successfully');
|
||||||
|
res.redirect('/admin/blog/tags');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Tag update error:', err);
|
||||||
|
req.flash('error_msg', 'Error updating tag');
|
||||||
|
res.redirect(`/admin/blog/tags/${req.params.id}/edit`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete tag
|
||||||
|
exports.destroy = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tag = await BlogTag.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!tag) {
|
||||||
|
req.flash('error_msg', 'Tag not found');
|
||||||
|
return res.redirect('/admin/blog/tags');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if tag has posts
|
||||||
|
const Blog = require('../models/blog');
|
||||||
|
const postCount = await Blog.countDocuments({ tags: { $in: [tag.name] } });
|
||||||
|
|
||||||
|
if (postCount > 0) {
|
||||||
|
req.flash('error_msg', 'Cannot delete tag that is used in blog posts');
|
||||||
|
return res.redirect('/admin/blog/tags');
|
||||||
|
}
|
||||||
|
|
||||||
|
await BlogTag.findByIdAndDelete(req.params.id);
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Tag deleted successfully');
|
||||||
|
res.redirect('/admin/blog/tags');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Tag delete error:', err);
|
||||||
|
req.flash('error_msg', 'Error deleting tag');
|
||||||
|
res.redirect('/admin/blog/tags');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------- Public API Controllers --------------------
|
||||||
|
|
||||||
|
// Get all active tags
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tags = await BlogTag.getActive();
|
||||||
|
|
||||||
|
// Update post counts
|
||||||
|
for (const tag of tags) {
|
||||||
|
await tag.updatePostCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(tags);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Tags API error:', err);
|
||||||
|
res.status(500).json({ error: 'Error loading tags' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get popular tags
|
||||||
|
exports.apiPopular = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
const tags = await BlogTag.getPopular(limit);
|
||||||
|
res.json(tags);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Popular tags API error:', err);
|
||||||
|
res.status(500).json({ error: 'Error loading popular tags' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get tag by slug
|
||||||
|
exports.apiShow = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const tag = await BlogTag.findOne({
|
||||||
|
slug: req.params.slug,
|
||||||
|
isActive: true
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
if (!tag) {
|
||||||
|
return res.status(404).json({ error: 'Tag not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(tag);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Tag show API error:', err);
|
||||||
|
res.status(500).json({ error: 'Error loading tag' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = exports;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||||
const Contact = require("../models/contact");
|
const Contact = require("../models/contact");
|
||||||
|
const ContactSubmission = require("../models/contactSubmission");
|
||||||
|
|
||||||
// Get contact data from MongoDB
|
// Get contact data from MongoDB
|
||||||
const getContactData = async () => {
|
const getContactData = async () => {
|
||||||
@@ -60,6 +61,7 @@ exports.index = async (req, res) => {
|
|||||||
zoom: 15,
|
zoom: 15,
|
||||||
location: "",
|
location: "",
|
||||||
markerTitle: "",
|
markerTitle: "",
|
||||||
|
embedUrl: "",
|
||||||
tileLayer: {
|
tileLayer: {
|
||||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
attribution: "",
|
attribution: "",
|
||||||
@@ -70,16 +72,38 @@ exports.index = async (req, res) => {
|
|||||||
form: {
|
form: {
|
||||||
sectionLabel: "",
|
sectionLabel: "",
|
||||||
heading: "",
|
heading: "",
|
||||||
|
description: "",
|
||||||
fields: [],
|
fields: [],
|
||||||
submitButton: { text: "Send Message" },
|
submitButton: { text: "Send Message", icon: "fa-solid fa-arrow-right", buttonClass: "theme-btn style-2" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
const query = {};
|
||||||
|
|
||||||
|
if (startDate || endDate) {
|
||||||
|
query.createdAt = {};
|
||||||
|
if (startDate) {
|
||||||
|
query.createdAt.$gte = new Date(startDate);
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
// Set end date to end of day
|
||||||
|
const end = new Date(endDate);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
query.createdAt.$lte = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissions = await ContactSubmission.find(query).sort({ createdAt: -1 }).limit(50);
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL;
|
||||||
|
|
||||||
res.render("admin/contact/index", {
|
res.render("admin/contact/index", {
|
||||||
title: "Contact Management",
|
title: "Contact Management",
|
||||||
layout: "layouts/main",
|
layout: "layouts/main",
|
||||||
data,
|
data,
|
||||||
|
submissions,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
frontendUrl,
|
frontendUrl,
|
||||||
currentPath: req.path,
|
currentPath: req.path,
|
||||||
user: req.session.user,
|
user: req.session.user,
|
||||||
@@ -140,6 +164,7 @@ exports.update = async (req, res) => {
|
|||||||
zoom: 15,
|
zoom: 15,
|
||||||
location: "",
|
location: "",
|
||||||
markerTitle: "",
|
markerTitle: "",
|
||||||
|
embedUrl: "",
|
||||||
tileLayer: {
|
tileLayer: {
|
||||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
attribution: "",
|
attribution: "",
|
||||||
@@ -150,8 +175,9 @@ exports.update = async (req, res) => {
|
|||||||
form: formData || {
|
form: formData || {
|
||||||
sectionLabel: "",
|
sectionLabel: "",
|
||||||
heading: "",
|
heading: "",
|
||||||
|
description: "",
|
||||||
fields: [],
|
fields: [],
|
||||||
submitButton: { text: "Send Message" },
|
submitButton: { text: "Send Message", icon: "fa-solid fa-arrow-right", buttonClass: "theme-btn style-2" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -179,3 +205,141 @@ exports.update = async (req, res) => {
|
|||||||
res.redirect("/admin/contact");
|
res.redirect("/admin/contact");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// API để submit contact form (từ frontend)
|
||||||
|
exports.submitForm = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, email, phone, address, date, message } = req.body;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!name || !email) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Name and email are required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new submission
|
||||||
|
const submission = new ContactSubmission({
|
||||||
|
name: name.trim(),
|
||||||
|
email: email.trim().toLowerCase(),
|
||||||
|
phone: phone?.trim() || "",
|
||||||
|
address: address?.trim() || "",
|
||||||
|
date: date?.trim() || "",
|
||||||
|
message: message?.trim() || "",
|
||||||
|
ipAddress: req.ip || req.connection?.remoteAddress || "",
|
||||||
|
userAgent: req.get("User-Agent") || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await submission.save();
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "Thank you for contacting us! We will get back to you soon.",
|
||||||
|
data: {
|
||||||
|
id: submission._id,
|
||||||
|
name: submission.name,
|
||||||
|
email: submission.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error submitting contact form:", err);
|
||||||
|
|
||||||
|
// Handle validation errors
|
||||||
|
if (err.name === "ValidationError") {
|
||||||
|
const errors = Object.values(err.errors).map((e) => e.message);
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: errors.join(", "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error submitting form. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để lấy danh sách submissions (cho admin)
|
||||||
|
exports.getSubmissions = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { status, page = 1, limit = 20 } = req.query;
|
||||||
|
|
||||||
|
const query = {};
|
||||||
|
if (status && ["pending", "read", "replied", "archived"].includes(status)) {
|
||||||
|
query.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||||
|
|
||||||
|
const [submissions, total] = await Promise.all([
|
||||||
|
ContactSubmission.find(query)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(parseInt(limit)),
|
||||||
|
ContactSubmission.countDocuments(query),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: submissions,
|
||||||
|
pagination: {
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / parseInt(limit)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error getting submissions:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error loading submissions",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để cập nhật status của submission
|
||||||
|
exports.updateSubmissionStatus = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { status, notes } = req.body;
|
||||||
|
|
||||||
|
const validStatuses = ["pending", "read", "replied", "archived"];
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid status",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = { status };
|
||||||
|
if (notes !== undefined) updateData.notes = notes;
|
||||||
|
if (status === "replied") updateData.repliedAt = new Date();
|
||||||
|
|
||||||
|
const submission = await ContactSubmission.findByIdAndUpdate(
|
||||||
|
id,
|
||||||
|
updateData,
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!submission) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Submission not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: submission,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error updating submission:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error updating submission",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
193
controllers/pricingController.js
Normal file
193
controllers/pricingController.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
const Pricing = require("../models/pricing");
|
||||||
|
|
||||||
|
// ==================== CMS ADMIN FUNCTIONS ====================
|
||||||
|
|
||||||
|
// Render admin page for pricing management
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
let pricing = await Pricing.findOne({ name: "default" });
|
||||||
|
|
||||||
|
// If no data in DB, try to load from JSON file
|
||||||
|
if (!pricing) {
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const jsonPath = path.join(__dirname, "../data/pricing.json");
|
||||||
|
|
||||||
|
if (fs.existsSync(jsonPath)) {
|
||||||
|
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
||||||
|
pricing = await Pricing.migrateFromJson(jsonData);
|
||||||
|
} else {
|
||||||
|
// Create default pricing
|
||||||
|
pricing = await Pricing.create({
|
||||||
|
name: "default",
|
||||||
|
hero: {
|
||||||
|
title: "Pricing Plan",
|
||||||
|
backgroundImage: "/assets/img/inner-page/breadcrumb.jpg",
|
||||||
|
shapeImage: "/assets/img/inner-page/shape.png",
|
||||||
|
breadcrumb: [
|
||||||
|
{ text: "Home", link: "/" },
|
||||||
|
{ text: "Pricing Plan", link: "" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pricingSection: {
|
||||||
|
subtitle: "pricing plan",
|
||||||
|
heading: "Flexible Plans to Suit Every Traveler",
|
||||||
|
description: "Choose the plan that fits your visa needs and enjoy expert guidance every step of the way.",
|
||||||
|
},
|
||||||
|
plans: {
|
||||||
|
monthly: [],
|
||||||
|
yearly: [],
|
||||||
|
},
|
||||||
|
testimonials: {
|
||||||
|
subtitle: "What Our Clients Say",
|
||||||
|
heading: "Immigration Success Stories",
|
||||||
|
buttonText: "View All Review",
|
||||||
|
buttonLink: "/contact",
|
||||||
|
buttonIcon: "fa-solid fa-arrow-right",
|
||||||
|
image: "",
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render("admin/pricing/index", {
|
||||||
|
layout: "layouts/main",
|
||||||
|
title: "Pricing Management",
|
||||||
|
data: pricing,
|
||||||
|
user: req.session.user,
|
||||||
|
frontendUrl: process.env.FRONTEND_URL || "http://localhost:3000",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading pricing admin page:", err);
|
||||||
|
req.flash("error", "Error loading pricing data");
|
||||||
|
res.redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update pricing data
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { hero, pricingSection, plans, testimonials } = req.body;
|
||||||
|
|
||||||
|
// Parse JSON strings if needed
|
||||||
|
const heroData = typeof hero === "string" ? JSON.parse(hero) : hero;
|
||||||
|
const pricingSectionData = typeof pricingSection === "string" ? JSON.parse(pricingSection) : pricingSection;
|
||||||
|
const plansData = typeof plans === "string" ? JSON.parse(plans) : plans;
|
||||||
|
const testimonialsData = typeof testimonials === "string" ? JSON.parse(testimonials) : testimonials;
|
||||||
|
|
||||||
|
let pricing = await Pricing.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (pricing) {
|
||||||
|
pricing.hero = heroData;
|
||||||
|
pricing.pricingSection = pricingSectionData;
|
||||||
|
pricing.plans = plansData;
|
||||||
|
pricing.testimonials = testimonialsData;
|
||||||
|
await pricing.save();
|
||||||
|
} else {
|
||||||
|
pricing = await Pricing.create({
|
||||||
|
name: "default",
|
||||||
|
hero: heroData,
|
||||||
|
pricingSection: pricingSectionData,
|
||||||
|
plans: plansData,
|
||||||
|
testimonials: testimonialsData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.flash("success", "Pricing data updated successfully");
|
||||||
|
res.redirect("/admin/pricing");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error updating pricing:", err);
|
||||||
|
req.flash("error", "Error updating pricing data");
|
||||||
|
res.redirect("/admin/pricing");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API to get pricing data (admin)
|
||||||
|
exports.getPricingData = async (req, res) => {
|
||||||
|
try {
|
||||||
|
let pricing = await Pricing.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (!pricing) {
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const jsonPath = path.join(__dirname, "../data/pricing.json");
|
||||||
|
|
||||||
|
if (fs.existsSync(jsonPath)) {
|
||||||
|
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
||||||
|
pricing = await Pricing.migrateFromJson(jsonData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: pricing,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error getting pricing data:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error loading pricing data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Public API to get pricing page data (for frontend)
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
let pricing = await Pricing.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (!pricing) {
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const jsonPath = path.join(__dirname, "../data/pricing.json");
|
||||||
|
|
||||||
|
if (fs.existsSync(jsonPath)) {
|
||||||
|
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
||||||
|
pricing = await Pricing.migrateFromJson(jsonData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pricing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Pricing data not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
|
||||||
|
|
||||||
|
const getFullUrl = (path) => {
|
||||||
|
if (!path || path.startsWith("http")) return path;
|
||||||
|
return `${backendUrl}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert to plain object to modify properties safely
|
||||||
|
const pricingData = pricing.toObject ? pricing.toObject() : pricing;
|
||||||
|
|
||||||
|
if (pricingData.hero) {
|
||||||
|
pricingData.hero.backgroundImage = getFullUrl(pricingData.hero.backgroundImage);
|
||||||
|
pricingData.hero.shapeImage = getFullUrl(pricingData.hero.shapeImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pricingData.testimonials) {
|
||||||
|
pricingData.testimonials.image = getFullUrl(pricingData.testimonials.image);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
hero: pricingData.hero,
|
||||||
|
pricingSection: pricingData.pricingSection,
|
||||||
|
plans: pricingData.plans,
|
||||||
|
testimonials: pricingData.testimonials,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error getting pricing API data:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error loading pricing data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
77
data/appointment.json
Normal file
77
data/appointment.json
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"title": "Make Appointment",
|
||||||
|
"backgroundImage": "/assets/img/inner-page/breadcrumb.jpg",
|
||||||
|
"subtitle": "About Our Consultancy",
|
||||||
|
"heading": "Want to meet us for your need?",
|
||||||
|
"description": "24/7 customer support is always ready to answer all your questions"
|
||||||
|
},
|
||||||
|
"visaOptions": [
|
||||||
|
"Canada Immigration",
|
||||||
|
"Tourist Visa",
|
||||||
|
"Medical Visa",
|
||||||
|
"Coaching",
|
||||||
|
"Student Visa",
|
||||||
|
"Spouse Visa",
|
||||||
|
"Job Opportunity",
|
||||||
|
"Exam"
|
||||||
|
],
|
||||||
|
"form": {
|
||||||
|
"heading": "Request Appointment",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"label": "Your Name",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "Your name",
|
||||||
|
"required": true,
|
||||||
|
"colClass": "col-lg-4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"label": "Your Email",
|
||||||
|
"type": "email",
|
||||||
|
"placeholder": "Your email",
|
||||||
|
"required": true,
|
||||||
|
"colClass": "col-lg-4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "phone",
|
||||||
|
"label": "Your Phone",
|
||||||
|
"type": "tel",
|
||||||
|
"placeholder": "Phone Number",
|
||||||
|
"required": false,
|
||||||
|
"colClass": "col-lg-4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "address",
|
||||||
|
"label": "Your Address",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "Your address",
|
||||||
|
"required": false,
|
||||||
|
"colClass": "col-lg-6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "appointmentDate",
|
||||||
|
"label": "Appointment Date",
|
||||||
|
"type": "date",
|
||||||
|
"placeholder": "",
|
||||||
|
"required": false,
|
||||||
|
"colClass": "col-lg-6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "message",
|
||||||
|
"label": "Your Message",
|
||||||
|
"type": "textarea",
|
||||||
|
"placeholder": "Type your message",
|
||||||
|
"required": false,
|
||||||
|
"colClass": "col-lg-12"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"submitButton": {
|
||||||
|
"text": "Request Appointment",
|
||||||
|
"icon": "fa-solid fa-arrow-right",
|
||||||
|
"buttonClass": "theme-btn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
data/blog.json
Normal file
142
data/blog.json
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
{
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"name": "Visa & Immigration",
|
||||||
|
"slug": "visa-immigration",
|
||||||
|
"description": "Tin tức và hướng dẫn về visa, định cư."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Study Abroad",
|
||||||
|
"slug": "study-abroad",
|
||||||
|
"description": "Kinh nghiệm du học, trường học, học bổng."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Travel Tips",
|
||||||
|
"slug": "travel-tips",
|
||||||
|
"description": "Mẹo du lịch, chuẩn bị hành lý, bảo hiểm."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "WorkVisa",
|
||||||
|
"slug": "work-visa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StudentVisa",
|
||||||
|
"slug": "student-visa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Canada",
|
||||||
|
"slug": "canada"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Scholarship",
|
||||||
|
"slug": "scholarship"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TravelSafety",
|
||||||
|
"slug": "travel-safety"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"title": "Ultimate Guide To Getting A Work Visa In Canada",
|
||||||
|
"slug": "ultimate-guide-work-visa-canada",
|
||||||
|
"excerpt": "Tổng hợp đầy đủ các bước xin work visa tại Canada cho người mới bắt đầu, từ điều kiện, hồ sơ đến thời gian xử lý.",
|
||||||
|
"content": "<p>Trong bài viết này, chúng ta sẽ đi qua từng bước cụ thể để xin work visa Canada, từ việc chuẩn bị hồ sơ, chọn chương trình phù hợp đến cách theo dõi tiến độ xử lý hồ sơ. Bạn cũng sẽ tìm thấy một số mẹo thực tế để tránh những sai lầm phổ biến.</p>",
|
||||||
|
"category": ["Visa & Immigration", "Canada"],
|
||||||
|
"tags": ["WorkVisa", "Canada"],
|
||||||
|
"author": "Admin",
|
||||||
|
"status": "published",
|
||||||
|
"publishedAt": "11 March 2025",
|
||||||
|
"isFeatured": true,
|
||||||
|
"featuredImage": "/uploads/blog/work-visa-canada-main.jpg",
|
||||||
|
"galleryImages": [
|
||||||
|
"/uploads/blog/work-visa-canada-1.jpg",
|
||||||
|
"/uploads/blog/work-visa-canada-2.jpg"
|
||||||
|
],
|
||||||
|
"commentsCount": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Top 5 Scholarship Programs For International Students",
|
||||||
|
"slug": "top-5-scholarship-programs-international-students",
|
||||||
|
"excerpt": "Danh sách 5 chương trình học bổng nổi bật dành cho sinh viên quốc tế với mức hỗ trợ hấp dẫn.",
|
||||||
|
"content": "<p>Nếu bạn đang tìm kiếm học bổng để giảm chi phí du học, đây là 5 chương trình bạn không nên bỏ qua. Mỗi chương trình đều có tiêu chí xét tuyển, mức hỗ trợ và thời hạn đăng ký khác nhau.</p>",
|
||||||
|
"category": ["Study Abroad"],
|
||||||
|
"tags": ["StudentVisa", "Scholarship"],
|
||||||
|
"author": "Admin",
|
||||||
|
"status": "published",
|
||||||
|
"publishedAt": "20 March 2025",
|
||||||
|
"isFeatured": false,
|
||||||
|
"featuredImage": "/uploads/blog/scholarship-programs-main.jpg",
|
||||||
|
"galleryImages": [],
|
||||||
|
"commentsCount": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "10 Travel Safety Tips You Should Know Before Flying",
|
||||||
|
"slug": "10-travel-safety-tips-before-flying",
|
||||||
|
"excerpt": "Những lưu ý quan trọng để đảm bảo an toàn cho chuyến bay và hành trình của bạn.",
|
||||||
|
"content": "<p>An toàn luôn là ưu tiên hàng đầu khi đi du lịch. Dưới đây là 10 tips giúp bạn yên tâm hơn trên mọi chuyến đi, từ việc chuẩn bị giấy tờ, bảo hiểm đến cách bảo vệ tài sản cá nhân.</p>",
|
||||||
|
"category": ["Travel Tips"],
|
||||||
|
"tags": ["TravelSafety"],
|
||||||
|
"author": "Admin",
|
||||||
|
"status": "published",
|
||||||
|
"publishedAt": "05 April 2025",
|
||||||
|
"isFeatured": false,
|
||||||
|
"featuredImage": "/uploads/blog/travel-safety-main.jpg",
|
||||||
|
"galleryImages": [
|
||||||
|
"/uploads/blog/travel-safety-1.jpg"
|
||||||
|
],
|
||||||
|
"commentsCount": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recentPosts": [
|
||||||
|
{
|
||||||
|
"title": "Ultimate Guide To Getting A Work Visa In Canada",
|
||||||
|
"slug": "ultimate-guide-work-visa-canada",
|
||||||
|
"thumbnail": "/uploads/blog/work-visa-canada-main.jpg",
|
||||||
|
"publishedAt": "11 March 2025"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Top 5 Scholarship Programs For International Students",
|
||||||
|
"slug": "top-5-scholarship-programs-international-students",
|
||||||
|
"thumbnail": "/uploads/blog/scholarship-programs-main.jpg",
|
||||||
|
"publishedAt": "20 March 2025"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "10 Travel Safety Tips You Should Know Before Flying",
|
||||||
|
"slug": "10-travel-safety-tips-before-flying",
|
||||||
|
"thumbnail": "/uploads/blog/travel-safety-main.jpg",
|
||||||
|
"publishedAt": "05 April 2025"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"comments": [
|
||||||
|
{
|
||||||
|
"postSlug": "ultimate-guide-work-visa-canada",
|
||||||
|
"authorName": "Frank Flores",
|
||||||
|
"authorAvatar": "/assets/img/inner-page/news-details/comment-1.png",
|
||||||
|
"content": "Bài viết rất hữu ích, cảm ơn bạn đã chia sẻ!",
|
||||||
|
"createdAt": "February 10, 2024",
|
||||||
|
"status": "approved",
|
||||||
|
"parentAuthorName": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"postSlug": "ultimate-guide-work-visa-canada",
|
||||||
|
"authorName": "Courtney Henry",
|
||||||
|
"authorAvatar": "/assets/img/inner-page/news-details/comment-2.png",
|
||||||
|
"content": "Mình đã làm theo hướng dẫn và hồ sơ được duyệt nhanh hơn hẳn.",
|
||||||
|
"createdAt": "February 12, 2024",
|
||||||
|
"status": "approved",
|
||||||
|
"parentAuthorName": "Frank Flores"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"postSlug": "10-travel-safety-tips-before-flying",
|
||||||
|
"authorName": "Jenny Wilson",
|
||||||
|
"authorAvatar": "/assets/img/inner-page/news-details/comment-3.png",
|
||||||
|
"content": "Những tip này rất thực tế, đặc biệt là phần chuẩn bị bảo hiểm!",
|
||||||
|
"createdAt": "March 02, 2024",
|
||||||
|
"status": "approved",
|
||||||
|
"parentAuthorName": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
{
|
|
||||||
"hero": {
|
|
||||||
"title": "Contact Us",
|
|
||||||
"backgroundImage": "/uploads/banner/b10.jpg",
|
|
||||||
"overlayColor": "rgba(0, 0, 0, 0)",
|
|
||||||
"sectionClass": "uk-section-secondary uk-section-overlap uk-preserve-color uk-light",
|
|
||||||
"titleClass": "uk-heading-large uk-text-center !text-[5vw]",
|
|
||||||
"enableScrollspy": true,
|
|
||||||
"backgroundPosition": "top-center"
|
|
||||||
},
|
|
||||||
"contactCards": [
|
|
||||||
{
|
|
||||||
"type": "phone",
|
|
||||||
"title": "Phone Number",
|
|
||||||
"content": ["+123456789"],
|
|
||||||
"iconType": "fas fa-phone",
|
|
||||||
"iconSource": "fontawesome"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "email",
|
|
||||||
"title": "Email Address",
|
|
||||||
"content": ["office@ggcamp.org"],
|
|
||||||
"iconType": "fas fa-envelope",
|
|
||||||
"iconSource": "fontawesome"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "location",
|
|
||||||
"title": "Our Location",
|
|
||||||
"content": ["Poblacion, Madridejos 22, Cebu City, Philippines"],
|
|
||||||
"iconType": "fas fa-map-marker-alt",
|
|
||||||
"iconSource": "fontawesome"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "hours",
|
|
||||||
"title": "Working hours",
|
|
||||||
"content": ["Monday to Saturday: 07pm - 05am", "Sunday: Closed"],
|
|
||||||
"iconType": "fas fa-clock",
|
|
||||||
"iconSource": "fontawesome"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"map": {
|
|
||||||
"coordinates": {
|
|
||||||
"lat": 10.3157,
|
|
||||||
"lng": 123.8854
|
|
||||||
},
|
|
||||||
"zoom": 15,
|
|
||||||
"location": "Poblacion, Madridejos 22, Cebu City, Philippines",
|
|
||||||
"markerTitle": "Our Office",
|
|
||||||
"tileLayer": {
|
|
||||||
"url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
||||||
"attribution": "© <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors",
|
|
||||||
"maxZoom": 18,
|
|
||||||
"minZoom": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"form": {
|
|
||||||
"sectionLabel": "Contact Us",
|
|
||||||
"heading": "Let's plan your dream adventure - contact our team today",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"name": "firstName",
|
|
||||||
"type": "text",
|
|
||||||
"placeholder": "First name",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "lastName",
|
|
||||||
"type": "text",
|
|
||||||
"placeholder": "Last name",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "phone",
|
|
||||||
"type": "tel",
|
|
||||||
"placeholder": "Phone Number",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "email",
|
|
||||||
"type": "email",
|
|
||||||
"placeholder": "Email Address",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "message",
|
|
||||||
"type": "textarea",
|
|
||||||
"placeholder": "Send Message",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"submitButton": {
|
|
||||||
"text": "Send Message"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
119
data/contact.json
Normal file
119
data/contact.json
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"title": "CONTACT US",
|
||||||
|
"backgroundImage": "/assets/img/inner-page/breadcrumb.jpg",
|
||||||
|
"overlayColor": "rgba(0, 0, 0, 0)",
|
||||||
|
"sectionClass": "breadcrumb-wrapper fix bg-cover",
|
||||||
|
"titleClass": "breadcrumb-title",
|
||||||
|
"enableScrollspy": false,
|
||||||
|
"backgroundPosition": "center"
|
||||||
|
},
|
||||||
|
"contactCards": [
|
||||||
|
{
|
||||||
|
"type": "location",
|
||||||
|
"title": "Location",
|
||||||
|
"content": [
|
||||||
|
"43 Sardinella, 3nd Land Walk,",
|
||||||
|
"Orchard view, London, UK"
|
||||||
|
],
|
||||||
|
"iconType": "fa-solid fa-location-dot",
|
||||||
|
"iconSource": "fontawesome"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "email",
|
||||||
|
"title": "Email Address",
|
||||||
|
"content": [
|
||||||
|
"supportinfo@gmail.com",
|
||||||
|
"arluxhotelinfo.com"
|
||||||
|
],
|
||||||
|
"iconType": "fa-solid fa-envelope",
|
||||||
|
"iconSource": "fontawesome"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "phone",
|
||||||
|
"title": "Phone Number",
|
||||||
|
"content": [
|
||||||
|
"+880 123 427 00",
|
||||||
|
"+000 938 809 12"
|
||||||
|
],
|
||||||
|
"iconType": "fa-solid fa-phone",
|
||||||
|
"iconSource": "fontawesome"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"map": {
|
||||||
|
"coordinates": {
|
||||||
|
"lat": -37.81450084255415,
|
||||||
|
"lng": 144.9618311901502
|
||||||
|
},
|
||||||
|
"zoom": 15,
|
||||||
|
"location": "Envato, Melbourne, Australia",
|
||||||
|
"markerTitle": "Our Office",
|
||||||
|
"embedUrl": "https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d6678.7619084840835!2d144.9618311901502!3d-37.81450084255415!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x6ad642b4758afc1d%3A0x3119cc820fdfc62e!2sEnvato!5e0!3m2!1sen!2sbd!4v1641984054261!5m2!1sen!2sbd",
|
||||||
|
"tileLayer": {
|
||||||
|
"url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
"attribution": "",
|
||||||
|
"maxZoom": 18,
|
||||||
|
"minZoom": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"sectionLabel": "",
|
||||||
|
"heading": "Send Us Message",
|
||||||
|
"description": "Have questions about visas or immigration? Send us a message today and our expert team will respond quickly.",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"label": "Your Name",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "Your name",
|
||||||
|
"required": true,
|
||||||
|
"colClass": "col-lg-4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"label": "Your Email",
|
||||||
|
"type": "email",
|
||||||
|
"placeholder": "Your email",
|
||||||
|
"required": true,
|
||||||
|
"colClass": "col-lg-4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "phone",
|
||||||
|
"label": "Your Phone",
|
||||||
|
"type": "tel",
|
||||||
|
"placeholder": "Phone Number",
|
||||||
|
"required": true,
|
||||||
|
"colClass": "col-lg-4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "address",
|
||||||
|
"label": "Your Address",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "Address Now",
|
||||||
|
"required": false,
|
||||||
|
"colClass": "col-lg-6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "date",
|
||||||
|
"label": "Your Date",
|
||||||
|
"type": "date",
|
||||||
|
"placeholder": "Date",
|
||||||
|
"required": false,
|
||||||
|
"colClass": "col-lg-6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "message",
|
||||||
|
"label": "Your Message",
|
||||||
|
"type": "textarea",
|
||||||
|
"placeholder": "Type your message",
|
||||||
|
"required": false,
|
||||||
|
"colClass": "col-lg-12"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"submitButton": {
|
||||||
|
"text": "SEND MESSAGE",
|
||||||
|
"icon": "fa-solid fa-arrow-right",
|
||||||
|
"buttonClass": "theme-btn style-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
data/pricing.json
Normal file
118
data/pricing.json
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"title": "Pricing Plan",
|
||||||
|
"backgroundImage": "/assets/img/inner-page/breadcrumb.jpg",
|
||||||
|
"shapeImage": "/assets/img/inner-page/shape.png",
|
||||||
|
"breadcrumb": [
|
||||||
|
{
|
||||||
|
"text": "Home",
|
||||||
|
"link": "/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Pricing Plan",
|
||||||
|
"link": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pricingSection": {
|
||||||
|
"subtitle": "pricing plan",
|
||||||
|
"heading": "Flexible Plans to Suit Every Traveler",
|
||||||
|
"description": "Choose the plan that fits your visa needs and enjoy expert guidance every step of the way."
|
||||||
|
},
|
||||||
|
"plans": {
|
||||||
|
"monthly": [
|
||||||
|
{
|
||||||
|
"name": "Basic Plan",
|
||||||
|
"price": "32",
|
||||||
|
"period": "mo",
|
||||||
|
"currency": "$",
|
||||||
|
"buttonText": "Get Started Today",
|
||||||
|
"buttonLink": "/pricing",
|
||||||
|
"buttonIcon": "fa-solid fa-arrow-right",
|
||||||
|
"style": "default",
|
||||||
|
"features": [
|
||||||
|
"Everything in Basic Plan",
|
||||||
|
"Visa Interview Preparation",
|
||||||
|
"Priority Processing Support",
|
||||||
|
"Phone & Email Assistance",
|
||||||
|
"Step-by-Step Application Support"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Premium Plan",
|
||||||
|
"price": "32",
|
||||||
|
"period": "mo",
|
||||||
|
"currency": "$",
|
||||||
|
"buttonText": "Get Started Today",
|
||||||
|
"buttonLink": "/pricing",
|
||||||
|
"buttonIcon": "fa-solid fa-arrow-right",
|
||||||
|
"style": "style-2",
|
||||||
|
"features": [
|
||||||
|
"Everything in Basic Plan",
|
||||||
|
"Visa Interview Preparation",
|
||||||
|
"Priority Processing Support",
|
||||||
|
"Phone & Email Assistance",
|
||||||
|
"Step-by-Step Application Support"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"yearly": [
|
||||||
|
{
|
||||||
|
"name": "Basic Plan",
|
||||||
|
"price": "32",
|
||||||
|
"period": "mo",
|
||||||
|
"currency": "$",
|
||||||
|
"buttonText": "Get Started Today",
|
||||||
|
"buttonLink": "/pricing",
|
||||||
|
"buttonIcon": "fa-solid fa-arrow-right",
|
||||||
|
"style": "default",
|
||||||
|
"features": [
|
||||||
|
"Everything in Basic Plan",
|
||||||
|
"Visa Interview Preparation",
|
||||||
|
"Priority Processing Support",
|
||||||
|
"Phone & Email Assistance",
|
||||||
|
"Step-by-Step Application Support"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Premium Plan",
|
||||||
|
"price": "32",
|
||||||
|
"period": "mo",
|
||||||
|
"currency": "$",
|
||||||
|
"buttonText": "Get Started Today",
|
||||||
|
"buttonLink": "/pricing",
|
||||||
|
"buttonIcon": "fa-solid fa-arrow-right",
|
||||||
|
"style": "style-2",
|
||||||
|
"features": [
|
||||||
|
"Everything in Basic Plan",
|
||||||
|
"Visa Interview Preparation",
|
||||||
|
"Priority Processing Support",
|
||||||
|
"Phone & Email Assistance",
|
||||||
|
"Step-by-Step Application Support"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"testimonials": {
|
||||||
|
"subtitle": "What Our Clients Say",
|
||||||
|
"heading": "Immigration Success Stories",
|
||||||
|
"buttonText": "View All Review",
|
||||||
|
"buttonLink": "/contact",
|
||||||
|
"buttonIcon": "fa-solid fa-arrow-right",
|
||||||
|
"image": "/assets/img/home-3/test-thumb.jpg",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Mohammed Ali",
|
||||||
|
"role": "Family Visa",
|
||||||
|
"rating": 5,
|
||||||
|
"content": "The team provided exceptional guidance throughout my immigration process. Their expertise, personalized support, and attention to detail ensured a smooth, stress-free experience and successful visa approval."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mohammed Ali",
|
||||||
|
"role": "Family Visa",
|
||||||
|
"rating": 5,
|
||||||
|
"content": "The team provided exceptional guidance throughout my immigration process. Their expertise, personalized support, and attention to detail ensured a smooth, stress-free experience and successful visa approval."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
206
models/appointment.js
Normal file
206
models/appointment.js
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
// Clear cache
|
||||||
|
if (mongoose.models.Appointment) {
|
||||||
|
delete mongoose.models.Appointment;
|
||||||
|
}
|
||||||
|
if (mongoose.connection.models.Appointment) {
|
||||||
|
delete mongoose.connection.models.Appointment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema cho hero section
|
||||||
|
const heroSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "Make Appointment",
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho form field
|
||||||
|
const formFieldSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
enum: ["text", "email", "tel", "textarea", "date", "select"],
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
colClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "col-lg-12",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho submit button
|
||||||
|
const submitButtonSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
default: "Request Appointment",
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "fa-solid fa-arrow-right",
|
||||||
|
},
|
||||||
|
buttonClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "theme-btn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho form
|
||||||
|
const formSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
heading: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "Request Appointment",
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
type: [formFieldSchema],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
type: submitButtonSchema,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Main Appointment Schema
|
||||||
|
const appointmentSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: "default",
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
type: heroSchema,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
visaOptions: {
|
||||||
|
type: [String],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
type: formSchema,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Migration method to import data from JSON
|
||||||
|
appointmentSchema.statics.migrateFromJson = async function (jsonData) {
|
||||||
|
try {
|
||||||
|
// Check if default appointment exists
|
||||||
|
const existingAppointment = await this.findOne({ name: "default" });
|
||||||
|
|
||||||
|
// Process data from JSON
|
||||||
|
const processedData = {
|
||||||
|
hero: {
|
||||||
|
title: jsonData.hero?.title || "Make Appointment",
|
||||||
|
backgroundImage: jsonData.hero?.backgroundImage || "",
|
||||||
|
subtitle: jsonData.hero?.subtitle || "",
|
||||||
|
heading: jsonData.hero?.heading || "",
|
||||||
|
description: jsonData.hero?.description || "",
|
||||||
|
},
|
||||||
|
visaOptions: Array.isArray(jsonData.visaOptions) ? jsonData.visaOptions : [],
|
||||||
|
form: {
|
||||||
|
heading: jsonData.form?.heading || "Request Appointment",
|
||||||
|
fields: (jsonData.form?.fields || []).map((field) => ({
|
||||||
|
name: field.name || "",
|
||||||
|
label: field.label || "",
|
||||||
|
type: field.type || "text",
|
||||||
|
placeholder: field.placeholder || "",
|
||||||
|
required: field.required || false,
|
||||||
|
colClass: field.colClass || "col-lg-12",
|
||||||
|
})),
|
||||||
|
submitButton: {
|
||||||
|
text: jsonData.form?.submitButton?.text || "Request Appointment",
|
||||||
|
icon: jsonData.form?.submitButton?.icon || "fa-solid fa-arrow-right",
|
||||||
|
buttonClass: jsonData.form?.submitButton?.buttonClass || "theme-btn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingAppointment) {
|
||||||
|
// Update existing appointment
|
||||||
|
existingAppointment.hero = processedData.hero;
|
||||||
|
existingAppointment.visaOptions = processedData.visaOptions;
|
||||||
|
existingAppointment.form = processedData.form;
|
||||||
|
await existingAppointment.save();
|
||||||
|
console.log("Appointment data updated successfully");
|
||||||
|
return existingAppointment;
|
||||||
|
} else {
|
||||||
|
// Create new appointment
|
||||||
|
const newAppointment = await this.create({
|
||||||
|
name: "default",
|
||||||
|
...processedData,
|
||||||
|
});
|
||||||
|
console.log("Appointment data imported successfully");
|
||||||
|
return newAppointment;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error migrating appointment data:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model("Appointment", appointmentSchema);
|
||||||
83
models/appointmentSubmission.js
Normal file
83
models/appointmentSubmission.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for Appointment Submissions
|
||||||
|
* Stores appointment requests from users
|
||||||
|
*/
|
||||||
|
const appointmentSubmissionSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: [true, "Name is required"],
|
||||||
|
trim: true,
|
||||||
|
maxlength: [100, "Name cannot exceed 100 characters"],
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
required: [true, "Email is required"],
|
||||||
|
trim: true,
|
||||||
|
lowercase: true,
|
||||||
|
match: [/^\S+@\S+\.\S+$/, "Please enter a valid email"],
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
appointmentDate: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
visaTypes: {
|
||||||
|
type: [String],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: ["pending", "confirmed", "completed", "cancelled"],
|
||||||
|
default: "pending",
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
confirmedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
completedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
ipAddress: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
userAgent: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Index for faster queries
|
||||||
|
appointmentSubmissionSchema.index({ status: 1, createdAt: -1 });
|
||||||
|
appointmentSubmissionSchema.index({ email: 1 });
|
||||||
|
appointmentSubmissionSchema.index({ appointmentDate: 1 });
|
||||||
|
|
||||||
|
module.exports = mongoose.model("AppointmentSubmission", appointmentSubmissionSchema);
|
||||||
135
models/blog.js
Normal file
135
models/blog.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const blogSchema = new mongoose.Schema({
|
||||||
|
// Basic blog information
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
slug: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
excerpt: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
maxlength: 500
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: mongoose.Schema.Types.Mixed, // Có thể là string HTML hoặc JSON EditorJS
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Media
|
||||||
|
featuredImage: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
galleryImages: [{
|
||||||
|
type: String
|
||||||
|
}], // Mảng URL ảnh cho gallery (details-2, details-3)
|
||||||
|
|
||||||
|
// Author and publishing
|
||||||
|
author: {
|
||||||
|
type: String,
|
||||||
|
default: 'Admin'
|
||||||
|
},
|
||||||
|
publishedAt: {
|
||||||
|
type: String, // Format: "11 March 2025"
|
||||||
|
default: function() {
|
||||||
|
return new Date().toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Categorization (simple strings, no references)
|
||||||
|
category: [{
|
||||||
|
type: String // ["Visa", "Travel", ...] - Một bài có thể thuộc nhiều category
|
||||||
|
}],
|
||||||
|
tags: [{
|
||||||
|
type: String // ["WorkVisa", "StudentVisa", ...]
|
||||||
|
}],
|
||||||
|
|
||||||
|
// Status and features
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: ['draft', 'published'],
|
||||||
|
default: 'published'
|
||||||
|
},
|
||||||
|
isFeatured: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
|
||||||
|
// Comments count (có thể fake trước)
|
||||||
|
commentsCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
blogSchema.index({ slug: 1 });
|
||||||
|
blogSchema.index({ status: 1, createdAt: -1 });
|
||||||
|
blogSchema.index({ category: 1, status: 1 });
|
||||||
|
blogSchema.index({ isFeatured: 1, status: 1 });
|
||||||
|
blogSchema.index({ tags: 1, status: 1 });
|
||||||
|
|
||||||
|
// Remove __v from JSON output
|
||||||
|
blogSchema.set('toJSON', {
|
||||||
|
transform: function(doc, ret) {
|
||||||
|
delete ret.__v;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-save middleware
|
||||||
|
blogSchema.pre('save', function(next) {
|
||||||
|
// Auto-generate slug if not provided
|
||||||
|
if (!this.slug && this.title) {
|
||||||
|
this.slug = this.title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.trim('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Static methods
|
||||||
|
blogSchema.statics.getPublished = function() {
|
||||||
|
return this.find({ status: 'published' }).sort({ createdAt: -1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
blogSchema.statics.getFeatured = function() {
|
||||||
|
return this.find({
|
||||||
|
status: 'published',
|
||||||
|
isFeatured: true
|
||||||
|
}).sort({ createdAt: -1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
blogSchema.statics.getByCategory = function(category) {
|
||||||
|
return this.find({
|
||||||
|
status: 'published',
|
||||||
|
category: { $in: [category] } // Tìm trong array categories
|
||||||
|
}).sort({ createdAt: -1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
blogSchema.statics.getByTag = function(tag) {
|
||||||
|
return this.find({
|
||||||
|
status: 'published',
|
||||||
|
tags: tag
|
||||||
|
}).sort({ createdAt: -1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Blog', blogSchema);
|
||||||
76
models/blogCategory.js
Normal file
76
models/blogCategory.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const blogCategorySchema = new mongoose.Schema({
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
unique: true // "Permanent Residency (PR)"
|
||||||
|
},
|
||||||
|
slug: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
trim: true // "permanent-residency"
|
||||||
|
},
|
||||||
|
postCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0 // "(04)"
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
blogCategorySchema.index({ slug: 1 });
|
||||||
|
blogCategorySchema.index({ isActive: 1, name: 1 });
|
||||||
|
|
||||||
|
// Remove __v from JSON output
|
||||||
|
blogCategorySchema.set('toJSON', {
|
||||||
|
transform: function(doc, ret) {
|
||||||
|
delete ret.__v;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-save middleware
|
||||||
|
blogCategorySchema.pre('save', function(next) {
|
||||||
|
// Auto-generate slug if not provided
|
||||||
|
if (!this.slug && this.name) {
|
||||||
|
this.slug = this.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.trim('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Static methods
|
||||||
|
blogCategorySchema.statics.getActive = function() {
|
||||||
|
return this.find({ isActive: true }).sort({ name: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method to update post count
|
||||||
|
blogCategorySchema.methods.updatePostCount = async function() {
|
||||||
|
const Blog = require('./blog');
|
||||||
|
const count = await Blog.countDocuments({
|
||||||
|
category: { $in: [this.name] }, // Tìm trong array categories
|
||||||
|
status: 'published'
|
||||||
|
});
|
||||||
|
this.postCount = count;
|
||||||
|
await this.save();
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model('BlogCategory', blogCategorySchema);
|
||||||
84
models/blogComment.js
Normal file
84
models/blogComment.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const blogCommentSchema = new mongoose.Schema({
|
||||||
|
postId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Blog',
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
authorName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true // "Frank Flores"
|
||||||
|
},
|
||||||
|
authorAvatar: {
|
||||||
|
type: String,
|
||||||
|
default: '' // "/assets/img/inner-page/news-details/comment-1.png"
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
parentId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'BlogComment',
|
||||||
|
default: null // Cho threaded comments (reply)
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: ['pending', 'approved', 'rejected'],
|
||||||
|
default: 'pending'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timestamps: true // Vẫn giữ timestamps cho admin quản lý
|
||||||
|
});
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
blogCommentSchema.index({ postId: 1, status: 1, createdAt: -1 });
|
||||||
|
blogCommentSchema.index({ parentId: 1 });
|
||||||
|
blogCommentSchema.index({ status: 1 });
|
||||||
|
|
||||||
|
// Remove __v from JSON output
|
||||||
|
blogCommentSchema.set('toJSON', {
|
||||||
|
transform: function(doc, ret) {
|
||||||
|
delete ret.__v;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Static methods
|
||||||
|
blogCommentSchema.statics.getApprovedByPost = function(postId) {
|
||||||
|
return this.find({
|
||||||
|
postId: postId,
|
||||||
|
status: 'approved',
|
||||||
|
parentId: null // Chỉ lấy comments gốc, không lấy replies
|
||||||
|
}).sort({ createdAt: -1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
blogCommentSchema.statics.getReplies = function(parentId) {
|
||||||
|
return this.find({
|
||||||
|
parentId: parentId,
|
||||||
|
status: 'approved'
|
||||||
|
}).sort({ createdAt: 1 }); // Replies sắp xếp theo thời gian tăng dần
|
||||||
|
};
|
||||||
|
|
||||||
|
blogCommentSchema.statics.getByStatus = function(status) {
|
||||||
|
return this.find({ status: status })
|
||||||
|
.populate('postId', 'title slug')
|
||||||
|
.sort({ createdAt: -1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method to approve comment
|
||||||
|
blogCommentSchema.methods.approve = function() {
|
||||||
|
this.status = 'approved';
|
||||||
|
return this.save();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method to reject comment
|
||||||
|
blogCommentSchema.methods.reject = function() {
|
||||||
|
this.status = 'rejected';
|
||||||
|
return this.save();
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model('BlogComment', blogCommentSchema);
|
||||||
78
models/blogTag.js
Normal file
78
models/blogTag.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const blogTagSchema = new mongoose.Schema({
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
unique: true // "WorkVisa"
|
||||||
|
},
|
||||||
|
slug: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
trim: true // "work-visa"
|
||||||
|
},
|
||||||
|
postCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
blogTagSchema.index({ slug: 1 });
|
||||||
|
blogTagSchema.index({ isActive: 1, name: 1 });
|
||||||
|
|
||||||
|
// Remove __v from JSON output
|
||||||
|
blogTagSchema.set('toJSON', {
|
||||||
|
transform: function(doc, ret) {
|
||||||
|
delete ret.__v;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-save middleware
|
||||||
|
blogTagSchema.pre('save', function(next) {
|
||||||
|
// Auto-generate slug if not provided
|
||||||
|
if (!this.slug && this.name) {
|
||||||
|
this.slug = this.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.trim('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Static methods
|
||||||
|
blogTagSchema.statics.getActive = function() {
|
||||||
|
return this.find({ isActive: true }).sort({ name: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
blogTagSchema.statics.getPopular = function(limit = 10) {
|
||||||
|
return this.find({ isActive: true })
|
||||||
|
.sort({ postCount: -1, name: 1 })
|
||||||
|
.limit(limit);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method to update post count
|
||||||
|
blogTagSchema.methods.updatePostCount = async function() {
|
||||||
|
const Blog = require('./blog');
|
||||||
|
const count = await Blog.countDocuments({
|
||||||
|
tags: { $in: [this.name] }, // Tìm trong array tags
|
||||||
|
status: 'published'
|
||||||
|
});
|
||||||
|
this.postCount = count;
|
||||||
|
await this.save();
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model('BlogTag', blogTagSchema);
|
||||||
@@ -145,6 +145,11 @@ const mapSchema = new mongoose.Schema(
|
|||||||
trim: true,
|
trim: true,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
|
embedUrl: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
tileLayer: {
|
tileLayer: {
|
||||||
type: tileLayerSchema,
|
type: tileLayerSchema,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -161,11 +166,16 @@ const formFieldSchema = new mongoose.Schema(
|
|||||||
required: true,
|
required: true,
|
||||||
trim: true,
|
trim: true,
|
||||||
},
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
type: {
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
trim: true,
|
trim: true,
|
||||||
enum: ["text", "email", "tel", "textarea", "programme"],
|
enum: ["text", "email", "tel", "textarea", "programme", "date"],
|
||||||
},
|
},
|
||||||
placeholder: {
|
placeholder: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -176,6 +186,11 @@ const formFieldSchema = new mongoose.Schema(
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
colClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "col-lg-12",
|
||||||
|
},
|
||||||
programmeName: {
|
programmeName: {
|
||||||
type: String,
|
type: String,
|
||||||
trim: true,
|
trim: true,
|
||||||
@@ -193,6 +208,16 @@ const submitButtonSchema = new mongoose.Schema(
|
|||||||
required: true,
|
required: true,
|
||||||
trim: true,
|
trim: true,
|
||||||
},
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "fa-solid fa-arrow-right",
|
||||||
|
},
|
||||||
|
buttonClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "theme-btn style-2",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
);
|
);
|
||||||
@@ -210,6 +235,11 @@ const formSchema = new mongoose.Schema(
|
|||||||
trim: true,
|
trim: true,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
fields: {
|
fields: {
|
||||||
type: [formFieldSchema],
|
type: [formFieldSchema],
|
||||||
default: [],
|
default: [],
|
||||||
@@ -335,6 +365,7 @@ contactSchema.statics.migrateFromJson = async function (jsonData) {
|
|||||||
zoom: jsonData.map?.zoom || 15,
|
zoom: jsonData.map?.zoom || 15,
|
||||||
location: jsonData.map?.location || "",
|
location: jsonData.map?.location || "",
|
||||||
markerTitle: jsonData.map?.markerTitle || "",
|
markerTitle: jsonData.map?.markerTitle || "",
|
||||||
|
embedUrl: jsonData.map?.embedUrl || "",
|
||||||
tileLayer: {
|
tileLayer: {
|
||||||
url:
|
url:
|
||||||
jsonData.map?.tileLayer?.url ||
|
jsonData.map?.tileLayer?.url ||
|
||||||
@@ -347,15 +378,20 @@ contactSchema.statics.migrateFromJson = async function (jsonData) {
|
|||||||
form: {
|
form: {
|
||||||
sectionLabel: jsonData.form?.sectionLabel || "",
|
sectionLabel: jsonData.form?.sectionLabel || "",
|
||||||
heading: jsonData.form?.heading || "",
|
heading: jsonData.form?.heading || "",
|
||||||
|
description: jsonData.form?.description || "",
|
||||||
fields: (jsonData.form?.fields || []).map((field) => ({
|
fields: (jsonData.form?.fields || []).map((field) => ({
|
||||||
name: field.name || "",
|
name: field.name || "",
|
||||||
|
label: field.label || "",
|
||||||
type: field.type || "text",
|
type: field.type || "text",
|
||||||
placeholder: field.placeholder || "",
|
placeholder: field.placeholder || "",
|
||||||
required: field.required || false,
|
required: field.required || false,
|
||||||
|
colClass: field.colClass || "col-lg-12",
|
||||||
programmeName: field.programmeName || "",
|
programmeName: field.programmeName || "",
|
||||||
})),
|
})),
|
||||||
submitButton: {
|
submitButton: {
|
||||||
text: jsonData.form?.submitButton?.text || "Send Message",
|
text: jsonData.form?.submitButton?.text || "Send Message",
|
||||||
|
icon: jsonData.form?.submitButton?.icon || "fa-solid fa-arrow-right",
|
||||||
|
buttonClass: jsonData.form?.submitButton?.buttonClass || "theme-btn style-2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
74
models/contactSubmission.js
Normal file
74
models/contactSubmission.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for Contact Form Submissions
|
||||||
|
* Stores user inquiries from the contact form
|
||||||
|
*/
|
||||||
|
const contactSubmissionSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: [true, "Name is required"],
|
||||||
|
trim: true,
|
||||||
|
maxlength: [100, "Name cannot exceed 100 characters"],
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
required: [true, "Email is required"],
|
||||||
|
trim: true,
|
||||||
|
lowercase: true,
|
||||||
|
match: [/^\S+@\S+\.\S+$/, "Please enter a valid email"],
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: ["pending", "read", "replied", "archived"],
|
||||||
|
default: "pending",
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
repliedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
ipAddress: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
userAgent: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Index for faster queries
|
||||||
|
contactSubmissionSchema.index({ status: 1, createdAt: -1 });
|
||||||
|
contactSubmissionSchema.index({ email: 1 });
|
||||||
|
|
||||||
|
module.exports = mongoose.model("ContactSubmission", contactSubmissionSchema);
|
||||||
328
models/pricing.js
Normal file
328
models/pricing.js
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
// Clear cache
|
||||||
|
if (mongoose.models.Pricing) {
|
||||||
|
delete mongoose.models.Pricing;
|
||||||
|
}
|
||||||
|
if (mongoose.connection.models.Pricing) {
|
||||||
|
delete mongoose.connection.models.Pricing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema for breadcrumb item
|
||||||
|
const breadcrumbItemSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema for hero section
|
||||||
|
const heroSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "Pricing Plan",
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "/assets/img/inner-page/breadcrumb.jpg",
|
||||||
|
},
|
||||||
|
shapeImage: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "/assets/img/inner-page/shape.png",
|
||||||
|
},
|
||||||
|
breadcrumb: {
|
||||||
|
type: [breadcrumbItemSchema],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema for pricing section header
|
||||||
|
const pricingSectionSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
subtitle: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "pricing plan",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "Flexible Plans to Suit Every Traveler",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema for individual plan
|
||||||
|
const planSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "0",
|
||||||
|
},
|
||||||
|
period: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "mo",
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "$",
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "Get Started Today",
|
||||||
|
},
|
||||||
|
buttonLink: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "/pricing",
|
||||||
|
},
|
||||||
|
buttonIcon: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "fa-solid fa-arrow-right",
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
enum: ["default", "style-2"],
|
||||||
|
default: "default",
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
type: [String],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema for plans container
|
||||||
|
const plansSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
monthly: {
|
||||||
|
type: [planSchema],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
yearly: {
|
||||||
|
type: [planSchema],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema for testimonial item
|
||||||
|
const testimonialItemSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
rating: {
|
||||||
|
type: Number,
|
||||||
|
min: 1,
|
||||||
|
max: 5,
|
||||||
|
default: 5,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema for testimonials section
|
||||||
|
const testimonialsSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
subtitle: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "What Our Clients Say",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "Immigration Success Stories",
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "View All Review",
|
||||||
|
},
|
||||||
|
buttonLink: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "/contact",
|
||||||
|
},
|
||||||
|
buttonIcon: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "fa-solid fa-arrow-right",
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: [testimonialItemSchema],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Main Pricing Schema
|
||||||
|
const pricingSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: "default",
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
type: heroSchema,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
pricingSection: {
|
||||||
|
type: pricingSectionSchema,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
plans: {
|
||||||
|
type: plansSchema,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
testimonials: {
|
||||||
|
type: testimonialsSchema,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Migration method to import data from JSON
|
||||||
|
pricingSchema.statics.migrateFromJson = async function (jsonData) {
|
||||||
|
try {
|
||||||
|
// Check if default pricing exists
|
||||||
|
const existingPricing = await this.findOne({ name: "default" });
|
||||||
|
|
||||||
|
// Process data from JSON
|
||||||
|
const processedData = {
|
||||||
|
hero: {
|
||||||
|
title: jsonData.hero?.title || "Pricing Plan",
|
||||||
|
backgroundImage: jsonData.hero?.backgroundImage || "/assets/img/inner-page/breadcrumb.jpg",
|
||||||
|
shapeImage: jsonData.hero?.shapeImage || "/assets/img/inner-page/shape.png",
|
||||||
|
breadcrumb: (jsonData.hero?.breadcrumb || []).map((item) => ({
|
||||||
|
text: item.text || "",
|
||||||
|
link: item.link || "",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
pricingSection: {
|
||||||
|
subtitle: jsonData.pricingSection?.subtitle || "pricing plan",
|
||||||
|
heading: jsonData.pricingSection?.heading || "Flexible Plans to Suit Every Traveler",
|
||||||
|
description: jsonData.pricingSection?.description || "",
|
||||||
|
},
|
||||||
|
plans: {
|
||||||
|
monthly: (jsonData.plans?.monthly || []).map((plan) => ({
|
||||||
|
name: plan.name || "",
|
||||||
|
price: plan.price || "0",
|
||||||
|
period: plan.period || "mo",
|
||||||
|
currency: plan.currency || "$",
|
||||||
|
buttonText: plan.buttonText || "Get Started Today",
|
||||||
|
buttonLink: plan.buttonLink || "/pricing",
|
||||||
|
buttonIcon: plan.buttonIcon || "fa-solid fa-arrow-right",
|
||||||
|
style: plan.style || "default",
|
||||||
|
features: plan.features || [],
|
||||||
|
})),
|
||||||
|
yearly: (jsonData.plans?.yearly || []).map((plan) => ({
|
||||||
|
name: plan.name || "",
|
||||||
|
price: plan.price || "0",
|
||||||
|
period: plan.period || "mo",
|
||||||
|
currency: plan.currency || "$",
|
||||||
|
buttonText: plan.buttonText || "Get Started Today",
|
||||||
|
buttonLink: plan.buttonLink || "/pricing",
|
||||||
|
buttonIcon: plan.buttonIcon || "fa-solid fa-arrow-right",
|
||||||
|
style: plan.style || "default",
|
||||||
|
features: plan.features || [],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
testimonials: {
|
||||||
|
subtitle: jsonData.testimonials?.subtitle || "What Our Clients Say",
|
||||||
|
heading: jsonData.testimonials?.heading || "Immigration Success Stories",
|
||||||
|
buttonText: jsonData.testimonials?.buttonText || "View All Review",
|
||||||
|
buttonLink: jsonData.testimonials?.buttonLink || "/contact",
|
||||||
|
buttonIcon: jsonData.testimonials?.buttonIcon || "fa-solid fa-arrow-right",
|
||||||
|
image: jsonData.testimonials?.image || "",
|
||||||
|
items: (jsonData.testimonials?.items || []).map((item) => ({
|
||||||
|
name: item.name || "",
|
||||||
|
role: item.role || "",
|
||||||
|
rating: item.rating || 5,
|
||||||
|
content: item.content || "",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingPricing) {
|
||||||
|
// Update existing pricing
|
||||||
|
existingPricing.hero = processedData.hero;
|
||||||
|
existingPricing.pricingSection = processedData.pricingSection;
|
||||||
|
existingPricing.plans = processedData.plans;
|
||||||
|
existingPricing.testimonials = processedData.testimonials;
|
||||||
|
await existingPricing.save();
|
||||||
|
console.log("Pricing data updated successfully");
|
||||||
|
return existingPricing;
|
||||||
|
} else {
|
||||||
|
// Create new pricing
|
||||||
|
const newPricing = await this.create({
|
||||||
|
name: "default",
|
||||||
|
...processedData,
|
||||||
|
});
|
||||||
|
console.log("Pricing data imported successfully");
|
||||||
|
return newPricing;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error migrating pricing data:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model("Pricing", pricingSchema);
|
||||||
79
models/recentPost.js
Normal file
79
models/recentPost.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
// Recent Post model - có thể là view hoặc collection riêng để optimize performance
|
||||||
|
const recentPostSchema = new mongoose.Schema({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
slug: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
thumbnail: {
|
||||||
|
type: String,
|
||||||
|
default: '' // Ảnh nhỏ ở sidebar
|
||||||
|
},
|
||||||
|
publishedAt: {
|
||||||
|
type: String, // "March 26, 2025"
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
// Reference to original blog post
|
||||||
|
originalPostId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Blog',
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
recentPostSchema.index({ createdAt: -1 });
|
||||||
|
recentPostSchema.index({ originalPostId: 1 });
|
||||||
|
|
||||||
|
// Remove __v from JSON output
|
||||||
|
recentPostSchema.set('toJSON', {
|
||||||
|
transform: function(doc, ret) {
|
||||||
|
delete ret.__v;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Static method to sync with Blog posts
|
||||||
|
recentPostSchema.statics.syncFromBlogs = async function(limit = 5) {
|
||||||
|
const Blog = require('./blog');
|
||||||
|
|
||||||
|
// Get recent published blogs
|
||||||
|
const recentBlogs = await Blog.find({ status: 'published' })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(limit)
|
||||||
|
.select('title slug featuredImage publishedAt');
|
||||||
|
|
||||||
|
// Clear existing recent posts
|
||||||
|
await this.deleteMany({});
|
||||||
|
|
||||||
|
// Create new recent posts
|
||||||
|
const recentPosts = recentBlogs.map(blog => ({
|
||||||
|
title: blog.title,
|
||||||
|
slug: blog.slug,
|
||||||
|
thumbnail: blog.featuredImage,
|
||||||
|
publishedAt: blog.publishedAt,
|
||||||
|
originalPostId: blog._id
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (recentPosts.length > 0) {
|
||||||
|
await this.insertMany(recentPosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return recentPosts;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static method to get recent posts
|
||||||
|
recentPostSchema.statics.getRecent = function(limit = 5) {
|
||||||
|
return this.find({}).sort({ createdAt: -1 }).limit(limit);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model('RecentPost', recentPostSchema);
|
||||||
120
routes/admin.js
120
routes/admin.js
@@ -24,6 +24,11 @@ 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");
|
||||||
|
|
||||||
|
// Blog controllers
|
||||||
|
const blogController = require("../controllers/blogController");
|
||||||
|
const blogCategoryController = require("../controllers/blogCategoryController");
|
||||||
|
const blogTagController = require("../controllers/blogTagController");
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
router.get("/dashboard", ensureAuthenticated, dashboardController.getDashboard);
|
router.get("/dashboard", ensureAuthenticated, dashboardController.getDashboard);
|
||||||
|
|
||||||
@@ -151,6 +156,64 @@ router.get(
|
|||||||
contactController.getContactData,
|
contactController.getContactData,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Contact submissions management
|
||||||
|
router.get(
|
||||||
|
"/contact/submissions",
|
||||||
|
ensureAuthenticated,
|
||||||
|
contactController.getSubmissions,
|
||||||
|
);
|
||||||
|
router.put(
|
||||||
|
"/contact/submissions/:id",
|
||||||
|
ensureAuthenticated,
|
||||||
|
contactController.updateSubmissionStatus,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Appointment management
|
||||||
|
const appointmentController = require("../controllers/appointmentController");
|
||||||
|
router.get(
|
||||||
|
"/appointments",
|
||||||
|
ensureAuthenticated,
|
||||||
|
appointmentController.getAppointments,
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/appointments/:id",
|
||||||
|
ensureAuthenticated,
|
||||||
|
appointmentController.getAppointmentById,
|
||||||
|
);
|
||||||
|
router.put(
|
||||||
|
"/appointments/:id",
|
||||||
|
ensureAuthenticated,
|
||||||
|
appointmentController.updateAppointmentStatus,
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/appointments/:id",
|
||||||
|
ensureAuthenticated,
|
||||||
|
appointmentController.deleteAppointment,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Appointment CMS page management
|
||||||
|
router.get("/appointment", ensureAuthenticated, appointmentController.index);
|
||||||
|
router.post(
|
||||||
|
"/appointment/update",
|
||||||
|
ensureAuthenticated,
|
||||||
|
appointmentController.update,
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/appointment/data",
|
||||||
|
ensureAuthenticated,
|
||||||
|
appointmentController.getAppointmentData,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pricing CMS page management
|
||||||
|
const pricingController = require("../controllers/pricingController");
|
||||||
|
router.get("/pricing", ensureAuthenticated, pricingController.index);
|
||||||
|
router.post("/pricing/update", ensureAuthenticated, pricingController.update);
|
||||||
|
router.get(
|
||||||
|
"/pricing/data",
|
||||||
|
ensureAuthenticated,
|
||||||
|
pricingController.getPricingData,
|
||||||
|
);
|
||||||
|
|
||||||
// Activity CRUD routes
|
// Activity CRUD routes
|
||||||
router.get("/activity", ensureAuthenticated, activityController.index);
|
router.get("/activity", ensureAuthenticated, activityController.index);
|
||||||
router.get(
|
router.get(
|
||||||
@@ -406,4 +469,61 @@ router.delete(
|
|||||||
ensureAuthenticated,
|
ensureAuthenticated,
|
||||||
visaController.deleteCountry,
|
visaController.deleteCountry,
|
||||||
);
|
);
|
||||||
|
// Blog routes
|
||||||
|
// Blog Management Routes
|
||||||
|
router.get("/blog", ensureAuthenticated, blogController.index);
|
||||||
|
router.get("/blog/create", ensureAuthenticated, blogController.create);
|
||||||
|
router.post("/blog/create", ensureAuthenticated, blogController.store);
|
||||||
|
router.get("/blog/:id/edit", ensureAuthenticated, blogController.edit);
|
||||||
|
router.post("/blog/:id/edit", ensureAuthenticated, blogController.update);
|
||||||
|
router.post("/blog/:id/delete", ensureAuthenticated, blogController.destroy);
|
||||||
|
|
||||||
|
// Blog Categories Management
|
||||||
|
router.get(
|
||||||
|
"/blog/categories",
|
||||||
|
ensureAuthenticated,
|
||||||
|
blogCategoryController.index,
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/blog/categories/create",
|
||||||
|
ensureAuthenticated,
|
||||||
|
blogCategoryController.create,
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/blog/categories/create",
|
||||||
|
ensureAuthenticated,
|
||||||
|
blogCategoryController.store,
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/blog/categories/:id/edit",
|
||||||
|
ensureAuthenticated,
|
||||||
|
blogCategoryController.edit,
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/blog/categories/:id/edit",
|
||||||
|
ensureAuthenticated,
|
||||||
|
blogCategoryController.update,
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/blog/categories/:id/delete",
|
||||||
|
ensureAuthenticated,
|
||||||
|
blogCategoryController.destroy,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Blog Tags Management
|
||||||
|
router.get("/blog/tags", ensureAuthenticated, blogTagController.index);
|
||||||
|
router.get("/blog/tags/create", ensureAuthenticated, blogTagController.create);
|
||||||
|
router.post("/blog/tags/create", ensureAuthenticated, blogTagController.store);
|
||||||
|
router.get("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.edit);
|
||||||
|
router.post(
|
||||||
|
"/blog/tags/:id/edit",
|
||||||
|
ensureAuthenticated,
|
||||||
|
blogTagController.update,
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/blog/tags/:id/delete",
|
||||||
|
ensureAuthenticated,
|
||||||
|
blogTagController.destroy,
|
||||||
|
);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ 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");
|
||||||
|
|
||||||
|
// Blog controllers
|
||||||
|
const blogController = require("../controllers/blogController");
|
||||||
|
const blogCategoryController = require("../controllers/blogCategoryController");
|
||||||
|
const blogTagController = require("../controllers/blogTagController");
|
||||||
|
|
||||||
// Trang chủ
|
// Trang chủ
|
||||||
router.get("/", (req, res) => {
|
router.get("/", (req, res) => {
|
||||||
res.render("index", {
|
res.render("index", {
|
||||||
@@ -50,6 +55,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);
|
||||||
@@ -132,6 +149,27 @@ 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
|
||||||
|
router.get("/api/blog", blogController.api);
|
||||||
|
router.get("/api/blog/featured", blogController.apiFeatured);
|
||||||
|
router.get("/api/blog/recent", blogController.apiRecent);
|
||||||
|
|
||||||
|
// Blog Categories API (must come before /api/blog/:slug)
|
||||||
|
router.get("/api/blog/categories", blogCategoryController.api);
|
||||||
|
router.get("/api/blog/categories/:slug", blogCategoryController.apiShow);
|
||||||
|
|
||||||
|
// Blog Tags API (must come before /api/blog/:slug)
|
||||||
|
router.get("/api/blog/tags", blogTagController.api);
|
||||||
|
router.get("/api/blog/tags/popular", blogTagController.apiPopular);
|
||||||
|
router.get("/api/blog/tags/:slug", blogTagController.apiShow);
|
||||||
|
|
||||||
|
// Blog post specific APIs (must come before /api/blog/:slug)
|
||||||
|
router.get("/api/blog/:id/categories", blogController.apiCategories);
|
||||||
|
router.get("/api/blog/:id/tags", blogController.apiTags);
|
||||||
|
|
||||||
|
// Blog detail by slug (must come last among blog routes)
|
||||||
|
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);
|
||||||
|
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
require("dotenv").config();
|
|
||||||
const fs = require("fs").promises;
|
|
||||||
const path = require("path");
|
|
||||||
const connectDB = require("../config/database");
|
|
||||||
const AboutUs = require("../models/aboutUs");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize the provided aboutUs.json into the AboutUs model shape.
|
|
||||||
*/
|
|
||||||
function transformAboutUs(source) {
|
|
||||||
const hero = {
|
|
||||||
banner: source?.hero?.banner || "",
|
|
||||||
title: source?.hero?.title || "",
|
|
||||||
breadcrumb: source?.hero?.breadcrumb || "",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Introduce section
|
|
||||||
const introduce = {
|
|
||||||
header: source?.introduce?.header || {},
|
|
||||||
services: Array.isArray(source?.introduce?.services)
|
|
||||||
? source.introduce.services
|
|
||||||
: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stats
|
|
||||||
const stats = Array.isArray(source?.stats) ? source.stats : [];
|
|
||||||
|
|
||||||
// Features: header + items
|
|
||||||
const features = {
|
|
||||||
header: source?.features?.header || {},
|
|
||||||
items: Array.isArray(source?.features?.items) ? source.features.items : [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Activities
|
|
||||||
const activities = source?.activities || {};
|
|
||||||
|
|
||||||
// Newsletter
|
|
||||||
const newsletter = source?.newsletter || {};
|
|
||||||
|
|
||||||
// Events: header + items
|
|
||||||
const events = {
|
|
||||||
header: source?.events?.header || {},
|
|
||||||
items: Array.isArray(source?.events?.items) ? source.events.items : [],
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
hero,
|
|
||||||
introduce,
|
|
||||||
stats,
|
|
||||||
features,
|
|
||||||
activities,
|
|
||||||
newsletter,
|
|
||||||
events,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migration: aboutus
|
|
||||||
*/
|
|
||||||
async function migrate() {
|
|
||||||
try {
|
|
||||||
await connectDB();
|
|
||||||
|
|
||||||
const filePath = path.join(__dirname, "..", "data", "aboutUs.json");
|
|
||||||
const raw = await fs.readFile(filePath, "utf8");
|
|
||||||
const source = JSON.parse(raw);
|
|
||||||
|
|
||||||
const doc = transformAboutUs(source);
|
|
||||||
|
|
||||||
await AboutUs.create(doc);
|
|
||||||
|
|
||||||
const mongoose = require('mongoose');
|
|
||||||
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 };
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
require('dotenv').config();
|
|
||||||
const connectDB = require('../config/database');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migration: asd
|
|
||||||
* Created: 11:41:22 2/12/2025
|
|
||||||
*/
|
|
||||||
async function migrate() {
|
|
||||||
try {
|
|
||||||
// Kết nối database
|
|
||||||
await connectDB();
|
|
||||||
console.log('Starting migration: asd...');
|
|
||||||
|
|
||||||
// TODO: Thêm code migration của bạn ở đây
|
|
||||||
|
|
||||||
console.log('Migration asd completed successfully!');
|
|
||||||
|
|
||||||
const mongoose = require('mongoose');
|
|
||||||
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 };
|
|
||||||
@@ -14,7 +14,7 @@ async function migrate() {
|
|||||||
await connectDB();
|
await connectDB();
|
||||||
|
|
||||||
// Read contact-data.json file
|
// Read contact-data.json file
|
||||||
const contactJsonPath = path.join(__dirname, "../data/contact-data.json");
|
const contactJsonPath = path.join(__dirname, "../data/contact.json");
|
||||||
const contactData = JSON.parse(await fs.readFile(contactJsonPath, "utf8"));
|
const contactData = JSON.parse(await fs.readFile(contactJsonPath, "utf8"));
|
||||||
|
|
||||||
// Migrate data using the model's static method
|
// Migrate data using the model's static method
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
require("dotenv").config();
|
|
||||||
const fs = require("fs").promises;
|
|
||||||
const path = require("path");
|
|
||||||
const connectDB = require("../config/database");
|
|
||||||
const Safety = require("../models/safety");
|
|
||||||
const mongoose = require("mongoose");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chuẩn hóa safety.json đúng theo safetySchema
|
|
||||||
*/
|
|
||||||
function transformSafety(data) {
|
|
||||||
return {
|
|
||||||
hero: {
|
|
||||||
banner: data?.hero?.banner || "",
|
|
||||||
title: data?.hero?.title || "",
|
|
||||||
},
|
|
||||||
|
|
||||||
approach: {
|
|
||||||
badge: data?.approach?.badge || "",
|
|
||||||
title: data?.approach?.title || "",
|
|
||||||
description: data?.approach?.description || "",
|
|
||||||
imgs: {
|
|
||||||
img1: data?.approach?.imgs?.img1 || "",
|
|
||||||
img2: data?.approach?.imgs?.img2 || "",
|
|
||||||
},
|
|
||||||
stats: {
|
|
||||||
count: data?.approach?.stats?.count || "",
|
|
||||||
label: data?.approach?.stats?.label || "",
|
|
||||||
avatars: Array.isArray(data?.approach?.stats?.avatars)
|
|
||||||
? data.approach.stats.avatars
|
|
||||||
: [],
|
|
||||||
},
|
|
||||||
features: Array.isArray(data?.approach?.features)
|
|
||||||
? data.approach.features
|
|
||||||
: [],
|
|
||||||
cards: Array.isArray(data?.approach?.cards)
|
|
||||||
? data.approach.cards
|
|
||||||
: [],
|
|
||||||
},
|
|
||||||
|
|
||||||
philosophy: {
|
|
||||||
title: data?.philosophy?.title || "",
|
|
||||||
subtitle: data?.philosophy?.subtitle || "",
|
|
||||||
cards: Array.isArray(data?.philosophy?.cards)
|
|
||||||
? data.philosophy.cards
|
|
||||||
: [],
|
|
||||||
},
|
|
||||||
|
|
||||||
security: {
|
|
||||||
title: data?.security?.title || "",
|
|
||||||
subtitle: data?.security?.subtitle || "",
|
|
||||||
cards: Array.isArray(data?.security?.cards)
|
|
||||||
? data.security.cards
|
|
||||||
: [],
|
|
||||||
},
|
|
||||||
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MIGRATION
|
|
||||||
*/
|
|
||||||
async function migrate() {
|
|
||||||
try {
|
|
||||||
console.log("Starting migration: create_safety_table...");
|
|
||||||
await connectDB();
|
|
||||||
|
|
||||||
const safetyJsonPath = path.join(__dirname, "../data/safety.json");
|
|
||||||
const raw = await fs.readFile(safetyJsonPath, "utf8");
|
|
||||||
const source = JSON.parse(raw);
|
|
||||||
|
|
||||||
const doc = transformSafety(source);
|
|
||||||
|
|
||||||
await Safety.create(doc);
|
|
||||||
|
|
||||||
console.log("Migration create_safety_table completed successfully!");
|
|
||||||
|
|
||||||
await mongoose.disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Migration error:", error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (require.main === module) migrate();
|
|
||||||
|
|
||||||
module.exports = { migrate };
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
require("dotenv").config();
|
|
||||||
const fs = require("fs").promises;
|
|
||||||
const path = require("path");
|
|
||||||
const connectDB = require("../config/database");
|
|
||||||
const FAQ = require("../models/faq");
|
|
||||||
const mongoose = require("mongoose");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migration: faq
|
|
||||||
* Migrate FAQ data from faq-data.json
|
|
||||||
* Created: 16:22:58 4/12/2025
|
|
||||||
*/
|
|
||||||
async function migrate() {
|
|
||||||
try {
|
|
||||||
// Kết nối database
|
|
||||||
await connectDB();
|
|
||||||
console.log("Starting migration: faq...");
|
|
||||||
|
|
||||||
// Read faq-data.json file - cùng cấp với file này
|
|
||||||
const faqJsonPath = path.join(__dirname, "../data/faq-data.json");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const faqData = JSON.parse(await fs.readFile(faqJsonPath, "utf8"));
|
|
||||||
console.log("FAQ JSON data loaded successfully");
|
|
||||||
|
|
||||||
// Đảm bảo có trường name
|
|
||||||
if (!faqData.name) {
|
|
||||||
faqData.name = "default";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate data using the model's static method
|
|
||||||
const result = await FAQ.importFromJson(faqData);
|
|
||||||
|
|
||||||
// Tính tổng số FAQ
|
|
||||||
const totalFaqs = result.faqSections.reduce((total, section) => {
|
|
||||||
return total + (section.faqs ? section.faqs.length : 0);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
console.log("FAQ migration completed successfully");
|
|
||||||
console.log(`Total sections migrated: ${result.faqSections.length}`);
|
|
||||||
console.log(`Total FAQs migrated: ${totalFaqs}`);
|
|
||||||
console.log(`FAQ ID: ${result._id}`);
|
|
||||||
|
|
||||||
} catch (fileError) {
|
|
||||||
console.error("Error reading FAQ JSON file:", fileError.message);
|
|
||||||
|
|
||||||
// Nếu không có file JSON, tạo data mẫu với dữ liệu đầy đủ
|
|
||||||
console.log("Creating complete FAQ data...");
|
|
||||||
|
|
||||||
const defaultFaqData = {
|
|
||||||
name: "default",
|
|
||||||
hero: {
|
|
||||||
title: "Go and Grow Camp",
|
|
||||||
backgroundImage: "yootheme/cache/18/faqs_header_new.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"
|
|
||||||
},
|
|
||||||
sidebarNav: [
|
|
||||||
{
|
|
||||||
id: "general-information",
|
|
||||||
label: "General Information"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "camps",
|
|
||||||
label: "Camps"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "camp-routine",
|
|
||||||
label: "Camp Routine"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "camp-counselors",
|
|
||||||
label: "Camp Counselors"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "camp-rules",
|
|
||||||
label: "Camp Rules"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "safety",
|
|
||||||
label: "Safety"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "accommodation-catering",
|
|
||||||
label: "Accommodation & Catering"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "transfers-shuttles",
|
|
||||||
label: "Transfers & Shuttles"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
contactBox: {
|
|
||||||
title: "Let's plan your perfect nature escape",
|
|
||||||
phone: {
|
|
||||||
icon: "phone",
|
|
||||||
text: "+(123)-456-789"
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
icon: "email",
|
|
||||||
text: "hello@ggcamp.org"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
faqSections: [
|
|
||||||
{
|
|
||||||
id: "general-information",
|
|
||||||
title: "General Information",
|
|
||||||
faqs: [
|
|
||||||
{
|
|
||||||
title: "What are FAQ?",
|
|
||||||
description: "FAQ are the initials for \"Frequently Asked Questions\".\n\nThe FAQ have been compiled by us over a long period of time and are intended to help give a general overview of our camps and clarify questions that arise before booking a camp."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "General booking process",
|
|
||||||
description: "Once the booking has been confirmed by us, you will receive an e-mail requesting a deposit. As soon as we have received this, you will receive an e-mail with a payment confirmation.\nPlease have a look at the welcome package, which will reach you by e-mail with the Last Travel Information. This contains information that applies to the camp you have booked.\n\nStep 1: Registration\nStep 2: Receipt of registration confirmation, total invoice and deposit request (e-mail)\nStep 3: Deposit of USD 50 (due within 7 days after booking)\nStep 4: Receiving an email with the latest important travel information, a packing list, addresses and important emergency phone numbers plus remaining payment request about 3-4 weeks before the camp starts."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Terms & Conditions",
|
|
||||||
description: "Our Terms & Conditions can be found in our official documents section."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Where can I find a packing guide for Camps?",
|
|
||||||
description: "Just click here to download our packing list."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Where can I find contact information from Camps and addresses?",
|
|
||||||
description: "Here you can find all the necessary information if you want to drive to our camps or send something. If you want to send something please ALWAYS include the full name of your child on the letter/package and please only send it at the time when your kids are staying in camp as we cannot store it for a longer period of time.\n\nWalsrode/Lüneburger Heide - Germany:\nCamp Adventure, Vethem 58, 29664 Walsrode, Germany\nwalsrode@campadventure.de\n\nRegen/Bavarian Forest - Germany:\nCamp Adventure, Badstrasse 18, 94209 Regen\nregen@campadventure.de\n\nBarcelona - Spain:\nBISC International Sailing Center, c/o Camp Adventure, Parc del Fòrum Sota plaça fotovoltàica, 08930 Sant Adrià de Besòs, Barcelona, Spain\nbarcelona@campadventure.de\n\nBath - England:\nUniversity of Bath, c/o Camp Adventure, Claverton Down, Bath BA2 7AY, England\nengland@campadventure.de\n\nRossall - England:\nRossall School, Broadway, Fleetwood, Lancashire FY7 8JW, England\nengland@campadventure.de"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "camps",
|
|
||||||
title: "Camps",
|
|
||||||
faqs: [
|
|
||||||
{
|
|
||||||
title: "Where do kids and camp counselors come from?",
|
|
||||||
description: "Camp Adventure attaches great importance to internationality. The participants and supervisors in our camps come from many different countries. Last year, for example, we had participants from over 60 different countries and counselors from 25 different nations. Of course, we don't know where they will come from this year. So we are at least as excited as you are.\n\nThrough our office in Hamburg and our branch office in Canada, we reach motivated and committed counselors from all over the world. Canadian and Australian teamers can therefore be found as well as German or Spanish teamers.\n\nDue to the different experiences and cultural backgrounds an indescribably fantastic, international atmosphere is created."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Which languages are spoken in camp?",
|
|
||||||
description: "The main language in all our camps is English. In addition, there is the language of the country in which the camp takes place. As we have our headquarters in Germany, German teamers are always present in all camps in Germany. All announcements and explanations are here therefore always in German and English. Of course, all our teamers with their different nationalities are also available for individual translations."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Are there problems if children have low language skills?",
|
|
||||||
description: "No, because there are usually more participants and team members who speak the same language. We know from experience that children are excellent at communicating nonverbally. They often need a few days to warm up to it, but are then very open to other children as well."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Are girls and boys separated?",
|
|
||||||
description: "Girls and boys are accommodated separately in the dormitories/tents. The program is completely mixed."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "How big are the camps? How high is the caregiver ratio?",
|
|
||||||
description: "Capacities range from around 30 participants in smaller language camps to a maximum of about 400 children in our camp Lueneburger Heide. However, the maximum capacity is not reached every week. However, a minimum number of participants must be guaranteed in order to run the camp.\n\nIt is important to us that all children are always grouped in small groups of 5-8, with a counselor as a contact person. This way homesickness doesn't stand a chance and despite the size of the camp in their group family, they experience a strong bond on which they can count on!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Should 12-year-olds go to Junior Camp or Senior Camp?",
|
|
||||||
description: "This question is not easy to answer and depends on the individual stage of development of your child. Therefore, as parents, we leave you the opportunity to decide for yourself. In the Junior Camp they belong to the older ones and can explore a lot in a playful way. In the Senior Camp they are the younger ones, who have role models through the older ones, whom they can emulate."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "camp-routine",
|
|
||||||
title: "Camp Routine",
|
|
||||||
faqs: [
|
|
||||||
{
|
|
||||||
title: "How is the choice of activities/courses in the camps made?",
|
|
||||||
description: "If your child would like to participate in a paid additional course (e.g. horse riding, language course, Survival etc.), this must be booked in advance when registering. In principle, no extra additional courses have to be booked. A program with a variety of activities is of course available to the participants in all camps. The various activities can be chosen by the participants on site in the respective camps. We present the offers to the participants, so that everyone gets an insight into the different courses. The children can then register in the lists of the respective courses."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "What is a hike?",
|
|
||||||
description: "The hike is a 1-3 day walking tour, in which all participants of the Adventure Camp who stay 2 weeks in the camp take part. On this hike the participants will not spend the night in a tent, but either in the open air or under a self-made shelter e.g. from tarpaulins. They will of course be accompanied by their teamers. The hike is a very special experience and a highlight for all participants. For this hike the participants need sturdy shoes and a big backpack."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Can I wash my clothes during the camp?",
|
|
||||||
description: "In principle, participants should bring sufficient clothing and change of clothes for the entire camp period.\n\nOnly in the camps in Lüneburger Heide and Bayerischer Wald a laundry service will be offered for kids staying three weeks or more, which means that a laundry bag (approx. 3 kg) will be washed in the laundry centre of the next village at a price of USD 45. This service can be booked upon registration for three-week camps. Please note that the laundry will be done either after one week or after two weeks."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Anti Homesick Adviser",
|
|
||||||
description: "Dear parents\n\nNow it's almost time: In summer your child travels for the first time with Camp Adventure. Maybe it will be the first time that he travels alone without parents or relatives. As we are getting more and more questions, we have decided to put together a small package for you parents with little tips from experts to make everything as easy as possible for you and your child. Follow our tips and your child will have a fantastic holiday, have many new experiences and make friends from all over the world! All these tips have been developed together with the International Camping Fellowship. And the more you think your child will be a \"homesick candidate\" - or your child even claims to be one - the more you consider the following tips."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "camp-counselors",
|
|
||||||
title: "Camp Counselors - Our Teamers",
|
|
||||||
faqs: [
|
|
||||||
{
|
|
||||||
title: "Who are the camp counselors?",
|
|
||||||
description: "Every year our team is made up of an international mix. The non-profit association Camp Europe e.V. with headquarters in Hamburg and a branch office in Canada takes care of the acquisition of national and international applicants. Since we have about 50% German-speaking children, there are also German carers in every location. But many also come from other countries, such as England, Spain, Canada and Australia, to name just a few."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "How are the teamers trained?",
|
|
||||||
description: "All counselors go through an extensive application process. For a successful application, not only an interesting curriculum vitae and a minimum age of 19 years are sufficient! We conduct a personal interview with each individual in which our employees get a first impression of the applicant.\n\nBefore the camp season, everyone, both the first supervisors (teamers) as well as many recomers, complete a one-week training in which they are prepared for their assignment by trained coaches. They must have a first aid certificate, which may not be older than two years, as well as an internationally flawless police clearance certificate. We know how important the teamers are for a great camp and therefore select them very conscientiously."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "camp-rules",
|
|
||||||
title: "Camp Rules",
|
|
||||||
faqs: [
|
|
||||||
{
|
|
||||||
title: "Drugs, Alcohol & Camp?",
|
|
||||||
description: "From our point of view an absolutely unacceptable and indiscutable combination! Due to our cooperation with the association \"Keine Macht den Drogen\" (No power to drugs) and our common opinion that all kinds of drugs do not belong in the hands of children & teenagers, any possession or consumption of drugs is forbidden for teenagers and children in the camp and also outside the camp.\n\nViolations can lead to exclusion or even to criminal charges. The term \"drugs\" also includes cigarettes and alcohol! Through our varied activities, we offer a much better alternative! We would like to make it clear from the outset that we are also against any form of discrimination or \"putting down\". This is - just like violence - immediately prevented by us, in order to offer each young person a relaxed and joyful time in the camp."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Should I call my kid or write an old-fashioned letter?",
|
|
||||||
description: "We ask all parents to write to their child at least once. This is especially useful at the beginning, as it is a particularly upsetting experience for every child and every teenager when most of the participants receive a letter, but they do not.\n\nPlease note that there is NO public \"camp phone\" available for incoming or outgoing calls. If your child doesn't bring her/his own phone, she/he won't be able to call you. In case of any problems, we will of course contact you immediately.\n\nIf your child brings a mobile phone, we will collect it on arrival and store it with the valuables. Your child's Teamer may hand it over during the phone time after lunch. Please keep in mind: no news is good news (the location manager will contact you if it is necessary due to homesickness or illness). We kindly ask you not to call the office in Hamburg to ask about your kid's health and wellbeing, nor if you would like to know why your child hasn't called you yet. Please use our camp email service for such enquiries.\n\nOur recommendation is the following:\nWe recommend not to call your child (even if he or she has a mobile phone with him or her) and not to tell him or her to call you. Telephoning can in our experience promote homesickness very strongly and your child will be cured thereby if completely immersed in camp life! At noon after lunch, if absolutely necessary, your child can pick up his or her mobile phone from the counselors until the start of next program and make phone calls. Instead, you are welcome to bring a pre-stamped and addressed envelope with you. We will then make sure that your child has enough time to write letters. Since letters and postcards often arrive late at the camps, we also offer the e-mail service. You can send your child max. ONE email per day directly to the camp, which we then print out and give to your child. There is no way for them to reply, but your child will be happy to receive a small message from home. You can find the postal and email address in the info package of the booked camp."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Are there any prohibited items?",
|
|
||||||
description: "Yes, there are. Not allowed are pocket knives with lockable blades, all weapons, lighters and matches (danger of fire in the forest!). Drugs of any kind, including alcohol and cigarettes, are also included."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "safety",
|
|
||||||
title: "Safety",
|
|
||||||
faqs: [
|
|
||||||
{
|
|
||||||
title: "Electronic equipment and valuables",
|
|
||||||
description: "We recommend that you do not take an MP3 player, e-book, tablet, etc. or any valuables with you. On the one hand we do not assume any liability and on the other hand there are no possibilities to charge the devices. We are of the opinion that the camp time is a special experience for the participants if they do not have the headphones in their ears all the time or are busy with their mobile phones. Instead they have the chance to deal with other topics and they find time to dedicate themselves to the new people in the camp."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "How do you provide safety for the kids?",
|
|
||||||
description: "Before our camp counselors start working with us, we check their police clearance certificates. You must be at least 19 years old to work for us as a teamer. They must also have a \"First Aid Certificate\", which must not be older than two years. In the camps we try to make sure that only adults from our camp or familiar faces are on the campground and that all our carers look after strangers.\n\nWe have many different camp sites. Some of them are fenced in, others are not. There are no armed guards or the like in our camps, as we believe that these conditions create a very insecure feeling. We do not have a high security zone in Germany, Northern Ireland or England, but we keep our eyes open and do everything we can to ensure that all participants have a great time."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Insurance in case of illness?",
|
|
||||||
description: "If your child should fall ill during the camp and medical help is required, he or she will of course be taken to the doctor by our carers and cared for there as well. It is therefore necessary for each participant to take their insurance card with them to the camp. We offer all participants the possibility of taking out liability, casualty & health insurance for travel abroad with us. This covers all costs in case of illness and prevents international children in particular from having to \"advance\" their own cash. You can find more detailed information on insurance in our documents section."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "accommodation-catering",
|
|
||||||
title: "Accommodation & Catering",
|
|
||||||
faqs: [
|
|
||||||
{
|
|
||||||
title: "How's the food at the camps?",
|
|
||||||
description: "Full board for the entire duration of the camp is of course already included in the camp price. In addition, water and fruit are available for the participants around the clock. For us it is a matter of course to provide one variant for vegetarians and one pork-free with each meal. In case of special allergies or intolerances of your children let us know in advance and we will try to find a solution."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "How is my child accommodated in the camp?",
|
|
||||||
description: "In our Adventure Camp Bayerischer Wald and our Camp Lueneburger Heide, the Juniors (7-12) and the Seniors (12-16) can choose between tents and huts.\n\nThe tents are equipped with a floor and a wooden platform, up to 7 children can share one tent. The participants can make themselves comfortable with sleeping bag and sleeping mat. The wooden huts are equipped with bunk beds and can accommodate 4-8 children. At the other locations, participants will be accommodated in shared rooms in youth hostels, sports centres or boarding schools of private schools. You will find detailed information about the accommodation on the individual camp pages."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "transfers-shuttles",
|
|
||||||
title: "Transfers & Shuttles",
|
|
||||||
faqs: [
|
|
||||||
{
|
|
||||||
title: "Entry regulations/Travel Consent for group flights",
|
|
||||||
description: "All parents need to fill this out and bring it to camp:\n\nBelow is a summary of the travel requirements for minors from various EU countries traveling with Camp Adventure on group flights. Please note that regulations can change, so it's essential to consult the official resources provided for the most up-to-date information."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Which transfers are offered?",
|
|
||||||
description: "The respective transfer possibilities depend on the period and venue of the camp. Check directly on the respective camp page under \"Arrival & Departure Services\"."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Where can I find the exact arrival and departure times?",
|
|
||||||
description: "Information about the different arrival and departure times can be found on the respective camp page under \"Arrival & Departure Services\"."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "How do the transfer costs come about?",
|
|
||||||
description: "When booking a train or air trip, the indicated price includes the arrival and departure as well as the accompaniment by a supervisor."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Where can I find the address/driving directions from the camp?",
|
|
||||||
description: "You will receive the exact address and directions of the camp with the Last Travel Information about 3-4 weeks before the camp starts."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
video: {
|
|
||||||
url: "https://www.youtube.com/embed/3NtE5wSwYTo?list=PLSOedrxa1c-bxvH6uuz_oZdIfJkov66wB&disablekb=1",
|
|
||||||
title: "Anti Homesickness Adviser"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await FAQ.importFromJson(defaultFaqData);
|
|
||||||
console.log("Complete FAQ data created successfully");
|
|
||||||
console.log(`FAQ ID: ${result._id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kiểm tra lại data đã được lưu
|
|
||||||
const savedFaq = await FAQ.findOne({ name: "default" });
|
|
||||||
if (savedFaq) {
|
|
||||||
const totalFaqs = savedFaq.faqSections.reduce((total, section) => {
|
|
||||||
return total + (section.faqs ? section.faqs.length : 0);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
console.log("\n=== Migration Summary ===");
|
|
||||||
console.log(`FAQ Name: ${savedFaq.name}`);
|
|
||||||
console.log(`Hero Title: ${savedFaq.hero.title}`);
|
|
||||||
console.log(`Sidebar Items: ${savedFaq.sidebarNav.length}`);
|
|
||||||
console.log(`FAQ Sections: ${savedFaq.faqSections.length}`);
|
|
||||||
console.log(`Total FAQ Items: ${totalFaqs}`);
|
|
||||||
console.log(`Created At: ${savedFaq.createdAt}`);
|
|
||||||
console.log(`Updated At: ${savedFaq.updatedAt}`);
|
|
||||||
|
|
||||||
// Hiển thị chi tiết từng section
|
|
||||||
console.log("\n=== FAQ Sections Details ===");
|
|
||||||
savedFaq.faqSections.forEach((section, index) => {
|
|
||||||
console.log(`Section ${index + 1}: ${section.title} (${section.faqs.length} FAQs)`);
|
|
||||||
});
|
|
||||||
console.log("=========================\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
await mongoose.disconnect();
|
|
||||||
console.log("Migration faq completed successfully!");
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Migration error:", error);
|
|
||||||
if (mongoose.connection.readyState !== 0) {
|
|
||||||
await mongoose.disconnect();
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chạy migration nếu được gọi trực tiếp
|
|
||||||
if (require.main === module) {
|
|
||||||
migrate();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { migrate };
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
const mongoose = require('mongoose');
|
|
||||||
const AboutUs = require('../models/aboutUs');
|
|
||||||
const fs = require('fs');
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
// Load and clean JSON data
|
|
||||||
const raw = fs.readFileSync(require('path').join(__dirname, '..', 'data', 'aboutUs.json'), 'utf8');
|
|
||||||
let data = JSON.parse(raw || '{}');
|
|
||||||
|
|
||||||
// Remove _id fields recursively to avoid conflicts
|
|
||||||
function stripIds(obj) {
|
|
||||||
if (Array.isArray(obj)) return obj.map(i => stripIds(i));
|
|
||||||
if (obj && typeof obj === 'object') {
|
|
||||||
const out = {};
|
|
||||||
for (const k in obj) {
|
|
||||||
if (k !== '_id') out[k] = stripIds(obj[k]);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
data = stripIds(data);
|
|
||||||
|
|
||||||
// Check for --dry-run flag
|
|
||||||
const dryRun = process.argv.includes('--dry-run') || process.argv.includes('-n');
|
|
||||||
|
|
||||||
async function importAboutUs() {
|
|
||||||
try {
|
|
||||||
const dbUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/ggcamps';
|
|
||||||
console.log('📍 Using DB URI:', dbUri);
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
console.log('\n🔍 === DRY RUN MODE ===');
|
|
||||||
console.log('Document to be upserted (preview only, no DB changes):\n');
|
|
||||||
console.log(JSON.stringify(data, null, 2));
|
|
||||||
console.log('\n=== END DRY RUN ===\n');
|
|
||||||
console.log('To actually import, run without --dry-run flag');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🔄 Connecting to database...');
|
|
||||||
await mongoose.connect(dbUri);
|
|
||||||
console.log('✓ Connected to database');
|
|
||||||
|
|
||||||
// Safe upsert: update existing doc or create new one
|
|
||||||
console.log('📥 Upserting AboutUs document (safe mode)...');
|
|
||||||
const result = await AboutUs.findOneAndUpdate({}, data, {
|
|
||||||
upsert: true,
|
|
||||||
new: true,
|
|
||||||
setDefaultsOnInsert: true
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ Successfully upserted AboutUs data!');
|
|
||||||
console.log('📝 Document ID:', result._id.toString());
|
|
||||||
console.log('📊 Data structure:');
|
|
||||||
console.log(' - Hero:', data.hero ? '✓' : '✗');
|
|
||||||
console.log(' - Introduction:', data.introduction ? '✓' : '✗');
|
|
||||||
console.log(' - Introduction Services:', data.introduction?.services?.length || 0, 'items');
|
|
||||||
console.log(' - Statistics:', data.statistics ? '✓' : '✗');
|
|
||||||
console.log(' - Statistics Items:', data.statistics?.items?.length || 0, 'items');
|
|
||||||
console.log(' - Accommodation:', data.accommodation ? '✓' : '✗');
|
|
||||||
console.log(' - Accommodation Features:', data.accommodation?.features?.length || 0, 'items');
|
|
||||||
console.log(' - Activities:', data.activities ? '✓' : '✗');
|
|
||||||
console.log(' - Activities Gallery:', data.activities?.gallery?.length || 0, 'items');
|
|
||||||
console.log(' - Newsletter:', data.newsletter ? '✓' : '✗');
|
|
||||||
console.log(' - Events:', data.events ? '✓' : '✗');
|
|
||||||
console.log(' - Events Items:', data.events?.items?.length || 0, 'items');
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error:', error.message);
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run import
|
|
||||||
importAboutUs();
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
require('dotenv').config();
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const path = require('path');
|
|
||||||
const connectDB = require('../config/database');
|
|
||||||
const CampLocation = require('../models/campLocation');
|
|
||||||
|
|
||||||
async function validateCampLocationData(data) {
|
|
||||||
const requiredFields = [
|
|
||||||
'metadata',
|
|
||||||
'hero',
|
|
||||||
'camps',
|
|
||||||
'locations',
|
|
||||||
'intro',
|
|
||||||
'faq',
|
|
||||||
'welcomeQuote',
|
|
||||||
'securityConcept'
|
|
||||||
];
|
|
||||||
|
|
||||||
const missingFields = requiredFields.filter(field => !data[field]);
|
|
||||||
if (missingFields.length > 0) {
|
|
||||||
throw new Error(`Missing required fields: ${missingFields.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate camps array
|
|
||||||
if (!Array.isArray(data.camps)) {
|
|
||||||
throw new Error('Camps must be an array');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate each camp has required fields
|
|
||||||
data.camps.forEach((camp, index) => {
|
|
||||||
if (!camp.id) {
|
|
||||||
throw new Error(`Camp at index ${index} is missing required field: id`);
|
|
||||||
}
|
|
||||||
if (!camp.title) {
|
|
||||||
throw new Error(`Camp at index ${index} is missing required field: title`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validate locations array
|
|
||||||
if (!Array.isArray(data.locations) || data.locations.length === 0) {
|
|
||||||
throw new Error('Locations must be a non-empty array');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate FAQ array
|
|
||||||
if (!Array.isArray(data.faq) || data.faq.length === 0) {
|
|
||||||
throw new Error('FAQ must be a non-empty array');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate security concept items
|
|
||||||
if (!Array.isArray(data.securityConcept.items) || data.securityConcept.items.length === 0) {
|
|
||||||
throw new Error('Security concept items must be a non-empty array');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✓ Data validation passed');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migration: camp_location
|
|
||||||
* Created: 13:18:38 9/12/2025
|
|
||||||
* Imports camp location data including camps, locations, FAQ, and security information
|
|
||||||
*/
|
|
||||||
async function migrate() {
|
|
||||||
try {
|
|
||||||
// Kết nối database
|
|
||||||
await connectDB();
|
|
||||||
console.log('Starting migration: camp_location...');
|
|
||||||
|
|
||||||
// Delete existing data
|
|
||||||
const deleteResult = await CampLocation.deleteMany({});
|
|
||||||
console.log(`✓ Deleted ${deleteResult.deletedCount} existing records`);
|
|
||||||
|
|
||||||
// Read JSON file
|
|
||||||
const campLocationData = JSON.parse(
|
|
||||||
await fs.readFile(path.join(__dirname, '../data/camp-location.json'), 'utf8')
|
|
||||||
);
|
|
||||||
console.log('✓ Loaded camp-location.json');
|
|
||||||
|
|
||||||
// Validate data
|
|
||||||
await validateCampLocationData(campLocationData);
|
|
||||||
|
|
||||||
// Create new record
|
|
||||||
const result = await CampLocation.create(campLocationData);
|
|
||||||
console.log('✓ Created camp location record');
|
|
||||||
console.log(` - ${result.camps.length} camps`);
|
|
||||||
console.log(` - ${result.locations.length} locations`);
|
|
||||||
console.log(` - ${result.faq.length} FAQ items`);
|
|
||||||
console.log(` - ${result.securityConcept.items.length} security measures`);
|
|
||||||
|
|
||||||
console.log('Migration camp_location completed successfully!');
|
|
||||||
|
|
||||||
const mongoose = require('mongoose');
|
|
||||||
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 };
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
require('dotenv').config();
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const path = require('path');
|
|
||||||
const connectDB = require('../config/database');
|
|
||||||
const Insurance = require('../models/insurance');
|
|
||||||
const mongoose = require('mongoose');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migration: insurance
|
|
||||||
* Created: 11:13:38 10/12/2025
|
|
||||||
* Updated: 14/12/2025 - Simplified for new structure only
|
|
||||||
*/
|
|
||||||
async function migrate() {
|
|
||||||
try {
|
|
||||||
console.log('Starting migration: insurance...');
|
|
||||||
await connectDB();
|
|
||||||
|
|
||||||
// Đọc file insurance.json
|
|
||||||
const insuranceJsonPath = path.join(__dirname, '../data/insurance.json');
|
|
||||||
console.log('Reading JSON file from:', insuranceJsonPath);
|
|
||||||
|
|
||||||
const insuranceData = JSON.parse(await fs.readFile(insuranceJsonPath, 'utf8'));
|
|
||||||
console.log('Insurance data loaded successfully');
|
|
||||||
|
|
||||||
// Sử dụng phương thức migrateFromJson của model
|
|
||||||
await Insurance.migrateFromJson(insuranceData);
|
|
||||||
|
|
||||||
console.log('Insurance migration completed successfully!');
|
|
||||||
|
|
||||||
await mongoose.disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Migration error:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom migration logic cho insurance data
|
|
||||||
*/
|
|
||||||
async function migrateInsuranceData(insuranceData) {
|
|
||||||
try {
|
|
||||||
console.log('Starting custom migration logic...');
|
|
||||||
|
|
||||||
// Xóa dữ liệu cũ nếu có
|
|
||||||
const existingInsurance = await Insurance.find({});
|
|
||||||
if (existingInsurance.length > 0) {
|
|
||||||
console.log(`Found ${existingInsurance.length} existing insurance documents`);
|
|
||||||
await Insurance.deleteMany({});
|
|
||||||
console.log('Cleared existing insurance data');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tạo document mới
|
|
||||||
const insuranceDocument = new Insurance({
|
|
||||||
name: 'default',
|
|
||||||
version: '2.0.0',
|
|
||||||
language: 'en',
|
|
||||||
hero: insuranceData.hero,
|
|
||||||
page: insuranceData.page,
|
|
||||||
content: insuranceData.content,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
isActive: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Lưu vào database
|
|
||||||
await insuranceDocument.save();
|
|
||||||
console.log('Insurance document saved successfully');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in migrateInsuranceData:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chạy migration nếu được gọi trực tiếp
|
|
||||||
if (require.main === module) {
|
|
||||||
migrate();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { migrate, migrateInsuranceData };
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
require('dotenv').config();
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const path = require('path');
|
|
||||||
const connectDB = require('../config/database');
|
|
||||||
const Terms = require('../models/terms');
|
|
||||||
const mongoose = require('mongoose');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migration: terms
|
|
||||||
* Migrate Terms & Conditions data từ terms-conditions.json
|
|
||||||
* Đã sửa để phù hợp với cấu trúc mới: hero, page, content
|
|
||||||
* Created: 12:00:47 10/12/2025
|
|
||||||
*/
|
|
||||||
async function migrate() {
|
|
||||||
try {
|
|
||||||
// Kết nối database
|
|
||||||
await connectDB();
|
|
||||||
console.log('Starting migration: terms...');
|
|
||||||
|
|
||||||
// Đọc file terms-conditions.json
|
|
||||||
const termsJsonPath = path.join(__dirname, '../data/terms-conditions.json');
|
|
||||||
console.log('Reading JSON file from:', termsJsonPath);
|
|
||||||
|
|
||||||
const termsData = JSON.parse(await fs.readFile(termsJsonPath, 'utf8'));
|
|
||||||
console.log('Terms data loaded successfully');
|
|
||||||
console.log('Data structure keys:', Object.keys(termsData));
|
|
||||||
|
|
||||||
// Kiểm tra cấu trúc và gọi method phù hợp
|
|
||||||
if (termsData.hero && termsData.page && termsData.content) {
|
|
||||||
// Cấu trúc mới - sử dụng migrateFromNewJson
|
|
||||||
console.log('Detected new structure, using migrateFromNewJson...');
|
|
||||||
if (typeof Terms.migrateFromNewJson === 'function') {
|
|
||||||
await Terms.migrateFromNewJson(termsData);
|
|
||||||
console.log('Migration completed using migrateFromNewJson method');
|
|
||||||
} else {
|
|
||||||
console.log('migrateFromNewJson not found, using custom logic...');
|
|
||||||
await migrateTermsData(termsData);
|
|
||||||
}
|
|
||||||
} else if (termsData.hero && termsData.termsHeader && termsData.sections) {
|
|
||||||
// Cấu trúc cũ - sử dụng migrateFromJson
|
|
||||||
console.log('Detected old structure, using migrateFromJson...');
|
|
||||||
if (typeof Terms.migrateFromJson === 'function') {
|
|
||||||
await Terms.migrateFromJson(termsData);
|
|
||||||
console.log('Migration completed using migrateFromJson method');
|
|
||||||
} else {
|
|
||||||
await migrateTermsData(termsData);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Không xác định được cấu trúc
|
|
||||||
console.error('Unknown data structure. Keys:', Object.keys(termsData));
|
|
||||||
throw new Error('Unknown terms data structure');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Terms migration completed successfully!');
|
|
||||||
|
|
||||||
await mongoose.disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Migration error:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom migration logic cho terms data với cấu trúc mới
|
|
||||||
* Chỉ có 3 phần: hero, page, content
|
|
||||||
*/
|
|
||||||
async function migrateTermsData(termsData) {
|
|
||||||
try {
|
|
||||||
console.log('Starting custom migration logic with new structure...');
|
|
||||||
|
|
||||||
// 1. Xóa dữ liệu cũ (tùy chọn)
|
|
||||||
const existingTerms = await Terms.find({});
|
|
||||||
if (existingTerms.length > 0) {
|
|
||||||
console.log(`Found ${existingTerms.length} existing terms documents`);
|
|
||||||
// Có thể bỏ comment để xóa dữ liệu cũ nếu cần
|
|
||||||
// await Terms.deleteMany({});
|
|
||||||
// console.log('Cleared existing terms data');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Chuyển đổi từ cấu trúc cũ sang cấu trúc mới nếu cần
|
|
||||||
let heroData, pageData, contentData;
|
|
||||||
|
|
||||||
// Kiểm tra xem data có cấu trúc cũ hay mới
|
|
||||||
if (termsData.hero && termsData.page && termsData.content) {
|
|
||||||
// Đây là cấu trúc mới, sử dụng trực tiếp
|
|
||||||
console.log('Using new structure (hero, page, content)');
|
|
||||||
heroData = termsData.hero;
|
|
||||||
pageData = termsData.page;
|
|
||||||
contentData = termsData.content;
|
|
||||||
|
|
||||||
// Debug: kiểm tra content data
|
|
||||||
console.log('contentData keys:', Object.keys(contentData));
|
|
||||||
console.log('contentData.content exists?', !!contentData.content);
|
|
||||||
console.log('contentData.content length:', contentData.content ? contentData.content.length : 0);
|
|
||||||
if (contentData.content && contentData.content.length > 0) {
|
|
||||||
console.log('First content item type:', contentData.content[0].type);
|
|
||||||
}
|
|
||||||
} else if (termsData.hero && termsData.termsHeader && termsData.sections) {
|
|
||||||
// Đây là cấu trúc cũ, cần chuyển đổi sang cấu trúc mới
|
|
||||||
console.log('Converting from old structure to new structure...');
|
|
||||||
heroData = termsData.hero;
|
|
||||||
pageData = convertOldPageToNew(termsData);
|
|
||||||
contentData = convertOldSectionsToNew(termsData);
|
|
||||||
} else {
|
|
||||||
throw new Error('Unknown terms data structure');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Tạo document mới cho terms
|
|
||||||
const termsDocument = new Terms({
|
|
||||||
version: '2.0.0', // Tăng version vì cấu trúc thay đổi
|
|
||||||
language: 'en',
|
|
||||||
|
|
||||||
// Cấu trúc mới chỉ có 3 phần chính
|
|
||||||
hero: {
|
|
||||||
title: heroData.title,
|
|
||||||
backgroundImage: heroData.backgroundImage,
|
|
||||||
sectionClass: heroData.sectionClass || 'uk-section-default uk-section-overlap uk-preserve-color uk-light uk-position-relative',
|
|
||||||
backgroundClasses: heroData.backgroundClasses || 'uk-background-norepeat uk-background-cover uk-background-top-center uk-section uk-section-xlarge',
|
|
||||||
overlayStyle: heroData.overlayStyle || { backgroundColor: 'rgba(0, 0, 0, 0)' },
|
|
||||||
titleClass: heroData.titleClass || 'text-white text-[5vw] uk-text-center',
|
|
||||||
enableScrollspy: heroData.enableScrollspy !== undefined ? heroData.enableScrollspy : true
|
|
||||||
},
|
|
||||||
|
|
||||||
page: {
|
|
||||||
title: pageData.title,
|
|
||||||
divider: pageData.divider !== undefined ? pageData.divider : true,
|
|
||||||
sectionClass: pageData.sectionClass || 'uk-section-default uk-section-overlap uk-section',
|
|
||||||
titleClass: pageData.titleClass || 'text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center',
|
|
||||||
dividerClass: pageData.dividerClass || 'uk-divider-small uk-text-left@m uk-text-center'
|
|
||||||
},
|
|
||||||
|
|
||||||
content: {
|
|
||||||
sectionClass: contentData.sectionClass || 'uk-section-muted uk-section-overlap uk-section',
|
|
||||||
textClass: contentData.textClass || 'uk-panel uk-margin text-[1vw]',
|
|
||||||
content: contentData.content || []
|
|
||||||
},
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
isActive: true,
|
|
||||||
migratedFromOldStructure: !termsData.content // Đánh dấu nếu được chuyển từ cấu trúc cũ
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Lưu vào database
|
|
||||||
await termsDocument.save();
|
|
||||||
console.log('Terms document saved successfully with new structure');
|
|
||||||
|
|
||||||
// 5. Log thông tin
|
|
||||||
console.log(`Created terms document with ID: ${termsDocument._id}`);
|
|
||||||
console.log(`Hero title: ${termsDocument.hero.title}`);
|
|
||||||
console.log(`Page title: ${termsDocument.page.title}`);
|
|
||||||
console.log(`Content items count: ${termsDocument.content.content.length}`);
|
|
||||||
|
|
||||||
// 6. Tạo thêm bản German nếu có
|
|
||||||
const germanJsonPath = path.join(__dirname, '../data/terms-conditions.de.json');
|
|
||||||
try {
|
|
||||||
const germanData = JSON.parse(await fs.readFile(germanJsonPath, 'utf8'));
|
|
||||||
|
|
||||||
// Xác định cấu trúc của German data
|
|
||||||
let germanHero, germanPage, germanContent;
|
|
||||||
|
|
||||||
if (germanData.hero && germanData.page && germanData.content) {
|
|
||||||
germanHero = germanData.hero;
|
|
||||||
germanPage = germanData.page;
|
|
||||||
germanContent = germanData.content;
|
|
||||||
} else if (germanData.hero && germanData.termsHeader && germanData.sections) {
|
|
||||||
germanHero = germanData.hero;
|
|
||||||
germanPage = convertOldPageToNew(germanData);
|
|
||||||
germanContent = convertOldSectionsToNew(germanData);
|
|
||||||
}
|
|
||||||
|
|
||||||
const germanTerms = new Terms({
|
|
||||||
...termsDocument.toObject(),
|
|
||||||
_id: new mongoose.Types.ObjectId(), // Tạo ID mới
|
|
||||||
language: 'de',
|
|
||||||
hero: {
|
|
||||||
...termsDocument.hero,
|
|
||||||
title: germanHero.title || termsDocument.hero.title
|
|
||||||
},
|
|
||||||
page: {
|
|
||||||
...termsDocument.page,
|
|
||||||
title: germanPage.title || termsDocument.page.title
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
...termsDocument.content,
|
|
||||||
content: germanContent.content || termsDocument.content.content
|
|
||||||
},
|
|
||||||
isActive: true
|
|
||||||
});
|
|
||||||
|
|
||||||
await germanTerms.save();
|
|
||||||
console.log('German terms document created successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.log('German version not found or error:', error.message);
|
|
||||||
console.log('Continuing with English version only...');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in migrateTermsData:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chuyển đổi từ cấu trúc page cũ sang cấu trúc page mới
|
|
||||||
*/
|
|
||||||
function convertOldPageToNew(oldData) {
|
|
||||||
return {
|
|
||||||
title: oldData.termsHeader?.title || 'Terms & Conditions',
|
|
||||||
divider: oldData.termsHeader?.divider !== false,
|
|
||||||
sectionClass: oldData.termsHeader?.sectionClass || 'uk-section-default uk-section-overlap uk-section',
|
|
||||||
titleClass: oldData.termsHeader?.titleClass || 'text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center',
|
|
||||||
dividerClass: oldData.termsHeader?.dividerClass || 'uk-divider-small uk-text-left@m uk-text-center'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chuyển đổi từ cấu trúc sections cũ sang cấu trúc content mới
|
|
||||||
*/
|
|
||||||
function convertOldSectionsToNew(oldData) {
|
|
||||||
const contentItems = [];
|
|
||||||
|
|
||||||
// Thêm disclaimer đầu tiên nếu có
|
|
||||||
if (oldData.disclaimer?.text) {
|
|
||||||
contentItems.push({
|
|
||||||
type: 'paragraph',
|
|
||||||
text: oldData.disclaimer.text
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Thêm các sections
|
|
||||||
if (oldData.sections && Array.isArray(oldData.sections)) {
|
|
||||||
oldData.sections.forEach(section => {
|
|
||||||
if (section.title && section.content) {
|
|
||||||
const contentItem = {
|
|
||||||
type: 'section',
|
|
||||||
title: section.title,
|
|
||||||
content: section.content
|
|
||||||
};
|
|
||||||
|
|
||||||
// Thêm subsections nếu có
|
|
||||||
if (section.subsections && section.subsections.length > 0) {
|
|
||||||
contentItem.subsections = section.subsections.map(sub => ({
|
|
||||||
type: 'note',
|
|
||||||
text: sub
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Thêm cancellation table nếu có
|
|
||||||
if (section.fees) {
|
|
||||||
contentItem.subsections = contentItem.subsections || [];
|
|
||||||
contentItem.subsections.push({
|
|
||||||
type: 'cancellation_table',
|
|
||||||
title: 'Standard Cancellation Fees',
|
|
||||||
items: Object.entries(section.fees).map(([key, value]) => `${key}: ${value}`)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
contentItems.push(contentItem);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Thêm footer note nếu có
|
|
||||||
if (oldData.footerNote?.text) {
|
|
||||||
contentItems.push({
|
|
||||||
type: 'paragraph',
|
|
||||||
text: oldData.footerNote.text
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sectionClass: oldData.layout?.termsSectionClass || 'uk-section-muted uk-section-overlap uk-section',
|
|
||||||
textClass: oldData.layout?.textContentClass || 'uk-panel uk-margin text-[1vw]',
|
|
||||||
content: contentItems
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hàm backup data trước khi migration
|
|
||||||
*/
|
|
||||||
async function backupExistingData() {
|
|
||||||
try {
|
|
||||||
console.log('Creating backup of existing terms data...');
|
|
||||||
const existingTerms = await Terms.find({});
|
|
||||||
|
|
||||||
if (existingTerms.length > 0) {
|
|
||||||
const backupPath = path.join(__dirname, '../backups/terms-backup-' + Date.now() + '.json');
|
|
||||||
|
|
||||||
// Tạo thư mục backup nếu chưa có
|
|
||||||
await fs.mkdir(path.dirname(backupPath), { recursive: true });
|
|
||||||
|
|
||||||
await fs.writeFile(backupPath, JSON.stringify(existingTerms, null, 2));
|
|
||||||
console.log(`Backup created at: ${backupPath}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Backup error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chạy migration nếu được gọi trực tiếp
|
|
||||||
if (require.main === module) {
|
|
||||||
migrate();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { migrate, migrateTermsData };
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
require("dotenv").config();
|
|
||||||
const fs = require("fs").promises;
|
|
||||||
const path = require("path");
|
|
||||||
const connectDB = require("../config/database");
|
|
||||||
const Activity = require("../models/activity");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform activities.json data to match Activity model schema
|
|
||||||
*/
|
|
||||||
function transformActivity(source, index, heroData) {
|
|
||||||
// Return a document that preserves the main activity fields and also
|
|
||||||
// keeps the detailed camp information (from `camp-detail`) under the
|
|
||||||
// `campDetail` key so it can be queried later.
|
|
||||||
return {
|
|
||||||
// Add hero section from global hero data if available (support activities/booking variants)
|
|
||||||
hero: heroData && Array.isArray(heroData) && heroData.length > 0 ? {
|
|
||||||
titleActivities: heroData[0].titleActivities || heroData[0].title || "",
|
|
||||||
titleBooking: heroData[0].titleBooking || heroData[0].title || "",
|
|
||||||
bannerImageActivities: heroData[0].bannerImageActivities || heroData[0].bannerImage || "",
|
|
||||||
bannerImageBooking: heroData[0].bannerImageBooking || heroData[0].bannerImage || "",
|
|
||||||
} : {
|
|
||||||
titleActivities: "",
|
|
||||||
titleBooking: "",
|
|
||||||
bannerImageActivities: "",
|
|
||||||
bannerImageBooking: "",
|
|
||||||
},
|
|
||||||
name: source.name || "",
|
|
||||||
price: source.price || 0,
|
|
||||||
priceText: source.priceText || `from ${source.price || 0} USD`,
|
|
||||||
season: Array.isArray(source.season) ? source.season : [],
|
|
||||||
age:
|
|
||||||
Array.isArray(source.age) && source.age.length === 2
|
|
||||||
? source.age
|
|
||||||
: [12, 18],
|
|
||||||
locations: Array.isArray(source.locations) ? source.locations : [],
|
|
||||||
image: source.image || "",
|
|
||||||
link: source.link || "",
|
|
||||||
program: source.program || "",
|
|
||||||
rating: source.rating || 4,
|
|
||||||
isActive: typeof source.isActive === 'boolean' ? source.isActive : true,
|
|
||||||
order: typeof source.order === 'number' ? source.order : index,
|
|
||||||
// Keep the rich camp detail under a schema-friendly key
|
|
||||||
campDetail: source['camp-detail'] || source.campDetail || {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migration: activities
|
|
||||||
* Import activities from data/activities.json into MongoDB
|
|
||||||
*/
|
|
||||||
async function migrate() {
|
|
||||||
try {
|
|
||||||
await connectDB();
|
|
||||||
console.log("Starting migration: activities...");
|
|
||||||
|
|
||||||
// Read data file
|
|
||||||
const dataPath = path.join(__dirname, "../data/activities.json");
|
|
||||||
console.log(`Reading data from ${dataPath}...`);
|
|
||||||
|
|
||||||
// Use fs.existsSync and fs.readFileSync for synchronous check and read
|
|
||||||
const fsSync = require("fs");
|
|
||||||
if (!fsSync.existsSync(dataPath)) {
|
|
||||||
throw new Error("Data file not found!");
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawData = fsSync.readFileSync(dataPath, "utf8");
|
|
||||||
const data = JSON.parse(rawData);
|
|
||||||
|
|
||||||
// Handle new data structure
|
|
||||||
const activitiesData = Array.isArray(data) ? data : data.camps || [];
|
|
||||||
const filtersData = Array.isArray(data) ? [] : data.filter || [];
|
|
||||||
const heroData = Array.isArray(data) ? null : data.hero || null;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Found ${activitiesData.length} activities and ${filtersData.length} filter groups to migrate.`
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Migrate Activities ---
|
|
||||||
if (activitiesData.length > 0) {
|
|
||||||
console.log("Migrating activities...");
|
|
||||||
|
|
||||||
// Transform data if needed (using the existing transformActivity for consistency, or a new one if structure changed)
|
|
||||||
const activitiesToInsert = activitiesData.map(
|
|
||||||
(source, index) => transformActivity(source, index, heroData) // Pass heroData to transform function
|
|
||||||
);
|
|
||||||
|
|
||||||
const insertedActivities = await Activity.insertMany(activitiesToInsert, {
|
|
||||||
ordered: false,
|
|
||||||
});
|
|
||||||
console.log(`Inserted ${insertedActivities.length} activities.`);
|
|
||||||
} else {
|
|
||||||
console.log("No activities to migrate.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Migrate Filters ---
|
|
||||||
if (filtersData.length > 0) {
|
|
||||||
console.log("Migrating activity filters...");
|
|
||||||
|
|
||||||
// Deduplicate filters by value (value must be unique per model)
|
|
||||||
const seen = new Map();
|
|
||||||
const filtersToUpsert = [];
|
|
||||||
filtersData.forEach((item, index) => {
|
|
||||||
// sanitize incoming filter items (remove any unexpected keys such as `count`)
|
|
||||||
const sanitizeItems = (arr) =>
|
|
||||||
(Array.isArray(arr) ? arr : [])
|
|
||||||
.map((it) => ({
|
|
||||||
value: (it && it.value) ? it.value.toString().trim() : "",
|
|
||||||
label: (it && it.label) ? it.label.toString().trim() : "",
|
|
||||||
}))
|
|
||||||
.filter((it) => it.value && it.label);
|
|
||||||
|
|
||||||
const f = {
|
|
||||||
label: item.label || item.name || `Filter ${index + 1}`,
|
|
||||||
value: (item.value || (item.label || item.name || `filter-${index + 1}`))
|
|
||||||
.toString()
|
|
||||||
.trim(),
|
|
||||||
items: sanitizeItems(item.items),
|
|
||||||
order: item.order || index + 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!f.value) return; // skip invalid
|
|
||||||
|
|
||||||
if (seen.has(f.value)) {
|
|
||||||
// merge items if duplicate in source (merge by `value`, prefer first occurrence)
|
|
||||||
const existing = seen.get(f.value);
|
|
||||||
const mergedMap = new Map();
|
|
||||||
[...existing.items, ...f.items].forEach((it) => {
|
|
||||||
if (it && it.value) mergedMap.set(it.value, it);
|
|
||||||
});
|
|
||||||
existing.items = Array.from(mergedMap.values());
|
|
||||||
existing.order = Math.min(existing.order, f.order);
|
|
||||||
} else {
|
|
||||||
seen.set(f.value, f);
|
|
||||||
filtersToUpsert.push(f);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filtersToUpsert.length === 0) {
|
|
||||||
console.log("No valid activity filters to migrate after dedupe.");
|
|
||||||
} else {
|
|
||||||
// Use bulkWrite with upsert to avoid duplicate-key errors and to update existing docs
|
|
||||||
const bulkOps = filtersToUpsert.map((f) => ({
|
|
||||||
updateOne: {
|
|
||||||
filter: { value: f.value },
|
|
||||||
update: { $set: { label: f.label, items: f.items, order: f.order } },
|
|
||||||
upsert: true,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Upsert the consolidated filters into a single Activity document
|
|
||||||
// that is used to store global filter definitions (marked by isFiltersDoc: true)
|
|
||||||
const upsertResult = await Activity.findOneAndUpdate(
|
|
||||||
{ isFiltersDoc: true },
|
|
||||||
{ $set: { filters: filtersToUpsert, isFiltersDoc: true } },
|
|
||||||
{ upsert: true, new: true }
|
|
||||||
);
|
|
||||||
console.log(`Upserted filters into Activity document id=${upsertResult._id} groups=${(upsertResult.filters || []).length}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("No activity filters to migrate.");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Migration activities completed successfully!");
|
|
||||||
|
|
||||||
const mongoose = require("mongoose");
|
|
||||||
await mongoose.disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Migration error:", error);
|
|
||||||
|
|
||||||
// If some documents failed but others succeeded, log partial success
|
|
||||||
if (error.insertedDocs && error.insertedDocs.length > 0) {
|
|
||||||
console.log(
|
|
||||||
`Partial success: ${error.insertedDocs.length} documents inserted`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rollback: Delete all activities (use with caution!)
|
|
||||||
*/
|
|
||||||
async function rollback() {
|
|
||||||
try {
|
|
||||||
await connectDB();
|
|
||||||
console.log("Starting rollback...");
|
|
||||||
|
|
||||||
const actResult = await Activity.deleteMany({});
|
|
||||||
console.log(`✅ Deleted ${actResult.deletedCount} activities`);
|
|
||||||
|
|
||||||
// Remove any filters document stored as an Activity with isFiltersDoc=true
|
|
||||||
const filterResult = await Activity.deleteMany({ isFiltersDoc: true });
|
|
||||||
console.log(`✅ Deleted ${filterResult.deletedCount} activity filters documents`);
|
|
||||||
|
|
||||||
console.log("Rollback completed successfully!");
|
|
||||||
|
|
||||||
const mongoose = require("mongoose");
|
|
||||||
await mongoose.disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Rollback error:", error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run migration or rollback based on command line arguments
|
|
||||||
if (require.main === module) {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
|
|
||||||
if (args.includes("--rollback")) {
|
|
||||||
rollback();
|
|
||||||
} else {
|
|
||||||
migrate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {migrate, rollback};
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
require('dotenv').config();
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const path = require('path');
|
|
||||||
const mongoose = require('mongoose');
|
|
||||||
const Home = require('../models/home'); // Đảm bảo đường dẫn đúng tới file model
|
|
||||||
|
|
||||||
// 1. Đọc file JSON
|
|
||||||
async function loadHomeData() {
|
|
||||||
// Đảm bảo đường dẫn đúng tới file json
|
|
||||||
const filePath = path.join(__dirname, '..', 'data', 'home.json');
|
|
||||||
const raw = await fs.readFile(filePath, 'utf8');
|
|
||||||
return JSON.parse(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Hàm Transform: Đổ dữ liệu từ JSON (source) vào đúng Schema
|
|
||||||
function transformHome(source) {
|
|
||||||
return {
|
|
||||||
// --- Hero Section ---
|
|
||||||
hero: {
|
|
||||||
title: source.hero?.title || "",
|
|
||||||
description: source.hero?.description || "",
|
|
||||||
backgroundImage: source.hero?.backgroundImage || "",
|
|
||||||
button: {
|
|
||||||
label: source.hero?.button?.label || "Book Now",
|
|
||||||
href: source.hero?.button?.href || "/booking"
|
|
||||||
},
|
|
||||||
contactBox: {
|
|
||||||
welcomeText: source.hero?.contactBox?.welcomeText || "",
|
|
||||||
phone: {
|
|
||||||
label: source.hero?.contactBox?.phone?.label || "Call us",
|
|
||||||
number: source.hero?.contactBox?.phone?.number || "",
|
|
||||||
href: source.hero?.contactBox?.phone?.href || ""
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
label: source.hero?.contactBox?.email?.label || "Email",
|
|
||||||
address: source.hero?.contactBox?.email?.address || "",
|
|
||||||
href: source.hero?.contactBox?.email?.href || ""
|
|
||||||
},
|
|
||||||
workingHours: {
|
|
||||||
label: source.hero?.contactBox?.workingHours?.label || "Working Hours",
|
|
||||||
hours: source.hero?.contactBox?.workingHours?.hours || ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- About Section ---
|
|
||||||
about: {
|
|
||||||
title: source.about?.title || "",
|
|
||||||
subtitle: source.about?.subtitle || "",
|
|
||||||
description: source.about?.description || "",
|
|
||||||
images: {
|
|
||||||
mainImage1: source.about?.images?.mainImage1 || "",
|
|
||||||
mainImage2: source.about?.images?.mainImage2 || "",
|
|
||||||
avatars: Array.isArray(source.about?.images?.avatars) ? source.about.images.avatars : []
|
|
||||||
},
|
|
||||||
features: Array.isArray(source.about?.features) ? source.about.features : [],
|
|
||||||
quote: source.about?.quote || "",
|
|
||||||
button: {
|
|
||||||
label: source.about?.button?.label || "",
|
|
||||||
href: source.about?.button?.href || ""
|
|
||||||
},
|
|
||||||
stats: {
|
|
||||||
customerCount: source.about?.stats?.customerCount || 0,
|
|
||||||
customerLabel: source.about?.stats?.customerLabel || ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Mission & Vision ---
|
|
||||||
missionVision: {
|
|
||||||
title: source.missionVision?.title || "",
|
|
||||||
subtitle: source.missionVision?.subtitle || "",
|
|
||||||
backgroundImage: source.missionVision?.backgroundImage || "",
|
|
||||||
cards: Array.isArray(source.missionVision?.cards) ? source.missionVision.cards : []
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Why Choose Us ---
|
|
||||||
whyChooseUs: {
|
|
||||||
title: source.whyChooseUs?.title || "",
|
|
||||||
subtitle: source.whyChooseUs?.subtitle || "",
|
|
||||||
description: source.whyChooseUs?.description || "",
|
|
||||||
button: source.whyChooseUs?.button || {},
|
|
||||||
features: Array.isArray(source.whyChooseUs?.features) ? source.whyChooseUs.features : [],
|
|
||||||
tags: Array.isArray(source.whyChooseUs?.tags) ? source.whyChooseUs.tags : [],
|
|
||||||
cta: source.whyChooseUs?.cta || {}
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Activities ---
|
|
||||||
activities: {
|
|
||||||
cards: Array.isArray(source.activities?.cards) ? source.activities.cards : []
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- FAQ ---
|
|
||||||
faq: {
|
|
||||||
title: source.faq?.title || "",
|
|
||||||
subtitle: source.faq?.subtitle || "",
|
|
||||||
description: source.faq?.description || "",
|
|
||||||
image: source.faq?.image || "",
|
|
||||||
contact: source.faq?.contact || {},
|
|
||||||
questions: Array.isArray(source.faq?.questions) ? source.faq.questions : []
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Partners ---
|
|
||||||
partners: {
|
|
||||||
title: source.partners?.title || "",
|
|
||||||
subtitle: source.partners?.subtitle || "",
|
|
||||||
backgroundImage: source.partners?.backgroundImage || "",
|
|
||||||
logos: Array.isArray(source.partners?.logos) ? source.partners.logos : [],
|
|
||||||
cta: source.partners?.cta || {}
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Programs ---
|
|
||||||
programs: {
|
|
||||||
title: source.programs?.title || "",
|
|
||||||
subtitle: source.programs?.subtitle || "",
|
|
||||||
button: source.programs?.button || {},
|
|
||||||
card: {
|
|
||||||
pricePrefix: source.programs?.card?.pricePrefix || "from",
|
|
||||||
priceSuffix: source.programs?.card?.priceSuffix || "USD",
|
|
||||||
buttonLabel: source.programs?.card?.buttonLabel || "Camp Detail",
|
|
||||||
buttonHref: source.programs?.card?.buttonHref || "/camp-profiles"
|
|
||||||
},
|
|
||||||
items: Array.isArray(source.programs?.items) ? source.programs.items : []
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// --- Newsletter ---
|
|
||||||
newsletter: {
|
|
||||||
title: source.newsletter?.title || "",
|
|
||||||
subtitle: source.newsletter?.subtitle || "",
|
|
||||||
description: source.newsletter?.description || "",
|
|
||||||
image: source.newsletter?.image || "",
|
|
||||||
decorativeImage: source.newsletter?.decorativeImage || "",
|
|
||||||
button: {
|
|
||||||
label: source.newsletter?.button?.label || "",
|
|
||||||
placeholder: source.newsletter?.button?.placeholder || "",
|
|
||||||
href: source.newsletter?.button?.href || ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Latest Posts ---
|
|
||||||
latestPosts: {
|
|
||||||
title: source.latestPosts?.title || "",
|
|
||||||
subtitle: source.latestPosts?.subtitle || "",
|
|
||||||
searchPlaceholder: source.latestPosts?.searchPlaceholder || "",
|
|
||||||
sidebarTitle: source.latestPosts?.sidebarTitle || "",
|
|
||||||
blogPosts: Array.isArray(source.latestPosts?.blogPosts) ? source.latestPosts.blogPosts : [],
|
|
||||||
sidebarPosts: Array.isArray(source.latestPosts?.sidebarPosts) ? source.latestPosts.sidebarPosts : [],
|
|
||||||
featuredCard: source.latestPosts?.featuredCard || {}
|
|
||||||
},
|
|
||||||
|
|
||||||
updatedAt: new Date()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Chạy Migration
|
|
||||||
async function migrate() {
|
|
||||||
try {
|
|
||||||
// Kết nối DB
|
|
||||||
await mongoose.connect(process.env.MONGODB_URI);
|
|
||||||
console.log('✅ Connected to MongoDB');
|
|
||||||
|
|
||||||
// A. Lấy dữ liệu thô
|
|
||||||
const rawData = await loadHomeData();
|
|
||||||
console.log('📖 Data loaded from JSON');
|
|
||||||
|
|
||||||
// B. Chuẩn hóa dữ liệu theo Schema
|
|
||||||
const homeData = transformHome(rawData);
|
|
||||||
|
|
||||||
// C. Lưu vào DB (Upsert: Có rồi thì update, chưa có thì tạo)
|
|
||||||
const existingDoc = await Home.findOne().sort({ updatedAt: -1 });
|
|
||||||
|
|
||||||
if (existingDoc) {
|
|
||||||
console.log('📝 Updating existing Home document...');
|
|
||||||
await Home.findByIdAndUpdate(existingDoc._id, { $set: homeData }, { new: true });
|
|
||||||
} else {
|
|
||||||
console.log('📝 Creating NEW Home document...');
|
|
||||||
await Home.create(homeData);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✨ Migration completed successfully!');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Migration failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await mongoose.connection.close();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
migrate();
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
const mongoose = require('mongoose');
|
|
||||||
const Booking = require('../models/booking');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const filePath = path.join(__dirname, '..', 'data', 'booking.json');
|
|
||||||
let raw = '{}';
|
|
||||||
try {
|
|
||||||
raw = fs.readFileSync(filePath, 'utf8');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Could not read booking.json at', filePath);
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = JSON.parse(raw || '{}');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Invalid JSON in booking.json:', e.message);
|
|
||||||
process.exit(3);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove _id fields recursively to avoid conflicts
|
|
||||||
function stripIds(obj) {
|
|
||||||
if (Array.isArray(obj)) return obj.map(i => stripIds(i));
|
|
||||||
if (obj && typeof obj === 'object') {
|
|
||||||
const out = {};
|
|
||||||
for (const k in obj) {
|
|
||||||
if (k !== '_id') out[k] = stripIds(obj[k]);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
data = stripIds(data);
|
|
||||||
|
|
||||||
// Normalize vouchers to an array of objects so Mongoose casting won't fail
|
|
||||||
function normalizeVouchers(doc) {
|
|
||||||
if (!doc) return;
|
|
||||||
// support root-level `vouchers` and `configuration.vouchers`
|
|
||||||
let v = doc.vouchers || (doc.configuration && doc.configuration.vouchers);
|
|
||||||
if (!v) return;
|
|
||||||
|
|
||||||
// Try to parse stringified arrays (may use single quotes or JS literal)
|
|
||||||
if (typeof v === 'string') {
|
|
||||||
try {
|
|
||||||
v = JSON.parse(v);
|
|
||||||
} catch (e1) {
|
|
||||||
try {
|
|
||||||
// try parsing JS object-literal style strings
|
|
||||||
// eslint-disable-next-line no-new-func
|
|
||||||
v = (new Function('return ' + v))();
|
|
||||||
} catch (e2) {
|
|
||||||
// fallback: attempt to extract codes by splitting on commas
|
|
||||||
v = v.split && v.split(',').map(s => s.trim()).filter(Boolean).map(s => ({ validCodes: s, type: 'unknown', value: null }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize array items into objects
|
|
||||||
if (Array.isArray(v)) {
|
|
||||||
v = v.map(item => {
|
|
||||||
if (typeof item === 'string') return { validCodes: item, type: 'unknown', value: null };
|
|
||||||
if (item && typeof item === 'object') {
|
|
||||||
return {
|
|
||||||
validCodes: item.validCodes || item.code || '',
|
|
||||||
type: item.type || '',
|
|
||||||
value: typeof item.value === 'number' ? item.value : (item.amount && Number(item.amount)) || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// If Booking schema expects array of strings, convert objects -> string codes
|
|
||||||
try {
|
|
||||||
const Booking = require('../models/booking');
|
|
||||||
const pathType = Booking.schema.path('vouchers') || Booking.schema.path('configuration.vouchers');
|
|
||||||
if (pathType && pathType.instance === 'Array' && pathType.caster && pathType.caster.instance === 'String') {
|
|
||||||
const mapped = (v || []).map(it => (typeof it === 'string' ? it : (it && typeof it === 'object' ? it.validCodes || JSON.stringify(it) : String(it))));
|
|
||||||
if (doc.vouchers) doc.vouchers = mapped;
|
|
||||||
else if (doc.configuration) doc.configuration.vouchers = mapped;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// ignore and keep object form
|
|
||||||
}
|
|
||||||
|
|
||||||
if (doc.vouchers) doc.vouchers = v;
|
|
||||||
else if (doc.configuration) doc.configuration.vouchers = v;
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeVouchers(data);
|
|
||||||
|
|
||||||
// Also normalize discounts (some inputs have them stringified or as objects)
|
|
||||||
function normalizeDiscounts(doc) {
|
|
||||||
if (!doc) return;
|
|
||||||
let d = doc.discounts || (doc.configuration && doc.configuration.discounts);
|
|
||||||
if (!d) return;
|
|
||||||
|
|
||||||
if (typeof d === 'string') {
|
|
||||||
try {
|
|
||||||
d = JSON.parse(d);
|
|
||||||
} catch (e1) {
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line no-new-func
|
|
||||||
d = (new Function('return ' + d))();
|
|
||||||
} catch (e2) {
|
|
||||||
d = d.split && d.split('\n').map(s => s.trim()).filter(Boolean).map(s => ({ id: '', name: s }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(d)) {
|
|
||||||
d = d.map(item => {
|
|
||||||
if (typeof item === 'string') return { id: '', name: item };
|
|
||||||
if (item && typeof item === 'object') return item;
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const Booking = require('../models/booking');
|
|
||||||
const pathType = Booking.schema.path('discounts') || Booking.schema.path('configuration.discounts');
|
|
||||||
if (pathType && pathType.instance === 'Array' && pathType.caster && pathType.caster.instance === 'String') {
|
|
||||||
const mapped = (d || []).map(it => (typeof it === 'string' ? it : (it.id || it.name || JSON.stringify(it))));
|
|
||||||
if (doc.discounts) doc.discounts = mapped;
|
|
||||||
else if (doc.configuration) doc.configuration.discounts = mapped;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
if (doc.discounts) doc.discounts = d;
|
|
||||||
else if (doc.configuration) doc.configuration.discounts = d;
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeDiscounts(data);
|
|
||||||
|
|
||||||
const dryRun = process.argv.includes('--dry-run') || process.argv.includes('-n');
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
try {
|
|
||||||
const dbUri = process.env.MONGODB_URI || process.env.MONGO_URL || 'mongodb://127.0.0.1:27017/cms';
|
|
||||||
console.log('Using DB URI:', dbUri);
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
console.log('\nDRY RUN - preview of document to upsert:\n');
|
|
||||||
console.log(JSON.stringify(data, null, 2));
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
await mongoose.connect(dbUri, { useNewUrlParser: true, useUnifiedTopology: true });
|
|
||||||
console.log('Connected to MongoDB');
|
|
||||||
|
|
||||||
const result = await Booking.findOneAndUpdate({}, data, {
|
|
||||||
upsert: true,
|
|
||||||
new: true,
|
|
||||||
setDefaultsOnInsert: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Upsert complete. Document id:', result._id.toString());
|
|
||||||
console.log('Summary: programs=', (data.programs || []).length, 'camps=', (data.camps || []).length);
|
|
||||||
|
|
||||||
await mongoose.disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Migration failed:', err && err.message ? err.message : err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
require('dotenv').config();
|
|
||||||
const connectDB = require('../config/database');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migration: migrate_header_ggcamp
|
|
||||||
* Created: 21:40:26 11/12/2025
|
|
||||||
*/
|
|
||||||
async function migrate() {
|
|
||||||
try {
|
|
||||||
// Kết nối database
|
|
||||||
await connectDB();
|
|
||||||
console.log('Starting migration: migrate_header_ggcamp...');
|
|
||||||
|
|
||||||
const mongoose = require('mongoose');
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const path = require('path');
|
|
||||||
const Header = require('../models/header');
|
|
||||||
|
|
||||||
// Đọc dữ liệu từ header.json
|
|
||||||
const headerDataPath = path.join(__dirname, '../data/header.json');
|
|
||||||
const headerData = JSON.parse(await fs.readFile(headerDataPath, 'utf8'));
|
|
||||||
|
|
||||||
// Xóa tất cả documents header cũ
|
|
||||||
await Header.deleteMany({});
|
|
||||||
|
|
||||||
// Tạo header mới với dữ liệu từ JSON (topbar và logo)
|
|
||||||
await Header.create({
|
|
||||||
name: 'default',
|
|
||||||
topbar: headerData.topbar,
|
|
||||||
logo: headerData.logo
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ Header 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 };
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
require('dotenv').config();
|
|
||||||
const connectDB = require('../config/database');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migration: migrate_menu_header_ggcamp
|
|
||||||
* Created: 21:40:38 11/12/2025
|
|
||||||
*/
|
|
||||||
async function migrate() {
|
|
||||||
try {
|
|
||||||
// Kết nối database
|
|
||||||
await connectDB();
|
|
||||||
console.log('Starting migration: migrate_menu_header_ggcamp...');
|
|
||||||
|
|
||||||
const mongoose = require('mongoose');
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const path = require('path');
|
|
||||||
const Menu = require('../models/menuHeader');
|
|
||||||
|
|
||||||
// Xóa tất cả dữ liệu menu cũ
|
|
||||||
await Menu.deleteMany({});
|
|
||||||
|
|
||||||
// Đọc JSON file
|
|
||||||
const jsonPath = path.join(__dirname, '../data/menu-header.json');
|
|
||||||
const jsonData = JSON.parse(await fs.readFile(jsonPath, 'utf8'));
|
|
||||||
|
|
||||||
// Tạo menu items (đơn giản, không có fetch/programmes)
|
|
||||||
for (const menuData of jsonData.menus) {
|
|
||||||
// Chỉ giữ lại các field cần thiết: menuid, parent, title, url, order, type
|
|
||||||
const menuItem = {
|
|
||||||
menuid: menuData.menuid,
|
|
||||||
parent: menuData.parent || null,
|
|
||||||
title: menuData.title,
|
|
||||||
url: menuData.url,
|
|
||||||
order: menuData.order || 0,
|
|
||||||
type: menuData.type || 'static',
|
|
||||||
fetch: false, // Không dùng fetch
|
|
||||||
isActive: true // Mặc định active
|
|
||||||
};
|
|
||||||
await Menu.create(menuItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Menu header 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 };
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
require('dotenv').config();
|
|
||||||
const connectDB = require('../config/database');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migration: migrate_footer_ggcamp
|
|
||||||
* Created: 21:41:03 11/12/2025
|
|
||||||
*/
|
|
||||||
async function migrate() {
|
|
||||||
try {
|
|
||||||
// Kết nối database
|
|
||||||
await connectDB();
|
|
||||||
console.log('Starting migration: migrate_footer_ggcamp...');
|
|
||||||
|
|
||||||
const mongoose = require('mongoose');
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const path = require('path');
|
|
||||||
const Footer = require('../models/footer');
|
|
||||||
|
|
||||||
// Read footer.json file
|
|
||||||
const footerJsonPath = path.join(__dirname, '../data/footer.json');
|
|
||||||
const footerData = JSON.parse(await fs.readFile(footerJsonPath, 'utf8'));
|
|
||||||
|
|
||||||
// Migrate data using the model's static method
|
|
||||||
await Footer.migrateFromJson(footerData);
|
|
||||||
|
|
||||||
console.log('✅ Footer 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 };
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
require('dotenv').config();
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const path = require('path');
|
|
||||||
const connectDB = require('../config/database');
|
|
||||||
const Travel = require('../models/travel');
|
|
||||||
const mongoose = require('mongoose');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migration: travel
|
|
||||||
* Migrate Travel data từ travel.json
|
|
||||||
* Created: 2025-12-13
|
|
||||||
*/
|
|
||||||
async function migrate() {
|
|
||||||
try {
|
|
||||||
// Kết nối database
|
|
||||||
await connectDB();
|
|
||||||
console.log('Starting migration: travel...');
|
|
||||||
|
|
||||||
// Đọc file travel.json
|
|
||||||
const travelJsonPath = path.join(__dirname, '../data/travel.json');
|
|
||||||
console.log('Reading JSON file from:', travelJsonPath);
|
|
||||||
|
|
||||||
const travelData = JSON.parse(await fs.readFile(travelJsonPath, 'utf8'));
|
|
||||||
console.log('Travel data loaded successfully');
|
|
||||||
console.log('Data structure keys:', Object.keys(travelData));
|
|
||||||
|
|
||||||
// Thực hiện migration
|
|
||||||
await migrateTravelData(travelData);
|
|
||||||
|
|
||||||
console.log('Travel migration completed successfully!');
|
|
||||||
|
|
||||||
await mongoose.disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Migration error:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom migration logic cho travel data
|
|
||||||
*/
|
|
||||||
async function migrateTravelData(travelData) {
|
|
||||||
try {
|
|
||||||
console.log('Starting custom migration logic...');
|
|
||||||
|
|
||||||
// 1. Kiểm tra dữ liệu cũ
|
|
||||||
const existingTravel = await Travel.findOne({});
|
|
||||||
if (existingTravel) {
|
|
||||||
console.log(`Found existing travel document: ${existingTravel._id}`);
|
|
||||||
console.log('Deleting existing travel data...');
|
|
||||||
await Travel.deleteMany({});
|
|
||||||
console.log('Cleared existing travel data');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Chuyển đổi locations thành blog content blocks
|
|
||||||
const contentBlocks = [];
|
|
||||||
|
|
||||||
// If travelData already has posts (blog format), use the first post
|
|
||||||
if (Array.isArray(travelData.posts) && travelData.posts.length > 0) {
|
|
||||||
const firstPost = travelData.posts[0];
|
|
||||||
if (firstPost.content && Array.isArray(firstPost.content.blocks)) {
|
|
||||||
contentBlocks.push(...firstPost.content.blocks);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Thêm general description (legacy format)
|
|
||||||
if (travelData.general) {
|
|
||||||
contentBlocks.push({
|
|
||||||
type: 'paragraph',
|
|
||||||
data: {
|
|
||||||
text: travelData.general.description
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Thêm additional info như conclusion
|
|
||||||
if (travelData.general.additionalInfo) {
|
|
||||||
contentBlocks.push({
|
|
||||||
type: 'conclusion',
|
|
||||||
data: {
|
|
||||||
text: travelData.general.additionalInfo,
|
|
||||||
callToAction: {
|
|
||||||
text: '',
|
|
||||||
link: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chuyển đổi từng location thành blog blocks
|
|
||||||
if (travelData.locations && Array.isArray(travelData.locations)) {
|
|
||||||
travelData.locations.forEach(location => {
|
|
||||||
// Header cho location
|
|
||||||
contentBlocks.push({
|
|
||||||
type: 'header',
|
|
||||||
data: {
|
|
||||||
text: location.title,
|
|
||||||
level: 2
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Address information
|
|
||||||
const addressItems = [];
|
|
||||||
if (location.address) {
|
|
||||||
if (location.address.name) {
|
|
||||||
addressItems.push(`Name: ${location.address.name}`);
|
|
||||||
}
|
|
||||||
if (location.address.line2) {
|
|
||||||
addressItems.push(location.address.line2);
|
|
||||||
}
|
|
||||||
if (location.address.street) {
|
|
||||||
addressItems.push(`Street: ${location.address.street}`);
|
|
||||||
}
|
|
||||||
if (location.address.postalCode && location.address.city) {
|
|
||||||
const country = location.address.country ? `, ${location.address.country}` : '';
|
|
||||||
addressItems.push(`Location: ${location.address.postalCode} ${location.address.city}${country}`);
|
|
||||||
}
|
|
||||||
if (location.address.googleMapsUrl) {
|
|
||||||
addressItems.push(`Google Maps: <a href="${location.address.googleMapsUrl}" target="_blank">View on Map</a>`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addressItems.length > 0) {
|
|
||||||
contentBlocks.push({
|
|
||||||
type: 'list',
|
|
||||||
data: {
|
|
||||||
style: 'unordered',
|
|
||||||
items: addressItems
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Address note as conclusion
|
|
||||||
if (location.address?.note) {
|
|
||||||
contentBlocks.push({
|
|
||||||
type: 'conclusion',
|
|
||||||
data: {
|
|
||||||
text: location.address.note,
|
|
||||||
callToAction: {
|
|
||||||
text: '',
|
|
||||||
link: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contact information
|
|
||||||
const contactItems = [];
|
|
||||||
if (location.contact) {
|
|
||||||
if (location.contact.email) {
|
|
||||||
contactItems.push(`Email: ${location.contact.email}`);
|
|
||||||
}
|
|
||||||
if (location.contact.phone) {
|
|
||||||
contactItems.push(`Phone: ${location.contact.phone}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contactItems.length > 0) {
|
|
||||||
contentBlocks.push({
|
|
||||||
type: 'paragraph',
|
|
||||||
data: {
|
|
||||||
text: 'Contact Information:'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
contentBlocks.push({
|
|
||||||
type: 'list',
|
|
||||||
data: {
|
|
||||||
style: 'unordered',
|
|
||||||
items: contactItems
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule information
|
|
||||||
const scheduleItems = [];
|
|
||||||
if (location.schedule) {
|
|
||||||
if (location.schedule.arrival) {
|
|
||||||
scheduleItems.push(`Arrival: ${location.schedule.arrival}`);
|
|
||||||
}
|
|
||||||
if (location.schedule.departure) {
|
|
||||||
scheduleItems.push(`Departure: ${location.schedule.departure}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scheduleItems.length > 0) {
|
|
||||||
contentBlocks.push({
|
|
||||||
type: 'paragraph',
|
|
||||||
data: {
|
|
||||||
text: 'Schedule:'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
contentBlocks.push({
|
|
||||||
type: 'list',
|
|
||||||
data: {
|
|
||||||
style: 'unordered',
|
|
||||||
items: scheduleItems
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Tạo document mới cho travel
|
|
||||||
const travelDocument = new Travel({
|
|
||||||
hero: {
|
|
||||||
title: travelData.hero?.title || 'Travel Information',
|
|
||||||
backgroundImage: travelData.hero?.backgroundImage || ''
|
|
||||||
},
|
|
||||||
|
|
||||||
page: {
|
|
||||||
title: travelData.page?.title || travelData.general?.title || 'Go and Grow Camp Travel Information',
|
|
||||||
description: travelData.page?.description || travelData.general?.description || '',
|
|
||||||
year: travelData.page?.year || travelData.pageYear || undefined,
|
|
||||||
metadata: {
|
|
||||||
title: 'Travel Guide - Go and Grow Camp',
|
|
||||||
description: 'Everything you need to know about traveling to our camps'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
content: {
|
|
||||||
blocks: contentBlocks
|
|
||||||
},
|
|
||||||
|
|
||||||
enableScrollspy: true,
|
|
||||||
lastUpdated: new Date()
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Lưu vào database
|
|
||||||
await travelDocument.save();
|
|
||||||
console.log('Travel document saved successfully');
|
|
||||||
|
|
||||||
// 5. Log thông tin
|
|
||||||
console.log(`Created travel document with ID: ${travelDocument._id}`);
|
|
||||||
console.log(`Hero title: ${travelDocument.hero.title}`);
|
|
||||||
console.log(`Page title: ${travelDocument.page.title}`);
|
|
||||||
console.log(`Content blocks count: ${travelDocument.content.blocks.length}`);
|
|
||||||
console.log(`Converted ${travelData.locations?.length || 0} locations to blog blocks`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in migrateTravelData:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hàm backup data trước khi migration
|
|
||||||
*/
|
|
||||||
async function backupExistingData() {
|
|
||||||
try {
|
|
||||||
console.log('Creating backup of existing travel data...');
|
|
||||||
const existingTravel = await Travel.find({});
|
|
||||||
|
|
||||||
if (existingTravel.length > 0) {
|
|
||||||
const backupPath = path.join(__dirname, '../backups/travel-backup-' + Date.now() + '.json');
|
|
||||||
|
|
||||||
// Tạo thư mục backup nếu chưa có
|
|
||||||
await fs.mkdir(path.dirname(backupPath), { recursive: true });
|
|
||||||
|
|
||||||
await fs.writeFile(backupPath, JSON.stringify(existingTravel, null, 2));
|
|
||||||
console.log(`Backup created at: ${backupPath}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Backup error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chạy migration nếu được gọi trực tiếp
|
|
||||||
if (require.main === module) {
|
|
||||||
migrate();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { migrate, migrateTravelData };
|
|
||||||
182
scripts/2026_02_02_170000_create_blog_system.js
Normal file
182
scripts/2026_02_02_170000_create_blog_system.js
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const connectDB = require('../config/database');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: create_complete_blog_system
|
||||||
|
* Created: 17:00:00 2/2/2026
|
||||||
|
* Description: Tạo hoàn chỉnh hệ thống blog với categories, tags, posts và comments
|
||||||
|
*/
|
||||||
|
async function migrate() {
|
||||||
|
try {
|
||||||
|
// Kết nối database
|
||||||
|
await connectDB();
|
||||||
|
console.log('🚀 Starting migration: create_complete_blog_system...');
|
||||||
|
|
||||||
|
// Import models
|
||||||
|
const Blog = require('../models/blog');
|
||||||
|
const BlogCategory = require('../models/blogCategory');
|
||||||
|
const BlogTag = require('../models/blogTag');
|
||||||
|
const BlogComment = require('../models/blogComment');
|
||||||
|
const RecentPost = require('../models/recentPost');
|
||||||
|
|
||||||
|
console.log('✅ Blog models registered successfully');
|
||||||
|
|
||||||
|
// Load complete data
|
||||||
|
const dataPath = path.join(__dirname, '..', 'data', 'blog.json');
|
||||||
|
const rawData = await fs.readFile(dataPath, 'utf8');
|
||||||
|
const data = JSON.parse(rawData);
|
||||||
|
|
||||||
|
console.log('📖 Complete blog data loaded from JSON');
|
||||||
|
|
||||||
|
// Clear existing data
|
||||||
|
console.log('🧹 Clearing existing blog data...');
|
||||||
|
await BlogComment.deleteMany({});
|
||||||
|
await Blog.deleteMany({});
|
||||||
|
await BlogCategory.deleteMany({});
|
||||||
|
await BlogTag.deleteMany({});
|
||||||
|
await RecentPost.deleteMany({});
|
||||||
|
console.log('✅ Existing data cleared');
|
||||||
|
|
||||||
|
// 1. Create categories
|
||||||
|
console.log('📝 Creating categories...');
|
||||||
|
const createdCategories = [];
|
||||||
|
for (const categoryData of data.categories) {
|
||||||
|
const category = new BlogCategory(categoryData);
|
||||||
|
await category.save();
|
||||||
|
createdCategories.push(category);
|
||||||
|
console.log(`✅ Created category: ${category.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create tags
|
||||||
|
console.log('📝 Creating tags...');
|
||||||
|
const createdTags = [];
|
||||||
|
for (const tagData of data.tags) {
|
||||||
|
const tag = new BlogTag(tagData);
|
||||||
|
await tag.save();
|
||||||
|
createdTags.push(tag);
|
||||||
|
console.log(`✅ Created tag: ${tag.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create blog posts
|
||||||
|
console.log('📝 Creating blog posts...');
|
||||||
|
const createdPosts = [];
|
||||||
|
for (const postData of data.posts) {
|
||||||
|
const post = new Blog(postData);
|
||||||
|
await post.save();
|
||||||
|
createdPosts.push(post);
|
||||||
|
console.log(`✅ Created blog post: ${post.title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create comments
|
||||||
|
console.log('💬 Creating comments...');
|
||||||
|
let createdCommentsCount = 0;
|
||||||
|
|
||||||
|
for (const commentData of data.comments) {
|
||||||
|
// Find the blog post by slug
|
||||||
|
const blog = await Blog.findOne({
|
||||||
|
slug: commentData.postSlug,
|
||||||
|
status: 'published'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (blog) {
|
||||||
|
const comment = new BlogComment({
|
||||||
|
postId: blog._id,
|
||||||
|
authorName: commentData.authorName,
|
||||||
|
authorAvatar: commentData.authorAvatar,
|
||||||
|
content: commentData.content,
|
||||||
|
createdAt: commentData.createdAt,
|
||||||
|
status: commentData.status
|
||||||
|
});
|
||||||
|
|
||||||
|
await comment.save();
|
||||||
|
createdCommentsCount++;
|
||||||
|
console.log(`✅ Created comment by ${comment.authorName} for: ${blog.title}`);
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ Blog post not found for slug: ${commentData.postSlug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Update category post counts
|
||||||
|
console.log('📊 Updating category post counts...');
|
||||||
|
for (const category of createdCategories) {
|
||||||
|
await category.updatePostCount();
|
||||||
|
console.log(`📊 Category "${category.name}": ${category.postCount} posts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Update tag post counts
|
||||||
|
console.log('📊 Updating tag post counts...');
|
||||||
|
for (const tag of createdTags) {
|
||||||
|
await tag.updatePostCount();
|
||||||
|
console.log(`📊 Tag "${tag.name}": ${tag.postCount} posts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Update comments count in blog posts
|
||||||
|
console.log('📊 Updating comments count in blog posts...');
|
||||||
|
const blogs = await Blog.find({ status: 'published' });
|
||||||
|
|
||||||
|
for (const blog of blogs) {
|
||||||
|
const commentsCount = await BlogComment.countDocuments({
|
||||||
|
postId: blog._id,
|
||||||
|
status: 'approved'
|
||||||
|
});
|
||||||
|
|
||||||
|
blog.commentsCount = commentsCount;
|
||||||
|
await blog.save();
|
||||||
|
|
||||||
|
if (commentsCount > 0) {
|
||||||
|
console.log(`📊 Updated comments count for "${blog.title}": ${commentsCount} comments`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Sync recent posts
|
||||||
|
console.log('🔄 Syncing recent posts...');
|
||||||
|
await RecentPost.syncFromBlogs(5);
|
||||||
|
const recentPostsCount = await RecentPost.countDocuments();
|
||||||
|
console.log(`🔄 Synced ${recentPostsCount} recent posts`);
|
||||||
|
|
||||||
|
// Final summary
|
||||||
|
console.log('\n🎉 Migration create_complete_blog_system completed successfully!');
|
||||||
|
console.log('=' .repeat(60));
|
||||||
|
console.log('📊 MIGRATION SUMMARY:');
|
||||||
|
console.log(` ✅ Categories: ${createdCategories.length}`);
|
||||||
|
console.log(` ✅ Tags: ${createdTags.length}`);
|
||||||
|
console.log(` ✅ Blog Posts: ${createdPosts.length}`);
|
||||||
|
console.log(` ✅ Comments: ${createdCommentsCount}`);
|
||||||
|
console.log(` ✅ Recent Posts: ${recentPostsCount}`);
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
const totalPublishedPosts = await Blog.countDocuments({ status: 'published' });
|
||||||
|
const totalFeaturedPosts = await Blog.countDocuments({ status: 'published', isFeatured: true });
|
||||||
|
const totalApprovedComments = await BlogComment.countDocuments({ status: 'approved' });
|
||||||
|
|
||||||
|
console.log('\n📈 SYSTEM STATISTICS:');
|
||||||
|
console.log(` 📝 Published Posts: ${totalPublishedPosts}`);
|
||||||
|
console.log(` ⭐ Featured Posts: ${totalFeaturedPosts}`);
|
||||||
|
console.log(` 💬 Approved Comments: ${totalApprovedComments}`);
|
||||||
|
|
||||||
|
console.log('\n🌐 ACCESS POINTS:');
|
||||||
|
console.log(' 📱 Admin Panel: http://localhost:3001/admin/blog');
|
||||||
|
console.log(' 🔗 API Endpoint: http://localhost:3001/api/blog');
|
||||||
|
console.log(' 📊 Categories API: http://localhost:3001/api/blog-categories');
|
||||||
|
console.log(' 🏷️ Tags API: http://localhost:3001/api/blog-tags');
|
||||||
|
|
||||||
|
console.log('\n✨ Blog system is now ready for use!');
|
||||||
|
console.log('=' .repeat(60));
|
||||||
|
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
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 };
|
||||||
71
scripts/2026_02_03_appointment.js
Normal file
71
scripts/2026_02_03_appointment.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Migration script for Appointment data
|
||||||
|
* Imports data from appointment.json to MongoDB
|
||||||
|
*
|
||||||
|
* Run: node scripts/2026_02_03_appointment.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
require("dotenv").config();
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
// Connect to MongoDB
|
||||||
|
const connectDB = async () => {
|
||||||
|
try {
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI);
|
||||||
|
console.log("MongoDB connected successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("MongoDB connection error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runMigration = async () => {
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
|
||||||
|
// Load Appointment model
|
||||||
|
const Appointment = require("../models/appointment");
|
||||||
|
|
||||||
|
// Load JSON data
|
||||||
|
const jsonPath = path.join(__dirname, "../data/appointment.json");
|
||||||
|
|
||||||
|
if (!fs.existsSync(jsonPath)) {
|
||||||
|
console.log("appointment.json not found, creating default data...");
|
||||||
|
const defaultData = {
|
||||||
|
hero: {
|
||||||
|
title: "Make Appointment",
|
||||||
|
backgroundImage: "",
|
||||||
|
subtitle: "",
|
||||||
|
heading: "",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
visaOptions: [],
|
||||||
|
form: {
|
||||||
|
heading: "Request Appointment",
|
||||||
|
fields: [],
|
||||||
|
submitButton: {
|
||||||
|
text: "Request Appointment",
|
||||||
|
icon: "fa-solid fa-arrow-right",
|
||||||
|
buttonClass: "theme-btn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await Appointment.migrateFromJson(defaultData);
|
||||||
|
} else {
|
||||||
|
const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
||||||
|
console.log("Loaded appointment.json data");
|
||||||
|
await Appointment.migrateFromJson(jsonData);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Appointment migration completed successfully!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Migration failed:", error);
|
||||||
|
} finally {
|
||||||
|
await mongoose.connection.close();
|
||||||
|
console.log("MongoDB connection closed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
runMigration();
|
||||||
22
server.js
22
server.js
@@ -32,6 +32,28 @@ app.set("layout extractStyles", true);
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
app.use(
|
||||||
|
"/assets",
|
||||||
|
(req, res, next) => {
|
||||||
|
// Cho phép mọi domain truy cập tài nguyên tĩnh
|
||||||
|
res.header("Access-Control-Allow-Origin", "*");
|
||||||
|
res.header("Access-Control-Allow-Methods", "GET");
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
express.static(path.join(__dirname, "assets")),
|
||||||
|
);
|
||||||
|
app.use(
|
||||||
|
"/uploads",
|
||||||
|
(req, res, next) => {
|
||||||
|
// Cho phép mọi domain truy cập tài nguyên tĩnh
|
||||||
|
res.header("Access-Control-Allow-Origin", "*");
|
||||||
|
res.header("Access-Control-Allow-Methods", "GET");
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
express.static(path.join(__dirname, "public", "uploads")),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Serve other public files
|
||||||
app.use(express.static(path.join(__dirname, "public")));
|
app.use(express.static(path.join(__dirname, "public")));
|
||||||
|
|
||||||
// Session configuration
|
// Session configuration
|
||||||
|
|||||||
791
views/admin/appointment/index.ejs
Normal file
791
views/admin/appointment/index.ejs
Normal file
@@ -0,0 +1,791 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0" style="color: var(--primary-dark);">
|
||||||
|
<%= title %>
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted mb-0">Edit content displayed on Appointment page</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="<%= frontendUrl %>/make-appointment/" class="btn btn-outline-primary" target="_blank">
|
||||||
|
<i class="fas fa-external-link-alt me-2"></i>View Appointment Page
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<form method="POST" class="content-with-fixed-buttons" id="appointmentForm"
|
||||||
|
action="/admin/appointment/update">
|
||||||
|
<!-- Hidden inputs for JSON data -->
|
||||||
|
<input type="hidden" name="hero" id="heroJson">
|
||||||
|
<input type="hidden" name="visaOptions" id="visaOptionsJson">
|
||||||
|
<input type="hidden" name="form" id="formJson">
|
||||||
|
|
||||||
|
<!-- Navigation Tabs -->
|
||||||
|
<div class="card shadow-sm border-0 mb-4">
|
||||||
|
<div class="card-header bg-white border-bottom">
|
||||||
|
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" data-bs-toggle="tab" href="#hero" role="tab">
|
||||||
|
<i class="fas fa-home me-2"></i>Hero
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#visaOptions" role="tab">
|
||||||
|
<i class="fas fa-passport me-2"></i>Visa Options
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#form" role="tab">
|
||||||
|
<i class="fas fa-envelope me-2"></i>Form
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#submissions" role="tab">
|
||||||
|
<i class="fas fa-list me-2"></i>Submissions
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content">
|
||||||
|
<!-- Hero Tab -->
|
||||||
|
<div class="tab-pane fade show active" id="hero" role="tabpanel">
|
||||||
|
<div class="card border shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-medium mb-3">Hero Section</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label fw-medium">Background Image</label>
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<input type="text" class="form-control" id="heroBackgroundImage"
|
||||||
|
name="heroBackgroundImage"
|
||||||
|
value="<%= data.hero?.backgroundImage || '' %>">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-primary btn-upload-image"
|
||||||
|
data-target-input="heroBackgroundImage"
|
||||||
|
data-image-type="appointment">
|
||||||
|
<i class="fas fa-upload me-1"></i>Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Recommended size: 1920x1080px</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div id="heroImagePreview" style="height: 200px;">
|
||||||
|
<% if (data.hero?.backgroundImage) { %>
|
||||||
|
<% let heroImgSrc=data.hero.backgroundImage; if (heroImgSrc &&
|
||||||
|
!heroImgSrc.startsWith('http://') &&
|
||||||
|
!heroImgSrc.startsWith('https://')) {
|
||||||
|
heroImgSrc=heroImgSrc.startsWith('/') ? heroImgSrc : '/' +
|
||||||
|
heroImgSrc; } %>
|
||||||
|
<img src="<%= heroImgSrc %>" class="img-thumbnail"
|
||||||
|
id="heroPreviewImg"
|
||||||
|
style="height: 200px; width: 100%; object-fit: cover;"
|
||||||
|
alt="Background image preview"
|
||||||
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||||
|
<div class="border rounded p-5 text-center text-muted"
|
||||||
|
style="height: 200px; display: none; align-items: center; justify-content: center;">
|
||||||
|
Image preview
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="border rounded p-5 text-center text-muted"
|
||||||
|
style="height: 200px; display: flex; align-items: center; justify-content: center;">
|
||||||
|
Image preview
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mt-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">Title</label>
|
||||||
|
<input type="text" class="form-control" id="heroTitle" name="heroTitle"
|
||||||
|
value="<%= data.hero?.title || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">Subtitle</label>
|
||||||
|
<input type="text" class="form-control" id="heroSubtitle"
|
||||||
|
name="heroSubtitle" value="<%= data.hero?.subtitle || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium">Heading</label>
|
||||||
|
<input type="text" class="form-control" id="heroHeading"
|
||||||
|
name="heroHeading" value="<%= data.hero?.heading || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium">Description</label>
|
||||||
|
<textarea class="form-control" id="heroDescription"
|
||||||
|
name="heroDescription"
|
||||||
|
rows="2"><%= data.hero?.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visa Options Tab -->
|
||||||
|
<div class="tab-pane fade" id="visaOptions" role="tabpanel">
|
||||||
|
<div class="card border shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="fw-medium mb-0">Visa Options</h6>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm"
|
||||||
|
onclick="addVisaOption()">
|
||||||
|
<i class="fas fa-plus"></i> Add Option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small">These options will appear in the visa type selection
|
||||||
|
dropdown on the appointment form.</p>
|
||||||
|
<div id="visaOptionsContainer">
|
||||||
|
<% if (data.visaOptions && data.visaOptions.length> 0) { %>
|
||||||
|
<% data.visaOptions.forEach((option, index)=> { %>
|
||||||
|
<div class="input-group mb-2 visa-option-item">
|
||||||
|
<span class="input-group-text"><i
|
||||||
|
class="fas fa-passport"></i></span>
|
||||||
|
<input type="text" class="form-control visa-option-input"
|
||||||
|
value="<%= option %>" placeholder="Enter visa option">
|
||||||
|
<button type="button" class="btn btn-outline-danger"
|
||||||
|
onclick="removeVisaOption(this)">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Tab -->
|
||||||
|
<div class="tab-pane fade" id="form" role="tabpanel">
|
||||||
|
<div class="card border shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-medium mb-3">Form Settings</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Form Heading</label>
|
||||||
|
<input type="text" class="form-control" id="formHeading"
|
||||||
|
value="<%= data.form?.heading || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Submit Button Text</label>
|
||||||
|
<input type="text" class="form-control" id="formSubmitButtonText"
|
||||||
|
value="<%= data.form?.submitButton?.text || 'Request Appointment' %>">
|
||||||
|
</div>
|
||||||
|
<!-- Hidden fields for submitButton icon and buttonClass -->
|
||||||
|
<input type="hidden" id="formSubmitButtonIcon"
|
||||||
|
value="<%= data.form?.submitButton?.icon || 'fa-solid fa-arrow-right' %>">
|
||||||
|
<input type="hidden" id="formSubmitButtonClass"
|
||||||
|
value="<%= data.form?.submitButton?.buttonClass || 'theme-btn' %>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="fw-medium mb-0">Form Fields</h6>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm"
|
||||||
|
onclick="addFormField()">
|
||||||
|
<i class="fas fa-plus"></i> Add Field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="formFieldsContainer">
|
||||||
|
<% if (data.form?.fields && data.form.fields.length> 0) { %>
|
||||||
|
<% data.form.fields.forEach((field, index)=> { %>
|
||||||
|
<div class="card mb-3 form-field-item">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Field Name</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control field-name-input"
|
||||||
|
value="<%= field.name || '' %>"
|
||||||
|
placeholder="e.g., name">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Label</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control field-label-input"
|
||||||
|
value="<%= field.label || '' %>"
|
||||||
|
placeholder="e.g., Your Name">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<select class="form-select field-type-select">
|
||||||
|
<option value="text" <%=field.type==='text'
|
||||||
|
? 'selected' : '' %>>Text</option>
|
||||||
|
<option value="email" <%=field.type==='email'
|
||||||
|
? 'selected' : '' %>>Email</option>
|
||||||
|
<option value="tel" <%=field.type==='tel'
|
||||||
|
? 'selected' : '' %>>Phone</option>
|
||||||
|
<option value="textarea"
|
||||||
|
<%=field.type==='textarea' ? 'selected' : ''
|
||||||
|
%>>Textarea</option>
|
||||||
|
<option value="date" <%=field.type==='date'
|
||||||
|
? 'selected' : '' %>>Date</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Col Class</label>
|
||||||
|
<select class="form-select field-col-select">
|
||||||
|
<option value="col-lg-4"
|
||||||
|
<%=field.colClass==='col-lg-4' ? 'selected'
|
||||||
|
: '' %>>1/3 Width</option>
|
||||||
|
<option value="col-lg-6"
|
||||||
|
<%=field.colClass==='col-lg-6' ? 'selected'
|
||||||
|
: '' %>>1/2 Width</option>
|
||||||
|
<option value="col-lg-12"
|
||||||
|
<%=field.colClass==='col-lg-12' ? 'selected'
|
||||||
|
: '' %>>Full Width</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Required</label>
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input
|
||||||
|
class="form-check-input field-required-check"
|
||||||
|
type="checkbox" <%=field.required
|
||||||
|
? 'checked' : '' %>>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Placeholder</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control field-placeholder-input"
|
||||||
|
value="<%= field.placeholder || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-danger btn-sm mt-3"
|
||||||
|
onclick="removeFormField(this)">
|
||||||
|
<i class="fas fa-trash me-2"></i>Remove Field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submissions Tab -->
|
||||||
|
<div class="tab-pane fade" id="submissions" role="tabpanel">
|
||||||
|
<div class="card border shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="fw-medium mb-0">Recent Submissions</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date Filter -->
|
||||||
|
<div class="row g-2 mb-4 align-items-end" id="filterContainer">
|
||||||
|
<input type="hidden" id="filterTab" value="submissions">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small text-muted">Start Date</label>
|
||||||
|
<input type="date" class="form-control form-control-sm"
|
||||||
|
id="filterStartDate" value="<%= locals.startDate || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small text-muted">End Date</label>
|
||||||
|
<input type="date" class="form-control form-control-sm"
|
||||||
|
id="filterEndDate" value="<%= locals.endDate || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary w-100"
|
||||||
|
onclick="applyDateFilter()">
|
||||||
|
<i class="fas fa-filter me-1"></i> Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<a href="/admin/appointment?tab=submissions"
|
||||||
|
class="btn btn-sm btn-outline-secondary w-100">
|
||||||
|
<i class="fas fa-times me-1"></i> Clear
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Contact</th>
|
||||||
|
<th>Appt Date</th>
|
||||||
|
<th>Visa Types</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% if (locals.submissions && submissions.length> 0) { %>
|
||||||
|
<% submissions.forEach(submission=> { %>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<%= new
|
||||||
|
Date(submission.createdAt).toLocaleDateString()
|
||||||
|
%>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">
|
||||||
|
<%= new
|
||||||
|
Date(submission.createdAt).toLocaleTimeString([],
|
||||||
|
{hour: '2-digit' , minute:'2-digit'}) %>
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<%= submission.name %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<a href="mailto:<%= submission.email %>"
|
||||||
|
class="text-decoration-none"><i
|
||||||
|
class="fas fa-envelope me-1"></i>
|
||||||
|
<%= submission.email %>
|
||||||
|
</a>
|
||||||
|
<% if(submission.phone) { %>
|
||||||
|
<span class="text-muted small"><i
|
||||||
|
class="fas fa-phone me-1"></i>
|
||||||
|
<%= submission.phone %>
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<%= submission.appointmentDate || '-' %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if (submission.visaTypes &&
|
||||||
|
submission.visaTypes.length> 0) { %>
|
||||||
|
<% submission.visaTypes.forEach(type=> { %>
|
||||||
|
<span
|
||||||
|
class="badge bg-light text-dark border me-1">
|
||||||
|
<%= type %>
|
||||||
|
</span>
|
||||||
|
<% }); %>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if (submission.message) { %>
|
||||||
|
<div title="<%= submission.message %>"
|
||||||
|
style="max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||||
|
<%= submission.message %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% let statusClass='bg-secondary' ;
|
||||||
|
if(submission.status==='pending' )
|
||||||
|
statusClass='bg-warning text-dark' ;
|
||||||
|
if(submission.status==='confirmed' )
|
||||||
|
statusClass='bg-success' ;
|
||||||
|
if(submission.status==='completed' )
|
||||||
|
statusClass='bg-info text-dark' ;
|
||||||
|
if(submission.status==='cancelled' )
|
||||||
|
statusClass='bg-danger' ; %>
|
||||||
|
<span
|
||||||
|
class="badge <%= statusClass %> rounded-pill">
|
||||||
|
<%= submission.status %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
onclick="openStatusModal('<%= submission._id %>', '<%= submission.status %>')"
|
||||||
|
title="Update Status">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
<% } else { %>
|
||||||
|
<tr>
|
||||||
|
<td colspan="8"
|
||||||
|
class="text-center py-4 text-muted">No
|
||||||
|
submissions found</td>
|
||||||
|
</tr>
|
||||||
|
<% } %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-end">
|
||||||
|
<small class="text-muted">Showing last 50 submissions</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fixed Bottom Buttons -->
|
||||||
|
<div class="fixed-bottom-buttons">
|
||||||
|
<button type="reset" class="btn btn-secondary" onclick="resetForm()">
|
||||||
|
<i class="fas fa-undo me-2"></i>Reset
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||||
|
<i class="fas fa-save me-2"></i>Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Update Modal -->
|
||||||
|
<div class="modal fade" id="statusModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Update Status</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="statusForm">
|
||||||
|
<input type="hidden" id="statusSubmissionId">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="statusSelect" class="form-label">Status</label>
|
||||||
|
<select class="form-select" id="statusSelect">
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="confirmed">Confirmed</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveStatus()">Save changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="application/json" id="appointmentDataJson"><%- JSON.stringify(data) %></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let originalFormData = null;
|
||||||
|
let statusModal = null;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
try {
|
||||||
|
var jsonScript = document.getElementById('appointmentDataJson');
|
||||||
|
originalFormData = JSON.parse(jsonScript.textContent);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing originalFormData:', e);
|
||||||
|
originalFormData = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for tab parameter in URL
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const tab = urlParams.get('tab');
|
||||||
|
if (tab) {
|
||||||
|
const triggerEl = document.querySelector(`a[href="#${tab}"]`);
|
||||||
|
if (triggerEl) {
|
||||||
|
const tabInstance = new bootstrap.Tab(triggerEl);
|
||||||
|
tabInstance.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move modal to body to prevent backdrop issues
|
||||||
|
const statusModalEl = document.getElementById('statusModal');
|
||||||
|
if (statusModalEl) {
|
||||||
|
document.body.appendChild(statusModalEl);
|
||||||
|
}
|
||||||
|
statusModal = new bootstrap.Modal(statusModalEl);
|
||||||
|
|
||||||
|
updateAllJsonInputs();
|
||||||
|
initializeFormHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyDateFilter() {
|
||||||
|
const startDate = document.getElementById('filterStartDate').value;
|
||||||
|
const endDate = document.getElementById('filterEndDate').value;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('tab', 'submissions');
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
url.searchParams.set('startDate', startDate);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('startDate');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
url.searchParams.set('endDate', endDate);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('endDate');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStatusModal(id, currentStatus) {
|
||||||
|
document.getElementById('statusSubmissionId').value = id;
|
||||||
|
document.getElementById('statusSelect').value = currentStatus;
|
||||||
|
statusModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStatus() {
|
||||||
|
const id = document.getElementById('statusSubmissionId').value;
|
||||||
|
const status = document.getElementById('statusSelect').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/admin/appointments/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Determine CSS class for the notification or badge
|
||||||
|
// Since this is generic, we'll reload or update UI manually if complex.
|
||||||
|
// Reload is safest to show updated table state (including sorting/filtering if any)
|
||||||
|
// But let's try to be smooth:
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to update status: ' + (result.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating status:', error);
|
||||||
|
alert('Error updating status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeFormHandlers() {
|
||||||
|
const form = document.getElementById('appointmentForm');
|
||||||
|
form.addEventListener('submit', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateJsonData();
|
||||||
|
this.submit();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating data:', error);
|
||||||
|
alert('Failed to process form data. Please try again.');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Image upload buttons
|
||||||
|
document.querySelectorAll('.btn-upload-image').forEach(button => {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
const targetInput = this.dataset.targetInput;
|
||||||
|
const imageType = this.dataset.imageType;
|
||||||
|
openImageUploader(targetInput, imageType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update preview when background image changes
|
||||||
|
document.getElementById('heroBackgroundImage').addEventListener('input', function () {
|
||||||
|
updateHeroImagePreview(this.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeroImagePreview(imagePath) {
|
||||||
|
const previewContainer = document.getElementById('heroImagePreview');
|
||||||
|
if (imagePath) {
|
||||||
|
let imgSrc = imagePath;
|
||||||
|
if (!imgSrc.startsWith('http://') && !imgSrc.startsWith('https://')) {
|
||||||
|
imgSrc = imgSrc.startsWith('/') ? imgSrc : '/' + imgSrc;
|
||||||
|
}
|
||||||
|
previewContainer.innerHTML = `
|
||||||
|
<img src="${imgSrc}" class="img-thumbnail" id="heroPreviewImg"
|
||||||
|
style="height: 200px; width: 100%; object-fit: cover;"
|
||||||
|
alt="Background image preview"
|
||||||
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||||
|
<div class="border rounded p-5 text-center text-muted"
|
||||||
|
style="height: 200px; display: none; align-items: center; justify-content: center;">
|
||||||
|
Image preview
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
previewContainer.innerHTML = `
|
||||||
|
<div class="border rounded p-5 text-center text-muted"
|
||||||
|
style="height: 200px; display: flex; align-items: center; justify-content: center;">
|
||||||
|
Image preview
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAllJsonInputs() {
|
||||||
|
updateJsonData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateJsonData() {
|
||||||
|
// Hero data
|
||||||
|
const heroData = {
|
||||||
|
title: document.getElementById('heroTitle').value || '',
|
||||||
|
backgroundImage: document.getElementById('heroBackgroundImage').value || '',
|
||||||
|
subtitle: document.getElementById('heroSubtitle').value || '',
|
||||||
|
heading: document.getElementById('heroHeading').value || '',
|
||||||
|
description: document.getElementById('heroDescription').value || '',
|
||||||
|
};
|
||||||
|
document.getElementById('heroJson').value = JSON.stringify(heroData);
|
||||||
|
|
||||||
|
// Visa options
|
||||||
|
const visaOptions = [];
|
||||||
|
document.querySelectorAll('.visa-option-input').forEach(input => {
|
||||||
|
if (input.value.trim()) {
|
||||||
|
visaOptions.push(input.value.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('visaOptionsJson').value = JSON.stringify(visaOptions);
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
const fields = [];
|
||||||
|
document.querySelectorAll('.form-field-item').forEach(item => {
|
||||||
|
fields.push({
|
||||||
|
name: item.querySelector('.field-name-input').value || '',
|
||||||
|
label: item.querySelector('.field-label-input').value || '',
|
||||||
|
type: item.querySelector('.field-type-select').value || 'text',
|
||||||
|
placeholder: item.querySelector('.field-placeholder-input').value || '',
|
||||||
|
required: item.querySelector('.field-required-check').checked,
|
||||||
|
colClass: item.querySelector('.field-col-select').value || 'col-lg-12',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
heading: document.getElementById('formHeading').value || '',
|
||||||
|
fields: fields,
|
||||||
|
submitButton: {
|
||||||
|
text: document.getElementById('formSubmitButtonText').value || 'Request Appointment',
|
||||||
|
icon: document.getElementById('formSubmitButtonIcon').value || 'fa-solid fa-arrow-right',
|
||||||
|
buttonClass: document.getElementById('formSubmitButtonClass').value || 'theme-btn',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
document.getElementById('formJson').value = JSON.stringify(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addVisaOption() {
|
||||||
|
const container = document.getElementById('visaOptionsContainer');
|
||||||
|
const html = `
|
||||||
|
<div class="input-group mb-2 visa-option-item">
|
||||||
|
<span class="input-group-text"><i class="fas fa-passport"></i></span>
|
||||||
|
<input type="text" class="form-control visa-option-input" value="" placeholder="Enter visa option">
|
||||||
|
<button type="button" class="btn btn-outline-danger" onclick="removeVisaOption(this)">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.insertAdjacentHTML('beforeend', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeVisaOption(button) {
|
||||||
|
button.closest('.visa-option-item').remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFormField() {
|
||||||
|
const container = document.getElementById('formFieldsContainer');
|
||||||
|
const html = `
|
||||||
|
<div class="card mb-3 form-field-item">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Field Name</label>
|
||||||
|
<input type="text" class="form-control field-name-input" value="" placeholder="e.g., name">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Label</label>
|
||||||
|
<input type="text" class="form-control field-label-input" value="" placeholder="e.g., Your Name">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<select class="form-select field-type-select">
|
||||||
|
<option value="text" selected>Text</option>
|
||||||
|
<option value="email">Email</option>
|
||||||
|
<option value="tel">Phone</option>
|
||||||
|
<option value="textarea">Textarea</option>
|
||||||
|
<option value="date">Date</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Col Class</label>
|
||||||
|
<select class="form-select field-col-select">
|
||||||
|
<option value="col-lg-4">1/3 Width</option>
|
||||||
|
<option value="col-lg-6">1/2 Width</option>
|
||||||
|
<option value="col-lg-12" selected>Full Width</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Required</label>
|
||||||
|
<div class="form-check mt-2">
|
||||||
|
<input class="form-check-input field-required-check" type="checkbox">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Placeholder</label>
|
||||||
|
<input type="text" class="form-control field-placeholder-input" value="">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm mt-3" onclick="removeFormField(this)">
|
||||||
|
<i class="fas fa-trash me-2"></i>Remove Field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.insertAdjacentHTML('beforeend', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFormField(button) {
|
||||||
|
button.closest('.form-field-item').remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
if (confirm('Are you sure you want to reset all changes?')) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image uploader function (reuse from shared)
|
||||||
|
function openImageUploader(targetInput, imageType) {
|
||||||
|
// Open upload modal or trigger file input
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/*';
|
||||||
|
input.onchange = async function (e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/upload/image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success && result.imagePath) {
|
||||||
|
document.getElementById(targetInput).value = result.imagePath;
|
||||||
|
if (targetInput === 'heroBackgroundImage') {
|
||||||
|
updateHeroImagePreview(result.imagePath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert('Upload failed: ' + (result.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
alert('Upload failed. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -118,6 +118,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 border-end border-top">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<div class="rounded-circle d-flex align-items-center justify-content-center me-3"
|
||||||
|
style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
|
||||||
|
<i class="fas fa-calendar-check fa-lg" style="color: var(--primary-color);"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0">Appointment</h5>
|
||||||
|
<p class="text-muted mb-0 small">Manage appointment page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/appointment" class="btn btn-sm btn-primary w-100 mt-2">
|
||||||
|
<i class="fas fa-edit me-2"></i>Edit
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 border-end border-top">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<div class="rounded-circle d-flex align-items-center justify-content-center me-3"
|
||||||
|
style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
|
||||||
|
<i class="fas fa-tags fa-lg" style="color: var(--primary-color);"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0">Pricing</h5>
|
||||||
|
<p class="text-muted mb-0 small">Manage pricing page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/pricing" class="btn btn-sm btn-primary w-100 mt-2">
|
||||||
|
<i class="fas fa-edit me-2"></i>Edit
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4 border-end border-top">
|
<div class="col-md-4 border-end border-top">
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<div class="d-flex align-items-center mb-3">
|
<div class="d-flex align-items-center mb-3">
|
||||||
@@ -494,13 +530,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-muted small">Logged in as</div>
|
<div class="text-muted small">Logged in as</div>
|
||||||
<div class="fw-bold" style="color: var(--primary-color);"><%= user.username %></div>
|
<div class="fw-bold" style="color: var(--primary-color);">
|
||||||
|
<%= user.username %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert mt-3 mb-0" style="background-color: rgba(184, 183, 106, 0.05); border-left: 4px solid var(--primary-color);" role="alert">
|
<div class="alert mt-3 mb-0"
|
||||||
|
style="background-color: rgba(184, 183, 106, 0.05); border-left: 4px solid var(--primary-color);" role="alert">
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<div class="me-3">
|
<div class="me-3">
|
||||||
<i class="fas fa-lightbulb fa-lg" style="color: var(--primary-color);"></i>
|
<i class="fas fa-lightbulb fa-lg" style="color: var(--primary-color);"></i>
|
||||||
|
|||||||
742
views/admin/pricing/index.ejs
Normal file
742
views/admin/pricing/index.ejs
Normal file
@@ -0,0 +1,742 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0" style="color: var(--primary-dark);">
|
||||||
|
<%= title %>
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted mb-0">Edit content displayed on Pricing page</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="<%= frontendUrl %>/pricing/" class="btn btn-outline-primary" target="_blank">
|
||||||
|
<i class="fas fa-external-link-alt me-2"></i>View Pricing Page
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<form method="POST" class="content-with-fixed-buttons" id="pricingForm" action="/admin/pricing/update">
|
||||||
|
<!-- Hidden inputs for JSON data -->
|
||||||
|
<input type="hidden" name="hero" id="heroJson">
|
||||||
|
<input type="hidden" name="pricingSection" id="pricingSectionJson">
|
||||||
|
<input type="hidden" name="plans" id="plansJson">
|
||||||
|
<input type="hidden" name="testimonials" id="testimonialsJson">
|
||||||
|
|
||||||
|
<!-- Navigation Tabs -->
|
||||||
|
<div class="card shadow-sm border-0 mb-4">
|
||||||
|
<div class="card-header bg-white border-bottom">
|
||||||
|
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" data-bs-toggle="tab" href="#hero" role="tab">
|
||||||
|
<i class="fas fa-home me-2"></i>Hero
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#pricingSection" role="tab">
|
||||||
|
<i class="fas fa-tags me-2"></i>Pricing Section
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#plans" role="tab">
|
||||||
|
<i class="fas fa-dollar-sign me-2"></i>Plans
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#testimonials" role="tab">
|
||||||
|
<i class="fas fa-quote-right me-2"></i>Testimonials
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content">
|
||||||
|
<!-- Hero Tab -->
|
||||||
|
<div class="tab-pane fade show active" id="hero" role="tabpanel">
|
||||||
|
<div class="card border shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-medium mb-3">Hero Section</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label fw-medium">Background Image</label>
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<input type="text" class="form-control" id="heroBackgroundImage"
|
||||||
|
name="heroBackgroundImage"
|
||||||
|
value="<%= data.hero?.backgroundImage || '' %>">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-primary btn-upload-image"
|
||||||
|
data-target-input="heroBackgroundImage"
|
||||||
|
data-image-type="pricing">
|
||||||
|
<i class="fas fa-upload me-1"></i>Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Recommended size: 1920x1080px</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div id="heroImagePreview" style="height: 200px;">
|
||||||
|
<% if (data.hero?.backgroundImage) { %>
|
||||||
|
<% let heroImgSrc=data.hero.backgroundImage; if (heroImgSrc &&
|
||||||
|
!heroImgSrc.startsWith('http://') &&
|
||||||
|
!heroImgSrc.startsWith('https://')) {
|
||||||
|
heroImgSrc=heroImgSrc.startsWith('/') ? heroImgSrc : '/' +
|
||||||
|
heroImgSrc; } %>
|
||||||
|
<img src="<%= heroImgSrc %>" class="img-thumbnail"
|
||||||
|
id="heroPreviewImg"
|
||||||
|
style="height: 200px; width: 100%; object-fit: cover;"
|
||||||
|
alt="Background image preview"
|
||||||
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||||
|
<div class="border rounded p-5 text-center text-muted"
|
||||||
|
style="height: 200px; display: none; align-items: center; justify-content: center;">
|
||||||
|
Image preview
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="border rounded p-5 text-center text-muted"
|
||||||
|
style="height: 200px; display: flex; align-items: center; justify-content: center;">
|
||||||
|
Image preview
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mt-2">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium">Title</label>
|
||||||
|
<input type="text" class="form-control" id="heroTitle" name="heroTitle"
|
||||||
|
value="<%= data.hero?.title || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pricing Section Tab -->
|
||||||
|
<div class="tab-pane fade" id="pricingSection" role="tabpanel">
|
||||||
|
<div class="card border shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-medium mb-3">Pricing Section Header</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">Subtitle</label>
|
||||||
|
<input type="text" class="form-control" id="pricingSectionSubtitle"
|
||||||
|
value="<%= data.pricingSection?.subtitle || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">Heading</label>
|
||||||
|
<input type="text" class="form-control" id="pricingSectionHeading"
|
||||||
|
value="<%= data.pricingSection?.heading || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-medium">Description</label>
|
||||||
|
<textarea class="form-control" id="pricingSectionDescription"
|
||||||
|
rows="3"><%= data.pricingSection?.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plans Tab -->
|
||||||
|
<div class="tab-pane fade" id="plans" role="tabpanel">
|
||||||
|
<!-- Monthly Plans -->
|
||||||
|
<div class="card border shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="fw-medium mb-0">Monthly Plans</h6>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm"
|
||||||
|
onclick="addPlan('monthly')">
|
||||||
|
<i class="fas fa-plus"></i> Add Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="monthlyPlansContainer">
|
||||||
|
<% if (data.plans?.monthly && data.plans.monthly.length> 0) { %>
|
||||||
|
<% data.plans.monthly.forEach((plan, index)=> { %>
|
||||||
|
<div class="card mb-3 plan-item" data-type="monthly">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Plan Name</label>
|
||||||
|
<input type="text" class="form-control plan-name"
|
||||||
|
value="<%= plan.name || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Price</label>
|
||||||
|
<input type="text" class="form-control plan-price"
|
||||||
|
value="<%= plan.price || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Currency</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control plan-currency"
|
||||||
|
value="<%= plan.currency || '$' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Period</label>
|
||||||
|
<input type="text" class="form-control plan-period"
|
||||||
|
value="<%= plan.period || 'mo' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Style</label>
|
||||||
|
<select class="form-select plan-style">
|
||||||
|
<option value="default"
|
||||||
|
<%=plan.style==='default' ? 'selected' : ''
|
||||||
|
%>>Default</option>
|
||||||
|
<option value="style-2"
|
||||||
|
<%=plan.style==='style-2' ? 'selected' : ''
|
||||||
|
%>>Style 2 (Featured)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Text</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control plan-button-text"
|
||||||
|
value="<%= plan.buttonText || 'Get Started Today' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Link</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control plan-button-link"
|
||||||
|
value="<%= plan.buttonLink || '/pricing' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Icon</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control plan-button-icon"
|
||||||
|
value="<%= plan.buttonIcon || 'fa-solid fa-arrow-right' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label">Features (one per
|
||||||
|
line)</label>
|
||||||
|
<textarea class="form-control plan-features"
|
||||||
|
rows="4"><%= (plan.features || []).join('\n') %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-danger btn-sm mt-3"
|
||||||
|
onclick="removePlan(this)">
|
||||||
|
<i class="fas fa-trash me-2"></i>Remove Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Yearly Plans -->
|
||||||
|
<div class="card border shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="fw-medium mb-0">Yearly Plans</h6>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm"
|
||||||
|
onclick="addPlan('yearly')">
|
||||||
|
<i class="fas fa-plus"></i> Add Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="yearlyPlansContainer">
|
||||||
|
<% if (data.plans?.yearly && data.plans.yearly.length> 0) { %>
|
||||||
|
<% data.plans.yearly.forEach((plan, index)=> { %>
|
||||||
|
<div class="card mb-3 plan-item" data-type="yearly">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Plan Name</label>
|
||||||
|
<input type="text" class="form-control plan-name"
|
||||||
|
value="<%= plan.name || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Price</label>
|
||||||
|
<input type="text" class="form-control plan-price"
|
||||||
|
value="<%= plan.price || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Currency</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control plan-currency"
|
||||||
|
value="<%= plan.currency || '$' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Period</label>
|
||||||
|
<input type="text" class="form-control plan-period"
|
||||||
|
value="<%= plan.period || 'mo' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Style</label>
|
||||||
|
<select class="form-select plan-style">
|
||||||
|
<option value="default"
|
||||||
|
<%=plan.style==='default' ? 'selected' : ''
|
||||||
|
%>>Default</option>
|
||||||
|
<option value="style-2"
|
||||||
|
<%=plan.style==='style-2' ? 'selected' : ''
|
||||||
|
%>>Style 2 (Featured)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Text</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control plan-button-text"
|
||||||
|
value="<%= plan.buttonText || 'Get Started Today' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Link</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control plan-button-link"
|
||||||
|
value="<%= plan.buttonLink || '/pricing' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Icon</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control plan-button-icon"
|
||||||
|
value="<%= plan.buttonIcon || 'fa-solid fa-arrow-right' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label">Features (one per
|
||||||
|
line)</label>
|
||||||
|
<textarea class="form-control plan-features"
|
||||||
|
rows="4"><%= (plan.features || []).join('\n') %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-danger btn-sm mt-3"
|
||||||
|
onclick="removePlan(this)">
|
||||||
|
<i class="fas fa-trash me-2"></i>Remove Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Testimonials Tab -->
|
||||||
|
<div class="tab-pane fade" id="testimonials" role="tabpanel">
|
||||||
|
<div class="card border shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-medium mb-3">Testimonials Section Header</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">Subtitle</label>
|
||||||
|
<input type="text" class="form-control" id="testimonialsSubtitle"
|
||||||
|
value="<%= data.testimonials?.subtitle || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">Heading</label>
|
||||||
|
<input type="text" class="form-control" id="testimonialsHeading"
|
||||||
|
value="<%= data.testimonials?.heading || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium">Button Text</label>
|
||||||
|
<input type="text" class="form-control" id="testimonialsButtonText"
|
||||||
|
value="<%= data.testimonials?.buttonText || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium">Button Link</label>
|
||||||
|
<input type="text" class="form-control" id="testimonialsButtonLink"
|
||||||
|
value="<%= data.testimonials?.buttonLink || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-medium">Section Image</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="testimonialsImage"
|
||||||
|
value="<%= data.testimonials?.image || '' %>">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-primary btn-upload-image"
|
||||||
|
data-target-input="testimonialsImage" data-image-type="pricing">
|
||||||
|
<i class="fas fa-upload"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="fw-medium mb-0">Testimonial Items</h6>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm"
|
||||||
|
onclick="addTestimonial()">
|
||||||
|
<i class="fas fa-plus"></i> Add Testimonial
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="testimonialsContainer">
|
||||||
|
<% if (data.testimonials?.items && data.testimonials.items.length> 0) { %>
|
||||||
|
<% data.testimonials.items.forEach((item, index)=> { %>
|
||||||
|
<div class="card mb-3 testimonial-item">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control testimonial-name"
|
||||||
|
value="<%= item.name || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Role/Type</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control testimonial-role"
|
||||||
|
value="<%= item.role || '' %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Rating</label>
|
||||||
|
<select class="form-select testimonial-rating">
|
||||||
|
<option value="1" <%=item.rating===1
|
||||||
|
? 'selected' : '' %>>1 Star</option>
|
||||||
|
<option value="2" <%=item.rating===2
|
||||||
|
? 'selected' : '' %>>2 Stars</option>
|
||||||
|
<option value="3" <%=item.rating===3
|
||||||
|
? 'selected' : '' %>>3 Stars</option>
|
||||||
|
<option value="4" <%=item.rating===4
|
||||||
|
? 'selected' : '' %>>4 Stars</option>
|
||||||
|
<option value="5" <%=item.rating===5
|
||||||
|
? 'selected' : '' %>>5 Stars</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label">Content</label>
|
||||||
|
<textarea class="form-control testimonial-content"
|
||||||
|
rows="3"><%= item.content || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-danger btn-sm mt-3"
|
||||||
|
onclick="removeTestimonial(this)">
|
||||||
|
<i class="fas fa-trash me-2"></i>Remove Testimonial
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fixed Bottom Buttons -->
|
||||||
|
<div class="fixed-bottom-buttons">
|
||||||
|
<button type="reset" class="btn btn-secondary" onclick="resetForm()">
|
||||||
|
<i class="fas fa-undo me-2"></i>Reset
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||||
|
<i class="fas fa-save me-2"></i>Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="application/json" id="pricingDataJson"><%- JSON.stringify(data) %></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let originalFormData = null;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
try {
|
||||||
|
var jsonScript = document.getElementById('pricingDataJson');
|
||||||
|
originalFormData = JSON.parse(jsonScript.textContent);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing originalFormData:', e);
|
||||||
|
originalFormData = {};
|
||||||
|
}
|
||||||
|
updateAllJsonInputs();
|
||||||
|
initializeFormHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function initializeFormHandlers() {
|
||||||
|
const form = document.getElementById('pricingForm');
|
||||||
|
form.addEventListener('submit', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateJsonData();
|
||||||
|
this.submit();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating data:', error);
|
||||||
|
alert('Failed to process form data. Please try again.');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Image upload buttons
|
||||||
|
document.querySelectorAll('.btn-upload-image').forEach(button => {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
const targetInput = this.dataset.targetInput;
|
||||||
|
const imageType = this.dataset.imageType;
|
||||||
|
openImageUploader(targetInput, imageType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update preview when background image changes
|
||||||
|
document.getElementById('heroBackgroundImage').addEventListener('input', function () {
|
||||||
|
updateHeroImagePreview(this.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeroImagePreview(imagePath) {
|
||||||
|
const previewContainer = document.getElementById('heroImagePreview');
|
||||||
|
if (imagePath) {
|
||||||
|
let imgSrc = imagePath;
|
||||||
|
if (!imgSrc.startsWith('http://') && !imgSrc.startsWith('https://')) {
|
||||||
|
imgSrc = imgSrc.startsWith('/') ? imgSrc : '/' + imgSrc;
|
||||||
|
}
|
||||||
|
previewContainer.innerHTML = `
|
||||||
|
<img src="${imgSrc}" class="img-thumbnail" id="heroPreviewImg"
|
||||||
|
style="height: 200px; width: 100%; object-fit: cover;"
|
||||||
|
alt="Background image preview"
|
||||||
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||||
|
<div class="border rounded p-5 text-center text-muted"
|
||||||
|
style="height: 200px; display: none; align-items: center; justify-content: center;">
|
||||||
|
Image preview
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
previewContainer.innerHTML = `
|
||||||
|
<div class="border rounded p-5 text-center text-muted"
|
||||||
|
style="height: 200px; display: flex; align-items: center; justify-content: center;">
|
||||||
|
Image preview
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAllJsonInputs() {
|
||||||
|
updateJsonData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateJsonData() {
|
||||||
|
// Hero data
|
||||||
|
const heroData = {
|
||||||
|
title: document.getElementById('heroTitle').value || '',
|
||||||
|
backgroundImage: document.getElementById('heroBackgroundImage').value || '',
|
||||||
|
shapeImage: originalFormData?.hero?.shapeImage || '/assets/img/inner-page/shape.png',
|
||||||
|
breadcrumb: originalFormData?.hero?.breadcrumb || [],
|
||||||
|
};
|
||||||
|
document.getElementById('heroJson').value = JSON.stringify(heroData);
|
||||||
|
|
||||||
|
// Pricing Section data
|
||||||
|
const pricingSectionData = {
|
||||||
|
subtitle: document.getElementById('pricingSectionSubtitle').value || '',
|
||||||
|
heading: document.getElementById('pricingSectionHeading').value || '',
|
||||||
|
description: document.getElementById('pricingSectionDescription').value || '',
|
||||||
|
};
|
||||||
|
document.getElementById('pricingSectionJson').value = JSON.stringify(pricingSectionData);
|
||||||
|
|
||||||
|
// Plans data
|
||||||
|
const monthlyPlans = [];
|
||||||
|
document.querySelectorAll('#monthlyPlansContainer .plan-item').forEach(item => {
|
||||||
|
const featuresText = item.querySelector('.plan-features').value || '';
|
||||||
|
monthlyPlans.push({
|
||||||
|
name: item.querySelector('.plan-name').value || '',
|
||||||
|
price: item.querySelector('.plan-price').value || '0',
|
||||||
|
currency: item.querySelector('.plan-currency').value || '$',
|
||||||
|
period: item.querySelector('.plan-period').value || 'mo',
|
||||||
|
style: item.querySelector('.plan-style').value || 'default',
|
||||||
|
buttonText: item.querySelector('.plan-button-text').value || 'Get Started Today',
|
||||||
|
buttonLink: item.querySelector('.plan-button-link').value || '/pricing',
|
||||||
|
buttonIcon: item.querySelector('.plan-button-icon').value || 'fa-solid fa-arrow-right',
|
||||||
|
features: featuresText.split('\n').filter(f => f.trim()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const yearlyPlans = [];
|
||||||
|
document.querySelectorAll('#yearlyPlansContainer .plan-item').forEach(item => {
|
||||||
|
const featuresText = item.querySelector('.plan-features').value || '';
|
||||||
|
yearlyPlans.push({
|
||||||
|
name: item.querySelector('.plan-name').value || '',
|
||||||
|
price: item.querySelector('.plan-price').value || '0',
|
||||||
|
currency: item.querySelector('.plan-currency').value || '$',
|
||||||
|
period: item.querySelector('.plan-period').value || 'mo',
|
||||||
|
style: item.querySelector('.plan-style').value || 'default',
|
||||||
|
buttonText: item.querySelector('.plan-button-text').value || 'Get Started Today',
|
||||||
|
buttonLink: item.querySelector('.plan-button-link').value || '/pricing',
|
||||||
|
buttonIcon: item.querySelector('.plan-button-icon').value || 'fa-solid fa-arrow-right',
|
||||||
|
features: featuresText.split('\n').filter(f => f.trim()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('plansJson').value = JSON.stringify({
|
||||||
|
monthly: monthlyPlans,
|
||||||
|
yearly: yearlyPlans,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Testimonials data
|
||||||
|
const testimonialItems = [];
|
||||||
|
document.querySelectorAll('#testimonialsContainer .testimonial-item').forEach(item => {
|
||||||
|
testimonialItems.push({
|
||||||
|
name: item.querySelector('.testimonial-name').value || '',
|
||||||
|
role: item.querySelector('.testimonial-role').value || '',
|
||||||
|
rating: parseInt(item.querySelector('.testimonial-rating').value) || 5,
|
||||||
|
content: item.querySelector('.testimonial-content').value || '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const testimonialsData = {
|
||||||
|
subtitle: document.getElementById('testimonialsSubtitle').value || '',
|
||||||
|
heading: document.getElementById('testimonialsHeading').value || '',
|
||||||
|
buttonText: document.getElementById('testimonialsButtonText').value || '',
|
||||||
|
buttonLink: document.getElementById('testimonialsButtonLink').value || '',
|
||||||
|
buttonIcon: originalFormData?.testimonials?.buttonIcon || 'fa-solid fa-arrow-right',
|
||||||
|
image: document.getElementById('testimonialsImage').value || '',
|
||||||
|
items: testimonialItems,
|
||||||
|
};
|
||||||
|
document.getElementById('testimonialsJson').value = JSON.stringify(testimonialsData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPlan(type) {
|
||||||
|
const container = document.getElementById(type + 'PlansContainer');
|
||||||
|
const html = `
|
||||||
|
<div class="card mb-3 plan-item" data-type="${type}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Plan Name</label>
|
||||||
|
<input type="text" class="form-control plan-name" value="">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Price</label>
|
||||||
|
<input type="text" class="form-control plan-price" value="">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Currency</label>
|
||||||
|
<input type="text" class="form-control plan-currency" value="$">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Period</label>
|
||||||
|
<input type="text" class="form-control plan-period" value="mo">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Style</label>
|
||||||
|
<select class="form-select plan-style">
|
||||||
|
<option value="default" selected>Default</option>
|
||||||
|
<option value="style-2">Style 2 (Featured)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Text</label>
|
||||||
|
<input type="text" class="form-control plan-button-text" value="Get Started Today">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Link</label>
|
||||||
|
<input type="text" class="form-control plan-button-link" value="/pricing">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Button Icon</label>
|
||||||
|
<input type="text" class="form-control plan-button-icon" value="fa-solid fa-arrow-right">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label">Features (one per line)</label>
|
||||||
|
<textarea class="form-control plan-features" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm mt-3" onclick="removePlan(this)">
|
||||||
|
<i class="fas fa-trash me-2"></i>Remove Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.insertAdjacentHTML('beforeend', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePlan(button) {
|
||||||
|
if (confirm('Are you sure you want to remove this plan?')) {
|
||||||
|
button.closest('.plan-item').remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTestimonial() {
|
||||||
|
const container = document.getElementById('testimonialsContainer');
|
||||||
|
const html = `
|
||||||
|
<div class="card mb-3 testimonial-item">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-control testimonial-name" value="">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Role/Type</label>
|
||||||
|
<input type="text" class="form-control testimonial-role" value="">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Rating</label>
|
||||||
|
<select class="form-select testimonial-rating">
|
||||||
|
<option value="1">1 Star</option>
|
||||||
|
<option value="2">2 Stars</option>
|
||||||
|
<option value="3">3 Stars</option>
|
||||||
|
<option value="4">4 Stars</option>
|
||||||
|
<option value="5" selected>5 Stars</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label">Content</label>
|
||||||
|
<textarea class="form-control testimonial-content" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm mt-3" onclick="removeTestimonial(this)">
|
||||||
|
<i class="fas fa-trash me-2"></i>Remove Testimonial
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.insertAdjacentHTML('beforeend', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTestimonial(button) {
|
||||||
|
if (confirm('Are you sure you want to remove this testimonial?')) {
|
||||||
|
button.closest('.testimonial-item').remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
if (confirm('Are you sure you want to reset all changes?')) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image uploader function
|
||||||
|
function openImageUploader(targetInput, imageType) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/*';
|
||||||
|
input.onchange = async function (e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send imageType via query string as controller expects req.query.imageType
|
||||||
|
const uploadUrl = '/admin/upload/image?imageType=' + encodeURIComponent(imageType || 'general');
|
||||||
|
const response = await fetch(uploadUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success && result.path) {
|
||||||
|
document.getElementById(targetInput).value = result.path;
|
||||||
|
if (targetInput === 'heroBackgroundImage') {
|
||||||
|
updateHeroImagePreview(result.path);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert('Upload failed: ' + (result.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
alert('Upload failed. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
|
<head>
|
||||||
<link rel="icon" type="image/png" href="/uploads/layout/favicon.png" />
|
<link rel="icon" type="image/png" href="/uploads/layout/favicon.png" />
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@@ -8,15 +9,9 @@
|
|||||||
<%= typeof title !=='undefined' ? title + ' | ' : '' %>CMS-GGCamp
|
<%= typeof title !=='undefined' ? title + ' | ' : '' %>CMS-GGCamp
|
||||||
</title>
|
</title>
|
||||||
<!-- Bootstrap CSS -->
|
<!-- Bootstrap CSS -->
|
||||||
<link
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<!-- Font Awesome -->
|
<!-- Font Awesome -->
|
||||||
<link
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||||
rel="stylesheet"
|
|
||||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
|
||||||
/>
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--primary-color: #b8b76a;
|
--primary-color: #b8b76a;
|
||||||
@@ -45,9 +40,9 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<%- style %>
|
<%- style %>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<nav class="col-md-2 d-none d-md-block bg-light sidebar">
|
<nav class="col-md-2 d-none d-md-block bg-light sidebar">
|
||||||
@@ -74,6 +69,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/admin/contact">Contact</a>
|
<a class="nav-link" href="/admin/contact">Contact</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/admin/appointment">Appointment</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/admin/faq">FAQ</a>
|
<a class="nav-link" href="/admin/faq">FAQ</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -100,12 +98,10 @@
|
|||||||
|
|
||||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
|
||||||
<!-- Flash Messages Data (Hidden) -->
|
<!-- Flash Messages Data (Hidden) -->
|
||||||
<% if(typeof success_msg !=='undefined' || typeof error_msg
|
<% if(typeof success_msg !=='undefined' || typeof error_msg !=='undefined' || typeof error !=='undefined' ) { %>
|
||||||
!=='undefined' || typeof error !=='undefined' ) { %>
|
|
||||||
<div id="flash-messages-data" style="display: none">
|
<div id="flash-messages-data" style="display: none">
|
||||||
<%- JSON.stringify({ success_msg: typeof success_msg !=='undefined'
|
<%- JSON.stringify({ success_msg: typeof success_msg !=='undefined' && success_msg ? success_msg : null,
|
||||||
&& success_msg ? success_msg : null, error_msg: typeof error_msg
|
error_msg: typeof error_msg !=='undefined' && error_msg ? error_msg : null, error: typeof error
|
||||||
!=='undefined' && error_msg ? error_msg : null, error: typeof error
|
|
||||||
!=='undefined' && error ? error : null }) %>
|
!=='undefined' && error ? error : null }) %>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
@@ -118,5 +114,6 @@
|
|||||||
<!-- Bootstrap JS -->
|
<!-- Bootstrap JS -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<%- script %>
|
<%- script %>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -729,34 +729,55 @@
|
|||||||
|
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a class="dropdown-item <%= currentPath === '/admin/about-us' ? 'active' : '' %>"
|
||||||
class="dropdown-item <%= currentPath === '/admin/about-us' ? 'active' : '' %>"
|
href="/admin/about-us">About us</a>
|
||||||
href="/admin/about-us"
|
|
||||||
>About us</a
|
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a class="dropdown-item <%= currentPath === '/admin/safety' ? 'active' : '' %>"
|
||||||
class="dropdown-item <%= currentPath === '/admin/safety' ? 'active' : '' %>"
|
href="/admin/safety">Safety</a>
|
||||||
href="/admin/safety"
|
|
||||||
>Safety</a
|
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a class="dropdown-item <%= currentPath === '/admin/FAQ' ? 'active' : '' %>" href="/admin/faq">FAQ</a>
|
||||||
class="dropdown-item <%= currentPath === '/admin/FAQ' ? 'active' : '' %>"
|
|
||||||
href="/admin/faq"
|
|
||||||
>FAQ</a
|
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a class="dropdown-item <%= currentPath === '/admin/insurance' ? 'active' : '' %>"
|
||||||
class="dropdown-item <%= currentPath === '/admin/insurance' ? 'active' : '' %>"
|
href="/admin/insurance">Insurance</a>
|
||||||
href="/admin/insurance"
|
|
||||||
>Insurance</a
|
|
||||||
>
|
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item <%= currentPath === '/admin/travel' ? 'active' : '' %>"
|
||||||
|
href="/admin/travel">Travel</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item <%= currentPath === '/admin/terms-conditions' ? 'active' : '' %>"
|
||||||
|
href="/admin/terms-conditions">Terms & Conditions</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/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">
|
||||||
|
<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>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
class="dropdown-item <%= currentPath === '/admin/travel' ? 'active' : '' %>"
|
class="dropdown-item <%= currentPath === '/admin/travel' ? 'active' : '' %>"
|
||||||
|
|||||||
Reference in New Issue
Block a user